diff options
| author | Jurjen de Vries <[email protected]> | 2025-09-11 00:48:15 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-09-11 00:48:15 +0200 |
| commit | 0c69c9848c6b98c92609d209103efc60003c9723 (patch) | |
| tree | 445121def885f13d0f80c06864ad2ff4d5ef95a1 /src/routes | |
| parent | 2abcf6e435fca5d86960f3ba4228002bfb0a19e6 (diff) | |
| parent | 9277b2921e09ee9dd5f39cf4af30a2330f4d95d1 (diff) | |
| download | coinos-ui-0c69c9848c6b98c92609d209103efc60003c9723.tar.gz coinos-ui-0c69c9848c6b98c92609d209103efc60003c9723.zip | |
Merge branch 'coinos:master' into master
Diffstat (limited to 'src/routes')
70 files changed, 1766 insertions, 1331 deletions
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index af4dbf2c..830e5291 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -46,22 +46,35 @@ checkSocket(); $pin = getCookie("pin"); - if (window.NDEFReader && user.nfc) { - try { - $ndef = new NDEFReader(); - await $ndef.scan(); - - $ndef.addEventListener("readingerror", (e) => { - console.log("nfc error", e); - }); - - $ndef.addEventListener("reading", ({ message, serialNumber }) => { - goto(`/send/${message}`); - }); - } catch (error) { - console.log("Argh! " + error); - } - } + // if (window.NDEFReader) { + // try { + // $ndef = new NDEFReader(); + // await $ndef.scan(); + // + // $ndef.addEventListener("readingerror", (e) => { + // console.log("nfc error", e); + // }); + // + // $ndef.addEventListener("reading", ({ message, serialNumber }) => { + // console.log("MESSAGE", message); + // + // const record = message.records[0]; + // + // if (record.recordType === "url") { + // const textDecoder = new TextDecoder(); + // const url = textDecoder.decode(record.data); + // console.log("URL:", url); + // + // // Navigate to the URL or use it however you want + // goto(`/send/${encodeURIComponent(url)}`); + // } else { + // console.error("Unsupported record type:", record.recordType); + // } + // }); + // } catch (error) { + // console.log("Argh! " + error); + // } + // } } }); diff --git a/src/routes/(app)/[username]/(profile)/+layout.svelte b/src/routes/(app)/[username]/(profile)/+layout.svelte index afd94ab0..b384344e 100644 --- a/src/routes/(app)/[username]/(profile)/+layout.svelte +++ b/src/routes/(app)/[username]/(profile)/+layout.svelte @@ -16,12 +16,13 @@ } from "$lib/utils"; import { t } from "$lib/translations"; import { sign, send } from "$lib/nostr"; - import { bech32m } from "@scure/base"; + import { bech32 } from "@scure/base"; import { page } from "$app/stores"; + import { PUBLIC_DOMAIN } from "$env/static/public"; let { data, children } = $props(); - let { encode, toWords } = bech32m; + let { encode, toWords } = bech32; let { events, @@ -107,6 +108,17 @@ : `${stripped}@${$page.url.host}`, ); let profile = $derived(`${$page.url.host}/${subject.anon ? npub : stripped}`); + let lnurl = $derived( + encode( + "lnurl", + toWords( + new TextEncoder().encode( + `https://${PUBLIC_DOMAIN}/p/${subject.anon ? npub : stripped}`, + ), + ), + 20000, + ), + ); $effect(() => followList.then((l) => (list = l))); let following = $derived(list.some((t) => t.includes(subject.pubkey))); </script> @@ -116,19 +128,23 @@ <div class="w-full lg:w-[280px] xl:w-[360px] lg:absolute space-y-2 left-20 mx-auto" > - <button - type="button" - class="flex text-3xl font-bold text-center mx-auto justify-center gap-2" + <div + class="flex text-3xl font-bold text-center mx-auto justify-center gap-1 items-center" > - <div class="flex gap-1" onclick={toggleDetails}> - <div class="my-auto break-words">{display || username}</div> - <iconify-icon noobserver icon="ph:qr-code-bold" width="32" - ></iconify-icon> - </div> + <button class="flex gap-1 items-center" onclick={toggleDetails}> + <div class="break-words">{display || username}</div> + <iconify-icon noobserver icon="ph:list-bold" width="32"></iconify-icon> + </button> + {#if subject.id === user?.id} + <a href="/settings/profile" class="btn contents"> + <iconify-icon noobserver icon="ph:pencil-bold" width="32" + ></iconify-icon> + </a> + {/if} <!-- <a href={`/${subject.pubkey}/notes`}> --> <!-- <iconify-icon noobserver icon="ph:note-bold" width="32"></iconify-icon> --> <!-- </a> --> - </button> + </div> {#if subject.about} <div @@ -169,7 +185,7 @@ <!-- </div> --> {#if showDetails} - <div class="space-y-5 pb-20"> + <div class="space-y-5"> {#if lnaddr} <div> <div class="text-secondary">{$t("user.lightningAddress")}</div> @@ -214,18 +230,18 @@ </div> </div> <div> - <div class="text-secondary">{$t("user.nostrPubkey")}</div> + <div class="text-secondary">{$t("user.lnurl")}</div> <div class="flex gap-4"> <div class="break-all grow text-xl"> - {subject.pubkey} + {lnurl} </div> - <div class="flex my-auto gap-1"> - <button class="my-auto" onclick={() => copy(subject.pubkey)} + <div class="flex mb-auto gap-1"> + <button class="my-auto" onclick={() => copy(`lightning:${lnurl}`)} ><iconify-icon noobserver icon="ph:copy-bold" width="32" ></iconify-icon></button > <a - href={`/qr/${encodeURIComponent(subject.pubkey)}`} + href={`/${n}/accepted/${encodeURIComponent(`lightning:${lnurl}`)}`} class="my-auto" > <iconify-icon noobserver icon="ph:qr-code-bold" width="32" @@ -234,24 +250,24 @@ </div> </div> </div> - <div> - <div class="text-secondary">{$t("user.nostrPubkey")}</div> - <div class="flex gap-4"> - <div class="break-all grow text-xl"> - {npub} - </div> - <div class="flex my-auto gap-1"> - <button class="my-auto" onclick={() => copy(npub)} - ><iconify-icon noobserver icon="ph:copy-bold" width="32" - ></iconify-icon></button - > - <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto"> - <iconify-icon noobserver icon="ph:qr-code-bold" width="32" - ></iconify-icon> - </a> - </div> - </div> - </div> + <!-- <div> --> + <!-- <div class="text-secondary">{$t("user.nostrPubkey")}</div> --> + <!-- <div class="flex gap-4"> --> + <!-- <div class="break-all grow text-xl"> --> + <!-- {npub} --> + <!-- </div> --> + <!-- <div class="flex my-auto gap-1"> --> + <!-- <button class="my-auto" onclick={() => copy(npub)} --> + <!-- ><iconify-icon noobserver icon="ph:copy-bold" width="32" --> + <!-- ></iconify-icon></button --> + <!-- > --> + <!-- <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto"> --> + <!-- <iconify-icon noobserver icon="ph:qr-code-bold" width="32" --> + <!-- ></iconify-icon> --> + <!-- </a> --> + <!-- </div> --> + <!-- </div> --> + <!-- </div> --> </div> {/if} @@ -314,6 +330,7 @@ {#if currency} <div class="flex fixed w-full px-4 bg-base-100 py-2 pb-3 bottom-0 bg-opacity-90 tabular-nums" + style="bottom: calc(var(--safe-area-inset-bottom) + 8px);" > <div class="text-secondary flex mr-auto"> <div class="flex mr-1"> diff --git a/src/routes/(app)/[username]/(profile)/+page.svelte b/src/routes/(app)/[username]/(profile)/+page.svelte index bd421545..7d342994 100644 --- a/src/routes/(app)/[username]/(profile)/+page.svelte +++ b/src/routes/(app)/[username]/(profile)/+page.svelte @@ -6,7 +6,7 @@ import Account from "$comp/Account.svelte"; import Balance from "$comp/Balance.svelte"; import { t } from "$lib/translations"; - import { installPrompt } from "$lib/store"; + import { installPrompt, password } from "$lib/store"; import { afterNavigate, preloadData } from "$app/navigation"; import { page } from "$app/stores"; @@ -18,6 +18,7 @@ }); let { accounts, subject, rate, user } = $derived(data); + let { locked } = $derived(user); let install = async () => { if (!$installPrompt) return; @@ -31,6 +32,35 @@ </script> <div class="space-y-2"> + {#if user?.destination && !user?.autowithdraw} + <div class="rounded-2xl space-y-2 p-4 shadow-lg"> + <h1 class="text-3xl font-bold"> + {$t("user.settings.confirmAutoWithdrawal")} + </h1> + <div> + {$t("user.settings.confirmAutoWithdrawalDesc")} + </div> + + <a href="/settings/account" class="btn"> + <iconify-icon icon="ph:gear-bold" width="32"></iconify-icon> + Settings</a + > + </div> + {/if} + {#if user?.fresh} + <div> + {$t("user.settings.yourUsername")} <b>{user?.username}</b> + {#if $password} + <br /> + {$t("user.settings.yourPassword")} + <b>{$password}</b>{/if} + </div> + + <a href="/settings/profile" class="btn"> + <iconify-icon icon="ph:gear-bold" width="32"></iconify-icon> + {$t("user.settings.continueSettingUp")}</a + > + {/if} {#if user?.id && user.id === subject.id} <div class="space-y-5" data-sveltekit-preload-data="false"> {#each accounts as account, i} @@ -38,13 +68,11 @@ {/each} </div> - <a href={`/account/savings`} class="contents"> - <button class="btn btn-lg w-full rounded-2xl whitespace-nowrap"> - <iconify-icon noobserver icon="ph:plus-circle-bold" width="32" - ></iconify-icon> - {$t("accounts.addAccount")} - </button> - </a> + {#if locked} + <div class="text-sm"> + {$t("incident")} + </div> + {/if} {#if $installPrompt} <button class="btn btn-accent lg:hidden" onclick={install}> @@ -56,6 +84,8 @@ {/if} </div> +<!-- <a href="/funder" class="btn">Funder</a> --> + <div class="fixed inset-x-0 mx-auto flex bottom-16 px-4"> {#if user?.username !== subject.username && (!subject.anon || subject.lud16)} <a diff --git a/src/routes/(app)/[username]/accepted/[text]/+page.server.ts b/src/routes/(app)/[username]/accepted/[text]/+page.server.ts new file mode 100644 index 00000000..aa25ce7f --- /dev/null +++ b/src/routes/(app)/[username]/accepted/[text]/+page.server.ts @@ -0,0 +1,6 @@ +export async function load({ params, url }) { + const { text } = params; + const query = new URLSearchParams(url.search); + const nfc = !!query.get("nfc"); + return { nfc, text }; +} diff --git a/src/routes/(app)/[username]/accepted/[text]/+page.svelte b/src/routes/(app)/[username]/accepted/[text]/+page.svelte new file mode 100644 index 00000000..1483a294 --- /dev/null +++ b/src/routes/(app)/[username]/accepted/[text]/+page.svelte @@ -0,0 +1,57 @@ +<script> + import Icon from "$comp/Icon.svelte"; + import Qr from "$comp/Qr.svelte"; + import { back, copy } from "$lib/utils"; + import { bech32 } from "@scure/base"; + import { page } from "$app/stores"; + import { PUBLIC_DOMAIN } from "$env/static/public"; + + let { encode, toWords } = bech32; + let { data } = $props(); + let { nfc, text } = $derived(data); + let { username } = $derived(data.subject); + let url = `https://coinos.io/p/${username.toLowerCase()}`; + + let lnurl = $derived( + `https://${PUBLIC_DOMAIN}/ln/${encode( + "lnurl", + toWords( + new TextEncoder().encode(`https://${PUBLIC_DOMAIN}/p/${username}`), + ), + 20000, + )}`, + ); +</script> + +<div class="px-2"> + <div + class="border-8 border-black p-8 max-w-[580px] mx-auto mt-20 space-y-2 rounded-3xl relative" + > + <div class="flex gap-2 mx-auto justify-center"> + <!-- <img src="/images/bitcoin.png" class="w-16 h-16 my-auto" /> --> + <img src="/images/bitcoin-logo.png" class="my-auto w-[400px]" /> + <!-- <h3 class="text-5xl font-bold text-center my-auto"> --> + <!-- bitcoin --> + <!-- </h3> --> + </div> + {#if nfc} + <img + src="/images/tap.png" + class="w-24 absolute top-52 left-4 rotate-[-10deg]" + /> + <img + src="/images/arrow.png" + class="w-24 scale-x-[-1] rotate-[120deg] absolute top-52 left-32 w-20 h-52" + /> + {/if} + <Qr {text} icon="icon.png" /> + <div class="text-center text-4xl font-bold break-all"> + coinos.io/{username.toLowerCase()} + </div> + </div> + + <button onclick={() => copy(lnurl)} class="flex gap-1 m-auto my-8"> + <iconify-icon icon="ph:copy-bold" width={32}></iconify-icon> + LNURL</button + > +</div> diff --git a/src/routes/(app)/[username]/accepted/[text]/raw/+server.ts b/src/routes/(app)/[username]/accepted/[text]/raw/+server.ts new file mode 100644 index 00000000..a24d3a3c --- /dev/null +++ b/src/routes/(app)/[username]/accepted/[text]/raw/+server.ts @@ -0,0 +1,16 @@ +import Qr from "qrcode-base64"; + +export async function GET({ params }) { + const { text } = params; + + const dataUri = Qr.drawImg(text, { size: 600 }); + const base64Data = dataUri.split(",")[1]; + const binaryData = Buffer.from(base64Data, "base64"); + + return new Response(binaryData, { + headers: { + "Content-Type": "image/gif", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/src/routes/(app)/[username]/accepted/create/+page.svelte b/src/routes/(app)/[username]/accepted/create/+page.svelte new file mode 100644 index 00000000..74316ba9 --- /dev/null +++ b/src/routes/(app)/[username]/accepted/create/+page.svelte @@ -0,0 +1,64 @@ +<script> + import { run, preventDefault } from 'svelte/legacy'; + + import { goto } from "$app/navigation"; + import { browser } from "$app/environment"; + import { t } from "$lib/translations"; + import Icon from "$comp/Icon.svelte"; + let el = $state(), text = $state(), pasted = $state(); + + let keypress = (e) => + e.key === "Enter" ? e.preventDefault() || el.click() : (pasted = false); + + let paste = async () => { + text = await navigator.clipboard.readText(); + pasted = true; + }; + + let submit = () => goto(`/qr/${encodeURIComponent(text)}`); + + run(() => { + if (browser && pasted && text) el.click() && (pasted = false); + }); +</script> + +<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> + <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold"> + Create QR + </h1> + + <form onsubmit={preventDefault(submit)} class="space-y-2 text-xl"> + <div class="mb-2"> + <textarea + use:focus + name="text" + placeholder="Enter some text or a URL to put in a QR" + onkeypress={keypress} + class="w-full p-4 border rounded-xl h-32 text-xl" + bind:value={text} + onpaste={() => (pasted = true)} + autocapitalize="none" +></textarea> + </div> + + <div class="flex gap-2"> + <button + type="button" + class="flex border rounded-2xl px-6 py-5 font-bold hover:opacity-80 w-full bg-primary justify-center gap-2" + onclick={paste} + > + <Icon icon="paste" style="w-8 my-auto" /> + <div class="my-auto">{$t("user.send.paste")}</div> + </button> + + <button + bind:this={el} + type="submit" + class="flex bg-black text-white border rounded-2xl px-6 py-5 w-full font-bold gap-2 justify-center" + > + <Icon icon="send" style="w-8 my-auto invert" /> + <div class="my-auto">{$t("user.dashboard.go")}</div> + </button> + </div> + </form> +</div> diff --git a/src/routes/(app)/[username]/receive/+page.svelte b/src/routes/(app)/[username]/receive/+page.svelte index b5b3153b..cd692abf 100644 --- a/src/routes/(app)/[username]/receive/+page.svelte +++ b/src/routes/(app)/[username]/receive/+page.svelte @@ -94,12 +94,12 @@ if (typeof $amountPrompt === "undefined") $amountPrompt = true; amount = newAmount; invoice.amount = newAmount; - invoice.tip = 0; + invoice.tip = null; await update(); }; - let newAmount = $state(); + let newAmount = $state(0); let settingAmount = $state(); let fiat = $state(true); let toggleAmount = () => (settingAmount = !settingAmount); diff --git a/src/routes/(app)/fund/[id]/+layout.server.ts b/src/routes/(app)/fund/[id]/+layout.server.ts new file mode 100644 index 00000000..3669eb7d --- /dev/null +++ b/src/routes/(app)/fund/[id]/+layout.server.ts @@ -0,0 +1,12 @@ +import getRates from "$lib/rates"; +import { get } from "$lib/utils"; + +export const load = async ({ depends, params: { id }, parent }) => { + depends("app:payments"); + const data = await get(`/fund/${id}`); + data.managers = await get(`/fund/${id}/managers`); + const rates = await getRates(); + const { user } = await parent(); + data.rate = rates[user?.currency || "USD"]; + return data; +}; diff --git a/src/routes/(app)/fund/[id]/+page.server.ts b/src/routes/(app)/fund/[id]/+page.server.ts index a4f402af..cd2bd979 100644 --- a/src/routes/(app)/fund/[id]/+page.server.ts +++ b/src/routes/(app)/fund/[id]/+page.server.ts @@ -1,17 +1,6 @@ -import getRates from "$lib/rates"; -import { auth, get, post } from "$lib/utils"; +import { auth, post } from "$lib/utils"; import { redirect } from "@sveltejs/kit"; -export const load = async ({ depends, params: { id }, parent }) => { - depends("app:payments"); - const data = await get(`/fund/${id}`); - data.managers = await get(`/fund/${id}/managers`); - const rates = await getRates(); - const { user } = await parent(); - data.rate = rates[user?.currency || "USD"]; - return data; -}; - export const actions = { default: async ({ cookies, locals, params }) => { const { user } = locals; diff --git a/src/routes/(app)/fund/[id]/+page.svelte b/src/routes/(app)/fund/[id]/+page.svelte index 6cb3b05d..da869fed 100644 --- a/src/routes/(app)/fund/[id]/+page.svelte +++ b/src/routes/(app)/fund/[id]/+page.svelte @@ -69,13 +69,15 @@ {/if} </div> - <a - href={`/fund/${id}/access`} - class="btn !w-auto ml-auto text-secondary grow" - > - <iconify-icon icon="ph:gear-bold" width={32}></iconify-icon> - {$t("funds.manage")} - </a> + {#if (user && !managers.length) || managers.some((m) => m.id === user?.id)} + <a + href={`/fund/${id}/access`} + class="btn !w-auto ml-auto text-secondary grow" + > + <iconify-icon icon="ph:gear-bold" width={32}></iconify-icon> + {$t("funds.manage")} + </a> + {/if} </div> <div class="flex flex-wrap gap-2" data-sveltekit-prefetch="off"> <div class="grow"> @@ -87,7 +89,7 @@ </button> </a> </div> - {#if !managers.length || managers.some((m) => m.id === user.id)} + {#if !managers.length || managers.some((m) => m.id === user?.id)} <div class="grow"> <a href={`/fund/${id}/withdraw`}> <button class="btn"> @@ -109,6 +111,11 @@ ></iconify-icon> <div class="my-auto">{$t("payments.shareLink")}</div> </a> + <a href={`${$page.url.pathname}/gift`} class="btn !w-auto grow"> + <iconify-icon noobserver icon="ph:gift-bold" width="32" + ></iconify-icon> + <div class="my-auto">{$t("payments.giftLink")}</div> + </a> <!-- <a href={`/qr/${lnurlw}`} class="btn !w-auto grow"> --> <!-- <iconify-icon --> <!-- noobserver --> diff --git a/src/routes/(app)/fund/[id]/access/+page.server.ts b/src/routes/(app)/fund/[id]/access/+page.server.ts index c38cb29b..36191643 100644 --- a/src/routes/(app)/fund/[id]/access/+page.server.ts +++ b/src/routes/(app)/fund/[id]/access/+page.server.ts @@ -11,11 +11,8 @@ export const actions = { const form = await fd(request); try { - console.log("SUBMITTING", form); const managers = await post("/fund/managers", form, auth(cookies)); - console.log(managers); - return { managers }; } catch (e) { const { message } = e as Error; diff --git a/src/routes/(app)/fund/[id]/access/+page.svelte b/src/routes/(app)/fund/[id]/access/+page.svelte index f18d6357..cc2921c5 100644 --- a/src/routes/(app)/fund/[id]/access/+page.svelte +++ b/src/routes/(app)/fund/[id]/access/+page.svelte @@ -1,15 +1,20 @@ <script> - import { fail, post } from "$lib/utils"; + import { focus, fail, post } from "$lib/utils"; import { enhance } from "$app/forms"; import { t } from "$lib/translations"; import Avatar from "$comp/Avatar.svelte"; let { data } = $props(); let { id, managers } = $derived(data); + let m = $state(data.managers); + $effect(() => (m = data.managers)); let username = $state(); - let del = async ({ id }) => { + let del = async (e, user) => { + e.preventDefault(); try { - await post("/post/fund/access/delete", { id }); + m = await post(`/post/fund/${id}/managers/delete`, { + id: user.id, + }); } catch (e) { fail(e.message); } @@ -25,14 +30,14 @@ Fund managers </h1> - {#each managers as c} + {#each m as c} <a href={`/${c.username}`} class="contents"> <div class="flex hover:bg-base-200 p-2 items-center"> <Avatar user={c} size={20} disabled={true} /> <div class="my-auto text-left"> <p class="ml-1 text-lg break-words">{c.username}</p> </div> - <button onclick={() => del(c)} class="ml-auto"> + <button onclick={(e) => del(e, c)} class="ml-auto"> <iconify-icon icon="ph:trash-bold" width="32"></iconify-icon> </button> </div> @@ -42,6 +47,7 @@ <form method="post" use:enhance class="space-y-2"> <input type="hidden" name="id" value={id} /> <input + use:focus name="username" bind:value={username} placeholder={$t("funds.username")} diff --git a/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts new file mode 100644 index 00000000..e5683ae6 --- /dev/null +++ b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts @@ -0,0 +1,36 @@ +import getRates from "$lib/rates"; +import { auth, get, post, sats } from "$lib/utils"; +import { error, redirect } from "@sveltejs/kit"; + +export async function load({ cookies, params, parent }) { + const { user } = await parent(); + const { id } = params; + let [amount, currency] = params.amount.split("/"); + let fiat; + + if (!currency) currency = user?.currency; + if (currency) currency = currency.toUpperCase(); + + const rates = await getRates(); + const rate = rates[currency || "USD"]; + if (currency && !rate) error(500, "Invalid currency symbol"); + + if (!amount) { + const balance = await get(`/fund/${id}`); + amount = balance.amount; + } else if (currency) { + fiat = amount; + amount = Math.round((fiat * sats) / rate); + } + + if (!amount) redirect(307, `/fund/${id}`); + + try { + await post("/authorize", { amount, currency, fiat, id }, auth(cookies)); + } catch (e) { + console.log("caught", e); + redirect(307, `/fund/${id}`); + } + + return { amount, currency, id, rate, rates }; +} diff --git a/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte new file mode 100644 index 00000000..b94c1ce9 --- /dev/null +++ b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte @@ -0,0 +1,27 @@ +<script> + import { onMount } from "svelte"; + import Amount from "$comp/Amount.svelte"; + import Success from "$comp/Success.svelte"; + import { scale } from "svelte/transition"; + import { toFiat, f, s } from "$lib/utils"; + import Icon from "$comp/Icon.svelte"; + import { t } from "$lib/translations"; + import { loc } from "$lib/utils"; + + let { data } = $props(); + + let { amount, currency, id, user, rate, rates } = data; + let locale = $derived(loc(user)); +</script> + +<div class="container px-4 text-center mx-auto"> + <Success {amount} {rate} {currency} {locale} title="Authorized" /> +</div> + +<a href={`/fund/${id}`} aria-label="Continue"> + <div class="opacity-0 w-screen h-screen fixed top-24 left-0 z-50"></div> +</a> + +<div class="flex justify-center mt-4"> + {$t("payments.tapAnywhere")} +</div> diff --git a/src/routes/(app)/fund/[id]/gift/+page.server.ts b/src/routes/(app)/fund/[id]/gift/+page.server.ts new file mode 100644 index 00000000..bd39d82b --- /dev/null +++ b/src/routes/(app)/fund/[id]/gift/+page.server.ts @@ -0,0 +1,6 @@ +export const load = async ({ params, parent }) => { + const p = await parent(); + const { amount, rate } = p; + const { id } = params; + return { amount, id, rate }; +}; diff --git a/src/routes/(app)/fund/[id]/gift/+page.svelte b/src/routes/(app)/fund/[id]/gift/+page.svelte new file mode 100644 index 00000000..7c65d314 --- /dev/null +++ b/src/routes/(app)/fund/[id]/gift/+page.svelte @@ -0,0 +1,40 @@ +<script> + import Amount from "$comp/Amount.svelte"; + import { t } from "$lib/translations"; + import { page } from "$app/stores"; + import Icon from "$comp/Icon.svelte"; + 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 = user ? user.currency : "CAD"; + let locale = 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)} + > + <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> + <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"> + <iconify-icon icon="ph:qr-code-bold" width={32}></iconify-icon> + </a> + </div> +</div> diff --git a/src/routes/(app)/fund/[id]/sweep/+page.server.ts b/src/routes/(app)/fund/[id]/sweep/+page.server.ts deleted file mode 100644 index 5bb42fa4..00000000 --- a/src/routes/(app)/fund/[id]/sweep/+page.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { get, auth, post } from "$lib/utils"; -import { redirect } from "@sveltejs/kit"; - -export async function load({ cookies, params, parent }) { - const { user } = await parent(); - const { id } = params; - const { amount } = await get(`/fund/${id}`); - - if (!amount) redirect(307, `/fund/${id}`); - if (!user) redirect(307, `/register?redirect=/fund/${id}/sweep`); - await post("/take", { amount, id }, auth(cookies)); - return { amount, id }; -} diff --git a/src/routes/(app)/fund/[id]/sweep/+page.svelte b/src/routes/(app)/fund/[id]/sweep/+page.svelte deleted file mode 100644 index cd89f6ae..00000000 --- a/src/routes/(app)/fund/[id]/sweep/+page.svelte +++ /dev/null @@ -1,41 +0,0 @@ -<script> - import { scale } from "svelte/transition"; - import { toFiat, f, s } from "$lib/utils"; - import Icon from "$comp/Icon.svelte"; - import { t } from "$lib/translations"; - - let { data } = $props(); - - let { amount, id, user, rates } = data; - let { currency } = user; - let rate = $derived(rates[currency]); -</script> - -<div class="container px-4 max-w-xl mx-auto mt-10 space-y-5 text-center"> - <div class="w-full flex"> - <a href="/" class="mx-auto"> - <Icon icon="logo" /> - </a> - </div> - - <div class="flex w-full py-5 max-w-[200px] mx-auto" in:scale={{ start: 0.5 }}> - <Icon icon="check" style="mx-auto" /> - </div> - <h1 class="text-3xl md:text-4xl font-bold mb-6"> - {$t("payments.fundsClaimed")} - </h1> - <h2 class="text-2xl md:text-3xl font-semibold"> - {f(toFiat(amount, rate), currency)} - </h2> - <h3 class="text-secondary md:text-lg mb-6 mt-1"> - ⚡️{s(amount)} - </h3> -</div> - -<a href={`/payments`}> - <div class="opacity-0 w-screen h-screen fixed top-0 left-0 z-50"></div> -</a> - -<div class="flex justify-center"> - {$t("payments.tapAnywhere")} -</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 new file mode 100644 index 00000000..78df3461 --- /dev/null +++ b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts @@ -0,0 +1,51 @@ +import { randomName, randomPassword } from "$lib/random"; +import getRates from "$lib/rates"; +import { auth, get, post, register, sats } from "$lib/utils"; +import { error, redirect } from "@sveltejs/kit"; + +export async function load({ cookies, request, params, parent }) { + let { user } = await parent(); + const { id } = params; + let [amount, currency] = params.amount.split("/"); + + if (!currency) currency = user?.currency; + if (currency) currency = currency.toUpperCase(); + const rates = await getRates(); + const rate = rates[currency || "USD"]; + if (currency && !rate) error(500, "Invalid currency symbol"); + + if (!amount) { + const balance = await get(`/fund/${id}`); + amount = balance.authorization || balance.amount; + } else if (currency) { + amount = Math.round((amount * sats) / rate); + } + + const username = randomName() + Math.floor(Math.random() * 99) + 1; + const password = randomPassword(); + + if (!amount) redirect(307, `/fund/${id}`); + if (!user) { + const ip = request.headers.get("cf-connecting-ip"); + + user = { + username, + password, + fresh: true, + }; + + let redirectUrl = `/fund/${id}/sweep`; + if (params.amount && !params.currency) redirectUrl += `/${params.amount}`; + if (params.currency) redirectUrl += `/${params.amount}/params.currency}`; + + await register(user, ip, cookies, redirectUrl); + } + + try { + ({ amount } = await post("/take", { amount, id }, auth(cookies))); + } catch (e) { + redirect(307, `/fund/${id}`); + } + + return { amount, currency, id, rate, rates, password }; +} diff --git a/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte new file mode 100644 index 00000000..7a7d5402 --- /dev/null +++ b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte @@ -0,0 +1,36 @@ +<script> + import { onMount } from "svelte"; + import { password } from "$lib/store"; + import Amount from "$comp/Amount.svelte"; + import Success from "$comp/Success.svelte"; + import { scale } from "svelte/transition"; + import { toFiat, f, s } from "$lib/utils"; + import Icon from "$comp/Icon.svelte"; + import { t } from "$lib/translations"; + import { loc } from "$lib/utils"; + + let { data } = $props(); + + let { amount, currency, id, user, rate, rates, password: pw } = data; + let locale = $derived(loc(user)); + + onMount(() => ($password = pw)); +</script> + +<div class="container px-4 text-center mx-auto"> + <Success + {amount} + {rate} + {currency} + {locale} + title={$t("payments.received")} + /> +</div> + +<a href={`/${user.username}`} aria-label="Continue"> + <div class="opacity-0 w-screen h-screen fixed top-24 left-0 z-50"></div> +</a> + +<div class="flex justify-center mt-4"> + {$t("payments.tapAnywhere")} +</div> diff --git a/src/routes/(app)/fund/[id]/withdraw/+page.svelte b/src/routes/(app)/fund/[id]/withdraw/+page.svelte index 78f9b16c..69a74a3f 100644 --- a/src/routes/(app)/fund/[id]/withdraw/+page.svelte +++ b/src/routes/(app)/fund/[id]/withdraw/+page.svelte @@ -6,15 +6,17 @@ import Numpad from "$comp/Numpad.svelte"; import Spinner from "$comp/Spinner.svelte"; import { page } from "$app/stores"; - import { s } from "$lib/utils"; + import { loc, s } from "$lib/utils"; import { fiat, pin } from "$lib/store"; let { data, form } = $props(); - let { currency } = $derived(data.user); + let { user } = $derived(data); + let { currency } = $derived(user); let loading = $state(); let { id, balance, rate } = $derived(data); let { amount } = $state(data); + let locale = loc(user); let submit = $state(); let submitting = $state(); @@ -39,7 +41,7 @@ {/if} <div class="container px-4 mt-20 max-w-xl mx-auto"> - <Numpad bind:amount {currency} {fiat} {rate} {submit} /> + <Numpad bind:amount {currency} {fiat} {rate} {submit} {locale} /> <form use:enhance={handler(toggle)} method="POST" class="space-y-5"> <input name="id" value={id} type="hidden" /> diff --git a/src/routes/(app)/funder/+page.svelte b/src/routes/(app)/funder/+page.svelte new file mode 100644 index 00000000..e6549360 --- /dev/null +++ b/src/routes/(app)/funder/+page.svelte @@ -0,0 +1,55 @@ +<script lang="ts"> + import { onMount } from 'svelte'; +let here = false; + let ready = false; + let success = false; + let controller: AbortController | null = null; + let NfcWriter: any; + + async function write() { + NfcWriter = window.Capacitor?.Plugins?.NfcWriter; + ready = true; + console.log(window.Capacitor); + if (!NfcWriter) return; + here = true; + + try { + await NfcWriter.enableExclusiveNfcMode(); + + if (controller) controller.abort(); + controller = new AbortController(); + + ready = true; + + const ndef = new NDEFReader(); + await ndef.write( + { + records: [ + { + recordType: 'url', + data: 'https://coinos.io', + }, + ], + }, + { signal: controller.signal } + ); + + success = true; + } catch (err) { + if (err.name === 'AbortError') { + console.warn('Previous write aborted'); + } else { + console.error('Write failed', err); + alert('❌ Write failed: ' + err.message); + } + } finally { + await NfcWriter.disableExclusiveNfcMode(); + } + } +</script> + +HI +<button class="p-4 btn" on:click={write}>write</button> +{#if here}HERE{/if} +{#if ready}go{/if} +{#if success}done{/if} diff --git a/src/routes/(app)/invoice/[id]/+layout.server.ts b/src/routes/(app)/invoice/[id]/+layout.server.ts index 590e5d4d..ab41dddc 100644 --- a/src/routes/(app)/invoice/[id]/+layout.server.ts +++ b/src/routes/(app)/invoice/[id]/+layout.server.ts @@ -12,19 +12,8 @@ export async function load({ cookies, depends, params, url, parent }) { if (id) { invoice = await get(`/invoice/${id}`); const options = !!url.searchParams.get("options"); - - if ( - user && - invoice.uid !== user.id && - !(url.pathname.includes("tip") || url.pathname.includes("memo")) && - !options - ) { - redirect(307, `/send/invoice/${id}`); - } - let { amount, pending, received } = invoice; amount = parseInt(amount); - subject = invoice.user; const paid = (!amount && (pending || received)) || @@ -32,6 +21,21 @@ export async function load({ cookies, depends, params, url, parent }) { if (paid && !url.pathname.endsWith("paid")) { redirect(307, `/invoice/${id}/paid` + (options ? "?options=true" : "")); } + + subject = invoice.user; + + if ( + user && + invoice.uid !== user?.id && + !(url.pathname.includes("tip") || url.pathname.includes("memo")) && + !options + ) { + if (invoice.prompt && invoice.tip === null) + redirect(307, `/invoice/${id}/tip`); + else { + redirect(307, `/send/invoice/${id}`); + } + } } const theme = cookies.get("theme") || "light"; diff --git a/src/routes/(app)/invoice/[id]/+page.svelte b/src/routes/(app)/invoice/[id]/+page.svelte index 0cb5aeac..29dd815d 100644 --- a/src/routes/(app)/invoice/[id]/+page.svelte +++ b/src/routes/(app)/invoice/[id]/+page.svelte @@ -56,16 +56,18 @@ let subbed = false; - onMount(async () => { - if (browser) { - last.subscribe((v) => { - if (!v || subbed) return; - subbed = true; - send("subscribe", invoice); - }); + $effect(() => { + if (!browser) return; + last.subscribe((v) => { + if (!v || subbed) return; + subbed = true; + send("subscribe", invoice); + }); + }); - if ($amountPrompt && !amount) toggleAmount(); - } + onMount(async () => { + if (!browser) return; + if ($amountPrompt && !amount) toggleAmount(); }); let update = async () => { diff --git a/src/routes/(app)/invoice/[id]/tip/+page.server.ts b/src/routes/(app)/invoice/[id]/tip/+page.server.ts index c3be68f0..851d3f1b 100644 --- a/src/routes/(app)/invoice/[id]/tip/+page.server.ts +++ b/src/routes/(app)/invoice/[id]/tip/+page.server.ts @@ -1,11 +1,36 @@ -import invoice from "$lib/invoice"; import getRates from "$lib/rates"; -import { redirect } from "@sveltejs/kit"; +import { auth, fd, get, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; -export async function load({ parent }) { +export async function load({ cookies, params, parent }) { + const { id } = params; const rates = await getRates(); - const { invoice, subject } = await parent(); + let { invoice, subject, user } = await parent(); if (!invoice.amount) redirect(307, `/invoice/${invoice.id}`); + const pin = cookies.get("pin"); + + if (user) { + const trust = await get("/trust", auth(cookies)); + const trusted = trust.includes(invoice.uid); + if (trusted && (pin || !user.haspin)) { + let p; + try { + if (invoice.tip === null && invoice.user.prompt) { + if (user.tip > 0) { + invoice.tip = Math.round(invoice.amount * (user.tip / 100)); + invoice = await post(`/invoice/${id}`, { invoice }, auth(cookies)); + p = await post("/payments", { ...invoice, pin }, auth(cookies)); + } + } else { + p = await post("/payments", { ...invoice, pin }, auth(cookies)); + } + } catch (e) { + const { message } = e as Error; + fail(400, { message }); + } + if (p) redirect(307, `/sent/${p.id}`); + } + } const rate = rates[subject?.currency]; const invoiceRate = rates[invoice.currency]; @@ -13,5 +38,18 @@ export async function load({ parent }) { } export const actions = { - default: invoice, + default: async ({ cookies, request }) => { + const form = await fd(request); + const invoice = { + tip: form.tip ?? null, + }; + + const { id } = await post( + `/invoice/${form.id}`, + { invoice }, + auth(cookies), + ); + + redirect(307, `/invoice/${id}`); + }, }; diff --git a/src/routes/(app)/invoice/[id]/tip/+page.svelte b/src/routes/(app)/invoice/[id]/tip/+page.svelte index d609d86a..a1ea3586 100644 --- a/src/routes/(app)/invoice/[id]/tip/+page.svelte +++ b/src/routes/(app)/invoice/[id]/tip/+page.svelte @@ -97,19 +97,8 @@ <div class="container px-4 max-w-lg text-center mx-auto"> <form method="POST" use:enhance class="space-y-5"> - <input type="hidden" name="aid" value={aid} /> - <input type="hidden" name="amount" value={amount} /> + <input type="hidden" name="id" value={id} /> <input type="hidden" name="tip" value={tip} /> - <input type="hidden" name="username" value={username} /> - <input type="hidden" name="rate" value={invoice.rate} /> - <input type="hidden" name="prompt" value="false" /> - <input type="hidden" name="type" value={type} /> - <input type="hidden" name="hash" value={hash} /> - <input type="hidden" name="items" value={JSON.stringify(items)} /> - - {#if invoice.memoPrompt} - <input type="hidden" name="memoPrompt" value={invoice.memoPrompt} /> - {/if} <h1 class="text-4xl font-semibold my-8">{$t("invoice.addTipq")}</h1> {#if !showCustomAmount} @@ -127,7 +116,10 @@ <div class="ml-auto text-lg flex gap-2"> <div class="flex items-center"> - <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300" + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" ></iconify-icon> <div>{s(amount, locale)}</div> </div> @@ -170,12 +162,14 @@ {/if} </div> - <div> + <div class="space-y-2"> <button type="button" class="btn" - onclick={() => (showCustomAmount = true)}>Custom</button + onclick={() => (showCustomAmount = true)} + >{$t("payments.custom")}</button > + <a href={`/invoice/${id}`} class="btn">{$t("payments.cancel")}</a> </div> {/if} diff --git a/src/routes/(app)/invoices/+page.svelte b/src/routes/(app)/invoices/+page.svelte index 8de240c2..fbacb2df 100644 --- a/src/routes/(app)/invoices/+page.svelte +++ b/src/routes/(app)/invoices/+page.svelte @@ -7,17 +7,28 @@ <div class="container w-full mx-auto text-lg px-2 space-y-2"> <div class="mx-auto flex justify-center w-full gap-1"> - <div class="grid grid-cols-12 w-full text-center text-lg break-all gap-2"> + <div + class="grid md:grid-cols-12 w-full text-center text-lg break-all gap-x-2" + > {#each invoices as i} - <div class="col-span-2 whitespace-nowrap">{i.type}</div> + <div class="md:col-span-2 whitespace-nowrap mt-8 md:mt-0">{i.type}</div> {#if show[i.id]} - <div class="col-span-8">{i.hash}</div> + <div class="md:col-span-8">{i.hash}</div> {:else} - <div class="cursor-pointer grow col-span-8" onclick={() => display(i.id)}> + <div + class="cursor-pointer grow md:col-span-6" + onclick={() => display(i.id)} + > {i.hash.substr(0, 12)} + {#if !show[i.id]}...{/if} </div> {/if} - <div class="col-span-2 whitespace-nowrap">{i.received} / {i.amount}</div> + <div class="md:col-span-2 whitespace-nowrap"> + {i.received} / {i.amount} + </div> + <div class="whitespace-nowrap"> + {i.memo?.startsWith("[") ? JSON.parse(i.memo)[0][1] : i.memo} + </div> {/each} </div> </div> diff --git a/src/routes/(app)/ln/[lnurl]/+page.server.ts b/src/routes/(app)/ln/[lnurl]/+page.server.ts index c113e03f..7348f96c 100644 --- a/src/routes/(app)/ln/[lnurl]/+page.server.ts +++ b/src/routes/(app)/ln/[lnurl]/+page.server.ts @@ -1,25 +1,60 @@ +import { PUBLIC_DOMAIN } from "$env/static/public"; import getRates from "$lib/rates"; import { auth, fd, get, post } from "$lib/utils"; +import { bech32 } from "@scure/base"; import { error, fail, redirect } from "@sveltejs/kit"; +const { decode, fromWords } = bech32; export async function load({ params, parent }) { const { user } = await parent(); - if (!user) redirect(307, "/register"); const rates = await getRates(); let data; + const { lnurl } = params; + + const url = Buffer.from(fromWords(decode(lnurl, 20000).words)).toString(); + try { - const { lnurl } = params; data = await get(`/decode?text=${lnurl}`); } catch (e) { const { message } = e as Error; error(500, message); } + let { callback, minSendable, maxSendable, comment, tag } = data; + if (tag === "payRequest" && minSendable === maxSendable) { + minSendable = Math.round(minSendable / 1000); + maxSendable = Math.round(maxSendable / 1000); + const amount = minSendable; + + const urlObj = new URL(callback); + urlObj.searchParams.set("amount", (amount * 1000).toString()); + if (comment) { + urlObj.searchParams.set("comment", comment); + } + const url = urlObj.toString(); + + const { pr } = await fetch(url).then((r) => (r as Response).json()); + + let invoice; + try { + invoice = await get(`/invoice/${pr}`); + } catch (e) {} + + if (invoice) redirect(307, `/invoice/${invoice.id}`); + + let path = `/send/lightning/${pr}`; + if (comment) path += `/${encodeURIComponent(comment)}`; + redirect(307, path); + } else if (callback.includes(PUBLIC_DOMAIN)) { + const username = url.split(`https://${PUBLIC_DOMAIN}/p/`)[1]; + redirect(307, `/pay/${username}`); + } + if (!["payRequest", "withdrawRequest"].includes(data.tag)) error(500, "We only support LNURLp and LNURLw at this time"); - data.rate = rates[user.currency]; + data.rate = rates[user?.currency || "USD"]; return data; } @@ -42,6 +77,7 @@ export const actions = { if (comment) url += `&comment=${comment}`; const { pr } = await fetch(url).then((r) => r.json()); + let path = `/send/lightning/${pr}`; if (comment) path += `/${encodeURIComponent(comment)}`; redirect(307, path); diff --git a/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts b/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts index b0f824ed..9c2b1e3b 100644 --- a/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts +++ b/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts @@ -13,7 +13,7 @@ export async function load({ cookies, params, parent }) { let [amount, currency] = params.amount.split("/"); const rate = rates[currency ? currency.toUpperCase() : subject.currency]; - if (!rate) error(500, "Invalid currency symbol"); + if (currency && !rate) error(500, "Invalid currency symbol"); if (amount) { if (currency) amount = (amount * sats) / rate; @@ -33,8 +33,6 @@ export async function load({ cookies, params, parent }) { auth(cookies), ); - const { balance } = await get(`/account/${user.id}`, auth(cookies)); - redirect(307, `/invoice/${id}`); } @@ -66,8 +64,8 @@ export const actions = { return fail(400, { message }); } - if (parseInt(amount) > 0 && prompt) redirect(307, `/invoice/${id}/tip`); - else if (parseInt(amount) <= 0 ) redirect(307, `/${username}/receive`); + if (parseInt(amount) > 0 && prompt) redirect(307, `/invoice/${id}/tip`); + else if (parseInt(amount) <= 0) redirect(307, `/${username}/receive`); redirect(307, `/invoice/${id}`); }, }; diff --git a/src/routes/(app)/payment/[id]/+page.svelte b/src/routes/(app)/payment/[id]/+page.svelte index fcde487b..0667291e 100644 --- a/src/routes/(app)/payment/[id]/+page.svelte +++ b/src/routes/(app)/payment/[id]/+page.svelte @@ -67,7 +67,9 @@ let direction = $state(""); onMount(() => { - direction = amount > 0 ? $t("payments.from") : $t("payments.to"); + let a = amount; + if (type === types.fund) a = 0 - a; + direction = a > 0 ? $t("payments.from") : $t("payments.to"); if (direction) direction = diff --git a/src/routes/(app)/payments/[...page]/+page.svelte b/src/routes/(app)/payments/[...page]/+page.svelte index 28cd29f4..57316f4a 100644 --- a/src/routes/(app)/payments/[...page]/+page.svelte +++ b/src/routes/(app)/payments/[...page]/+page.svelte @@ -215,7 +215,7 @@ <!-- >{$t("payments.subtotal")}</span --> <!-- > --> <span class="text-base text-secondary"> - {#if tipsIn || tipsOut}{$t("payments.tips")}{/if} + {#if tipsIn > 0 || tipsOut > 0}{$t("payments.tips")}{/if} </span> <span class="text-base text-secondary text-right" >{$t("payments.total")}</span @@ -229,32 +229,38 @@ {@const tipsOut = outgoing[c]?.fiatTips || 0} {@const subtotalOut = totalOut - tipsOut} - <span class="text-left text-base text-secondary" - >{$t("payments.income")}</span - > - <!-- <span><b>{subtotalIn ? f(subtotalIn, c) : "-"}</b></span> --> - <span><b>{tipsIn > 0 ? f(tipsIn, c, locale) : "-"}</b></span> - <span class="text-right" - ><b>{totalIn > 0 ? f(totalIn, c, locale) : "-"}</b></span - > + {#if totalIn > 0} + <span class="text-left text-base text-secondary" + >{$t("payments.income")}</span + > + <!-- <span><b>{subtotalIn ? f(subtotalIn, c) : "-"}</b></span> --> + <span><b>{tipsIn > 0 ? f(tipsIn, c, locale) : ""}</b></span> + <span class="text-right" + ><b>{totalIn > 0 ? f(totalIn, c, locale) : "-"}</b></span + > + {/if} - <span class="text-left text-base text-secondary" - >{$t("payments.expenditure")}</span - > - <!-- <span><b>{subtotalOut ? f(subtotalOut, c) : "-"}</b></span> --> - <span><b>{tipsOut > 0 ? f(tipsOut, c, locale) : "-"}</b></span> - <span class="text-right" - ><b>{totalOut > 0 ? f(totalOut, c, locale) : "-"}</b></span - > + {#if totalOut > 0} + <span class="text-left text-base text-secondary" + >{$t("payments.expenditure")}</span + > + <!-- <span><b>{subtotalOut ? f(subtotalOut, c) : "-"}</b></span> --> + <span><b>{tipsOut > 0 ? f(tipsOut, c, locale) : "-"}</b></span> + <span class="text-right" + ><b>{totalOut > 0 ? f(totalOut, c, locale) : "-"}</b></span + > + {/if} {/each} </div> </div> - <div class="flex justify-center"> - <button class="btn !w-auto" onclick={csv}> - <iconify-icon noobserver icon="ph:floppy-disk-bold" width="32" - ></iconify-icon> - <div class="my-auto">{$t("payments.export")}</div> - </button> - </div> + {#if payments.length} + <div class="flex justify-center"> + <button class="btn !w-auto" onclick={csv}> + <iconify-icon noobserver icon="ph:floppy-disk-bold" width="32" + ></iconify-icon> + <div class="my-auto">{$t("payments.export")}</div> + </button> + </div> + {/if} </div> diff --git a/src/routes/(app)/qr/[text]/[icon]/+server.ts b/src/routes/(app)/qr/[text]/[icon]/+server.ts new file mode 100644 index 00000000..c2825877 --- /dev/null +++ b/src/routes/(app)/qr/[text]/[icon]/+server.ts @@ -0,0 +1,33 @@ +import { createCanvas, loadImage } from "canvas"; +import QRCode from "qrcode"; + +export async function GET({ params }) { + const { icon, text } = params; + const size = 600; + + const canvas = createCanvas(size, size); + await QRCode.toCanvas(canvas, text, { + errorCorrectionLevel: "H", + width: size, + }); + + const ctx = canvas.getContext("2d"); + + const logo = await loadImage(`static/images/${icon}`); + const logoSize = size * 0.3; + ctx.drawImage( + logo, + (size - logoSize) / 2, + (size - logoSize) / 2, + logoSize, + logoSize, + ); + + const buffer = canvas.toBuffer("image/png"); + return new Response(buffer, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/src/routes/(app)/qr/[text]/raw/+server.ts b/src/routes/(app)/qr/[text]/raw/+server.ts index a24d3a3c..a2626c4d 100644 --- a/src/routes/(app)/qr/[text]/raw/+server.ts +++ b/src/routes/(app)/qr/[text]/raw/+server.ts @@ -14,3 +14,4 @@ export async function GET({ params }) { }, }); } + diff --git a/src/routes/(app)/scan/+page.svelte b/src/routes/(app)/scan/+page.svelte index 88a76fbb..b21a7458 100644 --- a/src/routes/(app)/scan/+page.svelte +++ b/src/routes/(app)/scan/+page.svelte @@ -1,222 +1,61 @@ <script> + import { tick } from "svelte"; + import Icon from "$comp/Icon.svelte"; + import QrScanner from "qr-scanner"; + import { onMount, onDestroy } from "svelte"; import { back } from "$lib/utils"; import { goto } from "$app/navigation"; - import { onMount, onDestroy } from "svelte"; import { BarcodeDetector } from "barcode-detector/ponyfill"; - import { camera } from "$lib/store"; + + let scanner, + vid = $state(), + resizing; let resize = () => { if (resizing) clearTimeout(resizing); resizing = setTimeout(initialize, 1000); }; - let videoEl; - let canvasEl; - let barcodeDetector; - let scanningFrameId; - let cameras = []; - let detectionResult = null; // will hold the detected QR code value - - // New variables to handle iOS behavior. - let isIOS = false; - let scanningStarted = false; - - // Enumerate available video input devices - async function getCameraDevices() { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - cameras = devices.filter((device) => device.kind === "videoinput"); - - // Simply choose the first available camera. - if (!$camera && cameras.length > 0) { - $camera = cameras[0].deviceId; - } - } catch (err) { - console.error("Error enumerating devices:", err); - } - } - - // Start the camera stream for a given deviceId (used for non-iOS or programmatic restarts) - async function startCamera(deviceId) { - stopCameraStream(); - detectionResult = null; // reset detection on restart - try { - const constraints = deviceId - ? { video: { deviceId: { exact: deviceId } } } - : { video: { facingMode: "environment" } }; - const stream = await navigator.mediaDevices.getUserMedia(constraints); - videoEl.srcObject = stream; - await videoEl.play(); - } catch (err) { - console.error("Error accessing camera:", err); - } - } - - // Stop any active video stream and cancel the scanning loop - function stopCameraStream() { - if (videoEl && videoEl.srcObject) { - const stream = videoEl.srcObject; - if (stream.getTracks) { - stream.getTracks().forEach((track) => track.stop()); - } - } - if (scanningFrameId) { - cancelAnimationFrame(scanningFrameId); - } - } - - function handleDetection(barcode) { - detectionResult = barcode.rawValue; - stopCameraStream(); - goto(`/send/${encodeURIComponent(detectionResult)}`); - } - - // The scanning loop: draw the video frame onto a hidden canvas and detect QR codes. - function scanLoop() { - if (!videoEl.videoWidth || !videoEl.videoHeight) { - scanningFrameId = requestAnimationFrame(scanLoop); - return; - } - - const context = canvasEl.getContext("2d"); - if (!context) return; - - // Ensure canvas dimensions match the video - canvasEl.width = videoEl.videoWidth; - canvasEl.height = videoEl.videoHeight; - context.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height); - - barcodeDetector - .detect(canvasEl) - .then((barcodes) => { - if (barcodes.length > 0) { - handleDetection(barcodes[0]); - return; // Stop scanning on detection. - } - }) - .catch((err) => { - console.error("Detection error:", err); - }); - - // Continue scanning if no detection has occurred. - if (!detectionResult) { - scanningFrameId = requestAnimationFrame(scanLoop); - } - } - - // Handle change of camera selection - async function onCameraChange(event) { - $camera = event.target.value; - await startCamera($camera); - videoEl.addEventListener("loadeddata", () => { - canvasEl.width = videoEl.videoWidth; - canvasEl.height = videoEl.videoHeight; - scanLoop(); - }); - } - - // Provide a way to restart scanning (non-iOS) - async function restart() { - detectionResult = null; - await startCamera($camera); - videoEl.addEventListener("loadeddata", () => { - canvasEl.width = videoEl.videoWidth; - canvasEl.height = videoEl.videoHeight; - scanLoop(); - }); - } - - // For iOS: Use a direct promise chain to call getUserMedia as part of the user gesture. - function startScanning() { - scanningStarted = true; - stopCameraStream(); - detectionResult = null; - const constraints = $camera - ? { video: { deviceId: { exact: $camera } } } - : { video: { facingMode: "environment" } }; - navigator.mediaDevices.getUserMedia(constraints) - .then((stream) => { - videoEl.srcObject = stream; - return videoEl.play(); - }) - .then(() => { - // When video is ready, start scanning. - canvasEl.width = videoEl.videoWidth; - canvasEl.height = videoEl.videoHeight; - scanLoop(); - }) - .catch((err) => { - console.error("Error accessing camera:", err); - }); - } - let initialize = async () => { - // Detect if we're on an iOS device - isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); - barcodeDetector = new BarcodeDetector({ formats: ["qr_code"] }); - await getCameraDevices(); - - // For non-iOS devices, auto-start scanning. - if (!isIOS) { - await startCamera($camera); - videoEl.addEventListener("loadeddata", () => { - canvasEl.width = videoEl.videoWidth; - canvasEl.height = videoEl.videoHeight; - scanLoop(); - }); + if (scanner) { + scanner.stop(); + await new Promise((r) => setTimeout(r, 1000)); } + + let options = { + highlightScanRegion: true, + highlightCodeOutline: true, + qrEngine: BarcodeDetector, + }; + let cb = ({ data }) => + scanner.stop() || goto(`/send/${encodeURIComponent(data)}`); + scanner = new QrScanner(vid, cb, options); + await tick(); + scanner.start(); }; onMount(initialize); - - onDestroy(() => { - stopCameraStream(); - }); + onDestroy(() => scanner?.stop()); </script> <svelte:window onresize={resize} /> + <div class="flex w-full mb-4 p-4"> - <div class="mx-auto rounded-3xl space-y-5"> + <div class="mx-auto rounded-3xl"> <video - bind:this={videoEl} + bind:this={vid} class="border-4 rounded-3xl border-black max-h-[calc(100vh*0.7)]" ></video> - <canvas bind:this={canvasEl} style="display: none;"></canvas> - <div class="flex justify-center"> - <button class="btn !w-auto" onclick={back}>Cancel</button> - </div> - <!-- Only display the camera selection if there are multiple cameras --> - <div> - <select - id="cameraSelect" - bind:value={$camera} - onchange={onCameraChange} - > - {#each cameras as camera} - <option value={camera.deviceId}> - {camera.label || `Camera ${camera.deviceId}`} - </option> - {/each} - </select> - </div> - <!-- For iOS users, show a "Start Scanning" button if scanning hasn't begun --> - {#if isIOS && !scanningStarted} - <div class="flex justify-center"> - <button class="btn !w-auto" onclick={startScanning}> - Start Scanning - </button> - </div> - {/if} </div> </div> +<div class="flex justify-center"> + <button class="btn !w-auto" onclick={back}>Cancel</button> +</div> + <style> video { - width: 100%; - max-width: 600px; - border: 4px solid black; - border-radius: 1rem; - transform: scale(1); transition: transform 0.3s ease-out; + transform: scale(1); } </style> diff --git a/src/routes/(app)/send/[...text]/+page.svelte b/src/routes/(app)/send/[...text]/+page.svelte index 183b4b51..52b92501 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,19 +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 @@ -139,12 +146,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 +167,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 060473ec..529e2691 100644 --- a/src/routes/(app)/send/bitcoin/[address]/+page.svelte +++ b/src/routes/(app)/send/bitcoin/[address]/+page.svelte @@ -1,25 +1,32 @@ <script> + import { tick } from "svelte"; import { t } from "$lib/translations"; import Icon from "$comp/Icon.svelte"; 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 submit = $state(), fiat = $state(); + let a = $state(0); + let submit = $state(), + fiat = $state(); $effect(() => ($rate = data.rate)); + $effect(() => (amount = a)); - let setMax = (e) => { + let setMax = async (e) => { e.preventDefault(); fiat = false; amount = balance; + await tick(); + submit.click(); }; </script> @@ -28,7 +35,7 @@ <div class="text-xl text-secondary break-all">{address}</div> - <Numpad bind:amount 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 @@ -39,7 +46,12 @@ > <form action={`/send/bitcoin/${address}/${amount}`} class="contents"> - <button use:focus bind:this={submit} type="submit" class="btn !w-auto grow btn-accent"> + <button + use:focus + bind:this={submit} + type="submit" + class="btn !w-auto grow btn-accent" + > {$t("payments.next")} </button> </form> 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 5f20d1bd..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 @@ -12,7 +12,7 @@ export async function load({ const aid = cookies.get("aid") || user.id; try { - const { fee, fees, inputs, ourfee, hex } = await post( + const { fee, fees, inputs, ourfee, hex, subtract } = await post( "/bitcoin/fee", { address, amount, feeRate, aid }, auth(cookies), @@ -29,6 +29,7 @@ export async function load({ feeRate, rate: rates[user.currency], ourfee, + subtract, hex, inputs, }; 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 7d9b8f64..1551cc2c 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,7 +13,6 @@ 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"; @@ -71,8 +68,18 @@ let toggle = () => (submitting = !submitting); - let { account, amount, address, message, fee, fees, 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} 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> @@ -137,14 +148,7 @@ <div class="text-center"> <h2 class="text-secondary text-lg">{$t("payments.platformFee")}</h2> - <div class="flex flex-wrap gap-4 justify-center"> - <div class="my-auto"> - <h2 class="text-xl"> - {f(toFiat(ourfee, $rate), currency)} - </h2> - <h3 class="text-secondary">⚡️{s(ourfee)}</h3> - </div> - </div> + <Amount amount={ourfee} rate={$rate} {currency} /> </div> {/if} 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 0f7b4003..eb324775 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts @@ -39,8 +39,7 @@ export const actions = { p = await post("/payments", body, auth(cookies)); } catch (e) { - let { message } = e as Error; - if (!message.includes("Insufficient")) message = "payments.failedToRoute"; + const { message } = e as Error; return fail(400, { message }); } diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte index e979e7c4..a5cc168c 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte @@ -13,7 +13,7 @@ let { alias, memo, payreq, ourfee, user } = $derived({ ...data, ...form }); let a = $state(); - let { currency } = $derived(user); + let { currency, locked } = $derived(user); let locale = loc(user); $effect(() => ($rate ||= data.rate)); @@ -36,7 +36,12 @@ <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"> - {$t(form.message)} + {#if form.message.includes($t("payments.insufficientFunds"))} + <div>{form.message}</div> + <div>{$t("payments.lockedBalance")}: {locked}</div> + {:else} + {$t("payments.failedToRoute")} + {/if} </div> {/if} {#if amount} diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index 2fe95813..3c2ebd88 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -36,8 +36,9 @@ }; let tabs = [ - { name: "account", key: "ACCOUNT" }, - { name: "preferences", key: "POINT_OF_SALE" }, + { name: "profile", key: "ACCOUNT" }, + { name: "account", key: "POINT_OF_SALE" }, + { name: "nostr", key: "NOSTR" }, { name: "security", key: "SECURITY" }, ]; @@ -208,16 +209,16 @@ {$t("user.settings.header")} </h1> - <div class="flex justify-around items-center border-b pb-3"> - {#each tabs.filter((t) => t.name !== "shopify") as { name, key }} - <a href={`/settings/${name}`}> - <button - type="button" - class="hover:opacity-80" - class:font-bold={tab === name || - (name === "preferences" && ["nostr", "shopify"].includes(tab))} - >{$t(`user.settings.${key}`)}</button - > + <div + class="flex flex-wrap justify-around items-center border-b pb-3" + > + {#each tabs as { name, key }} + <a + href={`/settings/${name}`} + class="px-4 py-2 w-1/2 sm:w-1/4 flex-shrink-0 hover:opacity-80 transition-opacity duration-150 text-center" + class:font-bold={name === tab} + > + {$t(`user.settings.${key}`)} </a> {/each} </div> @@ -227,17 +228,18 @@ </div> <div - class="z-10 fixed bottom-0 bg-base-100 shadow border-t border-t-8 border-accent w-full flex justify-center items-center py-3" + class="z-10 fixed bottom-0 bg-base-100 shadow border-accent w-full flex justify-center items-center py-3" > <button bind:this={$save} type="submit" - class="btn !w-auto" + class="btn btn-accent !w-auto" class:bg-base-200={submitting} > {#if submitting} <Spinner /> {:else} + <iconify-icon icon="ph:floppy-disk-bold" width={32}></iconify-icon> <div class="my-auto">{$t("user.settings.saveSettings")}</div> {/if} </button> diff --git a/src/routes/(app)/settings/account/+page.server.ts b/src/routes/(app)/settings/account/+page.server.ts index 4145935e..ebb90f05 100644 --- a/src/routes/(app)/settings/account/+page.server.ts +++ b/src/routes/(app)/settings/account/+page.server.ts @@ -1,4 +1,12 @@ +import getRates from "$lib/rates"; import updateUser from "$lib/settings"; +import { auth, get } from "$lib/utils"; + +export async function load({ cookies }) { + const rates = await getRates(); + const connect = await get("/square/connect", auth(cookies)); + return { connect, rates }; +} export const actions = { default: updateUser, diff --git a/src/routes/(app)/settings/account/+page.svelte b/src/routes/(app)/settings/account/+page.svelte index d4178fd1..8b38ffe8 100644 --- a/src/routes/(app)/settings/account/+page.svelte +++ b/src/routes/(app)/settings/account/+page.svelte @@ -1,78 +1,123 @@ <script> - import { tick } from "svelte"; - import { avatar, banner as bannerStore } from "$lib/store"; - import { t } from "$lib/translations"; + import { run } from "svelte/legacy"; + import { theme } from "$lib/store"; + + import { onMount, tick } from "svelte"; + import { browser } from "$app/environment"; + import Numpad from "$comp/Numpad.svelte"; + import LocaleSelector from "$comp/LocaleSelector.svelte"; + import Toggle from "$comp/Toggle.svelte"; + import { locale, t } from "$lib/translations"; + import { post, success, fail } from "$lib/utils"; import { page } from "$app/stores"; + import { PUBLIC_VAPID_PUBKEY } from "$env/static/public"; let { data } = $props(); - let { user } = $derived(data); - let { id } = user; - let { about, banner, picture, display, email, username, verified } = - $state(user); + let { user } = $state(data); + let { connect, rates, subscriptions } = data; + let { currency, email, tip, verified } = $state(user); + let rate = rates[currency]; + + let fiats = Object.keys(rates).sort((a, b) => a.localeCompare(b)); + let keypress = (e) => e.key === "Enter" && (e.preventDefault() || el.click()); - let avatarFile, - avatarInput = $state(), - bannerFile, - bannerInput = $state(); + let editingReserve = $state(), + editingThreshold = $state(), + doneReserve = $state(), + doneThreshold; + let doneEditing = () => { + editingReserve = false; + editingThreshold = false; + }; - let selectAvatar = () => avatarInput.click(); - let selectBanner = () => bannerInput.click(); + let editReserve = async () => { + editingReserve = true; + await tick(); + reserveEl.focus(); + }; - let percent; - let progress = async (event) => { - percent = Math.round((event.loaded / event.total) * 100); + let editThreshold = async () => { + editingThreshold = true; + await tick(); + thresholdEl.focus(); }; - let tooLarge = $state({}); + if (!user.threshold) user.threshold = 1000000; + if (!user.reserve) user.reserve = 100000; + let reserveEl = $state(), + thresholdEl = $state(); + + let push = $state(), + pm, + subscription; + + onMount(async () => { + if (!browser) return; - let handleFile = async ({ target }, type) => { - tooLarge[type] = false; - let file = target.files[0]; - if (!file) return; + pm = + navigator?.serviceWorker && + (await navigator.serviceWorker.getRegistration()).pushManager; - if (file.size > 10000000) return (tooLarge[type] = true); + permission = await pm.permissionState({ + userVisibleOnly: true, + applicationServerKey: PUBLIC_VAPID_PUBKEY, + }); - if (type === "picture") { - $avatar = { id, file, type, progress }; - } else if (type === "banner") { - $bannerStore = { id, file, type, progress }; + if (permission === "granted") { + subscription = await pm.getSubscription(); + if (subscriptions.includes(JSON.stringify(subscription))) push = true; } + }); - var reader = new FileReader(); - reader.onload = async (e) => { - if (type === "picture") { - $avatar.src = e.target.result; - } else if (type === "banner") { - $bannerStore.src = e.target.result; - } - }; + let permission = $state(); + let updateNotifications = async (push) => { + if (!browser || !pm) return (push = false); - reader.readAsDataURL(file); - }; + if (subscription && !push) { + return post("/subscription/delete", { subscription }); + } - let url = $derived(`${$page.url.host}/${username}`); - let full = $derived(`${$page.url.protocol}//${url}`); - let addr = $derived(`${username}@${$page.url.host}`); + if (push && permission !== "denied") { + subscription = await pm.subscribe({ + userVisibleOnly: true, + applicationServerKey: PUBLIC_VAPID_PUBKEY, + }); + } + + if (subscription) { + await post("/subscription", { subscription }); + } + }; + run(() => { + user.language = $locale; + }); + run(() => { + updateNotifications(push); + }); $effect(() => { if (email !== user.email) verified = false; }); + + let revoke = () => {}; </script> <div> - <label for="username" class="font-bold mb-1 block" - >{$t("user.settings.username")}</label + <label for="language" class="font-bold block mb-1" + >{$t("user.settings.locale")}</label > - <div class="flex mb-2"> - <input type="text" name="username" bind:value={username} /> - </div> + <LocaleSelector style="select-styles block py-3 w-full" /> </div> <div> - <label for="display" class="font-bold mb-1 block" - >{$t("user.settings.displayName")}</label + <label for="currency" class="font-bold block mb-1" + >{$t("user.settings.localCurrency")}</label > - <input type="text" name="display" bind:value={display} /> + <select name="currency" value={currency}> + {#each fiats as fiat} + <option value={fiat}>{fiat}</option> + {/each} + </select> </div> <div class="space-y-1 relative"> @@ -86,108 +131,210 @@ > <input type="text" name="email" class="clean" bind:value={email} /> {#if verified} - <iconify-icon noobserver icon="ph:check-bold" class="text-success ml-auto" width="32" + <iconify-icon + noobserver + icon="ph:check-bold" + class="text-success ml-auto" + width="32" ></iconify-icon> {:else if email} - <iconify-icon noobserver icon="ph:clock-bold" class="text-warning ml-auto" width="32" + <iconify-icon + noobserver + icon="ph:clock-bold" + class="text-warning ml-auto" + width="32" ></iconify-icon> {/if} </label> </div> -<div class="space-y-2"> - <span class="font-bold">{$t("user.settings.profileImage")}</span> +<div> + <div class="flex justify-between items-center"> + <span class="font-bold">{$t("user.settings.notifications")}</span> + <Toggle id="notify" bind:value={user.notify} /> + </div> + <p class="text-secondary mt-1 w-9/12"> + {$t("user.settings.notificationsDesc")} + </p> +</div> - <div class="flex"> - {#if $avatar || picture} - <div - class="relative rounded-full overflow-hidden text-center w-20 h-20 my-auto hover:opacity-80 cursor-pointer" - onclick={selectAvatar} - onkeydown={selectAvatar} - > - <img - src={$avatar?.src || picture} - class="absolute w-full h-full object-cover object-center visible overflow-hidden" - alt={username} - /> - </div> +<div> + <div class="flex justify-between items-center"> + <span class="font-bold">{$t("user.settings.pushNotifications")}</span> + {#if permission !== "denied"} + <Toggle id="push" bind:value={push} /> + {/if} + </div> + <p class="text-secondary mt-1 w-9/12"> + {#if permission === "denied"} + {$t("user.settings.pushNotificationsDisabled")} {:else} - <div - class="rounded-full border-4 border-base-100 p-4 bg-base-200 w-24 h-24 my-auto hover:opacity-80 cursor-pointer" - onclick={selectAvatar} - onkeydown={selectAvatar} - ></div> + {$t("user.settings.pushNotificationsDesc")} {/if} - <div class="ml-2 p-2"> - <!-- found missing translation --> - <button - type="button" - class="btn" - onclick={selectAvatar} - onkeydown={selectAvatar}>{$t("user.settings.select")}</button - > - <input - type="file" - class="hidden" - bind:this={avatarInput} - onchange={(e) => handleFile(e, "picture")} - /> - </div> + </p> +</div> + +<div> + <div class="flex justify-between items-center"> + <span class="font-bold">{$t("user.settings.tipPrompt")}</span> + <Toggle id="prompt" bind:value={user.prompt} /> </div> + <p class="text-secondary mt-1 w-9/12"> + {$t("user.settings.tipPromptDescription")} + </p> +</div> + +<div class="space-y-1 relative"> + <label for="tip" class="font-bold block">{$t("user.settings.tip")}</label> + <p class="text-secondary"> + {$t("user.settings.tipDesc")} + </p> - {#if tooLarge["avatar"]} - <div class="text-red-600">Max file size 10MB</div> - {/if} + <label + class="input input-bordered border-primary input-lg rounded-2xl flex items-center gap-2" + > + <input type="text" name="tip" class="clean" bind:value={tip} /> + </label> </div> -<div class="space-y-2"> +<div> <div class="flex justify-between items-center"> - <span class="font-bold">{$t("user.settings.bannerImage")}</span> + <span class="font-bold">{$t("user.settings.autoWithdraw")}</span> + <Toggle id="autowithdraw" bind:value={user.autowithdraw} /> </div> + <p class="text-secondary mt-1 w-9/12"> + {$t("user.settings.autoWithdrawDescription")} + </p> +</div> - {#if $bannerStore || banner} - <img - src={$bannerStore ? $bannerStore.src : banner} - class="w-full object-cover object-center visible overflow-hidden h-48 mb-4 hover:opacity-80" - onclick={selectBanner} - onkeydown={selectBanner} - alt="Banner" - /> - {:else} - <div - class="bg-base-200 w-full h-48 mb-4 cursor-pointer hover:opacity-80" - onclick={selectBanner} - onkeydown={selectBanner} - alt="Banner" - ></div> - {/if} +<div class:hidden={!user.autowithdraw}> + <div class="mb-2"> + <label for="display" class="font-bold mb-1 block" + >{$t("user.settings.destination")}</label + > + <textarea + name="destination" + placeholder={$t("user.settings.destinationPlaceholder")} + onkeypress={keypress} + class="w-full p-4 border rounded-xl h-48" + bind:value={user.destination} + ></textarea> + </div> - <button - type="button" - class="btn !w-auto" - onclick={selectBanner} - onkeydown={selectBanner}>{$t("user.settings.select")}</button - > - <input - type="file" - class="hidden" - bind:this={bannerInput} - onchange={(e) => handleFile(e, "banner")} - /> + <div> + <label for="display" class="font-bold mb-1 block" + >{$t("user.settings.threshold")}</label + > + <button type="button" class="flex w-full" onclick={editThreshold}> + <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + ></iconify-icon> + </div> + <div + class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left" + > + {user.threshold} + </div> + <input type="hidden" name="threshold" bind:value={user.threshold} /> + </button> + <p class="text-secondary mt-1"> + {$t("user.settings.thresholdDesc")} + </p> + </div> - {#if tooLarge["banner"]} - <div class="text-red-600">Max file size 10MB</div> - {/if} + <div> + <label for="display" class="font-bold mb-1 block" + >{$t("user.settings.reserve")}</label + > + <button type="button" class="flex w-full" onclick={editReserve}> + <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + ></iconify-icon> + </div> + <div + class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left" + > + {user.reserve} + </div> + <input type="hidden" name="reserve" bind:value={user.reserve} /> + </button> + <p class="text-secondary mt-1"> + {$t("user.settings.reserveDesc")} + </p> + </div> </div> -<div> - <label for="about" class="font-bold mb-1 block" - >{$t("user.settings.about")}</label +{#if editingThreshold} + <div + class="fixed bg-base-100 bg-opacity-90 inset-0 overflow-y-auto h-full w-full z-50 max-w-lg mx-auto" > - <textarea - type="text" - name="about" - bind:value={about} - placeholder={$t("user.settings.aboutPlaceholder")} - ></textarea> -</div> + <div class="relative p-5 border shadow-lg rounded-md bg-base-100 space-y-5"> + <h1 class="text-center text-2xl font-semibold"> + {$t("user.settings.threshold")} + </h1> + <Numpad + bind:amount={user.threshold} + {currency} + bind:rate + bind:submit={doneReserve} + bind:element={thresholdEl} + /> + + <button + bind:this={doneReserve} + type="button" + onclick={doneEditing} + class="btn">Ok</button + > + </div> + </div> +{/if} + +{#if editingReserve} + <div + class="fixed bg-base-100 bg-opacity-90 inset-0 overflow-y-auto h-full w-full z-50 mx-auto max-w-lg" + > + <div + class="relative mx-auto p-5 border shadow-lg rounded-md bg-base-100 space-y-5 text-center" + > + <h1 class="text-2xl font-semibold"> + {$t("user.settings.reserve")} + </h1> + <Numpad + bind:amount={user.reserve} + bind:currency + bind:rate + bind:submit={doneReserve} + bind:element={reserveEl} + /> + <button + bind:this={doneReserve} + type="button" + onclick={doneEditing} + class="btn">Ok</button + > + </div> + </div> +{/if} + +{#if connect !== "connected"} + <a href={connect} class="btn flex"> + <img src="/images/square.svg" class="w-12" /> + <div>{$t("user.settings.connectSquare")}</div></a + > +{:else} + <button type="button" onclick={revoke} class="btn"> + <img + src="/images/square.svg" + class="w-12" + class:invert={$theme === "dark"} + /> + <div>{$t("user.settings.revokeSquare")}</div> + </button> +{/if} diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte index 421b590a..7168f4f7 100644 --- a/src/routes/(app)/settings/nostr/+page.svelte +++ b/src/routes/(app)/settings/nostr/+page.svelte @@ -163,25 +163,26 @@ <div class="space-y-2"> <div class="font-bold">{$t("user.nostrPubkey")}</div> - <div class="flex gap-4"> - <textarea name="pubkey" bind:value={npub} rows={3}> </textarea> - <div class="flex my-auto gap-1"> - <button - type="button" - class="my-auto btn btn-circle" - onclick={() => copy(npub)} - aria-label="Copy" - ><iconify-icon noobserver icon="ph:copy-bold" width="32" - ></iconify-icon></button - > - <a - href={`/qr/${encodeURIComponent(npub)}`} - class="my-auto btn btn-circle" - aria-label="QR" - > - <iconify-icon noobserver icon="ph:qr-code-bold" width="32" - ></iconify-icon> - </a> + <textarea name="pubkey" bind:value={npub} rows={3}></textarea> + <div> + <div class="flex my-auto gap-1"> + <button + type="button" + class="my-auto btn btn-circle !w-auto grow" + onclick={() => copy(npub)} + aria-label="Copy" + ><iconify-icon noobserver icon="ph:copy-bold" width="32" + ></iconify-icon> {$t("accounts.copy")}</button + > + + <a + href={`/qr/${encodeURIComponent(npub)}`} + class="my-auto btn btn-circle !w-auto grow" + aria-label="QR" + > + <iconify-icon noobserver icon="ph:qr-code-bold" width="32"></iconify-icon> +{$t("accounts.qr")} + </a> </div> </div> diff --git a/src/routes/(app)/settings/preferences/+page.server.ts b/src/routes/(app)/settings/preferences/+page.server.ts deleted file mode 100644 index f0cc700e..00000000 --- a/src/routes/(app)/settings/preferences/+page.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import getRates from "$lib/rates"; -import updateUser from "$lib/settings"; - -export async function load() { - const rates = await getRates(); - return { rates }; -} - -export const actions = { - default: updateUser, -}; diff --git a/src/routes/(app)/settings/preferences/+page.svelte b/src/routes/(app)/settings/preferences/+page.svelte deleted file mode 100644 index 0f30c61b..00000000 --- a/src/routes/(app)/settings/preferences/+page.svelte +++ /dev/null @@ -1,291 +0,0 @@ -<script> - import { run } from "svelte/legacy"; - - import { onMount, tick } from "svelte"; - import { browser } from "$app/environment"; - import Numpad from "$comp/Numpad.svelte"; - import LocaleSelector from "$comp/LocaleSelector.svelte"; - import Toggle from "$comp/Toggle.svelte"; - import { locale, t } from "$lib/translations"; - import { post, success, fail } from "$lib/utils"; - import { page } from "$app/stores"; - import { PUBLIC_VAPID_PUBKEY } from "$env/static/public"; - - let { data } = $props(); - let { user } = $state(data); - let { rates, subscriptions } = data; - let { currency } = user; - let rate = rates[currency]; - - let fiats = Object.keys(rates).sort((a, b) => a.localeCompare(b)); - let keypress = (e) => e.key === "Enter" && (e.preventDefault() || el.click()); - - let editingReserve = $state(), - editingThreshold = $state(), - doneReserve = $state(), - doneThreshold; - let doneEditing = () => { - editingReserve = false; - editingThreshold = false; - }; - - let editReserve = async () => { - editingReserve = true; - await tick(); - reserveEl.focus(); - }; - - let editThreshold = async () => { - editingThreshold = true; - await tick(); - thresholdEl.focus(); - }; - - if (!user.threshold) user.threshold = 1000000; - if (!user.reserve) user.reserve = 100000; - let reserveEl = $state(), - thresholdEl = $state(); - - let push = $state(), - pm, - subscription; - - onMount(async () => { - if (!browser) return; - - pm = - navigator?.serviceWorker && - (await navigator.serviceWorker.getRegistration()).pushManager; - - permission = await pm.permissionState({ - userVisibleOnly: true, - applicationServerKey: PUBLIC_VAPID_PUBKEY, - }); - - if (permission === "granted") { - subscription = await pm.getSubscription(); - if (subscriptions.includes(JSON.stringify(subscription))) push = true; - } - }); - - let permission = $state(); - let updateNotifications = async (push) => { - if (!browser || !pm) return (push = false); - - if (subscription && !push) { - return post("/subscription/delete", { subscription }); - } - - if (push && permission !== "denied") { - subscription = await pm.subscribe({ - userVisibleOnly: true, - applicationServerKey: PUBLIC_VAPID_PUBKEY, - }); - } - - if (subscription) { - await post("/subscription", { subscription }); - } - }; - run(() => { - user.language = $locale; - }); - run(() => { - updateNotifications(push); - }); -</script> - -<div> - <label for="language" class="font-bold block mb-1" - >{$t("user.settings.locale")}</label - > - <LocaleSelector style="select-styles block py-3 w-full" /> -</div> - -<div> - <label for="currency" class="font-bold block mb-1" - >{$t("user.settings.localCurrency")}</label - > - <select name="currency" value={currency}> - {#each fiats as fiat} - <option value={fiat}>{fiat}</option> - {/each} - </select> -</div> - -<div> - <label for="currency" class="font-bold block mb-1" - >{$t("user.settings.integrations")}</label - > - <div class="flex flex-wrap gap-2 justify-center"> - <a href="/settings/nostr" class="block grow"> - <button type="button" class="btn"> - <img src="/images/nostr.png" class="w-8" /> - <div class="my-auto">{$t("user.settings.nostr")}</div> - </button> - </a> - - <a href="/settings/shopify" class="block grow"> - <button type="button" class="btn"> - <img src="/icons/shopify.svg" class="w-8" /> - <div class="my-auto">{$t("user.settings.shopify")}</div> - </button> - </a> - </div> -</div> - -<div> - <div class="flex justify-between items-center"> - <span class="font-bold">{$t("user.settings.notifications")}</span> - <Toggle id="notify" bind:value={user.notify} /> - </div> - <p class="text-secondary mt-1 w-9/12"> - {$t("user.settings.notificationsDesc")} - </p> -</div> - -<div> - <div class="flex justify-between items-center"> - <span class="font-bold">{$t("user.settings.pushNotifications")}</span> - {#if permission !== "denied"} - <Toggle id="push" bind:value={push} /> - {/if} - </div> - <p class="text-secondary mt-1 w-9/12"> - {#if permission === "denied"} - {$t("user.settings.pushNotificationsDisabled")} - {:else} - {$t("user.settings.pushNotificationsDesc")} - {/if} - </p> -</div> - -<div> - <div class="flex justify-between items-center"> - <span class="font-bold">{$t("user.settings.tipPrompt")}</span> - <Toggle id="prompt" bind:value={user.prompt} /> - </div> - <p class="text-secondary mt-1 w-9/12"> - {$t("user.settings.tipPromptDescription")} - </p> -</div> - -<div> - <div class="flex justify-between items-center"> - <span class="font-bold">{$t("user.settings.autoWithdraw")}</span> - <Toggle id="autowithdraw" bind:value={user.autowithdraw} /> - </div> - <p class="text-secondary mt-1 w-9/12"> - {$t("user.settings.autoWithdrawDescription")} - </p> -</div> - -{#if user.autowithdraw} - <div class="mb-2"> - <label for="display" class="font-bold mb-1 block" - >{$t("user.settings.destination")}</label - > - <textarea - name="destination" - placeholder={$t("user.settings.destinationPlaceholder")} - onkeypress={keypress} - class="w-full p-4 border rounded-xl h-48" - bind:value={user.destination} - ></textarea> - </div> - - <div> - <label for="display" class="font-bold mb-1 block" - >{$t("user.settings.threshold")}</label - > - <button type="button" class="flex w-full" onclick={editThreshold}> - <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> - <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300" - ></iconify-icon> - </div> - <div - class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left" - > - {user.threshold} - </div> - <input type="hidden" name="threshold" bind:value={user.threshold} /> - </button> - <p class="text-secondary mt-1"> - {$t("user.settings.thresholdDesc")} - </p> - </div> - - <div> - <label for="display" class="font-bold mb-1 block" - >{$t("user.settings.reserve")}</label - > - <button type="button" class="flex w-full" onclick={editReserve}> - <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> - <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300" - ></iconify-icon> - </div> - <div - class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left" - > - {user.reserve} - </div> - <input type="hidden" name="reserve" bind:value={user.reserve} /> - </button> - <p class="text-secondary mt-1"> - {$t("user.settings.reserveDesc")} - </p> - </div> -{/if} - -{#if editingThreshold} - <div - class="fixed bg-base-100 bg-opacity-90 inset-0 overflow-y-auto h-full w-full z-50 max-w-lg mx-auto" - > - <div class="relative p-5 border shadow-lg rounded-md bg-base-100 space-y-5"> - <h1 class="text-center text-2xl font-semibold"> - {$t("user.settings.threshold")} - </h1> - <Numpad - bind:amount={user.threshold} - {currency} - bind:rate - bind:submit={doneReserve} - bind:element={thresholdEl} - /> - - <button - bind:this={doneReserve} - type="button" - onclick={doneEditing} - class="btn">Ok</button - > - </div> - </div> -{/if} - -{#if editingReserve} - <div - class="fixed bg-base-100 bg-opacity-90 inset-0 overflow-y-auto h-full w-full z-50 mx-auto max-w-lg" - > - <div - class="relative mx-auto p-5 border shadow-lg rounded-md bg-base-100 space-y-5 text-center" - > - <h1 class="text-2xl font-semibold"> - {$t("user.settings.reserve")} - </h1> - <Numpad - bind:amount={user.reserve} - bind:currency - bind:rate - bind:submit={doneReserve} - bind:element={reserveEl} - /> - <button - bind:this={doneReserve} - type="button" - onclick={doneEditing} - class="btn">Ok</button - > - </div> - </div> -{/if} diff --git a/src/routes/(app)/settings/profile/+page.server.ts b/src/routes/(app)/settings/profile/+page.server.ts new file mode 100644 index 00000000..4145935e --- /dev/null +++ b/src/routes/(app)/settings/profile/+page.server.ts @@ -0,0 +1,5 @@ +import updateUser from "$lib/settings"; + +export const actions = { + default: updateUser, +}; diff --git a/src/routes/(app)/settings/profile/+page.svelte b/src/routes/(app)/settings/profile/+page.svelte new file mode 100644 index 00000000..3b7de9d3 --- /dev/null +++ b/src/routes/(app)/settings/profile/+page.svelte @@ -0,0 +1,181 @@ +<script> + import { tick } from "svelte"; + import { avatar, banner as bannerStore } from "$lib/store"; + import { t } from "$lib/translations"; + import { page } from "$app/stores"; + import PasswordInput from "$comp/PasswordInput.svelte"; + + let { data } = $props(); + let { user } = $derived(data); + let { id } = user; + let { about, banner, picture, display, username } = $state(user); + + let avatarFile, + avatarInput = $state(), + bannerFile, + password = $state(), + revealPassword = $state(), + bannerInput = $state(); + + let selectAvatar = () => avatarInput.click(); + let selectBanner = () => bannerInput.click(); + + let percent; + let progress = async (event) => { + percent = Math.round((event.loaded / event.total) * 100); + }; + + let tooLarge = $state({}); + + let handleFile = async ({ target }, type) => { + tooLarge[type] = false; + let file = target.files[0]; + if (!file) return; + + if (file.size > 10000000) return (tooLarge[type] = true); + + if (type === "picture") { + $avatar = { id, file, type, progress }; + } else if (type === "banner") { + $bannerStore = { id, file, type, progress }; + } + + var reader = new FileReader(); + reader.onload = async (e) => { + if (type === "picture") { + $avatar.src = e.target.result; + } else if (type === "banner") { + $bannerStore.src = e.target.result; + } + }; + + reader.readAsDataURL(file); + }; + + let url = $derived(`${$page.url.host}/${username}`); + let full = $derived(`${$page.url.protocol}//${url}`); + let addr = $derived(`${username}@${$page.url.host}`); +</script> + +<div> + <label for="username" class="font-bold mb-1 block" + >{$t("user.settings.username")}</label + > + <div class="flex mb-2"> + <input type="text" name="username" bind:value={username} /> + </div> +</div> + +<input type="hidden" name="confirm" bind:value={password} /> + +<div> + <label for="password" class="block font-bold block mb-1" + >{$t("user.settings.newPassword")}</label + > + + <PasswordInput bind:value={password} placeholder="(Leave blank to keep unchanged)" /> +</div> + +<div> + <label for="display" class="font-bold mb-1 block" + >{$t("user.settings.displayName")}</label + > + <input type="text" name="display" bind:value={display} /> +</div> + +<div class="space-y-2"> + <span class="font-bold">{$t("user.settings.profileImage")}</span> + + <div class="flex"> + {#if $avatar || picture} + <div + class="relative rounded-full overflow-hidden text-center w-20 h-20 my-auto hover:opacity-80 cursor-pointer" + onclick={selectAvatar} + onkeydown={selectAvatar} + > + <img + src={$avatar?.src || picture} + class="absolute w-full h-full object-cover object-center visible overflow-hidden" + alt={username} + /> + </div> + {:else} + <div + class="rounded-full border-4 border-base-100 p-4 bg-base-200 w-24 h-24 my-auto hover:opacity-80 cursor-pointer" + onclick={selectAvatar} + onkeydown={selectAvatar} + ></div> + {/if} + <div class="ml-2 p-2"> + <!-- found missing translation --> + <button + type="button" + class="btn" + onclick={selectAvatar} + onkeydown={selectAvatar}>{$t("user.settings.select")}</button + > + <input + type="file" + class="hidden!" + bind:this={avatarInput} + onchange={(e) => handleFile(e, "picture")} + /> + </div> + </div> + + {#if tooLarge["avatar"]} + <div class="text-red-600">Max file size 10MB</div> + {/if} +</div> + +<div class="space-y-2"> + <div class="flex justify-between items-center"> + <span class="font-bold">{$t("user.settings.bannerImage")}</span> + </div> + + {#if $bannerStore || banner} + <img + src={$bannerStore ? $bannerStore.src : banner} + class="w-full object-cover object-center visible overflow-hidden h-48 mb-4 hover:opacity-80" + onclick={selectBanner} + onkeydown={selectBanner} + alt="Banner" + /> + {:else} + <div + class="bg-base-200 w-full h-48 mb-4 cursor-pointer hover:opacity-80" + onclick={selectBanner} + onkeydown={selectBanner} + alt="Banner" + ></div> + {/if} + + <button + type="button" + class="btn !w-auto" + onclick={selectBanner} + onkeydown={selectBanner}>{$t("user.settings.select")}</button + > + <input + type="file" + class="hidden!" + bind:this={bannerInput} + onchange={(e) => handleFile(e, "banner")} + /> + + {#if tooLarge["banner"]} + <div class="text-red-600">Max file size 10MB</div> + {/if} +</div> + +<div> + <label for="about" class="font-bold mb-1 block" + >{$t("user.settings.about")}</label + > + <textarea + type="text" + name="about" + bind:value={about} + placeholder={$t("user.settings.aboutPlaceholder")} + ></textarea> +</div> diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte index 5d4b492f..8c38bca1 100644 --- a/src/routes/(app)/settings/security/+page.svelte +++ b/src/routes/(app)/settings/security/+page.svelte @@ -4,7 +4,6 @@ import { t } from "$lib/translations"; import Pin from "$comp/Pin.svelte"; import Qr from "$comp/Qr.svelte"; - import PasswordInput from "$comp/PasswordInput.svelte"; import { copy, post, success, fail } from "$lib/utils"; import { save, pin as current } from "$lib/store"; import { invalidate } from "$app/navigation"; @@ -21,9 +20,7 @@ newNsec = $state(), nsec = $state(), otp = $state(), - password = $state(), pin = $state(""), - revealPassword = $state(), revealSeed, revealNsec = $state(), token = $state(""), @@ -157,15 +154,6 @@ </script> <input type="hidden" name="newpin" value={disablingPin ? "delete" : pin} /> -<input type="hidden" name="confirm" bind:value={password} /> - -<div> - <label for="password" class="block font-bold block mb-1" - >{$t("user.settings.newPassword")}</label - > - - <PasswordInput bind:value={password} /> -</div> <div> <span class="font-bold mb-1" @@ -187,7 +175,8 @@ /> {:else} <button type="button" class="btn" onclick={togglePin}> - <iconify-icon noobserver + <iconify-icon + noobserver icon={haspin ? "ph:lock-key-open-bold" : "ph:lock-key-bold"} width="32" ></iconify-icon> @@ -220,12 +209,14 @@ </button> {:else if user.twofa} <button type="button" class="btn" onclick={startDisabling2fa}> - <iconify-icon noobserver icon="ph:device-mobile-bold" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:device-mobile-bold" width="32" + ></iconify-icon> <div class="my-auto">{$t("user.settings.twofaDisable")}</div> </button> {:else} <button type="button" class="btn" onclick={startEnabling2fa}> - <iconify-icon noobserver icon="ph:device-mobile-bold" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:device-mobile-bold" width="32" + ></iconify-icon> <div class="my-auto">{$t("user.settings.twofaSetup")}</div> </button> {/if} @@ -240,3 +231,13 @@ /> {/if} </div> + +<div> + <a href={`/account/savings`} class="contents"> + <button class="btn btn-lg w-full rounded-2xl whitespace-nowrap"> + <iconify-icon noobserver icon="ph:plus-circle-bold" width="32" + ></iconify-icon> + {$t("accounts.addAccount")} + </button> + </a> +</div> diff --git a/src/routes/(app)/settings/square/+page.server.ts b/src/routes/(app)/settings/square/+page.server.ts new file mode 100644 index 00000000..e00bdf1b --- /dev/null +++ b/src/routes/(app)/settings/square/+page.server.ts @@ -0,0 +1,18 @@ +import { auth, get } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export async function load({ cookies, url }) { + let connected; + + const query = url.searchParams.toString(); + + try { + await get(`/square/auth?${query}`, auth(cookies)); + connected = true; + } catch (e) { + const { message } = e as Error; + fail(400, { message }); + } + + if (connected) redirect(307, "/settings/account"); +} diff --git a/src/routes/(app)/settings/square/+page.svelte b/src/routes/(app)/settings/square/+page.svelte new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/routes/(app)/settings/square/+page.svelte diff --git a/src/routes/(static)/bounties/+page.server.ts b/src/routes/(static)/bounties/+page.server.ts new file mode 100644 index 00000000..c9d872b0 --- /dev/null +++ b/src/routes/(static)/bounties/+page.server.ts @@ -0,0 +1,18 @@ +import { get } from "$lib/utils"; +export const load = async () => { + const users = [ + { username: "fustler", merchant: "rrdrive", type: "Bakery" }, + { username: "laluna", merchant: "butchersguild", type: "Butcher Shop" }, + { username: "ica547", merchant: "vanlove", type: "Sushi Restaurant" }, + { username: "reverendhodl", merchant: "laughingbean", type: "Cafe" }, + ]; + + for (const user of users) { + const { username, merchant } = user; + console.log(username, merchant); + user.user = await get(`/users/${username}`); + user.merchant = await get(`/users/${merchant}`); + } + + return { users }; +}; diff --git a/src/routes/(static)/bounties/+page.svelte b/src/routes/(static)/bounties/+page.svelte index b53dc85d..db7a012b 100644 --- a/src/routes/(static)/bounties/+page.svelte +++ b/src/routes/(static)/bounties/+page.svelte @@ -1,96 +1,174 @@ -<div class="space-y-5"> - <h1 class="text-3xl mb-6 pt-28 md:pt-12">Bounties</h1> +<script> + import Avatar from "$comp/Avatar.svelte"; + import { s } from "$lib/utils"; + import Amount from "$comp/Amount.svelte"; - <p> - We're offering ⚡️1,000,000 to the first 10 people that help us onboard - these businesses in Vancouver to take bitcoin payments. - </p> + let { data } = $props(); + let { users } = data; - <ul class="text-center text-lg space-y-3"> - <li>Grocery / Produce Store</li> - <li class="leading-3"> - <span class="line-through">Cafe</span><br /> - <span class="text-xs" - ><a href="/reverendhodl">ReverendHodl</a> for - <a href="/laughingbean">Laughing Bean Coffee Co.</a></span - > - </li> - <li>Bakery</li> - <li class="leading-3"> - <span class="line-through">Butcher Shop</span><br /> - <span class="text-xs" - ><a href="/danielle">danielle</a> for - <a href="/butchersguild">All Natural & Exotic Meats</a></span - > - </li> - <li>Laundromat / Dry Cleaner</li> - <li>Dollar Store / Book Store / Gift Shop</li> - <li class="leading-3"> - <span class="line-through">Sushi Restaurant</span><br /> - <span class="text-xs" - ><a href="/ica547">ica547</a> for - <a href="/metallmash">VanLove Sushi</a></span + let bounties = [ + "Clothing Store", + "Hardware Store", + "Gas Station", + "Shoe Repair", + "Dollar Store", + "Book Store", + "Gift Shop", + "Game or Toy Store", + "Pet Store", + "Pharmacy", + ]; + + let small = [ + { + type: "Restaurant", + amount: 50000, + }, + { type: "Cafe", amount: 30000 }, + { type: "Tradesperson or Professional", amount: 25000 }, + { type: "Food Truck", amount: 20000 }, + { type: "Farmer's Market Vendor", amount: 20000 }, + ]; +</script> + +<div class="space-y-5 pb-20"> + <h1 class="text-3xl mb-6 md:pt-12 font-bold">Coinos Bounties</h1> + + <p>Earn sats by referring us to local businesses in Vancouver!</p> + + <h2 class="text-2xl font-bold">Currently Available</h2> + + <div class="space-y-0"> + {#each bounties as b} + <div class="flex max-w-xs sm:max-w-xl"> + <div>First {b}</div> + + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300 ml-auto" + ></iconify-icon>{s(100000, "en-CA")} + </div> + {/each} + </div> + + <hr /> + + <div class="space-y-0"> + {#each small as b} + <div class="flex max-w-xs sm:max-w-xl"> + <div>{b.type}</div> + + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300 ml-auto" + ></iconify-icon>{s(b.amount, "en-CA")} + </div> + {/each} + </div> + + <h2 class="text-2xl font-bold">Previously Awarded</h2> + + <div class="flex flex-col gap-4"> + {#each users as { user, merchant, type }} + <article + class="rounded-2xl border border-neutral-200 bg-white/70 p-4 shadow-sm transition hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900/60 sm:max-w-xl" > - </li> - <li>Pizza by the Slice</li> - <li>Liquor Store / Gas Station</li> - <li>Bubble Tea / Ice Cream Parlour</li> - </ul> + <div class="flex items-start gap-3 min-w-0 flex-wrap sm:flex-nowrap"> + <div> + <div class="flex"> + <div class="shrink-0"> + <Avatar user={merchant} size={12} /> + </div> - <p>Eligible businesses:</p> + <div class="flex items-center gap-2 min-w-0"> + <h3 + class="whitespace-wrap text-base font-semibold max-w-[150px] truncate sm:max-w-full" + title={merchant.display || merchant.username} + > + <a href={`/${merchant.username}`} + >{merchant.display || merchant.username}</a + > + </h3> - <ul class="text-sm ml-4 space-y-2"> - <li> - Have a physical location in Vancouver, North Vancouver, West Vancouver, - Burnaby, New Westminster, or Richmond - </li> - <li>Take walk-in customers</li> - <li>Have regular hours and a website with contact info</li> - <li>Have a 3 star or better rating on Google</li> - <li> - Accept bitcoin during their posted hours without needing to make any - special arrangements - </li> - <li>Use Coinos</li> - </ul> + <span + class="shrink-0 rounded-full border border-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:border-neutral-700 dark:text-neutral-300" + > + {type} + </span> + </div> + </div> + + <div + class="mt-2 flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300 min-w-0" + > + <Avatar {user} size={8} class="shrink-0" /> + <span class="truncate max-w-full" + >Referred by @{user.username}</span + > + </div> + </div> + + <div class="ml-auto flex shrink-0 items-center gap-1 whitespace"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + /> + <span class="text-sm font-semibold tabular-nums"> + {s(1000000, "en-CA")} + </span> + </div> + </div> + </article> + {/each} + </div> + + <h2 class="text-2xl font-bold">Terms and Conditions</h2> - <p>Each business category can be claimed only once</p> <p>One bounty per person</p> - <p> - The businesses can expect free ongoing support and promotion from us and our - community. - </p> + <div>Eligible businesses should:</div> + + <ul class="ml-4 space-y-0 list-disc"> + <li>Have a physical location in Greater Vancouver</li> + <li>Take walk-ins without appointments</li> + <li>Have a website with contact info</li> + <li>Have at least a 3-star rating on Google</li> + </ul> - <p>The prize money will be offered to the person who makes the referral.</p> + <div>They will be expected to:</div> + + <ul class="ml-4 space-y-0 list-disc"> + <li>Display a window sticker and QR</li> + <li>Train staff on Bitcoin procedures</li> + <li>Accept Bitcoin during posted business hours</li> + </ul> <p> - <b>⚡️250,000</b> will be awarded once the business has met with us and is ready - to take payments + 25% of the bounty will be awarded once the business is ready to take + payments and has been announced </p> + <p> - <b>⚡️750,000</b> will be awarded once the merchant has accepted bitcoin for - 6 months. We'll be sending - <a - href="https://btcmap.org/leaderboard" - class="text-black hover:underline" - target="_blank" - rel="noreferrer">shadowy supertaggers</a - > to check up on them periodically. + The rest will be awarded once they've been accepting Bitcoin for 6 months </p> + <p>Bounties will be awarded at our discretion</p> + <p> - Bounties will be awarded solely at our discretion and we reserve the right - to refuse any applicants without cause + We reserve the right to refuse applicants and change these terms without + cause or notice </p> - <h2 class="text-xl">Ready to begin!?</h2> + <h2 class="text-2xl font-bold">Get Started!</h2> <p> - To make a referral, reach out on one of our socials or email us at <a + Reach out to <a href="mailto:[email protected]" - class="text-black hover:underline">[email protected]</a - > and we'll set up an appointment with you and the business owner to get started. + class="text-blue-600 hover:underline">[email protected]</a + > and let us know who we should contact </p> <p> @@ -98,11 +176,10 @@ which ones are still available. </p> + <p> + Have a lead or idea for something that's not on the list? Reach out! We + offer sats for all sorts of referrals! + </p> + <p>Thanks for participating and helping to orange pill Vancouver!</p> </div> - -<style> - a { - @apply text-blue-400; - } -</style> diff --git a/src/routes/(static)/docs/+page.svelte b/src/routes/(static)/docs/+page.svelte index 7ac6e013..f53787e5 100644 --- a/src/routes/(static)/docs/+page.svelte +++ b/src/routes/(static)/docs/+page.svelte @@ -105,16 +105,18 @@ <div class="text-secondary"> <div>Request params</div> <div class="grid grid-cols-3 ml-2"> - <div class="font-bold">type</div> + <div class="font-bold">type (string, default: lightning)</div> <div class="col-span-2">bitcoin or lightning</div> - <div class="font-bold">amount</div> - <div class="col-span-2">amount in satoshis</div> - <div class="font-bold">webhook</div> + <div class="font-bold">fiat (boolean, default: false)</div> + <div class="col-span-2">whether the amount is denominated in satoshis or local currency</div> + <div class="font-bold">amount (numeric)</div> + <div class="col-span-2">amount to be paid</div> + <div class="font-bold">webhook (string, optional)</div> <div class="col-span-2"> - (optional) endpoint to hit when the invoice is paid + remote endpoint to call when the invoice is paid </div> - <div class="font-bold">secret</div> - <div class="col-span-2">a secret for the webhook to check</div> + <div class="font-bold">secret (string, optional)</div> + <div class="col-span-2">a secret value passed to the webhook to authenticate the request</div> </div> </div> diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index a24abd07..ffe6335d 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -11,6 +11,7 @@ export const load = async ({ parent }) => { export const actions = { login: async ({ cookies, request }) => { + console.log(request.headers.get('referer')); const form = await fd(request); let { username, password, token, loginRedirect } = form; const user = { username, password, token }; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 1a1f745c..825f6a88 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -124,7 +124,7 @@ </script> <div - class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20" + class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20 space-y-5" > <h1 class="text-2xl font-bold text-center">{$t("login.signIn")}</h1> {#if form?.error && !form?.message.includes("2fa")} diff --git a/src/routes/(static)/login/[token]/+page.server.ts b/src/routes/(static)/login/[token]/+page.server.ts new file mode 100644 index 00000000..8fe49578 --- /dev/null +++ b/src/routes/(static)/login/[token]/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from "@sveltejs/kit"; + +export const load = async ({ cookies, params }) => { + const { token } = params; + const maxAge = 380 * 24 * 60 * 60; + + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + + const opts = { path: "/", expires }; + cookies.set("token", token, opts); + redirect(307, "/login"); +}; diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 2c2807bd..cddd97ea 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -1,5 +1,5 @@ -import { fd, get, login, post } from "$lib/utils"; -import { fail, redirect } from "@sveltejs/kit"; +import { fd, get, register } from "$lib/utils"; +import { redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { const { user } = await parent(); @@ -14,39 +14,12 @@ export const load = async ({ parent }) => { export const actions = { default: async ({ cookies, request }) => { const ip = request.headers.get("cf-connecting-ip"); - const form = await fd(request); - let { picture, username, password, pubkey, loginRedirect } = form; - const user = { picture, username, password, pubkey }; - let error; - + const { picture, username, password } = form; + let { loginRedirect } = form; if (loginRedirect === "undefined") loginRedirect = undefined; - let sk; - try { - ({ sk } = await post("/register", { user }, { "cf-connecting-ip": ip })); - } catch (e) { - ({ message: error } = e as Error); - } - - try { - await login(user, cookies, ip); - error = null; - } catch (e) { - const { message } = e as Error; - error ||= message; - } - - const expires = new Date(); - expires.setSeconds(expires.getSeconds() + 21000000); - cookies.set("sk", sk, { - path: "/", - expires, - httpOnly: false, - sameSite: "lax", - }); - - if (error) return fail(400, { error }); - redirect(303, loginRedirect || `/${user.username}`); + const user = { picture, username, password }; + return register(user, ip, cookies, loginRedirect); }, }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index 43cafe6e..b0368d47 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -222,7 +222,7 @@ <h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1> <input type="file" - class="hidden" + class="hidden!" bind:this={avatarInput} onchange={(e) => handleFile(e, "picture")} /> @@ -263,9 +263,10 @@ </button> </div> - {#if form?.error} + {#if form?.error || form?.message} <div class="text-red-600 text-center" in:fly> - {form.error} + {form?.message} + {form?.error} </div> {/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d25f570a..7653e05a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,5 @@ <script> import { run } from "svelte/legacy"; - import { browser } from "$app/environment"; import { PUBLIC_DOMAIN } from "$env/static/public"; import "../app.css"; @@ -8,21 +7,72 @@ import { onMount } from "svelte"; import { installPrompt, theme as themeStore } from "$lib/store"; + +const map = { light: 'lofi', dark: 'black', system: 'system' }; + let { data, children } = $props(); let { pathname, theme } = $state(data); $effect(() => ($themeStore = data.theme)); $effect(() => (theme = $themeStore)); +$effect(() => { + const v = map[theme] ?? theme; + if (!v || v === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', v); + } +}); let host = PUBLIC_DOMAIN.includes("localhost") ? `http://${PUBLIC_DOMAIN}` : `https://${PUBLIC_DOMAIN}`; + function clearBadge() { + if ("clearAppBadge" in navigator) { + navigator.clearAppBadge().catch(() => {}); + } + } + onMount(() => { if (!browser) return; + + // Handle install prompt window.addEventListener("beforeinstallprompt", (event) => { event.preventDefault(); $installPrompt = event; }); + + clearBadge(); // first load + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") clearBadge(); + }); + + let hasNotch = typeof window.AndroidNotch !== "undefined"; + if (hasNotch) { + window.AndroidNotch.getInsetTop( + (px) => { + document.documentElement.style.setProperty( + "--safe-area-inset-top", + Math.round(px) + "px", + ); + }, + (err) => { + console.error("Failed to get notch inset:", err); + }, + ); + + window.AndroidNotch.getInsetBottom( + (px) => { + document.documentElement.style.setProperty( + "--safe-area-inset-bottom", + 24 + "px", + ); + }, + (err) => { + console.error("Failed to get bottom inset:", err); + }, + ); + } }); </script> @@ -32,23 +82,20 @@ <meta name="twitter:title" content="coinos" /> <meta property="og:image" content={`${host}/images/logo.webp`} /> - <meta property="og:type" content="website" /> <meta property="og:description" content="An easy to use bitcoin web wallet" /> <meta name="description" content="An easy to use bitcoin web wallet" /> <meta name="keywords" content="coinos easy bitcoin web wallet" /> - <meta name="twitter:image" content={`${host}/images/logo.webp`} /> <meta name="twitter:card" content="summary_large_image" /> - <meta property="og:url" content={host + pathname} /> <meta name="twitter:site" content="@coinoswallet" /> <meta name="twitter:creator" content="@coinoswallet" /> </svelte:head> {#if !$loading} - <main data-theme={theme}> + <main> {@render children?.()} </main> {/if} @@ -60,5 +107,13 @@ --toastContainerBottom: 8rem; --toastContainerLeft: calc(50vw - 8rem); --toastBackground: #292929; + + --safe-area-inset-top: 0px; + --safe-area-inset-bottom: 0px; + } + + main { + padding-top: var(--safe-area-inset-top); + padding-bottom: var(--safe-area-inset-bottom); } </style> diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 5679ea61..97184d3d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,3 +1,3 @@ export const load = async () => { - return { faqs: ["cost", "compatibility", "safety", "pos", "bitcoin"] }; + return { faqs: ["cost", "compatibility", "pos", "bitcoin"] }; }; diff --git a/src/routes/phtest/+page.svelte b/src/routes/phtest/+page.svelte new file mode 100644 index 00000000..625f0465 --- /dev/null +++ b/src/routes/phtest/+page.svelte @@ -0,0 +1 @@ +<a href="bitcoin:lnurl1dp68gurn8ghj7cm0d9hx7uewd9hj7up0v9kxjcm9lgkapg">Bitcoin link</a> |
