mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
Add fiat invoice inputs to UI
This commit is contained in:
@@ -73,6 +73,8 @@ export async function createInvoiceAction(
|
||||
_prevState: InvoiceState,
|
||||
formData: FormData
|
||||
): Promise<InvoiceState> {
|
||||
const amountMode = String(formData.get("amount_mode") ?? "xmr").trim();
|
||||
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);
|
||||
@@ -92,10 +94,26 @@ export async function createInvoiceAction(
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
const amount = Number(formData.get("amount_xmr"));
|
||||
const amountRaw = String(
|
||||
formData.get(isFiat ? "amount_fiat" : "amount_xmr") ?? ""
|
||||
).trim();
|
||||
const amount = Number(amountRaw);
|
||||
if (Number.isNaN(amount) || amount <= 0) {
|
||||
return {
|
||||
error: "Enter a valid XMR amount.",
|
||||
error: isFiat ? "Enter a valid fiat amount." : "Enter a valid XMR amount.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
recipientName: null,
|
||||
description: null,
|
||||
subaddressIndex: null,
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
const currency = String(formData.get("currency") ?? "").trim().toUpperCase();
|
||||
if (isFiat && !currency) {
|
||||
return {
|
||||
error: "Currency is required when using a fiat amount.",
|
||||
invoiceId: null,
|
||||
address: null,
|
||||
amount: null,
|
||||
@@ -105,7 +123,6 @@ export async function createInvoiceAction(
|
||||
warnings: null,
|
||||
};
|
||||
}
|
||||
const amountRaw = String(formData.get("amount_xmr") ?? "");
|
||||
const recipientName = String(formData.get("recipient_name") ?? "").trim();
|
||||
const description = String(formData.get("description") ?? "").trim();
|
||||
const expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
|
||||
@@ -194,7 +211,9 @@ export async function createInvoiceAction(
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount_xmr: amount,
|
||||
amount_xmr: isFiat ? null : amountRaw,
|
||||
amount_fiat: isFiat ? amountRaw : null,
|
||||
currency: isFiat ? currency : null,
|
||||
confirmation_target: confirmationTarget,
|
||||
expires_at: expiresAt,
|
||||
metadata:
|
||||
@@ -227,13 +246,14 @@ export async function createInvoiceAction(
|
||||
id: string;
|
||||
address: string;
|
||||
subaddress_index?: number | null;
|
||||
amount_xmr: string;
|
||||
warnings?: string[] | null;
|
||||
};
|
||||
return {
|
||||
error: null,
|
||||
invoiceId: data.id,
|
||||
address: data.address,
|
||||
amount: amountRaw,
|
||||
amount: data.amount_xmr,
|
||||
recipientName: recipientName || null,
|
||||
description: description || null,
|
||||
subaddressIndex: data.subaddress_index ?? null,
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
createInvoiceAction,
|
||||
type InvoiceState,
|
||||
} from "../app/(app)/dashboard/actions";
|
||||
import { formatUsdAmount } from "../lib/formatting";
|
||||
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,
|
||||
@@ -45,8 +50,13 @@ export default function CreateInvoiceCard() {
|
||||
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";
|
||||
@@ -69,16 +79,41 @@ export default function CreateInvoiceCard() {
|
||||
|
||||
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, state.amount]);
|
||||
}, [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} XMR`
|
||||
: fiatRateStatus === "loading"
|
||||
? "Fetching estimate..."
|
||||
: "Estimate unavailable";
|
||||
const fiatCurrencyFlag = getCurrencyFlag(fiatCurrency);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -148,6 +183,27 @@ export default function CreateInvoiceCard() {
|
||||
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">XMR</option>
|
||||
<option value="fiat">Fiat</option>
|
||||
</select>
|
||||
</div>
|
||||
{amountMode === "xmr" ? (
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="amount_xmr">
|
||||
Amount (XMR)
|
||||
@@ -179,6 +235,72 @@ export default function CreateInvoiceCard() {
|
||||
</>
|
||||
) : 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 an XMR 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. XMR 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
|
||||
|
||||
@@ -7,6 +7,11 @@ 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,
|
||||
@@ -100,6 +105,8 @@ export default function InvoicePanel({
|
||||
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)
|
||||
);
|
||||
@@ -117,6 +124,9 @@ export default function InvoicePanel({
|
||||
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
|
||||
@@ -192,26 +202,59 @@ export default function InvoicePanel({
|
||||
? new URL(`/invoice/${state.invoiceId}`, createInvoiceOrigin).toString()
|
||||
: `/invoice/${state.invoiceId}`
|
||||
: null;
|
||||
const formattedDraftAmount = amount ? formatXmrAmount(amount) : "";
|
||||
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]);
|
||||
}, [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("");
|
||||
@@ -612,12 +655,33 @@ export default function InvoicePanel({
|
||||
{createStep === 1 ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="wizard_amount">
|
||||
<label className={labelClass} htmlFor="wizard_amount_mode">
|
||||
Amount type
|
||||
</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
id="wizard_amount_mode"
|
||||
name="amount_mode"
|
||||
value={amountMode}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (value === "xmr" || value === "fiat") {
|
||||
setAmountMode(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="xmr">XMR</option>
|
||||
<option value="fiat">Fiat</option>
|
||||
</select>
|
||||
</div>
|
||||
{amountMode === "xmr" ? (
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="wizard_amount_xmr">
|
||||
Amount (XMR)
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
id="wizard_amount"
|
||||
id="wizard_amount_xmr"
|
||||
name="amount_xmr"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
@@ -643,6 +707,77 @@ export default function InvoicePanel({
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_140px]">
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="wizard_amount_fiat">
|
||||
Amount (fiat)
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
id="wizard_amount_fiat"
|
||||
name="amount_fiat"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="100.00"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="wizard_currency">
|
||||
Currency
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
id="wizard_currency"
|
||||
name="currency"
|
||||
type="text"
|
||||
placeholder="USD"
|
||||
list="wizard-fiat-currency-options"
|
||||
value={fiatCurrency}
|
||||
onChange={(event) =>
|
||||
setFiatCurrency(event.target.value.toUpperCase())
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-ink-soft sm:col-span-2">
|
||||
Fiat inputs create an XMR 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. XMR value: {draftXmrEstimateLabel}
|
||||
{fiatCurrencyFlag ? ` · ${fiatCurrencyLabel}` : ""}
|
||||
</p>
|
||||
{draftXmrEstimate ? (
|
||||
<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>
|
||||
)}
|
||||
{amountMode === "fiat" ? (
|
||||
<datalist id="wizard-fiat-currency-options">
|
||||
{FIAT_CURRENCY_SUGGESTIONS.map((code) => (
|
||||
<option
|
||||
key={code}
|
||||
value={code}
|
||||
label={`${getCurrencyFlag(code) ?? ""} ${code}`.trim()}
|
||||
/>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="wizard_confirmation_target">
|
||||
Confirmation target
|
||||
@@ -771,7 +906,15 @@ export default function InvoicePanel({
|
||||
|
||||
{createStep === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
<input type="hidden" name="amount_mode" value={amountMode} />
|
||||
{amountMode === "xmr" ? (
|
||||
<input type="hidden" name="amount_xmr" value={amount} />
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="amount_fiat" value={amount} />
|
||||
<input type="hidden" name="currency" value={fiatCurrency} />
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="hidden"
|
||||
name="confirmation_target"
|
||||
@@ -791,9 +934,31 @@ export default function InvoicePanel({
|
||||
Review details
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-ink">
|
||||
Amount:{" "}
|
||||
<strong>{formattedDraftAmount || "-"} XMR</strong>
|
||||
{amountMode === "xmr" ? "Amount:" : "Fiat amount:"}{" "}
|
||||
<strong>
|
||||
{formattedDraftAmount || "-"}{" "}
|
||||
{amountMode === "xmr" ? "XMR" : fiatCurrencyLabel}
|
||||
</strong>
|
||||
</p>
|
||||
{amountMode === "fiat" ? (
|
||||
<p className="mt-2 text-sm text-ink">
|
||||
Estimated XMR:{" "}
|
||||
<strong>{draftXmrEstimateLabel}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
{amountMode === "fiat" && !isFiatCurrencyValid ? (
|
||||
<p className="mt-2 text-sm font-semibold text-red-700">
|
||||
Currency is required to create a fiat-denominated invoice.
|
||||
</p>
|
||||
) : null}
|
||||
{amountMode === "fiat" &&
|
||||
fiatRateStatus === "error" &&
|
||||
isFiatCurrencyValid ? (
|
||||
<p className="mt-2 text-sm text-amber-800">
|
||||
Estimate unavailable for {fiatCurrencyLabel}. We will validate the
|
||||
currency when the invoice is created.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm text-ink">
|
||||
Confirmations:{" "}
|
||||
<strong>{confirmationTarget || "-"}</strong>
|
||||
@@ -866,6 +1031,7 @@ export default function InvoicePanel({
|
||||
onClick={() => setCreateStep(2)}
|
||||
disabled={
|
||||
!amount ||
|
||||
(amountMode === "fiat" && !fiatCurrency) ||
|
||||
!confirmationTarget ||
|
||||
!expiryIsValid ||
|
||||
(qrLogoMode === "custom" && !qrLogoDataUrl)
|
||||
@@ -883,7 +1049,11 @@ export default function InvoicePanel({
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button className={primaryButton} type="submit">
|
||||
<button
|
||||
className={primaryButton}
|
||||
type="submit"
|
||||
disabled={!isFiatCurrencyValid}
|
||||
>
|
||||
Create invoice
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const FIAT_CURRENCY_SUGGESTIONS = [
|
||||
"USD",
|
||||
"EUR",
|
||||
"GBP",
|
||||
"CAD",
|
||||
"AUD",
|
||||
"NZD",
|
||||
"CHF",
|
||||
"JPY",
|
||||
"CNY",
|
||||
"HKD",
|
||||
"SGD",
|
||||
"INR",
|
||||
"BRL",
|
||||
"MXN",
|
||||
"SEK",
|
||||
"NOK",
|
||||
"DKK",
|
||||
"ZAR",
|
||||
"PLN",
|
||||
"KRW",
|
||||
];
|
||||
|
||||
const CURRENCY_FLAGS: Record<string, string> = {
|
||||
USD: "🇺🇸",
|
||||
EUR: "🇪🇺",
|
||||
GBP: "🇬🇧",
|
||||
CAD: "🇨🇦",
|
||||
AUD: "🇦🇺",
|
||||
NZD: "🇳🇿",
|
||||
CHF: "🇨🇭",
|
||||
JPY: "🇯🇵",
|
||||
CNY: "🇨🇳",
|
||||
HKD: "🇭🇰",
|
||||
SGD: "🇸🇬",
|
||||
INR: "🇮🇳",
|
||||
BRL: "🇧🇷",
|
||||
MXN: "🇲🇽",
|
||||
SEK: "🇸🇪",
|
||||
NOK: "🇳🇴",
|
||||
DKK: "🇩🇰",
|
||||
ZAR: "🇿🇦",
|
||||
PLN: "🇵🇱",
|
||||
KRW: "🇰🇷",
|
||||
};
|
||||
|
||||
export const getCurrencyFlag = (
|
||||
code: string | null | undefined
|
||||
): string | null => {
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
return CURRENCY_FLAGS[code.trim().toUpperCase()] ?? null;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const RATE_TTL_MS = 5 * 60 * 1000;
|
||||
const BASE_URL = "https://api.coingecko.com/api/v3/simple/price";
|
||||
|
||||
type CachedRate = {
|
||||
rate: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const cachedRates = new Map<string, CachedRate>();
|
||||
const inflight = new Map<string, Promise<number | null>>();
|
||||
|
||||
const fetchRate = async (currency: string): Promise<number | null> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
ids: "monero",
|
||||
vs_currencies: currency,
|
||||
});
|
||||
const response = await fetch(`${BASE_URL}?${params.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
monero?: Record<string, number>;
|
||||
};
|
||||
const rate = data?.monero?.[currency];
|
||||
if (typeof rate !== "number" || !Number.isFinite(rate)) {
|
||||
return null;
|
||||
}
|
||||
cachedRates.set(currency, { rate, updatedAt: Date.now() });
|
||||
return rate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type RateStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export const useXmrFiatRate = (currency: string | null) => {
|
||||
const normalized = currency?.trim().toLowerCase() ?? "";
|
||||
const cached = normalized ? cachedRates.get(normalized) : null;
|
||||
const [rate, setRate] = useState<number | null>(cached?.rate ?? null);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(cached?.updatedAt ?? null);
|
||||
const [status, setStatus] = useState<RateStatus>(() => {
|
||||
if (!normalized) {
|
||||
return "idle";
|
||||
}
|
||||
return cached?.rate ? "ready" : "loading";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!normalized) {
|
||||
setRate(null);
|
||||
setUpdatedAt(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cachedEntry = cachedRates.get(normalized);
|
||||
const hasFreshRate =
|
||||
cachedEntry && now - cachedEntry.updatedAt < RATE_TTL_MS;
|
||||
|
||||
if (hasFreshRate) {
|
||||
setRate(cachedEntry.rate);
|
||||
setUpdatedAt(cachedEntry.updatedAt);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
const request =
|
||||
inflight.get(normalized) ??
|
||||
fetchRate(normalized).finally(() => {
|
||||
inflight.delete(normalized);
|
||||
});
|
||||
inflight.set(normalized, request);
|
||||
|
||||
request.then((nextRate) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const nextCached = cachedRates.get(normalized);
|
||||
if (nextRate !== null && nextCached) {
|
||||
setRate(nextCached.rate);
|
||||
setUpdatedAt(nextCached.updatedAt);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [normalized]);
|
||||
|
||||
return { rate, updatedAt, status, source: "coingecko" as const };
|
||||
};
|
||||
Generated
+73
-22
@@ -6,10 +6,13 @@
|
||||
"": {
|
||||
"name": "xmrcheckout-ui",
|
||||
"dependencies": {
|
||||
"doctrine": "^3.0.0",
|
||||
"esutils": "^2.0.3",
|
||||
"next": "15.5.9",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.12",
|
||||
@@ -2373,16 +2376,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
|
||||
"dev": true,
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
@@ -2830,6 +2832,32 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/json5": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -2840,6 +2868,19 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
"integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json5": "^0.0.29",
|
||||
"json5": "^1.0.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y": {
|
||||
"version": "6.10.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
|
||||
@@ -2923,6 +2964,19 @@
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -3104,7 +3158,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4128,16 +4181,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dev": true,
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
@@ -4303,7 +4355,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -5633,7 +5684,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -5914,16 +5964,17 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
"integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
|
||||
"dev": true,
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json5": "^0.0.29",
|
||||
"json5": "^1.0.2",
|
||||
"json5": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
|
||||
+4
-1
@@ -9,10 +9,13 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"doctrine": "^3.0.0",
|
||||
"esutils": "^2.0.3",
|
||||
"next": "15.5.9",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.12",
|
||||
|
||||
Reference in New Issue
Block a user