mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-21 08:14:27 +01:00
better receive
This commit is contained in:
82
src/components/AmountInput.tsx
Normal file
82
src/components/AmountInput.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { Match, Suspense, Switch, createEffect, createMemo, createResource, createSignal } from "solid-js";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { satsToUsd, usdToSats } from "~/utils/conversions";
|
||||
|
||||
export type AmountInputProps = {
|
||||
initialAmountSats: string;
|
||||
setAmountSats: (amount: string) => void;
|
||||
refSetter: (el: HTMLInputElement) => void;
|
||||
}
|
||||
|
||||
type ActiveCurrency = "usd" | "sats"
|
||||
|
||||
export function AmountInput(props: AmountInputProps) {
|
||||
// We need to keep a local amount state because we need to convert between sats and USD
|
||||
// But we should keep the parent state in sats
|
||||
const [localAmount, setLocalAmount] = createSignal(props.initialAmountSats || "0");
|
||||
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
async function getPrice() {
|
||||
return await state.node_manager?.get_bitcoin_price()
|
||||
}
|
||||
|
||||
const [activeCurrency, setActiveCurrency] = createSignal<ActiveCurrency>("sats")
|
||||
|
||||
const [price] = createResource(getPrice)
|
||||
|
||||
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(localAmount()) || 0, true))
|
||||
const amountInSats = createMemo(() => usdToSats(price(), parseFloat(localAmount() || "0.00") || 0, true))
|
||||
|
||||
createEffect(() => {
|
||||
// When the local amount changes, update the parent state if we're in sats
|
||||
if (activeCurrency() === "sats") {
|
||||
props.setAmountSats(localAmount())
|
||||
} else {
|
||||
// If we're in USD, convert the amount to sats
|
||||
props.setAmountSats(usdToSats(price(), parseFloat(localAmount() || "0.00") || 0, false))
|
||||
}
|
||||
})
|
||||
|
||||
function toggleActiveCurrency() {
|
||||
if (activeCurrency() === "sats") {
|
||||
setActiveCurrency("usd")
|
||||
// Convert the current amount of sats to USD
|
||||
const usd = satsToUsd(price() || 0, parseInt(localAmount()) || 0, false)
|
||||
console.log(`converted ${localAmount()} sats to ${usd} USD`)
|
||||
setLocalAmount(usd);
|
||||
} else {
|
||||
setActiveCurrency("sats")
|
||||
// Convert the current amount of USD to sats
|
||||
const sats = usdToSats(price() || 0, parseInt(localAmount()) || 0, false)
|
||||
console.log(`converted ${localAmount()} usd to ${sats} sats`)
|
||||
setLocalAmount(sats)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="">
|
||||
<TextField.Root
|
||||
value={localAmount()}
|
||||
onValueChange={setLocalAmount}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<pre>{`Bitcoin is ${price()?.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}</pre>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Amount {activeCurrency() === "sats" ? "(sats)" : "(USD)"}</TextField.Label>
|
||||
<TextField.Input autofocus ref={(el) => props.refSetter(el)} inputmode={"decimal"} class="w-full p-2 rounded-lg text-black" />
|
||||
<Suspense>
|
||||
<Switch fallback={<div>Loading...</div>}>
|
||||
<Match when={price() && activeCurrency() === "sats"}>
|
||||
<pre>{`~${amountInUsd()}`}</pre>
|
||||
</Match>
|
||||
<Match when={price() && activeCurrency() === "usd"}>
|
||||
<pre>{`${amountInSats()} sats`}</pre>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</TextField.Root>
|
||||
<button type="button" onClick={toggleActiveCurrency}>🔀</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
0
src/components/ReceiveQrShower.tsx
Normal file
0
src/components/ReceiveQrShower.tsx
Normal file
@@ -7,7 +7,7 @@ const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
|
||||
variants: {
|
||||
intent: {
|
||||
active: "bg-white text-black",
|
||||
inactive: "bg-black text-white border border-white",
|
||||
inactive: "bg-black text-white border border-white disabled:opacity-50",
|
||||
blue: "bg-[#3B6CCC] text-white",
|
||||
red: "bg-[#F61D5B] text-white",
|
||||
green: "bg-[#1EA67F] text-white",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ParentComponent } from "solid-js"
|
||||
import { ParentComponent, Show } from "solid-js"
|
||||
import Linkify from "./Linkify"
|
||||
import { Button, ButtonLink } from "./Button"
|
||||
import { Separator } from "@kobalte/core"
|
||||
@@ -15,11 +15,15 @@ const Card: ParentComponent<{ title?: string }> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const SafeArea: ParentComponent = (props) => {
|
||||
const SafeArea: ParentComponent<{ main?: boolean }> = (props) => {
|
||||
return (
|
||||
<div class="safe-top safe-left safe-right safe-bottom">
|
||||
<div class="disable-scrollbars max-h-screen h-full overflow-y-scroll md:pl-[8rem] md:pr-[6rem]">
|
||||
{props.children}
|
||||
<Show when={props.main} fallback={props.children}>
|
||||
<main class='flex flex-col py-8 px-4 items-center'>
|
||||
{props.children}
|
||||
</main>
|
||||
</Show>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
import { createResource, Show } from "solid-js";
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { createMemo, createResource, createSignal, Match, Show, Suspense, Switch } from "solid-js";
|
||||
import { QRCodeSVG } from "solid-qr-code";
|
||||
import { Button, SafeArea } from "~/components/layout";
|
||||
import { AmountInput } from "~/components/AmountInput";
|
||||
import { Button, Card, 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";
|
||||
|
||||
export default function Receive() {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
// TODO: would be nice if this was just newest unused address
|
||||
const getNewAddress = async () => {
|
||||
if (state.node_manager) {
|
||||
console.log("Getting new address");
|
||||
const address = await state.node_manager?.get_new_address();
|
||||
return address
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
};
|
||||
|
||||
const [address, { refetch: refetchAddress }] = createResource(getNewAddress);
|
||||
|
||||
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
||||
|
||||
async function share() {
|
||||
function ShareButton(props: { receiveString: string }) {
|
||||
async function share(receiveString: string) {
|
||||
// If the browser doesn't support share we can just copy the address
|
||||
if (!navigator.share) {
|
||||
copy(address() ?? "");
|
||||
copied();
|
||||
|
||||
console.error("Share not supported")
|
||||
}
|
||||
const shareData: ShareData = {
|
||||
title: "Mutiny Wallet",
|
||||
text: address(),
|
||||
text: receiveString,
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share(shareData)
|
||||
} catch (e) {
|
||||
@@ -43,27 +27,121 @@ export default function Receive() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeArea>
|
||||
<main class='flex flex-col py-8 px-4 items-center'>
|
||||
<div class="max-w-[400px] flex flex-col gap-4">
|
||||
<Show when={address()}>
|
||||
<div class="w-full bg-white rounded-xl">
|
||||
<QRCodeSVG value={address() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 w-full">
|
||||
<Button onClick={() => copy(address() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
|
||||
<Button onClick={share}>Share</Button>
|
||||
</div>
|
||||
<div class="rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]">
|
||||
<header class='text-sm font-semibold uppercase'>
|
||||
Address / Invoice
|
||||
</header>
|
||||
<code class="break-all">{address()}</code>
|
||||
<Button onClick={refetchAddress}>Get new address</Button>
|
||||
</div>
|
||||
<Button onClick={(_) => share(props.receiveString)}>Share</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type ReceiveState = "edit" | "show"
|
||||
|
||||
export default function Receive() {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
const [amount, setAmount] = createSignal("")
|
||||
const [label, setLabel] = createSignal("")
|
||||
|
||||
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit")
|
||||
|
||||
let amountInput!: HTMLInputElement;
|
||||
let labelInput!: HTMLInputElement;
|
||||
|
||||
function editAmount(e: Event) {
|
||||
e.preventDefault();
|
||||
setReceiveState("edit")
|
||||
amountInput.focus();
|
||||
}
|
||||
|
||||
function editLabel(e: Event) {
|
||||
e.preventDefault();
|
||||
setReceiveState("edit")
|
||||
labelInput.focus();
|
||||
}
|
||||
|
||||
const [unified, setUnified] = createSignal("")
|
||||
|
||||
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
||||
|
||||
async function getUnifiedQr(amount: string, label: string) {
|
||||
const bigAmount = BigInt(amount);
|
||||
const bip21Raw = await state.node_manager?.create_bip21(bigAmount, label);
|
||||
|
||||
const params = objectToSearchParams({
|
||||
amount: bip21Raw?.btc_amount,
|
||||
label: bip21Raw?.description,
|
||||
lightning: bip21Raw?.invoice
|
||||
})
|
||||
|
||||
return `bitcoin:${bip21Raw?.address}?${params}`
|
||||
}
|
||||
|
||||
async function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const unifiedQr = await getUnifiedQr(amount(), label())
|
||||
|
||||
setUnified(unifiedQr)
|
||||
setReceiveState("show")
|
||||
}
|
||||
|
||||
async function getPrice() {
|
||||
return await state.node_manager?.get_bitcoin_price()
|
||||
}
|
||||
|
||||
const [price] = createResource(getPrice)
|
||||
|
||||
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(amount()) || 0, true))
|
||||
|
||||
return (
|
||||
<SafeArea main>
|
||||
<div class="w-full max-w-[400px] flex flex-col gap-4">
|
||||
<Suspense fallback={"..."}>
|
||||
{/* If I don't have this guard then the node manager only half-works */}
|
||||
<Show when={state.node_manager}>
|
||||
<Switch>
|
||||
<Match when={!unified() || receiveState() === "edit"}>
|
||||
<form class="border border-white/20 rounded-xl p-2 flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
{/* TODO this initial amount is not reactive, hope that's okay? */}
|
||||
<AmountInput initialAmountSats={amount()} setAmountSats={setAmount} refSetter={el => amountInput = el} />
|
||||
<TextField.Root
|
||||
value={label()}
|
||||
onValueChange={setLabel}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Label (private)</TextField.Label>
|
||||
<TextField.Input
|
||||
autofocus
|
||||
ref={el => labelInput = el}
|
||||
class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<Button disabled={!amount() || !label()} layout="small" type="submit">Create Invoice</Button>
|
||||
</form >
|
||||
</Match>
|
||||
<Match when={unified() && receiveState() === "show"}>
|
||||
<div class="w-full bg-white rounded-xl">
|
||||
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 w-full">
|
||||
<Button onClick={(_) => copy(unified() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
|
||||
<ShareButton receiveString={unified() ?? ""} />
|
||||
</div>
|
||||
<Card>
|
||||
<SmallHeader>Amount</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{amount()} sats</p><button onClick={editAmount}>✏️</button>
|
||||
</div>
|
||||
<pre>({amountInUsd()})</pre>
|
||||
<SmallHeader>Private Label</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{label()} </p><button onClick={editLabel}>✏️</button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Bip21">
|
||||
<code class="break-all">{unified()}</code>
|
||||
</Card>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
</Suspense>
|
||||
</div>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea >
|
||||
|
||||
|
||||
39
src/utils/conversions.ts
Normal file
39
src/utils/conversions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NodeManager } from "@mutinywallet/node-manager";
|
||||
|
||||
export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string {
|
||||
if (typeof amount !== "number" || isNaN(amount)) {
|
||||
return ""
|
||||
}
|
||||
try {
|
||||
const btc = NodeManager.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 ""
|
||||
}
|
||||
}
|
||||
|
||||
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 = NodeManager.convert_btc_to_sats(btc);
|
||||
if (formatted) {
|
||||
return parseInt(sats.toString()).toLocaleString();
|
||||
} else {
|
||||
return sats.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user