Skip to content

Commit 6c20dec

Browse files
feat(#476): add XLM/USDC denomination toggle to amount input (#534)
- Add AmountDenominationInput component with inline XLM/USDC toggle - Add useXlmUsdcRate hook to fetch live XLM price vs USDC - Integrate denomination toggle into invoice creation form: - Total amount field (equal-split mode) uses AmountDenominationInput - Per-recipient amounts section shows denomination badge with toggle - Auto-converts entered value when switching denominations - Converts XLM amounts to USDC before on-chain submission - Shows live conversion hint (e.g. ≈ 0.1234 USDC) below input - Gracefully handles unavailable rate (clears value, shows hint) Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 15caa23 commit 6c20dec

3 files changed

Lines changed: 313 additions & 5 deletions

File tree

src/app/invoice/new/page.tsx

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
type SplitMeta,
3535
} from "@/hooks/useSplitCalculator";
3636
import InstallmentPlanBuilder from "@/components/invoice/InstallmentPlanBuilder";
37+
import AmountDenominationInput from "@/components/AmountDenominationInput";
38+
import { useXlmUsdcRate } from "@/hooks/useXlmUsdcRate";
3739

3840
import { useInvoiceCollaboration } from "@/hooks/useInvoiceCollaboration";
3941
import CursorOverlay from "@/components/CursorOverlay";
@@ -305,6 +307,22 @@ function NewInvoiceForm() {
305307
const [autofilled, setAutofilled] = useState(false);
306308
const [stepErrors, setStepErrors] = useState<Record<number, string | null>>({});
307309

310+
// Denomination toggle state (XLM / USDC)
311+
type Denomination = "XLM" | "USDC";
312+
const [amountDenom, setAmountDenom] = useState<Denomination>("USDC");
313+
const xlmUsdcRate = useXlmUsdcRate();
314+
315+
/** Convert an amount string from current denomination to USDC for on-chain use */
316+
const toUsdc = useCallback(
317+
(amount: string): string => {
318+
if (amountDenom === "USDC" || !xlmUsdcRate) return amount;
319+
const n = parseFloat(amount);
320+
if (isNaN(n)) return amount;
321+
return (n * xlmUsdcRate).toFixed(7).replace(/\.?0+$/, "");
322+
},
323+
[amountDenom, xlmUsdcRate],
324+
);
325+
308326
useEffect(() => {
309327
getFreighterPublicKey().then(setPublicKey).catch(() => null);
310328
}, []);
@@ -494,7 +512,7 @@ function NewInvoiceForm() {
494512
sourceInvoiceId: cloneSourceId,
495513
recipients: recipients.map((r) => ({
496514
address: r.address,
497-
amount: parseAmount(r.amount),
515+
amount: parseAmount(toUsdc(r.amount)),
498516
})),
499517
token,
500518
deadline: deadlineTs,
@@ -521,7 +539,7 @@ function NewInvoiceForm() {
521539
creator,
522540
recipients: recipients.map((r) => ({
523541
address: r.address,
524-
amount: parseAmount(equalSplit ? (perRecipientAmount ?? "0") : r.amount),
542+
amount: parseAmount(equalSplit ? toUsdc(perRecipientAmount ?? "0") : toUsdc(r.amount)),
525543
})),
526544
token,
527545
deadline: deadlineFromDays(deadlineDays),
@@ -768,9 +786,36 @@ function NewInvoiceForm() {
768786
)}
769787

770788
<div>
771-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
772-
{equalSplit ? t("invoiceNew.recipients") : t("invoiceNew.recipientsAndAmounts")}
773-
</label>
789+
<div className="flex items-center justify-between mb-2">
790+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
791+
{equalSplit ? t("invoiceNew.recipients") : t("invoiceNew.recipientsAndAmounts")}
792+
</label>
793+
{!equalSplit && !cloneSourceId && (
794+
<div className="flex items-center gap-1.5">
795+
<span className="text-xs text-gray-500">Amounts in:</span>
796+
<button
797+
type="button"
798+
onClick={() => setAmountDenom((d) => d === "XLM" ? "USDC" : "XLM")}
799+
aria-label={`Switch amount denomination to ${amountDenom === "XLM" ? "USDC" : "XLM"}`}
800+
title={xlmUsdcRate ? `1 XLM ≈ ${xlmUsdcRate.toFixed(4)} USDC` : "Rate unavailable"}
801+
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold border transition-colors ${
802+
amountDenom === "XLM"
803+
? "bg-yellow-500/15 border-yellow-500/40 text-yellow-300 hover:bg-yellow-500/25"
804+
: "bg-blue-500/15 border-blue-500/40 text-blue-300 hover:bg-blue-500/25"
805+
}`}
806+
>
807+
<span aria-hidden="true">{amountDenom === "XLM" ? "✦" : "$"}</span>
808+
{amountDenom}
809+
<svg xmlns="http://www.w3.org/2000/svg" className="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
810+
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4" />
811+
</svg>
812+
</button>
813+
{xlmUsdcRate && (
814+
<span className="text-xs text-gray-500">1 XLM ≈ {xlmUsdcRate.toFixed(4)} USDC</span>
815+
)}
816+
</div>
817+
)}
818+
</div>
774819
<ChangedField changed={recipientsChanged}>
775820
<RecipientForm
776821
recipients={recipients}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"use client";
2+
3+
import { useState, useEffect, useCallback, useId } from "react";
4+
5+
type Denomination = "XLM" | "USDC";
6+
7+
interface AmountDenominationInputProps {
8+
/** The current amount string in the active denomination */
9+
value: string;
10+
/** Called with the new amount string (in the active denomination) on change */
11+
onChange: (value: string) => void;
12+
/** The current denomination ("XLM" | "USDC") */
13+
denomination: Denomination;
14+
/** Called when the denomination is switched */
15+
onDenominationChange: (denom: Denomination) => void;
16+
/** XLM/USDC exchange rate: 1 XLM = rate USDC. null = unavailable */
17+
xlmToUsdcRate: number | null;
18+
/** Whether the input is disabled */
19+
disabled?: boolean;
20+
/** Optional error message */
21+
error?: string;
22+
/** Input label */
23+
label?: string;
24+
/** Whether the field is required */
25+
required?: boolean;
26+
}
27+
28+
/**
29+
* AmountDenominationInput
30+
*
31+
* An amount input with an inline XLM / USDC toggle. When the user switches
32+
* denomination the current value is automatically converted using the live
33+
* exchange rate. If the rate is unavailable the field is cleared rather than
34+
* showing a stale converted value.
35+
*/
36+
export default function AmountDenominationInput({
37+
value,
38+
onChange,
39+
denomination,
40+
onDenominationChange,
41+
xlmToUsdcRate,
42+
disabled = false,
43+
error,
44+
label = "Amount",
45+
required = false,
46+
}: AmountDenominationInputProps) {
47+
const inputId = useId();
48+
const convertedId = useId();
49+
50+
// Compute the equivalent amount in the other denomination for the hint label
51+
const otherDenom: Denomination = denomination === "XLM" ? "USDC" : "XLM";
52+
const convertedAmount = (() => {
53+
if (!xlmToUsdcRate || !value || isNaN(parseFloat(value))) return null;
54+
const n = parseFloat(value);
55+
if (denomination === "XLM") {
56+
return (n * xlmToUsdcRate).toFixed(4);
57+
} else {
58+
return (n / xlmToUsdcRate).toFixed(4);
59+
}
60+
})();
61+
62+
const handleToggle = useCallback(() => {
63+
const next: Denomination = denomination === "XLM" ? "USDC" : "XLM";
64+
65+
if (!xlmToUsdcRate || !value || isNaN(parseFloat(value))) {
66+
// Can't convert — clear the value and switch denomination
67+
onDenominationChange(next);
68+
onChange("");
69+
return;
70+
}
71+
72+
const n = parseFloat(value);
73+
let converted: number;
74+
if (denomination === "XLM") {
75+
converted = n * xlmToUsdcRate;
76+
} else {
77+
converted = n / xlmToUsdcRate;
78+
}
79+
80+
onDenominationChange(next);
81+
onChange(converted.toFixed(7).replace(/\.?0+$/, ""));
82+
}, [denomination, value, xlmToUsdcRate, onDenominationChange, onChange]);
83+
84+
const borderCls = error
85+
? "border-red-500 focus-within:ring-red-500"
86+
: "border-gray-600 hover:border-gray-500 focus-within:ring-indigo-500";
87+
88+
return (
89+
<div className="flex flex-col gap-1">
90+
{label && (
91+
<label
92+
htmlFor={inputId}
93+
className="text-sm font-medium text-gray-200"
94+
>
95+
{label}
96+
{required && (
97+
<span className="ml-1 text-red-400" aria-hidden="true">
98+
*
99+
</span>
100+
)}
101+
</label>
102+
)}
103+
104+
{/* Input + toggle pill */}
105+
<div
106+
className={`flex items-stretch w-full min-h-11 rounded-lg border bg-gray-800 transition-colors focus-within:outline-none focus-within:ring-2 ${borderCls} overflow-hidden`}
107+
>
108+
<input
109+
id={inputId}
110+
type="number"
111+
min="0"
112+
step="any"
113+
value={value}
114+
onChange={(e) => onChange(e.target.value)}
115+
disabled={disabled}
116+
required={required}
117+
placeholder="0.0000000"
118+
aria-required={required}
119+
aria-invalid={!!error}
120+
aria-describedby={
121+
error
122+
? `${inputId}-error`
123+
: convertedAmount
124+
? convertedId
125+
: undefined
126+
}
127+
className="flex-1 min-w-0 bg-transparent px-4 py-2 text-sm text-gray-100 placeholder-gray-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
128+
/>
129+
130+
{/* Denomination toggle button */}
131+
<button
132+
type="button"
133+
onClick={handleToggle}
134+
disabled={disabled}
135+
aria-label={`Switch to ${otherDenom}`}
136+
title={
137+
xlmToUsdcRate
138+
? `Switch to ${otherDenom} (1 XLM ≈ ${xlmToUsdcRate.toFixed(4)} USDC)`
139+
: `Switch to ${otherDenom} (rate unavailable)`
140+
}
141+
className={[
142+
"flex items-center gap-1 shrink-0 px-3 py-2 border-l border-gray-600",
143+
"text-xs font-semibold tracking-wide transition-colors select-none",
144+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500",
145+
disabled
146+
? "cursor-not-allowed opacity-50 text-gray-500"
147+
: denomination === "XLM"
148+
? "text-yellow-300 hover:bg-yellow-500/10 active:bg-yellow-500/20"
149+
: "text-blue-300 hover:bg-blue-500/10 active:bg-blue-500/20",
150+
].join(" ")}
151+
>
152+
{/* Small currency icon */}
153+
<span
154+
className={`inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] font-bold ${
155+
denomination === "XLM"
156+
? "bg-yellow-500/20 text-yellow-300"
157+
: "bg-blue-500/20 text-blue-300"
158+
}`}
159+
aria-hidden="true"
160+
>
161+
{denomination === "XLM" ? "✦" : "$"}
162+
</span>
163+
{denomination}
164+
{/* Swap arrows */}
165+
<svg
166+
xmlns="http://www.w3.org/2000/svg"
167+
className="w-3 h-3 opacity-60"
168+
fill="none"
169+
viewBox="0 0 24 24"
170+
stroke="currentColor"
171+
strokeWidth={2.5}
172+
aria-hidden="true"
173+
>
174+
<path
175+
strokeLinecap="round"
176+
strokeLinejoin="round"
177+
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
178+
/>
179+
</svg>
180+
</button>
181+
</div>
182+
183+
{/* Converted amount hint */}
184+
{convertedAmount && !error && (
185+
<p
186+
id={convertedId}
187+
className="text-xs text-gray-400"
188+
aria-live="polite"
189+
>
190+
≈&nbsp;{convertedAmount}&nbsp;{otherDenom}
191+
{xlmToUsdcRate && (
192+
<span className="ml-1 opacity-60">
193+
(1&nbsp;XLM&nbsp;≈&nbsp;{xlmToUsdcRate.toFixed(4)}&nbsp;USDC)
194+
</span>
195+
)}
196+
</p>
197+
)}
198+
199+
{/* Rate unavailable hint */}
200+
{!xlmToUsdcRate && !error && (
201+
<p className="text-xs text-gray-500">Rate unavailable — no auto-conversion</p>
202+
)}
203+
204+
{/* Error */}
205+
{error && (
206+
<p id={`${inputId}-error`} className="text-xs text-red-400" role="alert">
207+
{error}
208+
</p>
209+
)}
210+
</div>
211+
);
212+
}

src/hooks/useXlmUsdcRate.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client";
2+
3+
import { useState, useEffect, useRef } from "react";
4+
5+
const REFRESH_MS = 60_000;
6+
7+
/**
8+
* useXlmUsdcRate
9+
*
10+
* Returns the current XLM/USDC exchange rate (i.e. how many USDC one XLM is worth).
11+
* Polls CoinGecko's simple price API via the app's own rate proxy for XLM price in USD,
12+
* and since USDC ≈ 1 USD, uses xlmUsd as the XLM/USDC rate.
13+
*
14+
* Returns null while loading or when the rate is unavailable.
15+
*/
16+
export function useXlmUsdcRate(): number | null {
17+
const [rate, setRate] = useState<number | null>(null);
18+
const mounted = useRef(true);
19+
20+
useEffect(() => {
21+
mounted.current = true;
22+
23+
const load = async () => {
24+
try {
25+
// Fetch XLM price in USD from CoinGecko directly.
26+
// Since USDC ≈ $1.00, XLM/USDC ≈ XLM/USD.
27+
const res = await fetch(
28+
"https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd",
29+
{ signal: AbortSignal.timeout(8000) }
30+
);
31+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
32+
const data = await res.json();
33+
const xlmUsd: number | undefined = data?.stellar?.usd;
34+
if (typeof xlmUsd === "number" && xlmUsd > 0 && mounted.current) {
35+
setRate(xlmUsd);
36+
}
37+
} catch {
38+
// Leave rate unchanged on error — don't clear a valid cached rate
39+
}
40+
};
41+
42+
load();
43+
const timer = setInterval(load, REFRESH_MS);
44+
return () => {
45+
mounted.current = false;
46+
clearInterval(timer);
47+
};
48+
}, []);
49+
50+
return rate;
51+
}

0 commit comments

Comments
 (0)