fix(checkout): return the customer to the merchant when payment is detected

A merchant that supplies `redirectURL` and `redirectAutomatically` is asking for its
customer back, and the moment to do that is detection rather than final depth. The
payment is already irreversible from the payer's side, and the merchant's own page is
where an order status belongs. Waiting for the confirmation target instead parks the
customer on a payment page for as long as the coin takes.

Only the classic style acted on those fields, and it held its own copy of the rule. The
standard style had no automatic return at all, and its way back appeared only once the
invoice was confirmed. Both styles now render one shared component, so the rule lives in
one place, and the return appears as soon as the payment is seen. The receipt still
waits for confirmation, because that is when there is a settled payment to describe.

Also ignores the tsbuildinfo a typecheck leaves behind.
This commit is contained in:
jwinterm
2026-09-05 10:50:02 -04:00
parent ad6121582e
commit 4fdac75ba3
4 changed files with 80 additions and 23 deletions
+1
View File
@@ -10,3 +10,4 @@ ui/node_modules/
nginx/certs/ nginx/certs/
backups/ backups/
ui/tsconfig.tsbuildinfo
+20 -7
View File
@@ -6,6 +6,7 @@ import InvoicePaymentDetails from "../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh"; import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
import { formatRelativeTime } from "../../../components/relative-time"; import { formatRelativeTime } from "../../../components/relative-time";
import StatusRefreshButton from "../../../components/status-refresh-button"; import StatusRefreshButton from "../../../components/status-refresh-button";
import BtcpayAutoReturn from "../../../components/btcpay-auto-return";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -149,7 +150,12 @@ export default async function BtcpayModalInvoicePage({
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency); const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout = const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic"; isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed"; // The merchant's own order page is where a customer belongs once the payment has been seen, so
// the way back appears at detection. The receipt still waits for the confirmation target, because
// that is the point at which there is a settled payment to write one about.
const shouldShowBtcpayActions =
isBtcpayInvoice &&
(invoice.status === "payment_detected" || invoice.status === "confirmed");
const checkoutContinueAvailable = const checkoutContinueAvailable =
!isBtcpayInvoice && !isBtcpayInvoice &&
invoice.status === "confirmed" && invoice.status === "confirmed" &&
@@ -257,6 +263,11 @@ export default async function BtcpayModalInvoicePage({
) : null} ) : null}
</div> </div>
<BtcpayAutoReturn
status={invoice.status}
redirectUrl={invoice.btcpay_redirect_url ?? null}
redirectAutomatically={invoice.btcpay_redirect_automatically ?? null}
/>
<div className="mt-5"> <div className="mt-5">
<InvoicePaymentDetails <InvoicePaymentDetails
address={invoice.address} address={invoice.address}
@@ -270,12 +281,14 @@ export default async function BtcpayModalInvoicePage({
</div> </div>
{shouldShowBtcpayActions ? ( {shouldShowBtcpayActions ? (
<div className="mt-6 grid gap-3"> <div className="mt-6 grid gap-3">
<a {invoice.status === "confirmed" ? (
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5" <a
href={`/i/${encodeURIComponent(invoiceId)}/receipt`} className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
> href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
View receipt >
</a> View receipt
</a>
) : null}
{invoice.btcpay_redirect_url ? ( {invoice.btcpay_redirect_url ? (
<a <a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream" className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
/**
* Sends the customer back to the merchant once their payment has been seen.
*
* A merchant that supplied `redirectURL` and `redirectAutomatically` on the invoice is asking for
* its customer back, and the moment to do that is detection rather than final depth: the payment is
* irreversible from the payer's side already, and the merchant's own page is where the order status
* belongs. Waiting for the confirmation target instead leaves the customer parked on a payment page
* for as long as the coin takes, which for Wownero is its own unlock depth.
*
* The delay is deliberate. The customer should see the payment acknowledged here before the page
* changes under them, otherwise the redirect reads as the checkout failing.
*
* Rendered by both checkout styles so the rule lives in one place.
*/
type BtcpayAutoReturnProps = {
status: string;
redirectUrl: string | null;
redirectAutomatically: boolean | null;
delayMs?: number;
};
export default function BtcpayAutoReturn({
status,
redirectUrl,
redirectAutomatically,
delayMs = 3000,
}: BtcpayAutoReturnProps) {
useEffect(() => {
if (
(status !== "confirmed" && status !== "payment_detected") ||
!redirectUrl ||
!redirectAutomatically
) {
return;
}
const timer = window.setTimeout(() => {
try {
window.top?.location.assign(redirectUrl);
} catch {
window.location.assign(redirectUrl);
}
}, delayMs);
return () => {
window.clearTimeout(timer);
};
}, [delayMs, redirectAutomatically, redirectUrl, status]);
return null;
}
+6 -16
View File
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { formatXmrAmount } from "../lib/formatting"; import { formatXmrAmount } from "../lib/formatting";
import BtcpayAutoReturn from "./btcpay-auto-return";
type InvoiceStatus = type InvoiceStatus =
| "pending" | "pending"
@@ -129,22 +130,6 @@ export default function BtcpayClassicCheckout({
? `1 WOW = ${formatFiatAmount(quote.rate, quote.fiat_currency)}` ? `1 WOW = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
: null; : null;
useEffect(() => {
if ((status !== "confirmed" && status !== "payment_detected") || !redirectUrl || !redirectAutomatically) {
return;
}
const timer = window.setTimeout(() => {
try {
window.top?.location.assign(redirectUrl);
} catch {
window.location.assign(redirectUrl);
}
}, 3000);
return () => {
window.clearTimeout(timer);
};
}, [redirectAutomatically, redirectUrl, status]);
const handleCopy = async (value: string, field: string) => { const handleCopy = async (value: string, field: string) => {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
@@ -285,6 +270,11 @@ export default function BtcpayClassicCheckout({
return ( return (
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card"> <div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
<BtcpayAutoReturn
status={status}
redirectUrl={redirectUrl}
redirectAutomatically={redirectAutomatically}
/>
<div className="text-center"> <div className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft"> <p className="text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request Payment request