diff options
Diffstat (limited to 'src/routes/(app)/send')
14 files changed, 281 insertions, 322 deletions
diff --git a/src/routes/(app)/send/[...text]/+page.server.ts b/src/routes/(app)/send/[...text]/+page.server.ts index 9f0891d9..59d53dbe 100644 --- a/src/routes/(app)/send/[...text]/+page.server.ts +++ b/src/routes/(app)/send/[...text]/+page.server.ts @@ -9,16 +9,16 @@ export async function load({ cookies, depends, params, parent, url }) { if (!user) redirect(307, "/login"); const { text } = params; - await parse(text, url.host); + await parse(text, url.host, cookies); const contacts = await get(`/contacts/${limit}`, auth(cookies)); return { contacts }; } export const actions = { - default: async ({ request, url }) => { + default: async ({ cookies, request, url }) => { const { text } = await fd(request); - await parse(text, url.host); + await parse(text, url.host, cookies); return fail(400, { error: "default" }); }, }; diff --git a/src/routes/(app)/send/[...text]/+page.svelte b/src/routes/(app)/send/[...text]/+page.svelte index 183b4b51..b2b4d6bf 100644 --- a/src/routes/(app)/send/[...text]/+page.svelte +++ b/src/routes/(app)/send/[...text]/+page.svelte @@ -51,6 +51,20 @@ invalidate("app:contacts"); }; + let trust = async (e, { id, trusted }) => { + e.preventDefault(); + e.stopPropagation(); + + if (trusted) { + await post("/post/trust/delete", { id }); + } else { + await post("/post/trust", { id }); + } + + if (all) all = await get("/contacts"); + invalidate("app:contacts"); + }; + $effect(() => { if (browser && pasted && text) el.click() && (pasted = false); }); @@ -74,17 +88,6 @@ </div> {/if} - <textarea - use:focus - name="text" - placeholder={$t("user.send.placeholder")} - onkeypress={keypress} - class="w-full p-4 border rounded-xl h-32 text-xl" - bind:value={text} - onpaste={() => (pasted = true)} - autocapitalize="none" - ></textarea> - <div class="flex gap-2"> <a href="/scan" class="contents"> <button type="button" class="btn !w-auto flex-grow"> @@ -101,29 +104,23 @@ </button> </div> + <textarea + use:focus + name="text" + placeholder={$t("user.send.placeholder")} + onkeypress={keypress} + class="w-full p-4 border rounded-xl h-32 text-xl" + bind:value={text} + onpaste={() => (pasted = true)} + autocapitalize="none" + ></textarea> + <button bind:this={el} type="submit" class="btn btn-accent"> <iconify-icon noobserver icon="ph:paper-plane-right-bold" width="32" ></iconify-icon> <div class="my-auto">{$t("user.send.next")}</div> </button> - <a href="/send/ecash" class="block"> - <button type="button" class="btn"> - <img src="/images/cash.png" class="w-8" /> - <div class="my-auto">{$t("payments.createEcash")}</div> - </button> - </a> - - <a href="/send/fund" class="block"> - <button type="button" class="btn"> - <iconify-icon - noobserver - icon="ph:lightning-fill" - class="text-yellow-300 text-2xl" - ></iconify-icon> - <div class="my-auto">{$t("payments.createFund")}</div> - </button> - </a> </form> {#if contacts.length} @@ -139,12 +136,20 @@ <div class="my-auto text-left"> <p class="ml-1 text-lg break-words">{c.username}</p> </div> - <button class="ml-auto" onclick={(e) => pin(e, c)}> - <iconify-icon - icon={c.pinned ? "ph:push-pin-fill" : "ph:push-pin-bold"} - width={32} - ></iconify-icon> - </button> + <div class="flex ml-auto gap-1"> + <button onclick={(e) => pin(e, c)}> + <iconify-icon + icon={c.pinned ? "ph:push-pin-fill" : "ph:push-pin-bold"} + width={32} + ></iconify-icon> + </button> + <button class="ml-auto" onclick={(e) => trust(e, c)}> + <iconify-icon + icon={c.trusted ? "ph:star-fill" : "ph:star-bold"} + width={32} + ></iconify-icon> + </button> + </div> </div> </a> {/each} @@ -152,7 +157,7 @@ </div> {/if} - {#if !loaded} + {#if !loaded && contacts.length > 0} <button onclick={loadMore} type="button" class="btn" >{$t("user.loadMore")}</button > diff --git a/src/routes/(app)/send/bitcoin/[address]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/+page.svelte index 154534a7..529e2691 100644 --- a/src/routes/(app)/send/bitcoin/[address]/+page.svelte +++ b/src/routes/(app)/send/bitcoin/[address]/+page.svelte @@ -5,13 +5,14 @@ import Numpad from "$comp/Numpad.svelte"; import { page } from "$app/stores"; import { rate } from "$lib/store"; - import { fail, s } from "$lib/utils"; + import { loc, fail, s } from "$lib/utils"; let { data } = $props(); let { balance, user } = data; let { address } = $page.params; let { currency, username } = user; + let locale = loc(user); let amount = $state(0); let a = $state(0); @@ -34,8 +35,7 @@ <div class="text-xl text-secondary break-all">{address}</div> - <div>{amount}</div> - <Numpad bind:amount={a} bind:fiat {currency} {submit} bind:rate={$rate} /> + <Numpad bind:amount={a} bind:fiat {currency} {submit} bind:rate={$rate} {locale} /> <div class="flex justify-center gap-2"> <button diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts index ac883afd..ddee9c1c 100644 --- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts +++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts @@ -11,8 +11,6 @@ export async function load({ const { user } = await parent(); const aid = cookies.get("aid") || user.id; - console.log("YO", amount, address, feeRate); - try { const { fee, fees, inputs, ourfee, hex, subtract } = await post( "/bitcoin/fee", diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte index b86082f3..ac904304 100644 --- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte +++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte @@ -1,6 +1,4 @@ <script> - import { run } from "svelte/legacy"; - import { tick } from "svelte"; import { t } from "$lib/translations"; import { enhance } from "$app/forms"; @@ -15,11 +13,10 @@ import { applyAction } from "$app/forms"; import { hex as hexUtil } from "@scure/base"; import * as btc from "@scure/btc-signer"; - import { hash160 } from "@scure/btc-signer/utils"; import { decrypt } from "nostr-tools/nip49"; import { HDKey } from "@scure/bip32"; import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39"; - import { wordlist } from "@scure/bip39/wordlists/english"; + import { wordlist } from "@scure/bip39/wordlists/english.js"; import Amount from "$comp/Amount.svelte"; @@ -71,8 +68,18 @@ let toggle = () => (submitting = !submitting); - let { account, amount, address, message, fee, fees, subtract, ourfee, hex, inputs } = - $derived(data); + let { + account, + amount, + address, + message, + fee, + fees, + subtract, + ourfee, + hex, + inputs, + } = $derived(data); let { feeRate } = $state(data); @@ -116,7 +123,11 @@ {:else} <div class="text-xl text-secondary break-all">{address}</div> - <Amount amount={subtract ? amount - fee - ourfee : amount} rate={$rate} {currency} /> + <Amount + amount={subtract ? amount - fee - ourfee : amount} + rate={$rate} + {currency} + /> <div class="text-center"> <h2 class="text-secondary text-lg">{$t("payments.networkFee")}</h2> diff --git a/src/routes/(app)/send/ecash/+page.server.ts b/src/routes/(app)/send/ecash/+page.server.ts deleted file mode 100644 index b290fb95..00000000 --- a/src/routes/(app)/send/ecash/+page.server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import getRates from "$lib/rates"; -import { auth, fd, post } from "$lib/utils"; -import { error, redirect } from "@sveltejs/kit"; - -export async function load({ parent }) { - const rates = await getRates(); - const { user } = await parent(); - return { rate: rates[user.currency] }; -} - -export const actions = { - default: async ({ cookies, request }) => { - let id; - const body = await fd(request); - - try { - ({ id } = await post("/mint", body, auth(cookies))); - } catch (e) { - const { message } = e as Error; - console.log("problem creating ecash", e); - error(400, { message }); - } - - redirect(307, `/ecash/${id}`); - }, -}; diff --git a/src/routes/(app)/send/ecash/+page.svelte b/src/routes/(app)/send/ecash/+page.svelte deleted file mode 100644 index b9c93661..00000000 --- a/src/routes/(app)/send/ecash/+page.svelte +++ /dev/null @@ -1,63 +0,0 @@ -<script> - import { enhance, applyAction } from "$app/forms"; - import { t } from "$lib/translations"; - import Icon from "$comp/Icon.svelte"; - import Numpad from "$comp/Numpad.svelte"; - import Spinner from "$comp/Spinner.svelte"; - import { page } from "$app/stores"; - import { rate } from "$lib/store"; - import { fail, s } from "$lib/utils"; - import handler from "$lib/handler"; - - let { data } = $props(); - - let { user } = $derived(data); - let { balance, currency, username } = $derived(user); - - let amount = $state(0); - let submit = $state(), - fiat = $state(); - - let setMax = (e) => { - e.preventDefault(); - fiat = false; - amount = balance; - }; - - let submitting = $state(); - let toggle = () => (submitting = !submitting); - $effect(() => ({ rate: $rate } = data)); -</script> - -<div class="container px-4 max-w-xl mx-auto space-y-5 text-center"> - <form use:enhance={handler(toggle)} method="POST" class="space-y-5"> - <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold"> - {$t("payments.fundingAmount")} - </h1> - <Numpad bind:amount bind:fiat {currency} {submit} bind:rate={$rate} /> - <input type="hidden" name="amount" bind:value={amount} /> - - <div class="flex gap-2"> - <button - type="button" - class="btn !w-auto grow" - onclick={setMax} - disabled={submitting} - onkeydown={setMax}>Max ⚡️{s(balance)}</button - > - - <button - use:focus - bind:this={submit} - type="submit" - class="btn btn-accent !w-auto grow" - > - {#if submitting} - <Spinner /> - {:else} - <div class="my-auto">{$t("payments.next")}</div> - {/if} - </button> - </div> - </form> -</div> diff --git a/src/routes/(app)/send/ecash/[request]/+page.server.ts b/src/routes/(app)/send/ecash/[request]/+page.server.ts deleted file mode 100644 index 6cc593cc..00000000 --- a/src/routes/(app)/send/ecash/[request]/+page.server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { fd, get } from "$lib/utils"; -import { decodePaymentRequest } from "@cashu/cashu-ts"; -import { redirect } from "@sveltejs/kit"; - -export async function load({ cookies, params, url }) { - if (!cookies.get("token")) - redirect(307, `/login?redirect=${encodeURIComponent(url.pathname)}`); - - const req = { ...decodePaymentRequest(params.request) }; - const [{ target, type }] = req.transport; - - let recipient = { name: "", pubkey: null }; - if (type === "nostr") { - recipient = await get(`/profile/${target}`); - } else if (type === "post") { - recipient.name = new URL(target).hostname; - } - - return { req, recipient, type, target }; -} - -export const actions = { - setAmount: async ({ request }) => { - return await fd(request); - }, -}; diff --git a/src/routes/(app)/send/ecash/[request]/+page.svelte b/src/routes/(app)/send/ecash/[request]/+page.svelte deleted file mode 100644 index 42bc8e9d..00000000 --- a/src/routes/(app)/send/ecash/[request]/+page.svelte +++ /dev/null @@ -1,128 +0,0 @@ -<script lang="ts"> - import { goto } from "$app/navigation"; - import { focus, fail, post } from "$lib/utils"; - import { t } from "$lib/translations"; - import { enhance } from "$app/forms"; - import Amount from "$comp/Amount.svelte"; - import Numpad from "$comp/Numpad.svelte"; - import Spinner from "$comp/Spinner.svelte"; - import { rate, pin } from "$lib/store"; - import { wrapEvent } from "nostr-tools/nip17"; - import { getPrivateKey, sign, send } from "$lib/nostr"; - import { getDecodedToken } from "@cashu/cashu-ts"; - import type { PaymentRequestPayload } from "@cashu/cashu-ts"; - import { AbstractSimplePool } from "nostr-tools/abstract-pool"; - - const alwaysTrue: any = (t: Event) => { - t[Symbol("verified")] = true; - return true; - }; - - export const pool = new AbstractSimplePool({ verifyEvent: alwaysTrue }); - - const { data, form } = $props(); - - let formElement = $state(); - let submitting = $state(); - let submit = async (e) => { - e.preventDefault(); - submitting = true; - - try { - let { pid, token } = await post("/ecash/mint", { amount }); - let { proofs } = getDecodedToken(token); - let { id, mints, unit = "" } = req; - let mint = mints[0]; - - const payload = { - id, - mint, - unit, - proofs, - }; - - if (type === "nostr") { - let event = wrapEvent( - await getPrivateKey(user), - { publicKey: recipient.pubkey }, - JSON.stringify(payload), - ); - - await Promise.any(pool.publish(recipient.relays, event)); - } else if (type === "post") { - await post("/ecash/post", { target, payload }); - } - - goto(`/sent/${pid}`); - } catch (e) { - console.log(e); - fail(e.message); - } - - submitting = false; - }; - - let { recipient, req, user, type, target } = $derived({ - ...data, - ...form, - }); - let { currency } = $derived(user); - let a = $state(); - let next = $state(); - let toggle = () => (show = !show); - let amount = $derived(form?.amount || data.req.amount); - - $effect(() => ($rate ||= data.rate)); - $effect(() => form && (submitting = false)); -</script> - -<div class="container px-4 max-w-xl mx-auto text-center space-y-2"> - {#if amount} - {#if form?.message} - <div class="text-red-600 text-center"> - {$t(form.message)} - </div> - {/if} - - <div> - <h1 class="text-lg text-secondary"> - {$t("payments.send")} - </h1> - - <Amount {amount} rate={$rate} {currency} /> - </div> - - <div class="text-xl"> - <span class="text-secondary">{$t("payments.to")}</span> - <span class="break-words font-semibold">{recipient.name}</span> - </div> - - <form onsubmit={submit} class="space-y-2" bind:this={formElement}> - <input name="amount" value={amount} type="hidden" /> - <input name="pin" value={$pin} type="hidden" /> - - <button - type="submit" - class="btn btn-primary" - disabled={submitting} - use:focus - > - {#if submitting} - <Spinner /> - {:else} - {$t("payments.send")} - {/if} - </button> - </form> - {:else} - <form method="POST" action="?/setAmount" class="space-y-2" use:enhance> - <input type="hidden" value={a} name="amount" /> - <input name="rate" value={$rate} type="hidden" /> - - <Numpad bind:amount={a} {currency} bind:rate={$rate} submit={next} /> - <button type="submit" class="btn" bind:this={next} - >{$t("payments.next")}</button - > - </form> - {/if} -</div> diff --git a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte index 314be3ef..d34ed073 100644 --- a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte +++ b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte @@ -6,14 +6,16 @@ import { page } from "$app/stores"; import { pin } from "$lib/store"; import handler from "$lib/handler"; - import { s } from "$lib/utils"; + import { loc, s } from "$lib/utils"; + import { applyAction, deserialize } from "$app/forms"; let { data, form } = $props(); - let { amount } = $state(data); - let { balance, currency } = data.user; + let { amount, user } = $state(data); + let { balance, currency } = $derived(user); let { name, rate } = $derived(data); let loading = $state(); let fiat = $state(); + let locale = loc(user); let submit = $state(); let submitting = $state(); @@ -22,11 +24,22 @@ if (form?.message?.includes("pin")) $pin = undefined; loading = false; }); + let formElement = $state(); - let setMax = (e) => { + let setMax = async (e) => { e.preventDefault(); - fiat = false; - amount = balance; + let body = new FormData(formElement); + body.set("fiat", false); + body.set("amount", user.balance); + + const response = await fetch(formElement.action, { + method: "POST", + body, + }); + + const result = deserialize(await response.text()); + if (result.type === "success") await invalidateAll(); + applyAction(result); }; </script> @@ -37,9 +50,14 @@ {/if} <div class="container px-4 mt-20 max-w-xl mx-auto"> - <Numpad bind:amount {currency} {rate} {fiat} {submit} /> + <Numpad bind:amount {currency} {rate} {fiat} {submit} {locale} /> - <form use:enhance={handler(toggle)} method="POST" class="space-y-5"> + <form + use:enhance={handler(toggle)} + method="POST" + class="space-y-5" + bind:this={formElement} + > <input name="fund" value={name} type="hidden" /> <input name="amount" value={amount} type="hidden" /> <input name="pin" value={$pin} type="hidden" /> diff --git a/src/routes/(app)/send/invoice/[id]/+page.server.ts b/src/routes/(app)/send/invoice/[id]/+page.server.ts index 3ef933d4..70b007a8 100644 --- a/src/routes/(app)/send/invoice/[id]/+page.server.ts +++ b/src/routes/(app)/send/invoice/[id]/+page.server.ts @@ -1,12 +1,36 @@ import getRates from "$lib/rates"; import { auth, fd, get, post, types } from "$lib/utils"; -import { error, redirect } from "@sveltejs/kit"; +import { error, fail, redirect } from "@sveltejs/kit"; -export async function load({ cookies, params: { id }, parent }) { - const { user } = await parent(); +export async function load({ cookies, depends, params: { id }, parent }) { + depends("app:trust"); + const { subject, user } = await parent(); const aid = cookies.get("aid") || user.id; + const pin = cookies.get("pin"); - const invoice = await get(`/invoice/${id}`); + let invoice = await get(`/invoice/${id}`); + + const trust = await get("/trust", auth(cookies)); + const trusted = trust.includes(invoice.uid); + if (trusted && (pin || !user.haspin)) { + let p; + try { + if (!invoice.tip && invoice.user.prompt) { + if (user.tip > 0) { + invoice.tip = Math.round(invoice.amount * (user.tip / 100)); + invoice = await post(`/invoice/${id}`, { invoice }, auth(cookies)); + } else { + throw new Error("tip"); + } + } + p = await post("/payments", { ...invoice, pin }, auth(cookies)); + } catch (e) { + const { message } = e as Error; + if (message === "tip") redirect(307, `/invoice/${id}/tip`); + fail(400, { message }); + } + if (p) redirect(307, `/sent/${p.id}`); + } if (invoice.amount && invoice.prompt && invoice.tip === null) redirect(307, `/invoice/${id}/tip`); @@ -31,7 +55,7 @@ export async function load({ cookies, params: { id }, parent }) { const invoiceRate = rates[invoice.currency]; const { balance } = await get(`/account/${aid}`, auth(cookies)); - return { balance, invoice, user, rate, invoiceRate }; + return { balance, invoice, user, rate, invoiceRate, trusted }; } export const actions = { @@ -45,7 +69,7 @@ export const actions = { } catch (e) { const { message } = e as Error; console.log("payment failed", id, e); - error(500, message); + error(500, { message }); } redirect(307, `/sent/${p.id}`); diff --git a/src/routes/(app)/send/invoice/[id]/+page.svelte b/src/routes/(app)/send/invoice/[id]/+page.svelte index 7d167f41..d5fd00a8 100644 --- a/src/routes/(app)/send/invoice/[id]/+page.svelte +++ b/src/routes/(app)/send/invoice/[id]/+page.svelte @@ -1,4 +1,5 @@ <script> + import { invalidate } from "$app/navigation"; import { untrack } from "svelte"; import handler from "$lib/handler"; import { onDestroy, onMount } from "svelte"; @@ -26,7 +27,7 @@ } from "$lib/utils"; let { data, form } = $props(); - let { balance, invoice, user } = $state(data); + let { trusted, balance, invoice, user } = $state(data); let { address, hash, payreq, user: recipient, tip } = $state(invoice); let { currency } = $derived(user); let locale = $derived(loc(user)); @@ -64,6 +65,19 @@ }); }; + let toggleTrust = async (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (trusted) { + await post("/post/trust/delete", { id: recipient.id }); + } else { + await post("/post/trust", { id: recipient.id }); + } + + invalidate("app:trust"); + }; + $effect(() => { if (form?.message?.includes("pin")) $pin = undefined; submitting = false; @@ -193,5 +207,12 @@ <button type="button" class="btn" onclick={external} >{$t("payments.moreOptions")}</button > + + {#if !trusted} + <button type="button" class="btn" onclick={toggleTrust}> + <iconify-icon icon="ph:star-fill" width={32}></iconify-icon> + {$t("payments.trustUser")}</button + > + {/if} </form> </div> diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts index eb324775..2b637e75 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts @@ -1,14 +1,34 @@ import getRates from "$lib/rates"; -import { auth, fd, post } from "$lib/utils"; +import { auth, fd, get, post } from "$lib/utils"; import { fail, redirect } from "@sveltejs/kit"; +// Ask the server what this send will actually cost: the routing fee askrene +// quotes for the invoice (the same answer xpay gets when it pays) plus the +// platform fee. The quoted routing fee becomes the payment's maxfee, so what +// the user sees is what gets debited. A failed quote isn't fatal — the page +// falls back to the old manual max-fee reserve. +const quote = async (body, cookies) => { + try { + const q = await post("/lightning/quote", body, auth(cookies)); + return { ...q, quoteError: undefined }; + } catch (e) { + const { message } = e as Error; + return { fee: undefined, ourfee: undefined, quoteError: message }; + } +}; + export async function load({ cookies, params, parent }) { const { user } = await parent(); let data = { ...params }; const rates = await getRates(); - if (!params.payreq.startsWith("lno")) { - data = { ...data, ...(await post("/parse", params, auth(cookies))) }; - } + data = { ...data, ...(await post("/parse", params, auth(cookies))) }; + + // Lightning sends always draw on the main account + const { balance } = await get(`/account/${user.id}`, auth(cookies)); + data.balance = balance; + + if (data.amount) + data = { ...data, ...(await quote({ payreq: params.payreq, amount: data.amount }, cookies)) }; data.rate = rates[user.currency]; return data; @@ -29,9 +49,30 @@ export const actions = { data.payreq = invoice; } + data = { ...data, ...(await quote({ payreq: data.payreq || payreq, amount }, cookies)) }; + return data; }, + // Send everything: the server solves for the largest amount whose amount + + // routing fee + platform fee lands exactly on the balance. For an offer it + // also fetches the invoice for that exact amount. + max: async ({ cookies, params }) => { + const { payreq } = params; + try { + const q = await post("/lightning/quote", { payreq, max: true }, auth(cookies)); + let data = { amount: q.amount, fee: q.fee, ourfee: q.ourfee, total: q.total, max: true }; + if (q.payreq) { + data = { ...data, ...(await post("/parse", { payreq: q.payreq }, auth(cookies))) }; + data.payreq = q.payreq; + } + return data; + } catch (e) { + const { message } = e as Error; + return fail(400, { message }); + } + }, + send: async ({ cookies, request }) => { let p; try { diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte index a5cc168c..7b42a42d 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte @@ -11,7 +11,8 @@ let { data, form } = $props(); - let { alias, memo, payreq, ourfee, user } = $derived({ ...data, ...form }); + let { alias, memo, payreq, ourfee, fee, quoteError, balance, user } = + $derived({ ...data, ...form }); let a = $state(); let { currency, locked } = $derived(user); let locale = loc(user); @@ -27,18 +28,53 @@ let next = $state(); let toggle = () => (show = !show); let amount = $derived(form?.amount || data.amount); + + // The max routing fee we hand the server. When the route was quoted this is + // the exact fee askrene found, so the debit matches what's shown; without a + // quote fall back to the old 2% reserve (any unused part is credited back). + let quoted = $derived(typeof fee === "number"); + let fallback = $derived(Math.max(5, Math.round(amount * 0.02) || 0)); let maxfee = $state( - Math.max(5, Math.round(untrack(() => amount) * 0.005 || 0)), + untrack(() => (typeof fee === "number" ? fee : Math.max(5, Math.round(amount * 0.02) || 0))), + ); + $effect(() => (maxfee = quoted ? fee : fallback)); + + let total = $derived((amount || 0) + (maxfee || 0) + (ourfee || 0)); + + // Server-side messages worth showing verbatim: a balance shortfall, or the + // pre-flight check that the max fee is below what routing now costs. + let verbatim = $derived( + form?.message && + (form.message.includes($t("payments.insufficientFunds")) || + form.message.includes("Routing fee")), + ); + + // A resubmitted invoice (back button, double tap) isn't a routing failure — + // the first attempt went through or is still in flight. Say so instead of + // telling the user the recipient is unreachable. + let duplicate = $derived( + form?.message && + (/already (been )?paid/i.test(form.message) + ? "payments.alreadyPaid" + : form.message.includes("already underway") + ? "payments.alreadyUnderway" + : form.message.includes("still settling") + ? "payments.stillSettling" + : null), ); - $effect(() => (maxfee = Math.max(5, Math.round(amount * 0.005) || 0))); </script> <div class="container px-4 max-w-xl mx-auto text-center space-y-2"> {#if form?.message} <div class="text-red-600 text-center"> - {#if form.message.includes($t("payments.insufficientFunds"))} + {#if verbatim} <div>{form.message}</div> - <div>{$t("payments.lockedBalance")}: {locked}</div> + {#if form.message.includes($t("payments.insufficientFunds"))} + <div>{$t("payments.lockedBalance")}: {locked}</div> + {/if} + {:else if duplicate} + <div>{$t(duplicate)}</div> + <a href="/payments" class="underline">{$t("payments.header")}</a> {:else} {$t("payments.failedToRoute")} {/if} @@ -58,6 +94,23 @@ <span class="break-words font-semibold">{alias}</span> </div> + {#if quoted} + <div class="text-center"> + <h2 class="text-secondary text-lg">{$t("payments.routingFee")}</h2> + + <div class="flex flex-wrap gap-4 justify-center"> + <div class="my-auto"> + <h2 class="text-xl"> + {f(toFiat(fee, $rate), currency)} + </h2> + <h3 class="text-secondary">⚡️{s(fee)}</h3> + </div> + </div> + </div> + {:else if quoteError} + <div class="text-secondary">{$t("payments.noQuote")}</div> + {/if} + {#if ourfee} <div class="text-center"> <h2 class="text-secondary text-lg">{$t("payments.platformFee")}</h2> @@ -73,6 +126,14 @@ </div> {/if} + {#if quoted && (fee || ourfee)} + <div class="text-center"> + <h2 class="text-secondary text-lg">{$t("payments.total")}</h2> + <h2 class="text-xl">{f(toFiat(total, $rate), currency)}</h2> + <h3 class="text-secondary">⚡️{s(total)}</h3> + </div> + {/if} + <form method="POST" use:enhance @@ -126,11 +187,17 @@ <button type="button" class="btn" onclick={() => (showMax = !showMax)} >{$t("payments.advancedSettings")}</button > - <input name="maxfee" type="hidden" bind:value={maxfee} /> + <input name="fee" type="hidden" bind:value={maxfee} /> {/if} </form> {:else} - <form method="POST" action="?/setAmount" class="space-y-2" use:enhance> + <form + method="POST" + action="?/setAmount" + class="space-y-2" + use:enhance + onsubmit={submit} + > <input type="hidden" value={a} name="amount" /> <input name="rate" value={$rate} type="hidden" /> @@ -141,9 +208,26 @@ bind:rate={$rate} submit={next} /> - <button type="submit" class="btn" bind:this={next} - >{$t("payments.next")}</button - > + + <div class="flex justify-center gap-2"> + <!-- no onclick: see Lnurlp.svelte — disabling from the click + handler cancels the submit; the form's onsubmit sets loading --> + <button + type="submit" + class="btn !w-auto grow" + formaction="?/max" + disabled={loading} + > + {#if loading} + <Spinner /> + {:else} + Max ⚡️{s(balance)} + {/if} + </button> + <button type="submit" class="btn btn-accent !w-auto grow" bind:this={next} + >{$t("payments.next")}</button + > + </div> </form> {/if} </div> |
