|
| 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 | + ≈ {convertedAmount} {otherDenom} |
| 191 | + {xlmToUsdcRate && ( |
| 192 | + <span className="ml-1 opacity-60"> |
| 193 | + (1 XLM ≈ {xlmToUsdcRate.toFixed(4)} 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 | +} |
0 commit comments