diff --git a/src/components/AmountEditable.tsx b/src/components/AmountEditable.tsx index c51b66e..b56a5b7 100644 --- a/src/components/AmountEditable.tsx +++ b/src/components/AmountEditable.tsx @@ -1,7 +1,7 @@ -import { For, ParentComponent, Show, createMemo, createResource, createSignal } from "solid-js"; +import { For, ParentComponent, Show, createResource, createSignal, onMount } from "solid-js"; import { Button } from "~/components/layout"; import { useMegaStore } from "~/state/megaStore"; -import { satsToUsd } from "~/utils/conversions"; +import { satsToUsd, usdToSats } from "~/utils/conversions"; import { Dialog } from "@kobalte/core"; import close from "~/assets/icons/close.svg"; import pencil from "~/assets/icons/pencil.svg"; @@ -12,12 +12,43 @@ import { Network } from "~/logic/mutinyWalletSetup"; const CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "DEL"]; -const FIXED_AMOUNTS = [ +const FIXED_AMOUNTS_SATS = [ { label: "10k", amount: "10000" }, { label: "100k", amount: "100000" }, { label: "1m", amount: "1000000" } ]; +const FIXED_AMOUNTS_USD = [ + { label: "$1", amount: "1" }, + { label: "$10", amount: "10" }, + { label: "$100", amount: "100" } +]; + +function fiatInputSanitizer(input: string): string { + // Make sure only numbers and a single decimal point are allowed + const numeric = input.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1"); + + // Remove leading zeros if not a decimal, add 0 if starts with a decimal + const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0."); + + // If there are three characters after the decimal, shift the decimal + const shifted = cleaned.match(/(\.[0-9]{3}).*/g) ? (parseFloat(cleaned) * 10).toFixed(2) : cleaned; + + // Truncate any numbers two past the decimal + const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1"); + + return twoDecimals; +} + +function satsInputSanitizer(input: string): string { + // Make sure only numbers are allowed + const numeric = input.replace(/[^0-9]/g, ""); + // If it starts with a 0, remove the 0 + const noLeadingZero = numeric.replace(/^0([^.]|$)/g, "$1"); + + return noLeadingZero; +} + function SingleDigitButton(props: { character: string; onClick: (c: string) => void; @@ -27,7 +58,6 @@ function SingleDigitButton(props: { // Skip the "." if it's fiat }> props.onClick(props.character)} > @@ -37,18 +67,65 @@ function SingleDigitButton(props: { ); } +function BigScalingText(props: { text: string; fiat: boolean }) { + const chars = () => props.text.length; + + return ( + 9, + "scale-95": chars() > 8, + "scale-100": chars() > 7, + "scale-105": chars() > 6, + "scale-110": chars() > 5, + "scale-125": chars() > 4, + "scale-150": chars() <= 4 + }} + > + {props.text} {props.fiat ? "USD" : "SATS"} + + ); +} + +function SmallSubtleAmount(props: { text: string; fiat: boolean }) { + return ( + + ≈ {props.text} {props.fiat ? "USD" : "SATS"} + + ); +} + +function toDisplayHandleNaN(input: string, _fiat: boolean): string { + const parsed = Number(input); + if (isNaN(parsed)) { + return "0"; + } else { + return parsed.toLocaleString(); + } +} + export const AmountEditable: ParentComponent<{ initialAmountSats: string; initialOpen: boolean; setAmountSats: (s: bigint) => void; }> = (props) => { const [isOpen, setIsOpen] = createSignal(props.initialOpen); - const [store, _actions] = useMegaStore(); + const [state, _actions] = useMegaStore(); + const [mode, setMode] = createSignal<"fiat" | "sats">("sats"); + const [localSats, setLocalSats] = createSignal(props.initialAmountSats || "0"); + const [localFiat, setLocalFiat] = createSignal( + satsToUsd(state.price, parseInt(props.initialAmountSats || "0") || 0, false) + ); - const [displayAmount, setDisplayAmount] = createSignal(props.initialAmountSats || "0"); + const displaySats = () => toDisplayHandleNaN(localSats(), false); + const displayFiat = () => `$${toDisplayHandleNaN(localFiat(), true)}`; + + let satsInputRef!: HTMLInputElement; + let fiatInputRef!: HTMLInputElement; const [inboundCapacity] = createResource(async () => { - const channels = await store.mutiny_wallet?.list_channels(); + const channels = await state.mutiny_wallet?.list_channels(); let inbound = 0; for (const channel of channels) { @@ -59,7 +136,7 @@ export const AmountEditable: ParentComponent<{ }); const warningText = () => { - if ((store.balance?.lightning || 0n) === 0n) { + if ((state.balance?.lightning || 0n) === 0n) { const network = state.mutiny_wallet?.get_network() as Network; if (network === "bitcoin") { return "Your first lightning receive needs to be 50,000 sats or greater."; @@ -68,7 +145,7 @@ export const AmountEditable: ParentComponent<{ } } - const parsed = Number(displayAmount()); + const parsed = Number(localSats()); if (isNaN(parsed)) { return undefined; } @@ -80,119 +157,91 @@ export const AmountEditable: ParentComponent<{ return undefined; }; - let inputRef!: HTMLInputElement; - function handleCharacterInput(character: string) { + const isFiatMode = mode() === "fiat"; + const inputSanitizer = isFiatMode ? fiatInputSanitizer : satsInputSanitizer; + const localValue = isFiatMode ? localFiat : localSats; + const inputRef = isFiatMode ? fiatInputRef : satsInputRef; + + let sane; + if (character === "DEL") { - setDisplayAmount(displayAmount().slice(0, -1)); + sane = inputSanitizer(localValue().slice(0, -1)); } else { - if (displayAmount() === "0") { - setDisplayAmount(character); + if (localValue() === "0") { + sane = inputSanitizer(character); } else { - setDisplayAmount(displayAmount() + character); + sane = inputSanitizer(localValue() + character); } } + if (isFiatMode) { + setLocalFiat(sane); + setLocalSats(usdToSats(state.price, parseFloat(sane || "0") || 0, false)); + } else { + setLocalSats(sane); + setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); + } + // After a button press make sure we re-focus the input inputRef.focus(); } - // making a "controlled" input is a known hard problem - // https://github.com/solidjs/solid/discussions/416 - function handleHiddenInput( - e: Event & { - currentTarget: HTMLInputElement; - target: HTMLInputElement; + function setFixedAmount(amount: string) { + if (mode() === "fiat") { + setLocalFiat(amount); + setLocalSats(usdToSats(state.price, parseFloat(amount || "0") || 0, false)); + } else { + setLocalSats(amount); + setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false)); } - ) { - // if the input is empty, set the display amount to 0 - if (e.target.value === "") { - setDisplayAmount("0"); - return; - } - - // if the input starts with one or more 0s, remove them, unless the input is just 0 - if (e.target.value.startsWith("0") && e.target.value !== "0") { - setDisplayAmount(e.target.value.replace(/^0+/, "")); - return; - } - - // if there's already a decimal point, don't allow another one - if (e.target.value.includes(".") && e.target.value.endsWith(".")) { - setDisplayAmount(e.target.value.slice(0, -1)); - return; - } - - setDisplayAmount(e.target.value); } - // I tried to do this with cooler math but I think it gets confused between decimal and percent - const scale = createMemo(() => { - const chars = displayAmount().length; - - if (chars > 9) { - return "scale-90"; - } else if (chars > 8) { - return "scale-95"; - } else if (chars > 7) { - return "scale-100"; - } else if (chars > 6) { - return "scale-105"; - } else if (chars > 5) { - return "scale-110"; - } else if (chars > 4) { - return "scale-125"; - } else { - return "scale-150"; - } - }); - - const prettyPrint = createMemo(() => { - const parsed = Number(displayAmount()); - if (isNaN(parsed)) { - return displayAmount(); - } else { - return parsed.toLocaleString(); - } - }); - - // Fiat conversion - const [state, _] = useMegaStore(); - - const amountInUsd = () => satsToUsd(state.price, Number(displayAmount()) || 0, true); - // What we're all here for in the first place: returning a value function handleSubmit(e: SubmitEvent | MouseEvent) { e.preventDefault(); - - // validate it's a number - console.log("handling submit..."); - console.log(displayAmount()); - const number = Number(displayAmount()); - if (isNaN(number) || number < 0) { - setDisplayAmount("0"); - inputRef.focus(); - return; - } else { - const bign = BigInt(displayAmount()); - props.setAmountSats(bign); - } - + props.setAmountSats(BigInt(localSats())); setIsOpen(false); } + function handleSatsInput(e: InputEvent) { + const { value } = e.target as HTMLInputElement; + const sane = satsInputSanitizer(value); + setLocalSats(sane); + setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); + } + + function handleFiatInput(e: InputEvent) { + const { value } = e.target as HTMLInputElement; + const sane = fiatInputSanitizer(value); + setLocalFiat(sane); + setLocalSats(usdToSats(state.price, parseFloat(sane || "0") || 0, false)); + } + + function toggle() { + setMode((m) => (m === "sats" ? "fiat" : "sats")); + if (mode() === "sats") { + satsInputRef.focus(); + } else { + fiatInputRef.focus(); + } + } + + onMount(() => { + satsInputRef.focus(); + }); + return ( setIsOpen(true)} class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center" > - {/* ✏️ */} Set amount} > - + {/* {props.children} */} @@ -210,36 +259,51 @@ export const AmountEditable: ParentComponent<{ - + {/* */} + (inputRef = el)} - autofocus - inputmode="none" + ref={(el) => (satsInputRef = el)} + disabled={mode() === "fiat"} type="text" - class="opacity-0 absolute -z-10" - value={displayAmount()} - onInput={(e) => handleHiddenInput(e)} + value={localSats()} + onInput={handleSatsInput} + inputMode="none" + /> + (fiatInputRef = el)} + disabled={mode() === "sats"} + type="text" + value={localFiat()} + onInput={handleFiatInput} + inputMode="none" /> + - - - {prettyPrint()} SATS - - - ≈ {amountInUsd()} USD - + + + {warningText()} - + {(amount) => ( setDisplayAmount(amount.amount)} + onClick={() => { setFixedAmount(amount.amount) + if (mode() === "fiat") { + fiatInputRef.focus(); + } else { + satsInputRef.focus(); + } + }} class="py-2 px-4 rounded-lg bg-white/10" > {amount.label} @@ -251,7 +315,7 @@ export const AmountEditable: ParentComponent<{ {(character) => ( diff --git a/src/utils/conversions.ts b/src/utils/conversions.ts index 37db8ea..5fa9bf2 100644 --- a/src/utils/conversions.ts +++ b/src/utils/conversions.ts @@ -1,39 +1,44 @@ import { MutinyWallet } from "@mutinywallet/mutiny-wasm"; export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string { - if (typeof amount !== "number" || isNaN(amount)) { - return "" - } - try { - const btc = MutinyWallet.convert_sats_to_btc(BigInt(Math.floor(amount))); - const usd = btc * price; + if (typeof amount !== "number" || isNaN(amount)) { + return ""; + } + try { + const btc = MutinyWallet.convert_sats_to_btc(BigInt(Math.floor(amount))); + const usd = btc * price; - if (formatted) { - return usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); - } else { - return usd.toFixed(2); - } - - } catch (e) { - console.error(e); - return "" + if (formatted) { + return usd.toLocaleString("en-US", { style: "currency", currency: "USD" }); + } else { + // Some float fighting shenaningans + const roundedUsd = Math.round(usd); + if (roundedUsd * 100 === Math.round(usd * 100)) { + return usd.toFixed(0); + } else { + return usd.toFixed(2); + } } + } catch (e) { + console.error(e); + return ""; + } } export function usdToSats(amount: number | undefined, price: number, formatted: boolean): string { - if (typeof amount !== "number" || isNaN(amount)) { - return "" - } - try { - const btc = price / amount; - const sats = MutinyWallet.convert_btc_to_sats(btc); - if (formatted) { - return parseInt(sats.toString()).toLocaleString(); - } else { - return sats.toString(); - } - } catch (e) { - console.error(e); - return "" + if (typeof amount !== "number" || isNaN(amount)) { + return ""; + } + try { + const btc = price / amount; + const sats = MutinyWallet.convert_btc_to_sats(btc); + if (formatted) { + return parseInt(sats.toString()).toLocaleString(); + } else { + return sats.toString(); } + } catch (e) { + console.error(e); + return ""; + } }