aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/About.svelte117
-rw-r--r--src/components/AppForm.svelte59
-rw-r--r--src/components/AppHeader.svelte4
-rw-r--r--src/components/Avatar.svelte8
-rw-r--r--src/components/Footer.svelte2
-rw-r--r--src/components/InvoiceActions.svelte113
-rw-r--r--src/components/Lnurlp.svelte22
-rw-r--r--src/components/Menu.svelte16
-rw-r--r--src/components/Nostr.svelte13
-rw-r--r--src/components/Numpad.svelte414
-rw-r--r--src/components/Payments.svelte4
-rw-r--r--src/components/Pin.svelte8
-rw-r--r--src/components/Qr.svelte14
-rw-r--r--src/components/SetAmount.svelte10
-rw-r--r--src/components/V3Banner.svelte35
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}