"use client"; import Link from "next/link"; import { Fragment, useEffect, useMemo, useState } from "react"; import { useFormState } from "react-dom"; import { useRouter } from "next/navigation"; import { formatRelativeTime } from "./relative-time"; import { formatUsdAmount, formatXmrAmount } from "../lib/formatting"; import { useXmrUsdRate } from "../lib/use-xmr-usd-rate"; import { useXmrFiatRate } from "../lib/use-xmr-fiat-rate"; import { FIAT_CURRENCY_SUGGESTIONS, getCurrencyFlag, } from "../lib/fiat-currencies"; import { createInvoiceAction, archiveInvoiceAction, type ArchiveInvoiceState, type InvoiceState, } from "../app/(app)/dashboard/actions"; type InvoiceItem = { id: string; address: string; subaddress_index?: number | null; amount_xmr: string; status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid"; confirmation_target: number; paid_after_expiry?: boolean; paid_after_expiry_at?: string | null; metadata?: Record | null; created_at: string; archived_at: string | null; detected_at: string | null; confirmed_at: string | null; expires_at: string | null; }; type InvoicePanelProps = { activeInvoices: InvoiceItem[]; includeArchived: boolean; searchQuery: string; sort: string; order: string; defaultConfirmationTarget: number; }; const initialState: InvoiceState = { error: null, invoiceId: null, address: null, amount: null, recipientName: null, description: null, subaddressIndex: null, warnings: null, }; const initialArchiveState: ArchiveInvoiceState = { error: null, success: null, archivedId: null, }; const formatStatus = (status: InvoiceItem["status"]) => { if (status === "payment_detected") { return "Payment detected"; } if (status === "pending") { return "Awaiting funds"; } if (status === "confirmed") { return "Confirmed"; } if (status === "invalid") { return "Invalid"; } return "Expired"; }; const formatTimestamp = (value: string | null) => { if (!value) { return "-"; } return new Date(value).toLocaleString(); }; export default function InvoicePanel({ activeInvoices, includeArchived, searchQuery, sort, order, defaultConfirmationTarget, }: InvoicePanelProps) { const router = useRouter(); const [state, formAction] = useFormState(createInvoiceAction, initialState); const [archiveState, archiveAction] = useFormState( archiveInvoiceAction, initialArchiveState ); const [isCreateOpen, setIsCreateOpen] = useState(false); const [expandedInvoiceId, setExpandedInvoiceId] = useState(null); const [createStep, setCreateStep] = useState<1 | 2 | 3>(1); const [formKey, setFormKey] = useState(0); const [amount, setAmount] = useState(""); const [amountMode, setAmountMode] = useState<"xmr" | "fiat">("xmr"); const [fiatCurrency, setFiatCurrency] = useState("USD"); const [confirmationTarget, setConfirmationTarget] = useState( String(defaultConfirmationTarget ?? 10) ); const [expiresDate, setExpiresDate] = useState(""); const [expiresTime, setExpiresTime] = useState(""); const [recipientName, setRecipientName] = useState(""); const [description, setDescription] = useState(""); const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState(null); const [searchInput, setSearchInput] = useState(searchQuery ?? ""); const [qrLogoMode, setQrLogoMode] = useState< "account_default" | "monero" | "none" | "custom" >("account_default"); const [qrLogoDataUrl, setQrLogoDataUrl] = useState(null); const [createInvoiceOrigin, setCreateInvoiceOrigin] = useState( process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/+$/, "") ?? null ); const { rate: usdRate } = useXmrUsdRate(); const { rate: fiatRate, status: fiatRateStatus } = useXmrFiatRate( amountMode === "fiat" ? fiatCurrency : null ); const activeList = activeInvoices; const archivedToggleHref = includeArchived ? "/dashboard?tab=invoices" : "/dashboard?tab=invoices&archived=1"; useEffect(() => { setSearchInput(searchQuery ?? ""); }, [searchQuery]); const buildInvoicesHref = (next: { q?: string; sort?: string; order?: string; includeArchived?: boolean; }) => { const params = new URLSearchParams(); params.set("tab", "invoices"); const nextArchived = next.includeArchived ?? includeArchived; if (nextArchived) { params.set("archived", "1"); } const nextQuery = (next.q ?? searchQuery ?? "").trim(); if (nextQuery) { params.set("q", nextQuery); } const nextSort = next.sort ?? sort; if (nextSort) { params.set("sort", nextSort); } const nextOrder = next.order ?? order; if (nextOrder) { params.set("order", nextOrder); } return `/dashboard?${params.toString()}`; }; const csvExportHref = (() => { const params = new URLSearchParams(); if (includeArchived) { params.set("include_archived", "true"); } const trimmed = (searchQuery ?? "").trim(); if (trimmed) { params.set("q", trimmed); } if (sort) { params.set("sort", sort); } if (order) { params.set("order", order); } const suffix = params.toString(); return suffix ? `/dashboard/invoices.csv?${suffix}` : "/dashboard/invoices.csv"; })(); useEffect(() => { if (createInvoiceOrigin) { return; } setCreateInvoiceOrigin(window.location.origin); }, [createInvoiceOrigin]); useEffect(() => { if (state.invoiceId) { setCreateStep(3); router.refresh(); } }, [state.invoiceId, router]); const publicInvoiceUrl = state.invoiceId ? createInvoiceOrigin ? new URL(`/invoice/${state.invoiceId}`, createInvoiceOrigin).toString() : `/invoice/${state.invoiceId}` : null; const formattedDraftAmount = amountMode === "xmr" && amount ? formatXmrAmount(amount) : amount; const draftAmountValue = useMemo(() => { if (amountMode !== "xmr") { return null; } const parsed = Number.parseFloat(amount); return Number.isFinite(parsed) ? parsed : null; }, [amount, amountMode]); const draftUsdEstimate = useMemo(() => { if (!usdRate || draftAmountValue === null || draftAmountValue <= 0) { return null; } return formatUsdAmount(usdRate * draftAmountValue); }, [usdRate, draftAmountValue]); const fiatAmountValue = useMemo(() => { if (amountMode !== "fiat") { return null; } const parsed = Number.parseFloat(amount); return Number.isFinite(parsed) ? parsed : null; }, [amount, amountMode]); const draftXmrEstimate = 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 draftXmrEstimateLabel = draftXmrEstimate ? `~${draftXmrEstimate} XMR` : fiatRateStatus === "loading" ? "Fetching estimate..." : "Estimate unavailable"; const fiatCurrencyFlag = getCurrencyFlag(fiatCurrency); const fiatCurrencyLabel = fiatCurrencyFlag ? `${fiatCurrencyFlag} ${fiatCurrency}` : fiatCurrency; const expiryTimeValue = expiresTime || "00:00"; const expiryValue = expiresDate ? `${expiresDate}T${expiryTimeValue}` : ""; const expiryIsValid = (!expiresDate && !expiresTime) || !!expiresDate; const isFiatCurrencyValid = amountMode !== "fiat" || Boolean(fiatCurrency.trim()); const openCreate = () => { setIsCreateOpen(true); setCreateStep(1); setFormKey((prev) => prev + 1); setAmount(""); setAmountMode("xmr"); setFiatCurrency("USD"); setConfirmationTarget(String(defaultConfirmationTarget ?? 10)); setExpiresDate(""); setExpiresTime(""); setRecipientName(""); setDescription(""); setQrLogoMode("account_default"); setQrLogoDataUrl(null); }; const closeCreate = () => { setIsCreateOpen(false); setCreateStep(1); setFormKey((prev) => prev + 1); }; const handleQrLogoFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) { setQrLogoDataUrl(null); return; } const reader = new FileReader(); reader.onload = () => { const result = reader.result; if (typeof result === "string") { setQrLogoDataUrl(result); } }; reader.readAsDataURL(file); }; const toggleInvoice = (invoiceId: string) => { setExpandedInvoiceId((prev) => (prev === invoiceId ? null : invoiceId)); }; const openArchiveModal = (invoiceId: string) => { setArchiveModalInvoiceId(invoiceId); }; const closeArchiveModal = () => { setArchiveModalInvoiceId(null); }; useEffect(() => { if (archiveState.archivedId && expandedInvoiceId === archiveState.archivedId) { setExpandedInvoiceId(null); setArchiveModalInvoiceId(null); router.refresh(); } }, [archiveState.archivedId, expandedInvoiceId, router]); const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"; const inputClass = "w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"; const primaryButton = "inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70"; const secondaryButton = "inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70"; const smallSecondaryButton = "inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70"; const sortHref = (key: string) => { const nextOrder = sort === key && order === "asc" ? "desc" : "asc"; return buildInvoicesHref({ sort: key, order: nextOrder }); }; const applySearch = () => { router.push(buildInvoicesHref({ q: searchInput })); }; return (

Invoices

Search, sort, and expand invoices for details.

{includeArchived ? "Hide archived" : "Show archived"} Export CSV
{ event.preventDefault(); applySearch(); }} >
setSearchInput(event.target.value)} />
{searchQuery ? ( Clear ) : null}
{activeList.length === 0 ? (

No invoices match your filters.

) : ( <>
{activeList.map((invoice) => { const isExpanded = expandedInvoiceId === invoice.id; const showPaidAfterExpiry = Boolean(invoice.paid_after_expiry); const isArchivable = !invoice.archived_at && (invoice.status === "pending" || invoice.status === "expired" || invoice.status === "invalid"); return (
{invoice.archived_at ? ( Archived ) : null} {showPaidAfterExpiry ? ( Paid after expiry ) : null} {formatStatus(invoice.status)}

Amount

{formatXmrAmount(invoice.amount_xmr)} XMR

Created

{formatTimestamp(invoice.created_at)}

Expires

{formatTimestamp(invoice.expires_at)}

Open
{isExpanded ? (

Created at

{formatTimestamp(invoice.created_at)}

Detected at

{formatTimestamp(invoice.detected_at)}

Confirmed at

{formatTimestamp(invoice.confirmed_at)}

Expires at

{formatTimestamp(invoice.expires_at)}

Confirmation target

{invoice.confirmation_target}

Subaddress index

{invoice.subaddress_index ?? "-"}

{invoice.archived_at ? (

Archived at

{formatTimestamp(invoice.archived_at)}

) : null} {invoice.paid_after_expiry_at ? (

Paid after expiry at

{formatTimestamp(invoice.paid_after_expiry_at)}

) : null}
{isArchivable ? ( ) : (

Invoice actions are unavailable for this status.

)} {archiveState.error && archiveModalInvoiceId === invoice.id ? (

{archiveState.error}

) : null} {archiveState.archivedId === invoice.id ? (

Invoice archived.

) : null}
) : null}
); })}
{activeList.map((invoice) => { const isExpanded = expandedInvoiceId === invoice.id; const showPaidAfterExpiry = Boolean(invoice.paid_after_expiry); const isArchivable = !invoice.archived_at && (invoice.status === "pending" || invoice.status === "expired" || invoice.status === "invalid"); return ( {isExpanded ? ( ) : null} ); })}
Invoice Amount Status Created Expires Actions
{invoice.archived_at ? ( Archived ) : null} {showPaidAfterExpiry ? ( Paid after expiry ) : null}
{formatXmrAmount(invoice.amount_xmr)} XMR {formatStatus(invoice.status)} {formatTimestamp(invoice.created_at)} {formatTimestamp(invoice.expires_at)}
Open

Created at

{formatTimestamp(invoice.created_at)}

Detected at

{formatTimestamp(invoice.detected_at)}

Confirmed at

{formatTimestamp(invoice.confirmed_at)}

Expires at

{formatTimestamp(invoice.expires_at)}

Confirmation target

{invoice.confirmation_target}

Subaddress index

{invoice.subaddress_index ?? "-"}

{invoice.archived_at ? (

Archived at

{formatTimestamp(invoice.archived_at)}

) : null} {invoice.paid_after_expiry_at ? (

Paid after expiry at

{formatTimestamp(invoice.paid_after_expiry_at)}

) : null}
{isArchivable ? ( ) : (

Invoice actions are unavailable for this status.

)} {archiveState.error && archiveModalInvoiceId === invoice.id ? (

{archiveState.error}

) : null} {archiveState.success && archiveState.archivedId === invoice.id ? (

{archiveState.success}

) : null}
)}
We never hold funds. All payments move from the customer to your wallet.
{isCreateOpen ? (

Create invoice

Create a new invoice

{[ { id: 1, label: "1. Details" }, { id: 2, label: "2. Review" }, { id: 3, label: "3. Confirmation" }, ].map((step) => ( {step.label} ))}
{createStep === 1 ? (
{amountMode === "xmr" ? (
setAmount(event.target.value)} required /> {draftUsdEstimate ? ( <>

Approx. USD reference: ~{draftUsdEstimate}

About this estimate

Reference only, uses CoinGecko spot rate. Not a quote or guarantee.

) : null}
) : (
setAmount(event.target.value)} required />
setFiatCurrency(event.target.value.toUpperCase()) } required />

Fiat inputs create an XMR invoice using a non-binding rate at request time.

{showFiatEstimate ? (

Approx. XMR value: {draftXmrEstimateLabel} {fiatCurrencyFlag ? ` · ${fiatCurrencyLabel}` : ""}

{draftXmrEstimate ? (
About this estimate

Reference only, uses CoinGecko spot rate. Not a quote or guarantee.

) : null}
) : null}
)} {amountMode === "fiat" ? ( {FIAT_CURRENCY_SUGGESTIONS.map((code) => ( ) : null}
setConfirmationTarget(event.target.value)} required />
setExpiresDate(event.target.value)} /> setExpiresTime(event.target.value)} />

Leave blank to keep the default 60-minute expiry.

setRecipientName(event.target.value)} />
setDescription(event.target.value)} />

