This commit is contained in:
xmrcheckout
2026-01-04 17:57:07 +01:00
parent 3465c2054b
commit c92dfd2ef1
62 changed files with 15945 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
type ApiKeyCardProps = {
apiKey: string;
};
export default function ApiKeyCard({ apiKey }: ApiKeyCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Your API key
</p>
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
{apiKey}
</code>
</div>
<div className="flex justify-end">
<button
className="inline-flex 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"
type="button"
onClick={handleCopy}
>
{copied ? "Copied" : "Copy API key"}
</button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useFormState } from "react-dom";
import { resetApiKeyAction, type ApiKeyState } from "../app/(app)/dashboard/actions";
import ApiKeyCard from "./api-key-card";
const initialState = (apiKey: string): ApiKeyState => ({
apiKey,
error: null,
});
type ApiKeySectionProps = {
apiKey: string;
};
export default function ApiKeySection({ apiKey }: ApiKeySectionProps) {
const [state, formAction] = useFormState(resetApiKeyAction, initialState(apiKey));
return (
<div className="grid gap-4">
<ApiKeyCard apiKey={state.apiKey} />
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<form action={formAction} className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
>
Reset API key
</button>
</form>
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { useFormState } from "react-dom";
import {
updateBtcpayCheckoutStyleAction,
type BtcpayCheckoutPreferenceState,
} from "../app/(app)/dashboard/actions";
type BtcpayCheckoutStyle = "standard" | "btcpay_classic";
type BtcpayCheckoutStyleSectionProps = {
initialStyle: BtcpayCheckoutStyle;
};
const initialState = (
style: BtcpayCheckoutStyle
): BtcpayCheckoutPreferenceState => ({
style,
error: null,
success: null,
});
export default function BtcpayCheckoutStyleSection({
initialStyle,
}: BtcpayCheckoutStyleSectionProps) {
const [state, formAction] = useFormState(
updateBtcpayCheckoutStyleAction,
initialState(initialStyle)
);
const [style, setStyle] = useState<BtcpayCheckoutStyle>(state.style);
useEffect(() => {
setStyle(state.style);
}, [state.style]);
return (
<form action={formAction} className="grid gap-4">
<div>
<h3 className="font-serif text-2xl">BTCPay checkout layout</h3>
<p className="mt-2 text-sm text-ink-soft">
Choose the hosted invoice layout shown to payers when you use the BTCPay
compatibility endpoints.
</p>
</div>
<label className="grid gap-2 text-sm font-semibold text-ink">
Layout
<select
name="btcpay_checkout_style"
className="rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink"
value={style}
onChange={(event) =>
setStyle(event.target.value as BtcpayCheckoutStyle)
}
>
<option value="standard">Standard XMR Checkout</option>
<option value="btcpay_classic">BTCPay classic</option>
</select>
</label>
<p className="text-xs text-ink-soft">
The classic layout mirrors the BTCPay checkout screen for drop-in migration.
</p>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-ink px-5 py-2.5 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
type="submit"
>
Save preference
</button>
</div>
</form>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { formatXmrAmount } from "../lib/formatting";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type QuotePayload = {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
};
type BtcpayClassicCheckoutProps = {
address: string;
amountXmr: string;
btcpayAmount: string | null;
btcpayCurrency: string | null;
quote: QuotePayload | null;
status: InvoiceStatus;
confirmationTarget: number;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
const buildMoneroUri = (address: string, amount: string) => {
const params = new URLSearchParams();
params.set("tx_amount", amount);
return `monero:${address}?${params.toString()}`;
};
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 statusMessage = (
status: InvoiceStatus,
confirmationTarget: number
): string => {
if (status === "invalid") {
return "Invoice marked invalid. Do not send payment.";
}
if (status === "expired") {
return "Invoice expired. Do not send payment.";
}
if (status === "confirmed") {
return `Payment confirmed at ${confirmationTarget} confirmations.`;
}
if (status === "payment_detected") {
return "Payment detected. Awaiting confirmations.";
}
return "Awaiting funds.";
};
export default function BtcpayClassicCheckout({
address,
amountXmr,
btcpayAmount,
btcpayCurrency,
quote,
status,
confirmationTarget,
qrLogoMode,
qrLogoDataUrl,
}: BtcpayClassicCheckoutProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
);
const totalFiat =
btcpayAmount && btcpayCurrency && btcpayCurrency !== "XMR"
? formatFiatAmount(btcpayAmount, btcpayCurrency)
: null;
const exchangeRate =
quote?.rate && quote.fiat_currency
? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
: null;
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => {
setCopiedField((current) => (current === field ? null : current));
}, 1500);
} catch {
setCopiedField(null);
}
};
useEffect(() => {
let active = true;
const size = 260;
const canvas = document.createElement("canvas");
QRCode.toCanvas(canvas, uri, {
margin: 1,
width: size,
errorCorrectionLevel: "H",
})
.then(() => {
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const mode = qrLogoMode ?? "monero";
const wantsLogo = mode !== "none";
if (wantsLogo) {
const blankSize = Math.round(size * 0.28);
const blankOffset = Math.round((size - blankSize) / 2);
context.fillStyle = "#ffffff";
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
}
return canvas.toDataURL();
})
.then((url) => {
if (active) {
setQrDataUrl(url ?? null);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrLogoMode]);
const isActionDisabled = status === "expired" || status === "invalid";
const resolvedQrLogoMode = qrLogoMode ?? "monero";
const resolvedLogoSrc =
resolvedQrLogoMode === "custom"
? qrLogoDataUrl
: resolvedQrLogoMode === "monero"
? "/monero-logo.svg"
: null;
return (
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request
</p>
<p className="mt-3 text-[1.9rem] font-semibold text-ink">
{formattedAmount} XMR
</p>
<p className="mt-2 text-sm text-sage">{statusMessage(status, confirmationTarget)}</p>
</div>
<details className="group mt-5 rounded-2xl border border-stroke bg-cream/70 px-5 py-4">
<summary className="flex cursor-pointer items-center justify-center gap-2 text-sm font-semibold text-sage [&::-webkit-details-marker]:hidden">
<span className="group-open:hidden">View details</span>
<span className="hidden group-open:inline">Hide details</span>
</summary>
<dl className="mt-4 grid gap-3 text-sm text-ink">
<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 due</dt>
<dd className="font-semibold">{formattedAmount} XMR</dd>
</div>
</dl>
</details>
<div className="mt-6 flex items-center justify-center">
{qrDataUrl ? (
<div className="relative rounded-2xl border border-ink/10 bg-white p-3 shadow-soft">
<Image
className="h-[260px] w-[260px]"
src={qrDataUrl}
alt="Payment request QR"
width={260}
height={260}
unoptimized
/>
{resolvedLogoSrc ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-[0_8px_16px_rgba(16,18,23,0.18)]">
<Image
src={resolvedLogoSrc}
alt="QR logo"
width={38}
height={38}
unoptimized={resolvedQrLogoMode === "custom"}
/>
</span>
</div>
) : null}
</div>
) : null}
</div>
<div className="mt-6">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Address
</p>
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(address, "address")}
>
{copiedField === "address" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink">
{address}
</p>
</div>
<a
className={`mt-6 inline-flex w-full items-center justify-center rounded-full px-6 py-3 text-sm font-semibold text-cream transition ${
isActionDisabled
? "cursor-not-allowed bg-ink/30"
: "bg-sage shadow-[0_14px_22px_rgba(93,122,106,0.25)] hover:-translate-y-0.5"
}`}
href={isActionDisabled ? undefined : uri}
aria-disabled={isActionDisabled}
>
Pay in wallet
</a>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type BtcpayModalBridgeProps = {
invoiceId: string;
status: InvoiceStatus;
};
const statusMap: Record<InvoiceStatus, string> = {
pending: "New",
payment_detected: "Processing",
confirmed: "Settled",
expired: "Expired",
invalid: "Invalid",
};
const postToParent = (payload: unknown) => {
if (typeof window === "undefined" || !window.parent) {
return;
}
window.parent.postMessage(payload, "*");
};
export default function BtcpayModalBridge({
invoiceId,
status,
}: BtcpayModalBridgeProps) {
useEffect(() => {
postToParent("loaded");
}, []);
useEffect(() => {
postToParent({
invoiceId,
status: statusMap[status] ?? "new",
});
}, [invoiceId, status]);
return null;
}
@@ -0,0 +1,20 @@
"use client";
export default function BtcpayModalCloseButton() {
const handleClick = () => {
if (typeof window === "undefined" || !window.parent) {
return;
}
window.parent.postMessage("close", "*");
};
return (
<button
className="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"
type="button"
onClick={handleClick}
>
Close
</button>
);
}
+387
View File
@@ -0,0 +1,387 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import QRCode from "qrcode";
import {
createInvoiceAction,
type InvoiceState,
} from "../app/(app)/dashboard/actions";
import { formatUsdAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
const initialState: InvoiceState = {
error: null,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
const buildMoneroUri = (state: InvoiceState): string | null => {
if (!state.address || !state.amount) {
return null;
}
const params = new URLSearchParams();
params.set("tx_amount", state.amount);
if (state.recipientName) {
params.set("recipient_name", state.recipientName);
}
if (state.description) {
params.set("tx_description", state.description);
}
return `monero:${state.address}?${params.toString()}`;
};
export default function CreateInvoiceCard() {
const [state, formAction] = useFormState(createInvoiceAction, initialState);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrColor, setQrColor] = useState("#1b1b1a");
const [qrLogo, setQrLogo] = useState<string | null>(null);
const [origin, setOrigin] = useState<string | null>(null);
const [amountInput, setAmountInput] = useState("");
const { rate: usdRate } = useXmrUsdRate();
const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft";
const inputClass =
"w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10";
const secondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5";
useEffect(() => {
setOrigin(window.location.origin);
}, []);
const publicInvoiceUrl = useMemo(() => {
if (!state.invoiceId) {
return null;
}
if (origin) {
return new URL(`/invoice/${state.invoiceId}`, origin).toString();
}
return `/invoice/${state.invoiceId}`;
}, [origin, state.invoiceId]);
const uri = useMemo(() => buildMoneroUri(state), [state]);
const amountValue = useMemo(() => {
const raw = amountInput || state.amount || "";
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput, state.amount]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
useEffect(() => {
let active = true;
if (!uri) {
setQrDataUrl(null);
return undefined;
}
QRCode.toDataURL(uri, {
margin: 1,
width: 220,
errorCorrectionLevel: "H",
color: {
dark: qrColor,
light: "#ffffff",
},
})
.then((url) => {
if (active) {
setQrDataUrl(url);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrColor]);
const handleLogoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setQrLogo(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setQrLogo(result);
}
};
reader.readAsDataURL(file);
};
return (
<div className="rounded-2xl border border-stroke bg-white/80 p-6 shadow-card backdrop-blur">
<h3 className="font-serif text-xl">Create test invoice</h3>
<p className="mt-2 text-ink-soft">
Generate an invoice address and draft invoice for validation.
</p>
<form className="mt-6 grid gap-4" action={formAction}>
<div className="grid gap-2">
<label className={labelClass} htmlFor="payment_address">
Primary address
</label>
<input
className={inputClass}
id="payment_address"
name="payment_address"
type="text"
placeholder="Enter your primary address"
required
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="amount_xmr">
Amount (XMR)
</label>
<input
className={inputClass}
id="amount_xmr"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
{usdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="confirmation_target">
Confirmation target
</label>
<input
className={inputClass}
id="confirmation_target"
name="confirmation_target"
type="number"
min="0"
max="10"
step="1"
defaultValue={10}
required
/>
<p className="text-sm text-ink-soft">Expiry defaults to 24 hours.</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="expires_date">
Expiry date
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
<input
className={inputClass}
id="expires_date"
name="expires_date"
type="date"
/>
<input
className={inputClass}
id="expires_time"
name="expires_time"
type="time"
/>
</div>
<p className="text-sm text-ink-soft">
Leave blank to keep the default 24-hour expiry.
</p>
</div>
<div className="flex items-center gap-3 rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm font-semibold text-ink">
<label className="flex items-center gap-3" htmlFor="instant_confirmation">
<input
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
id="instant_confirmation"
name="instant_confirmation"
type="checkbox"
value="1"
/>
Instant confirmation (0 confirmations)
</label>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="recipient_name">
Recipient name
</label>
<input
className={inputClass}
id="recipient_name"
name="recipient_name"
type="text"
placeholder="Your store"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="description">
Description
</label>
<input
className={inputClass}
id="description"
name="description"
type="text"
placeholder="Order 1042"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_color">
QR accent color
</label>
<input
className="h-12 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2"
id="qr_color"
name="qr_color"
type="color"
value={qrColor}
onChange={(event) => setQrColor(event.target.value)}
aria-label="QR accent color"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_logo">
Logo overlay
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="qr_logo"
name="qr_logo"
type="file"
accept="image/*"
onChange={handleLogoChange}
/>
<p className="text-sm text-ink-soft">Logo overlays the QR center for branding.</p>
{qrLogo ? (
<button
className={secondaryButton}
type="button"
onClick={() => setQrLogo(null)}
>
Remove logo
</button>
) : null}
</div>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.invoiceId ? (
<div className="grid gap-3 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice created
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.invoiceId}
</code>
{publicInvoiceUrl ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Status link
</p>
<Link
className="break-all text-sm font-semibold text-ink underline"
href={publicInvoiceUrl}
target="_blank"
rel="noreferrer"
>
{publicInvoiceUrl}
</Link>
</>
) : null}
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Address
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.address}
</code>
{state.subaddressIndex !== null ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress index
</p>
<code className="rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.subaddressIndex}
</code>
</>
) : null}
{state.warnings && state.warnings.length > 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
{state.warnings.map((warning) => (
<p key={warning}>{warning}</p>
))}
</div>
) : null}
{uri ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{uri}
</code>
</>
) : null}
{qrDataUrl ? (
<div className="relative mx-auto mt-2 h-[220px] w-[220px] rounded-2xl border border-stroke bg-white p-2">
<Image
className="h-full w-full rounded-xl border border-ink/10 bg-white"
src={qrDataUrl}
alt="Payment request QR"
width={220}
height={220}
unoptimized
/>
{qrLogo ? (
<Image
className="absolute left-1/2 top-1/2 h-[54px] w-[54px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-white bg-white object-cover"
src={qrLogo}
alt="Logo overlay"
width={54}
height={54}
unoptimized
/>
) : null}
</div>
) : null}
</div>
) : null}
<div className="flex justify-end">
<button className={secondaryButton} type="submit">
Create invoice
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,85 @@
"use client";
import { useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateDefaultConfirmationTargetAction,
type DefaultConfirmationTargetState,
} from "../app/(app)/dashboard/actions";
const buildInitialState = (value: number): DefaultConfirmationTargetState => ({
value,
error: null,
success: null,
});
export default function DefaultConfirmationTargetSection({
initialValue,
}: {
initialValue: number;
}) {
const router = useRouter();
const [state, formAction] = useFormState(
updateDefaultConfirmationTargetAction,
buildInitialState(initialValue)
);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
return (
<div className="grid gap-4">
<div>
<h2 className="font-serif text-2xl">Default confirmations</h2>
<p className="mt-2 text-sm text-ink-soft">
Used as the default confirmation target for new invoices.
</p>
</div>
<form className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]" action={formAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_confirmation_target"
>
Confirmation target
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_confirmation_target"
name="default_confirmation_target"
type="number"
min={0}
max={10}
step={1}
defaultValue={state.value}
required
/>
</div>
<div className="flex items-end justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save
</button>
</div>
</form>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateDefaultQrLogoAction,
type DefaultQrLogoState,
} from "../app/(app)/dashboard/actions";
const buildInitialState = (logo: "monero" | "none" | "custom", logoDataUrl: string | null) => ({
logo,
logoDataUrl,
error: null,
success: null,
});
export default function DefaultQrLogoSection({
initialLogo,
initialLogoDataUrl,
}: {
initialLogo: "monero" | "none" | "custom";
initialLogoDataUrl: string | null;
}) {
const router = useRouter();
const [logo, setLogo] = useState<"monero" | "none" | "custom">(initialLogo);
const [logoDataUrl, setLogoDataUrl] = useState<string | null>(initialLogoDataUrl);
const [state, formAction] = useFormState(
updateDefaultQrLogoAction,
buildInitialState(initialLogo, initialLogoDataUrl)
);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
useEffect(() => {
setLogo(initialLogo);
setLogoDataUrl(initialLogoDataUrl);
}, [initialLogo, initialLogoDataUrl]);
const previewSrc = useMemo(() => {
if (logo === "monero") {
return "/monero-logo.svg";
}
if (logo === "custom") {
return logoDataUrl;
}
return null;
}, [logo, logoDataUrl]);
const handleLogoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setLogoDataUrl(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setLogoDataUrl(result);
}
};
reader.readAsDataURL(file);
};
return (
<div className="grid gap-4">
<div>
<h2 className="font-serif text-2xl">Default QR logo</h2>
<p className="mt-2 text-sm text-ink-soft">
Applies to new invoices unless overridden at creation time.
</p>
</div>
<form className="grid gap-4" action={formAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_qr_logo"
>
Logo mode
</label>
<select
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_qr_logo"
name="default_qr_logo"
value={logo}
onChange={(event) => {
const value = event.target.value;
if (value === "none" || value === "custom" || value === "monero") {
setLogo(value);
}
}}
>
<option value="monero">Monero logo</option>
<option value="none">No logo</option>
<option value="custom">Custom image</option>
</select>
</div>
{logo === "custom" ? (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_qr_logo_file"
>
Custom logo image
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="default_qr_logo_file"
type="file"
accept="image/*"
onChange={handleLogoFileChange}
/>
<p className="text-sm text-ink-soft">
Stored as a data URL for use on public invoice pages. Keep it small.
</p>
{logoDataUrl ? (
<button
className="inline-flex w-fit items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="button"
onClick={() => setLogoDataUrl(null)}
>
Remove image
</button>
) : null}
</div>
) : null}
<input
type="hidden"
name="default_qr_logo_data_url"
value={logo === "custom" ? logoDataUrl ?? "" : ""}
/>
{previewSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
<Image
src={previewSrc}
alt="QR logo preview"
width={32}
height={32}
unoptimized={logo === "custom"}
/>
</span>
<p className="text-sm text-ink-soft">Preview (used in the QR center).</p>
</div>
) : null}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save
</button>
</div>
</form>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFormState } from "react-dom";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
createDonationAction,
type DonationState,
} from "../app/(marketing)/donate/actions";
import { formatUsdAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
const donateParam = "donate";
const initialState: DonationState = {
error: null,
invoiceId: null,
};
export default function DonateModal() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
const isOpen = searchParams.get(donateParam) === "1";
const [state, formAction] = useFormState(createDonationAction, initialState);
const formRef = useRef<HTMLFormElement | null>(null);
const [amountInput, setAmountInput] = useState("");
const { rate: usdRate } = useXmrUsdRate();
const openModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(donateParam, "1");
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
const closeModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete(donateParam);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
useEffect(() => {
if (!state.invoiceId) {
return;
}
closeModal();
router.push(`/donate/${state.invoiceId}`);
}, [closeModal, router, state.invoiceId]);
useEffect(() => {
if (!isOpen) {
return;
}
formRef.current?.reset();
setAmountInput("");
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen]);
const amountValue = useMemo(() => {
const parsed = Number.parseFloat(amountInput);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
if (!donationsEnabled) {
return null;
}
return (
<div className="flex items-center">
<button
className="rounded-full border border-stroke bg-white/40 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-clay transition hover:text-ink"
type="button"
onClick={openModal}
>
Donate
</button>
{isOpen ? (
<div
className="fixed inset-0 z-30 grid place-items-center"
role="dialog"
aria-modal="true"
aria-labelledby="donate-title"
>
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
<div
className="relative w-[min(420px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Support xmrcheckout.com
</p>
<h3 id="donate-title" className="mt-2 font-serif text-xl">
Send a quick donation.
</h3>
</div>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
aria-label="Close donation modal"
>
Close
</button>
</div>
<p className="mt-3 text-ink-soft">
Choose an amount and we will open a read-only invoice page.
</p>
<form className="mt-4 grid gap-4" action={formAction} ref={formRef}>
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="donation_amount"
>
Amount (XMR)
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="donation_amount"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
<div className="min-h-[2.75rem]">
{usdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-3">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
>
Cancel
</button>
<button
className="inline-flex 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"
type="submit"
>
Donate
</button>
</div>
</form>
<p className="mt-4 text-sm text-ink-soft">
Payments go directly to the founder wallet. This checkout never
moves funds.
</p>
</div>
</div>
) : null}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function DonationStatusActions() {
return (
<div className="flex flex-wrap gap-3">
<Link
className="inline-flex 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="/"
>
Back to home
</Link>
</div>
);
}
+935
View File
@@ -0,0 +1,935 @@
"use client";
import Link from "next/link";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import { formatRelativeTime } from "./relative-time";
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
import {
createInvoiceAction,
archiveInvoiceAction,
type ArchiveInvoiceState,
type InvoiceState,
} from "../app/(app)/dashboard/actions";
type InvoiceItem = {
id: string;
address: string;
subaddress_index?: number | null;
amount_xmr: string;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmation_target: number;
paid_after_expiry?: boolean;
paid_after_expiry_at?: string | null;
metadata?: Record<string, string> | null;
created_at: string;
archived_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
expires_at: string | null;
};
type InvoicePanelProps = {
activeInvoices: InvoiceItem[];
includeArchived: boolean;
searchQuery: string;
sort: string;
order: string;
defaultConfirmationTarget: number;
};
const initialState: InvoiceState = {
error: null,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
const initialArchiveState: ArchiveInvoiceState = {
error: null,
success: null,
archivedId: null,
};
const formatStatus = (status: InvoiceItem["status"]) => {
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 formatTimestamp = (value: string | null) => {
if (!value) {
return "-";
}
return new Date(value).toLocaleString();
};
export default function InvoicePanel({
activeInvoices,
includeArchived,
searchQuery,
sort,
order,
defaultConfirmationTarget,
}: InvoicePanelProps) {
const router = useRouter();
const [state, formAction] = useFormState(createInvoiceAction, initialState);
const [archiveState, archiveAction] = useFormState(
archiveInvoiceAction,
initialArchiveState
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [expandedInvoiceId, setExpandedInvoiceId] = useState<string | null>(null);
const [createStep, setCreateStep] = useState<1 | 2 | 3>(1);
const [formKey, setFormKey] = useState(0);
const [amount, setAmount] = useState("");
const [confirmationTarget, setConfirmationTarget] = useState(
String(defaultConfirmationTarget ?? 10)
);
const [expiresDate, setExpiresDate] = useState("");
const [expiresTime, setExpiresTime] = useState("");
const [recipientName, setRecipientName] = useState("");
const [description, setDescription] = useState("");
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState(searchQuery ?? "");
const [qrLogoMode, setQrLogoMode] = useState<
"account_default" | "monero" | "none" | "custom"
>("account_default");
const [qrLogoDataUrl, setQrLogoDataUrl] = useState<string | null>(null);
const [createInvoiceOrigin, setCreateInvoiceOrigin] = useState<string | null>(
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/+$/, "") ?? null
);
const { rate: usdRate } = useXmrUsdRate();
const activeList = activeInvoices;
const archivedToggleHref = includeArchived
? "/dashboard?tab=invoices"
: "/dashboard?tab=invoices&archived=1";
useEffect(() => {
setSearchInput(searchQuery ?? "");
}, [searchQuery]);
const buildInvoicesHref = (next: {
q?: string;
sort?: string;
order?: string;
includeArchived?: boolean;
}) => {
const params = new URLSearchParams();
params.set("tab", "invoices");
const nextArchived = next.includeArchived ?? includeArchived;
if (nextArchived) {
params.set("archived", "1");
}
const nextQuery = (next.q ?? searchQuery ?? "").trim();
if (nextQuery) {
params.set("q", nextQuery);
}
const nextSort = next.sort ?? sort;
if (nextSort) {
params.set("sort", nextSort);
}
const nextOrder = next.order ?? order;
if (nextOrder) {
params.set("order", nextOrder);
}
return `/dashboard?${params.toString()}`;
};
const csvExportHref = (() => {
const params = new URLSearchParams();
if (includeArchived) {
params.set("include_archived", "true");
}
const trimmed = (searchQuery ?? "").trim();
if (trimmed) {
params.set("q", trimmed);
}
if (sort) {
params.set("sort", sort);
}
if (order) {
params.set("order", order);
}
const suffix = params.toString();
return suffix ? `/dashboard/invoices.csv?${suffix}` : "/dashboard/invoices.csv";
})();
useEffect(() => {
if (createInvoiceOrigin) {
return;
}
setCreateInvoiceOrigin(window.location.origin);
}, [createInvoiceOrigin]);
useEffect(() => {
if (state.invoiceId) {
setCreateStep(3);
router.refresh();
}
}, [state.invoiceId, router]);
const publicInvoiceUrl = state.invoiceId
? createInvoiceOrigin
? new URL(`/invoice/${state.invoiceId}`, createInvoiceOrigin).toString()
: `/invoice/${state.invoiceId}`
: null;
const formattedDraftAmount = amount ? formatXmrAmount(amount) : "";
const draftAmountValue = useMemo(() => {
const parsed = Number.parseFloat(amount);
return Number.isFinite(parsed) ? parsed : null;
}, [amount]);
const draftUsdEstimate = useMemo(() => {
if (!usdRate || draftAmountValue === null || draftAmountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * draftAmountValue);
}, [usdRate, draftAmountValue]);
const expiryTimeValue = expiresTime || "00:00";
const expiryValue = expiresDate ? `${expiresDate}T${expiryTimeValue}` : "";
const expiryIsValid = (!expiresDate && !expiresTime) || !!expiresDate;
const openCreate = () => {
setIsCreateOpen(true);
setCreateStep(1);
setFormKey((prev) => prev + 1);
setAmount("");
setConfirmationTarget(String(defaultConfirmationTarget ?? 10));
setExpiresDate("");
setExpiresTime("");
setRecipientName("");
setDescription("");
setQrLogoMode("account_default");
setQrLogoDataUrl(null);
};
const closeCreate = () => {
setIsCreateOpen(false);
setCreateStep(1);
setFormKey((prev) => prev + 1);
};
const handleQrLogoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setQrLogoDataUrl(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setQrLogoDataUrl(result);
}
};
reader.readAsDataURL(file);
};
const toggleInvoice = (invoiceId: string) => {
setExpandedInvoiceId((prev) => (prev === invoiceId ? null : invoiceId));
};
const openArchiveModal = (invoiceId: string) => {
setArchiveModalInvoiceId(invoiceId);
};
const closeArchiveModal = () => {
setArchiveModalInvoiceId(null);
};
useEffect(() => {
if (archiveState.archivedId && expandedInvoiceId === archiveState.archivedId) {
setExpandedInvoiceId(null);
setArchiveModalInvoiceId(null);
router.refresh();
}
}, [archiveState.archivedId, expandedInvoiceId, router]);
const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft";
const inputClass =
"w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10";
const primaryButton =
"inline-flex 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 disabled:cursor-not-allowed disabled:opacity-70";
const secondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70";
const smallSecondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70";
const sortHref = (key: string) => {
const nextOrder = sort === key && order === "asc" ? "desc" : "asc";
return buildInvoicesHref({ sort: key, order: nextOrder });
};
const applySearch = () => {
router.push(buildInvoicesHref({ q: searchInput }));
};
return (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-serif text-3xl">Invoices</h1>
<p className="mt-2 text-ink-soft">
Search, sort, and expand invoices for details.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link className={secondaryButton} href={archivedToggleHref}>
{includeArchived ? "Hide archived" : "Show archived"}
</Link>
<a className={secondaryButton} href={csvExportHref}>
Export CSV
</a>
<button className={primaryButton} type="button" onClick={openCreate}>
Create invoice
</button>
</div>
</div>
<div className="mt-6 grid gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<form
className="flex w-full flex-1 flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
applySearch();
}}
>
<div className="grid w-full gap-2 sm:max-w-lg">
<label className={labelClass} htmlFor="invoice_search">
Search
</label>
<input
className={inputClass}
id="invoice_search"
type="search"
value={searchInput}
placeholder="Invoice id, subaddress, or metadata"
onChange={(event) => setSearchInput(event.target.value)}
/>
</div>
<div className="flex gap-2">
<button className={smallSecondaryButton} type="submit">
Apply
</button>
{searchQuery ? (
<Link className={smallSecondaryButton} href={buildInvoicesHref({ q: "" })}>
Clear
</Link>
) : null}
</div>
</form>
</div>
{activeList.length === 0 ? (
<p className="text-sm font-semibold text-ink-soft">
No invoices match your filters.
</p>
) : (
<div className="overflow-x-auto rounded-2xl border border-stroke bg-white/70 shadow-soft">
<table className="w-full min-w-[720px] border-collapse text-left text-sm">
<thead className="bg-white/60">
<tr className="border-b border-stroke">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("amount_xmr")}>
Amount
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("status")}>
Status
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("created_at")}>
Created
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("expires_at")}>
Expires
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Actions
</th>
</tr>
</thead>
<tbody>
{activeList.map((invoice) => {
const isExpanded = expandedInvoiceId === invoice.id;
const showPaidAfterExpiry = Boolean(invoice.paid_after_expiry);
const isArchivable =
!invoice.archived_at &&
(invoice.status === "pending" ||
invoice.status === "expired" ||
invoice.status === "invalid");
return (
<Fragment key={invoice.id}>
<tr
className={`border-b border-stroke ${invoice.archived_at ? "bg-ink/5" : ""}`}
>
<td className="px-4 py-3 align-top">
<button
className="grid gap-1 text-left"
type="button"
onClick={() => toggleInvoice(invoice.id)}
>
<span className="font-mono text-xs text-ink">{invoice.id}</span>
<span className="text-xs text-ink-soft">
{invoice.address.slice(0, 18)}…{invoice.address.slice(-10)}
</span>
</button>
<div className="mt-2 flex flex-wrap gap-2">
{invoice.archived_at ? (
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink">
Archived
</span>
) : null}
{showPaidAfterExpiry ? (
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink">
Paid after expiry
</span>
) : null}
</div>
</td>
<td className="px-4 py-3 align-top font-semibold text-ink">
{formatXmrAmount(invoice.amount_xmr)} XMR
</td>
<td className="px-4 py-3 align-top">
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{formatStatus(invoice.status)}
</span>
</td>
<td
className="px-4 py-3 align-top text-ink"
title={formatRelativeTime(invoice.created_at) ?? undefined}
>
{formatTimestamp(invoice.created_at)}
</td>
<td
className="px-4 py-3 align-top text-ink"
title={formatRelativeTime(invoice.expires_at) ?? undefined}
>
{formatTimestamp(invoice.expires_at)}
</td>
<td className="px-4 py-3 align-top">
<div className="flex flex-wrap gap-2">
<Link
className={smallSecondaryButton}
href={`/invoice/${invoice.id}`}
target="_blank"
rel="noreferrer"
>
Open
</Link>
<button
className={smallSecondaryButton}
type="button"
onClick={() => toggleInvoice(invoice.id)}
>
{isExpanded ? "Hide" : "Details"}
</button>
</div>
</td>
</tr>
{isExpanded ? (
<tr className="border-b border-stroke">
<td className="px-4 py-4" colSpan={6}>
<div className="grid gap-4 rounded-xl border border-stroke bg-white/80 p-4 shadow-soft">
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<p className={labelClass}>Created at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.created_at) ?? undefined}
>
{formatTimestamp(invoice.created_at)}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Detected at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.detected_at) ?? undefined}
>
{formatTimestamp(invoice.detected_at)}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Confirmed at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.confirmed_at) ?? undefined}
>
{formatTimestamp(invoice.confirmed_at)}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Expires at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.expires_at) ?? undefined}
>
{formatTimestamp(invoice.expires_at)}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<p className={labelClass}>Confirmation target</p>
<p className="text-sm font-semibold text-ink">
{invoice.confirmation_target}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Subaddress index</p>
<p className="text-sm font-semibold text-ink">
{invoice.subaddress_index ?? "-"}
</p>
</div>
{invoice.archived_at ? (
<div className="grid gap-2">
<p className={labelClass}>Archived at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.archived_at) ?? undefined}
>
{formatTimestamp(invoice.archived_at)}
</p>
</div>
) : null}
{invoice.paid_after_expiry_at ? (
<div className="grid gap-2">
<p className={labelClass}>Paid after expiry at</p>
<p
className="text-sm font-semibold text-ink"
title={
formatRelativeTime(invoice.paid_after_expiry_at) ??
undefined
}
>
{formatTimestamp(invoice.paid_after_expiry_at)}
</p>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
{isArchivable ? (
<button
className={secondaryButton}
type="button"
onClick={() => openArchiveModal(invoice.id)}
>
Archive invoice
</button>
) : (
<p className="text-sm text-ink-soft">
Invoice actions are unavailable for this status.
</p>
)}
{archiveState.error && archiveModalInvoiceId === invoice.id ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{archiveState.error}
</p>
) : null}
{archiveState.success &&
archiveState.archivedId === invoice.id ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{archiveState.success}
</p>
) : null}
</div>
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
We never hold funds. All payments move from the customer to your wallet.
</div>
{isCreateOpen ? (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-ink/40 px-4 py-10">
<div className="w-full max-w-3xl rounded-3xl border border-stroke bg-white p-8 shadow-deep">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Create invoice
</p>
<h2 className="mt-2 font-serif text-2xl">Create a new invoice</h2>
</div>
<button className={secondaryButton} type="button" onClick={closeCreate}>
Close
</button>
</div>
<div className="mt-6 flex flex-wrap gap-2">
{[
{ id: 1, label: "1. Details" },
{ id: 2, label: "2. Review" },
{ id: 3, label: "3. Confirmation" },
].map((step) => (
<span
key={step.id}
className={`rounded-full border px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${
createStep === step.id
? "border-ink bg-ink text-cream"
: "border-stroke bg-white/70 text-ink-soft"
}`}
>
{step.label}
</span>
))}
</div>
<form className="mt-6 grid gap-6" action={formAction} key={formKey}>
{createStep === 1 ? (
<div className="grid gap-4">
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_amount">
Amount (XMR)
</label>
<input
className={inputClass}
id="wizard_amount"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
value={amount}
onChange={(event) => setAmount(event.target.value)}
required
/>
{draftUsdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{draftUsdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_confirmation_target">
Confirmation target
</label>
<input
className={inputClass}
id="wizard_confirmation_target"
name="confirmation_target"
type="number"
min="0"
max="10"
step="1"
value={confirmationTarget}
onChange={(event) => setConfirmationTarget(event.target.value)}
required
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_expires_date">
Expiry date
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
<input
className={inputClass}
id="wizard_expires_date"
name="expires_date"
type="date"
value={expiresDate}
onChange={(event) => setExpiresDate(event.target.value)}
/>
<input
className={inputClass}
id="wizard_expires_time"
name="expires_time"
type="time"
value={expiresTime}
onChange={(event) => setExpiresTime(event.target.value)}
/>
</div>
<p className="text-sm text-ink-soft">
Leave blank to keep the default 24-hour expiry.
</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_recipient">
Recipient name
</label>
<input
className={inputClass}
id="wizard_recipient"
name="recipient_name"
type="text"
placeholder="Your store"
value={recipientName}
onChange={(event) => setRecipientName(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_description">
Description
</label>
<input
className={inputClass}
id="wizard_description"
name="description"
type="text"
placeholder="Order 1042"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_qr_logo_mode">
QR logo
</label>
<select
className={inputClass}
id="wizard_qr_logo_mode"
name="qr_logo_mode"
value={qrLogoMode}
onChange={(event) => {
const value = event.target.value;
if (
value === "account_default" ||
value === "monero" ||
value === "none" ||
value === "custom"
) {
setQrLogoMode(value);
}
if (value !== "custom") {
setQrLogoDataUrl(null);
}
}}
>
<option value="account_default">Account default</option>
<option value="monero">Monero logo</option>
<option value="none">No logo</option>
<option value="custom">Custom image</option>
</select>
<p className="text-sm text-ink-soft">
The logo overlays the QR center. Custom images are stored on the invoice as a
data URL.
</p>
{qrLogoMode === "custom" ? (
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="wizard_qr_logo_file"
type="file"
accept="image/*"
onChange={handleQrLogoFileChange}
/>
) : null}
{qrLogoMode === "custom" && qrLogoDataUrl ? (
<button
className={secondaryButton}
type="button"
onClick={() => setQrLogoDataUrl(null)}
>
Remove custom image
</button>
) : null}
</div>
</div>
) : null}
{createStep === 2 ? (
<div className="grid gap-4">
<input type="hidden" name="amount_xmr" value={amount} />
<input
type="hidden"
name="confirmation_target"
value={confirmationTarget}
/>
<input type="hidden" name="expires_at" value={expiryValue} />
<input type="hidden" name="recipient_name" value={recipientName} />
<input type="hidden" name="description" value={description} />
<input type="hidden" name="qr_logo_mode" value={qrLogoMode} />
<input
type="hidden"
name="qr_logo_data_url"
value={qrLogoMode === "custom" ? qrLogoDataUrl ?? "" : ""}
/>
<div className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Review details
</p>
<p className="mt-3 text-sm text-ink">
Amount:{" "}
<strong>{formattedDraftAmount || "-"} XMR</strong>
</p>
<p className="mt-2 text-sm text-ink">
Confirmations:{" "}
<strong>{confirmationTarget || "-"}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Expiry:{" "}
<strong>
{expiryValue
? new Date(
expiryValue
).toLocaleString()
: "Default (24 hours)"}
</strong>
</p>
<p className="mt-2 text-sm text-ink">
QR logo: <strong>{qrLogoMode.replace("_", " ")}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Recipient: <strong>{recipientName || "-"}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Description: <strong>{description || "-"}</strong>
</p>
</div>
</div>
) : null}
{createStep === 3 ? (
<div className="grid gap-4">
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{publicInvoiceUrl ? (
<div className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice created
</p>
<p className="mt-3 text-sm text-ink">
Your invoice has been created. You can view and share the invoice
link with your customer here:
</p>
<p className="mt-2 text-sm font-semibold text-ink">
<Link
className="underline underline-offset-4"
href={publicInvoiceUrl}
target="_blank"
rel="noreferrer"
>
{publicInvoiceUrl}
</Link>
</p>
</div>
) : null}
</div>
) : null}
{state.error && createStep !== 3 ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-3">
{createStep === 1 ? (
<button
className={secondaryButton}
type="button"
onClick={() => setCreateStep(2)}
disabled={
!amount ||
!confirmationTarget ||
!expiryIsValid ||
(qrLogoMode === "custom" && !qrLogoDataUrl)
}
>
Continue
</button>
) : null}
{createStep === 2 ? (
<>
<button
className={secondaryButton}
type="button"
onClick={() => setCreateStep(1)}
>
Back
</button>
<button className={primaryButton} type="submit">
Create invoice
</button>
</>
) : null}
{createStep === 3 ? (
<button className={secondaryButton} type="button" onClick={closeCreate}>
Done
</button>
) : null}
</div>
</form>
</div>
</div>
) : null}
{archiveModalInvoiceId ? (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-ink/40 px-4 py-10">
<div className="w-full max-w-lg rounded-3xl border border-stroke bg-white p-8 shadow-deep">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Archive invoice
</p>
<h2 className="mt-2 font-serif text-2xl">Archive this invoice?</h2>
<p className="mt-2 text-sm text-ink-soft">
Archived invoices stay available in the archive list.
</p>
</div>
<button className={secondaryButton} type="button" onClick={closeArchiveModal}>
Close
</button>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3">
<button className={secondaryButton} type="button" onClick={closeArchiveModal}>
Cancel
</button>
<form action={archiveAction}>
<input type="hidden" name="invoice_id" value={archiveModalInvoiceId} />
<button className={primaryButton} type="submit">
Archive invoice
</button>
</form>
</div>
</div>
</div>
) : null}
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
type InvoicePaymentDetailsProps = {
address: string;
amount: string;
hasDetectedPayment: boolean;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmationTarget: number;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
const buildMoneroUri = (address: string, amount: string) => {
const params = new URLSearchParams();
params.set("tx_amount", amount);
return `monero:${address}?${params.toString()}`;
};
export default function InvoicePaymentDetails({
address,
amount,
hasDetectedPayment,
status,
confirmationTarget,
qrLogoMode,
qrLogoDataUrl,
}: InvoicePaymentDetailsProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const { rate: usdRate } = useXmrUsdRate();
const isInvalid = status === "invalid";
const shouldPromptPayment = !hasDetectedPayment && !isInvalid;
const formattedAmount = useMemo(() => formatXmrAmount(amount), [amount]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
);
const amountValue = useMemo(() => {
const parsed = Number.parseFloat(amount);
return Number.isFinite(parsed) ? parsed : null;
}, [amount]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => {
setCopiedField((current) => (current === field ? null : current));
}, 1500);
} catch {
setCopiedField(null);
}
};
useEffect(() => {
let active = true;
const size = 200;
const canvas = document.createElement("canvas");
QRCode.toCanvas(canvas, uri, {
margin: 1,
width: size,
errorCorrectionLevel: "H",
})
.then(() => {
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const mode = qrLogoMode ?? "monero";
const wantsLogo = mode !== "none";
if (wantsLogo) {
const blankSize = Math.round(size * 0.28);
const blankOffset = Math.round((size - blankSize) / 2);
context.fillStyle = "#ffffff";
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
}
return canvas.toDataURL();
})
.then((url) => {
if (active) {
setQrDataUrl(url ?? null);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrLogoMode]);
const resolvedQrLogoMode = qrLogoMode ?? "monero";
const resolvedLogoSrc =
resolvedQrLogoMode === "custom"
? qrLogoDataUrl
: resolvedQrLogoMode === "monero"
? "/monero-logo.svg"
: null;
return (
<details
className="group rounded-2xl border border-stroke bg-card p-8 shadow-card backdrop-blur"
open={shouldPromptPayment}
>
<summary className="cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Payment details
</p>
<h2 className="mt-2 font-serif text-2xl">
{isInvalid
? "Invoice marked invalid. Do not send payment."
: hasDetectedPayment
? status === "confirmed"
? `Payment confirmed on-chain at ${confirmationTarget} confirmations.`
: "Payment detected. Confirmations are in progress."
: "Send the exact amount to this address."}
</h2>
</div>
<span className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:hidden">
Show details
</span>
<span className="hidden rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:inline-flex">
Hide details
</span>
</div>
</summary>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Amount (XMR)
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(formattedAmount, "amount")}
>
{copiedField === "amount" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 text-2xl font-semibold text-ink">{formattedAmount}</p>
<p className="mt-2 text-sm text-ink-soft">
Your wallet adds the network fee on top. Do not subtract it from the
amount shown.
</p>
{usdEstimate ? (
<>
<p className="mt-2 text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="mt-1 w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment address
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(address, "address")}
>
{copiedField === "address" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{address}
</p>
</div>
</div>
<div className="mt-6 rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request URI
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(uri, "uri")}
>
{copiedField === "uri" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{uri}
</p>
{shouldPromptPayment ? (
<p className="mt-4 text-sm text-ink-soft">
After you pay, this page updates automatically once the payment is
detected on-chain.
</p>
) : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<p className="max-w-xl text-sm text-ink-soft">
{isInvalid
? "This invoice is marked invalid. Do not send a payment to this address."
: hasDetectedPayment
? "Details remain available if you need to reference the address or request URI."
: "Tip: use the QR or payment request URI to avoid copy errors."}
</p>
{qrDataUrl ? (
<div className="relative h-[200px] w-[200px] rounded-2xl border border-stroke bg-white p-2 shadow-soft">
<Image
className="h-full w-full rounded-xl border border-ink/10 bg-white"
src={qrDataUrl}
alt="Payment request QR"
width={200}
height={200}
unoptimized
/>
{resolvedLogoSrc ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
<Image
src={resolvedLogoSrc}
alt="QR logo"
width={32}
height={32}
unoptimized={resolvedQrLogoMode === "custom"}
/>
</span>
</div>
) : null}
</div>
) : null}
</div>
</details>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function InvoiceStatusActions() {
return (
<div className="flex flex-wrap gap-3">
<Link
className="inline-flex 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="/invoice"
>
Check another invoice
</Link>
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
type InvoiceStatusAutoRefreshProps = {
intervalMs?: number;
};
export default function InvoiceStatusAutoRefresh({
intervalMs = 30000,
}: InvoiceStatusAutoRefreshProps) {
const router = useRouter();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, intervalMs);
return () => clearInterval(interval);
}, [intervalMs, router]);
return null;
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type InvoiceStatusLookupProps = {
initialValue?: string;
compact?: boolean;
};
export default function InvoiceStatusLookup({
initialValue = "",
compact = false,
}: InvoiceStatusLookupProps) {
const router = useRouter();
const [invoiceId, setInvoiceId] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = invoiceId.trim();
if (!trimmed) {
setError("Enter an invoice id to continue.");
return;
}
setError(null);
router.push(`/invoice/${trimmed}`);
};
return (
<form
className={`grid gap-3 ${compact ? "text-sm" : "text-base"}`}
onSubmit={handleSubmit}
>
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="invoice_id"
>
Invoice ID
</label>
<div className="flex flex-wrap items-center gap-3">
<input
className="min-w-[220px] flex-1 rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="invoice_id"
name="invoice_id"
type="text"
value={invoiceId}
onChange={(event) => setInvoiceId(event.target.value)}
placeholder="e.g. 9d2f2b3c-8a77-4c2b-b9d4-..."
autoComplete="off"
spellCheck={false}
required
/>
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Check status
</button>
</div>
{compact ? null : (
<p className="text-sm text-ink-soft">
Read-only status view. Refresh the page to see new detections.
</p>
)}
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
role="alert"
>
{error}
</p>
) : null}
</form>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import LoginForm from "../app/(app)/login/login-form";
const loginParam = "login";
type LoginTriggerProps = {
className?: string;
children: React.ReactNode;
};
export function LoginTrigger({ className, children }: LoginTriggerProps) {
const router = useRouter();
const pathname = usePathname();
const openModal = useCallback(() => {
const params =
typeof window === "undefined"
? new URLSearchParams()
: new URLSearchParams(window.location.search);
params.set(loginParam, "1");
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
}, [pathname, router]);
return (
<button
className={className}
type="button"
onClick={openModal}
aria-haspopup="dialog"
aria-controls="login-modal"
>
{children}
</button>
);
}
export default function LoginModal() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isOpen = searchParams.get(loginParam) === "1";
const closeModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete(loginParam);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen]);
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-40 grid place-items-center"
id="login-modal"
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
>
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
<div
className="relative w-[min(480px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Merchant access
</p>
<h3 id="login-title" className="mt-2 font-serif text-xl">
Sign in to your dashboard.
</h3>
</div>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
aria-label="Close login modal"
>
Close
</button>
</div>
<p className="mt-3 text-ink-soft">
Use your primary address and secret view key to access checkout tools.
Your primary address is the only identifier we need - no email or other
identifying information required.
<br />
Your secret view key is stored encrypted at rest and used only for payment detection.
</p>
<LoginForm />
<p className="mt-4 text-sm font-semibold text-sage">
We never require more than view-only access to keep your funds safe.
</p>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
type RelativeTimeInput = string | number | Date | null | undefined;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const parseRelativeTime = (value: RelativeTimeInput) => {
if (value === null || value === undefined) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
const timestamp = date.getTime();
if (Number.isNaN(timestamp)) {
return null;
}
return timestamp;
};
export const formatRelativeTime = (value: RelativeTimeInput) => {
const timestamp = parseRelativeTime(value);
if (timestamp === null) {
return null;
}
const diffSeconds = Math.round((timestamp - Date.now()) / 1000);
const absSeconds = Math.abs(diffSeconds);
if (absSeconds < 60) {
return relativeTimeFormatter.format(diffSeconds, "second");
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, "minute");
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, "hour");
}
const diffDays = Math.round(diffHours / 24);
if (Math.abs(diffDays) < 30) {
return relativeTimeFormatter.format(diffDays, "day");
}
const diffMonths = Math.round(diffDays / 30);
if (Math.abs(diffMonths) < 12) {
return relativeTimeFormatter.format(diffMonths, "month");
}
const diffYears = Math.round(diffDays / 365);
return relativeTimeFormatter.format(diffYears, "year");
};
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
type SecretCardProps = {
label: string;
value: string;
buttonLabel: string;
};
export default function SecretCard({ label, value, buttonLabel }: SecretCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{label}
</p>
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
{value}
</code>
</div>
<div className="flex justify-end">
<button
className="inline-flex 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"
type="button"
onClick={handleCopy}
>
{copied ? "Copied" : buttonLabel}
</button>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type StatusRefreshButtonProps = {
label?: string;
className?: string;
};
export default function StatusRefreshButton({
label = "Refresh",
className = "",
}: StatusRefreshButtonProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = () => {
setIsRefreshing(true);
router.refresh();
setTimeout(() => setIsRefreshing(false), 500);
};
return (
<button
className={`inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70 ${className}`}
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : label}
</button>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
redeliverWebhookDeliveryAction,
type WebhookRedeliverState,
} from "../app/(app)/dashboard/actions";
type WebhookDeliverySummary = {
id: string;
webhook_id: string | null;
event: string;
url: string;
invoice_id: string | null;
invoice_address: string | null;
invoice_subaddress_index: number | null;
invoice_amount_xmr: string | null;
invoice_status:
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid"
| null;
http_status: number | null;
error_message: string | null;
created_at: string;
};
const initialState: WebhookRedeliverState = { error: null, success: null };
export default function WebhookHistoryPanel({
deliveries,
}: {
deliveries: WebhookDeliverySummary[];
}) {
const router = useRouter();
const [state, formAction] = useFormState(redeliverWebhookDeliveryAction, initialState);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
return (
<div className="grid gap-4">
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
{deliveries.length === 0 ? (
<p className="text-sm text-ink-soft">No webhook calls recorded yet.</p>
) : (
<div className="grid gap-3">
{deliveries.map((delivery) => {
const statusLabel =
delivery.http_status !== null ? delivery.http_status.toString() : "No response";
const isFailed =
delivery.http_status === null ||
(typeof delivery.http_status === "number" && delivery.http_status >= 400);
const hasInvoiceDetails =
Boolean(delivery.invoice_id) ||
Boolean(delivery.invoice_address) ||
Boolean(delivery.invoice_amount_xmr) ||
delivery.invoice_subaddress_index !== null;
return (
<div
key={delivery.id}
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{delivery.event}
</p>
<p className="mt-2 break-all text-sm font-semibold text-ink">
{delivery.url}
</p>
<p className="mt-2 text-xs text-ink-soft">
{formatTimestamp(delivery.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{statusLabel}
</span>
{isFailed ? (
<form action={formAction}>
<input type="hidden" name="delivery_id" value={delivery.id} />
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5"
type="submit"
>
Redeliver
</button>
</form>
) : null}
</div>
</div>
{hasInvoiceDetails ? (
<details className="mt-3 rounded-lg border border-stroke bg-white/70 px-3 py-2">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.12em] text-ink">
View invoice details
</summary>
<div className="mt-3 grid gap-2 text-xs text-ink-soft">
{delivery.invoice_id ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice id
</p>
<p className="mt-1 break-all text-xs text-ink">
{delivery.invoice_id}
</p>
</div>
) : null}
{delivery.invoice_address ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress
</p>
<p className="mt-1 break-all text-xs text-ink">
{delivery.invoice_address}
</p>
</div>
) : null}
{delivery.invoice_subaddress_index !== null ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress index
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_subaddress_index}
</p>
</div>
) : null}
{delivery.invoice_amount_xmr ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice amount (XMR)
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_amount_xmr}
</p>
</div>
) : null}
{delivery.invoice_status ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice status
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_status}
</p>
</div>
) : null}
</div>
</details>
) : null}
{delivery.error_message ? (
<p className="mt-2 text-xs text-ink-soft">
Delivery error: {delivery.error_message}
</p>
) : null}
</div>
);
})}
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useFormState } from "react-dom";
import {
resetWebhookSecretAction,
type WebhookSecretState,
} from "../app/(app)/dashboard/actions";
import SecretCard from "./secret-card";
const initialState = (webhookSecret: string | null): WebhookSecretState => ({
webhookSecret,
error: null,
});
type WebhookSecretSectionProps = {
webhookSecret: string | null;
};
export default function WebhookSecretSection({
webhookSecret,
}: WebhookSecretSectionProps) {
const [state, formAction] = useFormState(
resetWebhookSecretAction,
initialState(webhookSecret)
);
return (
<div className="grid gap-4">
{state.webhookSecret ? (
<SecretCard
label="Your webhook secret"
value={state.webhookSecret}
buttonLabel="Copy webhook secret"
/>
) : (
<div className="rounded-2xl border border-stroke bg-white/80 p-6 text-sm text-ink-soft shadow-soft backdrop-blur">
Webhook secret unavailable. Reset to generate a new secret.
</div>
)}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<form action={formAction} className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
>
Reset webhook secret
</button>
</form>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useFormState } from "react-dom";
import {
createWebhookAction,
deleteWebhookAction,
type WebhookFormState,
} from "../app/(app)/dashboard/actions";
type WebhookSummary = {
id: string;
url: string;
events: string[];
event_urls?: Record<string, string> | null;
active: boolean;
created_at: string;
};
const initialState: WebhookFormState = { error: null, success: null };
const webhookEvents = [
{ event: "invoice.created", key: "invoice_created", label: "invoice.created" },
{
event: "invoice.payment_detected",
key: "invoice_payment_detected",
label: "invoice.payment_detected",
},
{ event: "invoice.confirmed", key: "invoice_confirmed", label: "invoice.confirmed" },
{ event: "invoice.expired", key: "invoice_expired", label: "invoice.expired" },
];
type WebhookSectionProps = {
webhooks: WebhookSummary[];
};
export default function WebhookSection({ webhooks }: WebhookSectionProps) {
const [createState, createAction] = useFormState(createWebhookAction, initialState);
const [deleteState, deleteAction] = useFormState(deleteWebhookAction, initialState);
return (
<div className="grid gap-8">
<form className="grid gap-6" action={createAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_url"
>
Default webhook URL
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_url"
name="default_url"
type="url"
placeholder="https://xmrcheckout.com/webhooks"
/>
<p className="text-sm text-ink-soft">
Used for any selected event without a specific override URL.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{webhookEvents.map(({ event, key, label }) => (
<div key={event} className="rounded-xl border border-stroke bg-white/70 p-4">
<label className="flex items-center gap-3 text-sm font-semibold text-ink">
<input
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
type="checkbox"
name={`event_${key}`}
defaultChecked
/>
<span>{label}</span>
</label>
<input
className="mt-3 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id={`event_url_${key}`}
name={`event_url_${key}`}
type="url"
placeholder="Optional override URL"
/>
</div>
))}
</div>
{createState.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{createState.error}
</p>
) : null}
{createState.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{createState.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex 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"
type="submit"
>
Save webhook
</button>
</div>
</form>
<div className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Hint
</p>
<p className="mt-2 text-sm text-ink-soft">
Webhooks are optional. You can poll{" "}
<code>/api/core/public/invoice/&lt;invoice_id&gt;</code> without
authentication to read invoice status.
</p>
</div>
<div className="grid gap-4">
<div>
<h3 className="font-serif text-xl">Active webhooks</h3>
</div>
{deleteState.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{deleteState.error}
</p>
) : null}
{deleteState.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{deleteState.success}
</p>
) : null}
{webhooks.length === 0 ? (
<p className="text-sm font-semibold text-ink-soft">
No webhooks configured yet.
</p>
) : (
<form className="grid gap-4" action={deleteAction}>
{webhooks.map((hook) => (
<div
key={hook.id}
className="flex flex-wrap items-start justify-between gap-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft backdrop-blur"
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Webhook
</p>
<code className="mt-2 block break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{hook.url || "Per-event URLs only"}
</code>
<div className="mt-4 grid gap-3">
{hook.events.map((event) => (
<div key={event} className="grid gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{event}
</span>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{hook.event_urls?.[event] ?? hook.url}
</code>
</div>
))}
</div>
</div>
<div className="flex">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
name="webhook_id"
value={hook.id}
>
Remove
</button>
</div>
</div>
))}
</form>
)}
</div>
</div>
);
}