fancy amount editable

This commit is contained in:
Paul Miller
2023-04-14 10:30:45 -05:00
parent 222909b308
commit 8cbdf8edd4
8 changed files with 313 additions and 117 deletions

View File

@@ -9,8 +9,7 @@ function prettyPrintAmount(n?: number | bigint): string {
return n.toLocaleString()
}
export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean }) {
export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean, loading?: boolean }) {
const [state, _] = useMegaStore()
async function getPrice() {
@@ -23,11 +22,11 @@ export function Amount(props: { amountSats: bigint | number | undefined, showFia
return (
<div class="flex flex-col gap-2">
<h1 class="text-4xl font-light">
{prettyPrintAmount(props.amountSats)} <span class='text-xl'>SAT</span>
{props.loading ? "..." : prettyPrintAmount(props.amountSats)} <span class='text-xl'>SATS</span>
</h1>
<Show when={props.showFiat}>
<h2 class="text-xl font-light text-white/70" >
&#8776; {amountInUsd()} <span class="text-sm">USD</span>
&#8776; {props.loading ? "..." : amountInUsd()} <span class="text-sm">USD</span>
</h2>
</Show>
</div>

View File

@@ -0,0 +1,179 @@
import { For, Show, createEffect, createMemo, createResource, createSignal } from 'solid-js';
import { Button } from '~/components/layout';
import { useMegaStore } from '~/state/megaStore';
import { satsToUsd } from '~/utils/conversions';
import { Amount } from './Amount';
const CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "⌫"];
const FULLSCREEN_STYLE = 'fixed top-0 left-0 w-screen h-screen z-50 bg-sidebar-gray p-4';
function SingleDigitButton(props: { character: string, onClick: (c: string) => void }) {
return (
<button
class="p-2 rounded-lg hover:bg-white/10 active:bg-m-blue text-white text-4xl font-semi font-mono"
onClick={() => props.onClick(props.character)}
>
{props.character}
</button>
);
}
export function AmountEditable(props: { amountSats: number | bigint, setAmountSats: (s: string) => void }) {
const [isFullscreen, setIsFullscreen] = createSignal(false);
function toggleFullscreen() {
setIsFullscreen(!isFullscreen());
}
const [displayAmount, setDisplayAmount] = createSignal(props.amountSats.toString() || "0");
let inputRef!: HTMLInputElement;
function handleCharacterInput(character: string) {
if (character === "⌫") {
setDisplayAmount(displayAmount().slice(0, -1));
} else {
if (displayAmount() === "0") {
setDisplayAmount(character);
} else {
setDisplayAmount(displayAmount() + character);
}
}
// After a button press make sure we re-focus the input
inputRef.focus()
}
createEffect(() => {
if (isFullscreen()) {
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;
}) {
// 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(() => {
let parsed = Number(displayAmount());
if (isNaN(parsed)) {
return displayAmount();
} else {
return parsed.toLocaleString();
}
})
// Fiat conversion
const [state, _] = useMegaStore()
async function getPrice() {
return await state.node_manager?.get_bitcoin_price()
}
const [price] = createResource(getPrice)
const amountInUsd = () => satsToUsd(price(), Number(displayAmount()) || 0, true)
// What we're all here for in the first place: returning a value
function handleSubmit() {
// validate it's a number
const number = Number(displayAmount());
if (isNaN(number) || number < 0) {
setDisplayAmount("0");
inputRef.focus();
return;
} else {
props.setAmountSats(displayAmount());
toggleFullscreen();
}
}
return (
<>
{/* TODO: a better transition than this */}
<div class={`cursor-pointer transition-all ${isFullscreen() && FULLSCREEN_STYLE}`}>
<Show when={isFullscreen()} fallback={<div class="p-4 rounded-xl border-2 border-m-blue" onClick={toggleFullscreen}>
<Amount amountSats={Number(displayAmount())} showFiat />
</div>}>
<input ref={el => inputRef = el}
type="text"
class="opacity-0 absolute -z-10"
value={displayAmount()}
onInput={(e) => handleHiddenInput(e)}
/>
<div class="w-full h-full max-w-[600px] mx-auto">
<div class="flex flex-col gap-4 h-full">
<div class="p-4 bg-black rounded-xl flex-1 flex flex-col gap-4 items-center justify-center">
<h1 class={`font-light text-center transition-transform ease-out duration-300 text-6xl ${scale()}`}>
{prettyPrint()}&nbsp;<span class='text-xl'>SATS</span>
</h1>
<h2 class="text-xl font-light text-white/70" >
&#8776; {amountInUsd()} <span class="text-sm">USD</span>
</h2>
</div>
<div class="grid grid-cols-3 w-full flex-none">
<For each={CHARACTERS}>
{(character) => (
<SingleDigitButton character={character} onClick={handleCharacterInput} />
)}
</For>
</div>
<div class="flex-none">
<Button intent="inactive" class="w-full flex-none"
onClick={handleSubmit}
>
Set Amount
</Button>
</div>
</div>
</div>
</Show>
</div>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { Motion, Presence } from "@motionone/solid";
import { createResource, Show, Suspense } from "solid-js";
import { ButtonLink, SmallHeader } from "~/components/layout";
import { Button, ButtonLink, FancyCard, LoadingSpinner, SmallHeader } from "~/components/layout";
import { useMegaStore } from "~/state/megaStore";
import { Amount } from "./Amount";
@@ -25,46 +25,34 @@ export default function BalanceBox() {
const [balance, { refetch: refetchBalance }] = createResource(fetchBalance);
return (
<Presence>
<Motion
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, easing: [0.87, 0, 0.13, 1] }}
>
<div class='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
<SmallHeader>
Lightning Balance
</SmallHeader>
<div onClick={refetchBalance}>
<Suspense fallback={"..."}>
<Show when={balance()}>
<div class="flex flex-col gap-4">
<Amount amountSats={balance()?.lightning} showFiat />
<SmallHeader>
On-Chain Balance
</SmallHeader>
<Amount amountSats={balance()?.confirmed} showFiat />
<>
<FancyCard title="Lightning">
<Suspense fallback={<Amount amountSats={0} showFiat loading={true} />}>
<Amount amountSats={balance()?.lightning} showFiat loading={balance.loading} />
</Suspense>
</FancyCard>
<div class="flex gap-2 py-4">
<ButtonLink href="/send" intent="green">Send</ButtonLink>
<ButtonLink href="/receive" intent="blue">Receive</ButtonLink>
</div>
<FancyCard title="On-Chain">
<Suspense fallback={<Amount amountSats={0} showFiat loading={true} />}>
<Amount amountSats={balance()?.confirmed} showFiat loading={balance.loading} />
</Suspense>
<Suspense>
<Show when={balance()?.unconfirmed}>
<div class="flex flex-col gap-2">
<header class='text-sm font-semibold uppercase text-white/50'>
Unconfirmed Balance
</header>
<div class="text-white/50">
{prettyPrintAmount(balance()?.unconfirmed)} <span class='text-xl'>SAT</span>
{prettyPrintAmount(balance()?.unconfirmed)} <span class='text-sm'>SATS</span>
</div>
</div>
</Show>
</div>
</Show>
</Suspense>
</div>
<div class="flex gap-2 py-4">
<ButtonLink href="/send" intent="green">Send</ButtonLink>
<ButtonLink href="/receive" intent="blue">Receive</ButtonLink>
</div>
</div>
</Motion>
</Presence>
<Button onClick={() => refetchBalance()}>Sync</Button>
</FancyCard>
</>
)
}

View File

@@ -24,6 +24,15 @@ const InnerCard: ParentComponent<{ title?: string }> = (props) => {
)
}
const FancyCard: ParentComponent<{ title?: string }> = (props) => {
return (
<div class='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
{props.title && <SmallHeader>{props.title}</SmallHeader>}
{props.children}
</div>
)
}
const SafeArea: ParentComponent<{ main?: boolean }> = (props) => {
return (
<div class="safe-top safe-left safe-right safe-bottom">
@@ -69,4 +78,4 @@ const LoadingSpinner = () => {
const Hr = () => <Separator.Root class="my-4 border-white/20" />
export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr, NodeManagerGuard, FullscreenLoader, InnerCard }
export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr, NodeManagerGuard, FullscreenLoader, InnerCard, FancyCard }

View File

@@ -43,5 +43,5 @@ a {
/* Missing you sveltekit */
dd {
@apply mb-8 mt-4;
@apply mb-8 mt-2;
}

View File

@@ -10,6 +10,7 @@ import { Motion, Presence } from "@motionone/solid";
import { useMegaStore } from "~/state/megaStore";
import { MutinyInvoice, NodeManager } from "@mutinywallet/node-manager";
import { bip21decode } from "~/utils/TEMPbip21";
import { AmountEditable } from "~/components/AmountEditable";
type SendSource = "lightning" | "onchain";
@@ -29,6 +30,16 @@ export default function Send() {
const [address, setAddress] = createSignal<string>();
const [description, setDescription] = createSignal<string>();
function clearAll() {
setDestination("");
setPrivateLabel("");
setAmountSats(0n);
setSource("lightning");
setInvoice(undefined);
setAddress(undefined);
setDescription(undefined);
}
async function decode(source: string) {
if (!source) return;
try {
@@ -59,6 +70,7 @@ export default function Send() {
} catch (e) {
console.error("error", e)
clearAll();
}
}
@@ -100,26 +112,6 @@ export default function Send() {
<h1 class="text-2xl font-semibold uppercase border-b-2 border-b-white">Send Bitcoin</h1>
<dl>
<dt>
<SmallHeader>
Source
</SmallHeader>
</dt>
<dd>
<div class="flex gap-4 items-start">
<Button onClick={() => setSource("lightning")} intent={source() === "lightning" ? "active" : "inactive"} layout="small">Lightning</Button>
<Button onClick={() => setSource("onchain")} intent={source() === "onchain" ? "active" : "inactive"} layout="small">On-Chain</Button>
</div>
</dd>
<dt>
<SmallHeader>
How Much
</SmallHeader>
</dt>
<dd>
<Amount amountSats={amountSats() || 0} showFiat />
</dd>
<dt>
<SmallHeader>Destination</SmallHeader>
</dt>
<dd>
@@ -140,13 +132,6 @@ export default function Send() {
</div>
</Button>
</div>}>
<Presence>
<Motion
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<div class="flex flex-col gap-2">
<Show when={address()}>
<code class="line-clamp-3 text-sm break-all">{source() === "onchain" && "→"} {address()}</code>
@@ -158,13 +143,42 @@ export default function Send() {
</div>
{/* <code class="line-clamp-3 text-sm break-all mb-2">{destination()}</code> */}
</Motion>
</Presence>
<Show when={destination()}>
<Button intent="inactive" onClick={clearAll}>Clear</Button>
</Show>
</Show>
</InnerCard>
</dd>
<Show when={address() && invoice()}>
<dt>
<SmallHeader>
Payment Method
</SmallHeader>
</dt>
<dd>
<div class="flex gap-4 items-start">
<Button onClick={() => setSource("lightning")} intent={source() === "lightning" ? "active" : "inactive"} layout="small">Lightning</Button>
<Button onClick={() => setSource("onchain")} intent={source() === "onchain" ? "active" : "inactive"} layout="small">On-Chain</Button>
</div>
</dd>
</Show>
<Show when={destination()}>
<dt>
<SmallHeader>
Amount
</SmallHeader>
</dt>
<dd>
{/* if the amount came with the invoice we can't allow setting it */}
<Show when={!(invoice()?.amount_sats)} fallback={<Amount amountSats={amountSats() || 0} showFiat />}>
<AmountEditable amountSats={amountSats() || 0} setAmountSats={setAmountSats} />
</Show>
</dd>
</Show>
<Show when={description()}>
<dt>
@@ -176,6 +190,7 @@ export default function Send() {
</dd>
</Show>
<Show when={destination()}>
<TextField.Root
value={privateLabel()}
onValueChange={setPrivateLabel}
@@ -195,12 +210,12 @@ export default function Send() {
/>
</dd>
</TextField.Root>
</Show>
</dl>
<Button disabled={!destination()} intent="blue" onClick={handleSend}>Confirm Send</Button>
<Show when={destination()}>
<Button intent="inactive" onClick={() => setDestination("")}>Clear</Button>
</Show>
</div>
{/* safety div */}
<div class="h-32" />
<NavBar activeTab="send" />
</SafeArea >
)

View File

@@ -3,7 +3,7 @@
import { ParentComponent, createContext, createEffect, onMount, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { setupNodeManager } from "~/logic/nodeManagerSetup";
import { NodeManager } from "@mutinywallet/node-manager";
import { MutinyBalance, NodeManager } from "@mutinywallet/node-manager";
const MegaStoreContext = createContext<MegaStore>();
@@ -14,6 +14,8 @@ export type MegaStore = [{
node_manager?: NodeManager;
user_status: UserStatus;
scan_result?: string;
balance?: MutinyBalance;
last_sync?: number;
}, {
fetchUserStatus(): Promise<UserStatus>;
setupNodeManager(): Promise<void>;
@@ -42,12 +44,9 @@ export const Provider: ParentComponent = (props) => {
return "waitlisted"
}
// TODO: handle paid status
} catch (e) {
return "new_here"
}
},
async setupNodeManager(): Promise<void> {
try {
@@ -69,7 +68,7 @@ export const Provider: ParentComponent = (props) => {
})
})
// Only node manager when status is approved
// Only load node manager when status is approved
createEffect(() => {
if (state.user_status === "approved" && !state.node_manager) {
console.log("running setup node manager...")

View File

@@ -2,11 +2,18 @@
// bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl
// and return an object with this shape: { address: string, amount: number, label: string, lightning: string }
// using typescript type annotations
export function bip21decode(bip21: string): { address: string, amount?: number, label?: string, lightning?: string } {
export function bip21decode(bip21: string): { address?: string, amount?: number, label?: string, lightning?: string } {
const [scheme, data] = bip21.split(':')
if (scheme !== 'bitcoin') {
// TODO: this is a WAILA job I just want to debug more of the send flow
if (bip21.startsWith('lnt')) {
return { lightning: bip21 }
} else if (bip21.startsWith('tb1')) {
return { address: bip21 }
} else {
throw new Error('Not a bitcoin URI')
}
}
const [address, query] = data.split('?')
const params = new URLSearchParams(query)
return {