The logo overlays the QR center. Custom images are stored on the invoice as a data URL.

{qrLogoMode === "custom" ? ( ) : null} {qrLogoMode === "custom" && qrLogoDataUrl ? ( ) : null}
) : null} {createStep === 2 ? (
{amountMode === "xmr" ? ( ) : ( <> )}

Review details

{amountMode === "xmr" ? "Amount:" : "Fiat amount:"}{" "} {formattedDraftAmount || "-"}{" "} {amountMode === "xmr" ? "XMR" : fiatCurrencyLabel}

{amountMode === "fiat" ? (

Estimated XMR:{" "} {draftXmrEstimateLabel}

) : null} {amountMode === "fiat" && !isFiatCurrencyValid ? (

Currency is required to create a fiat-denominated invoice.

) : null} {amountMode === "fiat" && fiatRateStatus === "error" && isFiatCurrencyValid ? (

Estimate unavailable for {fiatCurrencyLabel}. We will validate the currency when the invoice is created.

) : null}

Confirmations:{" "} {confirmationTarget || "-"}

Expiry:{" "} {expiryValue ? new Date( expiryValue ).toLocaleString() : "Default (60 minutes)"}

QR logo: {qrLogoMode.replace("_", " ")}

Recipient: {recipientName || "-"}

Description: {description || "-"}

) : null} {createStep === 3 ? (
{state.error ? (

{state.error}

) : null} {publicInvoiceUrl ? (

Invoice created

Your invoice has been created. You can view and share the invoice link with your customer here:

{publicInvoiceUrl}

) : null}
) : null} {state.error && createStep !== 3 ? (

{state.error}

) : null}
{createStep === 1 ? ( ) : null} {createStep === 2 ? ( <> ) : null} {createStep === 3 ? ( ) : null}
) : null} {archiveModalInvoiceId ? (

Archive invoice

Archive this invoice?

Archived invoices stay available in the archive list.

) : null}
); }