From 0148caf501d013f8c924ad1a0a7df6b701e07530 Mon Sep 17 00:00:00 2001 From: xmrcheckout Date: Thu, 8 Jan 2026 14:33:21 +0000 Subject: [PATCH] Redesign create invoice modal --- ui/app/(app)/dashboard/actions.ts | 36 +- ui/components/invoice-panel.tsx | 1166 ++++++++++++++--------------- 2 files changed, 599 insertions(+), 603 deletions(-) diff --git a/ui/app/(app)/dashboard/actions.ts b/ui/app/(app)/dashboard/actions.ts index b967da3..d7d65b8 100644 --- a/ui/app/(app)/dashboard/actions.ts +++ b/ui/app/(app)/dashboard/actions.ts @@ -77,22 +77,24 @@ export async function createInvoiceAction( const isFiat = amountMode === "fiat"; const instantConfirmation = formData.get("instant_confirmation") === "1"; const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim(); - const confirmationTarget = instantConfirmation ? 0 : Number(confirmationRaw); - if ( - Number.isNaN(confirmationTarget) || - confirmationTarget < 0 || - confirmationTarget > 10 - ) { - return { - error: "Confirmation target must be between 0 and 10.", - invoiceId: null, - address: null, - amount: null, - recipientName: null, - description: null, - subaddressIndex: null, - warnings: null, - }; + let confirmationTarget: number | null = null; + if (instantConfirmation) { + confirmationTarget = 0; + } else if (confirmationRaw) { + const parsed = Number(confirmationRaw); + if (Number.isNaN(parsed) || parsed < 0 || parsed > 10) { + return { + error: "Confirmation target must be between 0 and 10.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + confirmationTarget = parsed; } const amountRaw = String( formData.get(isFiat ? "amount_fiat" : "amount_xmr") ?? "" @@ -245,7 +247,7 @@ export async function createInvoiceAction( amount_xmr: isFiat ? null : amountRaw, amount_fiat: isFiat ? amountRaw : null, currency: isFiat ? currency : null, - confirmation_target: confirmationTarget, + ...(confirmationTarget !== null ? { confirmation_target: confirmationTarget } : {}), expires_at: expiresAt, checkout_continue_url: checkoutContinueUrlRaw || null, metadata: diff --git a/ui/components/invoice-panel.tsx b/ui/components/invoice-panel.tsx index 2e6a5f8..0ae2747 100644 --- a/ui/components/invoice-panel.tsx +++ b/ui/components/invoice-panel.tsx @@ -86,37 +86,29 @@ const formatTimestamp = (value: string | null) => { return new Date(value).toLocaleString(); }; -export default function InvoicePanel({ - activeInvoices, - includeArchived, - searchQuery, - sort, - order, - defaultConfirmationTarget, -}: InvoicePanelProps) { +type CreateInvoiceModalProps = { + defaultConfirmationTarget: number; + onClose: () => void; +}; + +function CreateInvoiceModal({ defaultConfirmationTarget, onClose }: CreateInvoiceModalProps) { 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 [confirmationMode, setConfirmationMode] = useState<"account_default" | "custom">( + "account_default" + ); const [confirmationTarget, setConfirmationTarget] = useState( String(defaultConfirmationTarget ?? 10) ); + const [expiryMode, setExpiryMode] = useState<"default" | "custom">("default"); const [expiresDate, setExpiresDate] = useState(""); const [expiresTime, setExpiresTime] = useState(""); const [recipientName, setRecipientName] = useState(""); const [description, setDescription] = useState(""); const [checkoutContinueUrl, setCheckoutContinueUrl] = useState(""); - const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState(null); - const [searchInput, setSearchInput] = useState(searchQuery ?? ""); const [qrLogoMode, setQrLogoMode] = useState< "account_default" | "monero" | "none" | "custom" >("account_default"); @@ -129,6 +121,567 @@ export default function InvoicePanel({ amountMode === "fiat" ? fiatCurrency : null ); + useEffect(() => { + if (createInvoiceOrigin) { + return; + } + setCreateInvoiceOrigin(window.location.origin); + }, [createInvoiceOrigin]); + + useEffect(() => { + if (!state.invoiceId) { + return; + } + router.refresh(); + }, [router, state.invoiceId]); + + 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 = + expiryMode !== "custom" || (!expiresTime && !expiresDate) || !!expiresDate; + const isFiatCurrencyValid = amountMode !== "fiat" || Boolean(fiatCurrency.trim()); + + 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 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 createDisabled = + !amount || + !isFiatCurrencyValid || + (confirmationMode === "custom" && !confirmationTarget) || + !expiryIsValid || + (qrLogoMode === "custom" && !qrLogoDataUrl); + + return ( +
+
+
+
+

+ Create invoice +

+

Create a new invoice

+
+ +
+ +
+ {state.invoiceId ? ( +
+ {publicInvoiceUrl ? ( +
+

+ Invoice created +

+

+ Share this invoice link with your customer: +

+

+ + {publicInvoiceUrl} + +

+ {state.warnings && state.warnings.length > 0 ? ( +
+ {state.warnings.map((warning) => ( +

{warning}

+ ))} +
+ ) : null} +
+ ) : null} + + {state.error ? ( +

+ {state.error} +

+ ) : null} + +
+ +
+
+ ) : ( + <> +
+
+
+
+ + +
+ + {amountMode === "xmr" ? ( +
+ + setAmount(event.target.value)} + required + /> + {draftUsdEstimate ? ( +
+ + Approx. USD reference: ~{draftUsdEstimate} + +

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

+
+ ) : null} +
+ ) : ( +
+
+ + setAmount(event.target.value)} + required + /> +
+
+ + + setFiatCurrency(event.target.value.toUpperCase()) + } + required + /> +
+ + {FIAT_CURRENCY_SUGGESTIONS.map((code) => ( + +

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

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

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

+
+ ) : null} +
+ )} +
+ +
+ + Optional details + +
+
+

Confirmations

+
+
+ + +
+ {confirmationMode === "custom" ? ( +
+ + setConfirmationTarget(event.target.value)} + required + /> +
+ ) : null} +
+ {confirmationMode === "custom" ? ( +
+ +

+ Create the invoice with a custom confirmation target. +

+
+ ) : null} +
+ +
+

Expiry

+
+ + +
+ + {expiryMode === "custom" ? ( +
+ +
+ setExpiresDate(event.target.value)} + /> + setExpiresTime(event.target.value)} + /> +
+ +
+ ) : null} +
+ +
+
+ + setRecipientName(event.target.value)} + /> +
+
+ + setDescription(event.target.value)} + /> +
+
+ +
+ + setCheckoutContinueUrl(event.target.value)} + /> +

