mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
Add UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/<invoice_id></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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user