BTCPay: add paid/receipt flow and redirect support

This commit is contained in:
xmrcheckout
2026-01-08 16:43:11 +00:00
parent b53f6dca21
commit 6eb8d381c9
11 changed files with 475 additions and 12 deletions
+19 -2
View File
@@ -201,7 +201,13 @@ def startup():
connection.execute( connection.execute(
text( text(
"ALTER TABLE users " "ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'standard'" "ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'btcpay_classic'"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ALTER COLUMN btcpay_checkout_style SET DEFAULT 'btcpay_classic'"
) )
) )
connection.execute( connection.execute(
@@ -212,10 +218,21 @@ def startup():
) )
connection.execute( connection.execute(
text( text(
"UPDATE users SET btcpay_checkout_style = 'standard' " "UPDATE users SET btcpay_checkout_style = 'btcpay_classic' "
"WHERE btcpay_checkout_style IS NULL" "WHERE btcpay_checkout_style IS NULL"
) )
) )
connection.execute(
text(
"UPDATE users "
"SET btcpay_checkout_style = 'btcpay_classic' "
"WHERE btcpay_checkout_style = 'standard' "
"AND NOT EXISTS ("
" SELECT 1 FROM profile_history ph "
" WHERE ph.user_id = users.id AND ph.field_name = 'btcpay_checkout_style'"
")"
)
)
connection.execute( connection.execute(
text( text(
"UPDATE users SET default_confirmation_target = 2 " "UPDATE users SET default_confirmation_target = 2 "
+1 -1
View File
@@ -128,7 +128,7 @@ class User(Base):
default_confirmation_target = Column(Integer, nullable=False, server_default="2") default_confirmation_target = Column(Integer, nullable=False, server_default="2")
default_qr_logo = Column(String, nullable=False, server_default="monero") default_qr_logo = Column(String, nullable=False, server_default="monero")
default_qr_logo_data_url = Column(String, nullable=True) default_qr_logo_data_url = Column(String, nullable=True)
btcpay_checkout_style = Column(String, nullable=False, server_default="standard") btcpay_checkout_style = Column(String, nullable=False, server_default="btcpay_classic")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+12
View File
@@ -37,12 +37,24 @@ def ensure_invoice_qr_png(
detail="QR storage is not configured", detail="QR storage is not configured",
) )
os.makedirs(storage_dir, exist_ok=True) os.makedirs(storage_dir, exist_ok=True)
try:
os.chmod(storage_dir, 0o755)
except Exception:
pass
filename = f"{invoice.id}.png" filename = f"{invoice.id}.png"
path = os.path.join(storage_dir, filename) path = os.path.join(storage_dir, filename)
if os.path.exists(path): if os.path.exists(path):
try:
os.chmod(path, 0o644)
except Exception:
pass
return path return path
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings) png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
_atomic_write(path, png_bytes) _atomic_write(path, png_bytes)
try:
os.chmod(path, 0o644)
except Exception:
pass
return path return path
+28
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_DOWN from decimal import Decimal, ROUND_DOWN
import csv import csv
import io import io
import json
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -239,6 +240,11 @@ def _public_invoice_status_response(
response = InvoiceStatusResponse.model_validate(invoice) response = InvoiceStatusResponse.model_validate(invoice)
metadata = invoice.metadata_json or {} metadata = invoice.metadata_json or {}
update: dict[str, Any] = {} update: dict[str, Any] = {}
if invoice.total_paid_atomic is not None:
total_paid = (Decimal(invoice.total_paid_atomic) / Decimal("1000000000000")).quantize(
Decimal("0.000000000001"), rounding=ROUND_DOWN
)
update["amount_paid_xmr"] = format_xmr_amount(total_paid)
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
if isinstance(btcpay_data, dict): if isinstance(btcpay_data, dict):
amount = btcpay_data.get("amount") amount = btcpay_data.get("amount")
@@ -250,8 +256,30 @@ def _public_invoice_status_response(
user = db.query(User).filter(User.id == invoice.user_id).first() user = db.query(User).filter(User.id == invoice.user_id).first()
if user and user.btcpay_checkout_style: if user and user.btcpay_checkout_style:
update["btcpay_checkout_style"] = user.btcpay_checkout_style update["btcpay_checkout_style"] = user.btcpay_checkout_style
checkout = btcpay_data.get("checkout")
if isinstance(checkout, dict):
redirect_url = checkout.get("redirectURL")
if isinstance(redirect_url, str) and redirect_url.strip():
update["btcpay_redirect_url"] = redirect_url.strip()
redirect_auto = checkout.get("redirectAutomatically")
if isinstance(redirect_auto, bool):
update["btcpay_redirect_automatically"] = redirect_auto
if isinstance(metadata, dict) and isinstance(metadata.get("quote"), dict): if isinstance(metadata, dict) and isinstance(metadata.get("quote"), dict):
update["quote"] = metadata.get("quote") update["quote"] = metadata.get("quote")
if isinstance(metadata, dict) and isinstance(metadata.get("posData"), str):
try:
pos_data = json.loads(metadata.get("posData", ""))
except (TypeError, ValueError, json.JSONDecodeError):
pos_data = None
if isinstance(pos_data, dict):
woo = pos_data.get("WooCommerce")
if isinstance(woo, dict):
order_id = woo.get("Order ID")
order_number = woo.get("Order Number")
if order_id is not None:
update["btcpay_order_id"] = str(order_id)
if order_number is not None:
update["btcpay_order_number"] = str(order_number)
qr = metadata.get("qr") if isinstance(metadata, dict) else None qr = metadata.get("qr") if isinstance(metadata, dict) else None
if isinstance(qr, dict): if isinstance(qr, dict):
logo = qr.get("logo") logo = qr.get("logo")
+6 -1
View File
@@ -126,6 +126,7 @@ class InvoiceStatusResponse(BaseModel):
address: str address: str
subaddress_index: int | None = None subaddress_index: int | None = None
amount_xmr: Decimal amount_xmr: Decimal
amount_paid_xmr: str | None = None
status: InvoiceStatus status: InvoiceStatus
confirmation_target: int confirmation_target: int
confirmations: int = 0 confirmations: int = 0
@@ -140,6 +141,10 @@ class InvoiceStatusResponse(BaseModel):
btcpay_amount: str | None = None btcpay_amount: str | None = None
btcpay_currency: str | None = None btcpay_currency: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle | None = None btcpay_checkout_style: BtcpayCheckoutStyle | None = None
btcpay_redirect_url: str | None = None
btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None
btcpay_order_number: str | None = None
quote: dict[str, Any] | None = None quote: dict[str, Any] | None = None
qr_url: str | None = None qr_url: str | None = None
@@ -156,7 +161,7 @@ class ProfileResponse(BaseModel):
default_confirmation_target: conint(ge=0, le=10) = 2 default_confirmation_target: conint(ge=0, le=10) = 2
default_qr_logo: QrLogoMode = "monero" default_qr_logo: QrLogoMode = "monero"
default_qr_logo_data_url: str | None = None default_qr_logo_data_url: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle = "standard" btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
created_at: datetime created_at: datetime
+1 -1
View File
@@ -411,7 +411,7 @@ export default async function DashboardPage({
initialStyle={ initialStyle={
profileData?.btcpay_checkout_style === "btcpay_classic" profileData?.btcpay_checkout_style === "btcpay_classic"
? "btcpay_classic" ? "btcpay_classic"
: "standard" : "btcpay_classic"
} }
/> />
</div> </div>
+32 -5
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge"; import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
import BtcpayModalCloseButton from "../../../components/btcpay-modal-close-button";
import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout"; import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout";
import InvoicePaymentDetails from "../../../components/invoice-payment-details"; import InvoicePaymentDetails from "../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh"; import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
@@ -27,6 +26,7 @@ type InvoiceStatusResponse = {
address: string; address: string;
subaddress_index: number | null; subaddress_index: number | null;
amount_xmr: string; amount_xmr: string;
amount_paid_xmr?: string | null;
status: InvoiceStatus; status: InvoiceStatus;
confirmation_target: number; confirmation_target: number;
confirmations: number; confirmations: number;
@@ -37,6 +37,10 @@ type InvoiceStatusResponse = {
btcpay_amount?: string | null; btcpay_amount?: string | null;
btcpay_currency?: string | null; btcpay_currency?: string | null;
btcpay_checkout_style?: "standard" | "btcpay_classic" | null; btcpay_checkout_style?: "standard" | "btcpay_classic" | null;
btcpay_redirect_url?: string | null;
btcpay_redirect_automatically?: boolean | null;
btcpay_order_id?: string | null;
btcpay_order_number?: string | null;
qr_logo?: "monero" | "none" | "custom" | null; qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null; qr_logo_data_url?: string | null;
quote?: { quote?: {
@@ -104,7 +108,6 @@ export default async function BtcpayModalInvoicePage({
</p> </p>
<h1 className="mt-2 font-serif text-2xl">Invoice not found</h1> <h1 className="mt-2 font-serif text-2xl">Invoice not found</h1>
</div> </div>
<BtcpayModalCloseButton />
</div> </div>
<p className="mt-3 text-sm text-ink-soft"> <p className="mt-3 text-sm text-ink-soft">
The invoice id does not match a known invoice. Check the id and try The invoice id does not match a known invoice. Check the id and try
@@ -125,7 +128,6 @@ export default async function BtcpayModalInvoicePage({
</p> </p>
<h1 className="mt-2 font-serif text-2xl">Status unavailable</h1> <h1 className="mt-2 font-serif text-2xl">Status unavailable</h1>
</div> </div>
<BtcpayModalCloseButton />
</div> </div>
<p className="mt-3 text-sm text-ink-soft"> <p className="mt-3 text-sm text-ink-soft">
We could not load this invoice status. Refresh the page or try again We could not load this invoice status. Refresh the page or try again
@@ -146,6 +148,7 @@ 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";
return ( return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink"> <main className="min-h-screen bg-cream px-6 py-6 text-ink">
@@ -161,17 +164,22 @@ export default async function BtcpayModalInvoicePage({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusRefreshButton label="Refresh" className="text-xs" /> <StatusRefreshButton label="Refresh" className="text-xs" />
<BtcpayModalCloseButton />
</div> </div>
</div> </div>
<BtcpayClassicCheckout <BtcpayClassicCheckout
invoiceId={invoiceId}
address={invoice.address} address={invoice.address}
amountXmr={invoice.amount_xmr} amountXmr={invoice.amount_xmr}
amountPaidXmr={invoice.amount_paid_xmr ?? null}
btcpayAmount={invoice.btcpay_amount ?? null} btcpayAmount={invoice.btcpay_amount ?? null}
btcpayCurrency={invoice.btcpay_currency ?? null} btcpayCurrency={invoice.btcpay_currency ?? null}
quote={invoice.quote ?? null} quote={invoice.quote ?? null}
status={invoice.status} status={invoice.status}
confirmationTarget={confirmationTarget} confirmationTarget={confirmationTarget}
redirectUrl={invoice.btcpay_redirect_url ?? null}
redirectAutomatically={invoice.btcpay_redirect_automatically ?? null}
orderId={invoice.btcpay_order_id ?? null}
orderNumber={invoice.btcpay_order_number ?? null}
qrLogoMode={invoice.qr_logo ?? "monero"} qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null} qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/> />
@@ -187,7 +195,6 @@ export default async function BtcpayModalInvoicePage({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusRefreshButton label="Refresh" className="text-xs" /> <StatusRefreshButton label="Refresh" className="text-xs" />
<BtcpayModalCloseButton />
</div> </div>
</div> </div>
@@ -247,6 +254,26 @@ export default async function BtcpayModalInvoicePage({
qrLogoDataUrl={invoice.qr_logo_data_url ?? null} qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/> />
</div> </div>
{shouldShowBtcpayActions ? (
<div className="mt-6 grid gap-3">
<a
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>
{invoice.btcpay_redirect_url ? (
<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"
href={invoice.btcpay_redirect_url}
target="_top"
rel="noreferrer"
>
Return to store
</a>
) : null}
</div>
) : null}
</> </>
)} )}
</main> </main>
+253
View File
@@ -0,0 +1,253 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import BtcpayModalBridge from "../../../../components/btcpay-modal-bridge";
import { formatRelativeTime } from "../../../../components/relative-time";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Receipt",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
amount_xmr: string;
amount_paid_xmr?: string | null;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
created_at: string;
confirmed_at: string | null;
btcpay_amount?: string | null;
btcpay_currency?: string | null;
btcpay_redirect_url?: string | null;
btcpay_order_id?: string | null;
btcpay_order_number?: string | null;
quote?: {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
} | null;
qr_url?: string | null;
};
const formatFiatAmount = (value: string, currency: string) => {
const amount = Number.parseFloat(value);
if (!Number.isFinite(amount)) {
return `${value} ${currency}`;
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
};
const receiptTitle = (redirectUrl: string | null | undefined) => {
if (!redirectUrl) {
return "Receipt";
}
try {
const parsed = new URL(redirectUrl);
return parsed.hostname ? `${parsed.hostname}` : "Receipt";
} catch {
return "Receipt";
}
};
const resolveQrSrc = (invoiceId: string, qrUrl: string | null | undefined) => {
if (!qrUrl) {
return `/qr/${encodeURIComponent(invoiceId)}.png`;
}
if (qrUrl.startsWith("/qr/")) {
return qrUrl;
}
try {
const parsed = new URL(qrUrl);
if (parsed.pathname.startsWith("/qr/")) {
return parsed.pathname;
}
} catch {
// Ignore malformed URLs and fall back to the provided value.
}
return qrUrl;
};
const closeLinkClassName =
"rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white";
export default async function BtcpayReceiptPage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const requestHeaders = await headers();
const forwardedProto = requestHeaders.get("x-forwarded-proto");
const forwardedHost = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{
cache: "no-store",
headers: {
...(forwardedProto ? { "x-forwarded-proto": forwardedProto } : {}),
...(forwardedHost ? { "x-forwarded-host": forwardedHost } : {}),
},
}
);
if (response.status === 404) {
return (
<main className="min-h-screen bg-white px-4 py-10 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="mx-auto w-full max-w-4xl">
<div className="flex justify-end">
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
Close
</a>
</div>
<div className="mt-6 text-center">
<h1 className="text-3xl font-semibold">Invoice not found</h1>
<p className="mt-3 text-sm text-ink-soft">
The invoice id does not match a known invoice. Check the id and try again.
</p>
</div>
</div>
</main>
);
}
if (!response.ok) {
return (
<main className="min-h-screen bg-white px-4 py-10 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="mx-auto w-full max-w-4xl">
<div className="flex justify-end">
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
Close
</a>
</div>
<div className="mt-6 text-center">
<h1 className="text-3xl font-semibold">Status unavailable</h1>
<p className="mt-3 text-sm text-ink-soft">
We could not load this invoice status. Refresh the page or try again later.
</p>
</div>
</div>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const orderId = invoice.btcpay_order_number ?? invoice.btcpay_order_id ?? null;
const receiptTimestamp = invoice.confirmed_at ?? invoice.created_at;
const timestampLabel = new Date(receiptTimestamp).toLocaleString();
const timestampRelative = formatRelativeTime(receiptTimestamp);
const totalFiat =
invoice.btcpay_amount && invoice.btcpay_currency && invoice.btcpay_currency !== "XMR"
? formatFiatAmount(invoice.btcpay_amount, invoice.btcpay_currency)
: invoice.btcpay_amount && invoice.btcpay_currency
? `${invoice.btcpay_amount} ${invoice.btcpay_currency}`
: null;
const exchangeRate =
invoice.quote?.rate && invoice.quote.fiat_currency
? formatFiatAmount(invoice.quote.rate, invoice.quote.fiat_currency)
: null;
const paidXmr = invoice.amount_paid_xmr ?? invoice.amount_xmr;
const qrSrc = resolveQrSrc(invoiceId, invoice.qr_url);
const storeTitle = receiptTitle(invoice.btcpay_redirect_url);
return (
<main className="min-h-screen bg-white px-4 py-10 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status={invoice.status} />
<div className="mx-auto w-full max-w-4xl">
<div className="flex justify-end">
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
Close
</a>
</div>
{invoice.status !== "confirmed" ? (
<div className="mt-6 text-center">
<h1 className="text-4xl font-semibold tracking-tight">{storeTitle}</h1>
{orderId ? <p className="mt-3 text-lg text-ink">Order ID: {orderId}</p> : null}
<p className="mt-1 text-base text-ink-soft" title={timestampRelative ?? undefined}>
{timestampLabel}
</p>
<p className="mt-10 text-sm text-ink-soft">
This receipt is available after the payment is confirmed.
</p>
</div>
) : (
<>
<div className="mt-6 text-center">
<h1 className="text-4xl font-semibold tracking-tight">{storeTitle}</h1>
{orderId ? <p className="mt-3 text-lg text-ink">Order ID: {orderId}</p> : null}
<p className="mt-1 text-base text-ink-soft" title={timestampRelative ?? undefined}>
{timestampLabel}
</p>
</div>
<div className="mt-10 border-y border-stroke">
<dl className="text-base">
<div className="flex items-center justify-between gap-6 py-5">
<dt className="text-ink-soft">Total</dt>
<dd className="text-right font-medium">{totalFiat ?? "-"}</dd>
</div>
<div className="flex items-start justify-between gap-6 border-t border-stroke py-5">
<dt className="pt-0.5 text-ink-soft">Paid</dt>
<dd className="text-right">
<div className="font-medium">{paidXmr} XMR</div>
{totalFiat ? <div className="mt-2 text-ink-soft">{totalFiat}</div> : null}
</dd>
</div>
<div className="flex items-center justify-between gap-6 border-t border-stroke py-5">
<dt className="text-ink-soft">Rate</dt>
<dd className="text-right font-medium">{exchangeRate ?? "-"}</dd>
</div>
<div className="flex items-center gap-6 border-t border-stroke py-5">
<dt className="w-28 flex-none text-ink-soft sm:w-36">Destination</dt>
<dd className="min-w-0 flex-1 truncate font-mono text-xs text-ink">
{invoice.address}
</dd>
</div>
</dl>
</div>
<div className="mt-10 flex justify-center">
{qrSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Payment QR"
src={qrSrc}
className="h-64 w-64 bg-white p-2 sm:h-72 sm:w-72"
/>
) : null}
</div>
<p className="mt-10 text-center text-sm text-ink-soft">
Powered by XMR Checkout
</p>
</>
)}
</div>
</main>
);
}
@@ -63,7 +63,7 @@ export default function BtcpayCheckoutStyleSection({
} }
> >
<option value="standard">Standard XMR Checkout</option> <option value="standard">Standard XMR Checkout</option>
<option value="btcpay_classic">BTCPay classic</option> <option value="btcpay_classic">BTCPay Classic</option>
</select> </select>
</label> </label>
<p className="text-xs text-ink-soft"> <p className="text-xs text-ink-soft">
+121
View File
@@ -22,13 +22,19 @@ type QuotePayload = {
}; };
type BtcpayClassicCheckoutProps = { type BtcpayClassicCheckoutProps = {
invoiceId: string;
address: string; address: string;
amountXmr: string; amountXmr: string;
amountPaidXmr: string | null;
btcpayAmount: string | null; btcpayAmount: string | null;
btcpayCurrency: string | null; btcpayCurrency: string | null;
quote: QuotePayload | null; quote: QuotePayload | null;
status: InvoiceStatus; status: InvoiceStatus;
confirmationTarget: number; confirmationTarget: number;
redirectUrl: string | null;
redirectAutomatically: boolean | null;
orderId: string | null;
orderNumber: string | null;
qrLogoMode?: "monero" | "none" | "custom" | null; qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null; qrLogoDataUrl?: string | null;
}; };
@@ -56,6 +62,15 @@ const formatFiatAmount = (value: string, currency: string) => {
} }
}; };
const formatReturnLabel = (redirectUrl: string) => {
try {
const parsed = new URL(redirectUrl);
return parsed.hostname ? `Return to ${parsed.hostname}` : "Return to store";
} catch {
return "Return to store";
}
};
const statusMessage = ( const statusMessage = (
status: InvoiceStatus, status: InvoiceStatus,
confirmationTarget: number confirmationTarget: number
@@ -76,19 +91,31 @@ const statusMessage = (
}; };
export default function BtcpayClassicCheckout({ export default function BtcpayClassicCheckout({
invoiceId,
address, address,
amountXmr, amountXmr,
amountPaidXmr,
btcpayAmount, btcpayAmount,
btcpayCurrency, btcpayCurrency,
quote, quote,
status, status,
confirmationTarget, confirmationTarget,
redirectUrl,
redirectAutomatically,
orderId,
orderNumber,
qrLogoMode, qrLogoMode,
qrLogoDataUrl, qrLogoDataUrl,
}: BtcpayClassicCheckoutProps) { }: BtcpayClassicCheckoutProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null); const [copiedField, setCopiedField] = useState<string | null>(null);
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]); const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
const formattedPaidAmount = useMemo(() => {
if (!amountPaidXmr) {
return formattedAmount;
}
return formatXmrAmount(amountPaidXmr);
}, [amountPaidXmr, formattedAmount]);
const uri = useMemo( const uri = useMemo(
() => buildMoneroUri(address, formattedAmount), () => buildMoneroUri(address, formattedAmount),
[address, formattedAmount] [address, formattedAmount]
@@ -102,6 +129,22 @@ export default function BtcpayClassicCheckout({
? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}` ? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
: null; : null;
useEffect(() => {
if (status !== "confirmed" || !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);
@@ -162,6 +205,84 @@ export default function BtcpayClassicCheckout({
? "/monero-logo.svg" ? "/monero-logo.svg"
: null; : null;
if (status === "confirmed") {
return (
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-sage/15 text-sage">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-9 w-9"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice paid
</p>
</div>
<dl className="mt-6 grid gap-3 text-sm text-ink">
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Invoice id</dt>
<dd className="max-w-[60%] truncate text-right font-semibold">{invoiceId}</dd>
</div>
{orderNumber || orderId ? (
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Order id</dt>
<dd className="font-semibold">{orderNumber ?? orderId}</dd>
</div>
) : null}
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total price</dt>
<dd className="font-semibold">{formattedAmount} XMR</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total fiat</dt>
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Exchange rate</dt>
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Network cost</dt>
<dd className="font-semibold">Added by wallet</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Amount paid</dt>
<dd className="font-semibold">{formattedPaidAmount} XMR</dd>
</div>
</dl>
<div className="mt-8 grid gap-3">
<a
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>
{redirectUrl ? (
<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"
href={redirectUrl}
target="_top"
rel="noreferrer"
>
{formatReturnLabel(redirectUrl)}
</a>
) : null}
</div>
</div>
);
}
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">
<div className="text-center"> <div className="text-center">
+1 -1
View File
@@ -119,7 +119,7 @@
} }
state.handlers.forEach(function (handler) { state.handlers.forEach(function (handler) {
try { try {
handler(event.data); handler(event);
} catch (error) { } catch (error) {
return; return;
} }