aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.css80
-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
-rw-r--r--src/hooks.server.ts14
-rw-r--r--src/lib/invoice.ts2
-rw-r--r--src/lib/locales.ts62
-rw-r--r--src/lib/nostr.ts42
-rw-r--r--src/lib/parse.ts50
-rw-r--r--src/lib/random.ts20
-rw-r--r--src/lib/samples.ts14
-rw-r--r--src/lib/server/ip.ts2
-rw-r--r--src/lib/settings.ts2
-rw-r--r--src/lib/socket.ts8
-rw-r--r--src/lib/utils.ts123
-rw-r--r--src/locales/ar.json40
-rw-r--r--src/locales/bn.json40
-rw-r--r--src/locales/de.json40
-rw-r--r--src/locales/el.json40
-rw-r--r--src/locales/en.json1104
-rw-r--r--src/locales/es.json40
-rw-r--r--src/locales/fa.json40
-rw-r--r--src/locales/fr.json40
-rw-r--r--src/locales/hi.json40
-rw-r--r--src/locales/it.json40
-rw-r--r--src/locales/ja.json40
-rw-r--r--src/locales/ko.json40
-rw-r--r--src/locales/nl.json64
-rw-r--r--src/locales/pl.json40
-rw-r--r--src/locales/pt.json40
-rw-r--r--src/locales/ru.json40
-rw-r--r--src/locales/th.json40
-rw-r--r--src/locales/tr.json40
-rw-r--r--src/locales/zh.json40
-rw-r--r--src/routes/(app)/+layout.server.ts19
-rw-r--r--src/routes/(app)/+layout.svelte45
-rw-r--r--src/routes/(app)/[username]/(profile)/+layout.svelte87
-rw-r--r--src/routes/(app)/[username]/(profile)/+page.svelte33
-rw-r--r--src/routes/(app)/[username]/accepted/[text]/+page.server.ts6
-rw-r--r--src/routes/(app)/[username]/accepted/[text]/+page.svelte57
-rw-r--r--src/routes/(app)/[username]/accepted/[text]/raw/+server.ts16
-rw-r--r--src/routes/(app)/[username]/accepted/create/+page.svelte64
-rw-r--r--src/routes/(app)/[username]/receive/+page.svelte25
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte2
-rw-r--r--src/routes/(app)/account/import/+page.svelte2
-rw-r--r--src/routes/(app)/account/pass/+page.svelte2
-rw-r--r--src/routes/(app)/account/seed/+page.svelte2
-rw-r--r--src/routes/(app)/admin/+layout.server.ts10
-rw-r--r--src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts13
-rw-r--r--src/routes/(app)/flash/+page.server.ts58
-rw-r--r--src/routes/(app)/flash/+page.svelte360
-rw-r--r--src/routes/(app)/fund/[id]/+layout.server.ts12
-rw-r--r--src/routes/(app)/fund/[id]/+page.server.ts13
-rw-r--r--src/routes/(app)/fund/[id]/+page.svelte26
-rw-r--r--src/routes/(app)/fund/[id]/access/+page.server.ts3
-rw-r--r--src/routes/(app)/fund/[id]/access/+page.svelte13
-rw-r--r--src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts36
-rw-r--r--src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte27
-rw-r--r--src/routes/(app)/fund/[id]/gift/+page.server.ts6
-rw-r--r--src/routes/(app)/fund/[id]/gift/+page.svelte40
-rw-r--r--src/routes/(app)/fund/[id]/sweep/+page.server.ts13
-rw-r--r--src/routes/(app)/fund/[id]/sweep/+page.svelte41
-rw-r--r--src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts56
-rw-r--r--src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte36
-rw-r--r--src/routes/(app)/fund/[id]/withdraw/+page.svelte8
-rw-r--r--src/routes/(app)/funder/+page.svelte55
-rw-r--r--src/routes/(app)/invoice/[id]/+layout.server.ts56
-rw-r--r--src/routes/(app)/invoice/[id]/+page.svelte43
-rw-r--r--src/routes/(app)/invoice/[id]/paid/+page.svelte16
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.server.ts48
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.svelte24
-rw-r--r--src/routes/(app)/invoices/+page.svelte129
-rw-r--r--src/routes/(app)/ln/[lnurl]/+page.server.ts124
-rw-r--r--src/routes/(app)/ln/[lnurl]/+page.svelte8
-rw-r--r--src/routes/(app)/messages/[recipient]/+page.svelte2
-rw-r--r--src/routes/(app)/migrate/+page.server.ts106
-rw-r--r--src/routes/(app)/migrate/+page.svelte82
-rw-r--r--src/routes/(app)/nsec/+server.ts (renamed from src/routes/(app)/ecash/mint/+server.ts)4
-rw-r--r--src/routes/(app)/pay/[username]/[...amount]/+page.server.ts8
-rw-r--r--src/routes/(app)/pay/[username]/[...amount]/+page.svelte21
-rw-r--r--src/routes/(app)/payment/[id]/+page.svelte23
-rw-r--r--src/routes/(app)/pos/+page.ts6
-rw-r--r--src/routes/(app)/qr/[text]/[icon]/+server.ts33
-rw-r--r--src/routes/(app)/qr/[text]/raw/+server.ts1
-rw-r--r--src/routes/(app)/receive/[...token]/+page.server.ts10
-rw-r--r--src/routes/(app)/reset/[code]/+page.server.ts3
-rw-r--r--src/routes/(app)/reset/[code]/+page.svelte60
-rw-r--r--src/routes/(app)/send/[...text]/+page.server.ts6
-rw-r--r--src/routes/(app)/send/[...text]/+page.svelte75
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/+page.svelte6
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte25
-rw-r--r--src/routes/(app)/send/ecash/+page.server.ts26
-rw-r--r--src/routes/(app)/send/ecash/+page.svelte63
-rw-r--r--src/routes/(app)/send/ecash/[request]/+page.server.ts26
-rw-r--r--src/routes/(app)/send/ecash/[request]/+page.svelte128
-rw-r--r--src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte34
-rw-r--r--src/routes/(app)/send/invoice/[id]/+page.server.ts36
-rw-r--r--src/routes/(app)/send/invoice/[id]/+page.svelte23
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts49
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte104
-rw-r--r--src/routes/(app)/settings/+layout.svelte14
-rw-r--r--src/routes/(app)/settings/account/+page.server.ts6
-rw-r--r--src/routes/(app)/settings/account/+page.svelte40
-rw-r--r--src/routes/(app)/settings/nostr/+page.server.ts8
-rw-r--r--src/routes/(app)/settings/nostr/+page.svelte119
-rw-r--r--src/routes/(app)/settings/profile/+page.svelte20
-rw-r--r--src/routes/(app)/settings/security/+page.svelte12
-rw-r--r--src/routes/(app)/settings/shopify/+page.svelte2
-rw-r--r--src/routes/(app)/settings/square/+page.server.ts18
-rw-r--r--src/routes/(app)/settings/square/+page.svelte0
-rw-r--r--src/routes/(static)/+layout.server.ts9
-rw-r--r--src/routes/(static)/bounties/+page.server.ts44
-rw-r--r--src/routes/(static)/bounties/+page.svelte231
-rw-r--r--src/routes/(static)/docs/+page.server.ts5
-rw-r--r--src/routes/(static)/docs/+page.svelte64
-rw-r--r--src/routes/(static)/forgot/+page.svelte53
-rw-r--r--src/routes/(static)/login/+page.server.ts26
-rw-r--r--src/routes/(static)/login/+page.svelte85
-rw-r--r--src/routes/(static)/register/+page.server.ts40
-rw-r--r--src/routes/(static)/register/+page.svelte61
-rw-r--r--src/routes/(static)/support/+page.svelte31
-rw-r--r--src/routes/+layout.server.ts21
-rw-r--r--src/routes/+layout.svelte67
-rw-r--r--src/routes/+page.server.ts2
-rw-r--r--src/routes/phtest/+page.svelte1
138 files changed, 4652 insertions, 2029 deletions
diff --git a/src/app.css b/src/app.css
index f01c9346..901893b9 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,48 +1,67 @@
+/* 1) Load Tailwind first */
+@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
-@font-face {
- font-family: "PublicSans";
- src: url("/fonts/Public_Sans/static/PublicSans-Regular.ttf")
- format("truetype");
+/* 2) Register DaisyUI themes */
+@plugin "daisyui" {
+ themes: lofi --default, black --prefersdark;
}
-html {
- font-family: PublicSans, sans-serif;
+@plugin "daisyui/theme" {
+ name: "lofi";
+ default: true;
+ --gradient: #C3CFE2;
+ --color-error: #b91c1c;
}
-::selection {
- color: black;
- background: #eaf2ff;
+@plugin "daisyui/theme" {
+ name: "black";
+ prefersdark: true;
+ --gradient: #C3CFE2;
+ --color-secondary: #CCC;
+ --color-error: #f87171;
}
-:fullscreen {
- background-color: white;
-}
+/* 3) Tell Tailwind which layers we’ll @apply from */
+@reference utilities;
+@reference components;
-input:not([type="range"]) {
- @apply input
+/* 4) Now your custom CSS that uses @apply */
+.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;
}
-.input {
- @apply input-bordered input-lg input-primary;
+@font-face {
+ font-family: "PublicSans";
+ src: url("/fonts/Public_Sans/static/PublicSans-Regular.ttf") format("truetype");
}
-textarea {
- @apply textarea textarea-bordered textarea-lg textarea-primary;
-}
+html { font-family: PublicSans, sans-serif; }
-select {
- @apply select select-bordered select-lg select-primary;
-}
+::selection { color: black; background: #eaf2ff; }
+:fullscreen { background-color: white; }
-input:not([type="range"]), textarea, select, .input {
- @apply w-full rounded-2xl;
+/* Note: @apply-ing DaisyUI component classes requires components reference */
+.input { @apply input-lg input-primary h-16 px-4; }
+
+/* If you want to *turn* native inputs into DaisyUI inputs via @apply: */
+input[type="text"],
+input[type="email"],
+input[type="password"],
+input[type="number"],
+input[type="search"],
+input[type="url"],
+input[type="tel"] {
+ @apply input input-lg input-primary h-16 px-4;
}
+textarea { @apply textarea textarea-lg textarea-primary py-4; }
+select { @apply select select-lg select-primary h-16 px-4; }
-textarea:focus, textarea:focus-within, .input:focus, .input:focus-within {
- @apply outline-offset-0;
+input:not([type="range"]), textarea, select, .input {
+ @apply rounded-2xl w-full;
}
input.clean, textarea.clean {
@@ -54,6 +73,11 @@ input.clean, textarea.clean {
box-shadow: none !important;
}
-.btn {
- @apply rounded-2xl btn-lg btn-block outline-none
+button { cursor: pointer !important; }
+.btn { @apply rounded-2xl btn-lg btn-block outline-none h-16 cursor-pointer; }
+
+@layer components {
+ :where(input, textarea, select, .input, .textarea, .select) {
+ outline-offset: 0 !important;
+ }
}
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}
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
new file mode 100644
index 00000000..bd9bbc01
--- /dev/null
+++ b/src/hooks.server.ts
@@ -0,0 +1,14 @@
+import { ipStore } from "$lib/server/ip";
+import { setIpGetter } from "$lib/utils";
+import type { Handle } from "@sveltejs/kit";
+
+setIpGetter(() => ipStore.getStore());
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const ip =
+ event.request.headers.get("cf-connecting-ip") ||
+ event.request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ event.getClientAddress();
+
+ return ipStore.run(ip, () => resolve(event));
+};
diff --git a/src/lib/invoice.ts b/src/lib/invoice.ts
index d7576e82..91a69d88 100644
--- a/src/lib/invoice.ts
+++ b/src/lib/invoice.ts
@@ -15,7 +15,7 @@ export default async ({ cookies, request }) => {
items: JSON.parse(form.get("items")),
memo: form.get("memo"),
memoPrompt: form.get("memoPrompt"),
- tip: parseInt(form.get("tip")) || 0,
+ tip: form.get("tip") ?? null,
type: form.get("type"),
prompt: form.get("prompt") === "true",
rate: parseFloat(form.get("rate")) || rates[form.get("currency")],
diff --git a/src/lib/locales.ts b/src/lib/locales.ts
index b87ecc7a..68a28abd 100644
--- a/src/lib/locales.ts
+++ b/src/lib/locales.ts
@@ -1,23 +1,41 @@
-import {
- de,
- el,
- enUS as en,
- es,
- faIR as fa,
- fr,
- ja,
- pt,
- ru,
- zhCN as zh,
- arSA as ar,
- hiIN as hi,
- bnBD as bn,
- tr,
- ko,
- it,
- nl,
- th,
- pl
-} from "date-fns/locale/index.js";
+import de from "date-fns/locale/de";
+import el from "date-fns/locale/el";
+import en from "date-fns/locale/en-US";
+import es from "date-fns/locale/es";
+import fa from "date-fns/locale/fa-IR";
+import fr from "date-fns/locale/fr";
+import ja from "date-fns/locale/ja";
+import pt from "date-fns/locale/pt";
+import ru from "date-fns/locale/ru";
+import zh from "date-fns/locale/zh-CN";
+import ar from "date-fns/locale/ar-SA";
+import hi from "date-fns/locale/hi";
+import bn from "date-fns/locale/bn";
+import tr from "date-fns/locale/tr";
+import ko from "date-fns/locale/ko";
+import it from "date-fns/locale/it";
+import nl from "date-fns/locale/nl";
+import th from "date-fns/locale/th";
+import pl from "date-fns/locale/pl";
-export default { de, el, en, es, fa, fr, ja, pt, ru, zh, ar, hi, bn, tr, ko, it, nl, th, pl };
+export default {
+ de,
+ el,
+ en,
+ es,
+ fa,
+ fr,
+ ja,
+ pt,
+ ru,
+ zh,
+ ar,
+ hi,
+ bn,
+ tr,
+ ko,
+ it,
+ nl,
+ th,
+ pl
+};
diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts
index bfaa14c1..2e993f9d 100644
--- a/src/lib/nostr.ts
+++ b/src/lib/nostr.ts
@@ -8,7 +8,7 @@ import {
signer as $signer,
} from "$lib/store";
import { post, wait } from "$lib/utils";
-import { bytesToHex } from "@noble/hashes/utils";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import {
type EventTemplate,
finalizeEvent,
@@ -20,6 +20,7 @@ import {
import { decrypt as nip44decrypt, getConversationKey } from "nostr-tools/nip44";
import { Relay } from "nostr-tools/relay";
import { get } from "svelte/store";
+import cookies from "js-cookie";
import {
decrypt as nip49decrypt,
@@ -72,12 +73,22 @@ export const getPrivateKey = async (user: User): Promise<Uint8Array> => {
}
}
- const { nsec } = user;
+ // /me no longer returns the encrypted key; /login decrypts it into the
+ // sk cookie instead. user.nsec is only present on a fresh login response.
+ // Sessions without either (nostr or older logins) fetch it with the
+ // password.
+ const sk = browser && cookies.get("sk");
+ let { nsec } = user;
- if (nsec) {
+ if (!sk && !nsec && user.hasnsec)
+ ({ nsec } = await post("/nsec", { password: await getPassword() }));
+
+ if (sk) {
+ k = hexToBytes(sk);
+ } else if (nsec) {
k = nip49decrypt(nsec, await getPassword());
} else {
- throw new Error("nsec not available");
+ throw new Error("Log in again with your password to unlock your nostr key");
}
localStorage.setItem("nsec", nip19.nsecEncode(k));
@@ -105,7 +116,7 @@ export const encryptNsec = async (nsec: string) => {
};
export const sign = async (event, user) => {
- if (user?.nsec && !get($signer)?.ready) {
+ if ((user?.hasnsec || user?.nsec) && !get($signer)?.ready) {
const sk = await getPrivateKey(user);
$signer.set({
@@ -117,8 +128,14 @@ export const sign = async (event, user) => {
eventToSign.set(event);
let unsubscribe;
- const { method, params } = await new Promise((r) => {
- unsubscribe = $signer.subscribe((v) => v?.ready && r(v));
+ const { method, params } = await new Promise((r, j) => {
+ unsubscribe = $signer.subscribe((v) => {
+ if (v === "cancel") {
+ j("cancelled");
+ $signer.set(undefined);
+ }
+ v?.ready && r(v);
+ });
});
await unsubscribe();
const signedEvent = await signingMethods[method](event, params);
@@ -148,7 +165,8 @@ const signingMethods = {
params: [JSON.stringify(event)],
};
- const content = await nip04.encrypt(sk, pk, JSON.stringify(signEvent));
+ const skBytes = hexToBytes(sk);
+ const content = await nip04.encrypt(skBytes, pk, JSON.stringify(signEvent));
const signedSignEvent = finalizeEvent(
{
@@ -158,7 +176,7 @@ const signingMethods = {
content,
tags: [["p", pk]],
},
- sk,
+ skBytes,
);
const relay = await Relay.connect(nostrConnectRelay);
@@ -171,9 +189,9 @@ const signingMethods = {
try {
let response;
try {
- response = JSON.parse(await nip04.decrypt(sk, pk, event.content));
+ response = JSON.parse(await nip04.decrypt(hexToBytes(sk), pk, event.content));
} catch (e) {
- const ck = await getConversationKey(sk, pk);
+ const ck = await getConversationKey(hexToBytes(sk), pk);
response = JSON.parse(await nip44decrypt(event.content, ck));
}
@@ -187,7 +205,7 @@ const signingMethods = {
},
async nsec(event, { sk }) {
- return finalizeEvent(event, sk);
+ return finalizeEvent(event, hexToBytes(sk));
},
async extension(event) {
diff --git a/src/lib/parse.ts b/src/lib/parse.ts
index ba264be7..e8d8bafd 100644
--- a/src/lib/parse.ts
+++ b/src/lib/parse.ts
@@ -1,20 +1,36 @@
import { PUBLIC_DOMAIN } from "$env/static/public";
import { decode } from "$lib/bip21";
-import { get, isLiquid, post, sats } from "$lib/utils";
+import { auth, get, isLiquid, post, sats } from "$lib/utils";
import { redirect } from "@sveltejs/kit";
import { validate } from "bitcoin-address-validation";
-export default async (s, host) => {
+export default async (s, host, cookies) => {
if (!s) return;
let t = s.trim();
let amount;
+ let invoice;
let user;
if (t.startsWith("http")) redirect(307, t);
if (t.startsWith(host)) redirect(307, `http://${t}`);
+ if (t.includes("lightning=") || t.includes("lno=")) {
+ const url = new URL(t);
+ const params = new URLSearchParams(url.search);
+ t = params.get("lightning") || params.get("lno");
+ }
+
if (t.startsWith("lightning:")) t = t.replace("lightning:", "");
- if (t.endsWith(`@${PUBLIC_DOMAIN}`)) t = t.split("@")[0];
+ // Our own domain is no longer only ours: coinos v3 payment addresses live
+ // here too and are paid over lightning like anyone else's. Only strip the
+ // domain (and pay internally) when the name really is a local account.
+ if (t.includes("@") && t.split("@").pop() === PUBLIC_DOMAIN) {
+ const name = t.split("@")[0];
+ try {
+ const u = await get(`/users/${name}`);
+ if (u && !u.anon) t = name;
+ } catch (e) {}
+ }
if (t.includes("@") && t.includes(".")) {
try {
t = await get(`/encode?address=${t}`);
@@ -51,16 +67,12 @@ export default async (s, host) => {
if (t.toLowerCase().startsWith("lnurl")) redirect(307, `/ln/${t}`);
if (t.includes(":")) t = t.split(":")[1];
- if (t.includes("lightning=")) {
- const url = new URL(t);
- const params = new URLSearchParams(url.search);
- t = params.get("lightning");
- }
-
- if (t.toLowerCase().startsWith("ln")) {
+ if (t.toLowerCase().startsWith("lnr")) {
+ const { iid } = await post("/sendinvoice", { invreq: t }, auth(cookies));
+ invoice = { id: iid };
+ } else if (t.toLowerCase().startsWith("ln")) {
try {
- const inv = await get(`/invoice/${t}`);
- if (inv.user.username === "mint") throw new Error("mint payment");
+ invoice = await get(`/invoice/${t}`);
} catch (e) {
if (t.toLowerCase().startsWith("lno")) {
const { offer_amount_msat: a } = await get(`/decode/${t}`);
@@ -70,6 +82,8 @@ export default async (s, host) => {
}
}
+ if (invoice) redirect(307, `/invoice/${invoice.id}`);
+
if (t.match(/^🥜([\uFE00-\uFE0F]|[\uE0100-\uE01EF]+)$/)) {
t = Array.from(t)
.slice(1)
@@ -91,10 +105,8 @@ export default async (s, host) => {
.join("");
}
- if (t.startsWith("cashu")) {
- const { id } = await post("/cash", { token: t });
- redirect(307, `/ecash/${id}`);
- }
+ // The token rides in the URL; the ecash page asks the server for its status.
+ if (t.startsWith("cashu")) redirect(307, `/ecash/${encodeURIComponent(t)}`);
if (t.startsWith("creq")) redirect(307, `/send/ecash/${t}`);
@@ -115,11 +127,9 @@ export default async (s, host) => {
if (fund) redirect(307, `/send/fund/${t}`);
- // invoice
- let invoice;
try {
- invoice = await get(`/invoice/${t}`);
+ invoice ||= await get(`/invoice/${t}`);
} catch (e) {}
- if (invoice) redirect(307, `/send/invoice/${invoice.id}`);
+ if (invoice) redirect(307, `/invoice/${invoice.id}`);
};
diff --git a/src/lib/random.ts b/src/lib/random.ts
new file mode 100644
index 00000000..36dde0cc
--- /dev/null
+++ b/src/lib/random.ts
@@ -0,0 +1,20 @@
+import {
+ NumberDictionary,
+ animals,
+ colors,
+ uniqueNamesGenerator,
+} from "unique-names-generator";
+
+export const randomName = () =>
+ uniqueNamesGenerator({
+ dictionaries: [animals, NumberDictionary.generate({ min: 10, max: 99 })],
+ length: 2,
+ separator: "",
+ });
+
+export const randomPassword = () =>
+ uniqueNamesGenerator({
+ dictionaries: [colors, NumberDictionary.generate({ min: 100, max: 999 })],
+ length: 2,
+ separator: "",
+ });
diff --git a/src/lib/samples.ts b/src/lib/samples.ts
index ae7ef6d5..cc6b4039 100644
--- a/src/lib/samples.ts
+++ b/src/lib/samples.ts
@@ -29,6 +29,20 @@ export default {
-d '{
"payreq": "lnbc1pj94d8fsp5n77k340ps4m9jn7kp8he8yynpddvurv6mcsrrqpnq5l2jxdxzlwqpp5m96pqhc5nrlk8cqsu9ufdxxa43sarp8vwf9egvm2pg9nl0zu9r8qdq2vdhkjmn0wvxqztgcqpjrzjqwhmav82kntsppmkp8jp4vg4h9nns78tsy8mg7ve4lq5txrkp0h56zlarvqqdtcqqsqqqqqqqqqqp6cq9q9qyysgqzxlypywzphyujm3ga5j5csfcmqvlnae0fgnvymkaaw94eeg7py5rqzysyjkr3ev2snq63qpsc69vf54adkd0szvmvwt5cuadjnuy95sq4xfa40"
}'`,
+ quote: `curl "${api}/lightning/quote"
+ -H "content-type: application/json"
+ -H "Authorization: Bearer $token"
+ -d '{
+ "payreq": "lnbc1pj94d8fsp5n77k340ps4m9jn7kp8he8yynpddvurv6mcsrrqpnq5l2jxdxzlwqpp5m96pqhc5nrlk8cqsu9ufdxxa43sarp8vwf9egvm2pg9nl0zu9r8qdq2vdhkjmn0wvxqztgcqpjrzjqwhmav82kntsppmkp8jp4vg4h9nns78tsy8mg7ve4lq5txrkp0h56zlarvqqdtcqqsqqqqqqqqqqp6cq9q9qyysgqzxlypywzphyujm3ga5j5csfcmqvlnae0fgnvymkaaw94eeg7py5rqzysyjkr3ev2snq63qpsc69vf54adkd0szvmvwt5cuadjnuy95sq4xfa40",
+ "max": true
+}'`,
+ quoteResponse: `{
+ "amount": 99899,
+ "fee": 1,
+ "ourfee": 100,
+ "total": 100000,
+ "balance": 100000
+}`,
bitcoin: `curl "${api}/bitcoin/send"
-H "content-type: application/json"
-H "Authorization: Bearer $token"
diff --git a/src/lib/server/ip.ts b/src/lib/server/ip.ts
new file mode 100644
index 00000000..cfd933ac
--- /dev/null
+++ b/src/lib/server/ip.ts
@@ -0,0 +1,2 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+export const ipStore = new AsyncLocalStorage<string>();
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 53a25e18..6f4d3f62 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -8,7 +8,7 @@ type Error = {
export default async ({ cookies, request }) => {
const form = await fd(request);
- if (form.tab === "preferences") {
+ if (form.tab === "account") {
form.notify = form.notify === "on";
form.push = form.push === "on";
form.nip5 = form.nip5 === "on";
diff --git a/src/lib/socket.ts b/src/lib/socket.ts
index 2bb0d4f2..b28b1060 100644
--- a/src/lib/socket.ts
+++ b/src/lib/socket.ts
@@ -57,8 +57,12 @@ export const messages = (data) => ({
pathname.includes("/receive") ||
pathname.includes("/invoice")
) {
- await wait(() => !get(navigating));
- await goto(`/invoice/${iid}`);
+ // `paid=<amount>` marks this as a payment that arrived just now, and
+ // carries the amount of THIS payment. A reusable bolt12 offer needs
+ // both: its `received` is a lifetime total, so the invoice layout
+ // can't tell a live payment from an old one, and the success screen
+ // would otherwise show the accumulated figure.
+ if (!get(navigating)) goto(`/invoice/${iid}?paid=${amount}`);
} else success(`${confirmed ? "Received" : "Detected"} ⚡️${s(amount)}!`);
}
},
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 2e1bb840..b6bbdb53 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -2,7 +2,8 @@ import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { PUBLIC_COINOS_NETWORK, PUBLIC_COINOS_URL } from "$env/static/public";
-import { bytesToHex } from "@noble/hashes/utils";
+import { bytesToHex } from "@noble/hashes/utils.js";
+import { fail as svelteFail, redirect } from "@sveltejs/kit";
import { toast } from "@zerodevx/svelte-toast";
import {
differenceInDays,
@@ -20,11 +21,22 @@ export function scroll(section: any) {
const base = browser ? "" : PUBLIC_COINOS_URL;
+let getIp: (() => string | undefined) | undefined;
+
+export const setIpGetter = (fn: () => string | undefined) => {
+ getIp = fn;
+};
+
+const ipHeaders = (): Record<string, string> => {
+ const ip = getIp?.();
+ return ip ? { "cf-connecting-ip": ip } : {};
+};
+
export const punk = (k: string) =>
`${Math.floor((parseInt(k.slice(-2), 16) / 256) * 64) + 1}.webp`;
export const g = (url: string, fetch: any, headers: object) =>
- fetch(base + url, { headers })
+ fetch(base + url, { headers: { ...ipHeaders(), ...headers } })
.then((r: Response) => r.text())
.then((body: string) => {
try {
@@ -35,7 +47,7 @@ export const g = (url: string, fetch: any, headers: object) =>
});
export const get = (url: string, headers = {}) => {
- return fetch(base + url, { headers })
+ return fetch(base + url, { headers: { ...ipHeaders(), ...headers } })
.then(async (r) => {
if (r.ok) return r.text();
throw new Error(await r.text());
@@ -57,6 +69,7 @@ export const post = async (
headers = {
"content-type": "application/json",
accept: "application/json",
+ ...ipHeaders(),
...headers,
};
@@ -147,29 +160,54 @@ export const info = (m: string) => {
};
export const login = async (
- user: { username: string; password: string },
+ user: {
+ username: string;
+ password: string;
+ token?: string;
+ challenge?: string;
+ recaptcha?: string;
+ },
cookies,
ip: string,
+ host?: string,
+ extraHeaders?: Record<string, string>,
) => {
- const maxAge = 380 * 24 * 60 * 60;
+ const headers: Record<string, string> = {
+ "content-type": "application/json",
+ accept: "application/json",
+ "cf-connecting-ip": ip,
+ ...extraHeaders,
+ };
+ if (host) headers["x-forwarded-host"] = host;
const res = await fetch(`${base}/login`, {
method: "POST",
body: JSON.stringify(user),
- headers: {
- "content-type": "application/json",
- accept: "application/json",
- "cf-connecting-ip": ip,
- },
+ headers,
});
+
+ const text = await res.text();
+
if (res.status === 401) {
- const text = await res.text();
if (text.startsWith("2fa")) throw new Error("2fa");
+ throw new Error(text);
}
- const { user: u, token } = await res.json();
+ const { user: u, token } = JSON.parse(text);
if (!token) throw new Error("Login failed");
+ setSession(cookies, user, u, token);
+};
+
+// Session cookies for a freshly issued JWT — shared by login and register,
+// which both get a token back from the server.
+const setSession = (
+ cookies,
+ user: { username: string; password: string },
+ u: { language?: string; nsec?: string },
+ token: string,
+) => {
+ const maxAge = 380 * 24 * 60 * 60;
const expires = new Date();
expires.setSeconds(expires.getSeconds() + maxAge);
@@ -192,6 +230,20 @@ export const auth = (cookies) => ({
authorization: `Bearer ${cookies.get("token")}`,
});
+export const isInvalidTokenError = (e: unknown) => {
+ const message = (e as Error)?.message || "";
+ if (!message) return false;
+ const normalized = message.toLowerCase();
+ return (
+ normalized.includes("jwt") ||
+ normalized.includes("invalid token") ||
+ normalized.includes("token expired") ||
+ normalized.includes("expired token") ||
+ normalized.includes("unauthorized") ||
+ normalized.includes("not authorized")
+ );
+};
+
export const btc = (fiat: number, rate: number): number =>
Math.round((fiat * sats) / rate);
@@ -456,3 +508,50 @@ export const ago = (t) => {
const days = differenceInDays(now, date);
return `${days}d`;
};
+
+export const register = async (user, ip, cookies, loginRedirect, host?, extraHeaders?: Record<string, string>) => {
+ let error;
+
+ const headers: Record<string, string> = {
+ ...extraHeaders,
+ };
+ if (ip) headers["cf-connecting-ip"] = ip;
+
+ let sk, token, u;
+ try {
+ ({ sk, token, ...u } = await post("/register", { user }, headers));
+ } catch (e) {
+ ({ message: error } = e as Error);
+ }
+
+ // /register verifies the recaptcha token itself now, and Google tokens
+ // are single-use: a follow-up /login carrying the same token comes back
+ // "failed captcha" for an account that was just created. The register
+ // response already includes the JWT, so start the session from it and
+ // only fall back to /login when registration failed (e.g. the username
+ // already exists and this is really a login).
+ if (token) {
+ setSession(cookies, user, u, token);
+ error = null;
+ } else {
+ try {
+ await login(user, cookies, ip, host, extraHeaders);
+ 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 svelteFail(400, { error });
+ redirect(303, loginRedirect || `/${user.username}`);
+};
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 6129f1c8..234e076f 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -369,7 +369,24 @@
"nip5a": "الاستخدام",
"nip5b": "\"عنوان \"نوستر",
"name": "الاسم",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "النص الأصلي",
+ "tipDesc": "النسبة المئوية للمستخدمين الموثوقين الذين يدفعون بالسيارة",
+ "connectSquare": "Connect to Square",
+ "revokeSquare": "Revoke Squanection",
+ "confirmAutoWithdrawal": "التأكد من عمليات سحب السيارات",
+ "confirmAutoWithdrawalDesc": "سحبك للسيارات تم تعطيله يرجى التحقق من عنوان الوجهة الخاصة بك قبل إعادة فتحها أو إزالة العنوان لرفض هذه الرسالة.",
+ "newPasswordDesc": "(يَتْركُ فارغاً للإبْقاء بدون تغيير)",
+ "yourUsername": "إسمك المستخدم",
+ "yourPassword": "وكلمة السر الخاصة بك",
+ "welcome": "مرحباً!",
+ "clickBelow": "انقروا من هنا لينهيوا حسابكم",
+ "bolt12Offer": "عرض البرق (BOLT12)",
+ "bolt12OfferDescription": "A reusable payment code for receiving lightning payments and BOLT12 zaps. أضفها إلى قائمة عرض زبونك لـ (بولت 12).",
+ "continueSettingUp": "إنشاء حساب",
+ "publishOffer": "النشر إلى نوستر",
+ "offerPublished": "نُشرت!",
+ "offerPublishFailed": "عدم نشر العرض"
},
"support": {
"header": "الدعم",
@@ -381,12 +398,16 @@
"doOurBest": "وسنبذل قصارى جهدنا للعودة إليك في الوقت المناسب.",
"thankYou": "شكراً لك!",
"beInTouch": "شخص ما سيكون على اتصال قريبا."
- }
+ },
+ "lnurl": "رمز المدفوعات المستحقة"
},
"payments": {
"ok": "حسناً",
"cancel": "إلغاء",
"failedToRoute": "ولا يمكن الوصول إلى المتلقي إذا لم يتم الدفع على الطريق. حاول إرسال كمية أصغر أو زيادة احتياطي رسوم الحد الأقصى.",
+ "alreadyPaid": "تم دفع هذه الفاتورة بالفعل. تحقق من سجل مدفوعاتك.",
+ "alreadyUnderway": "هذه الدفعة قيد التنفيذ بالفعل. تحقق من سجل مدفوعاتك قبل المحاولة مرة أخرى.",
+ "stillSettling": "لا تزال محاولة سابقة لدفع هذه الفاتورة قيد التسوية. يرجى المحاولة مرة أخرى بعد دقيقة.",
"message": "الرسالة",
"enterWalletPass": "كلمة السر",
"legacyVersion": "النسخة الأصلية",
@@ -493,7 +514,16 @@
"pending": "معلقة",
"minted": "Minted",
"insufficientFunds": "عدم كفاية الأموال",
- "lockedBalance": "الرصيد الجاهز"
+ "lockedBalance": "الرصيد الجاهز",
+ "sweep": "وصلة Sweep",
+ "trustUser": "إرسال وإتاحة دفع رسوم السيارات",
+ "custom": "العرف",
+ "gift": "جميع الأموال",
+ "giftLink": "وصلة الهدايا",
+ "giftDesc1": "من يفتح الرابط أدناه سيتلقى جميع الأموال المتاحة.",
+ "giftDesc2": "وإذا تم تسجيلها، فإن الأموال ستدخل إلى حسابها، وإلا سينشأ حساب جديد.",
+ "routingFee": "رسوم المغادرة",
+ "noQuote": "لا يمكن أن ننظر حتى رسوم تحديد المسار قبل الوقت. أما رسوم تحديد المسارات القصوى الواردة أدناه فهي احتياطية؛ وأي جزء غير مستخدم يقيد لحسابه."
},
"items": {
"add": "البند",
@@ -515,5 +545,7 @@
"withdrawAccess": "الدخول السريع",
"username": "المستعمل",
"returnToFund": "العودة إلى التمويل"
- }
+ },
+ "dismiss": "الانصراف",
+ "v3Banner": "جرب النسخة الجديدة"
}
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 5a33c5dd..f6d4d87e 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -369,7 +369,24 @@
"nip5a": "ব্যবহার",
"nip5b": "আপনার পাবলিক ঠিকানা যাচাইকারী হিসেবে",
"name": "নাম",
- "NOSTR": "পার্সেল"
+ "NOSTR": "পার্সেল",
+ "tip": "ডিফল্ট",
+ "tipDesc": "স্বয়ংক্রিয়ভাবে সম্পূর্ণ করার সময় বিশ্বস্ত ব্যক্তিদের তালিকা প্রদর্শন করা হবে",
+ "connectSquare": "বর্গক্ষেত্রে সংযোগ করুন",
+ "revokeSquare": "সংযোগ প্রত্যাখ্যান করুন",
+ "confirmAutoWithdrawal": "পূর্ববর্তী বৈশিষ্ট্য নিশ্চিত করুন",
+ "confirmAutoWithdrawalDesc": "আপনার ব্যাক-আপ প্রত্যাহার নিষ্ক্রিয় করা হয়েছে । অনুগ্রহ করে এই বার্তাটি পুনরায় সক্রিয় করুন অথবা সেটিকে সঠিক ঠিকানা রূপে ধার্য করুন।.",
+ "newPasswordDesc": "(অঅপরিবর্তিত রাখা যাবে না)",
+ "yourUsername": "আপনার ব্যবহারকারী নাম",
+ "yourPassword": "আপনার পাসওয়ার্ড হল %s",
+ "welcome": "স্বাগতম!",
+ "clickBelow": "আপনার অ্যাকাউন্ট সম্পন্ন করার জন্য নীচের ক্লিক করুন",
+ "bolt12Offer": "লাইটিং প্রস্তাব (BO1212)",
+ "bolt12OfferDescription": "বিদ্যুৎ সরবরাহের জন্য একটি রিসপার কোড এবং BLT12 জিপ। BLTF12 এর জন্য আপনার Nostrer ক্লায়েন্টের তালিকা যোগ করুন।.",
+ "continueSettingUp": "অ্যাকাউন্ট সেটআপ",
+ "publishOffer": "চিহ্নিত স্থানে প্রকাশ করুন",
+ "offerPublished": "প্রকাশিত!",
+ "offerPublishFailed": "প্রকাশ করতে ব্যর্থ"
},
"support": {
"header": "সহায়তা",
@@ -381,12 +398,16 @@
"doOurBest": "এবং সময়মতো আপনার কাছে ফিরে পেতে আমরা আমাদের যথাসাধ্য চেষ্টা করব।.",
"thankYou": "ধন্যবাদ!",
"beInTouch": "কেউ শীঘ্রই যোগাযোগ করবে।."
- }
+ },
+ "lnurl": "পুনরায় চার্জের যোগ্য কোড"
},
"payments": {
"ok": "ঠিক আছে",
"cancel": "বাতিল",
"failedToRoute": "টাকা প্রদান করতে ব্যর্থ, সম্ভবত নিম্নলিখিত ঠিকানায় প্রেরিত হবে। সামান্য বড় অঙ্কের টাকা দিয়ে অথবা তার মূল্য বাড়ানোর চেষ্টা করুন।.",
+ "alreadyPaid": "এই ইনভয়েসটি ইতিমধ্যে পরিশোধ করা হয়েছে। আপনার পেমেন্টের ইতিহাস দেখুন।",
+ "alreadyUnderway": "এই পেমেন্টটি ইতিমধ্যে প্রক্রিয়াধীন। আবার চেষ্টা করার আগে আপনার পেমেন্টের ইতিহাস দেখুন।",
+ "stillSettling": "এই ইনভয়েস পরিশোধের আগের একটি চেষ্টা এখনও সম্পন্ন হচ্ছে। অনুগ্রহ করে এক মিনিট পরে আবার চেষ্টা করুন।",
"message": "বার্তা",
"enterWalletPass": "ওয়ালেট পাসওয়ার্ড",
"legacyVersion": "লিগ্যাসি সংস্করণ",
@@ -493,7 +514,16 @@
"pending": "অসমাপ্ত",
"minted": "মিনট",
"insufficientFunds": "অপর্যাপ্ত অর্থ",
- "lockedBalance": "লক করা হয়েছে"
+ "lockedBalance": "লক করা হয়েছে",
+ "sweep": "সুইপ লিংক",
+ "trustUser": "স্বয়ংক্রিয়ভাবে ফেরত পাঠান ও সক্রিয় করুন",
+ "custom": "স্বনির্ধারিত",
+ "gift": "উপহার দেওয়া",
+ "giftLink": "উপহার লিংক",
+ "giftDesc1": "যে কেউ এই লিঙ্কগুলো খোলে সে সব অর্থ পাবে।.",
+ "giftDesc2": "যদি তারা লগ-ইন করে, তাহলে এই অর্থ তাদের অ্যাকাউন্টে চলে যাবে, অন্যথায় নতুন অ্যাকাউন্ট তৈরি করা হবে।.",
+ "routingFee": "প্রসারিতstretch",
+ "noQuote": "সময়ের আগে রুট ফি খুঁজে পাওয়া যায়নি. নিচের সর্বোচ্চ ফি হল একটি সংরক্ষণ; যে কোন অব্যবহৃত অংশ ফেরত দেওয়া হয়।."
},
"items": {
"add": "বস্তু যোগ করুন",
@@ -515,5 +545,7 @@
"withdrawAccess": "ব্যবহারের অধিকার",
"username": "ব্যবহারকারীর নাম",
"returnToFund": "অর্থ পাঠানো"
- }
+ },
+ "dismiss": "বাতিল",
+ "v3Banner": "নতুন সংস্করণ প্রচেষ্টা করুন"
}
diff --git a/src/locales/de.json b/src/locales/de.json
index 8c268158..3dfa1300 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -319,7 +319,24 @@
"nwc": "Nostr Brieftasche verbinden",
"nwcDescription": "Aktivieren Sie Ihre nostr App, um Zahlungen von Ihrem Konto zu senden",
"name": "Bezeichnung",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "Standardtipp",
+ "tipDesc": "Standard-Tip-Prozentsatz bei automatischer Bezahlung vertrauenswürdiger Benutzer",
+ "connectSquare": "Verbindung zum Quadrat",
+ "revokeSquare": "Wiederherstellung Quadratische Verbindung",
+ "confirmAutoWithdrawal": "Auto-Entzugseinstellungen bestätigen",
+ "confirmAutoWithdrawalDesc": "Ihre Autoentnahmen wurden deaktiviert. Bitte überprüfen Sie Ihre Zieladresse, bevor Sie sie erneut aktivieren oder entfernen Sie die Adresse, um diese Nachricht zu entlassen.",
+ "newPasswordDesc": "(Leave blank to keep unverändert)",
+ "yourUsername": "Ihr Benutzername ist",
+ "yourPassword": "und ihr passwort ist",
+ "welcome": "Willkommen!",
+ "clickBelow": "Klicken Sie unten, um die Einrichtung Ihres Kontos abzuschließen",
+ "bolt12Offer": "Blitzangebot (BOLT12)",
+ "bolt12OfferDescription": "Ein wiederverwendbarer Zahlungscode für den Empfang von Blitzzahlungen und BOLT12 Zaps. Fügen Sie es zu der Liste Ihres nostr-Clients hinzu, um über BOLT12 gezappt zu werden.",
+ "continueSettingUp": "Konto einrichten",
+ "publishOffer": "Veröffentlichen an nostr",
+ "offerPublished": "Veröffentlicht!",
+ "offerPublishFailed": "Das Angebot konnte nicht veröffentlicht werden"
},
"support": {
"header": "Support",
@@ -350,7 +367,8 @@
"moreDetails": "Links und Details",
"url": "Öffentliche URL",
"nwc": "Nostr-Wallet verbinden",
- "loadMore": "Mehr laden"
+ "loadMore": "Mehr laden",
+ "lnurl": "Wiederverwendbarer Zahlungscode"
},
"payments": {
"header": "Transaktionen",
@@ -448,6 +466,9 @@
"amountPrompt": "Immer nach dem Betrag fragen",
"enterWalletPass": "Wallet-Passwort",
"failedToRoute": "Die Weiterleitung der Zahlung ist fehlgeschlagen. Der Empfänger ist möglicherweise nicht erreichbar. Versuchen Sie, einen kleineren Betrag zu senden oder die maximale Gebührenreserve zu erhöhen.",
+ "alreadyPaid": "Diese Rechnung wurde bereits bezahlt. Prüfe deinen Zahlungsverlauf.",
+ "alreadyUnderway": "Diese Zahlung wird bereits ausgeführt. Prüfe deinen Zahlungsverlauf, bevor du es erneut versuchst.",
+ "stillSettling": "Ein vorheriger Zahlungsversuch für diese Rechnung wird noch abgeschlossen. Bitte versuche es in einer Minute erneut.",
"ok": "Ok",
"cancel": "Abbrechen",
"message": "Nachricht",
@@ -467,7 +488,16 @@
"pending": "Andauernd",
"minted": "Minted",
"insufficientFunds": "Unzureichende Mittel",
- "lockedBalance": "Gesperrter Saldo"
+ "lockedBalance": "Gesperrter Saldo",
+ "sweep": "Sweep Link",
+ "trustUser": "Senden und aktivieren Auto-Pay",
+ "custom": "Benutzerdefiniert",
+ "gift": "Alle Fonds einschenken",
+ "giftLink": "Giftlink",
+ "giftDesc1": "Wer den Link unten öffnet, erhält alle verfügbaren Mittel.",
+ "giftDesc2": "Wenn sie eingeloggt sind, gehen die Fonds auf ihr Konto, ansonsten wird ein neues Konto generiert.",
+ "routingFee": "Routinegebühr",
+ "noQuote": "Ich konnte die Routing-Gebühren nicht rechtzeitig nachschlagen. Die maximale Routing-Gebühr ist eine Reserve; jeder ungenutzte Teil wird zurück gutgeschrieben."
},
"items": {
"add": "Artikel hinzufügen",
@@ -544,5 +574,7 @@
"withdrawAccess": "Zugriff aufheben",
"username": "Benutzername",
"returnToFund": "Zurück zum Fonds"
- }
+ },
+ "dismiss": "Abtreten",
+ "v3Banner": "Versuchen Sie die neue Version"
}
diff --git a/src/locales/el.json b/src/locales/el.json
index 2d25684a..c6ab9878 100644
--- a/src/locales/el.json
+++ b/src/locales/el.json
@@ -320,7 +320,24 @@
"nwc": "Σύνδεση πορτοφολιού Nostr",
"nwcDescription": "Ενεργοποιήστε την εφαρμογή nostr για να στείλετε πληρωμές από το λογαριασμό σας",
"name": "Όνομα",
- "NOSTR": "ΝΟΣΤTR"
+ "NOSTR": "ΝΟΣΤTR",
+ "tip": "Προκαθορισμένη άκρη",
+ "tipDesc": "Προκαθορισμένο ποσοστό πληροφοριών κατά την αυτόματη πληρωμή εμπίστων χρηστών",
+ "connectSquare": "Σύνδεση στην πλατεία",
+ "revokeSquare": "Recoke τετραγωνική σύνδεση",
+ "confirmAutoWithdrawal": "Επιβεβαίωση ρυθμίσεων αυτόματης απόσυρσης",
+ "confirmAutoWithdrawalDesc": "Οι αυτόματες αναλήψεις σας έχουν απενεργοποιηθεί. Παρακαλώ ελέγξτε εκ νέου τη διεύθυνση προορισμού σας πριν τις ξαναεγγράψετε ή αφαιρέσετε τη διεύθυνση για να απορρίψετε αυτό το μήνυμα.",
+ "newPasswordDesc": "(Άφησε το κενό για να παραμείνει αμετάβλητο)",
+ "yourUsername": "Το όνομα χρήστη είναι",
+ "yourPassword": "και ο κωδικός σας είναι",
+ "welcome": "Καλώς ήρθατε!",
+ "clickBelow": "Κάντε κλικ παρακάτω για να ολοκληρώσετε τη ρύθμιση του λογαριασμού σας",
+ "bolt12Offer": "Προσφορά Lightning (BOLT12)",
+ "bolt12OfferDescription": "Ένας επαναχρησιμοποιήσιμος κωδικός πληρωμής για τη λήψη αστραπιαίων πληρωμών και BOLT12 zaps. Προσθέστε το στη λίστα προσφοράς του πελάτη σας για να πάρει zapped πάνω από BOLT12.",
+ "continueSettingUp": "Setup λογαριασμού",
+ "publishOffer": "Δημοσίευση στη μύτη",
+ "offerPublished": "Εκδίδεται!",
+ "offerPublishFailed": "Αποτυχία δημοσίευσης της προσφοράς"
},
"support": {
"header": "Υποστήριξη",
@@ -344,7 +361,8 @@
"moreDetails": "Σύνδεσμοι και λεπτομέρειες",
"url": "Δημόσια διεύθυνση URL",
"nwc": "Σύνδεση πορτοφολιού Nostr",
- "loadMore": "Φόρτωση περισσότερων"
+ "loadMore": "Φόρτωση περισσότερων",
+ "lnurl": "Επαναχρησιμοποιήσιμος κωδικός πληρωμής"
},
"payments": {
"amount": "Ποσό",
@@ -439,6 +457,9 @@
"amountPrompt": "Πάντα προτροπή για ποσό",
"enterWalletPass": "Κωδικός πρόσβασης πορτοφολιού",
"failedToRoute": "Η δρομολόγηση της πληρωμής απέτυχε, ο παραλήπτης ενδέχεται να μην είναι προσβάσιμος. Δοκιμάστε να στείλετε ένα μικρότερο ποσό ή να αυξήσετε το μέγιστο απόθεμα χρέωσης.",
+ "alreadyPaid": "Αυτό το τιμολόγιο έχει ήδη πληρωθεί. Ελέγξτε το ιστορικό πληρωμών σας.",
+ "alreadyUnderway": "Αυτή η πληρωμή βρίσκεται ήδη σε εξέλιξη. Ελέγξτε το ιστορικό πληρωμών σας πριν δοκιμάσετε ξανά.",
+ "stillSettling": "Μια προηγούμενη προσπάθεια πληρωμής αυτού του τιμολογίου ολοκληρώνεται ακόμα. Δοκιμάστε ξανά σε ένα λεπτό.",
"ok": "Εντάξει",
"cancel": "Ακύρωση",
"message": "Μήνυμα",
@@ -458,7 +479,16 @@
"pending": "Σε αναμονή",
"minted": "Λεπτό",
"insufficientFunds": "Ανεπαρκή κεφάλαια",
- "lockedBalance": "Κλειδωμένο υπόλοιπο"
+ "lockedBalance": "Κλειδωμένο υπόλοιπο",
+ "sweep": "Συνδέσεις Sweep",
+ "trustUser": "Αποστολή και ενεργοποίηση αυτόματης πληρωμής",
+ "custom": "Προσαρμοσμένο",
+ "gift": "Δώρο όλα τα κεφάλαια",
+ "giftLink": "Δεσμός δώρων",
+ "giftDesc1": "Όποιος ανοίξει τον παρακάτω σύνδεσμο θα λάβει όλα τα διαθέσιμα κεφάλαια.",
+ "giftDesc2": "Αν είναι συνδεδεμένοι, τα κεφάλαια θα πάνε στο λογαριασμό τους, διαφορετικά θα δημιουργηθεί ένας νέος λογαριασμός.",
+ "routingFee": "Τέλος διαδρομής",
+ "noQuote": "Δεν μπορούσε να ψάξει το τέλος δρομολόγησης νωρίτερα. Το μέγιστο τέλος δρομολόγησης που ακολουθεί είναι αποθεματικό· κάθε αχρησιμοποίητο τμήμα πιστώνεται πίσω."
},
"items": {
"add": "Πρόσθεσε είδος",
@@ -535,5 +565,7 @@
"withdrawAccess": "Ανάσυρση πρόσβασης",
"username": "Όνομα χρήστη",
"returnToFund": "Επιστροφή στο κεφάλαιο"
- }
+ },
+ "dismiss": "Ελεύθεροι",
+ "v3Banner": "Δοκιμάστε τη νέα έκδοση"
}
diff --git a/src/locales/en.json b/src/locales/en.json
index 486ebaee..4c91c73d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1,540 +1,568 @@
{
- "incident": "NOTICE: Funds deposited before May 10 are temporarily locked while we attempt to recover from a data loss incident. We had to restore our database from a day-old backup so we need to ensure that balances and payment history are corrected to account for all activity that occurred during that period before we can release funds. We don't have an ETA at this time but we are working diligently to resolve the issue. We are sincerely sorry for the inconvenience.",
- "funds": {
- "anyone": "Anyone with the link",
- "addManager": "Add manager",
- "manage": "Manage",
- "withdrawAccess": "Withdraw access",
- "username": "Username",
- "returnToFund": "Back to fund"
- },
- "notes": {
- "contents": "Note contents",
- "notFound": "No notes found"
- },
- "accounts": {
- "qr": "QR",
- "edit": "Edit",
- "payments": "Payments",
- "notifications": "Notifications",
- "on": "On",
- "off": "Off",
- "maxfee": "Max routing fee",
- "maxfeeDesc": "The default is 1% of the payment value if this is not set",
- "per": "per",
- "none": "None",
- "day": "day",
- "week": "week",
- "month": "month",
- "year": "year",
- "daily": "Daily",
- "weekly": "Weekly",
- "monthly": "Monthly",
- "yearly": "Yearly",
- "never": "Never",
- "newConection": "New connection",
- "connect": "Connect",
- "name": "Name",
- "pubkey": "Pubkey",
- "secret": "Secret",
- "connection": "NWC Connection",
- "budget": "Budget",
- "delete": "Delete",
- "renewal": "Renewal period",
- "showQR": "Show QR",
- "revealNwc": "Reveal connection string",
- "finish": "Finish",
- "back": "Back",
- "next": "Next",
- "seedPhrase": "Seed phrase",
- "save": "Save",
- "copy": "Copy",
- "copyNwc": "Copy NWC",
- "revealMnemonic": "Reveal seed phrase",
- "regenerate": "Regenerate",
- "deleteAccount": "Delete account",
- "addAccount": "Create a non-custodial account",
- "create": "Create",
- "import": "Import",
- "accountSetup": "Account setup",
- "enterSeed": "Enter a seed phrase",
- "passwordMismatch": "Passwords don't match",
- "generateSeed": "Generate a new seed phrase",
- "accountPassword": "Account password",
- "importSeed": "Enter an existing seed phrase",
- "passwordWarning": "If you lose this password you won't be able to withdraw funds from the account or restore your seed phrase. We can't recover it for you.",
- "savings": "Savings",
- "password": "Password",
- "confirmPassword": "Confirm password"
- },
- "404": {
- "404": "404",
- "pageNotFound": "Page not found."
- },
- "about": {
- "header": "About",
- "team": {
- "header": "The Team",
- "adam": {
- "name": "Adam Soltys",
- "position": "Founder and CTO",
- "bio": "Adam is a software engineer who believes that the adoption of sound money will drive global prosperity. He lives in North Vancouver with his wife, daughter, and son."
- },
- "cole": {
- "name": "Cole",
- "position": "Business Development",
- "bio": "Cole is a driven individual with a passion for freedom, captivated by Bitcoin since 2015. His mission is to spread the adoption of Bitcoin and the Lightning Network across diverse communities globally."
- }
- },
- "locations": {
- "viewMap": "View map",
- "header": "Locations",
- "description": "Over 500 businesses worldwide are accepting bitcoin with Coinos. We hope that yours will be next!"
- }
- },
- "error": {
- "header": "Error",
- "message": "Please try again or contact support.",
- "signIn": "Please sign in to continue.",
- "unrecognizedInput": "Unrecognized input"
- },
- "faq": {
- "header": "FAQ",
- "cost": {
- "question": "What does it cost?",
- "answer": "Receiving payments into your account is free. Withdrawals to external wallets incur a small fee that varies depending on network conditions."
- },
- "compatibility": {
- "question": "Do I need any special device or software?",
- "answer": "No, Coinos works anywhere that has Internet. Merchants might use it on a tablet or put up a QR code for customers to scan with their phone."
- },
- "safety": {
- "question": "When do my funds settle?",
- "answer": "Lightning payments are settled instantly. You can withdraw your balance to another wallet within seconds. On-chain deposits take 5-10 minutes to confirm on average."
- },
- "pos": {
- "question": "Can Coinos integrate with my existing PoS?",
- "answer": "We recommend entering sales into your existing PoS as cash transactions."
- },
- "bitcoin": {
- "question": "What can I do with Bitcoin?",
- "answer": "Spend it at other merchants or pay your expenses with it by getting your staff, suppliers and landlord to take it."
- }
- },
- "footer": {
- "company": "Company",
- "followUs": "Follow Us",
- "github": "GitHub",
- "linktree": "Linktree",
- "jobs": "Jobs",
- "support": "Support",
- "telegram": "Telegram",
- "twitter": "Twitter",
- "nostr": "Nostr",
- "documentation": "Documentation"
- },
- "howItWorks": {
- "header": "How it works",
- "step1": {
- "header": "1. Create your account",
- "description": "A username and password is all you need to get started."
- },
- "step2": {
- "header": "2. Ask for payments",
- "description": "The same as you normally would, but with bitcoin."
- },
- "step3": {
- "header": "3. That's all!",
- "description": "Spend your coin immediately or watch it grow in value."
- }
- },
- "invoice": {
- "addTip": "Add a tip",
- "addTipq": "Add a tip?",
- "addMemo": "Add memo",
- "setMemo": "Set memo",
- "memo": "Memo",
- "done": "Done",
- "paymentSuccessful": "Payment received!",
- "invoice": "Invoice",
- "total": "Total",
- "tip": "Tip"
- },
- "jobs": {
- "header": "Jobs",
- "subheader": "Current Openings",
- "pitch1": "Coinos is actively looking for developers to join our growing team (full-time or part-time, remote working OK).",
- "pitch2": "Excited about working on open source bitcoin? Then take a look at our repositories on",
- "github": "GitHub",
- "pitch3": "If you think you can make an impact, we'd love to hear from you. Send us your resume to"
- },
- "landing": {
- "header_before_icon": "The",
- "header_with_icon": "easiest",
- "header_after_icon": "way to get started with bitcoin.",
- "subheader": "A free web wallet and payment page for everyone.",
- "info1": {
- "title": "Instant payments via Lightning",
- "description": "We make it easy for merchants to receive retail payments. No waiting for bank settlements or worrying about chargebacks. Instant settlement, in seconds. Say goodbye to 3% card fees."
- },
- "info2": {
- "title": "Simple to use",
- "description1": "Just enter an amount in your local currency, share the QR code and you’re done.",
- "description2": "We do all the heavy lifting so you can focus on your business.",
- "description3": "You always have the option of taking custody of your funds by withdrawing instantly at any time."
- },
- "info3": {
- "title": "Brand it your way!",
- "description": "Customize your profile page look and feel, or keep it on default - it's totally up to you."
- }
- },
- "login": {
- "forgot": "Forgot password?",
- "resetSent": "A password reset link will be sent to your email if we have it on file.",
- "continue": "Continue",
- "reset": "Password reset",
- "resetDesc": "We can send a reset link if you added and verified your email otherwise please reach out to",
- "optional": "Optional",
- "submit": "Submit",
- "token": "2FA Token",
- "username": "Username",
- "password": "Password",
- "newPassword": "New password",
- "email": "Email",
- "passwordRecommendation": "We recommend using a long, random password if you intend to keep funds in your account.",
- "forgotUserOrPassword": "Forgot password?",
- "haveAccount": "Already have an account?",
- "noAccount": "Don't have an account?",
- "register": "Register",
- "createAccount": "Create account",
- "signIn": "Sign In",
- "signUp": "Sign Up",
- "failed": "Login failed",
- "nostr": "Sign in with Nostr",
- "nostrConnect": "Nostr connect"
- },
- "nav": {
- "home": "Home",
- "dark": "Dark",
- "light": "Light",
- "about": "About",
- "docs": "Docs",
- "messages": "Messages",
- "funds": "Funds",
- "map": "Map",
- "merch": "Shop",
- "profile": "Profile",
- "register": "Register",
- "account": "Account",
- "startInSeconds": "Start in Seconds",
- "signIn": "Sign In",
- "signOut": "Sign out",
- "dashboard": "Dashboard",
- "settings": "Settings",
- "support": "Support"
- },
- "news": {
- "header": "News",
- "subheader": "Latest Updates"
- },
- "user": {
- "loadMore": "Load more",
- "url": "Public URL",
- "install": "Install app",
- "moreDetails": "Profile details",
- "noMessages": "You have not sent or received any messages",
- "profilePage": "Profile page",
- "nwc": "Nostr wallet connections",
- "nostrPubkey": "Nostr pubkey",
- "requestPayment": "Request Payment",
- "welcome": "Welcome! Your account balance is empty. You can start by making a deposit or by asking someone to pay you.",
- "showBalance": "Reveal balance",
- "bitcoin": "Bitcoin",
- "lightning": "Lightning",
- "invoice": "Invoice",
- "unfollow": "Unfollow",
- "following": "Following",
- "follow": "Follow",
- "followers": "Followers",
- "remove": "Remove",
- "noNotes": "No notes yet",
- "message": "Message",
- "messages": "Messages",
- "feed": "Feed",
- "notes": "Notes",
- "pay": "Pay",
- "scanToPayBTC": "Scan to pay with bitcoin",
- "this": "This",
- "lightningAddress": "Lightning address",
- "dashboard": {
- "setup": "Setup self-custody",
- "payNow": "Pay now",
- "checkout": "Checkout",
- "go": "Go",
- "dashboard": "Dashboard",
- "accountBalance": "Account Balance",
- "conversionFeeWarning": "You will be charged a 1% withdrawal fee on the amount.",
- "fetchingRate": "fetching rate...",
- "send": "Send",
- "receive": "Receive",
- "withdraw": "Send",
- "deposit": "Deposit",
- "voucher": "Voucher"
- },
- "receive": {
- "lessThan1BTCWarning": "Please enter an amount less than 1 bitcoin.",
- "showQR": "Show QR",
- "hideQR": "Hide QR"
- },
- "send": {
- "makeQR": "QR",
- "contacts": "Contacts",
- "scan": "Scan",
- "paste": "Paste",
- "next": "Next",
- "placeholder": "Enter an address, invoice or username"
- },
- "settings": {
- "nostr": "Nostr",
- "name": "Name",
- "integrations": "Integrations",
- "syncWithExtension": "Sync with extension",
- "connectNostr": "Nostr Wallet Connect",
- "pleaseEnterYourPassword": "Please enter your password",
- "memoPrompt": "Checkout instructions",
- "memoPromptPlaceholder": "e.g. Please enter your name for the order",
- "rememberFor": "Remember for",
- "import": "Import secret key",
- "destination": "Autowithdrawal address",
- "destinationDesc": "The address that withdrawals will be sent to",
- "destinationPlaceholder": "Enter a Bitcoin, Liquid or Lightning address",
- "reserve": "Reserve amount",
- "reserveDesc": "The amount to be left in your account after the withdrawal is complete",
- "shopify": "Shopify",
- "threshold": "Autowithdrawal threshold",
- "thresholdDesc": "When your balance exceeds this amount, a withdrawal will be triggered",
- "autoWithdraw": "Autowithdrawal",
- "autoWithdrawDescription": "Enable withdrawals to an external address when your balance reaches a given threshold",
- "notifications": "Email notifications",
- "pushNotifications": "Push notifications",
- "pushNotificationsDesc": "Get payment notifications on your device",
- "pushNotificationsDisabled": "You've denied notification permission on this device. Check your browser settings if you want to undo that.",
- "notificationsDesc": "Receive an email whenever a payment lands in your account",
- "seedPhrase": "Seed phrase",
- "nostrKeys": "Nostr secret key",
- "nostrDescription": "Your nostr secret key is used to sign and decrypt messages, keep it safe or someone may be able to impersonate you.",
- "nwc": "Nostr wallet connect (NWC)",
- "nwcDescription": "Enable nostr apps to send payments from your account",
- "seedDescription": "This 12 word seed phrase is used to derive your nostr private key and your self-custodial wallet keys which determine your auto-withdrawal addresses. Back it up and don't let anyone see it.",
- "emailDescription": "For password resets and payment notifications",
- "hideNsec": "Hide nostr secret key",
- "saved": "Settings saved!",
- "verifying": "Verification email sent",
- "verified": "Email verified!",
- "revealNsec": "Reveal secret key",
- "hideSeed": "Hide seed phrase",
- "revealSeed": "Reveal seed phrase",
- "saveSettings": "Save Settings",
- "bannerImage": "Banner Image",
- "profileImage": "Profile Image",
- "oneTimeCode": "Enter one-time code",
- "accountId": "Account Secret",
- "header": "Settings",
- "ACCOUNT": "PROFILE",
- "POINT_OF_SALE": "ACCOUNT",
- "NOSTR": "NOSTR",
- "SECURITY": "SECURITY",
- "locale": "Language",
- "username": "Username",
- "email": "Email",
- "newPassword": "Change Password",
- "about": "About",
- "aboutPlaceholder": "A little something about you or your business address and hours",
- "emailNotifications": "Email Notifications",
- "emailNotificationsDescription": "Get notified via email when a change occurs on your account or when people send you payments.",
- "smsNotifications": "SMS Notifications",
- "smsNotificationsDescription": "Coinos will send you text notifications when this is enabled.",
- "theme": "Color Theme",
- "tipPrompt": "Prompt for tips",
- "tipPromptDescription": "Ask people if they want to add a tip before paying their invoices",
- "displayName": "Display name",
- "themeDescription": "Select from the default color themes.",
- "localCurrency": "Currency",
- "btcUnit": "Display bitcoin in:",
- "bitcoin": "bitcoin",
- "seconds": "seconds",
- "minutes": "minutes",
- "hour": "hour",
- "hours": "hours",
- "satoshis": "Satoshis",
- "securityPIN": "Security PIN",
- "securityPINDescription": "Set a security pin that will be used to unlock your settings and to send outgoing payments.",
- "setPIN": "Set PIN",
- "autoLock": "Timeout",
- "autoLockDescription": "Time after which your PIN needs to be re-entered:",
- "twofa": "2-Factor Authentication",
- "twofaDescription": "Add an extra level of security to your device by enabling 2-factor authentication.",
- "twofaSetup": "Set up 2FA",
- "twofaDisable": "Disable 2FA",
- "select": "Select",
- "verifyPIN": "Verify PIN",
- "enablePIN": "Enable PIN",
- "disablePIN": "Disable PIN",
- "publishNip5": "Publish nostr identity",
- "nip5a": "Use",
- "nip5b": "as your publicly verified nostr address"
- },
- "support": {
- "fillForm": "Fill out this form or email us directly at",
- "doOurBest": "and we'll do our best to get back to you in a timely manner.",
- "thankYou": "Thank you!",
- "beInTouch": "Someone will be in touch shortly.",
- "header": "Support",
- "email": "Email",
- "accountName": "Account Name",
- "message": "Message",
- "send": "Send"
- }
- },
- "payments": {
- "lockedBalance": "Locked balance",
- "insufficientFunds": "Insufficient funds",
- "pending": "Pending",
- "minted": "Minted",
- "redeemed": "Redeemed",
- "exchangeRate": "Exchange rate",
- "viewFund": "View fund",
- "zappedEvent": "Zapped event",
- "accountSettings": "Account settings",
- "advancedSettings": "Advanced settings",
- "moreOptions": "More options",
- "other": "Other",
- "ok": "Ok",
- "cancel": "Cancel",
- "failedToRoute": "Failed to route payment, recipient may not be reachable. Try sending a smaller amount or increasing the max fee reserve.",
- "message": "Message",
- "enterWalletPass": "Wallet password",
- "legacyVersion": "Legacy version",
- "newVersion": "New version",
- "income": "Received",
- "subtotal": "Subtotal",
- "expenditure": "Spent",
- "spent": "Already spent",
- "mint": "Mint",
- "ecash": "Ecash",
- "memo": "Notes",
- "voucherText": "Enter an ecash token or lnurlw code",
- "createEcash": "Mint ecash",
- "lnurlw": "LNURLw",
- "tips": "Tips",
- "options": "options",
- "bump": "Bump fee",
- "networkFee": "Network fee",
- "platformFee": "Platform fee",
- "fastest": "Fastest",
- "fundingAmount": "Funding amount",
- "createFund": "Create a fund",
- "withdraw": "Withdraw",
- "fast": "Fast",
- "ecashDesc": "Ecash tokens are funded with bitcoin. They may be redeemed in exchange for a lightning payment equal to their value or loaded back into a coinos account at any time. They can also be transferred to other ecash wallets either wholly or partially. You might use them to give bitcoin to someone who doesn't have a wallet by texting the token to them or sharing a link to it. Learn more at",
- "ecashToken": "Ecash token",
- "medium": "Medium",
- "maxFeeDesc": "Set an upper limit on the network routing fee. If set too low the payment may fail. You can set it higher and try again. We debit the full amount while attempting the payment but credit back any unused portion.",
- "slow": "Slow",
- "create": "Create",
- "fund": "Fund",
- "addFunds": "Add funds",
- "takeFunds": "Take funds",
- "show": "Show",
- "showText": "Show text",
- "showQr": "Show QR",
- "hide": "Hide",
- "voucher": "Payment code",
- "qr": "QR",
- "copy": "Copy",
- "amountPrompt": "Prompt for amount",
- "shareLink": "Share",
- "copyLink": "Copy link",
- "copyText": "Copy text",
- "openLink": "Open",
- "setAmount": "Set amount",
- "redeemVoucher": "Ecash",
- "total": "Total",
- "gainLoss": "Gain/Loss",
- "presentValue": "Present Value",
- "amount": "Amount",
- "date": "Date",
- "id": "Transaction ID",
- "details": "Transaction Details",
- "header": "Payments",
- "preimage": "Lightning preimage",
- "maxfee": "Max routing fee",
- "type": "Type",
- "time": "Time",
- "sent": "Sent",
- "received": "Received",
- "empty": "No transactions yet.",
- "send": "Send",
- "next": "Next",
- "sendTo": "Send to",
- "to": "to",
- "for": "for",
- "pay": "Pay",
- "requesting": "is requesting",
- "sentInvoiceTo": "Sent invoice to",
- "sentRequestTo": "Request sent!",
- "pleasePay": "Please pay",
- "day": "Day",
- "week": "Week",
- "month": "Month",
- "all": "All",
- "export": "Export",
- "invoice": "Invoice",
- "readyToPay": "is ready to pay",
- "describeOrder": "Use this field to describe your order",
- "requestAnInvoice": "Request Invoice",
- "submit": "Submit",
- "isRequesting": "is requesting",
- "invoiceFrom": "Invoice from",
- "awaiting": "Awaiting",
- "tapAnywhere": "Tap anywhere to continue",
- "from": "from",
- "redeem": "Redeem",
- "swap": "Swap",
- "redeemedBy": "Redeemed by",
- "sendMessage": "Write a message...",
- "fundsClaimed": "Funds claimed!",
- "print": "Print",
- "onlyLbtc": "We only support",
- "dontDeposit": "Please don't deposit other Liquid assets"
- },
- "items": {
- "add": "Add item",
- "edit": "Edit item",
- "name": "Name",
- "price": "Price",
- "submit": "Submit",
- "image": "Image"
- },
- "lang": {
- "en": "English",
- "es": "Español",
- "fr": "Français",
- "de": "Deutsch",
- "ja": "日本語",
- "ru": "Русский",
- "pt": "Português",
- "zh": "中文",
- "fa": "فارسی ",
- "el": "Ελληνικά",
- "ar": "العربية",
- "hi": "हिन्दी",
- "bn": "বাংলা",
- "tr": "Türkçe",
- "ko": "한국어",
- "it": "Italiano",
- "nl": "Nederlands",
- "th": "ไทย",
- "pl": "Polski"
- }
+ "incident": "NOTICE: Funds deposited before May 10 are temporarily locked while we attempt to recover from a data loss incident. We had to restore our database from a day-old backup so we need to ensure that balances and payment history are corrected to account for all activity that occurred during that period before we can release funds. We don't have an ETA at this time but we are working diligently to resolve the issue. We are sincerely sorry for the inconvenience.",
+ "funds": {
+ "anyone": "Anyone with the link",
+ "addManager": "Add manager",
+ "manage": "Manage",
+ "withdrawAccess": "Withdraw access",
+ "username": "Username",
+ "returnToFund": "Back to fund"
+ },
+ "notes": {
+ "contents": "Note contents",
+ "notFound": "No notes found"
+ },
+ "accounts": {
+ "qr": "QR",
+ "edit": "Edit",
+ "payments": "Payments",
+ "notifications": "Notifications",
+ "on": "On",
+ "off": "Off",
+ "maxfee": "Max routing fee",
+ "maxfeeDesc": "The default is 1% of the payment value if this is not set",
+ "per": "per",
+ "none": "None",
+ "day": "day",
+ "week": "week",
+ "month": "month",
+ "year": "year",
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "yearly": "Yearly",
+ "never": "Never",
+ "newConection": "New connection",
+ "connect": "Connect",
+ "name": "Name",
+ "pubkey": "Pubkey",
+ "secret": "Secret",
+ "connection": "NWC Connection",
+ "budget": "Budget",
+ "delete": "Delete",
+ "renewal": "Renewal period",
+ "showQR": "Show QR",
+ "revealNwc": "Reveal connection string",
+ "finish": "Finish",
+ "back": "Back",
+ "next": "Next",
+ "seedPhrase": "Seed phrase",
+ "save": "Save",
+ "copy": "Copy",
+ "copyNwc": "Copy NWC",
+ "revealMnemonic": "Reveal seed phrase",
+ "regenerate": "Regenerate",
+ "deleteAccount": "Delete account",
+ "addAccount": "Create a non-custodial account",
+ "create": "Create",
+ "import": "Import",
+ "accountSetup": "Account setup",
+ "enterSeed": "Enter a seed phrase",
+ "passwordMismatch": "Passwords don't match",
+ "generateSeed": "Generate a new seed phrase",
+ "accountPassword": "Account password",
+ "importSeed": "Enter an existing seed phrase",
+ "passwordWarning": "If you lose this password you won't be able to withdraw funds from the account or restore your seed phrase. We can't recover it for you.",
+ "savings": "Savings",
+ "password": "Password",
+ "confirmPassword": "Confirm password"
+ },
+ "404": {
+ "404": "404",
+ "pageNotFound": "Page not found."
+ },
+ "about": {
+ "header": "About",
+ "team": {
+ "header": "The Team",
+ "adam": {
+ "name": "Adam Soltys",
+ "position": "Founder and CTO",
+ "bio": "Adam is a software engineer who believes that the adoption of sound money will drive global prosperity. He lives in North Vancouver with his wife, daughter, and son."
+ },
+ "cole": {
+ "name": "Cole",
+ "position": "Business Development",
+ "bio": "Cole is a driven individual with a passion for freedom, captivated by Bitcoin since 2015. His mission is to spread the adoption of Bitcoin and the Lightning Network across diverse communities globally."
+ }
+ },
+ "locations": {
+ "viewMap": "View map",
+ "header": "Locations",
+ "description": "Over 500 businesses worldwide are accepting bitcoin with Coinos. We hope that yours will be next!"
+ }
+ },
+ "error": {
+ "header": "Error",
+ "message": "Please try again or contact support.",
+ "signIn": "Please sign in to continue.",
+ "unrecognizedInput": "Unrecognized input"
+ },
+ "faq": {
+ "header": "FAQ",
+ "cost": {
+ "question": "What does it cost?",
+ "answer": "Receiving payments is free. Sending to other Coinos accounts is free. Withdrawals to external wallets are free if you use the same network you received on, otherwise there's a 0.4% fee for Bitcoin or 0.1% for Lightning or Liquid."
+ },
+ "compatibility": {
+ "question": "Do I need any special device or software?",
+ "answer": "Coinos works on any device that has Internet and a web browser. You can receive payment notifications within the app, via email or push notification. We do have PoS terminals and receipt printers available for those who prefer physical notifications."
+ },
+ "pos": {
+ "question": "Can Coinos integrate with my existing PoS?",
+ "answer": "We can log sales into Square automatically. On other systems you might choose to record Bitcoin sales under \"cash\"."
+ },
+ "bitcoin": {
+ "question": "What can I do with Bitcoin?",
+ "answer": "Spend it at other merchants, convince your staff, landlord or suppliers to let you pay your expenses with it, or hold it as an investment."
+ }
+ },
+ "footer": {
+ "company": "Company",
+ "followUs": "Follow Us",
+ "github": "GitHub",
+ "linktree": "Linktree",
+ "jobs": "Jobs",
+ "support": "Support",
+ "telegram": "Telegram",
+ "twitter": "Twitter",
+ "nostr": "Nostr",
+ "documentation": "Documentation"
+ },
+ "howItWorks": {
+ "header": "How it works",
+ "step1": {
+ "header": "1. Create your account",
+ "description": "A username and password is all you need to get started."
+ },
+ "step2": {
+ "header": "2. Ask for payments",
+ "description": "The same as you normally would, but with bitcoin."
+ },
+ "step3": {
+ "header": "3. That's all!",
+ "description": "Easy, right? Tell us when you're ready for us to promote your business to the Bitcoin community!"
+ }
+ },
+ "invoice": {
+ "addTip": "Add a tip",
+ "addTipq": "Add a tip?",
+ "addMemo": "Add memo",
+ "setMemo": "Add a note",
+ "memo": "Memo",
+ "done": "Done",
+ "paymentSuccessful": "Payment received!",
+ "invoice": "Invoice",
+ "total": "Total",
+ "tip": "Tip"
+ },
+ "jobs": {
+ "header": "Jobs",
+ "subheader": "Current Openings",
+ "pitch1": "Coinos is actively looking for developers to join our growing team (full-time or part-time, remote working OK).",
+ "pitch2": "Excited about working on open source bitcoin? Then take a look at our repositories on",
+ "github": "GitHub",
+ "pitch3": "If you think you can make an impact, we'd love to hear from you. Send us your resume to"
+ },
+ "landing": {
+ "header_before_icon": "The",
+ "header_with_icon": "easiest",
+ "header_after_icon": "way to get started with bitcoin.",
+ "subheader": "A free web wallet and payment page for everyone.",
+ "info1": {
+ "title": "Instant payments via Lightning",
+ "description": "We make it easy for merchants to receive retail payments. No waiting for bank settlements or worrying about chargebacks. Instant settlement, in seconds. Say goodbye to 3% card fees."
+ },
+ "info2": {
+ "title": "Simple to use",
+ "description1": "Just enter an amount in your local currency, share the QR code and you’re done.",
+ "description2": "We do all the heavy lifting so you can focus on your business.",
+ "description3": "You always have the option of taking custody of your funds by withdrawing instantly at any time."
+ },
+ "info3": {
+ "title": "Brand it your way!",
+ "description": "Customize your profile page look and feel, or keep it on default - it's totally up to you."
+ }
+ },
+ "login": {
+ "forgot": "Forgot password?",
+ "resetSent": "A password reset link will be sent to your email if we have it on file.",
+ "continue": "Continue",
+ "reset": "Password reset",
+ "resetDesc": "Please send an email with your username and any other details you can recall to",
+ "optional": "Optional",
+ "submit": "Submit",
+ "token": "2FA Token",
+ "username": "Username",
+ "password": "Password",
+ "newPassword": "New password",
+ "email": "Email",
+ "passwordRecommendation": "We recommend using a long, random password if you intend to keep funds in your account.",
+ "forgotUserOrPassword": "Forgot password?",
+ "haveAccount": "Already have an account?",
+ "noAccount": "Don't have an account?",
+ "register": "Register",
+ "createAccount": "Create account",
+ "signIn": "Sign In",
+ "signUp": "Sign Up",
+ "failed": "Login failed",
+ "nostr": "Sign in with Nostr",
+ "nostrConnect": "Nostr connect"
+ },
+ "nav": {
+ "home": "Home",
+ "dark": "Dark",
+ "light": "Light",
+ "about": "About",
+ "docs": "Docs",
+ "messages": "Messages",
+ "funds": "Funds",
+ "map": "Map",
+ "merch": "Shop",
+ "profile": "Profile",
+ "register": "Register",
+ "account": "Account",
+ "startInSeconds": "Start in Seconds",
+ "signIn": "Sign In",
+ "signOut": "Sign out",
+ "dashboard": "Dashboard",
+ "settings": "Settings",
+ "support": "Support"
+ },
+ "news": {
+ "header": "News",
+ "subheader": "Latest Updates"
+ },
+ "user": {
+ "loadMore": "Load more",
+ "url": "Public URL",
+ "lnurl": "Payment code",
+ "install": "Install app",
+ "moreDetails": "Profile details",
+ "noMessages": "You have not sent or received any messages",
+ "profilePage": "Profile page",
+ "nwc": "Nostr wallet connections",
+ "nostrPubkey": "Nostr pubkey",
+ "requestPayment": "Request Payment",
+ "welcome": "Welcome! Your account balance is empty. You can start by making a deposit or by asking someone to pay you.",
+ "showBalance": "Reveal balance",
+ "bitcoin": "Bitcoin",
+ "lightning": "Lightning",
+ "invoice": "Invoice",
+ "unfollow": "Unfollow",
+ "following": "Following",
+ "follow": "Follow",
+ "followers": "Followers",
+ "remove": "Remove",
+ "noNotes": "No notes yet",
+ "message": "Message",
+ "messages": "Messages",
+ "feed": "Feed",
+ "notes": "Notes",
+ "pay": "Pay",
+ "scanToPayBTC": "Scan to pay with bitcoin",
+ "this": "This",
+ "lightningAddress": "Lightning address",
+ "dashboard": {
+ "setup": "Setup self-custody",
+ "payNow": "Pay now",
+ "checkout": "Checkout",
+ "go": "Go",
+ "dashboard": "Dashboard",
+ "accountBalance": "Account Balance",
+ "conversionFeeWarning": "You will be charged a 1% withdrawal fee on the amount.",
+ "fetchingRate": "fetching rate...",
+ "send": "Send",
+ "receive": "Receive",
+ "withdraw": "Send",
+ "deposit": "Deposit",
+ "voucher": "Voucher"
+ },
+ "receive": {
+ "lessThan1BTCWarning": "Please enter an amount less than 1 bitcoin.",
+ "showQR": "Show QR",
+ "hideQR": "Hide QR"
+ },
+ "send": {
+ "makeQR": "QR",
+ "contacts": "Contacts",
+ "scan": "Scan",
+ "paste": "Paste",
+ "next": "Next",
+ "placeholder": "Enter an address, invoice or username"
+ },
+ "settings": {
+ "connectSquare": "Connect to Square",
+ "revokeSquare": "Revoke Square Connection",
+ "confirmAutoWithdrawal": "Confirm auto withdrawal settings",
+ "confirmAutoWithdrawalDesc": "Your auto withdrawals have been disabled. Please double check your destination address before re-enabling them or remove the address to dismiss this message.",
+ "tip": "Default tip",
+ "tipDesc": "Default tip percentage when auto-paying trusted users",
+ "nostr": "Nostr",
+ "name": "Name",
+ "integrations": "Integrations",
+ "syncWithExtension": "Sync with extension",
+ "connectNostr": "Nostr Wallet Connect",
+ "pleaseEnterYourPassword": "Please enter your password",
+ "memoPrompt": "Checkout instructions",
+ "memoPromptPlaceholder": "e.g. Please enter your name for the order",
+ "rememberFor": "Remember for",
+ "import": "Import secret key",
+ "destination": "Auto withdrawal address",
+ "destinationDesc": "The address that withdrawals will be sent to",
+ "destinationPlaceholder": "Enter a Bitcoin, Liquid or Lightning address",
+ "reserve": "Reserve amount",
+ "reserveDesc": "The amount to be left in your account after the withdrawal is complete",
+ "shopify": "Shopify",
+ "threshold": "Auto withdrawal threshold",
+ "thresholdDesc": "When your balance exceeds this amount, a withdrawal will be triggered",
+ "autoWithdraw": "Auto withdrawal",
+ "autoWithdrawDescription": "Enable withdrawals to an external address when your balance reaches a given threshold",
+ "notifications": "Email notifications",
+ "pushNotifications": "Push notifications",
+ "pushNotificationsDesc": "Get payment notifications on your device",
+ "pushNotificationsDisabled": "You've denied notification permission on this device. Check your browser settings if you want to undo that.",
+ "notificationsDesc": "Receive an email whenever a payment lands in your account",
+ "seedPhrase": "Seed phrase",
+ "nostrKeys": "Nostr secret key",
+ "nostrDescription": "Your nostr secret key is used to sign and decrypt messages, keep it safe or someone may be able to impersonate you.",
+ "nwc": "Nostr wallet connect (NWC)",
+ "nwcDescription": "Enable nostr apps to send payments from your account",
+ "bolt12Offer": "Lightning offer (BOLT12)",
+ "bolt12OfferDescription": "A reusable payment code for receiving lightning payments and BOLT12 zaps. Add it to your nostr client's offer list to get zapped over BOLT12.",
+ "publishOffer": "Publish to nostr",
+ "offerPublished": "Published!",
+ "offerPublishFailed": "Failed to publish offer",
+ "seedDescription": "This 12 word seed phrase is used to derive your nostr private key and your self-custodial wallet keys which determine your auto-withdrawal addresses. Back it up and don't let anyone see it.",
+ "emailDescription": "For password resets and payment notifications",
+ "hideNsec": "Hide nostr secret key",
+ "saved": "Settings saved!",
+ "verifying": "Verification email sent",
+ "verified": "Email verified!",
+ "revealNsec": "Reveal secret key",
+ "hideSeed": "Hide seed phrase",
+ "revealSeed": "Reveal seed phrase",
+ "saveSettings": "Save settings",
+ "bannerImage": "Banner Image",
+ "profileImage": "Profile Image",
+ "oneTimeCode": "Enter one-time code",
+ "accountId": "Account Secret",
+ "header": "Settings",
+ "ACCOUNT": "PROFILE",
+ "POINT_OF_SALE": "ACCOUNT",
+ "NOSTR": "NOSTR",
+ "SECURITY": "SECURITY",
+ "locale": "Language",
+ "username": "Username",
+ "email": "Email",
+ "newPassword": "New Password",
+ "newPasswordDesc": "(Leave blank to keep unchanged)",
+ "yourUsername": "Your username is:",
+ "yourPassword": "Your password is:",
+ "welcome": "Welcome!",
+ "continueSettingUp": "Account setup",
+ "clickBelow": "Click below to finish setting up your account",
+ "about": "About",
+ "aboutPlaceholder": "A little something about you or your business address and hours",
+ "emailNotifications": "Email Notifications",
+ "emailNotificationsDescription": "Get notified via email when a change occurs on your account or when people send you payments.",
+ "smsNotifications": "SMS Notifications",
+ "smsNotificationsDescription": "Coinos will send you text notifications when this is enabled.",
+ "theme": "Color Theme",
+ "tipPrompt": "Prompt for tips",
+ "tipPromptDescription": "Ask people if they want to add a tip before paying their invoices",
+ "displayName": "Display name",
+ "themeDescription": "Select from the default color themes.",
+ "localCurrency": "Currency",
+ "btcUnit": "Display bitcoin in:",
+ "bitcoin": "bitcoin",
+ "seconds": "seconds",
+ "minutes": "minutes",
+ "hour": "hour",
+ "hours": "hours",
+ "satoshis": "Satoshis",
+ "securityPIN": "Security PIN",
+ "securityPINDescription": "Set a security pin that will be used to unlock your settings and to send outgoing payments.",
+ "setPIN": "Set PIN",
+ "autoLock": "Timeout",
+ "autoLockDescription": "Time after which your PIN needs to be re-entered:",
+ "twofa": "2-Factor Authentication",
+ "twofaDescription": "Add an extra level of security to your device by enabling 2-factor authentication.",
+ "twofaSetup": "Set up 2FA",
+ "twofaDisable": "Disable 2FA",
+ "select": "Select",
+ "verifyPIN": "Verify PIN",
+ "enablePIN": "Enable PIN",
+ "disablePIN": "Disable PIN",
+ "publishNip5": "Publish nostr identity",
+ "nip5a": "Use",
+ "nip5b": "as your publicly verified nostr address"
+ },
+ "support": {
+ "fillForm": "Fill out this form or email us directly at",
+ "doOurBest": "and we'll do our best to get back to you in a timely manner.",
+ "thankYou": "Thank you!",
+ "beInTouch": "Someone will be in touch shortly.",
+ "header": "Support",
+ "email": "Email",
+ "accountName": "Account Name",
+ "message": "Message",
+ "send": "Send"
+ }
+ },
+ "payments": {
+ "custom": "Custom",
+ "trustUser": "Send and enable auto-pay",
+ "gift": "Gift all funds",
+ "giftLink": "Gift link",
+ "giftDesc1": "Whoever opens the link below will receive all the available funds.",
+ "giftDesc2": "If they are logged in, the funds will go to their account, otherwise a new account will be generated.",
+ "sweep": "Sweep link",
+ "lockedBalance": "Locked balance",
+ "insufficientFunds": "Insufficient funds",
+ "pending": "Pending",
+ "minted": "Minted",
+ "redeemed": "Redeemed",
+ "exchangeRate": "Exchange rate",
+ "viewFund": "View fund",
+ "zappedEvent": "Zapped event",
+ "accountSettings": "Account settings",
+ "advancedSettings": "Advanced settings",
+ "moreOptions": "More options",
+ "other": "Other",
+ "ok": "Ok",
+ "cancel": "Cancel",
+ "failedToRoute": "Failed to route payment, recipient may not be reachable. Try sending a smaller amount or increasing the max fee reserve.",
+ "alreadyPaid": "This invoice has already been paid. Check your payment history.",
+ "alreadyUnderway": "This payment is already in progress. Check your payment history before trying again.",
+ "stillSettling": "A previous attempt to pay this invoice is still settling. Please try again in a minute.",
+ "message": "Message",
+ "enterWalletPass": "Wallet password",
+ "legacyVersion": "Legacy version",
+ "newVersion": "New version",
+ "income": "Received",
+ "subtotal": "Subtotal",
+ "expenditure": "Spent",
+ "spent": "Already spent",
+ "mint": "Mint",
+ "ecash": "Ecash",
+ "memo": "Notes",
+ "voucherText": "Enter an ecash token or lnurlw code",
+ "createEcash": "Mint ecash",
+ "lnurlw": "LNURLw",
+ "tips": "Tips",
+ "options": "options",
+ "bump": "Bump fee",
+ "networkFee": "Network fee",
+ "platformFee": "Platform fee",
+ "fastest": "Fastest",
+ "fundingAmount": "Funding amount",
+ "createFund": "Create a fund",
+ "withdraw": "Withdraw",
+ "fast": "Fast",
+ "ecashDesc": "Ecash tokens are funded with bitcoin. They may be redeemed in exchange for a lightning payment equal to their value or loaded back into a coinos account at any time. They can also be transferred to other ecash wallets either wholly or partially. You might use them to give bitcoin to someone who doesn't have a wallet by texting the token to them or sharing a link to it. Learn more at",
+ "ecashToken": "Ecash token",
+ "medium": "Medium",
+ "maxFeeDesc": "An upper limit on the network routing fee. We look up the route and its exact fee before sending, so normally this is already right. If set too low the payment may fail; you can set it higher and try again. We debit the full amount while attempting the payment but credit back any unused portion.",
+ "routingFee": "Routing fee",
+ "noQuote": "Couldn't look up the routing fee ahead of time. The max routing fee below is a reserve; any unused portion is credited back.",
+ "slow": "Slow",
+ "create": "Create",
+ "fund": "Fund",
+ "addFunds": "Add funds",
+ "takeFunds": "Take funds",
+ "show": "Show",
+ "showText": "Show text",
+ "showQr": "Show QR",
+ "hide": "Hide",
+ "voucher": "Payment code",
+ "qr": "QR",
+ "copy": "Copy",
+ "amountPrompt": "Always prompt for amount",
+ "shareLink": "Share",
+ "copyLink": "Copy link",
+ "copyText": "Copy text",
+ "openLink": "Open",
+ "setAmount": "Set amount",
+ "redeemVoucher": "Ecash",
+ "total": "Total",
+ "gainLoss": "Gain/Loss",
+ "presentValue": "Present Value",
+ "amount": "Amount",
+ "date": "Date",
+ "id": "Transaction ID",
+ "details": "Transaction Details",
+ "header": "Payments",
+ "preimage": "Lightning preimage",
+ "maxfee": "Max routing fee",
+ "type": "Type",
+ "time": "Time",
+ "sent": "Sent",
+ "received": "Received",
+ "empty": "No transactions yet.",
+ "send": "Send",
+ "next": "Next",
+ "sendTo": "Send to",
+ "to": "to",
+ "for": "for",
+ "pay": "Pay",
+ "requesting": "is requesting",
+ "sentInvoiceTo": "Sent invoice to",
+ "sentRequestTo": "Request sent!",
+ "pleasePay": "Please pay",
+ "day": "Day",
+ "week": "Week",
+ "month": "Month",
+ "all": "All",
+ "export": "Export",
+ "invoice": "Invoice",
+ "readyToPay": "is ready to pay",
+ "describeOrder": "Use this field to describe your order",
+ "requestAnInvoice": "Request Invoice",
+ "submit": "Submit",
+ "isRequesting": "is requesting",
+ "invoiceFrom": "Invoice from",
+ "awaiting": "Awaiting",
+ "tapAnywhere": "Tap anywhere to continue",
+ "from": "from",
+ "redeem": "Redeem",
+ "swap": "Swap",
+ "redeemedBy": "Redeemed by",
+ "sendMessage": "Write a message...",
+ "fundsClaimed": "Funds claimed!",
+ "print": "Print",
+ "onlyLbtc": "We only support",
+ "dontDeposit": "Please don't deposit other Liquid assets"
+ },
+ "items": {
+ "add": "Add item",
+ "edit": "Edit item",
+ "name": "Name",
+ "price": "Price",
+ "submit": "Submit",
+ "image": "Image"
+ },
+ "lang": {
+ "en": "English",
+ "es": "Español",
+ "fr": "Français",
+ "de": "Deutsch",
+ "ja": "日本語",
+ "ru": "Русский",
+ "pt": "Português",
+ "zh": "中文",
+ "fa": "فارسی ",
+ "el": "Ελληνικά",
+ "ar": "العربية",
+ "hi": "हिन्दी",
+ "bn": "বাংলা",
+ "tr": "Türkçe",
+ "ko": "한국어",
+ "it": "Italiano",
+ "nl": "Nederlands",
+ "th": "ไทย",
+ "pl": "Polski"
+ },
+ "v3Banner": "Try the new version",
+ "dismiss": "Dismiss"
}
diff --git a/src/locales/es.json b/src/locales/es.json
index da65ac4a..c96f1686 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -308,7 +308,24 @@
"nwc": "Cartera de Nostr",
"nwcDescription": "Permite que su aplicación de la nariz envíe pagos de su cuenta",
"name": "Nombre",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "Consejo predeterminado",
+ "tipDesc": "Porcentaje de punta predeterminada cuando los usuarios de confianza pagan auto",
+ "connectSquare": "Conectarse a la Plaza",
+ "revokeSquare": "Revoke Square Connection",
+ "confirmAutoWithdrawal": "Confirme la configuración de retiro automático",
+ "confirmAutoWithdrawalDesc": "Sus retiros de automóviles han sido desactivados. Por favor, compruebe su dirección de destino antes de volver a habilitarlas o retirar la dirección para desestimar este mensaje.",
+ "newPasswordDesc": "(Leave blank to keep unchanged)",
+ "yourUsername": "Tu nombre de usuario es",
+ "yourPassword": "y su contraseña es",
+ "welcome": "¡Bienvenido!",
+ "clickBelow": "Haga clic a continuación para terminar de configurar su cuenta",
+ "bolt12Offer": "Oferta de relámpago (BOLT12)",
+ "bolt12OfferDescription": "Un código de pago reutilizable para recibir pagos de rayos y BOLT12 zaps. Añádelo a la lista de ofertas de tu cliente de la nariz para que te den una paliza.",
+ "continueSettingUp": "Configuración de la cuenta",
+ "publishOffer": "Publish to nostr",
+ "offerPublished": "¡Publicado!",
+ "offerPublishFailed": "Failed to publish offer"
},
"support": {
"header": "Soporte",
@@ -327,7 +344,8 @@
"moreDetails": "Enlaces y detalles",
"url": "URL pública",
"nwc": "Conexión de billetera Nostr",
- "loadMore": "Cargar más"
+ "loadMore": "Cargar más",
+ "lnurl": "Código de pago reutilizable"
},
"payments": {
"insufficientFunds": "Fondos insuficientes",
@@ -422,6 +440,9 @@
"amountPrompt": "Siempre solicite la cantidad",
"enterWalletPass": "Contraseña de la billetera",
"failedToRoute": "No se pudo enviar el pago; es posible que no se pueda contactar al destinatario. Intente enviar un monto menor o aumentar la reserva de tarifa máxima.",
+ "alreadyPaid": "Esta factura ya fue pagada. Revisa tu historial de pagos.",
+ "alreadyUnderway": "Este pago ya está en curso. Revisa tu historial de pagos antes de intentarlo de nuevo.",
+ "stillSettling": "Un intento anterior de pagar esta factura aún se está procesando. Inténtalo de nuevo en un minuto.",
"ok": "Ok",
"cancel": "Cancelar",
"message": "Mensaje",
@@ -440,7 +461,16 @@
"exchangeRate": "Tipo de cambio",
"pending": "Pendiente",
"minted": "Minted",
- "lockedBalance": "Saldo bloqueado"
+ "lockedBalance": "Saldo bloqueado",
+ "sweep": "Sweep link",
+ "trustUser": "Enviar y activar el pago automático",
+ "custom": "Aduanas",
+ "gift": "Donar todos los fondos",
+ "giftLink": "Enlace de regalo",
+ "giftDesc1": "Quien abra el enlace de abajo recibirá todos los fondos disponibles.",
+ "giftDesc2": "Si se registran, los fondos irán a su cuenta, de lo contrario se generará una nueva cuenta.",
+ "routingFee": "Tasa de rotación",
+ "noQuote": "No podía mirar la tarifa de enrutamiento por delante del tiempo. La tarifa máxima de enrutamiento a continuación es una reserva; cualquier porción no utilizada se acredita de nuevo."
},
"items": {
"add": "Añadir artículo",
@@ -517,5 +547,7 @@
"withdrawAccess": "Acceso retirado",
"username": "Nombre de usuario",
"returnToFund": "Regreso al fondo"
- }
+ },
+ "dismiss": "Retírense",
+ "v3Banner": "Prueba la nueva versión"
}
diff --git a/src/locales/fa.json b/src/locales/fa.json
index bd26974a..ddd4bcca 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -308,7 +308,24 @@
"nwc": "کیف پول Nostr اتصال",
"nwcDescription": "اجازه دهید برنامه nostr خود را برای ارسال پرداخت از حساب شما",
"name": "نام",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "شکست",
+ "tipDesc": "درصد نهایی زمانی که کاربران قابل اعتماد به پول نقد",
+ "connectSquare": "اتصال به میدان",
+ "revokeSquare": "بازی Square Connection",
+ "confirmAutoWithdrawal": "تایید تنظیمات خروج خودکار",
+ "confirmAutoWithdrawalDesc": "ماشین های شما غیرفعال شده اند. لطفاً آدرس مقصد خود را قبل از اینکه دوباره آنها را باز کنید یا آدرس را برای رد این پیام حذف کنید، بررسی کنید.",
+ "newPasswordDesc": "(تغییرمسیر از)",
+ "yourUsername": "نام کاربری شما",
+ "yourPassword": "و رمز عبور شما",
+ "welcome": "خوش آمدید!",
+ "clickBelow": "در زیر کلیک کنید تا حساب خود را تنظیم کنید",
+ "bolt12Offer": "پیشنهاد رعد و برق (BOLT12)",
+ "bolt12OfferDescription": "یک کد پرداخت مجدد برای دریافت پرداخت رعد و برق و BOLT12 zaps آن را به لیست پیشنهادی مشتری نو رشته خود اضافه کنید تا در BOLT12 گرفتار شوید.",
+ "continueSettingUp": "تنظیم حساب",
+ "publishOffer": "انتشار به Nostr",
+ "offerPublished": "منتشر شده!",
+ "offerPublishFailed": "شکست در انتشار پیشنهاد"
},
"support": {
"header": "حمایت کردن",
@@ -327,7 +344,8 @@
"moreDetails": "لینک ها و جزئیات",
"url": "URL عمومی",
"nwc": "اتصال کیف پول Nostr",
- "loadMore": "بار بیشتر"
+ "loadMore": "بار بیشتر",
+ "lnurl": "کد پرداخت مناسب"
},
"payments": {
"networkFee": "هزینه شبکه",
@@ -421,6 +439,9 @@
"amountPrompt": "همیشه مقدار را درخواست کنید",
"enterWalletPass": "رمز کیف پول",
"failedToRoute": "مسیر پرداخت انجام نشد، ممکن است گیرنده قابل دسترسی نباشد. سعی کنید مبلغ کمتری ارسال کنید یا حداکثر ذخیره هزینه را افزایش دهید.",
+ "alreadyPaid": "این صورتحساب قبلاً پرداخت شده است. تاریخچه پرداخت‌های خود را بررسی کنید.",
+ "alreadyUnderway": "این پرداخت در حال انجام است. پیش از تلاش دوباره، تاریخچه پرداخت‌های خود را بررسی کنید.",
+ "stillSettling": "تلاش قبلی برای پرداخت این صورتحساب هنوز در حال تسویه است. لطفاً یک دقیقه دیگر دوباره تلاش کنید.",
"ok": "Ok",
"cancel": "لغو",
"message": "پیام",
@@ -440,7 +461,16 @@
"pending": "بازگشت",
"minted": "Minted",
"insufficientFunds": "صندوق های بی کفایت",
- "lockedBalance": "تعادل قفل شده"
+ "lockedBalance": "تعادل قفل شده",
+ "sweep": "پیوند",
+ "trustUser": "ارسال و فعال کردن خودکار پرداخت",
+ "custom": "سفارشی",
+ "gift": "هدیه همه وجوه",
+ "giftLink": "پیوند هدیه",
+ "giftDesc1": "هر کس لینک زیر را باز کند، تمام وجوه موجود را دریافت خواهد کرد.",
+ "giftDesc2": "اگر آنها وارد شوند، وجوه به حساب خود می آیند، در غیر این صورت حساب جدید تولید خواهد شد.",
+ "routingFee": "هزینه مسیریابی",
+ "noQuote": "نمی توان هزینه مسیریابی را قبل از زمان بررسی کرد. حداکثر هزینه مسیریابی زیر یک رزرو است؛ هر بخش استفاده نشده به آن بازگردانده می شود."
},
"items": {
"add": "این مورد را اضافه کنید",
@@ -517,5 +547,7 @@
"withdrawAccess": "دسترسی با",
"username": "Username",
"returnToFund": "بازگشت به صندوق"
- }
+ },
+ "dismiss": "عدم فرار",
+ "v3Banner": "نسخه جدید را امتحان کنید"
}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index b4361623..702bd3dc 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -277,7 +277,24 @@
"nwc": "Le portefeuille Nost se connecte",
"nwcDescription": "Activez votre app nost pour envoyer des paiements depuis votre compte",
"name": "Nom",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "Conseil par défaut",
+ "tipDesc": "Pourcentage de pointe par défaut lors du paiement automatique d'utilisateurs de confiance",
+ "connectSquare": "Se connecter à Square",
+ "revokeSquare": "Renverser la connexion carrée",
+ "confirmAutoWithdrawal": "Confirmer les paramètres de retrait automatique",
+ "confirmAutoWithdrawalDesc": "Vos retraits automatiques ont été désactivés. Veuillez vérifier à nouveau votre adresse de destination avant de les renflouer ou supprimer l'adresse pour rejeter ce message.",
+ "newPasswordDesc": "(Laisser blanc pour garder inchangé)",
+ "yourUsername": "Votre nom d'utilisateur",
+ "yourPassword": "et votre mot de passe est",
+ "welcome": "Bienvenue !",
+ "clickBelow": "Cliquez ci-dessous pour terminer la configuration de votre compte",
+ "bolt12Offer": "Offre de foudre (BOLT12)",
+ "bolt12OfferDescription": "Un code de paiement réutilisable pour recevoir les paiements par foudre et BOLT12 zaps. Ajoutez-le à la liste d'offres de votre client nostra pour vous faire zapper sur BOLT12.",
+ "continueSettingUp": "Configuration du compte",
+ "publishOffer": "Publier sur nosr",
+ "offerPublished": "Publié !",
+ "offerPublishFailed": "Échec de la publication de l'offre"
},
"support": {
"header": "Assistance",
@@ -323,7 +340,8 @@
"moreDetails": "Liens et détails",
"url": "URL publique",
"nwc": "Connexion au portefeuille Nostr",
- "loadMore": "Charger plus"
+ "loadMore": "Charger plus",
+ "lnurl": "Code de paiement réutilisable"
},
"news": {
"header": "Nouvelles",
@@ -421,6 +439,9 @@
"amountPrompt": "Toujours demander le montant",
"enterWalletPass": "Mot de passe du portefeuille",
"failedToRoute": "Impossible d'acheminer le paiement, le destinataire n'est peut-être pas joignable. Essayez d'envoyer un montant inférieur ou d'augmenter les frais de transaction.",
+ "alreadyPaid": "Cette facture a déjà été payée. Consultez votre historique de paiements.",
+ "alreadyUnderway": "Ce paiement est déjà en cours. Consultez votre historique de paiements avant de réessayer.",
+ "stillSettling": "Une tentative précédente de payer cette facture est encore en cours de règlement. Réessayez dans une minute.",
"ok": "ok",
"cancel": "Annuler",
"message": "Message",
@@ -440,7 +461,16 @@
"pending": "En attente",
"minted": "Mentionné",
"insufficientFunds": "Insuffisance des fonds",
- "lockedBalance": "Solde verrouillé"
+ "lockedBalance": "Solde verrouillé",
+ "sweep": "Lien de balayage",
+ "trustUser": "Envoyer et activer auto-payer",
+ "custom": "Personnalisé",
+ "gift": "Donner tous les fonds",
+ "giftLink": "Lien cadeau",
+ "giftDesc1": "Quiconque ouvre le lien ci-dessous recevra tous les fonds disponibles.",
+ "giftDesc2": "S'ils sont connectés, les fonds vont à leur compte, sinon un nouveau compte sera généré.",
+ "routingFee": "Frais d'expédition",
+ "noQuote": "Je ne pouvais pas regarder les frais d'acheminement à l'avance. Les frais d'acheminement maximum ci-dessous sont une réserve; toute partie inutilisée est remboursée."
},
"items": {
"add": "Ajouter un article",
@@ -517,5 +547,7 @@
"withdrawAccess": "Retrait de l'accès",
"username": "Nom d'utilisateur",
"returnToFund": "Retour au fonds"
- }
+ },
+ "dismiss": "Rejet",
+ "v3Banner": "Essayez la nouvelle version"
}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 2975745f..05049011 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -369,7 +369,24 @@
"nip5a": "उपयोग",
"nip5b": "अपने सार्वजनिक रूप से सत्यापित nostr पता के रूप में",
"name": "नाम",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "डिफ़ॉल्ट टिप",
+ "tipDesc": "डिफ़ॉल्ट टिप प्रतिशत जब ऑटो भुगतान विश्वसनीय उपयोगकर्ताओं",
+ "connectSquare": "स्क्वायर से कनेक्ट करें",
+ "revokeSquare": "स्क्वायर कनेक्शन",
+ "confirmAutoWithdrawal": "ऑटो वापसी सेटिंग्स की पुष्टि करें",
+ "confirmAutoWithdrawalDesc": "आपकी ऑटो निकासी अक्षम हो गई है। कृपया अपने गंतव्य पते को फिर से सक्षम करने से पहले डबल चेक करें या इस संदेश को खारिज करने के लिए पते को हटा दें।.",
+ "newPasswordDesc": "(बदलने के लिए खाली छोड़ दें)",
+ "yourUsername": "आपका उपयोगकर्ता नाम है",
+ "yourPassword": "आपका पासवर्ड है",
+ "welcome": "स्वागत!",
+ "clickBelow": "अपने खाते की स्थापना समाप्त करने के लिए नीचे क्लिक करें",
+ "bolt12Offer": "बिजली की पेशकश (BOLT12)",
+ "bolt12OfferDescription": "बिजली भुगतान और BOLT12 zaps प्राप्त करने के लिए एक पुन: प्रयोज्य भुगतान कोड। इसे BOLT12 पर Zapped होने के लिए अपने नोस्ट्र क्लाइंट की पेशकश सूची में जोड़ें।.",
+ "continueSettingUp": "खाता सेटअप",
+ "publishOffer": "Nostr को प्रकाशित करना",
+ "offerPublished": "प्रकाशित!",
+ "offerPublishFailed": "प्रस्ताव को प्रकाशित करने में विफल"
},
"support": {
"header": "समर्थन",
@@ -381,12 +398,16 @@
"doOurBest": "और हम आपको समय पर ढंग से वापस पाने के लिए अपनी पूरी कोशिश करेंगे।.",
"thankYou": "धन्यवाद!",
"beInTouch": "कुछ लोग शीघ्र ही स्पर्श में रहेंगे।."
- }
+ },
+ "lnurl": "भुगतान कोड"
},
"payments": {
"ok": "ठीक है",
"cancel": "रद्द करना",
"failedToRoute": "भुगतान करने में विफल, प्राप्तकर्ता पहुंच योग्य नहीं हो सकता है। एक छोटी राशि भेजने या अधिकतम शुल्क आरक्षित बढ़ाने की कोशिश करें।.",
+ "alreadyPaid": "इस इनवॉइस का भुगतान पहले ही हो चुका है। अपना भुगतान इतिहास देखें।",
+ "alreadyUnderway": "यह भुगतान पहले से प्रगति में है। दोबारा कोशिश करने से पहले अपना भुगतान इतिहास देखें।",
+ "stillSettling": "इस इनवॉइस के भुगतान का पिछला प्रयास अभी पूरा हो रहा है। कृपया एक मिनट बाद फिर कोशिश करें।",
"message": "संदेश",
"enterWalletPass": "वॉलेट पासवर्ड",
"legacyVersion": "विरासत संस्करण",
@@ -493,7 +514,16 @@
"pending": "लंबित",
"minted": "मिंट",
"insufficientFunds": "अपर्याप्त निधि",
- "lockedBalance": "बंद संतुलन"
+ "lockedBalance": "बंद संतुलन",
+ "sweep": "स्वीप लिंक",
+ "trustUser": "ऑटो-पे",
+ "custom": "कस्टम",
+ "gift": "सभी फंड",
+ "giftLink": "उपहार लिंक",
+ "giftDesc1": "जो भी नीचे दिए गए लिंक को खोलता है उसे सभी उपलब्ध फंड प्राप्त होंगे।.",
+ "giftDesc2": "यदि वे लॉग इन हैं, तो धन उनके खाते में जाएगा, अन्यथा एक नया खाता उत्पन्न किया जाएगा।.",
+ "routingFee": "रूटिंग शुल्क",
+ "noQuote": "समय से पहले रूटिंग शुल्क नहीं देख सका। नीचे अधिकतम रूटिंग शुल्क एक आरक्षित है; किसी भी अप्रयुक्त हिस्से को वापस श्रेय दिया जाता है।."
},
"items": {
"add": "आइटम जोड़ें",
@@ -536,5 +566,7 @@
"withdrawAccess": "निकासी",
"username": "उपयोगकर्ता नाम",
"returnToFund": "फंड वापस"
- }
+ },
+ "dismiss": "Dismis",
+ "v3Banner": "नया संस्करण आज़माएं"
}
diff --git a/src/locales/it.json b/src/locales/it.json
index 14c8b5e5..0bab970b 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -369,7 +369,24 @@
"nip5a": "Usa",
"nip5b": "come indirizzo nostr pubblicamente verificato",
"name": "Nome",
- "NOSTR": "NO"
+ "NOSTR": "NO",
+ "tip": "Punta predefinita",
+ "tipDesc": "Percentuale di punta predefinito quando gli utenti fidati di pagamento automatico",
+ "connectSquare": "Collegamento a Piazza",
+ "revokeSquare": "Connessione quadrata",
+ "confirmAutoWithdrawal": "Confermare le impostazioni di prelievo automatico",
+ "confirmAutoWithdrawalDesc": "I vostri prelievi auto sono stati disabilitati. Si prega di controllare doppio l'indirizzo di destinazione prima di riabilitare o rimuovere l'indirizzo per annullare questo messaggio.",
+ "newPasswordDesc": "(Lasciare vuoto per mantenere invariato)",
+ "yourUsername": "Il tuo nome utente è",
+ "yourPassword": "e la tua password è",
+ "welcome": "Benvenuto!",
+ "clickBelow": "Clicca qui sotto per completare l'impostazione del tuo account",
+ "bolt12Offer": "Offerta fulmine (BOLT12)",
+ "bolt12OfferDescription": "Un codice di pagamento riutilizzabile per ricevere pagamenti fulmini e zaps BOLT12. Aggiungilo alla lista di offerta del tuo cliente nostr per ottenere zapped oltre BOLT12.",
+ "continueSettingUp": "Configurazione account",
+ "publishOffer": "Pubblica a nostr",
+ "offerPublished": "Pubblicato!",
+ "offerPublishFailed": "Non pubblicare l'offerta"
},
"support": {
"header": "Supporto",
@@ -381,12 +398,16 @@
"doOurBest": "e faremo del nostro meglio per tornare a te in modo tempestivo.",
"thankYou": "Grazie!",
"beInTouch": "Qualcuno sarà in contatto tra poco."
- }
+ },
+ "lnurl": "Codice di pagamento riutilizzabile"
},
"payments": {
"ok": "Ok",
"cancel": "Annulla",
"failedToRoute": "Impossibile inoltrare il pagamento, il destinatario potrebbe non essere raggiungibile. Prova ad inviare un importo inferiore o aumentando la quota di commissione.",
+ "alreadyPaid": "Questa fattura è già stata pagata. Controlla la cronologia dei pagamenti.",
+ "alreadyUnderway": "Questo pagamento è già in corso. Controlla la cronologia dei pagamenti prima di riprovare.",
+ "stillSettling": "Un tentativo precedente di pagare questa fattura è ancora in elaborazione. Riprova tra un minuto.",
"message": "Messaggio",
"enterWalletPass": "Portafoglio password",
"legacyVersion": "Versione legacy",
@@ -493,7 +514,16 @@
"pending": "Finanziamenti",
"minted": "Minted",
"insufficientFunds": "Fondi insufficienti",
- "lockedBalance": "Bilancia chiusa"
+ "lockedBalance": "Bilancia chiusa",
+ "sweep": "Collegamento di nuoto",
+ "trustUser": "Inviare e abilitare il pagamento automatico",
+ "custom": "Personale",
+ "gift": "Regalare tutti i fondi",
+ "giftLink": "Link regalo",
+ "giftDesc1": "Chi apre il link qui sotto riceverà tutti i fondi disponibili.",
+ "giftDesc2": "Se sono connessi, i fondi andranno al loro account, altrimenti verrà generato un nuovo account.",
+ "routingFee": "Tassa di routing",
+ "noQuote": "Non ho potuto controllare la tassa di routing prima del tempo. La quota massima di routing di seguito è una riserva; qualsiasi parte non utilizzata è accreditata indietro."
},
"items": {
"add": "Aggiungi articolo",
@@ -536,5 +566,7 @@
"withdrawAccess": "Ritiro l'accesso",
"username": "Nome utente",
"returnToFund": "Torna a fondo"
- }
+ },
+ "dismiss": "Oggetto",
+ "v3Banner": "Prova la nuova versione"
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 00d085ff..50bde442 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -308,7 +308,24 @@
"nwc": "Nostrウォレットは接続します",
"nwcDescription": "あなたのnostrアプリを有効にして、あなたのアカウントから支払いを送信",
"name": "お名前 (必須)",
- "NOSTR": "ログイン"
+ "NOSTR": "ログイン",
+ "tip": "デフォルトチップ",
+ "tipDesc": "信頼できるユーザーを自動支払するときのデフォルトのヒントパーセンテージ",
+ "connectSquare": "スクエアに接続する",
+ "revokeSquare": "Revokeスクエア接続",
+ "confirmAutoWithdrawal": "自動出金設定の確認",
+ "confirmAutoWithdrawalDesc": "自動出金が無効になっています。 アドレスを再エナブルするか、このメッセージを却下するアドレスを削除する前に、宛先アドレスをダブルクリックしてください.",
+ "newPasswordDesc": "(変更されないままにブランクを残す)",
+ "yourUsername": "あなたのユーザー名は",
+ "yourPassword": "パスワードは",
+ "welcome": "お問い合わせ!",
+ "clickBelow": "アカウントの設定を完了するには、下記をクリックしてください",
+ "bolt12Offer": "避雷器(BOLT12)",
+ "bolt12OfferDescription": "落雷支払いとBOLT12 zapsを受信するための再利用可能な支払いコード。 BOLT12 上で zapped を取得するために nostr クライアントのオファーリストに追加します.",
+ "continueSettingUp": "アカウント設定",
+ "publishOffer": "Nostr に公開",
+ "offerPublished": "掲載情報!",
+ "offerPublishFailed": "オファーを公開できなかった"
},
"support": {
"header": "サポート",
@@ -327,7 +344,8 @@
"moreDetails": "リンクと詳細",
"url": "公開URL",
"nwc": "Nostrウォレットコネクト",
- "loadMore": "もっと見る"
+ "loadMore": "もっと見る",
+ "lnurl": "再利用可能な決済コード"
},
"payments": {
"networkFee": "ネットワーク料金",
@@ -421,6 +439,9 @@
"amountPrompt": "常に金額の入力を求める",
"enterWalletPass": "ウォレットパスワード",
"failedToRoute": "支払いのルーティングに失敗しました。受取人に連絡が取れない可能性があります。送金金額を減らすか、最大手数料準備金を増やしてみてください。",
+ "alreadyPaid": "この請求書はすでに支払われています。支払い履歴をご確認ください。",
+ "alreadyUnderway": "この支払いはすでに処理中です。もう一度試す前に支払い履歴をご確認ください。",
+ "stillSettling": "この請求書への以前の支払いがまだ確定処理中です。1分後にもう一度お試しください。",
"ok": "お問い合わせ",
"cancel": "キャンセル",
"message": "メッセージ",
@@ -440,7 +461,16 @@
"pending": "ペンディング",
"minted": "ログイン",
"insufficientFunds": "十分な資金",
- "lockedBalance": "ロックバランス"
+ "lockedBalance": "ロックバランス",
+ "sweep": "スイープリンク",
+ "trustUser": "オートペイの送信と有効化",
+ "custom": "カスタム",
+ "gift": "すべての資金をギフト",
+ "giftLink": "ギフトリンク",
+ "giftDesc1": "下記のリンクを開いたら、利用可能なすべての資金を受け取ることになります.",
+ "giftDesc2": "彼らがログインしている場合, 資金は、自分のアカウントに行きます, それ以外の場合は、新しいアカウントが生成されます.",
+ "routingFee": "ルーティング料金",
+ "noQuote": "事前にルーティング料金を調べることができません。 下記の最大ルーティング料金は予約制です。未使用部分は返金されます."
},
"items": {
"add": "アイテムの追加",
@@ -517,5 +547,7 @@
"withdrawAccess": "アクセスを撤退",
"username": "ユーザ名",
"returnToFund": "資金を調達する"
- }
+ },
+ "dismiss": "免責事項",
+ "v3Banner": "新しいバージョンを試す"
}
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 5162846e..f67191db 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -369,7 +369,24 @@
"nip5a": "제품 정보",
"nip5b": "공개적으로 검증된 nostr 주소",
"name": "이름 *",
- "NOSTR": "사이트맵"
+ "NOSTR": "사이트맵",
+ "tip": "기본 팁",
+ "tipDesc": "자동 결제가 가능한 경우 기본 팁 비율",
+ "connectSquare": "Square에 연결",
+ "revokeSquare": "Revoke 광장 연결",
+ "confirmAutoWithdrawal": "자동 인출 설정 확인",
+ "confirmAutoWithdrawalDesc": "자동 인출이 비활성화되었습니다. 해당 이용 후기에 달린 코멘트가 없습니다.",
+ "newPasswordDesc": "(변경되지 않도록 공백을 사용하십시오)",
+ "yourUsername": "사용자 이름",
+ "yourPassword": "비밀번호",
+ "welcome": "제품 정보!",
+ "clickBelow": "계정 설정하기",
+ "bolt12Offer": "번개 제안 (BOLT12)",
+ "bolt12OfferDescription": "번개 결제 및 BOLT12 zaps를 수신하기위한 재사용 가능한 지불 코드. nostr 클라이언트의 제안 목록에 추가하여 BOLT12를 통해 zapped를 얻을 수 있습니다.",
+ "continueSettingUp": "계정 설정",
+ "publishOffer": "Nostr에 게시",
+ "offerPublished": "이름 *!",
+ "offerPublishFailed": "게시 제안에 실패"
},
"support": {
"header": "지원하다",
@@ -381,12 +398,16 @@
"doOurBest": "그리고 우리는 적시에 당신에게 다시 얻을 수있는 최선을 다할 것입니다.",
"thankYou": "감사합니다!",
"beInTouch": "누군가가 곧 연락 할 것입니다."
- }
+ },
+ "lnurl": "Reusable 지불 부호"
},
"payments": {
"ok": "·",
"cancel": "이름 *",
"failedToRoute": "결제에 실패, 수신자는 도달 할 수 없습니다. 더 작은 금액을 보내거나 최대 수수료 예약을 증가하십시오.",
+ "alreadyPaid": "이 인보이스는 이미 결제되었습니다. 결제 내역을 확인하세요.",
+ "alreadyUnderway": "이 결제는 이미 진행 중입니다. 다시 시도하기 전에 결제 내역을 확인하세요.",
+ "stillSettling": "이 인보이스에 대한 이전 결제 시도가 아직 정산 중입니다. 1분 후에 다시 시도하세요.",
"message": "이름 *",
"enterWalletPass": "지갑 암호",
"legacyVersion": "Legacy 버전",
@@ -493,7 +514,16 @@
"pending": "관련 기사",
"minted": "이름 *",
"insufficientFunds": "충분한 자금",
- "lockedBalance": "Locked 균형"
+ "lockedBalance": "Locked 균형",
+ "sweep": "Sweep 링크",
+ "trustUser": "자동 결제 가능",
+ "custom": "제품 정보",
+ "gift": "모든 자금",
+ "giftLink": "선물 링크",
+ "giftDesc1": "아래 링크를 열면 모든 가능한 자금을 받게됩니다.",
+ "giftDesc2": "그들이 로그인한 경우, 자금은 계정으로 갈 것입니다. 그렇지 않으면 새로운 계정이 생성됩니다.",
+ "routingFee": "대출 수수료",
+ "noQuote": "시간이 지남에 따라 routing 요금을 볼 수 없습니다. 아래 최대 라우팅 수수료는 예비입니다. 사용되지 않은 부분은 다시 적립됩니다."
},
"items": {
"add": "더 보기",
@@ -536,5 +566,7 @@
"withdrawAccess": "비밀번호",
"username": "사용자 이름",
"returnToFund": "자금 지원"
- }
+ },
+ "dismiss": "뚱 베어",
+ "v3Banner": "새 버전 시도"
}
diff --git a/src/locales/nl.json b/src/locales/nl.json
index 90e77b7a..875ea696 100644
--- a/src/locales/nl.json
+++ b/src/locales/nl.json
@@ -89,7 +89,7 @@
"header": "Veelgestelde vragen",
"cost": {
"question": "Wat kost het?",
- "answer": "Het ontvangen van betalingen op jouw rekening is gratis. Verzendingen naar externe portemonnees krijgen een vergoeding die afhankelijk zijn van het Bitcoin (lightning) netwerk."
+ "answer": "Het ontvangen van betalingen op jouw rekening is gratis. Verzenden naar andere Coinos is gratis. Verzendingen naar externe portemonnees zijn gratis als je hetzelfde netwerk gebruikt waar je van ontvangen hebt. In andere gevallen is er een vergoeding van 0,4% voor Bitcoin en 0,1 voor Lightning of Liquid."
},
"compatibility": {
"question": "Heb ik een speciaal apparaat of software nodig?",
@@ -97,15 +97,15 @@
},
"safety": {
"question": "Wanneer is mijn transactie geregeld?",
- "answer": "Bitcoin Lightning betalingen worden onmiddellijk verrekend. Je kunt jouw ontvangen transacties binnen enkele seconden besteden. Bitcoin On-chain stortingen duren gemiddeld 10 minuten voordat deze bevestigd zijn. In uitzonderlijke gevallen kan dit langer duren."
+ "answer": "Coinos werkt op elk apparaat met Internet en een webbrowser. Je ontvangt notificaties van binnenkomende betalingen in de app, via e-mail en push notificaties. We hebben een PoS terminal en bonnen printer beschikbaar voor diegenen die een fysieke notificatie willen ontvangen."
},
"pos": {
"question": "Kan ik Coinos combineren met mijn bestaande kassasysteem?",
- "answer": "Wij raden aan om de verkoop in jouw bestaande kassasysteem als cash transacties aan te slaan."
+ "answer": "We kunnen verkopen automatisch in Square vastleggen. Als je een ander kassasysteem gebruikt, raden we aan Bitcoin als cash in jouw bestaande kassasysteem aan te slaan."
},
"bitcoin": {
"question": "Wat kan ik doen met Bitcoin?",
- "answer": "Geef het door aan andere handelaren of betaal je kosten ermee door je personeel, leveranciers en verhuurder te vragen Bitcoin aan te nemen."
+ "answer": "Geef het door aan andere handelaren of betaal je kosten ermee door je personeel, leveranciers en verhuurder te vragen Bitcoin aan te nemen. Of spaar het voor de lange termijn."
}
},
"footer": {
@@ -132,7 +132,7 @@
},
"step3": {
"header": "3. Dat is alles!",
- "description": "Besteed jouw Bitcoin sats makkelijk uit of spaar een deel op."
+ "description": "Makkelijk toch?! Laat het ons weten wanneer je er klaar voor bent jouw bedrijf te promoten bij de Bitcoin community."
}
},
"invoice": {
@@ -369,7 +369,24 @@
"nip5a": "Gebruik",
"nip5b": "als jouw publiekelijk geverifieerd nostr adres",
"name": "Naam",
- "NOSTR": "NOST"
+ "NOSTR": "NOST",
+ "tip": "Standaard tip",
+ "tipDesc": "Standaard tippercentage bij het automatisch betalen van vertrouwde gebruikers",
+ "connectSquare": "Verbinden met Square",
+ "revokeSquare": "Square verbinding ongedaan maken",
+ "confirmAutoWithdrawal": "Auto-opnameinstellingen bevestigen",
+ "confirmAutoWithdrawalDesc": "Je auto opnames zijn uitgeschakeld. Controleer uw bestemmingsadres voordat u deze opnieuw toevoegd of verwijder het adres om dit bericht te verwijderen.",
+ "newPasswordDesc": "(Laat leeg om ongewijzigd te blijven)",
+ "yourUsername": "Uw gebruikersnaam is:",
+ "yourPassword": "Uw wachtwoord is:",
+ "welcome": "Welkom!",
+ "clickBelow": "Klik hieronder om het aanmaken van uw account af te ronden",
+ "bolt12Offer": "Bliksemaanbieding (BOLT12)",
+ "bolt12OfferDescription": "Een herbruikbare betaalcode voor het ontvangen van bliksem betalingen en BOLT12 zaps. Voeg het toe aan de aanbiedingenlijst van uw nostr-cliënt om over BOLT12 te worden gezapt.",
+ "continueSettingUp": "Accountinstellingen",
+ "publishOffer": "Publiceren naar nostr",
+ "offerPublished": "Gepubliceerd!",
+ "offerPublishFailed": "Kon aanbod niet publiceren"
},
"support": {
"header": "Ondersteuning",
@@ -377,28 +394,32 @@
"accountName": "Accountnaam",
"message": "Bericht",
"send": "Verzenden",
- "fillForm": "Vul dit formulier of e-mail ons direct bij",
- "doOurBest": "en we zullen ons best doen om tijdig bij je terug te komen.",
+ "fillForm": "Vul dit formulier in of stuur een e-mail naar",
+ "doOurBest": "en we zullen ons best doen om tijdig bij je terug te komen",
"thankYou": "Dank je!",
"beInTouch": "Er neemt zo iemand contact op."
- }
+ },
+ "lnurl": "Betalingscode"
},
"payments": {
"ok": "Ok",
"cancel": "Annuleren",
"failedToRoute": "Het is mogelijk dat de ontvanger niet bereikbaar is. Probeer het verzenden van een kleiner bedrag of controleer met de ontvanger.",
+ "alreadyPaid": "Deze factuur is al betaald. Bekijk je betalingsgeschiedenis.",
+ "alreadyUnderway": "Deze betaling is al bezig. Bekijk je betalingsgeschiedenis voordat je het opnieuw probeert.",
+ "stillSettling": "Een eerdere poging om deze factuur te betalen wordt nog afgehandeld. Probeer het over een minuut opnieuw.",
"message": "Notitie",
"enterWalletPass": "Wachtwoord portemonnee",
"legacyVersion": "Legacy versie",
"newVersion": "Nieuwe versie",
"income": "Ontvangen",
"subtotal": "Subtotaal",
- "expenditure": "Gewenst",
+ "expenditure": "Uitgegeven",
"spent": "Reeds uitgegeven",
"mint": "Munt",
"ecash": "Ecash",
"memo": "Notities",
- "voucherText": "Vul een ecash token of Inurlw-code in",
+ "voucherText": "Vul een Ecash token of LNURLw-code in",
"createEcash": "Mint ecash",
"lnurlw": "LNURLw",
"tips": "Fooien",
@@ -414,7 +435,7 @@
"ecashDesc": "Ecash tokens worden gefinancierd met Bitcoin. Ze kunnen worden ingewisseld in ruil voor een lightning betaling gelijk aan hun waarde of elk moment worden teruggehaald in een coinos rekening. Ze kunnen ook geheel of gedeeltelijk worden overgedragen naar andere ecash portemonnees. Je zou ze kunnen gebruiken om Bitcoin te geven aan iemand die geen portemonnee heeft door de token naar hen te sms'en of een link te delen. Meer informatie op",
"ecashToken": "Ecash token",
"medium": "Middel",
- "maxFeeDesc": "Stel een bovengrens in voor de de kosten van de netwerkroutering. Indien deze te laag is ingesteld kan de betaling mislukken. Je kunt het hoger zetten en het opnieuw proberen. We debiteren het volledige bedrag tijdens het proberen van de betaling, maar crediteren elk ongebruikte deel.",
+ "maxFeeDesc": "Stel een bovengrens in voor de kosten van de netwerkroutering. Als deze te laag is ingesteld kan de betaling mislukken. Je kunt het hoger instellen en het opnieuw proberen. We debiteren het volledige bedrag tijdens de betalingpoging, maar crediteren het ongebruikte deel.",
"slow": "Langzaam",
"create": "Aanmaken",
"fund": "Fonds",
@@ -427,7 +448,7 @@
"voucher": "Betalingscode",
"qr": "QR",
"copy": "Kopiëren",
- "amountPrompt": "Waarschuwing voor bedrag",
+ "amountPrompt": "Vraag om bedrag",
"shareLink": "Delen",
"copyLink": "Verwijzing kopiëren",
"copyText": "Tekst kopiëren",
@@ -444,7 +465,7 @@
"details": "Transactiedetails",
"header": "Betalingen",
"preimage": "Lightning afbeelding",
- "maxfee": "Maximale routingvergoeding",
+ "maxfee": "Maximale routeringvergoeding",
"type": "Type",
"time": "Tijd",
"sent": "Verzonden",
@@ -493,7 +514,16 @@
"pending": "In afwachting",
"minted": "Mint",
"insufficientFunds": "Onvoldoende middelen",
- "lockedBalance": "Gesloten balans"
+ "lockedBalance": "Gesloten balans",
+ "sweep": "Veegverbinding",
+ "trustUser": "Automatisch betalen verzenden en inschakelen",
+ "custom": "Aangepast",
+ "gift": "Alle fondsen cadeau",
+ "giftLink": "Kadokoppeling",
+ "giftDesc1": "Wie de onderstaande link opent, ontvangt alle beschikbare fondsen.",
+ "giftDesc2": "Als ze ingelogd zijn, gaan de fondsen naar hun account, anders wordt er een nieuwe account gegenereerd.",
+ "routingFee": "Routingvergoeding",
+ "noQuote": "Kon de routekosten niet vooraf opzoeken. De maximale routing fee hieronder is een reserve; eventuele ongebruikte gedeelte wordt terug gecrediteerd."
},
"items": {
"add": "Item toevoegen",
@@ -536,5 +566,7 @@
"withdrawAccess": "Toegang intrekken",
"username": "Gebruikersnaam",
"returnToFund": "Terug naar financiering"
- }
+ },
+ "dismiss": "Ingetrokken",
+ "v3Banner": "Probeer de nieuwe versie"
}
diff --git a/src/locales/pl.json b/src/locales/pl.json
index 7704ebd6..130df267 100644
--- a/src/locales/pl.json
+++ b/src/locales/pl.json
@@ -369,7 +369,24 @@
"nip5a": "Stosowanie",
"nip5b": "jako publicznie zweryfikowany adres nostr",
"name": "Nazwa",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "Domyślny napiwek",
+ "tipDesc": "Domyślny procent napiwku przy autopłatnych zaufanych użytkowników",
+ "connectSquare": "Połącz z kwadratem",
+ "revokeSquare": "Połączenie kwadratowe Revoke",
+ "confirmAutoWithdrawal": "Potwierdź ustawienia automatycznego wycofania",
+ "confirmAutoWithdrawalDesc": "Wypłaty samochodów zostały wyłączone. Proszę sprawdzić adres docelowy przed ponownym włączeniem lub usunięciem adresu do odrzucenia tej wiadomości.",
+ "newPasswordDesc": "(Pozostawić puste, aby pozostać niezmienione)",
+ "yourUsername": "Twoja nazwa użytkownika jest",
+ "yourPassword": "twoje hasło jest",
+ "welcome": "Witamy!",
+ "clickBelow": "Kliknij poniżej, aby skonfigurować konto",
+ "bolt12Offer": "Oferta błyskawicy (BOLT12)",
+ "bolt12OfferDescription": "Kod płatności wielokrotnego użytku do otrzymywania płatności błyskawicznych i BOLT12 zaps. Dodaj go do listy ofert klienta nostr, aby dostać zapped nad BOLT12.",
+ "continueSettingUp": "Konfiguracja konta",
+ "publishOffer": "Publikuj do nostr",
+ "offerPublished": "Opublikowane!",
+ "offerPublishFailed": "Nie udało się opublikować oferty"
},
"support": {
"header": "Wsparcie",
@@ -381,12 +398,16 @@
"doOurBest": "i zrobimy co w naszej mocy, by wrócić do ciebie w odpowiednim czasie.",
"thankYou": "Dziękuję!",
"beInTouch": "Ktoś wkrótce będzie w kontakcie."
- }
+ },
+ "lnurl": "Kod płatności wielokrotnego użytku"
},
"payments": {
"ok": "Ok",
"cancel": "Anuluj",
"failedToRoute": "Nie udało się dokonać płatności za trasę, odbiorca może nie być dostępny. Spróbuj wysłać mniejszą kwotę lub zwiększenie maksymalnej rezerwy opłat.",
+ "alreadyPaid": "Ta faktura została już opłacona. Sprawdź historię płatności.",
+ "alreadyUnderway": "Ta płatność jest już w toku. Sprawdź historię płatności przed ponowną próbą.",
+ "stillSettling": "Poprzednia próba opłacenia tej faktury jest wciąż rozliczana. Spróbuj ponownie za minutę.",
"message": "Wiadomość",
"enterWalletPass": "Hasło portfela",
"legacyVersion": "Wersja w języku litewskim",
@@ -493,7 +514,16 @@
"pending": "Oczekiwanie",
"minted": "Puste",
"insufficientFunds": "Niewystarczające fundusze",
- "lockedBalance": "Saldo zablokowane"
+ "lockedBalance": "Saldo zablokowane",
+ "sweep": "Połączenie zamiatające",
+ "trustUser": "Wysyłanie i włączanie auto- pay",
+ "custom": "Własny",
+ "gift": "Dawać wszystkie fundusze",
+ "giftLink": "Link prezent",
+ "giftDesc1": "Ktokolwiek otworzy poniższy link otrzyma wszystkie dostępne fundusze.",
+ "giftDesc2": "Jeśli są one zalogowane, fundusze trafią na ich konto, w przeciwnym razie powstanie nowe konto.",
+ "routingFee": "Opłata za trasy",
+ "noQuote": "Nie mogłem wcześniej sprawdzić opłat za routing. Maksymalna opłata za routing poniżej jest rezerwą; niewykorzystana część jest zapisywana z powrotem."
},
"items": {
"add": "Dodaj element",
@@ -536,5 +566,7 @@
"withdrawAccess": "Wycofanie dostępu",
"username": "Nazwa użytkownika",
"returnToFund": "Powrót do funduszu"
- }
+ },
+ "dismiss": "Rozejść się",
+ "v3Banner": "Spróbuj nowej wersji"
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 13d60e4a..2d74ea33 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -327,7 +327,24 @@
"nwc": "Nostr carteira conectar",
"nwcDescription": "Habilite seu aplicativo nostr para enviar pagamentos de sua conta",
"name": "Nome",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "Dica padrão",
+ "tipDesc": "Percentagem de dica padrão ao pagar automaticamente usuários confiáveis",
+ "connectSquare": "Ligar ao Quadrado",
+ "revokeSquare": "Revogar a Ligação Quadrada",
+ "confirmAutoWithdrawal": "Confirmar as configurações de retirada automática",
+ "confirmAutoWithdrawalDesc": "As suas retiradas automáticas foram desactivadas. Por favor, verifique o seu endereço de destino antes de voltar a acedê-los ou remova o endereço para descartar esta mensagem.",
+ "newPasswordDesc": "(Deixe em branco para manter inalterada)",
+ "yourUsername": "Seu nome de usuário é",
+ "yourPassword": "e sua senha é",
+ "welcome": "Bem-vindos!",
+ "clickBelow": "Clique abaixo para terminar de configurar sua conta",
+ "bolt12Offer": "Oferta relâmpago (BOLT12)",
+ "bolt12OfferDescription": "Um código de pagamento reutilizável para receber pagamentos relâmpagos e BOLT12 zaps. Adicione-o à lista de ofertas do seu cliente nostr para ser zapped sobre BOLT12.",
+ "continueSettingUp": "Configuração da conta",
+ "publishOffer": "Publicar em nostr",
+ "offerPublished": "Publicado!",
+ "offerPublishFailed": "Falha ao publicar a oferta"
},
"support": {
"header": "Suporte",
@@ -348,7 +365,8 @@
"moreDetails": "Links e detalhes",
"url": "URL pública",
"nwc": "Nostr carteira conectar",
- "loadMore": "Carregar mais"
+ "loadMore": "Carregar mais",
+ "lnurl": "Código de pagamento reutilizável"
},
"payments": {
"amount": "Quantidade",
@@ -443,6 +461,9 @@
"amountPrompt": "Sempre solicite o valor",
"enterWalletPass": "Senha da carteira",
"failedToRoute": "Falha ao encaminhar o pagamento, o destinatário pode não estar acessível. Tente enviar uma quantia menor ou aumentar a reserva de taxa máxima.",
+ "alreadyPaid": "Esta fatura já foi paga. Confira seu histórico de pagamentos.",
+ "alreadyUnderway": "Este pagamento já está em andamento. Confira seu histórico de pagamentos antes de tentar novamente.",
+ "stillSettling": "Uma tentativa anterior de pagar esta fatura ainda está sendo concluída. Tente novamente em um minuto.",
"ok": "Ok",
"cancel": "Cancelar",
"message": "Mensagem",
@@ -462,7 +483,16 @@
"pending": "Pendente",
"minted": "Minado",
"insufficientFunds": "Fundos insuficientes",
- "lockedBalance": "Saldo bloqueado"
+ "lockedBalance": "Saldo bloqueado",
+ "sweep": "Varrer a ligação",
+ "trustUser": "Enviar e ativar o pagamento automático",
+ "custom": "Personalizado",
+ "gift": "Dar todos os fundos",
+ "giftLink": "Ligação do presente",
+ "giftDesc1": "Quem abrir o link abaixo receberá todos os fundos disponíveis.",
+ "giftDesc2": "Se eles estiverem conectados, os fundos irão para sua conta, caso contrário uma nova conta será gerada.",
+ "routingFee": "Taxa de encaminhamento",
+ "noQuote": "Não consegui ver a taxa de roteamento antes. A taxa de roteamento máxima abaixo é uma reserva; qualquer porção não utilizada é creditada de volta."
},
"items": {
"add": "Adicionar item",
@@ -539,5 +569,7 @@
"withdrawAccess": "Retirar o acesso",
"username": "Utilizador",
"returnToFund": "Voltar ao fundo"
- }
+ },
+ "dismiss": "Licenciamento",
+ "v3Banner": "Experimente a nova versão"
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 18deefed..cd170ee3 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -308,7 +308,24 @@
"nwc": "Кошелек Nostr подключается",
"nwcDescription": "Включите приложение Nostr для отправки платежей с вашего счета",
"name": "Имя",
- "NOSTR": "НОСТР"
+ "NOSTR": "НОСТР",
+ "tip": "Подсказка по умолчанию",
+ "tipDesc": "Процент отказов при автоплатежах доверенных пользователей",
+ "connectSquare": "Подключение к Square",
+ "revokeSquare": "Отменить Square Connection",
+ "confirmAutoWithdrawal": "Подтвердите настройки автозабора",
+ "confirmAutoWithdrawalDesc": "Ваш отзыв авто был отключен. Пожалуйста, дважды проверьте адрес назначения, прежде чем повторно включить их или удалить адрес, чтобы отклонить это сообщение.",
+ "newPasswordDesc": "(Оставьте пустой, чтобы остаться неизменным)",
+ "yourUsername": "Ваше имя пользователя",
+ "yourPassword": "ваш пароль является",
+ "welcome": "Добро пожаловать!",
+ "clickBelow": "Нажмите ниже, чтобы закончить настройку вашей учетной записи",
+ "bolt12Offer": "Молния (BOLT12)",
+ "bolt12OfferDescription": "Код многоразового использования для приема молниеносных платежей и BOLT12 zaps. Добавьте его в список предложений вашего клиента nostr, чтобы получить поверх BOLT12.",
+ "continueSettingUp": "Настройка аккаунта",
+ "publishOffer": "Опубликовать в Nostr",
+ "offerPublished": "Опубликовано!",
+ "offerPublishFailed": "Не удалось опубликовать предложение"
},
"support": {
"header": "Поддержка",
@@ -327,7 +344,8 @@
"moreDetails": "Ссылки и подробности",
"url": "Публичный URL-адрес",
"nwc": "Nostr кошелек подключить",
- "loadMore": "Загрузить больше"
+ "loadMore": "Загрузить больше",
+ "lnurl": "Код многоразового платежа"
},
"payments": {
"networkFee": "Сетевая комиссия",
@@ -421,6 +439,9 @@
"amountPrompt": "Всегда запрашивать сумму",
"enterWalletPass": "Пароль кошелька",
"failedToRoute": "Не удалось направить платеж, получатель может быть недоступен. Попробуйте отправить меньшую сумму или увеличить максимальный резерв комиссии.",
+ "alreadyPaid": "Этот счёт уже оплачен. Проверьте историю платежей.",
+ "alreadyUnderway": "Этот платёж уже выполняется. Проверьте историю платежей, прежде чем пробовать снова.",
+ "stillSettling": "Предыдущая попытка оплатить этот счёт ещё обрабатывается. Попробуйте снова через минуту.",
"ok": "Хорошо",
"cancel": "Отменить",
"message": "Сообщение",
@@ -440,7 +461,16 @@
"pending": "В ожидании",
"minted": "Отчеканенный",
"insufficientFunds": "Недостаточно средств",
- "lockedBalance": "Замкнутый баланс"
+ "lockedBalance": "Замкнутый баланс",
+ "sweep": "Связь со свистом",
+ "trustUser": "Отправить и включить автоплатеж",
+ "custom": "Обычай",
+ "gift": "Подарите все средства",
+ "giftLink": "Подарочная ссылка",
+ "giftDesc1": "Тот, кто откроет ссылку ниже, получит все доступные средства.",
+ "giftDesc2": "Если они вошли в систему, средства пойдут на их счет, в противном случае будет создан новый счет.",
+ "routingFee": "Стоимость маршрутизации",
+ "noQuote": "Я не мог заранее взимать плату за маршрутизацию. Максимальная плата за маршрутизацию ниже является резервом; любая неиспользованная часть зачисляется обратно."
},
"items": {
"add": "Добавить элемент",
@@ -517,5 +547,7 @@
"withdrawAccess": "Отозвать доступ",
"username": "Имя пользователя",
"returnToFund": "Назад в фонд"
- }
+ },
+ "dismiss": "Увольнение",
+ "v3Banner": "Попробуйте новую версию"
}
diff --git a/src/locales/th.json b/src/locales/th.json
index aea599e5..1fc20cc3 100644
--- a/src/locales/th.json
+++ b/src/locales/th.json
@@ -369,7 +369,24 @@
"nip5a": "ใช้",
"nip5b": "ในฐานะที่อยู่พม่าของคุณ",
"name": "ชื่อ",
- "NOSTR": "NOSR"
+ "NOSTR": "NOSR",
+ "tip": "เคล็ดลับปริยาย",
+ "tipDesc": "เปอร์เซ็นต์ของทิปเมื่อผู้ใช้ที่เชื่อถือได้อัตโนมัติ",
+ "connectSquare": "เชื่อมต่อไปยังสแควร์",
+ "revokeSquare": "การเชื่อมต่อแบบ Reviok Square",
+ "confirmAutoWithdrawal": "ยืนยันการเลิกทําอัตโนมัติ",
+ "confirmAutoWithdrawalDesc": "การถอนเงินอัตโนมัติของคุณถูกยกเลิก กรุณาตรวจสอบที่อยู่ปลายทางของคุณสองครั้ง ก่อนที่จะทําการเปิดใช้งานมันใหม่อีกครั้ง หรือลบที่อยู่เพื่อยกเลิกข้อความนี้.",
+ "newPasswordDesc": "(ปล่อยช่องว่างไว้ไม่ให้เปลี่ยน)",
+ "yourUsername": "ชื่อผู้ใช้ของคุณ",
+ "yourPassword": "และรหัสผ่านของคุณ",
+ "welcome": "ยินดีต้อนรับ!",
+ "clickBelow": "คลิกด้านล่างนี้เพื่อทําบัญชีให้เสร็จ",
+ "bolt12Offer": "นําเสนอสายฟ้า (BOLT12)",
+ "bolt12OfferDescription": "รหัสการชําระหนี้สําหรับ การชําระเงินฟ้าผ่าและ BOLT12 ช็อต เพิ่มมันเข้าไปในรายชื่อลูกค้าของคุณ เพื่อถูกช็อตทับ.",
+ "continueSettingUp": "ตั้งค่าบัญชีผู้ใช้",
+ "publishOffer": "เผยแพร่ไปยังโหนด",
+ "offerPublished": "ตีพิมพ์!",
+ "offerPublishFailed": "ล้มเหลวในการเผยแพร่ข้อเสนอ"
},
"support": {
"header": "สนับสนุน",
@@ -381,12 +398,16 @@
"doOurBest": "และเราจะทําอย่างดีที่สุด เพื่อกลับไปหาคุณในเวลาที่เหมาะสม.",
"thankYou": "ขอบคุณ!",
"beInTouch": "เดี๋ยวจะมีคนติดต่อไป."
- }
+ },
+ "lnurl": "รหัสการชําระหนี้ที่ใช้ได้"
},
"payments": {
"ok": "โอเค",
"cancel": "ยกเลิก",
"failedToRoute": "การจ่ายเงินตามเส้นทางล้มเหลว อาจไม่สามารถเข้าถึงผู้รับได้ ลอง ส่ง เงิน ที่ มี ค่า น้อย กว่า หรือ ทํา การ เพิ่ม อัตรา ค่า ธรรมเนียม ให้ มาก ที่ สุด.",
+ "alreadyPaid": "ใบแจ้งหนี้นี้ชำระแล้ว โปรดตรวจสอบประวัติการชำระเงินของคุณ",
+ "alreadyUnderway": "การชำระเงินนี้กำลังดำเนินการอยู่แล้ว โปรดตรวจสอบประวัติการชำระเงินก่อนลองอีกครั้ง",
+ "stillSettling": "ความพยายามชำระใบแจ้งหนี้นี้ครั้งก่อนยังอยู่ระหว่างดำเนินการ โปรดลองอีกครั้งในอีกหนึ่งนาที",
"message": "จดหมาย",
"enterWalletPass": "รหัสผ่านของกระเป๋าคุมข้อมูล",
"legacyVersion": "รุ่นของมรดก",
@@ -493,7 +514,16 @@
"pending": "จํานวนครั้งที่เล่น",
"minted": "ย่อหาย",
"insufficientFunds": "เงินทุนไม่เพียงพอ",
- "lockedBalance": "สมดุลที่ล็อค"
+ "lockedBalance": "สมดุลที่ล็อค",
+ "sweep": "ล้างลิงก์",
+ "trustUser": "ส่งและเปิดใช้งานการชําระอัตโนมัติ",
+ "custom": "กําหนดเอง",
+ "gift": "นําเสนอกองทุนทั้งหมด",
+ "giftLink": "เชื่อมโยงของพรสวรรค์",
+ "giftDesc1": "ใครก็ตามที่เปิดลิงก์ด้านล่าง จะได้รับเงินทั้งหมดที่มี.",
+ "giftDesc2": "ถ้าพวกเขาถูกล็อกอิน กองทุนก็จะไปที่บัญชีของพวกเขา มิฉะนั้นจะสร้างบัญชีใหม่.",
+ "routingFee": "ค่า ใช้ จ่าย",
+ "noQuote": "ไม่ทันตั้งราคา ค่า ใช้ จ่าย ที่ ค่า ใช้ จ่าย สูง ที่ สุด ข้าง ล่าง นี้ เป็น ค่า สงวน; ส่วน ใด ๆ ที่ ไม่ ได้ ใช้ ก็ ได้ รับ การ รับรอง คืน."
},
"items": {
"add": "เพิ่มรายการ",
@@ -536,5 +566,7 @@
"withdrawAccess": "กําลังดึงข้อมูลการเข้าใช้",
"username": "ชื่อผู้ใช้",
"returnToFund": "กลับไปยังกองทุน"
- }
+ },
+ "dismiss": "ไม่สนใจ",
+ "v3Banner": "ลองใหม่ดู"
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 0034cc70..9bba1a45 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -369,7 +369,24 @@
"nip5a": "Use",
"nip5b": "sizin kamuya açık olarak doğrulanmamış nostr adresi",
"name": "Name",
- "NOSTR": "NOSTR"
+ "NOSTR": "NOSTR",
+ "tip": "Varsayılan ipucu",
+ "tipDesc": "Otomatik ücretli güvenilen kullanıcıların ortalama yüzdesi",
+ "connectSquare": "Square",
+ "revokeSquare": "Revoke Square Connection",
+ "confirmAutoWithdrawal": "Otomatik geri çekilme ayarlarını onaylayın",
+ "confirmAutoWithdrawalDesc": "Otomatik geri çekilmeleriniz devre dışı bırakıldı. Lütfen hedef adresinizi yeniden etkinleştirin veya bu mesajı reddetme adresi ortadan kaldırın.",
+ "newPasswordDesc": "(Değişmemek için boş tut)",
+ "yourUsername": "Kullanıcınız Kullanıcınızdır",
+ "yourPassword": "ve şifreniziz",
+ "welcome": "Hoşgeldiniz!",
+ "clickBelow": "Hesabınızı kurmak için aşağıda tıklayın",
+ "bolt12Offer": "Lightning teklifi (BOLT12)",
+ "bolt12OfferDescription": "Yıldırım ödemeleri ve BOLT12 zaps almak için yeniden kullanılabilir bir ödeme kodu. BOLT12 üzerinden zapt edilmek için Nostr müşterinizin teklif listesine ekleyin.",
+ "continueSettingUp": "Hesap kurulumu",
+ "publishOffer": "Nostr",
+ "offerPublished": "Yayınlanmış!",
+ "offerPublishFailed": "Yayımlama teklifinde başarısız oldu"
},
"support": {
"header": "Destek Desteği Destek Desteği",
@@ -381,12 +398,16 @@
"doOurBest": "ve size zamanında geri dönmek için elimizden geleni yapacağız.",
"thankYou": "Teşekkür ederim!",
"beInTouch": "Birisi kısa bir süre içinde dokunacaktır."
- }
+ },
+ "lnurl": "Reusable ödeme kodu"
},
"payments": {
"ok": "Tamam",
"cancel": "Cancel",
"failedToRoute": "Yol ödemesine başarısız oldu, alıcı ulaşılamaz olabilir. Daha küçük bir miktar göndermeye veya maksimum ücret rezervini arttırmaya çalışın.",
+ "alreadyPaid": "Bu fatura zaten ödendi. Ödeme geçmişinizi kontrol edin.",
+ "alreadyUnderway": "Bu ödeme zaten devam ediyor. Tekrar denemeden önce ödeme geçmişinizi kontrol edin.",
+ "stillSettling": "Bu faturayı ödemek için önceki bir deneme hâlâ tamamlanıyor. Lütfen bir dakika sonra tekrar deneyin.",
"message": "Mesaj",
"enterWalletPass": "Cüzdan şifresi",
"legacyVersion": "Legacy Version",
@@ -493,7 +514,16 @@
"pending": "Pending",
"minted": "Minted",
"insufficientFunds": "Yetersiz fonlarda",
- "lockedBalance": "Locked bakiye"
+ "lockedBalance": "Locked bakiye",
+ "sweep": "Sweep link",
+ "trustUser": "Gönder ve otomatik ödeme sağlar",
+ "custom": "Özel",
+ "gift": "Hediye tüm fonlar",
+ "giftLink": "Hediye linki",
+ "giftDesc1": "Aşağıdaki bağlantıyı açan herkes mevcut tüm fonları alacak.",
+ "giftDesc2": "Eğer giriş yapılırsa, fonlar hesabına gidecek, aksi takdirde yeni bir hesap oluşturulacaktır.",
+ "routingFee": "Routing ücretleri",
+ "noQuote": "Zaman öncesindeki routing ücretini göz ardı edemezdi. Aşağıdaki maksimum routing ücreti bir rezervdir; herhangi bir kullanılmayan kısım geri ödenir."
},
"items": {
"add": "Ekle item",
@@ -536,5 +566,7 @@
"withdrawAccess": "Withdraw access",
"username": "Username",
"returnToFund": "Geri finanse etmek için"
- }
+ },
+ "dismiss": "Başarısızlık",
+ "v3Banner": "Yeni versiyonu deneyin"
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index dd8f1bdd..f72ac733 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -308,7 +308,24 @@
"nwc": "鼻孔钱包连接",
"nwcDescription": "启用您的鼻孔应用程序从您的账户发送付款",
"name": "名称",
- "NOSTR": "无"
+ "NOSTR": "无",
+ "tip": "默认提示",
+ "tipDesc": "自动付费信任用户时默认提示百分比",
+ "connectSquare": "连接到平方",
+ "revokeSquare": "撤销正方形连接",
+ "confirmAutoWithdrawal": "确认自动退出设置",
+ "confirmAutoWithdrawalDesc": "您的自动取款被禁用 。 在重新启用之前, 请重复检查您的地址, 或是删除地址以删除此信件 .",
+ "newPasswordDesc": "(空出以不变)",
+ "yourUsername": "您的用户名是",
+ "yourPassword": "而您的密码是",
+ "welcome": "欢迎光临!",
+ "clickBelow": "点击下面完成您的账户设置",
+ "bolt12Offer": "闪电报价(BOLT12)",
+ "bolt12OfferDescription": "用于接收闪电支付和BOLT12 zaps的可再用支付代码. 把它添加到你的鼻孔客户端的要约列表里 以获得被ZALT12.",
+ "continueSettingUp": "账户设置",
+ "publishOffer": "发布到节点",
+ "offerPublished": "发表!",
+ "offerPublishFailed": "发布要约失败"
},
"support": {
"header": "支持",
@@ -327,7 +344,8 @@
"moreDetails": "链接和详细信息",
"url": "公共 URL",
"nwc": "Nostr 钱包连接",
- "loadMore": "装入更多"
+ "loadMore": "装入更多",
+ "lnurl": "可重复使用的付款代码"
},
"payments": {
"networkFee": "网络费",
@@ -421,6 +439,9 @@
"amountPrompt": "始终提示金额",
"enterWalletPass": "钱包密码",
"failedToRoute": "付款路线失败,收款人可能无法联系到。请尝试发送较少的金额或增加最大费用储备。",
+ "alreadyPaid": "此发票已支付。请查看您的付款记录。",
+ "alreadyUnderway": "此付款正在进行中。请在重试前查看您的付款记录。",
+ "stillSettling": "此前对该发票的付款尝试仍在结算中。请一分钟后再试。",
"ok": "确认",
"cancel": "取消",
"message": "消息",
@@ -440,7 +461,16 @@
"pending": "待决",
"minted": "已剪切",
"insufficientFunds": "资金不足",
- "lockedBalance": "锁定余额"
+ "lockedBalance": "锁定余额",
+ "sweep": "扫描链接",
+ "trustUser": "发送和启用自动支付",
+ "custom": "自定义",
+ "gift": "赠与所有基金",
+ "giftLink": "礼品链接",
+ "giftDesc1": "任何人打开以下链接,都将获得所有可用资金.",
+ "giftDesc2": "如果登录,资金将存入其账户,否则将产生一个新的账户.",
+ "routingFee": "骑行费",
+ "noQuote": "无法提前查清路由费 以下最大路由费为准备金;任何未用部分将贷记回账."
},
"items": {
"add": "新增项目",
@@ -517,5 +547,7 @@
"withdrawAccess": "撤销访问",
"username": "用户名",
"returnToFund": "回到资金"
- }
+ },
+ "dismiss": "开除",
+ "v3Banner": "尝试新版本"
}
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
index e28d8b56..8b694241 100644
--- a/src/routes/(app)/+layout.server.ts
+++ b/src/routes/(app)/+layout.server.ts
@@ -1,4 +1,4 @@
-import { auth, get, sleep } from "$lib/utils";
+import { auth, get, isInvalidTokenError, sleep } from "$lib/utils";
import { error, redirect } from "@sveltejs/kit";
export async function load({ cookies, request, url, params }) {
@@ -12,8 +12,21 @@ export async function load({ cookies, request, url, params }) {
user = await get("/me", auth(cookies));
} catch (e) {
const { message } = e as Error;
- if (message.startsWith("Rate")) await sleep(3000);
- user = await get("/me", auth(cookies));
+ if (message.startsWith("Rate")) {
+ await sleep(3000);
+ try {
+ user = await get("/me", auth(cookies));
+ } catch (retryError) {
+ if (isInvalidTokenError(retryError) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ }
+ throw retryError;
+ }
+ } else if (isInvalidTokenError(e) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ } else {
+ throw e;
+ }
}
}
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 d064c3a9..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";
@@ -32,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}
@@ -55,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..4bc37a7f 100644
--- a/src/routes/(app)/[username]/receive/+page.svelte
+++ b/src/routes/(app)/[username]/receive/+page.svelte
@@ -53,10 +53,10 @@
amountFiat,
tip;
- let update = async () => {
+ let update = async (body) => {
try {
({ id } = await post(`/invoice`, {
- invoice,
+ invoice: body,
user: { username, currency },
}));
@@ -77,10 +77,12 @@
if (type === types.lightning && !amount && typeof newAmount === "undefined")
goto(`/${username}/receive`, { invalidateAll: true, noScroll: true });
else {
- if (typeof newAmount !== "undefined") invoice.amount = newAmount;
- invoice.address_type = address_type;
- invoice.type = type;
- await update();
+ // Build the POST body from a snapshot + explicit changes. Mutating the
+ // $derived `invoice` proxy and POSTing it serializes unreliably, leaking
+ // stale type/amount from a prior interaction (see funk POS bug).
+ const body = { ...$state.snapshot(invoice), type, address_type };
+ if (typeof newAmount !== "undefined") body.amount = newAmount;
+ await update(body);
}
settingType = false;
};
@@ -93,13 +95,12 @@
if (typeof $amountPrompt === "undefined") $amountPrompt = true;
amount = newAmount;
- invoice.amount = newAmount;
- invoice.tip = 0;
+ const body = { ...$state.snapshot(invoice), amount: newAmount, tip: null };
- await update();
+ await update(body);
};
- let newAmount = $state();
+ let newAmount = $state(0);
let settingAmount = $state();
let fiat = $state(true);
let toggleAmount = () => (settingAmount = !settingAmount);
@@ -109,8 +110,8 @@
e.stopPropagation();
settingMemo = false;
- invoice.memo = memo;
- await update();
+ const body = { ...$state.snapshot(invoice), memo };
+ await update(body);
};
let memo = $state();
diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte
index a8dcd70b..258912d6 100644
--- a/src/routes/(app)/account/[id]/+page.svelte
+++ b/src/routes/(app)/account/[id]/+page.svelte
@@ -8,7 +8,7 @@
import { t } from "$lib/translations";
import { decrypt } from "nostr-tools/nip49";
import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
import {
PUBLIC_COINOS_PUBKEY as pk,
PUBLIC_COINOS_RELAY as relay,
diff --git a/src/routes/(app)/account/import/+page.svelte b/src/routes/(app)/account/import/+page.svelte
index b94cc839..10440357 100644
--- a/src/routes/(app)/account/import/+page.svelte
+++ b/src/routes/(app)/account/import/+page.svelte
@@ -8,7 +8,7 @@
import { browser } from "$app/environment";
import Icon from "$comp/Icon.svelte";
import { validateMnemonic } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
let el = $state(), pasted = $state(), text = $state();
diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte
index b75ac50f..3be7628d 100644
--- a/src/routes/(app)/account/pass/+page.svelte
+++ b/src/routes/(app)/account/pass/+page.svelte
@@ -11,7 +11,7 @@
mnemonicToEntropy,
entropyToMnemonic,
} from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
import { focus, versions, fail, post } from "$lib/utils";
import { goto } from "$app/navigation";
import { HDKey } from "@scure/bip32";
diff --git a/src/routes/(app)/account/seed/+page.svelte b/src/routes/(app)/account/seed/+page.svelte
index 91dfd213..9df891d6 100644
--- a/src/routes/(app)/account/seed/+page.svelte
+++ b/src/routes/(app)/account/seed/+page.svelte
@@ -6,7 +6,7 @@
import { t } from "$lib/translations";
import { mnemonic } from "$lib/store";
import { generateMnemonic } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
let { data } = $props();
let { user } = data;
diff --git a/src/routes/(app)/admin/+layout.server.ts b/src/routes/(app)/admin/+layout.server.ts
index d4aa181d..a2da1a90 100644
--- a/src/routes/(app)/admin/+layout.server.ts
+++ b/src/routes/(app)/admin/+layout.server.ts
@@ -1,10 +1,16 @@
-import { auth, get } from "$lib/utils";
+import { auth, get, isInvalidTokenError } from "$lib/utils";
import { redirect } from "@sveltejs/kit";
export async function load({ cookies }) {
const token = cookies.get("token");
if (!token) redirect(307, "/login");
- const user = await get("/me", auth(cookies));
+ let user;
+ try {
+ user = await get("/me", auth(cookies));
+ } catch (e) {
+ if (isInvalidTokenError(e)) redirect(307, "/logout");
+ throw e;
+ }
if (!user) redirect(307, "/login");
return { user };
diff --git a/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts b/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts
index 50be6986..e241930e 100644
--- a/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts
+++ b/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts
@@ -1,5 +1,5 @@
import getRates from "$lib/rates";
-import { get } from "$lib/utils";
+import { get, post } from "$lib/utils";
export async function load({ params, parent }) {
const { user } = await parent();
@@ -8,7 +8,16 @@ export async function load({ params, parent }) {
version ||= 4;
version = parseInt(version);
- const { token, status } = await get(`/cash/${id}/${version}`);
+ // A pasted token arrives in the URL itself and is checked statelessly;
+ // anything else is an id from the share links the server used to hand out.
+ let token: string;
+ let status: any;
+ if (id.startsWith("cashu")) {
+ token = id;
+ status = await post("/ecash/status", { token });
+ } else {
+ ({ token, status } = await get(`/cash/${id}/${version}`));
+ }
const { spent, total, mint, external } = status;
return {
id,
diff --git a/src/routes/(app)/flash/+page.server.ts b/src/routes/(app)/flash/+page.server.ts
new file mode 100644
index 00000000..1e3e1e99
--- /dev/null
+++ b/src/routes/(app)/flash/+page.server.ts
@@ -0,0 +1,58 @@
+import { PUBLIC_COINOS_URL } from "$env/static/public";
+import { auth, fd, get } from "$lib/utils";
+import { hex } from "@scure/base";
+import { fail } from "@sveltejs/kit";
+
+const LITTLEFS_SIZE = 0x20000;
+const LITTLEFS_MAGIC = "littlefs";
+
+// A valid image is exactly one partition long and carries the LittleFS
+// superblock magic at byte 8. Anything else (e.g. a JSON error body from
+// the server) must never reach the flasher.
+const isLittleFS = (buf: Uint8Array) =>
+ buf.length === LITTLEFS_SIZE &&
+ new TextDecoder().decode(buf.subarray(8, 8 + LITTLEFS_MAGIC.length)) ===
+ LITTLEFS_MAGIC;
+
+export async function load({ cookies }) {
+ let token;
+
+ try {
+ token = await get("/ro", auth(cookies));
+ } catch (e) {}
+
+ return { token };
+}
+
+export const actions = {
+ default: async ({ fetch, request }) => {
+ const body = await fd(request);
+ const res = await fetch(`${PUBLIC_COINOS_URL}/flash`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: {
+ "content-type": "application/json",
+ accept: "application/octet-stream",
+ },
+ });
+
+ const buf = new Uint8Array(await res.arrayBuffer());
+
+ if (!res.ok) {
+ let message = `Config server returned ${res.status}`;
+ try {
+ message = JSON.parse(new TextDecoder().decode(buf)).message || message;
+ } catch (e) {}
+ return fail(res.status >= 400 && res.status < 600 ? res.status : 500, {
+ error: message,
+ });
+ }
+
+ if (!isLittleFS(buf))
+ return fail(500, {
+ error: `Config server returned an invalid image (${buf.length} bytes)`,
+ });
+
+ return { bytes: hex.encode(buf) };
+ },
+};
diff --git a/src/routes/(app)/flash/+page.svelte b/src/routes/(app)/flash/+page.svelte
new file mode 100644
index 00000000..5a11d148
--- /dev/null
+++ b/src/routes/(app)/flash/+page.svelte
@@ -0,0 +1,360 @@
+<script>
+ import { enhance } from "$app/forms";
+ import { onDestroy } from "svelte";
+ import { ESPLoader, Transport } from "esptool-js";
+ import { hex } from "@scure/base";
+
+ // Convert Uint8Array → JS "binary string" in safe chunks
+ function bs(uint8Array) {
+ let binaryString = "";
+ const chunkSize = 0x8000;
+ for (let i = 0; i < uint8Array.length; i += chunkSize) {
+ const chunk = uint8Array.subarray(i, i + chunkSize);
+ binaryString += String.fromCharCode(...chunk);
+ }
+ return binaryString;
+ }
+
+ let { data, form } = $props();
+ let { token } = $state(data);
+
+ // --- Defaults ---
+ const LITTLEFS_ADDRESS_DEFAULT = 0x3d0000; // your existing LittleFS offset
+ const FW_ADDRESS_DEFAULT = 0x10000; // typical app offset on ESP32-C3
+ const CONNECT_BAUD = 115200; // initial
+ const WORK_BAUD = 921600; // fast flashing
+
+ // --- Shared state ---
+ let esploader = $state();
+ let chip, transport;
+ let connected = $state(false);
+ let portInfo = $state("");
+
+ // --- Existing config path (generated server-side to hex in form.bytes) ---
+ let bytes = $derived(form?.bytes ? hex.decode(form.bytes) : undefined);
+ let configError = $state("");
+ $effect(() => {
+ configError = form?.error || "";
+ });
+ let littlefsAddress = $state(LITTLEFS_ADDRESS_DEFAULT);
+ let configProgress = $state(0);
+ let configDone = $state(false);
+
+ // --- New firmware flashing path ---
+ let fwFile = $state(null);
+ let fwBytes = $state(); // Uint8Array
+ let fwAddress = $state(FW_ADDRESS_DEFAULT);
+ let fwEraseAll = $state(false);
+ let fwProgress = $state(0);
+ let fwDone = $state(false);
+ let fwError = $state("");
+
+ // --- Latest published release (see coinos-pos/publish.sh) ---
+ /** @type {any} */
+ let release = $state(null); // manifest.json contents
+ let releaseError = $state("");
+ $effect(() => {
+ fetch("/firmware/manifest.json", { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
+ .then((m) => (release = m))
+ .catch((e) => (releaseError = `No published release: ${e.message}`));
+ });
+
+ // Simple tab toggle
+ let tab = $state("config"); // "config" | "firmware"
+
+ let connect = async () => {
+ try {
+ const device = await navigator.serial.requestPort({});
+ transport = new Transport(device, true);
+ esploader = new ESPLoader({
+ transport,
+ baudrate: CONNECT_BAUD,
+ });
+
+ chip = await esploader.main();
+
+ connected = true;
+ portInfo = (await device.getInfo?.()) ? JSON.stringify(await device.getInfo()) : "Connected";
+ } catch (e) {
+ console.log(e);
+ // A failed connect must not leave the port claimed: the browser keeps
+ // it open until someone calls close(), and the chip may already be
+ // sitting in the ROM download mode.
+ await resetDevice();
+ }
+ };
+
+ // Close the serial port and forget it. The browser holds a port open until
+ // it is explicitly closed — navigating away does not release it.
+ let releasePort = async () => {
+ try {
+ await transport?.disconnect();
+ } catch (e) {}
+ transport = undefined;
+ esploader = undefined;
+ connected = false;
+ };
+
+ // Pulse RTS to hard-reset the chip out of the ROM bootloader so it boots
+ // the app with the new flash contents. Same sequence esptool.py uses
+ // ("Hard resetting via RTS pin"); on the C3's USB-JTAG-serial port RTS
+ // drives EN, so the device re-enumerates and must be reconnected.
+ let resetDevice = async () => {
+ if (!transport) return;
+ try {
+ await transport.setDTR(false);
+ await transport.setRTS(true);
+ await new Promise((r) => setTimeout(r, 100));
+ await transport.setRTS(false);
+ } catch (e) {
+ console.warn("reset failed", e);
+ }
+ await releasePort();
+ portInfo = "Device reset. Reconnect to flash again.";
+ };
+
+ // Connecting parks the chip in the ROM download mode (esptool stub loaded,
+ // app not running). Until it is hard-reset the OLED keeps whatever the app
+ // last drew and the keypad is dead, so it looks frozen rather than
+ // "in flashing mode". Leaving the page — SPA navigation, reload, or tab
+ // close — must therefore reset the device and release the port; neither
+ // happens on its own.
+ onDestroy(() => {
+ resetDevice();
+ });
+ $effect(() => {
+ const onHide = () => resetDevice();
+ window.addEventListener("pagehide", onHide);
+ return () => window.removeEventListener("pagehide", onHide);
+ });
+
+ let flashConfig = async () => {
+ if (!esploader || !bytes) return;
+ if (bytes.length !== 0x20000) {
+ configError = `Refusing to flash: image is ${bytes.length} bytes, expected ${0x20000}`;
+ return;
+ }
+ configDone = false;
+ configProgress = 0;
+
+ const fileArray = [{ data: bs(bytes), address: Number(littlefsAddress) }];
+
+ await esploader.writeFlash({
+ fileArray,
+ flashSize: "keep",
+ eraseAll: false,
+ compress: true,
+ reportProgress: (_fileIndex, written, total) => {
+ configProgress = Math.floor((written / total) * 100);
+ },
+ });
+
+ configDone = true;
+ await resetDevice();
+ };
+
+ function onPickFw(e) {
+ fwError = "";
+ fwDone = false;
+ const f = e.target.files?.[0];
+ fwFile = f || null;
+ if (!fwFile) {
+ fwBytes = undefined;
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ const buf = new Uint8Array(reader.result);
+ fwBytes = buf;
+ };
+ reader.onerror = () => {
+ fwError = "Failed to read file.";
+ fwBytes = undefined;
+ };
+ reader.readAsArrayBuffer(fwFile);
+ }
+
+ let flashFirmware = async () => {
+ if (!esploader) {
+ fwError = "Not connected.";
+ return;
+ }
+ if (!fwBytes?.length) {
+ fwError = "No firmware selected.";
+ return;
+ }
+ fwError = "";
+ fwDone = false;
+ fwProgress = 0;
+
+ try {
+ const fileArray = [{ data: bs(fwBytes), address: Number(fwAddress) }];
+
+ await esploader.writeFlash({
+ fileArray,
+ flashSize: "keep",
+ eraseAll: Boolean(fwEraseAll),
+ compress: true,
+ reportProgress: (_fileIndex, written, total) => {
+ fwProgress = Math.floor((written / total) * 100);
+ },
+ });
+
+ fwDone = true;
+ await resetDevice();
+ } catch (e) {
+ console.error(e);
+ fwError = e?.message || "Flash failed.";
+ }
+ };
+
+ // Flash every part of the published release at its offset (bootloader,
+ // partition table, OTA data, app). The LittleFS config partition is not
+ // part of the manifest, so device credentials survive.
+ let flashRelease = async () => {
+ if (!esploader) {
+ fwError = "Not connected.";
+ return;
+ }
+ if (!release?.parts?.length) {
+ fwError = "No release available.";
+ return;
+ }
+ fwError = "";
+ fwDone = false;
+ fwProgress = 0;
+
+ try {
+ const fileArray = [];
+ for (const part of release.parts) {
+ const r = await fetch(`${part.path}?v=${encodeURIComponent(release.version)}`, { cache: "no-store" });
+ if (!r.ok) throw new Error(`Download failed: ${part.path} (HTTP ${r.status})`);
+ const buf = new Uint8Array(await r.arrayBuffer());
+ if (part.size && buf.length !== part.size)
+ throw new Error(`Size mismatch for ${part.path}: got ${buf.length}, expected ${part.size}`);
+ fileArray.push({ data: bs(buf), address: Number(part.address) });
+ }
+
+ const totals = fileArray.map((f) => f.data.length);
+ const grand = totals.reduce((a, b) => a + b, 0);
+ await esploader.writeFlash({
+ fileArray,
+ flashSize: "keep",
+ eraseAll: false,
+ compress: true,
+ reportProgress: (fileIndex, written, total) => {
+ const before = totals.slice(0, fileIndex).reduce((a, b) => a + b, 0);
+ fwProgress = Math.floor(((before + (written / total) * totals[fileIndex]) / grand) * 100);
+ },
+ });
+
+ fwDone = true;
+ await resetDevice();
+ } catch (e) {
+ console.error(e);
+ fwError = e?.message || "Flash failed.";
+ }
+ };
+</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">
+ ESP32-C3 Flasher
+ </h1>
+
+ {#if !connected}
+ <div class="text-center">
+ {#if configDone}
+ <div class="text-2xl mb-3">Config written ✔ Device restarted</div>
+ {:else if fwDone}
+ <div class="text-2xl mb-3">Firmware written ✔ Device restarted</div>
+ {/if}
+ <button class="btn" onclick={connect}>Connect</button>
+ <div class="text-sm mt-2 opacity-70">{portInfo}</div>
+ </div>
+{/if}
+
+ {#if connected}
+ <!-- Tabs -->
+ <div class="flex gap-2 justify-center">
+ <button class="btn" class:btn-neutral={tab === "config"} onclick={() => (tab = "config")}>Flash config</button>
+ <button class="btn" class:btn-neutral={tab === "firmware"} onclick={() => (tab = "firmware")}>Flash firmware</button>
+ <button class="btn" onclick={resetDevice} title="Reset the device into its app and release the serial port">Disconnect</button>
+ </div>
+
+ {#if tab === "config"}
+ <!-- Existing CONFIG FLOW -->
+ {#if bytes}
+ {#if configDone}
+ <div class="text-center text-2xl">Config written ✔</div>
+ {:else}
+ <div>Generated config file (<b>{bytes.length} bytes</b>)</div>
+ <label class="label mt-2">LittleFS address (hex)</label>
+ <input class="input" bind:value={littlefsAddress} onchange={(e) => (littlefsAddress = Number(e.target.value))} />
+ <div class="mt-3">
+ <button class="btn" onclick={flashConfig}>Flash config</button>
+ </div>
+ {#if configProgress > 0}<div class="mt-2">Progress: {configProgress}%</div>{/if}
+ {/if}
+ {:else}
+ {#if configError}
+ <div class="text-red-600 mb-2">{configError}</div>
+ {/if}
+ <form method="POST" use:enhance class="space-y-2">
+ <input name="ssid" placeholder="Wifi SSID" class="input" />
+ <input name="key" placeholder="Wifi password" class="input" />
+ <input name="token" bind:value={token} placeholder="Coinos API token" class="input" />
+ <button type="submit" class="btn">Generate Config</button>
+ </form>
+ {/if}
+ {:else}
+ <!-- NEW FIRMWARE FLOW -->
+ <div class="space-y-3">
+ {#if release}
+ <div class="p-3 rounded-lg border border-current/20 space-y-2">
+ <div class="font-semibold">Latest release: {release.version}</div>
+ <div class="text-sm opacity-70">Built {release.built} · flashes bootloader, partitions and app; keeps your wifi/token config</div>
+ <button class="btn" onclick={flashRelease}>Flash latest release</button>
+ </div>
+ {:else if releaseError}
+ <div class="text-sm opacity-70">{releaseError}</div>
+ {/if}
+
+ <label class="label">Or select a .ino.bin (firmware)</label>
+ <input type="file" accept=".bin,application/octet-stream" class="input" onchange={onPickFw} />
+
+ <label class="label">Firmware address (hex, default 0x10000)</label>
+ <input class="input" bind:value={fwAddress} onchange={(e) => (fwAddress = Number(e.target.value))} />
+
+ <label class="flex items-center gap-2 mt-2">
+ <input type="checkbox" bind:checked={fwEraseAll} />
+ <span>Erase all flash (usually NOT needed)</span>
+ </label>
+
+ <div class="mt-3">
+ <button class="btn" disabled={!fwBytes} onclick={flashFirmware}>Flash firmware</button>
+ </div>
+
+ {#if fwProgress > 0 && !fwDone}
+ <div>Progress: {fwProgress}%</div>
+ {/if}
+ {#if fwDone}
+ <div class="text-center text-2xl">Firmware written ✔</div>
+ {/if}
+ {#if fwError}
+ <div class="text-red-600">{fwError}</div>
+ {/if}
+ </div>
+ {/if}
+ {/if}
+</div>
+
+<style>
+ @reference "../../../app.css";
+
+ .btn { @apply px-4 py-2 rounded-2xl shadow; }
+ .btn-neutral { @apply bg-gray-200; }
+ .input { @apply w-full px-3 py-2 border rounded-xl; }
+ .label { @apply text-sm opacity-70; }
+</style>
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 f1a1838e..a22e4b93 100644
--- a/src/routes/(app)/fund/[id]/+page.svelte
+++ b/src/routes/(app)/fund/[id]/+page.svelte
@@ -79,32 +79,6 @@
</a>
{/if}
</div>
- <div class="flex flex-wrap gap-2" data-sveltekit-prefetch="off">
- <div class="grow">
- <a href={`/send/fund/${id}`}>
- <button class="btn">
- <iconify-icon noobserver icon="ph:plus-bold" width="32"
- ></iconify-icon>
- {$t("payments.addFunds")}
- </button>
- </a>
- </div>
- {#if !managers.length || managers.some((m) => m.id === user?.id)}
- <div class="grow">
- <a href={`/fund/${id}/withdraw`}>
- <button class="btn">
- <iconify-icon
- noobserver
- icon="ph:hand-coins-bold"
- width="32"
- flip="horizontal"
- ></iconify-icon>
- {$t("payments.takeFunds")}
- </button>
- </a>
- </div>
- {/if}
- </div>
<div class="flex gap-2" data-sveltekit-prefetch="off">
<a href={`/qr/${href}`} class="btn !w-auto grow">
<iconify-icon noobserver icon="ph:link-bold" width="32"
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 a2ea9dff..cc2921c5 100644
--- a/src/routes/(app)/fund/[id]/access/+page.svelte
+++ b/src/routes/(app)/fund/[id]/access/+page.svelte
@@ -6,10 +6,15 @@
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>
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..8cc463c2
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts
@@ -0,0 +1,56 @@
+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";
+import { env } from "$env/dynamic/private";
+
+export async function load({ cookies, request, params, parent }) {
+ let { user } = await parent();
+ const { id } = params;
+ let [amount, currency] = params.amount.split("/");
+
+ 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}`;
+
+ const extraHeaders: Record<string, string> = {};
+ if (env.INTERNAL_API_KEY)
+ extraHeaders["x-api-key"] = env.INTERNAL_API_KEY;
+
+ await register(user, ip, cookies, redirectUrl, undefined, extraHeaders);
+ }
+
+ 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..faabb753 100644
--- a/src/routes/(app)/invoice/[id]/+layout.server.ts
+++ b/src/routes/(app)/invoice/[id]/+layout.server.ts
@@ -8,32 +8,60 @@ export async function load({ cookies, depends, params, url, parent }) {
let { subject, user } = await parent();
const { id } = params;
let invoice;
+ // Amount of a payment that arrived while the page was open, set by the
+ // socket handler. Only meaningful for reusable (bolt12) offers, whose
+ // `received` is a lifetime total rather than this payment.
+ let justPaid: string | null = null;
if (id) {
invoice = await get(`/invoice/${id}`);
const options = !!url.searchParams.get("options");
+ let { amount, pending, received } = invoice;
+ amount = parseInt(amount);
+
+ // A bolt12 offer is a standing, REUSABLE payment code: CLN derives the same
+ // offer_id from the same (amount, description), so re-creating one hands back
+ // the original record with `received` accumulated over its entire lifetime.
+ // Treating that as "paid" bounces the user straight to the success screen so
+ // they never get to see the QR or copy the lno — and a long-lived offer that
+ // takes regular payouts is effectively unusable, since its last payment may
+ // be hours or months old. A standing offer has no terminal paid state, so
+ // only a payment arriving live counts — the socket handler signals that
+ // with `?paid=<amount>`.
+ const reusable = invoice.type === "bolt12";
+ justPaid = url.searchParams.get("paid");
+
+ const paid = reusable
+ ? !!justPaid
+ : (!amount && (pending || received)) ||
+ (amount > 0 && (pending >= amount || received >= amount));
+
+ if (paid && !url.pathname.endsWith("paid")) {
+ const q = new URLSearchParams();
+ if (options) q.set("options", "true");
+ // Carry the amount of THIS payment through; the success screen would
+ // otherwise render lifetime `received` for a reusable offer.
+ if (justPaid) q.set("paid", justPaid);
+ const qs = q.toString();
+ redirect(307, `/invoice/${id}/paid${qs ? `?${qs}` : ""}`);
+ }
+
+ subject = invoice.user;
if (
user &&
- invoice.uid !== user.id &&
+ 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)) ||
- (amount > 0 && (pending >= amount || received >= amount));
- if (paid && !url.pathname.endsWith("paid")) {
- redirect(307, `/invoice/${id}/paid` + (options ? "?options=true" : ""));
+ if (invoice.prompt && invoice.tip === null)
+ redirect(307, `/invoice/${id}/tip`);
+ else {
+ redirect(307, `/send/invoice/${id}`);
+ }
}
}
const theme = cookies.get("theme") || "light";
- return { id, invoice, subject, user, token, theme };
+ return { id, invoice, subject, user, token, theme, justPaid };
}
diff --git a/src/routes/(app)/invoice/[id]/+page.svelte b/src/routes/(app)/invoice/[id]/+page.svelte
index 0cb5aeac..a2a299bd 100644
--- a/src/routes/(app)/invoice/[id]/+page.svelte
+++ b/src/routes/(app)/invoice/[id]/+page.svelte
@@ -56,22 +56,24 @@
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 () => {
+ let update = async (body) => {
try {
({ id } = await post(`/invoice`, {
- invoice,
+ invoice: body,
user: { username, currency },
}));
@@ -92,10 +94,12 @@
if (type === types.lightning && !amount && typeof newAmount === "undefined")
goto(`/${username}/receive`, { invalidateAll: true, noScroll: true });
else {
- if (typeof newAmount !== "undefined") invoice.amount = newAmount;
- invoice.address_type = address_type;
- invoice.type = type;
- await update();
+ // Build the POST body from a snapshot + explicit changes. Mutating the
+ // $derived `invoice` proxy and POSTing it serializes unreliably, leaking
+ // stale type/amount/hash from a prior interaction (see funk POS bug).
+ const body = { ...$state.snapshot(invoice), type, address_type };
+ if (typeof newAmount !== "undefined") body.amount = newAmount;
+ await update(body);
}
settingType = false;
};
@@ -111,10 +115,9 @@
settingAmount = false;
if (typeof $amountPrompt === "undefined") $amountPrompt = true;
- invoice.amount = newAmount;
- invoice.tip = 0;
+ const body = { ...$state.snapshot(invoice), amount: newAmount, tip: 0 };
- await update();
+ await update(body);
};
let setMemo = async (e) => {
@@ -122,8 +125,8 @@
e.stopPropagation();
settingMemo = false;
- invoice.memo = memo;
- await update();
+ const body = { ...$state.snapshot(invoice), memo };
+ await update(body);
};
let settingMemo = $state();
diff --git a/src/routes/(app)/invoice/[id]/paid/+page.svelte b/src/routes/(app)/invoice/[id]/paid/+page.svelte
index bc61d7fe..8e5bda27 100644
--- a/src/routes/(app)/invoice/[id]/paid/+page.svelte
+++ b/src/routes/(app)/invoice/[id]/paid/+page.svelte
@@ -10,6 +10,20 @@
);
let locale = $derived(loc(user));
+ // `received` is the invoice's running total. For a reusable bolt12 offer that
+ // is the LIFETIME figure across every payment the offer has ever taken, so
+ // showing it here would announce the accumulated total rather than the payment
+ // that just arrived. For those, show the amount the socket reported — it is
+ // already net of tip, the same basis as `received - tip`. Single-use invoices
+ // keep using `received` so a top-up onto a partly-paid invoice still totals
+ // correctly.
+ let reusable = $derived(data.invoice?.type === "bolt12");
+ let settled = $derived(
+ reusable && data.justPaid
+ ? Number.parseInt(data.justPaid) + (tip ?? 0)
+ : received,
+ );
+
// toast.pop(0);
</script>
@@ -27,7 +41,7 @@
<Amount amount={pending - tip} {tip} {rate} {currency} {locale} />
{:else}
<Success
- amount={received - tip}
+ amount={settled - tip}
{rate}
{tip}
{currency}
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 fbacb2df..40cf337a 100644
--- a/src/routes/(app)/invoices/+page.svelte
+++ b/src/routes/(app)/invoices/+page.svelte
@@ -1,35 +1,118 @@
<script>
+ import { copy, loc, s, types } from "$lib/utils";
+ import { t } from "$lib/translations";
+
let { data } = $props();
- let { invoices } = $derived(data);
- let show = $state({});
- let display = (id) => (show[id] = true);
+ let { invoices, user } = $derived(data);
+ let locale = $derived(loc(user));
+
+ let open = $state({});
+ let toggle = (idx) => (open[idx] = !open[idx]);
+
+ let memoText = (memo) => {
+ if (!memo?.startsWith("[")) return memo;
+ try {
+ return JSON.parse(memo)[0][1];
+ } catch {
+ return memo;
+ }
+ };
</script>
-<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="container w-full mx-auto px-2 py-4 max-w-3xl">
+ <h1 class="text-2xl font-bold mb-4 px-2">{$t("invoice.invoice")}</h1>
+
+ <div
+ class="flex items-center gap-3 px-2 py-2 text-secondary text-sm font-medium border-b border-base-200"
+ >
+ <div class="w-24 sm:w-28 shrink-0">{$t("payments.type")}</div>
+ <div class="flex-1 min-w-0">{$t("invoice.invoice")}</div>
+ <div class="shrink-0 text-right">
+ {$t("payments.received")} / {$t("payments.amount")}
+ </div>
+ <div class="w-[18px] shrink-0"></div>
+ </div>
+
+ {#each invoices as i, idx}
+ {@const memo = memoText(i.memo)}
<div
- class="grid md:grid-cols-12 w-full text-center text-lg break-all gap-x-2"
+ class="flex gap-3 border-b border-base-200 hover:bg-base-200 px-2 py-3 cursor-pointer select-none"
+ class:items-center={!open[idx]}
+ class:items-start={open[idx]}
+ onclick={() => toggle(idx)}
>
- {#each invoices as i}
- <div class="md:col-span-2 whitespace-nowrap mt-8 md:mt-0">{i.type}</div>
- {#if show[i.id]}
- <div class="md:col-span-8">{i.hash}</div>
- {:else}
+ <div class="w-24 sm:w-28 shrink-0 flex items-center gap-2 min-w-0">
+ {#if i.type === types.lightning || i.type === types.bolt12}
+ <iconify-icon
+ noobserver
+ icon="ph:lightning-fill"
+ width="22"
+ class="text-yellow-300 shrink-0"
+ ></iconify-icon>
+ {:else if i.type === types.bitcoin}
+ <iconify-icon
+ noobserver
+ icon="logos:bitcoin"
+ width="22"
+ class="shrink-0"
+ ></iconify-icon>
+ {:else if i.type === types.liquid}
+ <img src="/images/liquid.svg" class="w-[22px] shrink-0" alt="Liquid" />
+ {:else if i.type === types.ecash}
+ <img src="/images/cash.png" class="w-[22px] shrink-0" alt="Ecash" />
+ {/if}
+ <span class="truncate text-sm">{i.type}</span>
+ </div>
+
+ <div class="flex-1 min-w-0">
+ <div
+ class="font-mono text-sm"
+ class:truncate={!open[idx]}
+ class:break-all={open[idx]}
+ >
+ {i.hash}
+ </div>
+ {#if memo}
<div
- class="cursor-pointer grow md:col-span-6"
- onclick={() => display(i.id)}
+ class="text-secondary text-xs mt-0.5"
+ class:truncate={!open[idx]}
+ class:break-all={open[idx]}
>
- {i.hash.substr(0, 12)}
- {#if !show[i.id]}...{/if}
+ {memo}
</div>
{/if}
- <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}
+ {#if open[idx]}
+ <button
+ type="button"
+ class="mt-2 inline-flex items-center gap-1 text-primary text-xs font-medium hover:underline"
+ onclick={(e) => {
+ e.stopPropagation();
+ copy(i.hash);
+ }}
+ >
+ <iconify-icon noobserver icon="ph:copy" width="16"></iconify-icon>
+ {$t("payments.copy")}
+ </button>
+ {/if}
+ </div>
+
+ <div class="shrink-0 text-right whitespace-nowrap tabular-nums">
+ <span class="text-secondary">{s(i.received, locale)}</span>
+ <span class="text-secondary"> / </span>
+ {s(i.amount, locale)}
+ </div>
+
+ <iconify-icon
+ noobserver
+ icon="ph:caret-down"
+ width="18"
+ class="shrink-0 text-secondary transition-transform"
+ class:rotate-180={open[idx]}
+ ></iconify-icon>
</div>
- </div>
+ {:else}
+ <p class="text-secondary text-lg text-center py-12">
+ {$t("payments.empty")}
+ </p>
+ {/each}
</div>
diff --git a/src/routes/(app)/ln/[lnurl]/+page.server.ts b/src/routes/(app)/ln/[lnurl]/+page.server.ts
index 405dc4b3..04cff338 100644
--- a/src/routes/(app)/ln/[lnurl]/+page.server.ts
+++ b/src/routes/(app)/ln/[lnurl]/+page.server.ts
@@ -1,39 +1,69 @@
+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 lnurlFetch = async (url: string) =>
+ get(`/lnurl/proxy?url=${encodeURIComponent(url)}`);
+
+export async function load({ cookies, params, parent }) {
const { user } = await parent();
- if (!user) redirect(307, "/register");
const rates = await getRates();
let data;
- try {
- const { lnurl } = params;
- data = await get(`/decode?text=${lnurl}`);
- 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 { lnurl } = params;
- let url = `${callback}?amount=${amount * 1000}`;
- if (comment) url += `&comment=${comment}`;
+ const url = Buffer.from(fromWords(decode(lnurl, 20000).words)).toString();
- const { pr } = await fetch(url).then((r) => r.json());
- let path = `/send/lightning/${pr}`;
- if (comment) path += `/${encodeURIComponent(comment)}`;
- redirect(307, path);
- }
+ try {
+ 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 lnurlFetch(url);
+
+ 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 (url.startsWith(`https://${PUBLIC_DOMAIN}/`)) {
+ const username = url.split(`https://${PUBLIC_DOMAIN}/p/`)[1];
+ if (username) 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"];
+ // Lightning sends draw on the main account; the Max button needs its balance
+ if (user && data.tag === "payRequest") {
+ try {
+ ({ balance: data.balance } = await get(`/account/${user.id}`, auth(cookies)));
+ } catch (e) {}
+ }
return data;
}
@@ -52,10 +82,60 @@ export const actions = {
error = `Amount must be at most ${maxSendable} sats`;
if (error) return fail(400, { error });
- let url = `${callback}?amount=${amount * 1000}`;
- if (comment) url += `&comment=${comment}`;
+ 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 lnurlFetch(url);
+
+ let path = `/send/lightning/${pr}`;
+ if (comment) path += `/${encodeURIComponent(comment)}`;
+ redirect(307, path);
+ },
+
+ // Send everything to an LNURL-pay endpoint. The routing cost depends on the
+ // amount and the amount on the routing cost, and the invoice only exists
+ // once we name an amount — so fetch a probe invoice at the most we could
+ // possibly send (its destination and route hints are what the quote needs),
+ // let the server solve for the amount that lands the balance on zero, then
+ // fetch the real invoice for exactly that amount.
+ max: async ({ cookies, request }) => {
+ let { callback, minSendable, maxSendable, comment } = await fd(request);
+ minSendable = Math.round(minSendable / 1000);
+ maxSendable = Math.round(maxSendable / 1000);
+
+ const user = await get("/me", auth(cookies));
+ const { balance } = await get(`/account/${user.id}`, auth(cookies));
+ const ceiling = Math.min(balance, maxSendable);
+ if (ceiling < minSendable)
+ return fail(400, { error: `Amount must be at least ${minSendable} sats` });
+
+ const invoiceFor = async (amount: number) => {
+ const urlObj = new URL(callback);
+ urlObj.searchParams.set("amount", (amount * 1000).toString());
+ if (comment) urlObj.searchParams.set("comment", comment);
+ const { pr, reason } = await lnurlFetch(urlObj.toString());
+ if (!pr) throw new Error(reason || "Could not fetch invoice");
+ return pr;
+ };
+
+ let pr;
+ try {
+ const probe = await invoiceFor(ceiling);
+ const { amount } = await post(
+ "/lightning/quote",
+ { payreq: probe, max: true, ceiling },
+ auth(cookies),
+ );
+ if (amount < minSendable)
+ return fail(400, { error: `Amount must be at least ${minSendable} sats` });
+ pr = await invoiceFor(amount);
+ } catch (e) {
+ const { error } = e as Error & { error?: string };
+ return fail(400, { error: (e as Error).message || error });
+ }
- const { pr } = await fetch(url).then((r) => r.json());
let path = `/send/lightning/${pr}`;
if (comment) path += `/${encodeURIComponent(comment)}`;
redirect(307, path);
@@ -96,7 +176,7 @@ export const actions = {
url.searchParams.append("k1", k1);
url.searchParams.append("pr", pr);
- await fetch(url.toString());
+ await lnurlFetch(url.toString());
redirect(307, "/payments");
},
diff --git a/src/routes/(app)/ln/[lnurl]/+page.svelte b/src/routes/(app)/ln/[lnurl]/+page.svelte
index 386df9d2..0f1b030f 100644
--- a/src/routes/(app)/ln/[lnurl]/+page.svelte
+++ b/src/routes/(app)/ln/[lnurl]/+page.svelte
@@ -1,12 +1,14 @@
<script>
import Lnurlp from "$comp/Lnurlp.svelte";
import Lnurlw from "$comp/Lnurlw.svelte";
- let { data } = $props();
+ let { data, form } = $props();
let { tag } = data;
</script>
+<!-- form carries the action result; without it a failed pay/max/withdraw
+ showed nothing and left the button spinning forever -->
{#if tag === "payRequest"}
- <Lnurlp {data} />
+ <Lnurlp {data} {form} />
{:else if tag === "withdrawRequest"}
- <Lnurlw {data} />
+ <Lnurlw {data} {form} />
{/if}
diff --git a/src/routes/(app)/messages/[recipient]/+page.svelte b/src/routes/(app)/messages/[recipient]/+page.svelte
index d1cf6f94..d383d576 100644
--- a/src/routes/(app)/messages/[recipient]/+page.svelte
+++ b/src/routes/(app)/messages/[recipient]/+page.svelte
@@ -185,6 +185,8 @@
</div>
<style>
+ @reference "../../../../app.css";
+
.ours {
@apply bg-gradient-to-r from-[#F2F6FC] to-[#E1E3FF] text-black rounded-br-none ml-auto;
}
diff --git a/src/routes/(app)/migrate/+page.server.ts b/src/routes/(app)/migrate/+page.server.ts
new file mode 100644
index 00000000..6559485f
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.server.ts
@@ -0,0 +1,106 @@
+import { auth, post } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
+import { env } from "$env/dynamic/private";
+
+const REGISTRAR = "https://names.coinos.io";
+
+// Tell the v3 registrar who may claim the released name. This session — the
+// old account's authenticated login — is the proof of ownership; the
+// registrar's own claim guard otherwise only accepts a claim signed by the
+// old account's nostr key, which a v3 wallet never has.
+async function grantMigration(username: string, to: string) {
+ if (!env.MIGRATE_TOKEN) throw new Error("migration hand-off isn't configured");
+ let lastErr;
+ for (let i = 0; i < 3; i++) {
+ try {
+ const r = await fetch(`${REGISTRAR}/migrate-grant`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${env.MIGRATE_TOKEN}` },
+ body: JSON.stringify({ name: username, to }),
+ });
+ if (r.ok) return;
+ lastErr = new Error((await r.json().catch(() => null))?.error || `registrar said ${r.status}`);
+ } catch (e) {
+ lastErr = e;
+ }
+ await new Promise((res) => setTimeout(res, 1500));
+ }
+ throw lastErr;
+}
+
+// An invoice from the v3 registrar for this address and amount (LNURL-pay).
+async function invoiceFor(address: string, sats: number) {
+ const [name, domain] = String(address).split("@");
+ if (!name || !domain) throw new Error("bad destination address");
+ const p = await fetch(
+ `${REGISTRAR}/.well-known/lnurlp/${name}?domain=${encodeURIComponent(domain)}`,
+ ).then((r) => r.json());
+ if (!p?.callback) throw new Error(p?.reason || "that address isn't registered on coinos v3 yet");
+ const msat = sats * 1000;
+ if (msat < p.minSendable) throw new Error("balance is below the destination's minimum");
+ if (msat > p.maxSendable) throw new Error("balance is above the destination's maximum");
+ const sep = p.callback.includes("?") ? "&" : "?";
+ const inv = await fetch(`${p.callback}${sep}amount=${msat}`).then((r) => r.json());
+ if (!inv?.pr) throw new Error(inv?.reason || "could not get an invoice");
+ return inv.pr as string;
+}
+
+export async function load({ parent, url }) {
+ const { user } = await parent();
+ if (!user) redirect(307, `/login?redirect=${encodeURIComponent(url.pathname + url.search)}`);
+ return {
+ to: url.searchParams.get("to") || "",
+ back: url.searchParams.get("back") || "",
+ };
+}
+
+export const actions = {
+ default: async ({ cookies, request, url }) => {
+ const form = await request.formData();
+ const to = String(form.get("to") || url.searchParams.get("to") || "");
+ const pin = String(form.get("pin") || "");
+ const balance = Number(form.get("balance") || 0);
+ const username = String(form.get("username") || "");
+ if (!to) return { error: "no destination" };
+
+ let sent = 0;
+ try {
+ if (balance > 0) {
+ // coinos and the ASP peer directly, so a sweep normally costs
+ // nothing. Try the whole balance first and only hold a little
+ // back if the payment actually comes up short.
+ const attempts = [balance, balance - Math.max(1, Math.ceil(balance * 0.005))];
+ let lastErr;
+ for (const amount of attempts) {
+ if (amount <= 0) continue;
+ try {
+ const payreq = await invoiceFor(to, amount);
+ await post("/payments", { payreq, ...(pin ? { pin } : {}) }, auth(cookies));
+ sent = amount;
+ break;
+ } catch (e) {
+ lastErr = e;
+ const msg = String((e as any)?.message || e);
+ // only a shortfall is worth retrying smaller. The server's debit
+ // error is in the user's language ("Unzureichende Mittel ⚡️20582 /
+ // 20600"), so match its numeric tail rather than just the words.
+ if (!/insufficient|balance|not enough|fee|⚡\uFE0F?\s*\d+\s*(\/|of)\s*⚡?\uFE0F?\s*\d+/i.test(msg))
+ throw e;
+ }
+ }
+ if (!sent) throw lastErr;
+ }
+ // Hand the name over for receiving. The account itself stays exactly
+ // as it is — same username, same history, still usable here; the
+ // registrar just becomes the answer for incoming payments.
+ await post("/user", { migrated: true, ...(pin ? { pin } : {}) }, auth(cookies));
+ // …and tell the registrar which v3 identity the name now belongs
+ // to, or their claim over there can never pass the squat guard.
+ await grantMigration(username, to);
+ return { ok: true, sent, released: username };
+ } catch (e) {
+ const msg = e && typeof e === "object" && "message" in e ? String(e.message) : String(e);
+ return { error: msg, sent };
+ }
+ },
+};
diff --git a/src/routes/(app)/migrate/+page.svelte b/src/routes/(app)/migrate/+page.svelte
new file mode 100644
index 00000000..e246b1e8
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.svelte
@@ -0,0 +1,82 @@
+<script>
+ // Migrate a legacy coinos account to coinos v3.
+ //
+ // v3 sends the user here with ?to=<their new payment address>&back=<url>.
+ // They're already signed in here, so no password or key has to travel:
+ // the form action (server side, with their session cookie) sweeps the
+ // balance over Lightning and then releases the username, which is what
+ // lets v3's registrar hand it to them.
+ import { enhance } from "$app/forms";
+
+ let { data, form } = $props();
+ let { user, to, back } = $derived(data);
+
+ let working = $state(false);
+ let balance = $derived(user?.balance || 0);
+ let backUrl = $derived.by(() => {
+ try {
+ const u = new URL(back || "https://v3.coinos.io/");
+ u.searchParams.set("migrated", user.username);
+ return u.toString();
+ } catch {
+ return "https://v3.coinos.io/";
+ }
+ });
+</script>
+
+<div class="max-w-md mx-auto p-4 space-y-5">
+ <h1 class="text-2xl font-bold">Move your account to coinos v3</h1>
+
+ {#if !to}
+ <p class="text-red-600">This page needs a destination — start the move from coinos v3.</p>
+ {:else if form?.ok}
+ <div class="space-y-3">
+ <p class="text-xl">✓ All done!</p>
+ {#if form.sent}
+ <p>{form.sent.toLocaleString()} sats are on their way to your new wallet.</p>
+ {/if}
+ <p>
+ <strong>{form.released}@coinos.io</strong> now receives into your coinos v3 wallet.
+ </p>
+ <a class="btn btn-primary w-full" href={backUrl}>Back to coinos v3</a>
+ </div>
+ {:else}
+ <p>
+ This sends your balance to your new wallet and points
+ <strong>{user.username}@coinos.io</strong> at it, so payments to your address arrive
+ there from now on. This account stays exactly as it is — same name, same history —
+ you just receive on coinos v3.
+ </p>
+
+ <div class="rounded-xl border p-3">
+ <div class="flex justify-between"><span>Balance</span><strong>{balance.toLocaleString()} sats</strong></div>
+ </div>
+
+ {#if form?.error}<p class="text-red-600">{form.error}</p>{/if}
+
+ <form
+ method="POST"
+ use:enhance={() => {
+ working = true;
+ return async ({ update }) => {
+ await update({ reset: false });
+ working = false;
+ };
+ }}
+ class="space-y-3"
+ >
+ <input type="hidden" name="to" value={to} />
+ <input type="hidden" name="balance" value={balance} />
+ <input type="hidden" name="username" value={user.username} />
+ {#if user?.haspin}
+ <input class="input input-bordered w-full" type="password" inputmode="numeric"
+ name="pin" placeholder="Your PIN" />
+ {/if}
+ <button class="btn btn-primary w-full" disabled={working}>
+ {#if working}Migrating…{:else}Migrate{/if}
+ </button>
+ </form>
+
+ <p class="text-sm opacity-60">You can also do nothing — your old account keeps working as it is.</p>
+ {/if}
+</div>
diff --git a/src/routes/(app)/ecash/mint/+server.ts b/src/routes/(app)/nsec/+server.ts
index 02170650..ceead0d5 100644
--- a/src/routes/(app)/ecash/mint/+server.ts
+++ b/src/routes/(app)/nsec/+server.ts
@@ -1,7 +1,7 @@
-import { auth, post } from "$lib/utils";
import { json } from "@sveltejs/kit";
+import { auth, post } from "$lib/utils";
export async function POST({ cookies, request }) {
- const result = await post("/mint", await request.json(), auth(cookies));
+ const result = await post("/nsec", await request.json(), auth(cookies));
return json(result);
}
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)/pay/[username]/[...amount]/+page.svelte b/src/routes/(app)/pay/[username]/[...amount]/+page.svelte
index 618504ca..99502f95 100644
--- a/src/routes/(app)/pay/[username]/[...amount]/+page.svelte
+++ b/src/routes/(app)/pay/[username]/[...amount]/+page.svelte
@@ -20,20 +20,13 @@
let fiat = $state(!initialAmount);
let formElement = $state();
- let setMax = async (e) => {
+ let setMax = (e) => {
e.preventDefault();
- 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);
+ fiat = false;
+ const bal = user.balance;
+ const maxfee = Math.max(5, Math.round(bal * 0.02));
+ const platformFee = Math.round(bal * 0.001);
+ amount = Math.max(0, bal - maxfee - platformFee);
};
</script>
@@ -62,7 +55,7 @@
type="button"
class="btn !w-auto grow"
onclick={setMax}
- onkeydown={setMax}>Max ⚡️{s(user.balance)}</button
+ onkeydown={setMax}>Max</button
>
{/if}
diff --git a/src/routes/(app)/payment/[id]/+page.svelte b/src/routes/(app)/payment/[id]/+page.svelte
index fcde487b..a4c693a0 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 =
@@ -95,7 +97,11 @@
<div class="container mx-auto max-w-lg px-4 space-y-8 break-all text-2xl">
<h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold mb-10">
- {$t(amount < 0 ? "payments.sent" : "payments.received")}
+ {#if amount < 0 && !confirmed && (type === types.lightning || type === types.bolt12)}
+ {$t("payments.pending")}
+ {:else}
+ {$t(amount < 0 ? "payments.sent" : "payments.received")}
+ {/if}
</h1>
{#if p.with}
@@ -187,8 +193,19 @@
{#if type === types.lightning || type === types.bolt12}
<div>
+ <span class="text-lg text-secondary">{$t("payments.invoice")}</span>
+ <div>{hash}</div>
+ </div>
+
+ <div>
<span class="text-lg text-secondary">{$t("payments.preimage")}</span>
- <div>{ref}</div>
+ <div>
+ {#if ref}
+ {ref}
+ {:else}
+ <span class="text-secondary">{$t("payments.pending")}</span>
+ {/if}
+ </div>
</div>
{/if}
diff --git a/src/routes/(app)/pos/+page.ts b/src/routes/(app)/pos/+page.ts
new file mode 100644
index 00000000..393950d8
--- /dev/null
+++ b/src/routes/(app)/pos/+page.ts
@@ -0,0 +1,6 @@
+import { redirect } from "@sveltejs/kit";
+
+// The web flasher moved to /flash: /pos is the coinos v3 till.
+export const load = () => {
+ redirect(301, "/flash");
+};
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)/receive/[...token]/+page.server.ts b/src/routes/(app)/receive/[...token]/+page.server.ts
index 05b2dad8..052d507f 100644
--- a/src/routes/(app)/receive/[...token]/+page.server.ts
+++ b/src/routes/(app)/receive/[...token]/+page.server.ts
@@ -1,17 +1,17 @@
+import parse from "$lib/parse";
import { fd } from "$lib/utils";
import { fail, redirect } from "@sveltejs/kit";
-import parse from "$lib/parse";
-export async function load({ params, parent, url }) {
+export async function load({ cookies, params, parent, url }) {
const { user } = await parent();
if (!user) redirect(307, `/register?redirect=${url.pathname}`);
- await parse(params.token, url.host);
+ await parse(params.token, url.host, cookies);
}
export const actions = {
- default: async ({ request, url }) => {
+ default: async ({ cookies, request, url }) => {
const { text } = await fd(request);
- await parse(text, url.host);
+ await parse(text, url.host, cookies);
return fail(400, { error: "default" });
},
};
diff --git a/src/routes/(app)/reset/[code]/+page.server.ts b/src/routes/(app)/reset/[code]/+page.server.ts
index 533e21f5..35d49be9 100644
--- a/src/routes/(app)/reset/[code]/+page.server.ts
+++ b/src/routes/(app)/reset/[code]/+page.server.ts
@@ -7,11 +7,12 @@ export const actions = {
let error;
const { code } = params;
- const { password } = await fd(request);
+ const { password, recaptcha } = await fd(request);
try {
const user = await post("/reset", { code, password });
user.password = password;
+ user.recaptcha = recaptcha;
try {
await login(user, cookies, ip);
diff --git a/src/routes/(app)/reset/[code]/+page.svelte b/src/routes/(app)/reset/[code]/+page.svelte
index df9d1144..ce43a79d 100644
--- a/src/routes/(app)/reset/[code]/+page.svelte
+++ b/src/routes/(app)/reset/[code]/+page.svelte
@@ -1,14 +1,71 @@
<script>
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
+ import { browser } from "$app/environment";
+ import { onDestroy, onMount, tick } from "svelte";
import { t } from "$lib/translations";
import PasswordInput from "$comp/PasswordInput.svelte";
import Icon from "$comp/Icon.svelte";
import Spinner from "$comp/Spinner.svelte";
import { fly } from "svelte/transition";
import { password } from "$lib/store";
+ import { fail } from "$lib/utils";
let { form } = $props();
let email;
let loading;
+ let recaptchaToken = "";
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
+
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha
+ .execute(recaptchaSiteKey, { action: "reset" })
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ loading = true;
+ try {
+ recaptchaToken = await getRecaptchaToken();
+ await tick();
+ e.currentTarget.submit();
+ } catch (err) {
+ fail(err.message || "captcha failed");
+ loading = false;
+ }
+ };
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" +
+ recaptchaSiteKey +
+ "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
</script>
<div class="pt-10">
@@ -30,7 +87,8 @@
</div>
{/if}
- <form class="space-y-5" method="POST">
+ <form class="space-y-5" method="POST" onsubmit={handleSubmit}>
+ <input type="hidden" name="recaptcha" value={recaptchaToken} />
<PasswordInput
bind:value={$password}
placeholder={$t("login.password")}
diff --git a/src/routes/(app)/send/[...text]/+page.server.ts b/src/routes/(app)/send/[...text]/+page.server.ts
index 9f0891d9..59d53dbe 100644
--- a/src/routes/(app)/send/[...text]/+page.server.ts
+++ b/src/routes/(app)/send/[...text]/+page.server.ts
@@ -9,16 +9,16 @@ export async function load({ cookies, depends, params, parent, url }) {
if (!user) redirect(307, "/login");
const { text } = params;
- await parse(text, url.host);
+ await parse(text, url.host, cookies);
const contacts = await get(`/contacts/${limit}`, auth(cookies));
return { contacts };
}
export const actions = {
- default: async ({ request, url }) => {
+ default: async ({ cookies, request, url }) => {
const { text } = await fd(request);
- await parse(text, url.host);
+ await parse(text, url.host, cookies);
return fail(400, { error: "default" });
},
};
diff --git a/src/routes/(app)/send/[...text]/+page.svelte b/src/routes/(app)/send/[...text]/+page.svelte
index 183b4b51..b2b4d6bf 100644
--- a/src/routes/(app)/send/[...text]/+page.svelte
+++ b/src/routes/(app)/send/[...text]/+page.svelte
@@ -51,6 +51,20 @@
invalidate("app:contacts");
};
+ let trust = async (e, { id, trusted }) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (trusted) {
+ await post("/post/trust/delete", { id });
+ } else {
+ await post("/post/trust", { id });
+ }
+
+ if (all) all = await get("/contacts");
+ invalidate("app:contacts");
+ };
+
$effect(() => {
if (browser && pasted && text) el.click() && (pasted = false);
});
@@ -74,17 +88,6 @@
</div>
{/if}
- <textarea
- use:focus
- name="text"
- placeholder={$t("user.send.placeholder")}
- onkeypress={keypress}
- class="w-full p-4 border rounded-xl h-32 text-xl"
- bind:value={text}
- onpaste={() => (pasted = true)}
- autocapitalize="none"
- ></textarea>
-
<div class="flex gap-2">
<a href="/scan" class="contents">
<button type="button" class="btn !w-auto flex-grow">
@@ -101,29 +104,23 @@
</button>
</div>
+ <textarea
+ use:focus
+ name="text"
+ placeholder={$t("user.send.placeholder")}
+ onkeypress={keypress}
+ class="w-full p-4 border rounded-xl h-32 text-xl"
+ bind:value={text}
+ onpaste={() => (pasted = true)}
+ autocapitalize="none"
+ ></textarea>
+
<button bind:this={el} type="submit" class="btn btn-accent">
<iconify-icon noobserver icon="ph:paper-plane-right-bold" width="32"
></iconify-icon>
<div class="my-auto">{$t("user.send.next")}</div>
</button>
- <a href="/send/ecash" class="block">
- <button type="button" class="btn">
- <img src="/images/cash.png" class="w-8" />
- <div class="my-auto">{$t("payments.createEcash")}</div>
- </button>
- </a>
-
- <a href="/send/fund" class="block">
- <button type="button" class="btn">
- <iconify-icon
- noobserver
- icon="ph:lightning-fill"
- class="text-yellow-300 text-2xl"
- ></iconify-icon>
- <div class="my-auto">{$t("payments.createFund")}</div>
- </button>
- </a>
</form>
{#if contacts.length}
@@ -139,12 +136,20 @@
<div class="my-auto text-left">
<p class="ml-1 text-lg break-words">{c.username}</p>
</div>
- <button class="ml-auto" onclick={(e) => pin(e, c)}>
- <iconify-icon
- icon={c.pinned ? "ph:push-pin-fill" : "ph:push-pin-bold"}
- width={32}
- ></iconify-icon>
- </button>
+ <div class="flex ml-auto gap-1">
+ <button onclick={(e) => pin(e, c)}>
+ <iconify-icon
+ icon={c.pinned ? "ph:push-pin-fill" : "ph:push-pin-bold"}
+ width={32}
+ ></iconify-icon>
+ </button>
+ <button class="ml-auto" onclick={(e) => trust(e, c)}>
+ <iconify-icon
+ icon={c.trusted ? "ph:star-fill" : "ph:star-bold"}
+ width={32}
+ ></iconify-icon>
+ </button>
+ </div>
</div>
</a>
{/each}
@@ -152,7 +157,7 @@
</div>
{/if}
- {#if !loaded}
+ {#if !loaded && contacts.length > 0}
<button onclick={loadMore} type="button" class="btn"
>{$t("user.loadMore")}</button
>
diff --git a/src/routes/(app)/send/bitcoin/[address]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
index 154534a7..529e2691 100644
--- a/src/routes/(app)/send/bitcoin/[address]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
@@ -5,13 +5,14 @@
import Numpad from "$comp/Numpad.svelte";
import { page } from "$app/stores";
import { rate } from "$lib/store";
- import { fail, s } from "$lib/utils";
+ import { loc, fail, s } from "$lib/utils";
let { data } = $props();
let { balance, user } = data;
let { address } = $page.params;
let { currency, username } = user;
+ let locale = loc(user);
let amount = $state(0);
let a = $state(0);
@@ -34,8 +35,7 @@
<div class="text-xl text-secondary break-all">{address}</div>
- <div>{amount}</div>
- <Numpad bind:amount={a} bind:fiat {currency} {submit} bind:rate={$rate} />
+ <Numpad bind:amount={a} bind:fiat {currency} {submit} bind:rate={$rate} {locale} />
<div class="flex justify-center gap-2">
<button
diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts
index ac883afd..ddee9c1c 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts
@@ -11,8 +11,6 @@ export async function load({
const { user } = await parent();
const aid = cookies.get("aid") || user.id;
- console.log("YO", amount, address, feeRate);
-
try {
const { fee, fees, inputs, ourfee, hex, subtract } = await post(
"/bitcoin/fee",
diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
index b86082f3..ac904304 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -1,6 +1,4 @@
<script>
- import { run } from "svelte/legacy";
-
import { tick } from "svelte";
import { t } from "$lib/translations";
import { enhance } from "$app/forms";
@@ -15,11 +13,10 @@
import { applyAction } from "$app/forms";
import { hex as hexUtil } from "@scure/base";
import * as btc from "@scure/btc-signer";
- import { hash160 } from "@scure/btc-signer/utils";
import { decrypt } from "nostr-tools/nip49";
import { HDKey } from "@scure/bip32";
import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
import Amount from "$comp/Amount.svelte";
@@ -71,8 +68,18 @@
let toggle = () => (submitting = !submitting);
- let { account, amount, address, message, fee, fees, subtract, ourfee, hex, inputs } =
- $derived(data);
+ let {
+ account,
+ amount,
+ address,
+ message,
+ fee,
+ fees,
+ subtract,
+ ourfee,
+ hex,
+ inputs,
+ } = $derived(data);
let { feeRate } = $state(data);
@@ -116,7 +123,11 @@
{:else}
<div class="text-xl text-secondary break-all">{address}</div>
- <Amount amount={subtract ? amount - fee - ourfee : amount} rate={$rate} {currency} />
+ <Amount
+ amount={subtract ? amount - fee - ourfee : amount}
+ rate={$rate}
+ {currency}
+ />
<div class="text-center">
<h2 class="text-secondary text-lg">{$t("payments.networkFee")}</h2>
diff --git a/src/routes/(app)/send/ecash/+page.server.ts b/src/routes/(app)/send/ecash/+page.server.ts
deleted file mode 100644
index b290fb95..00000000
--- a/src/routes/(app)/send/ecash/+page.server.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import getRates from "$lib/rates";
-import { auth, fd, post } from "$lib/utils";
-import { error, redirect } from "@sveltejs/kit";
-
-export async function load({ parent }) {
- const rates = await getRates();
- const { user } = await parent();
- return { rate: rates[user.currency] };
-}
-
-export const actions = {
- default: async ({ cookies, request }) => {
- let id;
- const body = await fd(request);
-
- try {
- ({ id } = await post("/mint", body, auth(cookies)));
- } catch (e) {
- const { message } = e as Error;
- console.log("problem creating ecash", e);
- error(400, { message });
- }
-
- redirect(307, `/ecash/${id}`);
- },
-};
diff --git a/src/routes/(app)/send/ecash/+page.svelte b/src/routes/(app)/send/ecash/+page.svelte
deleted file mode 100644
index b9c93661..00000000
--- a/src/routes/(app)/send/ecash/+page.svelte
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
- import { enhance, applyAction } from "$app/forms";
- import { t } from "$lib/translations";
- import Icon from "$comp/Icon.svelte";
- import Numpad from "$comp/Numpad.svelte";
- import Spinner from "$comp/Spinner.svelte";
- import { page } from "$app/stores";
- import { rate } from "$lib/store";
- import { fail, s } from "$lib/utils";
- import handler from "$lib/handler";
-
- let { data } = $props();
-
- let { user } = $derived(data);
- let { balance, currency, username } = $derived(user);
-
- let amount = $state(0);
- let submit = $state(),
- fiat = $state();
-
- let setMax = (e) => {
- e.preventDefault();
- fiat = false;
- amount = balance;
- };
-
- let submitting = $state();
- let toggle = () => (submitting = !submitting);
- $effect(() => ({ rate: $rate } = data));
-</script>
-
-<div class="container px-4 max-w-xl mx-auto space-y-5 text-center">
- <form use:enhance={handler(toggle)} method="POST" class="space-y-5">
- <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold">
- {$t("payments.fundingAmount")}
- </h1>
- <Numpad bind:amount bind:fiat {currency} {submit} bind:rate={$rate} />
- <input type="hidden" name="amount" bind:value={amount} />
-
- <div class="flex gap-2">
- <button
- type="button"
- class="btn !w-auto grow"
- onclick={setMax}
- disabled={submitting}
- onkeydown={setMax}>Max ⚡️{s(balance)}</button
- >
-
- <button
- use:focus
- bind:this={submit}
- type="submit"
- class="btn btn-accent !w-auto grow"
- >
- {#if submitting}
- <Spinner />
- {:else}
- <div class="my-auto">{$t("payments.next")}</div>
- {/if}
- </button>
- </div>
- </form>
-</div>
diff --git a/src/routes/(app)/send/ecash/[request]/+page.server.ts b/src/routes/(app)/send/ecash/[request]/+page.server.ts
deleted file mode 100644
index 6cc593cc..00000000
--- a/src/routes/(app)/send/ecash/[request]/+page.server.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { fd, get } from "$lib/utils";
-import { decodePaymentRequest } from "@cashu/cashu-ts";
-import { redirect } from "@sveltejs/kit";
-
-export async function load({ cookies, params, url }) {
- if (!cookies.get("token"))
- redirect(307, `/login?redirect=${encodeURIComponent(url.pathname)}`);
-
- const req = { ...decodePaymentRequest(params.request) };
- const [{ target, type }] = req.transport;
-
- let recipient = { name: "", pubkey: null };
- if (type === "nostr") {
- recipient = await get(`/profile/${target}`);
- } else if (type === "post") {
- recipient.name = new URL(target).hostname;
- }
-
- return { req, recipient, type, target };
-}
-
-export const actions = {
- setAmount: async ({ request }) => {
- return await fd(request);
- },
-};
diff --git a/src/routes/(app)/send/ecash/[request]/+page.svelte b/src/routes/(app)/send/ecash/[request]/+page.svelte
deleted file mode 100644
index 42bc8e9d..00000000
--- a/src/routes/(app)/send/ecash/[request]/+page.svelte
+++ /dev/null
@@ -1,128 +0,0 @@
-<script lang="ts">
- import { goto } from "$app/navigation";
- import { focus, fail, post } from "$lib/utils";
- import { t } from "$lib/translations";
- import { enhance } from "$app/forms";
- import Amount from "$comp/Amount.svelte";
- import Numpad from "$comp/Numpad.svelte";
- import Spinner from "$comp/Spinner.svelte";
- import { rate, pin } from "$lib/store";
- import { wrapEvent } from "nostr-tools/nip17";
- import { getPrivateKey, sign, send } from "$lib/nostr";
- import { getDecodedToken } from "@cashu/cashu-ts";
- import type { PaymentRequestPayload } from "@cashu/cashu-ts";
- import { AbstractSimplePool } from "nostr-tools/abstract-pool";
-
- const alwaysTrue: any = (t: Event) => {
- t[Symbol("verified")] = true;
- return true;
- };
-
- export const pool = new AbstractSimplePool({ verifyEvent: alwaysTrue });
-
- const { data, form } = $props();
-
- let formElement = $state();
- let submitting = $state();
- let submit = async (e) => {
- e.preventDefault();
- submitting = true;
-
- try {
- let { pid, token } = await post("/ecash/mint", { amount });
- let { proofs } = getDecodedToken(token);
- let { id, mints, unit = "" } = req;
- let mint = mints[0];
-
- const payload = {
- id,
- mint,
- unit,
- proofs,
- };
-
- if (type === "nostr") {
- let event = wrapEvent(
- await getPrivateKey(user),
- { publicKey: recipient.pubkey },
- JSON.stringify(payload),
- );
-
- await Promise.any(pool.publish(recipient.relays, event));
- } else if (type === "post") {
- await post("/ecash/post", { target, payload });
- }
-
- goto(`/sent/${pid}`);
- } catch (e) {
- console.log(e);
- fail(e.message);
- }
-
- submitting = false;
- };
-
- let { recipient, req, user, type, target } = $derived({
- ...data,
- ...form,
- });
- let { currency } = $derived(user);
- let a = $state();
- let next = $state();
- let toggle = () => (show = !show);
- let amount = $derived(form?.amount || data.req.amount);
-
- $effect(() => ($rate ||= data.rate));
- $effect(() => form && (submitting = false));
-</script>
-
-<div class="container px-4 max-w-xl mx-auto text-center space-y-2">
- {#if amount}
- {#if form?.message}
- <div class="text-red-600 text-center">
- {$t(form.message)}
- </div>
- {/if}
-
- <div>
- <h1 class="text-lg text-secondary">
- {$t("payments.send")}
- </h1>
-
- <Amount {amount} rate={$rate} {currency} />
- </div>
-
- <div class="text-xl">
- <span class="text-secondary">{$t("payments.to")}</span>
- <span class="break-words font-semibold">{recipient.name}</span>
- </div>
-
- <form onsubmit={submit} class="space-y-2" bind:this={formElement}>
- <input name="amount" value={amount} type="hidden" />
- <input name="pin" value={$pin} type="hidden" />
-
- <button
- type="submit"
- class="btn btn-primary"
- disabled={submitting}
- use:focus
- >
- {#if submitting}
- <Spinner />
- {:else}
- {$t("payments.send")}
- {/if}
- </button>
- </form>
- {:else}
- <form method="POST" action="?/setAmount" class="space-y-2" use:enhance>
- <input type="hidden" value={a} name="amount" />
- <input name="rate" value={$rate} type="hidden" />
-
- <Numpad bind:amount={a} {currency} bind:rate={$rate} submit={next} />
- <button type="submit" class="btn" bind:this={next}
- >{$t("payments.next")}</button
- >
- </form>
- {/if}
-</div>
diff --git a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte
index 314be3ef..d34ed073 100644
--- a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte
+++ b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte
@@ -6,14 +6,16 @@
import { page } from "$app/stores";
import { pin } from "$lib/store";
import handler from "$lib/handler";
- import { s } from "$lib/utils";
+ import { loc, s } from "$lib/utils";
+ import { applyAction, deserialize } from "$app/forms";
let { data, form } = $props();
- let { amount } = $state(data);
- let { balance, currency } = data.user;
+ let { amount, user } = $state(data);
+ let { balance, currency } = $derived(user);
let { name, rate } = $derived(data);
let loading = $state();
let fiat = $state();
+ let locale = loc(user);
let submit = $state();
let submitting = $state();
@@ -22,11 +24,22 @@
if (form?.message?.includes("pin")) $pin = undefined;
loading = false;
});
+ let formElement = $state();
- let setMax = (e) => {
+ let setMax = async (e) => {
e.preventDefault();
- fiat = false;
- amount = balance;
+ let body = new FormData(formElement);
+ body.set("fiat", false);
+ body.set("amount", user.balance);
+
+ const response = await fetch(formElement.action, {
+ method: "POST",
+ body,
+ });
+
+ const result = deserialize(await response.text());
+ if (result.type === "success") await invalidateAll();
+ applyAction(result);
};
</script>
@@ -37,9 +50,14 @@
{/if}
<div class="container px-4 mt-20 max-w-xl mx-auto">
- <Numpad bind:amount {currency} {rate} {fiat} {submit} />
+ <Numpad bind:amount {currency} {rate} {fiat} {submit} {locale} />
- <form use:enhance={handler(toggle)} method="POST" class="space-y-5">
+ <form
+ use:enhance={handler(toggle)}
+ method="POST"
+ class="space-y-5"
+ bind:this={formElement}
+ >
<input name="fund" value={name} type="hidden" />
<input name="amount" value={amount} type="hidden" />
<input name="pin" value={$pin} type="hidden" />
diff --git a/src/routes/(app)/send/invoice/[id]/+page.server.ts b/src/routes/(app)/send/invoice/[id]/+page.server.ts
index 3ef933d4..70b007a8 100644
--- a/src/routes/(app)/send/invoice/[id]/+page.server.ts
+++ b/src/routes/(app)/send/invoice/[id]/+page.server.ts
@@ -1,12 +1,36 @@
import getRates from "$lib/rates";
import { auth, fd, get, post, types } from "$lib/utils";
-import { error, redirect } from "@sveltejs/kit";
+import { error, fail, redirect } from "@sveltejs/kit";
-export async function load({ cookies, params: { id }, parent }) {
- const { user } = await parent();
+export async function load({ cookies, depends, params: { id }, parent }) {
+ depends("app:trust");
+ const { subject, user } = await parent();
const aid = cookies.get("aid") || user.id;
+ const pin = cookies.get("pin");
- const invoice = await get(`/invoice/${id}`);
+ let invoice = await get(`/invoice/${id}`);
+
+ const trust = await get("/trust", auth(cookies));
+ const trusted = trust.includes(invoice.uid);
+ if (trusted && (pin || !user.haspin)) {
+ let p;
+ try {
+ if (!invoice.tip && invoice.user.prompt) {
+ if (user.tip > 0) {
+ invoice.tip = Math.round(invoice.amount * (user.tip / 100));
+ invoice = await post(`/invoice/${id}`, { invoice }, auth(cookies));
+ } else {
+ throw new Error("tip");
+ }
+ }
+ p = await post("/payments", { ...invoice, pin }, auth(cookies));
+ } catch (e) {
+ const { message } = e as Error;
+ if (message === "tip") redirect(307, `/invoice/${id}/tip`);
+ fail(400, { message });
+ }
+ if (p) redirect(307, `/sent/${p.id}`);
+ }
if (invoice.amount && invoice.prompt && invoice.tip === null)
redirect(307, `/invoice/${id}/tip`);
@@ -31,7 +55,7 @@ export async function load({ cookies, params: { id }, parent }) {
const invoiceRate = rates[invoice.currency];
const { balance } = await get(`/account/${aid}`, auth(cookies));
- return { balance, invoice, user, rate, invoiceRate };
+ return { balance, invoice, user, rate, invoiceRate, trusted };
}
export const actions = {
@@ -45,7 +69,7 @@ export const actions = {
} catch (e) {
const { message } = e as Error;
console.log("payment failed", id, e);
- error(500, message);
+ error(500, { message });
}
redirect(307, `/sent/${p.id}`);
diff --git a/src/routes/(app)/send/invoice/[id]/+page.svelte b/src/routes/(app)/send/invoice/[id]/+page.svelte
index 7d167f41..d5fd00a8 100644
--- a/src/routes/(app)/send/invoice/[id]/+page.svelte
+++ b/src/routes/(app)/send/invoice/[id]/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { invalidate } from "$app/navigation";
import { untrack } from "svelte";
import handler from "$lib/handler";
import { onDestroy, onMount } from "svelte";
@@ -26,7 +27,7 @@
} from "$lib/utils";
let { data, form } = $props();
- let { balance, invoice, user } = $state(data);
+ let { trusted, balance, invoice, user } = $state(data);
let { address, hash, payreq, user: recipient, tip } = $state(invoice);
let { currency } = $derived(user);
let locale = $derived(loc(user));
@@ -64,6 +65,19 @@
});
};
+ let toggleTrust = async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (trusted) {
+ await post("/post/trust/delete", { id: recipient.id });
+ } else {
+ await post("/post/trust", { id: recipient.id });
+ }
+
+ invalidate("app:trust");
+ };
+
$effect(() => {
if (form?.message?.includes("pin")) $pin = undefined;
submitting = false;
@@ -193,5 +207,12 @@
<button type="button" class="btn" onclick={external}
>{$t("payments.moreOptions")}</button
>
+
+ {#if !trusted}
+ <button type="button" class="btn" onclick={toggleTrust}>
+ <iconify-icon icon="ph:star-fill" width={32}></iconify-icon>
+ {$t("payments.trustUser")}</button
+ >
+ {/if}
</form>
</div>
diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts
index eb324775..2b637e75 100644
--- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts
+++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts
@@ -1,14 +1,34 @@
import getRates from "$lib/rates";
-import { auth, fd, post } from "$lib/utils";
+import { auth, fd, get, post } from "$lib/utils";
import { fail, redirect } from "@sveltejs/kit";
+// Ask the server what this send will actually cost: the routing fee askrene
+// quotes for the invoice (the same answer xpay gets when it pays) plus the
+// platform fee. The quoted routing fee becomes the payment's maxfee, so what
+// the user sees is what gets debited. A failed quote isn't fatal — the page
+// falls back to the old manual max-fee reserve.
+const quote = async (body, cookies) => {
+ try {
+ const q = await post("/lightning/quote", body, auth(cookies));
+ return { ...q, quoteError: undefined };
+ } catch (e) {
+ const { message } = e as Error;
+ return { fee: undefined, ourfee: undefined, quoteError: message };
+ }
+};
+
export async function load({ cookies, params, parent }) {
const { user } = await parent();
let data = { ...params };
const rates = await getRates();
- if (!params.payreq.startsWith("lno")) {
- data = { ...data, ...(await post("/parse", params, auth(cookies))) };
- }
+ data = { ...data, ...(await post("/parse", params, auth(cookies))) };
+
+ // Lightning sends always draw on the main account
+ const { balance } = await get(`/account/${user.id}`, auth(cookies));
+ data.balance = balance;
+
+ if (data.amount)
+ data = { ...data, ...(await quote({ payreq: params.payreq, amount: data.amount }, cookies)) };
data.rate = rates[user.currency];
return data;
@@ -29,9 +49,30 @@ export const actions = {
data.payreq = invoice;
}
+ data = { ...data, ...(await quote({ payreq: data.payreq || payreq, amount }, cookies)) };
+
return data;
},
+ // Send everything: the server solves for the largest amount whose amount +
+ // routing fee + platform fee lands exactly on the balance. For an offer it
+ // also fetches the invoice for that exact amount.
+ max: async ({ cookies, params }) => {
+ const { payreq } = params;
+ try {
+ const q = await post("/lightning/quote", { payreq, max: true }, auth(cookies));
+ let data = { amount: q.amount, fee: q.fee, ourfee: q.ourfee, total: q.total, max: true };
+ if (q.payreq) {
+ data = { ...data, ...(await post("/parse", { payreq: q.payreq }, auth(cookies))) };
+ data.payreq = q.payreq;
+ }
+ return data;
+ } catch (e) {
+ const { message } = e as Error;
+ return fail(400, { message });
+ }
+ },
+
send: async ({ cookies, request }) => {
let p;
try {
diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte
index a5cc168c..7b42a42d 100644
--- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte
+++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte
@@ -11,7 +11,8 @@
let { data, form } = $props();
- let { alias, memo, payreq, ourfee, user } = $derived({ ...data, ...form });
+ let { alias, memo, payreq, ourfee, fee, quoteError, balance, user } =
+ $derived({ ...data, ...form });
let a = $state();
let { currency, locked } = $derived(user);
let locale = loc(user);
@@ -27,18 +28,53 @@
let next = $state();
let toggle = () => (show = !show);
let amount = $derived(form?.amount || data.amount);
+
+ // The max routing fee we hand the server. When the route was quoted this is
+ // the exact fee askrene found, so the debit matches what's shown; without a
+ // quote fall back to the old 2% reserve (any unused part is credited back).
+ let quoted = $derived(typeof fee === "number");
+ let fallback = $derived(Math.max(5, Math.round(amount * 0.02) || 0));
let maxfee = $state(
- Math.max(5, Math.round(untrack(() => amount) * 0.005 || 0)),
+ untrack(() => (typeof fee === "number" ? fee : Math.max(5, Math.round(amount * 0.02) || 0))),
+ );
+ $effect(() => (maxfee = quoted ? fee : fallback));
+
+ let total = $derived((amount || 0) + (maxfee || 0) + (ourfee || 0));
+
+ // Server-side messages worth showing verbatim: a balance shortfall, or the
+ // pre-flight check that the max fee is below what routing now costs.
+ let verbatim = $derived(
+ form?.message &&
+ (form.message.includes($t("payments.insufficientFunds")) ||
+ form.message.includes("Routing fee")),
+ );
+
+ // A resubmitted invoice (back button, double tap) isn't a routing failure —
+ // the first attempt went through or is still in flight. Say so instead of
+ // telling the user the recipient is unreachable.
+ let duplicate = $derived(
+ form?.message &&
+ (/already (been )?paid/i.test(form.message)
+ ? "payments.alreadyPaid"
+ : form.message.includes("already underway")
+ ? "payments.alreadyUnderway"
+ : form.message.includes("still settling")
+ ? "payments.stillSettling"
+ : null),
);
- $effect(() => (maxfee = Math.max(5, Math.round(amount * 0.005) || 0)));
</script>
<div class="container px-4 max-w-xl mx-auto text-center space-y-2">
{#if form?.message}
<div class="text-red-600 text-center">
- {#if form.message.includes($t("payments.insufficientFunds"))}
+ {#if verbatim}
<div>{form.message}</div>
- <div>{$t("payments.lockedBalance")}: {locked}</div>
+ {#if form.message.includes($t("payments.insufficientFunds"))}
+ <div>{$t("payments.lockedBalance")}: {locked}</div>
+ {/if}
+ {:else if duplicate}
+ <div>{$t(duplicate)}</div>
+ <a href="/payments" class="underline">{$t("payments.header")}</a>
{:else}
{$t("payments.failedToRoute")}
{/if}
@@ -58,6 +94,23 @@
<span class="break-words font-semibold">{alias}</span>
</div>
+ {#if quoted}
+ <div class="text-center">
+ <h2 class="text-secondary text-lg">{$t("payments.routingFee")}</h2>
+
+ <div class="flex flex-wrap gap-4 justify-center">
+ <div class="my-auto">
+ <h2 class="text-xl">
+ {f(toFiat(fee, $rate), currency)}
+ </h2>
+ <h3 class="text-secondary">⚡️{s(fee)}</h3>
+ </div>
+ </div>
+ </div>
+ {:else if quoteError}
+ <div class="text-secondary">{$t("payments.noQuote")}</div>
+ {/if}
+
{#if ourfee}
<div class="text-center">
<h2 class="text-secondary text-lg">{$t("payments.platformFee")}</h2>
@@ -73,6 +126,14 @@
</div>
{/if}
+ {#if quoted && (fee || ourfee)}
+ <div class="text-center">
+ <h2 class="text-secondary text-lg">{$t("payments.total")}</h2>
+ <h2 class="text-xl">{f(toFiat(total, $rate), currency)}</h2>
+ <h3 class="text-secondary">⚡️{s(total)}</h3>
+ </div>
+ {/if}
+
<form
method="POST"
use:enhance
@@ -126,11 +187,17 @@
<button type="button" class="btn" onclick={() => (showMax = !showMax)}
>{$t("payments.advancedSettings")}</button
>
- <input name="maxfee" type="hidden" bind:value={maxfee} />
+ <input name="fee" type="hidden" bind:value={maxfee} />
{/if}
</form>
{:else}
- <form method="POST" action="?/setAmount" class="space-y-2" use:enhance>
+ <form
+ method="POST"
+ action="?/setAmount"
+ class="space-y-2"
+ use:enhance
+ onsubmit={submit}
+ >
<input type="hidden" value={a} name="amount" />
<input name="rate" value={$rate} type="hidden" />
@@ -141,9 +208,26 @@
bind:rate={$rate}
submit={next}
/>
- <button type="submit" class="btn" bind:this={next}
- >{$t("payments.next")}</button
- >
+
+ <div class="flex justify-center gap-2">
+ <!-- no onclick: see Lnurlp.svelte — disabling from the click
+ handler cancels the submit; the form's onsubmit sets loading -->
+ <button
+ type="submit"
+ class="btn !w-auto grow"
+ formaction="?/max"
+ disabled={loading}
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ Max ⚡️{s(balance)}
+ {/if}
+ </button>
+ <button type="submit" class="btn btn-accent !w-auto grow" bind:this={next}
+ >{$t("payments.next")}</button
+ >
+ </div>
</form>
{/if}
</div>
diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte
index 33398ed5..88708843 100644
--- a/src/routes/(app)/settings/+layout.svelte
+++ b/src/routes/(app)/settings/+layout.svelte
@@ -15,7 +15,7 @@
import { sign, send, getPrivateKey } from "$lib/nostr";
import { invalidateAll } from "$app/navigation";
import { getPublicKey } from "nostr-tools";
- import { bytesToHex } from "@noble/hashes/utils";
+ import { bytesToHex } from "@noble/hashes/utils.js";
let { children, data, form } = $props();
@@ -133,8 +133,7 @@
event = await sign(event, user);
send(event);
} catch (e) {
- console.log(e);
- warning("Nostr profile could not be updated");
+ warning("Nostr profile not updated");
}
}
@@ -209,9 +208,7 @@
{$t("user.settings.header")}
</h1>
- <div
- class="flex flex-wrap justify-around items-center border-b pb-3"
- >
+ <div class="flex flex-wrap justify-around items-center border-b pb-3">
{#each tabs as { name, key }}
<a
href={`/settings/${name}`}
@@ -228,17 +225,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 f0cc700e..ebb90f05 100644
--- a/src/routes/(app)/settings/account/+page.server.ts
+++ b/src/routes/(app)/settings/account/+page.server.ts
@@ -1,9 +1,11 @@
import getRates from "$lib/rates";
import updateUser from "$lib/settings";
+import { auth, get } from "$lib/utils";
-export async function load() {
+export async function load({ cookies }) {
const rates = await getRates();
- return { rates };
+ const connect = await get("/square/connect", auth(cookies));
+ return { connect, rates };
}
export const actions = {
diff --git a/src/routes/(app)/settings/account/+page.svelte b/src/routes/(app)/settings/account/+page.svelte
index c5c75d88..8b38ffe8 100644
--- a/src/routes/(app)/settings/account/+page.svelte
+++ b/src/routes/(app)/settings/account/+page.svelte
@@ -1,5 +1,6 @@
<script>
import { run } from "svelte/legacy";
+ import { theme } from "$lib/store";
import { onMount, tick } from "svelte";
import { browser } from "$app/environment";
@@ -13,8 +14,8 @@
let { data } = $props();
let { user } = $state(data);
- let { rates, subscriptions } = data;
- let { currency, email, verified } = $state(user);
+ 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));
@@ -97,6 +98,8 @@
$effect(() => {
if (email !== user.email) verified = false;
});
+
+ let revoke = () => {};
</script>
<div>
@@ -181,6 +184,19 @@
</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>
+
+ <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>
<div class="flex justify-between items-center">
<span class="font-bold">{$t("user.settings.autoWithdraw")}</span>
@@ -191,7 +207,7 @@
</p>
</div>
-{#if user.autowithdraw}
+<div class:hidden={!user.autowithdraw}>
<div class="mb-2">
<label for="display" class="font-bold mb-1 block"
>{$t("user.settings.destination")}</label
@@ -252,7 +268,7 @@
{$t("user.settings.reserveDesc")}
</p>
</div>
-{/if}
+</div>
{#if editingThreshold}
<div
@@ -306,3 +322,19 @@
</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.server.ts b/src/routes/(app)/settings/nostr/+page.server.ts
index 1767ebaf..84b3fa27 100644
--- a/src/routes/(app)/settings/nostr/+page.server.ts
+++ b/src/routes/(app)/settings/nostr/+page.server.ts
@@ -4,7 +4,13 @@ import { auth, get } from "$lib/utils";
export const load = async ({ cookies }) => {
const { challenge } = await get("/challenge");
const apps = await get("/apps", auth(cookies));
- return { apps, challenge };
+
+ let offer;
+ try {
+ ({ hash: offer } = await get("/offer", auth(cookies)));
+ } catch (e) {}
+
+ return { apps, challenge, offer };
};
export const actions = {
diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte
index 421b590a..3fe6e445 100644
--- a/src/routes/(app)/settings/nostr/+page.svelte
+++ b/src/routes/(app)/settings/nostr/+page.svelte
@@ -1,7 +1,7 @@
<script>
import { untrack } from "svelte";
import { browser } from "$app/environment";
- import { getNsec } from "$lib/nostr";
+ import { getNsec, send, sign } from "$lib/nostr";
import { page } from "$app/stores";
import { tick } from "svelte";
import { t } from "$lib/translations";
@@ -13,7 +13,7 @@
import { signer, save } from "$lib/store";
let { data } = $props();
- let { apps, challenge, user } = $derived(data);
+ let { apps, challenge, offer, user } = $derived(data);
let npub = $state(untrack(() => user.npub));
let extensionAvailable = $derived(browser && window.nostr);
let { locale } = $derived(user);
@@ -41,6 +41,33 @@
await tick();
$save.click();
};
+
+ let publishing = $state(),
+ published = $state();
+
+ // Kind 10058 is the NIP-177 BOLT12 offer list — publishing it lets nostr
+ // clients (Amethyst v1.13+) zap this user over BOLT12. Replaceable event,
+ // so republishing just updates it.
+ let publishOffer = async () => {
+ try {
+ publishing = true;
+ let event = {
+ kind: 10058,
+ content: "",
+ created_at: Math.round(Date.now() / 1000),
+ tags: [["offer", offer]],
+ };
+
+ event = await sign(event, user);
+ await send(event);
+ published = true;
+ } catch (e) {
+ console.log(e);
+ fail($t("user.settings.offerPublishFailed"));
+ } finally {
+ publishing = false;
+ }
+ };
</script>
<input type="hidden" name="challenge" value={challenge} />
@@ -145,6 +172,53 @@
</div>
</div>
+{#if offer}
+ <div class="space-y-2">
+ <h2 class="text-2xl font-bold mb-2">
+ {$t("user.settings.bolt12Offer")}
+ </h2>
+ <p class="text-secondary mb-4">
+ {$t("user.settings.bolt12OfferDescription")}
+ </p>
+
+ <div class="break-all text-secondary">{offer}</div>
+
+ <div class="flex my-auto gap-1">
+ <button
+ type="button"
+ class="my-auto btn btn-circle !w-auto grow"
+ onclick={() => copy(offer)}
+ aria-label="Copy"
+ ><iconify-icon noobserver icon="ph:copy-bold" width="32"
+ ></iconify-icon> {$t("accounts.copy")}</button
+ >
+
+ <a
+ href={`/qr/${encodeURIComponent(offer)}`}
+ 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>
+
+ <button
+ type="button"
+ class="my-auto btn btn-circle !w-auto grow"
+ onclick={publishOffer}
+ disabled={publishing}
+ aria-label="Publish"
+ ><iconify-icon noobserver icon="ph:megaphone-bold" width="32"
+ ></iconify-icon>
+ {published
+ ? $t("user.settings.offerPublished")
+ : $t("user.settings.publishOffer")}</button
+ >
+ </div>
+ </div>
+{/if}
+
{#if revealNwc}
<div class="break-all grow">
{nwc}
@@ -163,25 +237,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>
@@ -193,7 +268,7 @@
{/if}
</div>
-{#if user.nsec}
+{#if user.hasnsec || user.nsec}
<div class="space-y-2">
<label for="seedphrase" class="font-bold"
>{$t("user.settings.nostrKeys")}</label
diff --git a/src/routes/(app)/settings/profile/+page.svelte b/src/routes/(app)/settings/profile/+page.svelte
index bee50d39..3b7de9d3 100644
--- a/src/routes/(app)/settings/profile/+page.svelte
+++ b/src/routes/(app)/settings/profile/+page.svelte
@@ -3,16 +3,18 @@
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 { about, banner, picture, display, username } = $state(user);
let avatarFile,
avatarInput = $state(),
bannerFile,
+ password = $state(),
+ revealPassword = $state(),
bannerInput = $state();
let selectAvatar = () => avatarInput.click();
@@ -64,6 +66,16 @@
</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
@@ -104,7 +116,7 @@
>
<input
type="file"
- class="hidden"
+ class="hidden!"
bind:this={avatarInput}
onchange={(e) => handleFile(e, "picture")}
/>
@@ -146,7 +158,7 @@
>
<input
type="file"
- class="hidden"
+ class="hidden!"
bind:this={bannerInput}
onchange={(e) => handleFile(e, "banner")}
/>
diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte
index ea1bf1f9..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"
diff --git a/src/routes/(app)/settings/shopify/+page.svelte b/src/routes/(app)/settings/shopify/+page.svelte
index 5b0c7ceb..04f82009 100644
--- a/src/routes/(app)/settings/shopify/+page.svelte
+++ b/src/routes/(app)/settings/shopify/+page.svelte
@@ -91,6 +91,8 @@
</div>
<style>
+ @reference "../../../../app.css";
+
.active {
@apply text-blue-500;
}
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)/+layout.server.ts b/src/routes/(static)/+layout.server.ts
index c780b5e0..5de752fa 100644
--- a/src/routes/(static)/+layout.server.ts
+++ b/src/routes/(static)/+layout.server.ts
@@ -1,4 +1,5 @@
-import { get, auth } from "$lib/utils";
+import { auth, get, isInvalidTokenError } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
export async function load({ cookies }) {
const token = cookies.get("token");
@@ -7,7 +8,11 @@ export async function load({ cookies }) {
if (token) {
try {
user = await get("/me", auth(cookies));
- } catch (e) {}
+ } catch (e) {
+ if (isInvalidTokenError(e)) {
+ redirect(307, "/logout");
+ }
+ }
}
return { user };
diff --git a/src/routes/(static)/bounties/+page.server.ts b/src/routes/(static)/bounties/+page.server.ts
new file mode 100644
index 00000000..8378e2f2
--- /dev/null
+++ b/src/routes/(static)/bounties/+page.server.ts
@@ -0,0 +1,44 @@
+import { get } from "$lib/utils";
+export const load = async () => {
+ const users = [
+ {
+ username: "fustler",
+ merchant: "rrdrive",
+ type: "Bakery",
+ amount: 1000000,
+ },
+ {
+ username: "laluna",
+ merchant: "butchersguild",
+ type: "Butcher Shop",
+ amount: 1000000,
+ },
+ {
+ username: "ica547",
+ merchant: "vanlove",
+ type: "Sushi Restaurant",
+ amount: 1000000,
+ },
+ {
+ username: "reverendhodl",
+ merchant: "laughingbean",
+ type: "Cafe",
+ amount: 1000000,
+ },
+ {
+ username: "kimsrepair",
+ merchant: "kimsrepair",
+ type: "Kims Repair",
+ amount: 100000,
+ },
+ ];
+
+ 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..695eae3e 100644
--- a/src/routes/(static)/bounties/+page.svelte
+++ b/src/routes/(static)/bounties/+page.svelte
@@ -1,96 +1,172 @@
-<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 &amp; 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 = [
+ "Hardware Store",
+ "Gas Station",
+ "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, amount }}
+ <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(amount, "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 +174,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.server.ts b/src/routes/(static)/docs/+page.server.ts
index 86f95824..93ba544c 100644
--- a/src/routes/(static)/docs/+page.server.ts
+++ b/src/routes/(static)/docs/+page.server.ts
@@ -1,4 +1,7 @@
+import { auth, get } from "$lib/utils";
export const load = async ({ cookies }) => {
const token = cookies.get("token");
- return { token };
+ let ro;
+ if (token) ro = await get("/ro", auth(cookies));
+ return { ro, token };
};
diff --git a/src/routes/(static)/docs/+page.svelte b/src/routes/(static)/docs/+page.svelte
index 7ac6e013..7cf7e2cb 100644
--- a/src/routes/(static)/docs/+page.svelte
+++ b/src/routes/(static)/docs/+page.svelte
@@ -7,7 +7,7 @@
let { data } = $props();
- let { user, token } = data;
+ let { user, ro, token } = data;
let api = PUBLIC_DOMAIN.includes("localhost")
? `${PUBLIC_COINOS_URL}`
@@ -16,6 +16,8 @@
let tokenSample = token
? `export token="${token}"`
: `export token=<your auth token>`;
+
+ let roSample = `export token="${ro}"`;
</script>
{#snippet cp(something)}
@@ -59,7 +61,7 @@
or get one from the /login endpoint.
{/if}
- Save it in a variable called <b>$token</b> to run the examples.
+ Save it in a variable called <b>$token</b> to run the examples. It gives full access to your account so keep it safe.
</p>
<div
@@ -69,6 +71,19 @@
{@render cp(tokenSample)}
</div>
+ {#if ro}
+ <p class="text-secondary">
+ This read-only token has permission to create invoices and fetch
+ payments but can't be used to make withdrawals
+ </p>
+ <div
+ class="bg-primary text-primary-content rounded-lg p-4 flex gap-4 items-center"
+ >
+ <div class="w-full break-all">{roSample}</div>
+ {@render cp(roSample)}
+ </div>
+ {/if}
+
<h2 class="text-2xl">POST /register</h2>
<p class="text-secondary">
Register a new user account with a username and password
@@ -92,9 +107,13 @@
<div class="font-bold">display</div>
<div class="col-span-2">string - spaces allowed</div>
<div class="font-bold">currency</div>
- <div class="col-span-2">string - three-letter ISO currency code e.g. CAD or USD</div>
+ <div class="col-span-2">
+ string - three-letter ISO currency code e.g. CAD or USD
+ </div>
<div class="font-bold">language</div>
- <div class="col-span-2">string - two-letter language code e.g. en, fr, es</div>
+ <div class="col-span-2">
+ string - two-letter language code e.g. en, fr, es
+ </div>
</div>
<Code sample="update" />
@@ -105,16 +124,22 @@
<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 (string, optional)</div>
+ <div class="col-span-2">
+ a secret value passed to the webhook to authenticate the request
</div>
- <div class="font-bold">secret</div>
- <div class="col-span-2">a secret for the webhook to check</div>
</div>
</div>
@@ -137,8 +162,23 @@
</p>
<Code sample="fetchInvoice" />
+ <h2 class="text-2xl">POST /lightning/quote</h2>
+ <p class="text-secondary">
+ Find out what a lightning payment will cost before sending it. The routing
+ fee comes from querying the route ahead of time and is exact; pass it as
+ <b>fee</b> to POST /payments. Pass <b>amount</b> for an amountless invoice,
+ or <b>max</b> to solve for the largest amount whose amount + fees uses the
+ whole balance.
+ </p>
+ <Code sample="quote" />
+ <p>Sample response</p>
+ <Code sample="quoteResponse" />
+
<h2 class="text-2xl">POST /payments</h2>
- <p class="text-secondary">Send a lightning payment</p>
+ <p class="text-secondary">
+ Send a lightning payment. <b>fee</b> caps the routing fee; if omitted, the
+ route is quoted and the exact fee used.
+ </p>
<Code sample="lightning" />
<p class="text-secondary">Send an internal payment to another user</p>
diff --git a/src/routes/(static)/forgot/+page.svelte b/src/routes/(static)/forgot/+page.svelte
index 45180543..49a837ae 100644
--- a/src/routes/(static)/forgot/+page.svelte
+++ b/src/routes/(static)/forgot/+page.svelte
@@ -6,8 +6,6 @@
import Spinner from "$comp/Spinner.svelte";
import { fly } from "svelte/transition";
- let { form } = $props();
- let email = $state();
let loading;
</script>
@@ -16,51 +14,8 @@
>
<h1 class="text-2xl font-bold text-center">{$t("login.reset")}</h1>
- {#if form?.error}
- <div class="text-red-600 text-center" in:fly>
- {form.error}
- </div>
- {/if}
-
- {#if form?.success}
- <div class="text-secondary">
- {$t("login.resetSent")}
- </div>
-
- <div class="flex w-full">
- <a href="/login" class="btn">
- {$t("login.continue")}
- </a>
- </div>
- {:else}
- <form class="space-y-5" method="POST" use:enhance>
- <div>
- <input
- placeholder={$t("login.email")}
- name="email"
- type="text"
- required
- class="bg-primary"
- bind:value={email}
- use:focus
- autocapitalize="none"
- />
- </div>
-
- <div class="text-secondary">
- {$t("login.resetDesc")}
- <a href="mailto:[email protected]" class="font-bold"
- >
- </div>
-
- <button type="submit" class="btn" disabled={loading}>
- {#if loading}
- <Spinner />
- {:else}
- {$t("login.submit")}
- {/if}
- </button>
- </form>
- {/if}
+ <div class="text-secondary">
+ {$t("login.resetDesc")}
+ <a href="mailto:[email protected]" class="font-bold">[email protected]</a>
+ </div>
</div>
diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts
index a24abd07..ce343790 100644
--- a/src/routes/(static)/login/+page.server.ts
+++ b/src/routes/(static)/login/+page.server.ts
@@ -1,5 +1,5 @@
import { PUBLIC_COINOS_URL } from "$env/static/public";
-import { fd, get, login } from "$lib/utils";
+import { fd, get, login, post } from "$lib/utils";
import { fail, redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
@@ -12,13 +12,20 @@ export const load = async ({ parent }) => {
export const actions = {
login: async ({ cookies, request }) => {
const form = await fd(request);
- let { username, password, token, loginRedirect } = form;
- const user = { username, password, token };
+ let { username, password, token, loginRedirect, recaptcha } = form;
+ username = username.split("@")[0];
+
+ const user = {
+ username,
+ password,
+ token,
+ recaptcha,
+ };
if (loginRedirect === "undefined") loginRedirect = undefined;
try {
- await login(user, cookies, request.headers.get("cf-connecting-ip"));
+ await login(user, cookies, request.headers.get("cf-connecting-ip"), request.headers.get("host"));
} catch (e) {
const { message } = e as Error;
return fail(400, { error: "Login failed", message, ...form });
@@ -29,7 +36,7 @@ export const actions = {
nostr: async ({ cookies, fetch, request }) => {
const form = await fd(request);
- let { challenge, event, loginRedirect } = form;
+ let { challenge, event, loginRedirect, recaptcha } = form;
if (loginRedirect === "null") loginRedirect = undefined;
event = JSON.parse(event);
@@ -37,20 +44,23 @@ export const actions = {
const res = await fetch(`${PUBLIC_COINOS_URL}/nostrAuth`, {
method: "POST",
- body: JSON.stringify({ challenge, event }),
+ body: JSON.stringify({ challenge, event, recaptcha }),
headers: {
"content-type": "application/json",
accept: "application/json",
"cf-connecting-ip": request.headers.get("cf-connecting-ip"),
+ "x-forwarded-host": request.headers.get("host"),
},
});
+ const text = await res.text();
+
if (res.status === 401) {
- const text = await res.text();
if (text.startsWith("2fa")) throw new Error("2fa");
+ throw new Error(text);
}
- const { user, token } = await res.json();
+ const { user, token } = JSON.parse(text);
if (!token) throw new Error("Login failed");
const { username, language } = user;
diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte
index 1a1f745c..fe2f4533 100644
--- a/src/routes/(static)/login/+page.svelte
+++ b/src/routes/(static)/login/+page.svelte
@@ -1,8 +1,9 @@
<script>
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
import { browser } from "$app/environment";
import handler from "$lib/handler";
import { applyAction, deserialize } from "$app/forms";
- import { onMount, tick } from "svelte";
+ import { onDestroy, onMount, tick } from "svelte";
import { fly } from "svelte/transition";
import { enhance } from "$app/forms";
import Pinpad from "$comp/Pinpad.svelte";
@@ -14,8 +15,8 @@
import { page } from "$app/stores";
import { sign } from "$lib/nostr";
import { invalidateAll } from "$app/navigation";
- import { sha256 } from "@noble/hashes/sha256";
- import { bytesToHex } from "@noble/hashes/utils";
+ import { sha256 } from "@noble/hashes/sha2.js";
+ import { bytesToHex } from "@noble/hashes/utils.js";
let { data, form } = $props();
@@ -37,8 +38,18 @@
});
let { challenge } = $derived(data);
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
let token = $state("");
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
+
let cancel = () => (need2fa = false);
let username = $state(),
@@ -53,28 +64,36 @@
let revealPassword = $state(false);
- async function handleSubmit(e) {
- e.preventDefault();
- let data = new FormData(this);
- let user = Object.fromEntries(data);
-
- for (let k in user) {
- data.set(k, user[k]);
- }
-
- const response = await fetch("?/login", {
- method: "POST",
- body: data,
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha
+ .execute(recaptchaSiteKey, { action: "login" })
+ .then(resolve)
+ .catch(reject);
+ });
});
- const result = deserialize(await response.text());
-
- if (result.type === "success") {
- await invalidateAll();
+ const enhanceLogin = async ({ formData, cancel }) => {
+ try {
+ const recaptcha = await getRecaptchaToken();
+ formData.set("recaptcha", recaptcha);
+ } catch (err) {
+ fail(err.message || "captcha failed");
+ cancel();
+ return;
}
- applyAction(result);
- }
+ return async ({ result }) => {
+ if (result.type === "success") {
+ await invalidateAll();
+ }
+
+ applyAction(result);
+ };
+ };
$effect(() => {
token?.length === 6 &&
@@ -104,10 +123,12 @@
const formData = new FormData();
try {
+ const recaptcha = await getRecaptchaToken();
formData.append("loginRedirect", redirect);
formData.append("token", token);
formData.append("event", JSON.stringify(signedEvent));
formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha);
let response = await fetch("/login?/nostr", {
method: "POST",
@@ -121,10 +142,27 @@
fail(e.message);
}
};
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" +
+ recaptchaSiteKey +
+ "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
</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")}
@@ -134,9 +172,8 @@
{/if}
<form
- use:enhance
+ use:enhance={enhanceLogin}
class="space-y-5"
- onsubmit={handleSubmit}
method="POST"
action="?/login"
>
diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts
index 2c2807bd..2bcf6f2d 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,13 @@ export const load = async ({ parent }) => {
export const actions = {
default: async ({ cookies, request }) => {
const ip = request.headers.get("cf-connecting-ip");
-
+ const host = request.headers.get("host");
const form = await fd(request);
- let { picture, username, password, pubkey, loginRedirect } = form;
- const user = { picture, username, password, pubkey };
- let error;
-
+ const { picture, username, password, challenge, recaptcha } = 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, challenge, recaptcha };
+ return register(user, ip, cookies, loginRedirect, host);
},
};
diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte
index 43cafe6e..bb3af128 100644
--- a/src/routes/(static)/register/+page.svelte
+++ b/src/routes/(static)/register/+page.svelte
@@ -1,5 +1,6 @@
<script>
- import { onMount } from "svelte";
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
+ import { onDestroy, onMount } from "svelte";
import { browser } from "$app/environment";
import punks from "$lib/punks";
import { upload } from "$lib/upload";
@@ -28,6 +29,16 @@
let { form, data } = $props();
let { challenge } = $derived(data);
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
onMount(() => {
if (browser) {
@@ -93,6 +104,18 @@
btn = $state();
let loading = $state();
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha
+ .execute(recaptchaSiteKey, { action: "register" })
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+
async function handleSubmit(e) {
e.preventDefault();
@@ -106,6 +129,15 @@
data.set(k, user[k]);
}
+ try {
+ const recaptcha = await getRecaptchaToken();
+ data.set("recaptcha", recaptcha);
+ } catch (err) {
+ fail(err.message || "captcha failed");
+ loading = false;
+ return;
+ }
+
data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
if ($avatar) {
try {
@@ -189,10 +221,12 @@
const formData = new FormData();
try {
+ const recaptcha = await getRecaptchaToken();
formData.append("loginRedirect", redirect);
formData.append("token", token);
formData.append("event", JSON.stringify(signedEvent));
formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha);
let response = await fetch("/login?/nostr", {
method: "POST",
@@ -210,6 +244,23 @@
fail(e.message);
}
};
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" +
+ recaptchaSiteKey +
+ "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
</script>
{#if need2fa}
@@ -222,7 +273,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 +314,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}
@@ -276,6 +328,7 @@
value={$loginRedirect || $page.url.searchParams.get("redirect")}
/>
<input type="hidden" name="token" value={token} />
+ <input type="hidden" name="challenge" value={challenge} />
<label
for="username"
diff --git a/src/routes/(static)/support/+page.svelte b/src/routes/(static)/support/+page.svelte
index b9028cbf..4cf9706d 100644
--- a/src/routes/(static)/support/+page.svelte
+++ b/src/routes/(static)/support/+page.svelte
@@ -1,6 +1,7 @@
<script>
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
import { browser } from "$app/environment";
- import { onDestroy } from "svelte";
+ import { onDestroy, onMount } from "svelte";
import { fail, success, post } from "$lib/utils";
import { scale } from "svelte/transition";
import Icon from "$comp/Icon.svelte";
@@ -13,10 +14,30 @@
let email = $state();
let message = $state();
let sent = $state();
- let recaptchaSiteKey = "6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM";
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
let submit = (e) => {
e.preventDefault();
+ if (isTor) {
+ post("/post/email", {
+ username: user?.username || `${username} (unverified)`,
+ email,
+ message,
+ token: "",
+ })
+ .then(() => (sent = true))
+ .catch(() => fail("problem submitting"));
+ return;
+ }
grecaptcha.ready(() => {
grecaptcha.execute(recaptchaSiteKey, { action: "submit" }).then((token) =>
post("/post/email", {
@@ -49,12 +70,6 @@
});
</script>
-<svelte:head
- ><script
- src="https://www.google.com/recaptcha/api.js?render=6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM"
- ></script></svelte:head
->
-
<div class="container max-w-lg mx-auto space-y-8 p-4 pb-20">
{#if sent}
<h1 class="text-center text-3xl lg:text-4xl font-semibold">Thank you!</h1>
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index 584e836c..c1f77695 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -3,7 +3,8 @@ import {
loadTranslations,
locales,
} from "$lib/translations/index";
-import { auth, get, sleep } from "$lib/utils";
+import { auth, get, isInvalidTokenError, sleep } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
export const load = async ({ cookies, request, url }) => {
const { host, pathname } = url;
@@ -13,8 +14,22 @@ export const load = async ({ cookies, request, url }) => {
try {
user = await get("/me", auth(cookies));
} catch (e) {
- if (e.message.startsWith("Rate")) await sleep(3000);
- user = await get("/me", auth(cookies));
+ const { message } = e as Error;
+ if (message.startsWith("Rate")) {
+ await sleep(3000);
+ try {
+ user = await get("/me", auth(cookies));
+ } catch (retryError) {
+ if (isInvalidTokenError(retryError) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ }
+ throw retryError;
+ }
+ } else if (isInvalidTokenError(e) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ } else {
+ throw e;
+ }
}
}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index d25f570a..d3956c83 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,28 +1,79 @@
<script>
import { run } from "svelte/legacy";
-
import { browser } from "$app/environment";
import { PUBLIC_DOMAIN } from "$env/static/public";
import "../app.css";
import { loading, t } from "$lib/translations";
import { onMount } from "svelte";
+ import V3Banner from "$comp/V3Banner.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 +83,21 @@
<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>
+ <V3Banner />
{@render children?.()}
</main>
{/if}
@@ -60,5 +109,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>