option for address / invoice

This commit is contained in:
Paul Miller
2023-04-26 10:56:15 -05:00
parent 3eb2e0bdb9
commit d5acdf3a82
5 changed files with 114 additions and 74 deletions

View File

@@ -18,9 +18,12 @@ function SingleDigitButton(props: { character: string, onClick: (c: string) => v
);
}
export function AmountEditable(props: { initialAmountSats: string, setAmountSats: (s: bigint) => void, onSave?: () => void }) {
export function AmountEditable(props: { initialAmountSats: string, setAmountSats: (s: bigint) => void }) {
const [isOpen, setIsOpen] = createSignal(false);
const [displayAmount, setDisplayAmount] = createSignal(props.initialAmountSats || "0");
let inputRef!: HTMLInputElement;
function handleCharacterInput(character: string) {
@@ -101,9 +104,12 @@ export function AmountEditable(props: { initialAmountSats: string, setAmountSats
const amountInUsd = () => satsToUsd(state.price, Number(displayAmount()) || 0, true)
// What we're all here for in the first place: returning a value
function handleSubmit() {
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");
@@ -112,36 +118,36 @@ export function AmountEditable(props: { initialAmountSats: string, setAmountSats
} else {
const bign = BigInt(displayAmount());
props.setAmountSats(bign);
// This is so the parent can focus the next input if it wants to
if (props.onSave) {
props.onSave();
}
}
setIsOpen(false);
}
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
const DIALOG_CONTENT = "h-screen-safe p-4 bg-gray/50 backdrop-blur-md bg-black/80"
return (
<Dialog.Root>
<Dialog.Trigger>
<div class="p-4 rounded-xl border-2 border-m-blue">
<Amount amountSats={Number(displayAmount())} showFiat />
</div>
</Dialog.Trigger>
<Dialog.Root isOpen={isOpen()}>
<button onClick={() => setIsOpen(true)} class="p-4 rounded-xl border-2 border-m-blue">
<Amount amountSats={Number(displayAmount())} showFiat />
</button>
<Dialog.Portal>
{/* <Dialog.Overlay class={OVERLAY} /> */}
<div class={DIALOG_POSITIONER}>
<Dialog.Content class={DIALOG_CONTENT}>
{/* TODO: figure out how to submit on enter */}
<input ref={el => inputRef = el}
autofocus
inputmode='none'
type="text"
class="opacity-0 absolute -z-10"
value={displayAmount()}
onInput={(e) => handleHiddenInput(e)}
/>
<form onSubmit={handleSubmit}>
<input ref={el => inputRef = el}
autofocus
inputmode='none'
type="text"
class="opacity-0 absolute -z-10"
value={displayAmount()}
onInput={(e) => handleHiddenInput(e)}
/>
</form>
<div class="flex flex-col gap-4 max-w-[400px] mx-auto">
<div class="p-4 bg-black rounded-xl flex flex-col gap-4 items-center justify-center">
<h1 class={`font-light text-center transition-transform ease-out duration-300 text-6xl ${scale()}`}>
@@ -159,13 +165,9 @@ export function AmountEditable(props: { initialAmountSats: string, setAmountSats
</For>
</div>
{/* TODO: this feels wrong */}
<Dialog.CloseButton>
<Button intent="inactive" class="w-full flex-none"
onClick={handleSubmit}
>
Set Amount
</Button>
</Dialog.CloseButton>
<Button intent="inactive" class="w-full flex-none" onClick={handleSubmit}>
Set Amount
</Button>
</div>
</Dialog.Content>
</div>

View File

@@ -1,7 +1,7 @@
import { Dialog } from "@kobalte/core";
import { JSX } from "solid-js";
import { Button, SmallHeader } from "~/components/layout";
import { Button, LargeHeader, SmallHeader } from "~/components/layout";
import close from "~/assets/icons/close.svg";
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
@@ -24,9 +24,9 @@ export function FullscreenModal(props: FullscreenModalProps) {
<Dialog.Content class={DIALOG_CONTENT}>
<div class="flex justify-between items-center mb-2">
<Dialog.Title>
<SmallHeader>
<LargeHeader>
{props.title}
</SmallHeader>
</LargeHeader>
</Dialog.Title>
<Dialog.CloseButton class="p-2 hover:bg-white/10 rounded-lg active:bg-m-blue">
<img src={close} alt="Close" />

View File

@@ -1,24 +1,26 @@
import { RadioGroup } from "@kobalte/core";
import { For } from "solid-js";
import { For, Show } from "solid-js";
type Choices = { value: string, label: string, caption: string }[]
// TODO: how could would it be if we could just pass the estimated fees in here?
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void }) {
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void, small?: boolean, }) {
return (
<RadioGroup.Root value={props.value} onValueChange={(e) => props.onValueChange(e)} class="grid w-full gap-4 grid-cols-2">
<RadioGroup.Root value={props.value} onValueChange={(e) => props.onValueChange(e)} class={`grid w-full gap-${props.small ? 2 : 4} grid-cols-${props.choices.length}`}>
<For each={props.choices}>
{choice =>
<RadioGroup.Item value={choice.value} class="ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2">
<div class="py-3 px-4">
<div class={props.small ? "py-2 px-2" : "py-3 px-4"}>
<RadioGroup.ItemInput />
<RadioGroup.ItemControl >
<RadioGroup.ItemIndicator />
</RadioGroup.ItemControl>
<RadioGroup.ItemLabel class="ui-checked:text-white text-neutral-400">
<div class="block">
<div class="text-lg font-semibold">{choice.label}</div>
<div class="text-sm font-light">{choice.caption}</div>
<div class={`text-${props.small ? "base" : "lg"} font-semibold`}>{choice.label}</div>
<Show when={!props.small}>
<div class="text-sm font-light">{choice.caption}</div>
</Show>
</div>
</RadioGroup.ItemLabel>
</div>

View File

@@ -1,12 +1,10 @@
import { TextField } from "@kobalte/core";
import { MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
import { createEffect, createMemo, createResource, createSignal, Match, onCleanup, Switch } from "solid-js";
import { createEffect, createResource, createSignal, For, Match, onCleanup, Switch } from "solid-js";
import { QRCodeSVG } from "solid-qr-code";
import { AmountEditable } from "~/components/AmountEditable";
import { Button, Card, DefaultMain, LargeHeader, NodeManagerGuard, SafeArea, SmallHeader } from "~/components/layout";
import { Button, Card, LargeHeader, NodeManagerGuard, SafeArea, SmallHeader } from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils/conversions";
import { objectToSearchParams } from "~/utils/objectToSearchParams";
import { useCopy } from "~/utils/useCopy";
import mempoolTxUrl from "~/utils/mempoolTxUrl";
@@ -15,7 +13,8 @@ import party from '~/assets/party.gif';
import { Amount } from "~/components/Amount";
import { FullscreenModal } from "~/components/layout/FullscreenModal";
import { BackButton } from "~/components/layout/BackButton";
import { TagEditor } from "~/components/TagEditor";
import { TagEditor, TagItem } from "~/components/TagEditor";
import { StyledRadioGroup } from "~/components/layout/Radio";
type OnChainTx = {
transaction: {
@@ -41,6 +40,19 @@ type OnChainTx = {
}
}
const createUniqueId = () => Math.random().toString(36).substr(2, 9);
const fakeContacts: TagItem[] = [
{ id: createUniqueId(), name: "Unknown", kind: "text" },
{ id: createUniqueId(), name: "Alice", kind: "contact" },
{ id: createUniqueId(), name: "Bob", kind: "contact" },
{ id: createUniqueId(), name: "Carol", kind: "contact" },
]
const RECEIVE_FLAVORS = [{ value: "unified", label: "Unified", caption: "Sender decides" }, { value: "lightning", label: "Lightning", caption: "Fast and cool" }, { value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }]
type ReceiveFlavor = "unified" | "lightning" | "onchain"
function ShareButton(props: { receiveString: string }) {
async function share(receiveString: string) {
// If the browser doesn't support share we can just copy the address
@@ -70,26 +82,29 @@ export default function Receive() {
const [state, _] = useMegaStore()
const [amount, setAmount] = createSignal("")
const [label, setLabel] = createSignal("")
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit")
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
const [unified, setUnified] = createSignal("")
// Tagging stuff
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
const [values, setValues] = createSignal([...fakeContacts]);
// The data we get after a payment
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
// The flavor of the receive
const [flavor, setFlavor] = createSignal<ReceiveFlavor>("unified");
function clearAll() {
setAmount("")
setLabel("")
setReceiveState("edit")
setBip21Raw(undefined)
setUnified("")
setPaymentTx(undefined)
setPaymentInvoice(undefined)
setSelectedValues([])
}
let amountInput!: HTMLInputElement;
@@ -107,13 +122,21 @@ export default function Receive() {
labelInput.focus();
}
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
async function getUnifiedQr(amount: string, label: string) {
function handleCopy() {
if (flavor() === "unified") {
copy(unified() ?? "")
} else if (flavor() === "lightning") {
copy(bip21Raw()?.invoice ?? "")
} else if (flavor() === "onchain") {
copy(bip21Raw()?.address ?? "")
}
}
async function getUnifiedQr(amount: string) {
const bigAmount = BigInt(amount);
const raw = await state.node_manager?.create_bip21(bigAmount, label);
const raw = await state.node_manager?.create_bip21(bigAmount, "TODO DELETE ME");
// Save the raw info so we can watch the address and invoice
setBip21Raw(raw);
@@ -130,20 +153,12 @@ export default function Receive() {
async function onSubmit(e: Event) {
e.preventDefault();
const unifiedQr = await getUnifiedQr(amount(), label())
const unifiedQr = await getUnifiedQr(amount())
setUnified(unifiedQr)
setReceiveState("show")
}
const amountInUsd = createMemo(() => satsToUsd(state.price, parseInt(amount()) || 0, true))
function handleAmountSave() {
console.error("focusing label input...")
console.error(labelInput)
labelInput.focus();
}
async function checkIfPaid(bip21?: MutinyBip21RawMaterials): Promise<PaidState | undefined> {
if (bip21) {
console.log("checking if paid...")
@@ -179,6 +194,9 @@ export default function Receive() {
});
});
return (
<NodeManagerGuard>
<SafeArea>
@@ -189,32 +207,52 @@ export default function Receive() {
<Match when={!unified() || receiveState() === "edit"}>
<dl>
<dd>
<AmountEditable initialAmountSats={amount() || "0"} setAmountSats={setAmount} onSave={handleAmountSave} />
<AmountEditable initialAmountSats={amount() || "0"} setAmountSats={setAmount} />
</dd>
<dd>
<TagEditor />
<TagEditor title="Tag the origin" values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} />
</dd>
</dl>
<Button class="w-full" disabled={!amount() || !label()} intent="green" onClick={onSubmit}>Create Invoice</Button>
<Button class="w-full" disabled={!amount() || !selectedValues().length} intent="green" onClick={onSubmit}>Create Invoice</Button>
</Match>
<Match when={unified() && receiveState() === "show"}>
<StyledRadioGroup small value={flavor()} onValueChange={setFlavor} choices={RECEIVE_FLAVORS} />
<div class="w-full bg-white rounded-xl">
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
<Switch>
<Match when={flavor() === "unified"}>
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</Match>
<Match when={flavor() === "lightning"}>
<QRCodeSVG value={bip21Raw()?.invoice ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</Match>
<Match when={flavor() === "onchain"}>
<QRCodeSVG value={bip21Raw()?.address ?? ""} class="w-full h-full p-8 max-h-[400px]" />
</Match>
</Switch>
</div>
<div class="flex gap-2 w-full">
<Button onClick={(_) => copy(unified() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
<Button onClick={handleCopy}>{copied() ? "Copied" : "Copy"}</Button>
<ShareButton receiveString={unified() ?? ""} />
</div>
<Card>
<SmallHeader>Amount</SmallHeader>
<div class="flex justify-between">
<p>{amount()} sats</p><button onClick={editAmount}>&#x270F;&#xFE0F;</button>
<Amount amountSats={parseInt(amount()) || 0} showFiat={true} />
<button onClick={editAmount}>&#x270F;&#xFE0F;</button>
</div>
<pre>({amountInUsd()})</pre>
<SmallHeader>Private Label</SmallHeader>
<SmallHeader>Tags</SmallHeader>
<div class="flex justify-between">
<p>{label()} </p><button onClick={editLabel}>&#x270F;&#xFE0F;</button>
<div class="flex flex-wrap">
<For each={selectedValues()}>
{(tag) => (
<div class=" bg-white/20 rounded px-1">
{tag.name}
</div>)}
</For>
</div>
{/* <pre>{JSON.stringify(selectedValues(), null, 2)}</pre> */}
<button onClick={editLabel}>&#x270F;&#xFE0F;</button>
</div>
</Card>
<Card title="Bip21">
@@ -222,7 +260,7 @@ export default function Receive() {
</Card>
</Match>
<Match when={receiveState() === "paid" && paidState() === "lightning_paid"}>
<FullscreenModal title="Payment Received!" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
<FullscreenModal title="Payment Received" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
<div class="flex flex-col items-center gap-8">
<img src={party} alt="party" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
<Amount amountSats={paymentInvoice()?.amount_sats} showFiat />
@@ -230,7 +268,7 @@ export default function Receive() {
</FullscreenModal>
</Match>
<Match when={receiveState() === "paid" && paidState() === "onchain_paid"}>
<FullscreenModal title="Payment Received!" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
<FullscreenModal title="Payment Received" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
<div class="flex flex-col items-center gap-8">
<img src={party} alt="party" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
<Amount amountSats={paymentTx()?.received} showFiat />

View File

@@ -171,7 +171,7 @@ export default function Send() {
<DefaultMain>
<BackButton />
<LargeHeader>Send Bitcoin</LargeHeader>
<FullscreenModal title="Sent!" open={!!sentDetails()} setOpen={(open: boolean) => { if (!open) setSentDetails(undefined) }} onConfirm={() => setSentDetails(undefined)}>
<FullscreenModal title="Sent" open={!!sentDetails()} setOpen={(open: boolean) => { if (!open) setSentDetails(undefined) }} onConfirm={() => setSentDetails(undefined)}>
<div class="flex flex-col items-center gap-8">
<img src={handshake} alt="party" class="w-1/2 mx-auto max-w-[50vh] zoom-image" />
<Amount amountSats={sentDetails()?.amount} showFiat />
@@ -247,8 +247,6 @@ export default function Send() {
</div>
</ButtonLink>
</HStack>
</VStack>
</Match>
</Switch>