mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
import type { Metadata } from "next";
|
|
|
|
import InvoicePaymentDetails from "../../../../components/invoice-payment-details";
|
|
import InvoiceStatusAutoRefresh from "../../../../components/invoice-status-auto-refresh";
|
|
import InvoiceStatusActions from "../../../../components/invoice-status-actions";
|
|
import InvoiceStatusLookup from "../../../../components/invoice-status-lookup";
|
|
import { formatRelativeTime } from "../../../../components/relative-time";
|
|
import StatusRefreshButton from "../../../../components/status-refresh-button";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const metadata: Metadata = {
|
|
title: "Payment Request",
|
|
};
|
|
|
|
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
|
|
|
type InvoiceStatus =
|
|
| "pending"
|
|
| "payment_detected"
|
|
| "confirmed"
|
|
| "expired"
|
|
| "invalid";
|
|
|
|
type InvoiceStatusResponse = {
|
|
id: string;
|
|
address: string;
|
|
subaddress_index: number | null;
|
|
amount_xmr: string;
|
|
status: InvoiceStatus;
|
|
confirmation_target: number;
|
|
confirmations: number;
|
|
checkout_continue_available?: boolean;
|
|
qr_logo?: "monero" | "none" | "custom" | null;
|
|
qr_logo_data_url?: string | null;
|
|
created_at: string;
|
|
expires_at: string | null;
|
|
detected_at: string | null;
|
|
confirmed_at: string | null;
|
|
};
|
|
type SystemStatusResponse = {
|
|
wallet_rpc: "ok" | "unreachable";
|
|
daemon: "ok" | "unreachable" | "unknown";
|
|
daemon_height: number | null;
|
|
invoice_reconcile_interval_seconds: number;
|
|
last_reconcile_started_at: string | null;
|
|
last_reconcile_completed_at: string | null;
|
|
last_reconcile_error: string | null;
|
|
};
|
|
|
|
const formatStatus = (status: InvoiceStatus) => {
|
|
if (status === "payment_detected") {
|
|
return "Payment detected";
|
|
}
|
|
if (status === "pending") {
|
|
return "Awaiting funds";
|
|
}
|
|
if (status === "confirmed") {
|
|
return "Confirmed";
|
|
}
|
|
if (status === "invalid") {
|
|
return "Invalid";
|
|
}
|
|
return "Expired";
|
|
};
|
|
|
|
const statusDescription = (
|
|
status: InvoiceStatus,
|
|
confirmationTarget: number,
|
|
confirmations: number
|
|
) => {
|
|
if (status === "pending") {
|
|
return "Waiting for a payment to be detected on-chain.";
|
|
}
|
|
if (status === "payment_detected") {
|
|
return `Payment detected. ${confirmations}/${confirmationTarget} confirmations.`;
|
|
}
|
|
if (status === "confirmed") {
|
|
return `Payment confirmed on-chain at ${confirmationTarget} confirmations.`;
|
|
}
|
|
if (status === "invalid") {
|
|
return "Invoice marked invalid. Do not send payment.";
|
|
}
|
|
return "Invoice expired before detection. Create a new invoice if needed.";
|
|
};
|
|
|
|
const formatTimestamp = (value: string | null) => {
|
|
if (!value) {
|
|
return { label: "-", relative: null };
|
|
}
|
|
return {
|
|
label: new Date(value).toLocaleString(),
|
|
relative: formatRelativeTime(value),
|
|
};
|
|
};
|
|
|
|
const statusPillStyles: Record<InvoiceStatus, string> = {
|
|
pending: "bg-amber-100 text-amber-900",
|
|
payment_detected: "bg-emerald-100 text-emerald-900",
|
|
confirmed: "bg-ink/20 text-ink",
|
|
expired: "bg-red-100 text-red-700",
|
|
invalid: "bg-clay/15 text-clay",
|
|
};
|
|
|
|
export default async function InvoiceStatusDetailPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ invoiceId: string }>;
|
|
}) {
|
|
const { invoiceId } = await params;
|
|
const [response, systemStatusResponse] = await Promise.all([
|
|
fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
|
|
cache: "no-store",
|
|
}),
|
|
fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
|
|
]);
|
|
|
|
if (response.status === 404) {
|
|
return (
|
|
<main className="px-[6vw] pb-20 pt-10 text-ink">
|
|
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
|
|
<div className="grid gap-4">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
|
|
Invoice status
|
|
</p>
|
|
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
|
|
Invoice not found.
|
|
</h1>
|
|
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
|
|
The invoice id does not match a known invoice. Check the id and
|
|
try again.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
|
|
Need another lookup?
|
|
</p>
|
|
<div className="mt-4">
|
|
<InvoiceStatusLookup initialValue={invoiceId} compact />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return (
|
|
<main className="px-[6vw] pb-20 pt-10 text-ink">
|
|
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
|
|
<div className="grid gap-4">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
|
|
Invoice status
|
|
</p>
|
|
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
|
|
Status unavailable.
|
|
</h1>
|
|
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
|
|
We could not load this invoice status. Refresh the page or try
|
|
again later.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
|
|
<InvoiceStatusLookup initialValue={invoiceId} compact />
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const invoice = (await response.json()) as InvoiceStatusResponse;
|
|
const systemStatus = systemStatusResponse.ok
|
|
? ((await systemStatusResponse.json()) as SystemStatusResponse)
|
|
: null;
|
|
const statusLabel = formatStatus(invoice.status);
|
|
const confirmations = Math.max(0, invoice.confirmations ?? 0);
|
|
const confirmationTarget = Math.max(0, invoice.confirmation_target);
|
|
const isInvalid = invoice.status === "invalid";
|
|
const hasDetectedPayment =
|
|
invoice.status === "payment_detected" || invoice.status === "confirmed";
|
|
const checkoutContinueAvailable =
|
|
invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
|
|
const timelineItems: {
|
|
label: string;
|
|
timestamp: ReturnType<typeof formatTimestamp>;
|
|
state: "complete";
|
|
}[] = [];
|
|
|
|
timelineItems.push({
|
|
label: "Invoice created",
|
|
timestamp: formatTimestamp(invoice.created_at),
|
|
state: "complete",
|
|
});
|
|
|
|
if (invoice.detected_at) {
|
|
timelineItems.push({
|
|
label: "Payment detected",
|
|
timestamp: formatTimestamp(invoice.detected_at),
|
|
state: "complete",
|
|
});
|
|
}
|
|
|
|
if (invoice.confirmed_at) {
|
|
timelineItems.push({
|
|
label: "Payment confirmed",
|
|
timestamp: formatTimestamp(invoice.confirmed_at),
|
|
state: "complete",
|
|
});
|
|
}
|
|
|
|
if (invoice.status === "expired") {
|
|
timelineItems.push({
|
|
label: "Invoice expired",
|
|
timestamp: formatTimestamp(invoice.expires_at),
|
|
state: "complete",
|
|
});
|
|
}
|
|
|
|
const showConfirmedStamp = invoice.status === "confirmed";
|
|
const createdTimestamp = formatTimestamp(invoice.created_at);
|
|
const expiresTimestamp = formatTimestamp(invoice.expires_at);
|
|
const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
|
|
|
|
return (
|
|
<main className="px-[6vw] pb-20 pt-10 text-ink">
|
|
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
|
|
<InvoiceStatusAutoRefresh intervalMs={30000} />
|
|
<div className="grid gap-4">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
|
|
Payment request
|
|
</p>
|
|
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
|
|
Payment request
|
|
</h1>
|
|
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
|
|
{isInvalid
|
|
? "This invoice is marked invalid. Do not pay."
|
|
: "Send the exact amount shown below to pay this invoice."}
|
|
</p>
|
|
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
|
|
{statusDescription(invoice.status, confirmationTarget, confirmations)}
|
|
</p>
|
|
<details className="rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm text-ink-soft shadow-soft">
|
|
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-ink">
|
|
Details
|
|
</summary>
|
|
<div className="mt-3 grid gap-2">
|
|
<p className="font-mono text-xs text-ink">Invoice ID: {invoiceId}</p>
|
|
<p className="font-mono text-xs text-ink">
|
|
Current block height: {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
|
|
</p>
|
|
<p>
|
|
This status page is public. Anyone with the link can view the current
|
|
invoice state.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
<div className="relative overflow-hidden rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
|
|
{showConfirmedStamp ? (
|
|
<div
|
|
className="pointer-events-none absolute left-1/2 top-4 z-10 grid h-20 w-20 -translate-x-1/2 -rotate-[12deg] place-items-center rounded-full border-2 border-emerald-700 bg-cream/80 text-center text-[0.55rem] font-bold uppercase tracking-[0.2em] text-emerald-800 shadow-[0_10px_20px_rgba(16,18,23,0.18)]"
|
|
aria-hidden="true"
|
|
>
|
|
<span className="leading-tight">Payment confirmed</span>
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
|
|
Status
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
<StatusRefreshButton label="Refresh" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 rounded-xl border border-stroke bg-white/70 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Monero node
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 text-[0.7rem] font-semibold uppercase tracking-[0.12em]">
|
|
<span
|
|
className={`rounded-full px-3 py-1 ${
|
|
systemStatus?.wallet_rpc === "ok"
|
|
? "bg-emerald-100 text-emerald-900"
|
|
: "bg-red-100 text-red-700"
|
|
}`}
|
|
>
|
|
Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
|
|
</span>
|
|
<span
|
|
className={`rounded-full px-3 py-1 ${
|
|
systemStatus?.daemon === "ok"
|
|
? "bg-emerald-100 text-emerald-900"
|
|
: systemStatus?.daemon === "unknown"
|
|
? "bg-amber-100 text-amber-900"
|
|
: "bg-red-100 text-red-700"
|
|
}`}
|
|
>
|
|
Daemon{" "}
|
|
{systemStatus?.daemon === "ok"
|
|
? "connected"
|
|
: systemStatus?.daemon === "unknown"
|
|
? "not configured"
|
|
: "down"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Current block height
|
|
</p>
|
|
<p className="mt-1 text-sm font-semibold">
|
|
{systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Detection poll
|
|
</p>
|
|
<p className="mt-1 text-sm font-semibold">
|
|
{systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Last successful scan
|
|
</p>
|
|
<p
|
|
className="mt-1 text-sm font-semibold"
|
|
title={lastReconcileCompleted.relative ?? undefined}
|
|
>
|
|
{lastReconcileCompleted.label}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{systemStatus?.last_reconcile_error ? (
|
|
<p className="mt-3 rounded-xl bg-red-100 px-3 py-2 text-sm text-red-700">
|
|
Reconciler error: {systemStatus.last_reconcile_error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-6 grid gap-4 sm:grid-cols-3">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Confirmations
|
|
</p>
|
|
<p className="mt-1 text-lg font-semibold">
|
|
{confirmations}/{confirmationTarget}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Created
|
|
</p>
|
|
<p
|
|
className="mt-1 text-sm font-semibold"
|
|
title={createdTimestamp.relative ?? undefined}
|
|
>
|
|
{createdTimestamp.label}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Expires
|
|
</p>
|
|
<p
|
|
className="mt-1 text-sm font-semibold"
|
|
title={expiresTimestamp.relative ?? undefined}
|
|
>
|
|
{expiresTimestamp.label}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<InvoicePaymentDetails
|
|
address={invoice.address}
|
|
amount={invoice.amount_xmr}
|
|
hasDetectedPayment={hasDetectedPayment}
|
|
status={invoice.status}
|
|
confirmationTarget={confirmationTarget}
|
|
qrLogoMode={invoice.qr_logo ?? "monero"}
|
|
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
|
|
/>
|
|
|
|
<section className="mt-8 rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
|
|
<h2 className="font-serif text-2xl">Timeline</h2>
|
|
<ol className="mt-5 grid gap-3">
|
|
{[...timelineItems].reverse().map((item) => (
|
|
<li
|
|
className="flex items-center justify-between gap-6 rounded-xl border border-ink/20 bg-white/70 px-4 py-3"
|
|
key={item.label}
|
|
>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
{item.label}
|
|
</p>
|
|
<p
|
|
className="mt-1 text-sm font-semibold"
|
|
title={item.timestamp.relative ?? undefined}
|
|
>
|
|
{item.timestamp.label}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</section>
|
|
|
|
<section className="mt-6 grid gap-4">
|
|
{checkoutContinueAvailable ? (
|
|
<a
|
|
className="inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
|
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
|
|
rel="noreferrer"
|
|
>
|
|
Continue to merchant
|
|
</a>
|
|
) : null}
|
|
<InvoiceStatusActions />
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|