+ After confirmation, the hosted invoice page can show a Continue button. +

+
+ +
+ + + + {qrLogoMode === "custom" ? ( + + ) : null} + {qrLogoMode === "custom" && qrLogoDataUrl ? ( + + ) : null} +
+
+
+ +
+

Summary

+

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

+
+ + {state.error ? ( +

+ {state.error} +

+ ) : null} +
+
+ +
+ + +
+ + )} +
+
+
+ ); +} + +export default function InvoicePanel({ + activeInvoices, + includeArchived, + searchQuery, + sort, + order, + defaultConfirmationTarget, +}: InvoicePanelProps) { + const router = useRouter(); + const [archiveState, archiveAction] = useFormState( + archiveInvoiceAction, + initialArchiveState + ); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [createModalKey, setCreateModalKey] = useState(0); + const [expandedInvoiceId, setExpandedInvoiceId] = useState(null); + const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState(null); + const [searchInput, setSearchInput] = useState(searchQuery ?? ""); + const activeList = activeInvoices; const archivedToggleHref = includeArchived ? "/dashboard?tab=invoices" @@ -184,108 +737,13 @@ export default function InvoicePanel({ 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(""); - setCheckoutContinueUrl(""); - setQrLogoMode("account_default"); - setQrLogoDataUrl(null); + setCreateModalKey((prev) => prev + 1); }; 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) => { @@ -817,478 +1275,14 @@ export default function InvoicePanel({ {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)} - /> -
-
- - setCheckoutContinueUrl(event.target.value)} - /> -

- After confirmation, the hosted invoice page can show a Continue button. -

-
-
- - -

- 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 || "-"} -

-

- Continue URL: {checkoutContinueUrl || "-"} -

-
-
- ) : 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 ? (