mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
Fork xmrcheckout for Wownero (WOW)
Minimal changes from upstream xmrcheckout to support Wownero: - Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD - Subaddress derivation: Wownero network byte overrides (53/63/54) - Payment URI scheme: wownero: - Dockerfile: Wownero binaries from Codeberg - Display text: Monero→Wownero, XMR→WOW throughout UI - Default daemon: 192.168.88.235:34568 - Added wownero-logo.svg File and folder structure kept identical to upstream for easy sync.
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,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
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 router = useRouter();
|
||||
const [state, formAction] = useFormState(
|
||||
updateBtcpayCheckoutStyleAction,
|
||||
initialState(initialStyle)
|
||||
);
|
||||
const [style, setStyle] = useState<BtcpayCheckoutStyle>(initialStyle);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success) {
|
||||
router.refresh();
|
||||
}
|
||||
}, [router, state.success]);
|
||||
|
||||
useEffect(() => {
|
||||
setStyle(initialStyle);
|
||||
}, [initialStyle]);
|
||||
|
||||
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">
|
||||
This setting only applies to BTCPay checkouts (invoices created via 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 WOW 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,402 @@
|
||||
"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 = {
|
||||
invoiceId: string;
|
||||
address: string;
|
||||
amountXmr: string;
|
||||
amountPaidXmr: string | null;
|
||||
btcpayAmount: string | null;
|
||||
btcpayCurrency: string | null;
|
||||
quote: QuotePayload | null;
|
||||
status: InvoiceStatus;
|
||||
confirmationTarget: number;
|
||||
redirectUrl: string | null;
|
||||
redirectAutomatically: boolean | null;
|
||||
orderId: string | null;
|
||||
orderNumber: string | null;
|
||||
qrLogoMode?: "monero" | "none" | "custom" | null;
|
||||
qrLogoDataUrl?: string | null;
|
||||
};
|
||||
|
||||
const buildMoneroUri = (address: string, amount: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("tx_amount", amount);
|
||||
return `wownero:${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 formatReturnLabel = (redirectUrl: string) => {
|
||||
try {
|
||||
const parsed = new URL(redirectUrl);
|
||||
return parsed.hostname ? `Return to ${parsed.hostname}` : "Return to store";
|
||||
} catch {
|
||||
return "Return to store";
|
||||
}
|
||||
};
|
||||
|
||||
const statusMessage = (
|
||||
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({
|
||||
invoiceId,
|
||||
address,
|
||||
amountXmr,
|
||||
amountPaidXmr,
|
||||
btcpayAmount,
|
||||
btcpayCurrency,
|
||||
quote,
|
||||
status,
|
||||
confirmationTarget,
|
||||
redirectUrl,
|
||||
redirectAutomatically,
|
||||
orderId,
|
||||
orderNumber,
|
||||
qrLogoMode,
|
||||
qrLogoDataUrl,
|
||||
}: BtcpayClassicCheckoutProps) {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
|
||||
const formattedPaidAmount = useMemo(() => {
|
||||
if (!amountPaidXmr) {
|
||||
return formattedAmount;
|
||||
}
|
||||
return formatXmrAmount(amountPaidXmr);
|
||||
}, [amountPaidXmr, formattedAmount]);
|
||||
const uri = useMemo(
|
||||
() => buildMoneroUri(address, formattedAmount),
|
||||
[address, formattedAmount]
|
||||
);
|
||||
const totalFiat =
|
||||
btcpayAmount && btcpayCurrency && btcpayCurrency !== "WOW"
|
||||
? formatFiatAmount(btcpayAmount, btcpayCurrency)
|
||||
: null;
|
||||
const exchangeRate =
|
||||
quote?.rate && quote.fiat_currency
|
||||
? `1 WOW = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "confirmed" || !redirectUrl || !redirectAutomatically) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
window.top?.location.assign(redirectUrl);
|
||||
} catch {
|
||||
window.location.assign(redirectUrl);
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [redirectAutomatically, redirectUrl, status]);
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
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"
|
||||
? "/wownero-logo.svg"
|
||||
: null;
|
||||
|
||||
if (status === "confirmed") {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-sage/15 text-sage">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-9 w-9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||
Invoice paid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-3 text-sm text-ink">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Invoice id</dt>
|
||||
<dd className="max-w-[60%] truncate text-right font-semibold">{invoiceId}</dd>
|
||||
</div>
|
||||
{orderNumber || orderId ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Order id</dt>
|
||||
<dd className="font-semibold">{orderNumber ?? orderId}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Total price</dt>
|
||||
<dd className="font-semibold">{formattedAmount} WOW</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Total fiat</dt>
|
||||
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Exchange rate</dt>
|
||||
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Network cost</dt>
|
||||
<dd className="font-semibold">Added by wallet</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-ink-soft">Amount paid</dt>
|
||||
<dd className="font-semibold">{formattedPaidAmount} WOW</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-8 grid gap-3">
|
||||
<a
|
||||
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
|
||||
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
|
||||
>
|
||||
View receipt
|
||||
</a>
|
||||
{redirectUrl ? (
|
||||
<a
|
||||
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
|
||||
href={redirectUrl}
|
||||
target="_top"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{formatReturnLabel(redirectUrl)}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-3">
|
||||
<p className="text-[1.9rem] font-semibold text-ink">{formattedAmount} WOW</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(formattedAmount, "amount")}
|
||||
>
|
||||
{copiedField === "amount" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<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} WOW</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="flex items-center gap-2 font-semibold">
|
||||
<span>{formattedAmount} WOW</span>
|
||||
<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(formattedAmount, "amount")}
|
||||
>
|
||||
{copiedField === "amount" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</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,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type CopyIconButtonProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function CopyIconButton({ value, label, className }: CopyIconButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={copied ? "Copied" : label}
|
||||
onClick={handleCopy}
|
||||
className={
|
||||
className ??
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-neutral-200 bg-white text-neutral-600 transition hover:bg-neutral-50"
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 6 9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path
|
||||
d="M8 5h9a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 17H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"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, formatXmrAmount } from "../lib/formatting";
|
||||
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
|
||||
import { useXmrFiatRate } from "../lib/use-xmr-fiat-rate";
|
||||
import {
|
||||
FIAT_CURRENCY_SUGGESTIONS,
|
||||
getCurrencyFlag,
|
||||
} from "../lib/fiat-currencies";
|
||||
|
||||
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 `wownero:${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 [amountMode, setAmountMode] = useState<"xmr" | "fiat">("xmr");
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
const [fiatCurrency, setFiatCurrency] = useState("USD");
|
||||
const { rate: usdRate } = useXmrUsdRate();
|
||||
const { rate: fiatRate, status: fiatRateStatus } = useXmrFiatRate(
|
||||
amountMode === "fiat" ? fiatCurrency : null
|
||||
);
|
||||
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(() => {
|
||||
if (amountMode !== "xmr") {
|
||||
return null;
|
||||
}
|
||||
const raw = amountInput || state.amount || "";
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [amountInput, amountMode, state.amount]);
|
||||
const usdEstimate = useMemo(() => {
|
||||
if (!usdRate || amountValue === null || amountValue <= 0) {
|
||||
return null;
|
||||
}
|
||||
return formatUsdAmount(usdRate * amountValue);
|
||||
}, [usdRate, amountValue]);
|
||||
const fiatAmountValue = useMemo(() => {
|
||||
if (amountMode !== "fiat") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(amountInput);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [amountInput, amountMode]);
|
||||
const xmrEstimate = useMemo(() => {
|
||||
if (!fiatRate || fiatAmountValue === null || fiatAmountValue <= 0) {
|
||||
return null;
|
||||
}
|
||||
const estimate = fiatAmountValue / fiatRate;
|
||||
return formatXmrAmount(estimate.toFixed(12));
|
||||
}, [fiatAmountValue, fiatRate]);
|
||||
const showFiatEstimate =
|
||||
amountMode === "fiat" && fiatAmountValue !== null && fiatAmountValue > 0;
|
||||
const fiatEstimateLabel = xmrEstimate
|
||||
? `~${xmrEstimate} WOW`
|
||||
: fiatRateStatus === "loading"
|
||||
? "Fetching estimate..."
|
||||
: "Estimate unavailable";
|
||||
const fiatCurrencyFlag = getCurrencyFlag(fiatCurrency);
|
||||
|
||||
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_mode">
|
||||
Amount type
|
||||
</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
id="amount_mode"
|
||||
name="amount_mode"
|
||||
value={amountMode}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (value === "xmr" || value === "fiat") {
|
||||
setAmountMode(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="xmr">WOW</option>
|
||||
<option value="fiat">Fiat</option>
|
||||
</select>
|
||||
</div>
|
||||
{amountMode === "xmr" ? (
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="amount_xmr">
|
||||
Amount (WOW)
|
||||
</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-3 sm:grid-cols-[minmax(0,1fr)_140px]">
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="amount_fiat">
|
||||
Amount (fiat)
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
id="amount_fiat"
|
||||
name="amount_fiat"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="100.00"
|
||||
required
|
||||
onChange={(event) => setAmountInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="currency">
|
||||
Currency
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
id="currency"
|
||||
name="currency"
|
||||
type="text"
|
||||
placeholder="USD"
|
||||
list="fiat-currency-options"
|
||||
value={fiatCurrency}
|
||||
onChange={(event) => setFiatCurrency(event.target.value.toUpperCase())}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<datalist id="fiat-currency-options">
|
||||
{FIAT_CURRENCY_SUGGESTIONS.map((code) => (
|
||||
<option
|
||||
key={code}
|
||||
value={code}
|
||||
label={`${getCurrencyFlag(code) ?? ""} ${code}`.trim()}
|
||||
/>
|
||||
))}
|
||||
</datalist>
|
||||
<p className="text-sm text-ink-soft sm:col-span-2">
|
||||
Fiat inputs create a WOW invoice using a non-binding rate at request time.
|
||||
</p>
|
||||
{showFiatEstimate ? (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm text-ink-soft">
|
||||
Approx. WOW value: {fiatEstimateLabel}
|
||||
{fiatCurrencyFlag ? ` · ${fiatCurrencyFlag} ${fiatCurrency}` : ""}
|
||||
</p>
|
||||
{xmrEstimate ? (
|
||||
<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>
|
||||
) : 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 60 minutes.</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 60-minute 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="checkout_continue_url">
|
||||
Continue URL (optional)
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
id="checkout_continue_url"
|
||||
name="checkout_continue_url"
|
||||
type="url"
|
||||
placeholder="https://merchant.example/thanks"
|
||||
/>
|
||||
<p className="text-sm text-ink-soft">
|
||||
After confirmation, the hosted invoice page shows a "Continue" button
|
||||
that navigates to this URL.
|
||||
</p>
|
||||
</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 "/wownero-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">Wownero 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 wowcheckout.com
|
||||
</p>
|
||||
<h3 id="donate-title" className="mt-2 font-serif text-xl">
|
||||
If wowcheckout.com has been helpful, a donation is appreciated.
|
||||
</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 an invoice page with payment details and status.
|
||||
</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 (WOW)
|
||||
</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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 `wownero:${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"
|
||||
? "/wownero-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 (WOW)
|
||||
</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,47 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path.startsWith("/")) {
|
||||
return `/${path}`;
|
||||
}
|
||||
|
||||
if (path === "/") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return path.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export default function NavLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
exact,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
exact?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const currentPath = normalizePath(pathname);
|
||||
const targetPath = normalizePath(href);
|
||||
|
||||
const isActive = exact
|
||||
? currentPath === targetPath
|
||||
: currentPath === targetPath || currentPath.startsWith(`${targetPath}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
type PrintButtonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function PrintButton({ className }: PrintButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.print()}
|
||||
className={
|
||||
className ??
|
||||
"inline-flex items-center justify-center rounded-md border border-emerald-200 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 transition hover:bg-emerald-50"
|
||||
}
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,189 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
redeliverWebhookDeliveryAction,
|
||||
redeliverWebhookDeliveryTourAction,
|
||||
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({
|
||||
mode = "live",
|
||||
deliveries,
|
||||
}: {
|
||||
mode?: "live" | "tour";
|
||||
deliveries: WebhookDeliverySummary[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [state, formAction] = useFormState(
|
||||
mode === "tour" ? redeliverWebhookDeliveryTourAction : redeliverWebhookDeliveryAction,
|
||||
initialState
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success && mode !== "tour") {
|
||||
router.refresh();
|
||||
}
|
||||
}, [mode, 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 (WOW)
|
||||
</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,189 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState } from "react-dom";
|
||||
|
||||
import {
|
||||
createWebhookAction,
|
||||
createWebhookTourAction,
|
||||
deleteWebhookAction,
|
||||
deleteWebhookTourAction,
|
||||
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 = {
|
||||
mode?: "live" | "tour";
|
||||
webhooks: WebhookSummary[];
|
||||
};
|
||||
|
||||
export default function WebhookSection({ mode = "live", webhooks }: WebhookSectionProps) {
|
||||
const [createState, createAction] = useFormState(
|
||||
mode === "tour" ? createWebhookTourAction : createWebhookAction,
|
||||
initialState
|
||||
);
|
||||
const [deleteState, deleteAction] = useFormState(
|
||||
mode === "tour" ? deleteWebhookTourAction : deleteWebhookAction,
|
||||
initialState
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
{mode === "tour" ? (
|
||||
<div className="rounded-2xl border border-stroke bg-white/70 p-5 text-sm text-ink-soft shadow-soft">
|
||||
Tour mode: webhook changes are simulated. No endpoints are saved.
|
||||
</div>
|
||||
) : null}
|
||||
<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://wowcheckout.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