amount input switchable to usd

This commit is contained in:
Paul Miller
2023-05-31 16:26:09 -05:00
parent bbb13f3c73
commit f5e12d1b7c
2 changed files with 210 additions and 141 deletions

View File

@@ -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 { Button } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions"; import { satsToUsd, usdToSats } from "~/utils/conversions";
import { Dialog } from "@kobalte/core"; import { Dialog } from "@kobalte/core";
import close from "~/assets/icons/close.svg"; import close from "~/assets/icons/close.svg";
import pencil from "~/assets/icons/pencil.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 CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "DEL"];
const FIXED_AMOUNTS = [ const FIXED_AMOUNTS_SATS = [
{ label: "10k", amount: "10000" }, { label: "10k", amount: "10000" },
{ label: "100k", amount: "100000" }, { label: "100k", amount: "100000" },
{ label: "1m", amount: "1000000" } { 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: { function SingleDigitButton(props: {
character: string; character: string;
onClick: (c: string) => void; onClick: (c: string) => void;
@@ -27,7 +58,6 @@ function SingleDigitButton(props: {
// Skip the "." if it's fiat // Skip the "." if it's fiat
<Show when={props.fiat || !(props.character === ".")} fallback={<div />}> <Show when={props.fiat || !(props.character === ".")} fallback={<div />}>
<button <button
disabled={props.character === "."}
class="disabled:opacity-50 p-2 rounded-lg md:hover:bg-white/10 active:bg-m-blue text-white text-4xl font-semi font-mono" class="disabled:opacity-50 p-2 rounded-lg md:hover:bg-white/10 active:bg-m-blue text-white text-4xl font-semi font-mono"
onClick={() => props.onClick(props.character)} onClick={() => props.onClick(props.character)}
> >
@@ -37,18 +67,65 @@ function SingleDigitButton(props: {
); );
} }
function BigScalingText(props: { text: string; fiat: boolean }) {
const chars = () => props.text.length;
return (
<h1
class="font-light text-center transition-transform ease-out duration-300 text-4xl"
classList={{
"scale-90": chars() > 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}&nbsp;<span class="text-xl">{props.fiat ? "USD" : "SATS"}</span>
</h1>
);
}
function SmallSubtleAmount(props: { text: string; fiat: boolean }) {
return (
<h2 class="text-xl font-light text-neutral-400">
&#8776;&nbsp;{props.text}&nbsp;<span class="text-sm">{props.fiat ? "USD" : "SATS"}</span>
</h2>
);
}
function toDisplayHandleNaN(input: string, _fiat: boolean): string {
const parsed = Number(input);
if (isNaN(parsed)) {
return "0";
} else {
return parsed.toLocaleString();
}
}
export const AmountEditable: ParentComponent<{ export const AmountEditable: ParentComponent<{
initialAmountSats: string; initialAmountSats: string;
initialOpen: boolean; initialOpen: boolean;
setAmountSats: (s: bigint) => void; setAmountSats: (s: bigint) => void;
}> = (props) => { }> = (props) => {
const [isOpen, setIsOpen] = createSignal(props.initialOpen); 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 [inboundCapacity] = createResource(async () => {
const channels = await store.mutiny_wallet?.list_channels(); const channels = await state.mutiny_wallet?.list_channels();
let inbound = 0; let inbound = 0;
for (const channel of channels) { for (const channel of channels) {
@@ -59,7 +136,7 @@ export const AmountEditable: ParentComponent<{
}); });
const warningText = () => { const warningText = () => {
if ((store.balance?.lightning || 0n) === 0n) { if ((state.balance?.lightning || 0n) === 0n) {
const network = state.mutiny_wallet?.get_network() as Network; const network = state.mutiny_wallet?.get_network() as Network;
if (network === "bitcoin") { if (network === "bitcoin") {
return "Your first lightning receive needs to be 50,000 sats or greater."; 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)) { if (isNaN(parsed)) {
return undefined; return undefined;
} }
@@ -80,119 +157,91 @@ export const AmountEditable: ParentComponent<{
return undefined; return undefined;
}; };
let inputRef!: HTMLInputElement;
function handleCharacterInput(character: string) { 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") { if (character === "DEL") {
setDisplayAmount(displayAmount().slice(0, -1)); sane = inputSanitizer(localValue().slice(0, -1));
} else { } else {
if (displayAmount() === "0") { if (localValue() === "0") {
setDisplayAmount(character); sane = inputSanitizer(character);
} else { } 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 // After a button press make sure we re-focus the input
inputRef.focus(); inputRef.focus();
} }
// making a "controlled" input is a known hard problem function setFixedAmount(amount: string) {
// https://github.com/solidjs/solid/discussions/416 if (mode() === "fiat") {
function handleHiddenInput( setLocalFiat(amount);
e: Event & { setLocalSats(usdToSats(state.price, parseFloat(amount || "0") || 0, false));
currentTarget: HTMLInputElement; } else {
target: HTMLInputElement; 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 // What we're all here for in the first place: returning a value
function handleSubmit(e: SubmitEvent | MouseEvent) { function handleSubmit(e: SubmitEvent | MouseEvent) {
e.preventDefault(); e.preventDefault();
props.setAmountSats(BigInt(localSats()));
// 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);
}
setIsOpen(false); 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 ( return (
<Dialog.Root open={isOpen()}> <Dialog.Root open={isOpen()}>
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center" class="px-4 py-2 rounded-xl border-2 border-m-blue flex gap-2 items-center"
> >
{/* <Amount amountSats={Number(displayAmount())} showFiat /><span>&#x270F;&#xFE0F;</span> */}
<Show <Show
when={displayAmount() !== "0"} when={localSats() !== "0"}
fallback={<div class="inline-block font-semibold">Set amount</div>} fallback={<div class="inline-block font-semibold">Set amount</div>}
> >
<InlineAmount amount={displayAmount()} /> <InlineAmount amount={localSats()} />
</Show> </Show>
<img src={pencil} alt="Edit" /> <img src={pencil} alt="Edit" />
{/* {props.children} */} {/* {props.children} */}
@@ -210,36 +259,51 @@ export const AmountEditable: ParentComponent<{
<img src={close} alt="Close" /> <img src={close} alt="Close" />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit}> {/* <form onSubmit={handleSubmit} class="text-black"> */}
<form onSubmit={handleSubmit} class="opacity-0 absolute -z-10">
<input <input
ref={(el) => (inputRef = el)} ref={(el) => (satsInputRef = el)}
autofocus disabled={mode() === "fiat"}
inputmode="none"
type="text" type="text"
class="opacity-0 absolute -z-10" value={localSats()}
value={displayAmount()} onInput={handleSatsInput}
onInput={(e) => handleHiddenInput(e)} inputMode="none"
/>
<input
ref={(el) => (fiatInputRef = el)}
disabled={mode() === "sats"}
type="text"
value={localFiat()}
onInput={handleFiatInput}
inputMode="none"
/> />
</form> </form>
<div class="flex flex-col flex-1 justify-around gap-2 max-w-[400px] mx-auto w-full"> <div class="flex flex-col flex-1 justify-around gap-2 max-w-[400px] mx-auto w-full">
<div class="w-full p-4 flex flex-col gap-4 items-center justify-center"> <div class="p-4 flex flex-col gap-4 items-center justify-center" onClick={toggle}>
<h1 <BigScalingText
class={`font-light text-center transition-transform ease-out duration-300 text-4xl ${scale()}`} text={mode() === "fiat" ? displayFiat() : displaySats()}
> fiat={mode() === "fiat"}
{prettyPrint()}&nbsp;<span class="text-xl">SATS</span> />
</h1> <SmallSubtleAmount
<h2 class="text-xl font-light text-white/70"> text={mode() === "fiat" ? displaySats() : displayFiat()}
&#8776; {amountInUsd()} <span class="text-sm">USD</span> fiat={mode() !== "fiat"}
</h2> />
</div> </div>
<Show when={warningText()}> <Show when={warningText()}>
<InfoBox accent="green">{warningText()}</InfoBox> <InfoBox accent="green">{warningText()}</InfoBox>
</Show> </Show>
<div class="flex justify-center gap-4 my-2"> <div class="flex justify-center gap-4 my-2">
<For each={FIXED_AMOUNTS}> <For each={mode() === "fiat" ? FIXED_AMOUNTS_USD : FIXED_AMOUNTS_SATS}>
{(amount) => ( {(amount) => (
<button <button
onClick={() => 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" class="py-2 px-4 rounded-lg bg-white/10"
> >
{amount.label} {amount.label}
@@ -251,7 +315,7 @@ export const AmountEditable: ParentComponent<{
<For each={CHARACTERS}> <For each={CHARACTERS}>
{(character) => ( {(character) => (
<SingleDigitButton <SingleDigitButton
fiat={false} fiat={mode() === "fiat"}
character={character} character={character}
onClick={handleCharacterInput} onClick={handleCharacterInput}
/> />

View File

@@ -1,39 +1,44 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm"; import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string { export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string {
if (typeof amount !== "number" || isNaN(amount)) { if (typeof amount !== "number" || isNaN(amount)) {
return "" return "";
} }
try { try {
const btc = MutinyWallet.convert_sats_to_btc(BigInt(Math.floor(amount))); const btc = MutinyWallet.convert_sats_to_btc(BigInt(Math.floor(amount)));
const usd = btc * price; const usd = btc * price;
if (formatted) { if (formatted) {
return usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); return usd.toLocaleString("en-US", { style: "currency", currency: "USD" });
} else { } else {
return usd.toFixed(2); // Some float fighting shenaningans
} const roundedUsd = Math.round(usd);
if (roundedUsd * 100 === Math.round(usd * 100)) {
} catch (e) { return usd.toFixed(0);
console.error(e); } else {
return "" return usd.toFixed(2);
}
} }
} catch (e) {
console.error(e);
return "";
}
} }
export function usdToSats(amount: number | undefined, price: number, formatted: boolean): string { export function usdToSats(amount: number | undefined, price: number, formatted: boolean): string {
if (typeof amount !== "number" || isNaN(amount)) { if (typeof amount !== "number" || isNaN(amount)) {
return "" return "";
} }
try { try {
const btc = price / amount; const btc = price / amount;
const sats = MutinyWallet.convert_btc_to_sats(btc); const sats = MutinyWallet.convert_btc_to_sats(btc);
if (formatted) { if (formatted) {
return parseInt(sats.toString()).toLocaleString(); return parseInt(sats.toString()).toLocaleString();
} else { } else {
return sats.toString(); return sats.toString();
}
} catch (e) {
console.error(e);
return ""
} }
} catch (e) {
console.error(e);
return "";
}
} }