diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/About.svelte | 117 | ||||
| -rw-r--r-- | src/components/AppForm.svelte | 59 | ||||
| -rw-r--r-- | src/components/AppHeader.svelte | 4 | ||||
| -rw-r--r-- | src/components/Avatar.svelte | 8 | ||||
| -rw-r--r-- | src/components/Footer.svelte | 2 | ||||
| -rw-r--r-- | src/components/InvoiceActions.svelte | 113 | ||||
| -rw-r--r-- | src/components/Lnurlp.svelte | 22 | ||||
| -rw-r--r-- | src/components/Menu.svelte | 16 | ||||
| -rw-r--r-- | src/components/Nostr.svelte | 13 | ||||
| -rw-r--r-- | src/components/Numpad.svelte | 414 | ||||
| -rw-r--r-- | src/components/Payments.svelte | 4 | ||||
| -rw-r--r-- | src/components/Pin.svelte | 8 | ||||
| -rw-r--r-- | src/components/Qr.svelte | 14 | ||||
| -rw-r--r-- | src/components/SetAmount.svelte | 10 | ||||
| -rw-r--r-- | src/components/V3Banner.svelte | 35 |
15 files changed, 444 insertions, 395 deletions
diff --git a/src/components/About.svelte b/src/components/About.svelte index a1c03204..d98ef74d 100644 --- a/src/components/About.svelte +++ b/src/components/About.svelte @@ -1,105 +1,28 @@ <script> - import { onMount } from "svelte"; - import Icon from "$comp/Icon.svelte"; - import Image from "$comp/Image.svelte"; import { t } from "$lib/translations"; - - let team = [ - { - name: "adam", - socials: [ - { - alt: "Nostr", - src: "/images/nostr.svg", - url: "https://coinos.io/566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8", - }, - { - alt: "Github", - src: "/images/github.svg", - url: "https://github.com/asoltys", - }, - ], - }, - { name: "cole", socials: [] }, - ]; - - let locations = $state(); - onMount(async () => { - try { - ({ locations } = await fetch("/locations").then((r) => r.json())); - } catch (e) { - console.log(e); - } - }); </script> -<div class="flex justify-center"> - <div class="container space-y-10"> - <h3 class="text-5xl font-medium text-center"> - {$t("about.team.header")} - </h3> - <div class="space-y-10 2xl:space-y-0 2xl:grid gap-y-10 grid-cols-2"> - {#each team as member} - <div class="flex w-full"> - <div class="md:flex 2xl:items-start md:max-w-2xl mx-auto"> - <div class="w-[220px] mx-auto"> - <Image - image={member.name} - style="rounded-full mx-auto w-40 h-40 mb-5 object-cover" - /> +<div> + <h3 class="text-5xl font-medium mb-5 text-center"> + {$t("about.locations.header")} + </h3> + <p + class="text-secondary text-xl w-full md:w-10/12 lg:w-2/3 2xl:w-[800px] mx-auto mb-10" + > + {@html $t("about.locations.description")} + </p> - <div class="space-y-2"> - <span class="text-2xl text-center font-semibold block" - >{$t(`about.team.${member.name}.name`)}</span - > - <span class="text-xl text-center text-secondary block" - >{$t(`about.team.${member.name}.position`)}</span - > - </div> + <div class="relative"> + <a href="/map" class="contents"> + <img src="/map.png" class="mx-auto" alt="Map" /> + </a> - <div class="mt-2 flex justify-center items-center space-x-5"> - {#each member.socials as { alt, src, url }} - <a - href={url} - class={`rounded-full p-2 hover:opacity-80`} - ><img {src} {alt} class="w-8" /></a - > - {/each} - </div> - </div> - <div class="text-secondary text-xl w-full max-w-[450px] mt-5"> - {$t(`about.team.${member.name}.bio`)} - </div> - </div> - </div> - {/each} - </div> + <a href="/map" class="contents"> + <button + class="bg-black text-white border border-4 rounded-full px-6 py-2 font-bold hover:bg-secondary absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 text-2xl" + > + {$t("about.locations.viewMap")} + </button> + </a> </div> </div> - -{#if locations?.length} - <div> - <h3 class="text-5xl font-medium mb-5 text-center"> - {$t("about.locations.header")} - </h3> - <p - class="text-secondary text-xl w-full md:w-10/12 lg:w-2/3 2xl:w-[800px] mx-auto mb-10" - > - {@html $t("about.locations.description")} - </p> - - <div class="relative"> - <a href="/map" class="contents"> - <img src="/map.png" class="mx-auto" alt="Map" /> - </a> - - <a href="/map" class="contents"> - <button - class="bg-black text-white border border-4 rounded-full px-6 py-2 font-bold hover:bg-secondary absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 text-2xl" - > - {$t("about.locations.viewMap")} - </button> - </a> - </div> - </div> -{/if} diff --git a/src/components/AppForm.svelte b/src/components/AppForm.svelte index 76f703f1..97472720 100644 --- a/src/components/AppForm.svelte +++ b/src/components/AppForm.svelte @@ -7,7 +7,7 @@ import Toggle from "$comp/Toggle.svelte"; import { getPublicKey } from "nostr-tools"; import { onMount, tick } from "svelte"; - import { bytesToHex, randomBytes } from "@noble/hashes/utils"; + import { bytesToHex, randomBytes } from "@noble/hashes/utils.js"; import { goto } from "$app/navigation"; import { copy, focus, fail, post } from "$lib/utils"; import { t } from "$lib/translations"; @@ -17,17 +17,7 @@ PUBLIC_COINOS_RELAY as relayUrl, } from "$env/static/public"; - let { - rate, - user, - name, - max_fee, - max_amount, - budget_renewal, - pubkey, - secret, - notify, - } = $props(); + let { rate, user, name, max_fee, max_amount, budget_renewal, pubkey, secret, notify } = $props(); let { currency } = $derived(user); onMount(() => { @@ -36,8 +26,9 @@ }); let generate = () => { - secret = bytesToHex(randomBytes(32)); - pubkey = getPublicKey(secret); + let secretBytes = randomBytes(32); + secret = bytesToHex(secretBytes); + pubkey = getPublicKey(secretBytes); }; let lud16 = $derived(`${user.username}@${$page.url.host}`); @@ -95,9 +86,7 @@ /> <div> - <label for="name" class="font-bold mb-1 block" - >{$t("accounts.renewal")}</label - > + <label for="name" class="font-bold mb-1 block">{$t("accounts.renewal")}</label> <select name="budget_renewal" bind:value={budget_renewal}> <option value="never" selected>{$t("accounts.never")}</option> <option value="daily">{$t("accounts.daily")}</option> @@ -108,9 +97,7 @@ </div> <div> - <label for="notify" class="font-bold mb-1 block" - >{$t("accounts.notifications")}</label - > + <label for="notify" class="font-bold mb-1 block">{$t("accounts.notifications")}</label> <select name="notify" bind:value={notify}> <option value={false} selected>{$t("accounts.off")}</option> <option value={true}>{$t("accounts.on")}</option> @@ -118,22 +105,10 @@ </div> <div> - <label for="pubkey" class="font-bold mb-1 block" - >{$t("accounts.pubkey")}</label - > + <label for="pubkey" class="font-bold mb-1 block">{$t("accounts.pubkey")}</label> <div class="flex gap-1 items-center"> - <textarea - rows={3} - name="pubkey" - bind:value={pubkey} - class="grow" - readonly={secret} - /> - <button - type="button" - onclick={() => copy(pubkey)} - class="btn !w-auto shrink" - > + <textarea rows={3} name="pubkey" bind:value={pubkey} class="grow" readonly={secret} /> + <button type="button" onclick={() => copy(pubkey)} class="btn !w-auto shrink"> <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> </button> </div> @@ -141,16 +116,13 @@ {#if !pubkey || secret} <div> - <label for="secret" class="font-bold mb-1 block" - >{$t("accounts.secret")}</label - > + <label for="secret" class="font-bold mb-1 block">{$t("accounts.secret")}</label> <div class="flex gap-1 items-center"> <textarea rows={3} name="secret" bind:value={secret} lcass="grow" /> <div class="space-y-2 w-24"> <button type="button" onclick={generate} class="btn"> - <iconify-icon noobserver icon="ph:dice-three-bold" width="32" - ></iconify-icon> + <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon> </button> <button type="button" onclick={() => copy(secret)} class="btn"> <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> @@ -161,13 +133,12 @@ {/if} <div class="space-y-2"> - <button type="submit" class="btn btn-accent">{$t("payments.submit")}</button - > + <button type="submit" class="btn btn-accent">{$t("payments.submit")}</button> {#if pubkey} <button type="button" class="btn" onclick={del}> <iconify-icon icon="ph:trash-bold" width="32"></iconify-icon> - {$t("accounts.delete")}</button - > + {$t("accounts.delete")} + </button> {/if} </div> </form> diff --git a/src/components/AppHeader.svelte b/src/components/AppHeader.svelte index 7ed4563f..0a3497c4 100644 --- a/src/components/AppHeader.svelte +++ b/src/components/AppHeader.svelte @@ -92,8 +92,10 @@ {/if} <style> + @reference "../app.css"; + .btn-menu { - @apply flex justify-center items-center bg-base-100 p-2 rounded-full w-12 h-12 sm:w-16 sm:h-16 drop-shadow-xl; + @apply flex justify-center items-center bg-base-100 p-2 rounded-full w-12 h-12 drop-shadow-xl; } header { diff --git a/src/components/Avatar.svelte b/src/components/Avatar.svelte index 09b6c037..438008b8 100644 --- a/src/components/Avatar.svelte +++ b/src/components/Avatar.svelte @@ -11,7 +11,9 @@ let { user, size = 32, disabled = false }: Props = $props(); let s = $derived(size.toString()); - let link = $derived(user?.anon ? `/${user.pubkey}` : `/${user.username}`); + let link = $derived( + user ? (user.anon ? `/${user.pubkey}` : `/${user.username}`) : undefined, + ); let base = "/api/public"; let profile = $derived( @@ -38,8 +40,10 @@ {#if disabled} {@render body()} -{:else} +{:else if link} <a href={link} class:pointer-events-none={disabled}> {@render body()} </a> +{:else} + {@render body()} {/if} diff --git a/src/components/Footer.svelte b/src/components/Footer.svelte index 0cab1d13..8d2275ca 100644 --- a/src/components/Footer.svelte +++ b/src/components/Footer.svelte @@ -5,7 +5,7 @@ const followLinks = [ { - url: "nostr:nprofile1qqst4qyeqenw7zm0fwjsty68h6cnys5jre2xd8ngqpjv5a2j26s78fspz4mhxue69uhhyetvv9ujucm0d9hx7uewd9hsgwx239", + url: "nostr:nprofile1qy28wue69uhnzv3h9cczuvpwxyargwpk8yhszxrhwvaz7tmhda6zucnjd9nksarzdak8gtnwv46z7qpqw27mc4aa6m0ufe3xs5z3m6qyr52gc0rglept7vqlwx4xeaf72tasv5xyqv", titleID: "nostr", }, { url: "https://t.me/coinoswallet", titleID: "telegram" }, diff --git a/src/components/InvoiceActions.svelte b/src/components/InvoiceActions.svelte index b11a66c3..e2a31ab8 100644 --- a/src/components/InvoiceActions.svelte +++ b/src/components/InvoiceActions.svelte @@ -45,62 +45,6 @@ <div class="my-auto text-lg">Liquid</div> </button> - <div class="flex gap-2"> - <button - class="btn flex-nowrap !w-auto grow" - class:bg-base-300={type === types.lightning} - class:text-secondary={type === types.lightning} - onclick={() => { - $amountPrompt = false; - newAmount = undefined; - setType(types.lightning); - }} - > - <div - class="bg-black rounded-full w-8 h-8 items-center justify-center flex" - > - <div class="m-auto"> - <iconify-icon - noobserver - icon="ph:lightning-fill" - class="text-yellow-300 text-2xl" - ></iconify-icon> - </div> - </div> - <div class="my-auto text-lg">Lightning</div> - </button> - - <button - class="btn flex-nowrap !w-auto grow" - class:bg-base-300={type === types.bitcoin} - class:text-secondary={type === types.bitcoin} - onclick={() => setType(types.bitcoin)} - > - <img src="/images/bitcoin.svg" class="w-8" alt="Bitcoin" /> - <div class="my-auto text-lg">Bitcoin</div> - </button> - </div> - - <div class="flex flex-wrap gap-2 justify-around text-secondary w-full"> - <button type="button" class="btn !w-auto grow" onclick={() => copy(txt)}> - <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> - <div class="my-auto"> - {t("payments.copy")} - </div> - </button> - - <button class="btn grow !w-auto" onclick={() => goto("/receive")}> - <iconify-icon noobserver icon="ph:clipboard-text-bold" width="32" - ></iconify-icon> - <div class="my-auto text-lg">{t("user.send.paste")}</div> - </button> - - <button class="btn grow !w-auto" onclick={() => goto("/scan")}> - <iconify-icon noobserver icon="ph:camera-bold" width="32"></iconify-icon> - <div class="my-auto text-lg">{t("user.send.scan")}</div> - </button> - </div> - <div class="flex flex-wrap gap-2 justify-around text-secondary w-full"> {#if user?.id === invoice?.user?.id} <button class="btn grow !w-auto" onclick={toggleAmount}> @@ -127,6 +71,26 @@ </button> </div> + <div class="flex flex-wrap gap-2 justify-around text-secondary w-full"> + <button type="button" class="btn !w-auto grow" onclick={() => copy(txt)}> + <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> + <div class="my-auto"> + {t("payments.copy")} + </div> + </button> + + <button class="btn grow !w-auto" onclick={() => goto("/receive")}> + <iconify-icon noobserver icon="ph:clipboard-text-bold" width="32" + ></iconify-icon> + <div class="my-auto text-lg">{t("user.send.paste")}</div> + </button> + + <button class="btn grow !w-auto" onclick={() => goto("/scan")}> + <iconify-icon noobserver icon="ph:camera-bold" width="32"></iconify-icon> + <div class="my-auto text-lg">{t("user.send.scan")}</div> + </button> + </div> + {#if txt.length > 120} <div class="w-full flex justify-center gap-2 flex-wrap"> <button class="btn" onclick={() => (showQr = !showQr)}> @@ -144,6 +108,43 @@ </div> {/if} + <div class="flex gap-2"> + <button + class="btn flex-nowrap !w-auto grow" + class:bg-base-300={type === types.lightning} + class:text-secondary={type === types.lightning} + onclick={() => { + $amountPrompt = false; + newAmount = undefined; + setType(types.lightning); + }} + > + <div + class="bg-black rounded-full w-8 h-8 items-center justify-center flex" + > + <div class="m-auto"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300 text-2xl" + ></iconify-icon> + </div> + </div> + <div class="my-auto text-lg">Lightning</div> + </button> + + <button + class="btn flex-nowrap !w-auto grow" + class:bg-base-300={type === types.bitcoin} + class:text-secondary={type === types.bitcoin} + onclick={() => setType(types.bitcoin)} + > + <img src="/images/bitcoin.svg" class="w-8" alt="Bitcoin" /> + <div class="my-auto text-lg">Bitcoin</div> + </button> + </div> + + <button type="button" class="btn" onclick={toggleType}> <img src="/images/liquid.svg" class="w-8" alt="Liquid" /> <img src="/images/cash.png" class="w-8 my-auto" /> diff --git a/src/components/Lnurlp.svelte b/src/components/Lnurlp.svelte index 8f14ddff..c1ae0c95 100644 --- a/src/components/Lnurlp.svelte +++ b/src/components/Lnurlp.svelte @@ -6,15 +6,19 @@ import Numpad from "$comp/Numpad.svelte"; import Spinner from "$comp/Spinner.svelte"; import { pin } from "$lib/store"; + import { s } from "$lib/utils"; let { data, form, send = $bindable(), comment = $bindable() } = $props(); let { currency } = data.user; - let { minSendable, maxSendable, commentAllowed, callback, metadata, rate } = + let { minSendable, maxSendable, commentAllowed, callback, metadata, rate, balance } = data; + let max = $derived(Math.min(balance || 0, Math.floor(maxSendable / 1000))); let amount = $state(Math.round(minSendable / 1000)), loading = $state(); let submit = () => (loading = true); + // An action result (an error, since success redirects) ends the wait + $effect(() => form && (loading = false)); </script> <div class="container px-4 mt-20 max-w-xl mx-auto space-y-2"> @@ -56,8 +60,20 @@ ></textarea> {/if} - <div class="flex w-full"> - <button bind:this={send} type="submit" class="btn"> + <div class="flex w-full gap-2"> + {#if max >= Math.round(minSendable / 1000)} + <!-- loading is set by the form's onsubmit, not here: flipping + `disabled` from a click handler lands in the DOM before the + browser's submit activation runs, and a disabled button submits + nothing (the page just sat on a spinner). --> + <button + type="submit" + class="btn !w-auto grow" + formaction="?/max" + disabled={loading}>Max ⚡️{s(max)}</button + > + {/if} + <button bind:this={send} type="submit" class="btn btn-accent !w-auto grow"> {#if loading} <Spinner /> {:else} diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 7408687c..e0ef8779 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -1,7 +1,7 @@ <script> import { theme } from "$lib/store"; import DarkToggle from "$comp/DarkToggle.svelte"; - import OutClick from "svelte-outclick"; + import { OutClick } from "svelte-outclick"; let { t, user, w, opacity } = $props(); let dark = () => ($theme = $theme === "dark" ? "light" : "dark"); @@ -11,11 +11,11 @@ { key: "nav.settings", icon: "ph:gear-bold", href: `/settings` }, { key: "nav.support", icon: "ph:lifebuoy-bold", href: `/support` }, { key: "nav.map", icon: "ph:map-trifold-bold", href: `/map` }, - { - key: "nav.merch", - icon: "ph:storefront-bold", - href: `https://coinosmerch.com`, - }, + // { + // key: "nav.merch", + // icon: "ph:storefront-bold", + // href: `https://coinosmerch.com`, + // }, { key: "nav.dark", icon: "ph:moon-stars-bold", href: `/dark` }, { key: "nav.signOut", icon: "ph:sign-out-bold", href: `/logout` }, ]; @@ -25,7 +25,7 @@ </script> <div> - <OutClick on:outclick={hideMenu}> + <OutClick onOutClick={hideMenu}> <button class="btn-menu {opacity('/support')}" onclick={toggleMenu} ><iconify-icon noobserver icon="ph:list-bold" width={w > 640 ? 32 : 24} ></iconify-icon> @@ -68,6 +68,8 @@ </div> <style> + @reference "../app.css"; + .btn-menu { @apply flex justify-center items-center bg-base-100 p-2 rounded-full w-12 h-12 sm:w-16 sm:h-16 drop-shadow-xl; } diff --git a/src/components/Nostr.svelte b/src/components/Nostr.svelte index ad70cd31..b41cb8ff 100644 --- a/src/components/Nostr.svelte +++ b/src/components/Nostr.svelte @@ -4,7 +4,7 @@ import { t } from "$lib/translations"; import PasswordInput from "$comp/PasswordInput.svelte"; import { Relay } from "nostr-tools/relay"; - import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; + import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { nip04, nip19, @@ -13,7 +13,6 @@ getPublicKey, } from "nostr-tools"; import { getConversationKey, decrypt } from "nostr-tools/nip44"; - import { AmberClipboardSigner } from "applesauce-signer"; import { copy, focus, fail, post } from "$lib/utils"; import { eventToSign, signer } from "$lib/store"; @@ -26,8 +25,8 @@ let cancel = () => { eventToSign.set(); showNsec = false; - pubkey = undefined; connectUrl = undefined; + $signer = "cancel"; }; let signerAvailable = $derived( @@ -105,14 +104,6 @@ $signer = { method: "extension", ready: true }; }; - let signerGetPubkey = async () => { - const pubkey = await new AmberClipboardSigner().getPublicKey(); - $signer = { - method: "signer", - params: { pubkey }, - }; - }; - let signerSign = async () => { document.addEventListener("focus", signerSigned, true); }; diff --git a/src/components/Numpad.svelte b/src/components/Numpad.svelte index d80f0400..c321e0bc 100644 --- a/src/components/Numpad.svelte +++ b/src/components/Numpad.svelte @@ -1,23 +1,30 @@ <script> import { onMount } from "svelte"; - import { fiat } from "$lib/store"; import Left from "$comp/Left.svelte"; - import Icon from "$comp/Icon.svelte"; import { f, s, focus, warning, sat, sats } from "$lib/utils"; import { t } from "$lib/translations"; + import { fiat as fiatStore } from "$lib/store"; - export let amount = 0, + // ---- Props (Svelte 5 runes) ---- + let { + amount = $bindable(), currency = "USD", + fiat = $bindable(), element, - rate, + rate = $bindable(), locale, - submit = undefined; + submit = undefined, + amountFiat = 0, + } = $props(); - export let amountFiat = 0; + // ---- Local state ---- + let fiatDigits = $state("0"); // cents buffer in fiat mode + let html = $state("0"); - let pasted = async () => { - html = (await navigator.clipboard.readText()).replace(/,/g, ""); - }; + // currency info as reactive state + let decimalChar = $state("."); + let symbol = $state(""); + let position = $state("before"); function getCurrencyInfo(locale = "en-US", currency) { const formatter = new Intl.NumberFormat(locale, { @@ -27,168 +34,232 @@ minimumFractionDigits: 2, maximumFractionDigits: 2, }); - const parts = formatter.formatToParts(123456.78); - const { value: decimal } = parts.find((part) => part.type === "decimal"); - const currencyPart = parts.find((part) => part.type === "currency"); - const symbol = currencyPart ? currencyPart.value : ""; + const { value: decimal } = parts.find((p) => p.type === "decimal"); + const currencyPart = parts.find((p) => p.type === "currency"); + const sym = currencyPart ? currencyPart.value : ""; + const firstNumberIndex = parts.findIndex((p) => p.type === "integer"); + const currencyIndex = parts.findIndex((p) => p.type === "currency"); + const pos = currencyIndex < firstNumberIndex ? "before" : "after"; + return { symbol: sym, position: pos, decimal }; + } - const firstNumberIndex = parts.findIndex((part) => part.type === "integer"); - const currencyIndex = parts.findIndex((part) => part.type === "currency"); - const position = currencyIndex < firstNumberIndex ? "before" : "after"; + // reflect external prop/locale changes + $effect(() => { + const info = getCurrencyInfo(locale, currency); + decimalChar = info.decimal; + symbol = info.symbol; + position = info.position; + }); - return { symbol, position, decimal }; + function formatFiatFromDigits(digits, dec = ".") { + let d = digits.replace(/\D+/g, ""); + if (!d) d = "0"; + if (d.length > 15) d = d.slice(0, 15); + if (d.length === 1) d = "0" + d; + if (d.length === 2) d = "0" + d; + const whole = d.slice(0, -2).replace(/^0+(?=\d)/, "") || "0"; + const cents = d.slice(-2); + return `${whole}${dec}${cents}`; } - function strip(input) { - const regex = new RegExp(`[^0-9\.]`, "g"); - return input.replace(decimal, ".").replace(regex, ""); + function normalizeSats(str) { + const digits = (str ?? "").replace(/[^\d]/g, ""); + const noLead = digits.replace(/^0+(?=\d)/, ""); + return noLead || "0"; } - let { decimal, symbol, position } = getCurrencyInfo(locale, currency); - - let arrow = "<"; - - $: update(amount, amountFiat); - let update = (a, f) => { - if ($fiat) { - amount = f ? Math.round(strip(f) / (rate / sats)) : 0; + const updateByMode = () => { + if (fiat) { + const numericFiat = + parseFloat((fiatDigits || "0").replace(/\D/g, "")) / 100; + amountFiat = isFinite(numericFiat) ? numericFiat.toFixed(2) : "0.00"; + amount = numericFiat ? Math.round((numericFiat * sats) / rate) : 0; } else { - amountFiat = a ? ((a * rate) / sats).toFixed(2) : 0; + // integer-only sats, already normalized + const a = parseInt((html || "0").replace(/[^\d]/g, "")) || 0; + amount = a || 0; + const fval = amount ? (amount * rate) / sats : 0; + amountFiat = fval ? fval.toFixed(2) : "0.00"; } }; - let loading = false; - - const numPad = [ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - decimal, - "0", - "<", - ]; - - const handleInput = (value) => { - if (value === "<") html = html.substr(0, html.length - 1); - else html += value; + const pasted = async () => { + const txt = (await navigator.clipboard.readText()) ?? ""; + if (fiat) { + const m = txt.replace(/[^\d.,]/g, ""); + const normalized = m.replace(new RegExp(`[${decimalChar}]`, "g"), "."); + const val = parseFloat(normalized); + const cents = isFinite(val) ? Math.round(val * 100) : 0; + fiatDigits = Math.max(0, cents).toString(); + html = formatFiatFromDigits(fiatDigits, decimalChar); + } else { + const clean = normalizeSats(txt); + html = clean; + element && (element.innerHTML = html); + } input(null, true); }; - $: html = $fiat ? amountFiat : (amount || 0).toString(); - - let prev = ""; - - let input = (e, numpad = false) => { - let d = decimal.replace(".", "\\."); - - if (!$fiat && html.includes(decimal)) { - html = html.replace(new RegExp(d), ""); - } - - if (prev === "0" && html !== `0${decimal}`) { - prev = ""; - html = html.replace("0", ""); - } - - let sel = getSelection(); - let i = sel.focusOffset; - - if (html[0] === "0") i = html.length; - - if (html.split(decimal)[0][0] === "0" && html.length > 1) { - html = html.replace("0", ""); - i++; - } + let prevHtml = $state(""); - if (html[0] === decimal) { - html = `0${html}`; - i = html.length; - } + // (kept for completeness; no longer used in sats path) + function trimTrailingZeros(str) { + if (!str.includes(".")) return str; + let out = str.replace(/(\.\d*?[1-9])0+$/, "$1"); + out = out.replace(/\.0*$/, ""); + return out; + } - if (html.includes(decimal) && html.split(decimal)[1].length > 2) { - html = html.slice(0, html.indexOf(decimal) + 3); - i = html.length; + const input = (e, fromPad = false) => { + if (fiat) { + if (!fromPad) { + const raw = (element?.textContent ?? "").replace(/[^\d]/g, ""); + fiatDigits = raw || "0"; + } + const cents = parseInt(fiatDigits || "0") || 0; + const numericFiat = cents / 100; + const satsEstimate = Math.round((numericFiat * sats) / rate); + if (satsEstimate > sats && html.length > prevHtml.length) { + fiatDigits = fiatDigits.slice(0, -1) || "0"; + warning($t("user.receive.lessThan1BTCWarning")); + } + html = formatFiatFromDigits(fiatDigits, decimalChar); + } else { + // integer-only sats, normalize leading zeros + let clean = normalizeSats(element?.textContent ?? ""); + const maybe = parseInt(clean) || 0; + if (maybe > sats && clean.length > prevHtml.length) { + clean = normalizeSats(clean.slice(0, -1)); + warning($t("user.receive.lessThan1BTCWarning")); + } + html = clean; + element && (element.innerHTML = html); } - let clean = html - .substr(0, 15) - .replace(new RegExp(`[^0-9${d}]+`, "g"), "") - .replace(new RegExp(d), "X") - .replace(new RegExp(d, "g"), "") - .replace("X", decimal); + updateByMode(); - if (clean !== html) { - html = clean; - i = html.length; - } - if (amount > sats && html.length > prev.length) { - html = html.substr(0, html.length - 1); - i = html.length; - warning($t("user.receive.lessThan1BTCWarning")); + if (!fromPad) { + queueMicrotask(() => { + const node = element?.childNodes?.[0]; + if (!node) return; + const range = document.createRange(); + const sel = getSelection(); + const L = node.length ?? node.textContent?.length ?? 0; + range.setStart(node, L); + range.setEnd(node, L); + sel.removeAllRanges(); + sel.addRange(range); + }); } + prevHtml = html.toString(); + }; - if ($fiat) { - amountFiat = html; + const handleInput = (value) => { + if (fiat) { + if (value === arrow) { + fiatDigits = fiatDigits.length > 1 ? fiatDigits.slice(0, -1) : "0"; + } else if (value === "00") { + fiatDigits = (fiatDigits === "0" ? "" : fiatDigits) + "00"; + if (!fiatDigits) fiatDigits = "0"; + } else { + fiatDigits = (fiatDigits === "0" ? "" : fiatDigits) + value; + } + html = formatFiatFromDigits(fiatDigits, decimalChar); } else { - amount = parseInt(html); - } - - if (!amount) amount = null; - - if (!numpad) { - setTimeout(() => { - let node = element.childNodes[0]; - let range = document.createRange(); - - if (node) { - if (i > node.length) i = node.length; - range.setStart(node, i); - range.setEnd(node, i); - - let sel = getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - node.selectionStart = node.selectionEnd; - } - - prev = html.toString(); - }, 0); + if (value === arrow) { + html = html.length > 1 ? html.slice(0, -1) : "0"; + } else if (value === "00") { + // If current is "0", keep it "0"; otherwise append two zeros + html = html === "0" ? "0" : html + "00"; + } else { + // If current is "0", replace with the digit; else append + html = html === "0" ? value : html + value; + } + // normalize away any accidental leading zeros + html = normalizeSats(html); + element && (element.innerHTML = html); } + input(null, true); }; - let select = (e) => { - let range = document.createRange(); + const select = (e) => { + const range = document.createRange(); range.selectNodeContents(e.target); - let sel = getSelection(); + const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range); }; - let blur = () => { - if (!html) html = "0"; + const blur = () => { + if (!html) html = fiat ? formatFiatFromDigits("0", decimalChar) : "0"; }; - let keydown = (e) => - e.key === "Enter" && (e.preventDefault() || submit.click()); + const keydown = (e) => + e.key === "Enter" && (e.preventDefault() || submit?.click?.()); - let swap = () => { - if ($fiat) { - amount = parseInt((amountFiat / (rate / sats)).toFixed(0)); + // CHANGED: make swap set html for the new mode immediately and keep DOM in sync + const swap = () => { + if (fiat) { + // going -> sats + fiat = false; + html = normalizeSats((amount || 0).toString()); + element && (element.innerHTML = html); } else { - amountFiat = - (amount * (rate / sats)).toFixed(2) > 0.0 - ? (amount * (rate / sats)).toFixed(2) - : 0; + // going -> fiat + const cents = Math.round((parseFloat(amountFiat || "0") || 0) * 100); + fiatDigits = Math.max(0, cents).toString(); + fiat = true; + html = formatFiatFromDigits(fiatDigits, decimalChar); + element && (element.innerHTML = html); } - $fiat = !$fiat; + updateByMode(); + prevHtml = html.toString(); + + // Move caret to end after swap + queueMicrotask(() => { + const node = element?.childNodes?.[0]; + if (!node) return; + const range = document.createRange(); + const sel = getSelection(); + const L = node.length ?? node.textContent?.length ?? 0; + range.setStart(node, L); + range.setEnd(node, L); + sel.removeAllRanges(); + sel.addRange(range); + }); + + $fiatStore = fiat; }; + + const arrow = "<"; + const numPad = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "00", + "0", + arrow, + ]; + + onMount(() => { + fiat = $fiatStore; + if (fiat) { + const cents = Math.round((parseFloat(amountFiat || "0") || 0) * 100); + fiatDigits = Math.max(0, cents).toString(); + html = formatFiatFromDigits(fiatDigits, decimalChar); // keep html in sync on mount + } else { + html = normalizeSats((amount || 0).toString()); + } + element && (element.innerHTML = html); + updateByMode(); + }); </script> <div class="flex justify-center items-center"> @@ -197,11 +268,9 @@ <div class="text-5xl md:text-6xl font-semibold tracking-widest flex justify-center" > - <div class="my-auto" class:text-5xl={!$fiat}> - {#if $fiat} - {#if position === "before"} - {symbol} - {/if} + <div class="my-auto" class:text-5xl={!fiat}> + {#if fiat} + {#if position === "before"}{symbol}{/if} {:else} <iconify-icon noobserver @@ -210,27 +279,47 @@ ></iconify-icon> {/if} </div> + + <!-- contenteditable input --> <div use:focus + role="textbox" + tabindex="0" + aria-label="Amount input" + aria-multiline="false" contenteditable bind:innerHTML={html} - on:paste={pasted} - on:focus={select} - on:blur={blur} - on:input={input} - on:keydown={keydown} + inputmode={fiat ? "decimal" : "numeric"} + enterkeyhint="done" + autocapitalize="off" + autocorrect="off" + spellcheck="false" + onpaste={pasted} + onfocus={(e) => { + select(e); + }} + onblur={blur} + oninput={input} + onkeydown={keydown} class="outline-none my-auto" bind:this={element} - /> - {#if $fiat && position === "after"} - {symbol} - {/if} + ></div> + + {#if fiat && position === "after"}{symbol}{/if} </div> - <div - class="flex items-center justify-center text-2xl cursor-pointer" - on:click|preventDefault|stopPropagation={swap} + + <!-- swap button --> + <button + type="button" + class="flex items-center justify-center text-2xl cursor-pointer mx-auto select-none" + aria-label="Swap currency display" + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + swap(); + }} > - {#if $fiat} + {#if fiat} <iconify-icon noobserver icon="ph:lightning-fill" @@ -240,7 +329,7 @@ {:else} {f(amountFiat, currency, locale)} {/if} - </div> + </button> </div> <div class="grid grid-cols-3 gap-2 w-full mx-auto"> @@ -249,7 +338,12 @@ <button type="button" class="btn" - on:click|preventDefault|stopPropagation={() => handleInput(value)} + aria-label="Backspace" + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleInput(value); + }} > <Left /> </button> @@ -257,9 +351,15 @@ <button type="button" class="btn" - on:click|preventDefault|stopPropagation={() => handleInput(value)} - >{value}</button + aria-label={`Key ${value}`} + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleInput(value); + }} > + {value} + </button> {/if} {/each} </div> diff --git a/src/components/Payments.svelte b/src/components/Payments.svelte index 36633089..232ef552 100644 --- a/src/components/Payments.svelte +++ b/src/components/Payments.svelte @@ -111,7 +111,9 @@ ? p.confirmed ? $t("payments.received") : $t("payments.pending") - : $t("payments.sent")} + : p.confirmed + ? $t("payments.sent") + : $t("payments.pending")} {/if} </div> </div> diff --git a/src/components/Pin.svelte b/src/components/Pin.svelte index a429e976..980e17fa 100644 --- a/src/components/Pin.svelte +++ b/src/components/Pin.svelte @@ -20,7 +20,7 @@ let loaded; onMount(() => setTimeout(() => (loaded = true), 500)); - let locktime = 30 * 60; + let locktime = 5 * 60; $: loaded && checkPin(p); let checkPin = async (p) => { @@ -34,7 +34,10 @@ if (result) { if (notify) success("Pin confirmed"); - if (locktime) setCookie("pin", p, locktime); + if (locktime) { + setCookie("pin", p, locktime); + setTimeout(() => ($pin = undefined), locktime * 1000); + } $pin = p; } else { fail("Invalid pin"); @@ -63,7 +66,6 @@ <select name="locktime" bind:value={locktime}> <option value={30}>30 {$t("user.settings.seconds")}</option> <option value={5 * 60}>5 {$t("user.settings.minutes")}</option> - <option value={10 * 60}>10 {$t("user.settings.minutes")}</option> <option value={30 * 60}>30 {$t("user.settings.minutes")}</option> <option value={60 * 60}>1 {$t("user.settings.hour")}</option> <option value={8 * 60 * 60}>8 {$t("user.settings.hours")}</option> diff --git a/src/components/Qr.svelte b/src/components/Qr.svelte index 72655ebc..da4ceb00 100644 --- a/src/components/Qr.svelte +++ b/src/components/Qr.svelte @@ -1,18 +1,18 @@ <script> import screenfull from "screenfull"; - let { text } = $props(); + let { icon, text } = $props(); let qr = $state(); + + let src = `/qr/${encodeURIComponent(text)}`; + if (icon) src += `/${icon}`; + else src += "/raw"; </script> <div - class="w-80 mx-auto cursor-pointer" + class="w-72 sm:w-80 mx-auto cursor-pointer" bind:this={qr} onclick={() => screenfull.toggle(qr)} onkeydown={() => screenfull.toggle(qr)} > - <img - src={`/qr/${encodeURIComponent(text)}/raw`} - class="mx-auto" - alt="QR Code" - /> + <img {src} class="mx-auto" alt="QR Code" /> </div> diff --git a/src/components/SetAmount.svelte b/src/components/SetAmount.svelte index a453b9d1..536c8e24 100644 --- a/src/components/SetAmount.svelte +++ b/src/components/SetAmount.svelte @@ -6,13 +6,13 @@ let submit = $state(); let { - currency = $bindable(), - fiat = $bindable(), - locale = $bindable(), - rate = $bindable(), + currency = $bindable("USD"), + fiat = $bindable(true), + locale = $bindable("en-US"), + rate = $bindable(0), settingAmount, setAmount, - newAmount = $bindable(), + newAmount = $bindable(0), toggleAmount, t, } = $props(); diff --git a/src/components/V3Banner.svelte b/src/components/V3Banner.svelte new file mode 100644 index 00000000..312b06fd --- /dev/null +++ b/src/components/V3Banner.svelte @@ -0,0 +1,35 @@ +<script> + import { onMount } from "svelte"; + import { t } from "$lib/translations"; + + const key = "v3BannerDismissed"; + let show = $state(false); + + onMount(() => { + try { + show = !localStorage.getItem(key); + } catch (e) { + show = true; + } + }); + + let dismiss = () => { + show = false; + try { + localStorage.setItem(key, "1"); + } catch (e) {} + }; +</script> + +{#if show} + <div + class="flex items-center justify-center gap-2 bg-accent text-accent-content px-4 py-2 text-sm" + > + <a href="https://v3.coinos.io" class="flex-1 text-center font-semibold"> + {$t("v3Banner")}: v3.coinos.io + </a> + <button onclick={dismiss} class="flex" aria-label={$t("dismiss")}> + <iconify-icon noobserver icon="ph:x-bold" width="20"></iconify-icon> + </button> + </div> +{/if} |
