diff options
| author | Adam Soltys <[email protected]> | 2026-03-04 12:43:18 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-04 12:43:18 -0800 |
| commit | 350a2a46aa3b48d3223b6e5c14ddc5952d260dba (patch) | |
| tree | 71ee314b7cedb0a63ca7b8a16f1f03113233095e /src | |
| parent | 3fdb649666e47a053fcd51e1ee45f3b959ab788f (diff) | |
| download | coinos-ui-350a2a46aa3b48d3223b6e5c14ddc5952d260dba.tar.gz coinos-ui-350a2a46aa3b48d3223b6e5c14ddc5952d260dba.zip | |
fund ui
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Menu.svelte | 2 | ||||
| -rw-r--r-- | src/locales/en.json | 12 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.server.ts | 42 | ||||
| -rw-r--r-- | src/routes/(app)/fund/[id]/+page.svelte | 71 | ||||
| -rw-r--r-- | src/routes/(app)/fund/[id]/gift/+page.svelte | 41 | ||||
| -rw-r--r-- | src/routes/(app)/fund/[id]/share/+page.server.ts (renamed from src/routes/(app)/fund/[id]/gift/+page.server.ts) | 0 | ||||
| -rw-r--r-- | src/routes/(app)/fund/[id]/share/+page.svelte | 64 | ||||
| -rw-r--r-- | src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts | 30 | ||||
| -rw-r--r-- | src/routes/(app)/fund/[id]/withdraw/+page.svelte | 3 | ||||
| -rw-r--r-- | src/routes/(app)/qr/[text]/+page.svelte | 8 | ||||
| -rw-r--r-- | src/routes/(app)/send/fund/[...name]/[...amount]/+page.server.ts | 16 | ||||
| -rw-r--r-- | src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte | 49 |
12 files changed, 224 insertions, 114 deletions
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 4b0204fd..9803e7a2 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -16,11 +16,11 @@ let showMenu = $state(false); let menuButtons = [ - { key: "nav.funds", icon: PhLightningFill, href: `/funds` }, { key: "nav.settings", icon: PhFadersBold, href: `/settings` }, { key: "nav.support", icon: PhLifebuoyBold, href: `/support` }, { key: "nav.map", icon: PhMapTrifoldBold, href: `/map` }, { key: "nav.dm", icon: PhChatBold, href: `/messages` }, + { key: "nav.funds", icon: PhLightningFill, href: `/funds` }, { key: "nav.dark", icon: PhMoonStarsBold, href: `/dark` }, { key: "nav.signOut", icon: PhSignOutBold, href: `/logout` }, ]; diff --git a/src/locales/en.json b/src/locales/en.json index 2d49ac8d..68492464 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -6,7 +6,12 @@ "manage": "Manage", "withdrawAccess": "Withdraw access", "username": "Username", - "returnToFund": "Back to fund" + "returnToFund": "Back to fund", + "authorizations": "Authorizations", + "immediateContribution": "Immediate contribution", + "fiatAuthorization": "Fiat authorization", + "authorizeDesc": "Authorize a fiat amount that someone can claim later at the current exchange rate", + "contributeDesc": "Send sats to the fund immediately from your balance" }, "notes": { "contents": "Note contents", @@ -546,6 +551,10 @@ "custom": "Custom", "trustUser": "Send and enable auto-pay", "gift": "Gift all funds", + "entireBalance": "Entire balance", + "shareSetAmount": "Share set amount", + "shareEntireBalance": "Share entire balance", + "denominateIn": "Denominate in", "giftLink": "Gift link", "giftDesc1": "Whoever opens the link below will receive all the available funds.", "giftDesc2": "If they are logged in, the funds will go to their account, otherwise a new account will be generated.", @@ -632,6 +641,7 @@ "empty": "No transactions yet.", "send": "Send", "next": "Next", + "authorize": "Authorize", "sendTo": "Send to", "to": "to", "for": "for", diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index f1cb4d24..f863e026 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -6,31 +6,35 @@ export async function load({ cookies, url, params }) { const { id, username } = params; const token = cookies.get("token"); - if (!token) redirect(307, "/login"); + const isSweep = pathname.match(/^\/fund\/[^/]+\/sweep/); + if (!token && !isSweep) redirect(307, "/login"); let user; - try { - user = await get("/me", auth(cookies)); - } catch (e) { - const { message } = e as Error; - if (message.startsWith("Rate")) { - await sleep(3000); - try { - user = await get("/me", auth(cookies)); - } catch (retryError) { - if (isInvalidTokenError(retryError) && pathname !== "/logout") { - redirect(307, "/logout"); + let hasArk = false; + if (token) { + try { + user = await get("/me", auth(cookies)); + } catch (e) { + const { message } = e as Error; + if (message.startsWith("Rate")) { + await sleep(3000); + try { + user = await get("/me", auth(cookies)); + } catch (retryError) { + if (isInvalidTokenError(retryError) && pathname !== "/logout") { + redirect(307, "/logout"); + } + throw retryError; } - throw retryError; + } else if (isInvalidTokenError(e) && pathname !== "/logout") { + redirect(307, "/logout"); + } else { + throw e; } - } else if (isInvalidTokenError(e) && pathname !== "/logout") { - redirect(307, "/logout"); - } else { - throw e; } - } - const hasArk = (await get("/accounts", auth(cookies))).some((a: any) => a.type === "ark"); + hasArk = (await get("/accounts", auth(cookies))).some((a: any) => a.type === "ark"); + } let subject; if (url.pathname.includes("/invoice") && id) { diff --git a/src/routes/(app)/fund/[id]/+page.svelte b/src/routes/(app)/fund/[id]/+page.svelte index dea5a19b..c19db2ac 100644 --- a/src/routes/(app)/fund/[id]/+page.svelte +++ b/src/routes/(app)/fund/[id]/+page.svelte @@ -2,55 +2,51 @@ import PhGearBold from "virtual:icons/ph/gear-bold"; import PhPlusBold from "virtual:icons/ph/plus-bold"; import PhHandCoinsBold from "virtual:icons/ph/hand-coins-bold"; - import PhLinkBold from "virtual:icons/ph/link-bold"; - import PhGiftBold from "virtual:icons/ph/gift-bold"; - import PhLightningFill from "virtual:icons/ph/lightning-fill"; + import PhShareNetworkBold from "virtual:icons/ph/share-network-bold"; + import PhTrashBold from "virtual:icons/ph/trash-bold"; + import PhCopyBold from "virtual:icons/ph/copy-bold"; import Amount from "$comp/Amount.svelte"; import Payments from "$comp/Payments.svelte"; - import { enhance } from "$app/forms"; + import { invalidate } from "$app/navigation"; import { page } from "$app/stores"; import Avatar from "$comp/Avatar.svelte"; import { toast } from "@zerodevx/svelte-toast"; import { t } from "$lib/translations"; - import { types, copy, f, s, sat, sats, loc } from "$lib/utils"; + import { copy, sats, loc, post } from "$lib/utils"; import { loginRedirect } from "$lib/store"; - import { bech32 } from "@scure/base"; - const encoder = new TextEncoder(); let { data }: any = $props(); let amount = $derived(data.amount); let payments = $derived(data.payments); let managers = $derived(data.managers); + let authorizations = $derived(data.authorizations || []); let rate = $derived(data.rate); let user = $derived(data.user); let { id } = $page.params; let locale = $derived(loc(user)); let currency = $derived(user ? user.currency : "CAD"); + let isManager = $derived(!managers.length || managers.some((m) => m.id === user?.id)); let amountFiat = $derived(parseFloat(((amount * rate) / sats).toFixed(2))); $effect(() => { $loginRedirect = $page.url.pathname; }); - let show = $state(); - let toggle = () => (show = !show); - let href = $derived(encodeURIComponent($page.url.href)); - // let withdrawUrl = encoder.encode( - // `${$page.url.origin}/api/fund/${id}/withdraw`, - // ); - // let lnurlw = bech32.encode("lnurl", bech32.toWords(withdrawUrl), 20000); + async function deleteAuth(authId: string) { + try { + await post(`/fund/${id}/authorization/${authId}/delete`, {}); + invalidate("app:payments"); + } catch (e: any) { + toast.push(e.message); + } + } </script> <div class="container px-4 max-w-4xl mx-auto mt-10 space-y-5"> <div class="flex justify-center items-center"> <div class="md:shadow-xl rounded-3xl md:px-10 pt-5 pb-10 space-y-5 w-full md:mx-5"> - {#if show} - <div class="max-w-[360px] mx-auto"> - <img src={`/qr/${href}/raw`} class="z-10 border-4 border-white w-" alt="URL" /> - </div> - {/if} <div class="flex justify-center gap-4"> <Amount {amount} {currency} {rate} {locale} /> </div> @@ -74,7 +70,7 @@ {/if} </div> - {#if (user && !managers.length) || managers.some((m) => m.id === user?.id)} + {#if user && isManager} <a href={`/fund/${id}/access`} class="btn !w-auto ml-auto text-secondary grow"> <PhGearBold width={32} /> {$t("funds.manage")} @@ -90,7 +86,7 @@ </button> </a> </div> - {#if !managers.length || managers.some((m) => m.id === user?.id)} + {#if isManager} <div class="grow"> <a href={`/fund/${id}/withdraw`}> <button class="btn"> @@ -102,19 +98,32 @@ {/if} </div> <div class="flex gap-2" data-sveltekit-prefetch="off"> - <a href={`/qr/${href}`} class="btn !w-auto grow"> - <PhLinkBold width="32" /> + <a href={`/fund/${id}/share`} class="btn grow"> + <PhShareNetworkBold width="32" /> <div class="my-auto">{$t("payments.shareLink")}</div> </a> - <a href={`${$page.url.pathname}/gift`} class="btn !w-auto grow"> - <PhGiftBold width="32" /> - <div class="my-auto">{$t("payments.giftLink")}</div> - </a> - <!-- <a href={`/qr/${lnurlw}`} class="btn !w-auto grow"> --> - <!-- <PhLightningFill width="24" class="text-yellow-300" /> --> - <!-- {$t("payments.lnurlw")} --> - <!-- </a> --> </div> + {#if isManager && authorizations.length} + <div class="space-y-2"> + <div class="font-bold text-lg">{$t("funds.authorizations")}</div> + {#each authorizations as auth} + <div class="flex items-center justify-between bg-white/5 rounded-xl px-4 py-3"> + <div> + <span class="font-semibold">{auth.fiat} {auth.currency}</span> + <span class="text-sm opacity-60 ml-2">{new Date(auth.created).toLocaleDateString(locale)}</span> + </div> + <div class="flex gap-2"> + <button onclick={() => { copy(`${$page.url.origin}/fund/${id}/sweep/auth/${auth.authId}`); }} class="opacity-60 hover:opacity-100"> + <PhCopyBold width={20} /> + </button> + <button onclick={() => deleteAuth(auth.authId)} class="text-red-500 hover:text-red-400"> + <PhTrashBold width={20} /> + </button> + </div> + </div> + {/each} + </div> + {/if} <Payments {payments} fund={true} {locale} {user} /> </div> </div> diff --git a/src/routes/(app)/fund/[id]/gift/+page.svelte b/src/routes/(app)/fund/[id]/gift/+page.svelte deleted file mode 100644 index c7c82ccd..00000000 --- a/src/routes/(app)/fund/[id]/gift/+page.svelte +++ /dev/null @@ -1,41 +0,0 @@ -<script lang="ts"> - import PhCopyBold from "virtual:icons/ph/copy-bold"; - import PhQrCodeBold from "virtual:icons/ph/qr-code-bold"; - import Amount from "$comp/Amount.svelte"; - import { t } from "$lib/translations"; - import { page } from "$app/stores"; - import Qr from "$comp/Qr.svelte"; - import { loc, copy } from "$lib/utils"; - - let { data } = $props(); - let { id, user, amount, rate } = $derived(data); - let text = $page.url.href.replace("gift", "sweep"); - let currency = $derived(() => (user ? user.currency : "CAD")); - let locale = $derived(loc(user)); -</script> - -<div class="container px-4 max-w-4xl mx-auto space-y-5 pb-10"> - <h1 class="text-center text-2xl font-semibold">{$t("payments.gift")}</h1> - - <Amount {amount} {currency} {rate} {locale} /> - - <div class="text-secondary shadow-lg p-8 mx-auto text-center space-y-2"> - <p>{$t("payments.giftDesc1")}</p> - <p>{$t("payments.giftDesc2")}</p> - </div> - - <div class="flex gap-1 min-w-0 items-center"> - <button - type="button" - class="btn break-all !h-auto flex-nowrap p-4 font-normal shrink !w-auto max-w-full" - onclick={() => copy(text)} - > - <PhCopyBold width="32" /> - <div class="my-auto break-all min-w-0 !w-auto max-w-full">{text}</div> - </button> - - <a href={`/qr/${encodeURIComponent(text)}`} class="btn !w-auto" aria-label="Show gift QR"> - <PhQrCodeBold width={32} /> - </a> - </div> -</div> diff --git a/src/routes/(app)/fund/[id]/gift/+page.server.ts b/src/routes/(app)/fund/[id]/share/+page.server.ts index 549fc9d0..549fc9d0 100644 --- a/src/routes/(app)/fund/[id]/gift/+page.server.ts +++ b/src/routes/(app)/fund/[id]/share/+page.server.ts diff --git a/src/routes/(app)/fund/[id]/share/+page.svelte b/src/routes/(app)/fund/[id]/share/+page.svelte new file mode 100644 index 00000000..5a3d0276 --- /dev/null +++ b/src/routes/(app)/fund/[id]/share/+page.svelte @@ -0,0 +1,64 @@ +<script lang="ts"> + import PhShareNetworkBold from "virtual:icons/ph/share-network-bold"; + import Numpad from "$comp/Numpad.svelte"; + import Toggle from "$comp/Toggle.svelte"; + import { page } from "$app/stores"; + import { goto } from "$app/navigation"; + import { loc, sats } from "$lib/utils"; + import { t } from "$lib/translations"; + + let { data }: any = $props(); + + let { id } = $page.params; + let amount = $state(0); + let rate = $derived(data.rate); + let user = $derived(data.user); + let currency = $derived(user ? user.currency : "CAD"); + let locale = $derived(loc(user)); + let fiat = $state(false); + let linkInFiat = $state(false); + let amountFiat = $state(0); + let element: HTMLElement | undefined = $state(); + let submit: HTMLButtonElement | undefined = $state(); + + function share() { + let sweepUrl: string; + if (linkInFiat) { + sweepUrl = `${$page.url.origin}/fund/${id}/sweep/${amountFiat}/${currency}`; + } else { + sweepUrl = `${$page.url.origin}/fund/${id}/sweep/${amount}`; + } + goto(`/qr/${encodeURIComponent(sweepUrl)}`); + } + + function shareAll() { + goto(`/qr/${encodeURIComponent(`${$page.url.origin}/fund/${id}/sweep`)}`); + } +</script> + +<div class="container px-4 max-w-lg mx-auto space-y-5 pb-10 mt-10"> + <h1 class="text-center text-2xl font-semibold">{$t("payments.shareLink")}</h1> + + <Numpad bind:amount bind:fiat {currency} {rate} {locale} bind:element bind:amountFiat {submit} /> + + <div class="flex items-center justify-between"> + <span class="text-lg">{$t("payments.denominateIn")} {currency}</span> + <Toggle id="linkInFiat" bind:value={linkInFiat} /> + </div> + + <button class="btn" bind:this={submit} onclick={share}> + <PhShareNetworkBold width="32" /> + {$t("payments.shareSetAmount")} + </button> + + <div class="flex items-center gap-4"> + <hr class="grow border-white/20" /> + <span class="text-secondary shrink-0">or</span> + <hr class="grow border-white/20" /> + </div> + + <button class="btn btn-accent" onclick={shareAll}> + <PhShareNetworkBold width="32" /> + {$t("payments.shareEntireBalance")} + </button> +</div> diff --git a/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts index a20f8764..362e3387 100644 --- a/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts +++ b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts @@ -7,7 +7,20 @@ import { env } from "$env/dynamic/private"; export async function load({ cookies, request, params, parent }) { let { user } = await parent(); const { id } = params; - let [amount, currency] = params.amount.split("/"); + const parts = params.amount.split("/"); + + let amount: string | undefined; + let currency: string | undefined; + let authId: string | undefined; + + let urlHasCurrency = false; + if (parts[0] === "auth") { + authId = parts[1]; + } else { + amount = parts[0]; + currency = parts[1]; + if (currency) urlHasCurrency = true; + } if (!currency) currency = user?.currency; if (currency) currency = currency.toUpperCase(); @@ -17,8 +30,17 @@ export async function load({ cookies, request, params, parent }) { if (!amount) { const balance = await get(`/fund/${id}`); - amount = balance.authorization || balance.amount; - } else if (currency) { + if (authId) { + const a = balance.authorizations?.find((a: any) => a.authId === authId); + if (a) amount = a.amount; + } + if (!amount && balance.authorizations?.length) { + const a = balance.authorizations[0]; + authId = a.authId; + amount = a.amount; + } + if (!amount) amount = balance.amount; + } else if (urlHasCurrency) { amount = String(Math.round((Number(amount) * sats) / rate)); } @@ -45,7 +67,7 @@ export async function load({ cookies, request, params, parent }) { } try { - ({ amount } = await post("/take", { amount, id }, auth(cookies))); + ({ amount } = await post("/take", { amount, id, authId }, auth(cookies))); } catch { redirect(307, `/fund/${id}`); } diff --git a/src/routes/(app)/fund/[id]/withdraw/+page.svelte b/src/routes/(app)/fund/[id]/withdraw/+page.svelte index 8dd52e6e..c09c9049 100644 --- a/src/routes/(app)/fund/[id]/withdraw/+page.svelte +++ b/src/routes/(app)/fund/[id]/withdraw/+page.svelte @@ -40,7 +40,7 @@ <div class="text-red-600 text-center">{form.message}</div> {/if} -<div class="container px-4 mt-20 max-w-xl mx-auto"> +<div class="container px-4 mt-20 max-w-xl mx-auto space-y-2"> <Numpad bind:amount {currency} {fiat} {rate} {submit} {locale} /> <form use:enhance={handler(toggle)} method="POST" class="space-y-5"> @@ -69,4 +69,5 @@ </button> </div> </form> + </div> diff --git a/src/routes/(app)/qr/[text]/+page.svelte b/src/routes/(app)/qr/[text]/+page.svelte index 9c030ef1..d89343c2 100644 --- a/src/routes/(app)/qr/[text]/+page.svelte +++ b/src/routes/(app)/qr/[text]/+page.svelte @@ -1,4 +1,5 @@ <script> + import PhArrowLeftBold from "virtual:icons/ph/arrow-left-bold"; import PhCopyBold from "virtual:icons/ph/copy-bold"; import Qr from "$comp/Qr.svelte"; import { back, copy } from "$lib/utils"; @@ -17,4 +18,11 @@ <PhCopyBold width="32" /> <div class="my-auto">{text}</div> </button> + + <div class="flex justify-center"> + <button type="button" class="btn btn-accent !w-auto" onclick={back}> + <PhArrowLeftBold width="24" /> + Back + </button> + </div> </div> diff --git a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.server.ts b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.server.ts index 0c4b4d96..31d6c65d 100644 --- a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.server.ts +++ b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.server.ts @@ -1,5 +1,5 @@ import getRates from "$lib/rates"; -import { auth, fd, post } from "$lib/utils"; +import { auth, fd, post, sats } from "$lib/utils"; import { fail, redirect } from "@sveltejs/kit"; export async function load({ parent, params }) { @@ -14,8 +14,20 @@ export async function load({ parent, params }) { export const actions = { default: async ({ cookies, request }) => { const body = await fd(request); - let p; + if (body.authorize === "true") { + const rate = Number(body.rate); + const fiat = parseFloat(((Number(body.amount) * rate) / sats).toFixed(2)); + try { + await post("/authorize", { amount: body.amount, currency: body.currency, fiat, id: body.fund }, auth(cookies)); + } catch (e) { + const { message } = e as Error; + return fail(400, { message }); + } + redirect(307, `/fund/${body.fund}`); + } + + let p; try { p = await post("/payments", body, auth(cookies)); } catch (e) { diff --git a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte index baeb8795..45e76727 100644 --- a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte +++ b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte @@ -6,6 +6,7 @@ import { page } from "$app/stores"; import { fiat as fiatStore, pin } from "$lib/store"; import handler from "$lib/handler"; + import Toggle from "$comp/Toggle.svelte"; import { loc, focus, s, f, sats } from "$lib/utils"; import { applyAction, deserialize } from "$app/forms"; import { invalidateAll } from "$app/navigation"; @@ -21,6 +22,7 @@ let fiat = $state(); let locale = $derived(loc(user)); + let authorize = $state(false); let submit = $state(); let submitting: boolean = $state(false); let toggle = () => (submitting = !submitting); @@ -58,29 +60,48 @@ <input name="fund" value={name} type="hidden" /> <input name="amount" value={amount} type="hidden" /> <input name="pin" value={$pin} type="hidden" /> + <input name="authorize" value={authorize} type="hidden" /> + <input name="currency" value={currency} type="hidden" /> + <input name="rate" value={rate} type="hidden" /> + + <div class="flex items-center justify-center gap-3 mt-4"> + <span class="text-sm" class:opacity-40={authorize}>{$t("funds.immediateContribution")}</span> + <Toggle id="authorizeToggle" bind:value={authorize} /> + <span class="text-sm" class:opacity-40={!authorize}>{$t("funds.fiatAuthorization")}</span> + </div> + <p class="text-center text-sm opacity-60 mt-2"> + {#if authorize} + {$t("funds.authorizeDesc")} + {:else} + {$t("funds.contributeDesc")} + {/if} + </p> <div class="flex gap-2"> - <button - type="button" - class="btn !w-auto grow" - onclick={setMax} - disabled={submitting} - onkeydown={setMax} - > - {#if $fiatStore} - Max {f((balance * rate) / sats, currency, locale)} - {:else} - Max ⚡️{s(balance)} - {/if} - </button> + {#if !authorize} + <button + type="button" + class="btn !w-auto grow" + onclick={setMax} + disabled={submitting} + onkeydown={setMax} + > + {#if $fiatStore} + Max {f((balance * rate) / sats, currency, locale)} + {:else} + Max ⚡️{s(balance)} + {/if} + </button> + {/if} <button bind:this={submit} type="submit" class="btn btn-accent !w-auto grow" disabled={submitting || !amount}> {#if submitting} <Spinner /> {:else} - <div class="my-auto">{$t("payments.next")}</div> + <div class="my-auto">{authorize ? $t("payments.authorize") : $t("payments.next")}</div> {/if} </button> </div> </form> + </div> |
