aboutsummaryrefslogtreecommitdiffstats
diff options
context:
style='width: 99.0%;'/> -rw-r--r--src/components/Avatar.svelte8
-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/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.json67
-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.svelte67
-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.ts107
-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.ts103
-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.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.svelte5
-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
-rw-r--r--static/firmware/boot_app0.binbin0 -> 8192 bytes-rw-r--r--static/firmware/bootloader.binbin0 -> 18688 bytes-rw-r--r--static/firmware/coinos-pos.binbin0 -> 1298928 bytes-rw-r--r--static/firmware/manifest.json12
-rw-r--r--static/firmware/partitions.binbin0 -> 3072 bytes-rw-r--r--static/icon-512x512.webpbin0 -> 11762 bytes-rw-r--r--static/images/arrow.pngbin0 -> 180076 bytes-rw-r--r--static/images/bitcoin-logo.pngbin0 -> 99084 bytes-rw-r--r--static/images/lntap.pngbin0 -> 377663 bytes-rw-r--r--static/images/nfc.pngbin0 -> 118238 bytes-rw-r--r--static/images/square.svg15
-rw-r--r--static/images/tap.pngbin0 -> 180156 bytes-rw-r--r--static/manifest.webmanifest78
-rw-r--r--tailwind.config.cjs28
-rw-r--r--vite.config.js11
168 files changed, 4509 insertions, 1763 deletions
diff --git a/.env.production b/.env.production
index 52119afa..1576a33b 100644
--- a/.env.production
+++ b/.env.production
@@ -8,3 +8,4 @@ PUBLIC_COINOS_PUBKEY=ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a
PUBLIC_COINOS_RELAY=wss://relay.coinos.io
PUBLIC_COINOS_NETWORK=bitcoin
PUBLIC_VAPID_PUBKEY=BFCcO7u4fpgDjWK-Py2qL7rGeueaijf97zxyBKcld25Hya9psuB9pVPeWKpUTdZI0wEWWzTOUK0LVO9uUfbaMhI
+PUBLIC_RECAPTCHA_SITE_KEY=6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM
diff --git a/.env.sample b/.env.sample
index 8474ee0a..72dfb652 100644
--- a/.env.sample
+++ b/.env.sample
@@ -7,4 +7,5 @@ ORIGIN=http://localhost:5173
PUBLIC_DOMAIN=localhost:5173
PUBLIC_COINOS_PUBKEY=ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6
PUBLIC_COINOS_RELAY=wss://relay.coinos.io
-PUBLIC_COINOS_NETWORK=regtest \ No newline at end of file
+PUBLIC_COINOS_NETWORK=regtest
+PUBLIC_RECAPTCHA_SITE_KEY=
diff --git a/.env.staging b/.env.staging
index fa6cb6ff..54f2be53 100644
--- a/.env.staging
+++ b/.env.staging
@@ -1,10 +1,11 @@
-PUBLIC_COINOS_URL=http://localhost:3119
+PUBLIC_COINOS_URL=http://app:3119
PUBLIC_BTC=6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d
-PUBLIC_SOCKET=wss://dev.coinos.io/ws
+PUBLIC_SOCKET=wss://staging.coinos.io/ws
PUBLIC_EXPLORER=https://mempool.space
PUBLIC_LIQUID_EXPLORER=https://liquid.network
-PUBLIC_DOMAIN=dev.coinos.io
+PUBLIC_DOMAIN=staging.coinos.io
PUBLIC_COINOS_PUBKEY=ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6
PUBLIC_COINOS_RELAY=wss://relay.coinos.io
-PUBLIC_COINOS_NETWORK=regtest
-PUBLIC_VAPID_PUBKEY=BEfgMDTeAn1F8ZQUGC6v5iKTD86zYXaa3QVr1E-ylKXC5c24JplcfSAN9PDlXAsVtUd3cU6j4-LsX_TKQzz_yDQ
+PUBLIC_COINOS_NETWORK=bitcoin
+PUBLIC_VAPID_PUBKEY=BFCcO7u4fpgDjWK-Py2qL7rGeueaijf97zxyBKcld25Hya9psuB9pVPeWKpUTdZI0wEWWzTOUK0LVO9uUfbaMhI
+PUBLIC_RECAPTCHA_SITE_KEY=6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM
diff --git a/.gitignore b/.gitignore
index db03a1c7..92c23615 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@ node_modules
/.svelte-kit
/package
.env
+.env*.local
ios
.history/
db
+.claude/
+test-results/
diff --git a/Dockerfile b/Dockerfile
index 74982d30..1dae80a2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,16 +1,16 @@
-FROM node:21
+FROM oven/bun
-ARG NODE_ENV=production
-ENV NODE_ENV $NODE_ENV
-
-RUN npm i -g bun
WORKDIR /app
-ADD package.json .
-RUN NODE_ENV=development bun i
+ADD package.json bun.lockb .
+RUN NODE_ENV=development bun i --frozen-lockfile
COPY . .
-RUN bun run build
-# CMD ["node", "build"]
-CMD ["bun", "run", "preview", "--host", "--port", "3000"]
+ARG VITE_MODE=production
+RUN rm -f .env .env.local .env.production.local && \
+ NODE_ENV=production bunx vite build --mode $VITE_MODE
+
+ENV NODE_ENV=production
+
+CMD ["bun", "./build/index.js"]
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 88077520..0a658d76 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "io.coinos"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 6
- versionName "1.0.6"
+ versionCode 11
+ versionName "1.0.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
@@ -24,9 +24,9 @@ android {
signingConfigs {
release {
keyAlias = System.getenv("KEY_ALIAS") ?: "coinos"
- keyPassword = System.getenv("KEY_PASSWORD") ?: "default-password"
+ keyPassword = System.getenv("KEY_PASSWORD") ?: "MPJzfq97"
storeFile = file(System.getenv("STORE_FILE") ?: "coinos.keystore")
- storePassword = System.getenv("STORE_PASSWORD") ?: "default-password"
+ storePassword = System.getenv("STORE_PASSWORD") ?: "MPJzfq97"
}
}
diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle
index fdb4970c..7d5ceefd 100644
--- a/android/app/capacitor.build.gradle
+++ b/android/app/capacitor.build.gradle
@@ -2,14 +2,14 @@
android {
compileOptions {
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
+ sourceCompatibility JavaVersion.VERSION_21
+ targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
-
+ implementation project(':capacitor-status-bar')
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 86de46b2..72d7ccc0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -14,20 +14,66 @@
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
+
+ <!-- Launcher intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
+ <!-- bitcoin: scheme -->
<intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitcoin" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="bitcoin" />
+ </intent-filter>
+
+ <!-- lightning: scheme -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="lightning" />
- <data android:scheme="nostr" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="lightning" />
+ </intent-filter>
+
+ <!-- lnurl: scheme -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="lnurl" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="lnurl" />
+ </intent-filter>
+
+ <!-- nostr: scheme -->
+ <intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="nostr" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="nostr" />
</intent-filter>
+ <!-- Deep linking to https://coinos.io -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -46,7 +92,6 @@
</application>
<!-- Permissions -->
-
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true"/>
diff --git a/android/app/src/main/java/io/coinos/MainActivity.java b/android/app/src/main/java/io/coinos/MainActivity.java
index 7f710952..271ee363 100644
--- a/android/app/src/main/java/io/coinos/MainActivity.java
+++ b/android/app/src/main/java/io/coinos/MainActivity.java
@@ -1,81 +1,167 @@
package io.coinos;
+import android.os.Build;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+import androidx.core.view.ViewCompat;
+
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Bridge;
-import com.getcapacitor.PluginCall;
-import com.getcapacitor.PluginHandle;
+
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
+import android.os.Parcelable;
import android.webkit.CookieManager;
import android.webkit.WebView;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+
+import java.nio.charset.Charset;
+import java.util.Arrays;
+
+import io.coinos.nfcwriter.NfcWriterPlugin;
+
public class MainActivity extends BridgeActivity {
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
+ private NfcAdapter nfcAdapter;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ // ✅ Let Capacitor inflate its layout and wire everything
+ super.onCreate(savedInstanceState);
+ registerPlugin(NfcWriterPlugin.class);
+
+ WebView webView = bridge.getWebView(); // This works *after* super.onCreate()
+
+ // ✅ Ensure layout respects system bars
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
+ getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
+ }
+
+ // ✅ Apply top inset padding if needed
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ webView.setOnApplyWindowInsetsListener((v, insets) -> {
+ v.setPadding(0, insets.getSystemWindowInsetTop(), 0, 0);
+ return insets.consumeSystemWindowInsets();
+ });
+ }
+
+ // Other app setup
+ CookieManager cookieManager = CookieManager.getInstance();
+ cookieManager.setAcceptCookie(true);
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.getSettings().setDomStorageEnabled(true);
+
+ handleIntent(getIntent());
+ }
+
+ @Override
+ public void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ handleIntent(intent);
+ }
+
+ private void handleIntent(Intent intent) {
+ if (intent == null) return;
- WebView webView = bridge.getWebView();
- CookieManager cookieManager = CookieManager.getInstance();
- cookieManager.setAcceptCookie(true);
- webView.getSettings().setJavaScriptEnabled(true);
- webView.getSettings().setDomStorageEnabled(true);
+ String action = intent.getAction();
+ Uri data = null;
- Intent intent = getIntent();
- if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction())) {
- Uri data = intent.getData();
- if (data != null) {
- String url = data.toString();
- Bridge bridge = getBridge();
-
- if (bridge != null) {
- String encodedUrl = Uri.encode(url);
- String destinationUrl = "https://coinos.io/send/" + encodedUrl;
+ if (Intent.ACTION_VIEW.equals(action)) {
+ data = intent.getData();
+ } else if ("android.nfc.action.NDEF_DISCOVERED".equals(action)) {
+ Parcelable[] rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
+ if (rawMsgs != null && rawMsgs.length > 0) {
+ NdefMessage ndefMessage = (NdefMessage) rawMsgs[0];
+ for (NdefRecord record : ndefMessage.getRecords()) {
+ if (record.getTnf() == NdefRecord.TNF_WELL_KNOWN &&
+ Arrays.equals(record.getType(), NdefRecord.RTD_URI)) {
- bridge.getWebView().post(() -> {
- bridge.getWebView().loadUrl(destinationUrl);
- });
- }
- }
- }
- }
+ byte[] payload = record.getPayload();
+ byte prefix = payload[0];
+ String uriPrefix = resolveUriPrefix(prefix);
+ String uriBody = new String(Arrays.copyOfRange(payload, 1, payload.length), Charset.forName("UTF-8"));
+ data = Uri.parse(uriPrefix + uriBody);
+ break;
+ }
+ }
+ }
+ }
- @Override
- public void onNewIntent(Intent intent) {
- super.onNewIntent(intent);
- setIntent(intent);
- if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction())) {
- Uri data = intent.getData();
- if (data != null) {
- String url = data.toString();
+ if (data != null) {
+ String url = data.toString();
+ Bridge bridge = getBridge();
+ if (bridge != null) {
+ String encodedUrl = Uri.encode(url);
+ String destinationUrl = "https://coinos.io/send/" + encodedUrl;
- // Dispatch deep link when app is already open
- Bridge bridge = getBridge();
- if (bridge != null) {
- String encodedUrl = Uri.encode(url);
- String destinationUrl = "https://coinos.io/send/" + encodedUrl;
+ bridge.getWebView().post(() -> {
+ bridge.getWebView().loadUrl(destinationUrl);
+ });
+ }
+ }
+ }
- bridge.getWebView().post(() -> {
- bridge.getWebView().loadUrl(destinationUrl);
- });
- }
- }
- }
- }
+ private String resolveUriPrefix(byte prefix) {
+ switch (prefix) {
+ case 0x00: return "";
+ case 0x01: return "http://www.";
+ case 0x02: return "https://www.";
+ case 0x03: return "http://";
+ case 0x04: return "https://";
+ case 0x05: return "tel:";
+ case 0x06: return "mailto:";
+ case 0x07: return "ftp://anonymous:anonymous@";
+ case 0x08: return "ftp://ftp.";
+ case 0x09: return "ftps://";
+ case 0x0A: return "sftp://";
+ case 0x0B: return "smb://";
+ case 0x0C: return "nfs://";
+ case 0x0D: return "ftp://";
+ case 0x0E: return "dav://";
+ case 0x0F: return "news:";
+ case 0x10: return "telnet://";
+ case 0x11: return "imap:";
+ case 0x12: return "rtsp://";
+ case 0x13: return "urn:";
+ case 0x14: return "pop:";
+ case 0x15: return "sip:";
+ case 0x16: return "sips:";
+ case 0x17: return "tftp:";
+ case 0x18: return "btspp://";
+ case 0x19: return "btl2cap://";
+ case 0x1A: return "btgoep://";
+ case 0x1B: return "tcpobex://";
+ case 0x1C: return "irdaobex://";
+ case 0x1D: return "file://";
+ case 0x1E: return "urn:epc:id:";
+ case 0x1F: return "urn:epc:tag:";
+ case 0x20: return "urn:epc:pat:";
+ case 0x21: return "urn:epc:raw:";
+ case 0x22: return "urn:epc:";
+ case 0x23: return "urn:nfc:";
+ default: return "";
+ }
+ }
- @Override
- public void onPause() {
- super.onPause();
- CookieManager.getInstance().flush();
- }
+ @Override
+ public void onPause() {
+ super.onPause();
+ CookieManager.getInstance().flush();
+ }
- @Override
- public void onBackPressed() {
- WebView webView = getBridge().getWebView();
- if (webView.canGoBack()) {
- webView.goBack();
- } else {
- super.onBackPressed(); // Default behavior, which minimizes the app
- }
- }
+ @Override
+ public void onBackPressed() {
+ WebView webView = getBridge().getWebView();
+ if (webView.canGoBack()) {
+ webView.goBack();
+ } else {
+ super.onBackPressed();
+ }
+ }
}
diff --git a/android/app/src/main/java/io/coinos/nfcwriter/NfcWriterPlugin.java b/android/app/src/main/java/io/coinos/nfcwriter/NfcWriterPlugin.java
new file mode 100644
index 00000000..d2954e9b
--- /dev/null
+++ b/android/app/src/main/java/io/coinos/nfcwriter/NfcWriterPlugin.java
@@ -0,0 +1,62 @@
+// src/android/NfcWriterPlugin.java
+package io.coinos.nfcwriter;
+
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.nfc.NfcAdapter.ReaderCallback;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+@CapacitorPlugin(name = "NfcWriter")
+public class NfcWriterPlugin extends Plugin {
+ private NfcAdapter nfcAdapter;
+
+ @Override
+ public void load() {
+ super.load();
+ nfcAdapter = NfcAdapter.getDefaultAdapter(getContext());
+ }
+
+ @PluginMethod
+ public void enableExclusiveNfcMode(PluginCall call) {
+ if (nfcAdapter == null) {
+ call.reject("NFC adapter not available");
+ return;
+ }
+
+ nfcAdapter.enableReaderMode(
+ getActivity(),
+ new ReaderCallback() {
+ @Override
+ public void onTagDiscovered(Tag tag) {
+ Log.d("NfcWriter", "Tag discovered");
+ bridge.getWebView().post(() -> {
+ bridge.getWebView().evaluateJavascript(
+ "window.dispatchEvent(new CustomEvent('nativeNfcTagDiscovered'))",
+ null
+ );
+ });
+ }
+ },
+ NfcAdapter.FLAG_READER_NFC_A |
+ NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK |
+ NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS,
+ null
+ );
+
+ call.resolve();
+ }
+
+ @PluginMethod
+ public void disableExclusiveNfcMode(PluginCall call) {
+ if (nfcAdapter != null) {
+ nfcAdapter.disableReaderMode(getActivity());
+ }
+ call.resolve();
+ }
+}
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index b5ad1387..3710e23b 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -1,12 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
- tools:context=".MainActivity">
+ android:fitsSystemWindows="true">
<WebView
+ android:id="@+id/webview"
android:layout_width="match_parent"
- android:layout_height="match_parent" />
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true" />
+</FrameLayout>
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index be874e54..07128ac1 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -1,9 +1,7 @@
-<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
- <!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
@@ -15,8 +13,15 @@
<item name="android:background">@null</item>
</style>
-
+ <!-- 🚨 This is your actual launch theme. It must stop fullscreen mode. -->
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
+
+ <!-- ✅ These lines are critical -->
+ <item name="android:windowFullscreen">false</item>
+ <item name="android:windowTranslucentStatus">false</item>
+ <item name="android:fitsSystemWindows">true</item>
+ <item name="android:statusBarColor">#000000</item> <!-- Or match your splash background -->
</style>
-</resources> \ No newline at end of file
+
+</resources>
diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle
index 9a5fa872..c736879f 100644
--- a/android/capacitor.settings.gradle
+++ b/android/capacitor.settings.gradle
@@ -1,3 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+
+include ':capacitor-status-bar'
+project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
diff --git a/android/variables.gradle b/android/variables.gradle
index 8ef305d0..b0e92003 100644
--- a/android/variables.gradle
+++ b/android/variables.gradle
@@ -1,7 +1,7 @@
ext {
minSdkVersion = 22
- compileSdkVersion = 34
- targetSdkVersion = 34
+ compileSdkVersion = 35
+ targetSdkVersion = 35
androidxActivityVersion = '1.8.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
@@ -13,4 +13,4 @@ ext {
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
-} \ No newline at end of file
+}
diff --git a/bun.lockb b/bun.lockb
index 9b8ffada..7cb98a68 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --git a/capacitor.config.json b/capacitor.config.json
index 585ff21c..f74633b3 100644
--- a/capacitor.config.json
+++ b/capacitor.config.json
@@ -1,13 +1,22 @@
{
- "appId": "io.coinos",
- "appName": "Coinos",
- "server": {
- "hostname": "coinos.io",
- "url": "https://coinos.io/start"
- },
- "android": {
- "webContentsDebuggingEnabled": true,
- "allowMixedContent": true,
- "useBrowserHistory": true
- }
+ "appId": "io.coinos",
+ "appName": "Coinos",
+ "server": {
+ "hostname": "coinos.io",
+ "url": "https://coinos.io/start"
+ },
+ "android": {
+ "webContentsDebuggingEnabled": true,
+ "allowMixedContent": true,
+ "useBrowserHistory": true,
+ "immersive": false
+ },
+ "plugins": {
+ "NfcWriter": {},
+ "StatusBar": {
+ "overlaysWebView": false,
+ "style": "DARK",
+ "backgroundColor": "#ffffffff"
+ }
+ }
}
diff --git a/package.json b/package.json
index 449e03a2..15459627 100644
--- a/package.json
+++ b/package.json
@@ -1,63 +1,61 @@
{
"name": "coinos",
- "version": "1.0.4",
+ "version": "1.0.9",
"devDependencies": {
- "@capacitor/android": "^6.2.0",
+ "@capacitor/android": "^7.4.3",
"@capacitor/assets": "^3.0.5",
- "@capacitor/cli": "^6.2.0",
- "@capacitor/core": "^7.0.1",
- "@capacitor/ios": "^6.1.2",
- "@cashu/cashu-ts": "^2.0.0-rc4",
+ "@capacitor/cli": "^7.4.3",
+ "@capacitor/core": "^7.4.3",
+ "@capacitor/ios": "^7.4.3",
+ "@cashu/cashu-ts": "^2.7.1",
"@iconify/iconify": "^3.1.1",
- "@scure/base": "^1.1.8",
- "@scure/bip39": "^1.2.1",
- "@scure/btc-signer": "^1.3.2",
- "@sveltejs/adapter-node": "^2.0.0",
- "@sveltejs/kit": "^2.8.0",
- "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "@scure/base": "^2.0.0",
+ "@scure/bip39": "^2.0.0",
+ "@scure/btc-signer": "^2.0.1",
+ "@sveltejs/adapter-node": "^5.3.1",
+ "@sveltejs/kit": "^2.37.1",
+ "@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/uuid": "^10.0.0",
- "@zerodevx/svelte-toast": "^0.9.5",
- "applesauce-signer": "^0.10.0",
- "autoprefixer": "^10.4.13",
- "barcode-detector": "^3.0.1",
+ "@zerodevx/svelte-toast": "^0.9.6",
+ "autoprefixer": "^10.4.21",
+ "barcode-detector": "^3.0.5",
"bech32": "^2.0.0",
- "bip21": "^2.0.3",
- "bitcoin-address-validation": "^2.2.1",
+ "bip21": "^3.0.0",
+ "bitcoin-address-validation": "^3.0.0",
"buffer": "^6.0.3",
- "daisyui": "^4.12.14",
- "date-fns": "^2.29.3",
- "esptool-js": "^0.5.2",
+ "daisyui": "^5.1.9",
+ "date-fns": "^4.1.0",
+ "esptool-js": "^0.5.7",
"html5-qrcode": "^2.3.8",
- "iconify-icon": "^2.1.0",
+ "iconify-icon": "^3.0.0",
"js-cookie": "^3.0.5",
- "leaflet": "^1.9.3",
+ "leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
- "maplibre-gl": "^3.3.1",
- "nostr-tools": "^2.10.4",
- "postcss": "^8.4.20",
- "prettier": "^3.1.0",
- "prettier-plugin-svelte": "^3.2.6",
- "qr-code-styling": "1.6.0-rc.1",
+ "maplibre-gl": "^5.7.1",
+ "nostr-tools": "^2.16.2",
+ "prettier": "^3.6.2",
+ "prettier-plugin-svelte": "^3.4.0",
+ "qr-code-styling": "1.9.2",
"qr-scanner": "^1.4.2",
"qrcode-base64": "^1.0.1",
- "rollup": "^2.79.1",
+ "rollup": "^4.50.1",
"screenfull": "^6.0.2",
- "svelte": "^5.1.12",
- "svelte-adapter-bun": "^0.5.1",
- "svelte-confetti": "^2.0.0",
- "svelte-dnd-action": "^0.9.39",
- "svelte-outclick": "^3.3.3",
- "svelte-preprocess": "^6.0.0",
- "svelte-virtual-scroll-list": "^1.1.0",
- "sveltekit-i18n": "^2.2.2",
- "tailwind-scrollbar": "^2.1.0",
- "tailwindcss": "^3.2.4",
- "typescript": "^5.5.4",
- "uint8array-extras": "^1.0.0",
+ "svelte": "^5.38.8",
+ "svelte-adapter-bun": "^0.5.2",
+ "svelte-confetti": "^2.3.2",
+ "svelte-dnd-action": "^0.9.65",
+ "svelte-outclick": "^4.1.2",
+ "svelte-preprocess": "^6.0.3",
+ "svelte-virtual-scroll-list": "^1.3.0",
+ "sveltekit-i18n": "^2.4.2",
+ "tailwind-scrollbar": "^4.0.2",
+ "tailwindcss": "^4.1.13",
+ "typescript": "^5.9.2",
+ "uint8array-extras": "^1.5.0",
"unique-names-generator": "^4.7.1",
- "uuid": "^9.0.0",
- "vite": "^5.4.4",
- "ws": "^8.18.0"
+ "uuid": "^13.0.0",
+ "vite": "^6.3.5",
+ "ws": "^8.18.3"
},
"scripts": {
"dev": "vite dev --host 0.0.0.0",
@@ -69,5 +67,13 @@
"lint": "prettier --check .",
"format": "prettier --write ."
},
- "type": "module"
+ "type": "module",
+ "dependencies": {
+ "@capacitor/status-bar": "^7.0.3",
+ "@tailwindcss/postcss": "^4.1.13",
+ "@tailwindcss/vite": "^4.1.13",
+ "canvas": "^3.2.0",
+ "cordova-plugin-android-notch": "^1.0.3",
+ "qrcode": "^1.5.4"
+ }
}
diff --git a/postcss.config.cjs b/postcss.config.cjs
index 12a703d9..b4bee663 100644
--- a/postcss.config.cjs
+++ b/postcss.config.cjs
@@ -1,6 +1,6 @@
module.exports = {
plugins: {
- tailwindcss: {},
+ '@tailwindcss/postcss': {},
autoprefixer: {},
},
};
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/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..bdc2c4e5
--- /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")}
+ </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/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..ad32cfbd 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": "العودة إلى التمويل"
- }
+ },
+ "v3Banner": "جربي النسخة الجديدة من \"الكينو\" في \"ف3.كينو\"",
+ "dismiss": "الانصراف"
}
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 5a33c5dd..0bb833d3 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": "অর্থ পাঠানো"
- }
+ },
+ "v3Banner": "ভি",
+ "dismiss": "বাতিল"
}
diff --git a/src/locales/de.json b/src/locales/de.json
index 8c268158..e472a98f 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"
- }
+ },
+ "v3Banner": "Probieren Sie die neue Version von Coinos bei v3.coinos.io aus",
+ "dismiss": "Abtreten"
}
diff --git a/src/locales/el.json b/src/locales/el.json
index 2d25684a..20589da0 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": "Επιστροφή στο κεφάλαιο"
- }
+ },
+ "v3Banner": "Δοκιμάστε τη νέα έκδοση του Coinos στο v3.coinos.io",
+ "dismiss": "Ελεύθεροι"
}
diff --git a/src/locales/en.json b/src/locales/en.json
index fa3ad96d..4dac5b99 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -102,23 +102,19 @@
"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."
+ "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": "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."
+ "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 recommend entering sales into your existing PoS as cash transactions."
+ "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 or pay your expenses with it by getting your staff, suppliers and landlord to take it."
+ "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": {
@@ -145,14 +141,14 @@
},
"step3": {
"header": "3. That's all!",
- "description": "Spend your coin immediately or watch it grow in value."
+ "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": "Set memo",
+ "setMemo": "Add a note",
"memo": "Memo",
"done": "Done",
"paymentSuccessful": "Payment received!",
@@ -193,7 +189,7 @@
"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",
+ "resetDesc": "Please send an email with your username and any other details you can recall to",
"optional": "Optional",
"submit": "Submit",
"token": "2FA Token",
@@ -240,7 +236,7 @@
"user": {
"loadMore": "Load more",
"url": "Public URL",
- "lnurl": "Reusable payment code",
+ "lnurl": "Payment code",
"install": "Install app",
"moreDetails": "Profile details",
"noMessages": "You have not sent or received any messages",
@@ -296,6 +292,12 @@
"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",
@@ -306,15 +308,15 @@
"memoPromptPlaceholder": "e.g. Please enter your name for the order",
"rememberFor": "Remember for",
"import": "Import secret key",
- "destination": "Autowithdrawal address",
+ "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": "Autowithdrawal threshold",
+ "threshold": "Auto withdrawal threshold",
"thresholdDesc": "When your balance exceeds this amount, a withdrawal will be triggered",
- "autoWithdraw": "Autowithdrawal",
+ "autoWithdraw": "Auto withdrawal",
"autoWithdrawDescription": "Enable withdrawals to an external address when your balance reaches a given threshold",
"notifications": "Email notifications",
"pushNotifications": "Push notifications",
@@ -326,6 +328,11 @@
"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",
@@ -335,7 +342,7 @@
"revealNsec": "Reveal secret key",
"hideSeed": "Hide seed phrase",
"revealSeed": "Reveal seed phrase",
- "saveSettings": "Save Settings",
+ "saveSettings": "Save settings",
"bannerImage": "Banner Image",
"profileImage": "Profile Image",
"oneTimeCode": "Enter one-time code",
@@ -348,7 +355,13 @@
"locale": "Language",
"username": "Username",
"email": "Email",
- "newPassword": "Change Password",
+ "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",
@@ -398,6 +411,13 @@
}
},
"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",
@@ -413,6 +433,9 @@
"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",
@@ -440,7 +463,9 @@
"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.",
+ "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",
@@ -453,7 +478,7 @@
"voucher": "Payment code",
"qr": "QR",
"copy": "Copy",
- "amountPrompt": "Prompt for amount",
+ "amountPrompt": "Always prompt for amount",
"shareLink": "Share",
"copyLink": "Copy link",
"copyText": "Copy text",
@@ -537,5 +562,7 @@
"nl": "Nederlands",
"th": "ไทย",
"pl": "Polski"
- }
+ },
+ "v3Banner": "Try out the new version of Coinos at v3.coinos.io",
+ "dismiss": "Dismiss"
}
diff --git a/src/locales/es.json b/src/locales/es.json
index da65ac4a..85a54954 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"
- }
+ },
+ "v3Banner": "Pruebe la nueva versión de Coinos en v3.coinos.io",
+ "dismiss": "Retírense"
}
diff --git a/src/locales/fa.json b/src/locales/fa.json
index bd26974a..2627ba81 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": "بازگشت به صندوق"
- }
+ },
+ "v3Banner": "نسخه جدید Coinos را در v3.coinos.io امتحان کنید",
+ "dismiss": "عدم فرار"
}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index b4361623..d7a5bcd8 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"
- }
+ },
+ "v3Banner": "Essayez la nouvelle version de Coinos à v3.coinos.io",
+ "dismiss": "Rejet"
}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 2975745f..9204e1a8 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": "फंड वापस"
- }
+ },
+ "v3Banner": "V3.coinos.io पर Coinos का नया संस्करण आज़माएं",
+ "dismiss": "Dismis"
}
diff --git a/src/locales/it.json b/src/locales/it.json
index 14c8b5e5..3d960c20 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"
- }
+ },
+ "v3Banner": "Prova la nuova versione di Coinos a v3.coinos.io",
+ "dismiss": "Oggetto"
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 00d085ff..63daced7 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": "資金を調達する"
- }
+ },
+ "v3Banner": "新しいバージョンの Coinos を v3.coinos.io で試してみましょう",
+ "dismiss": "免責事項"
}
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 5162846e..a0bba6f6 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": "자금 지원"
- }
+ },
+ "v3Banner": "V3.coinos.io에서 Coinos의 새로운 버전을 시도",
+ "dismiss": "뚱 베어"
}
diff --git a/src/locales/nl.json b/src/locales/nl.json
index 90e77b7a..96118e20 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"
- }
+ },
+ "v3Banner": "Probeer de nieuwe versie van Coinos op v3.coinos.io",
+ "dismiss": "Ingetrokken"
}
diff --git a/src/locales/pl.json b/src/locales/pl.json
index 7704ebd6..0f5cc96b 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"
- }
+ },
+ "v3Banner": "Wypróbuj nową wersję Coinos na v3.coinos.io",
+ "dismiss": "Rozejść się"
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 13d60e4a..3f7c99d2 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"
- }
+ },
+ "v3Banner": "Experimente a nova versão do Coinos no v3.coinos.io",
+ "dismiss": "Licenciamento"
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 18deefed..84ad61b5 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": "Назад в фонд"
- }
+ },
+ "v3Banner": "Попробуйте новую версию Coinos на v3.coinos.io",
+ "dismiss": "Увольнение"
}
diff --git a/src/locales/th.json b/src/locales/th.json
index aea599e5..aaa64a89 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": "กลับไปยังกองทุน"
- }
+ },
+ "v3Banner": "ลองใหม่ของวิโนสที่ v3.conos.io",
+ "dismiss": "ไม่สนใจ"
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 0034cc70..e0279565 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"
- }
+ },
+ "v3Banner": "V3.coinos.io'daki Coinos'un yeni versiyonunu deneyin",
+ "dismiss": "Başarısızlık"
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index dd8f1bdd..8cd2cd8f 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": "回到资金"
- }
+ },
+ "v3Banner": "在v3.coinos.io试出Coinos的新版本",
+ "dismiss": "开除"
}
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 f3e018ee..b384344e 100644
--- a/src/routes/(app)/[username]/(profile)/+layout.svelte
+++ b/src/routes/(app)/[username]/(profile)/+layout.svelte
@@ -128,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
@@ -181,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>
@@ -232,14 +236,12 @@
{lnurl}
</div>
<div class="flex mb-auto gap-1">
- <button class="my-auto" onclick={() => copy(lnurl)}
+ <button class="my-auto" onclick={() => copy(`lightning:${lnurl}`)}
><iconify-icon noobserver icon="ph:copy-bold" width="32"
></iconify-icon></button
>
<a
- href={`/qr/${encodeURIComponent(
- `${$page.url.protocol}//${lnurl}`,
- )}`}
+ href={`/${n}/accepted/${encodeURIComponent(`lightning:${lnurl}`)}`}
class="my-auto"
>
<iconify-icon noobserver icon="ph:qr-code-bold" width="32"
@@ -248,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}
@@ -328,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 1ee182b1..04cff338 100644
--- a/src/routes/(app)/ln/[lnurl]/+page.server.ts
+++ b/src/routes/(app)/ln/[lnurl]/+page.server.ts
@@ -1,13 +1,15 @@
import { PUBLIC_DOMAIN } from "$env/static/public";
import getRates from "$lib/rates";
-import { auth, fd, get, post, types } from "$lib/utils";
+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;
@@ -23,36 +25,45 @@ export async function load({ params, parent }) {
}
let { callback, minSendable, maxSendable, comment, tag } = data;
- if (callback.includes(PUBLIC_DOMAIN)) {
- const username = url.split(`https://${PUBLIC_DOMAIN}/p/`)[1];
- // const inv = await post("/invoice", {
- // invoice: { type: types.lightning, amount: minSendable / 1000 },
- // user: { username },
- // });
- // const { id: iid } = inv;
- let redir = `/pay/${username}`;
- if (minSendable === maxSendable && minSendable > 0) redir += `/${minSendable / 1000}`;
- redirect(307, redir);
- }
if (tag === "payRequest" && minSendable === maxSendable) {
minSendable = Math.round(minSendable / 1000);
maxSendable = Math.round(maxSendable / 1000);
const amount = minSendable;
- 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 invoice;
+ try {
+ invoice = await get(`/invoice/${pr}`);
+ } catch (e) {}
+
+ if (invoice) redirect(307, `/invoice/${invoice.id}`);
- const { pr } = await fetch(url).then((r) => (r as Response).json());
- console.log("PR", pr);
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;
}
@@ -71,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);
@@ -115,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..48ae6a96
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.server.ts
@@ -0,0 +1,103 @@
+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
+ if (!/insufficient|balance|not enough|fee/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.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 671de954..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,7 +35,7 @@
<div class="text-xl text-secondary break-all">{address}</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:bounties@coinos.io"
- class="text-black hover:underline">bounties@coinos.io</a
- > and we'll set up an appointment with you and the business owner to get started.
+ class="text-blue-600 hover:underline">bounties@coinos.io</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:support@coinos.io" class="font-bold"
- >support@coinos.io</a
- >
- </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:support@coinos.io" class="font-bold">support@coinos.io</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>
diff --git a/static/firmware/boot_app0.bin b/static/firmware/boot_app0.bin
new file mode 100644
index 00000000..13562cab
--- /dev/null
+++ b/static/firmware/boot_app0.bin
Binary files differ
diff --git a/static/firmware/bootloader.bin b/static/firmware/bootloader.bin
new file mode 100644
index 00000000..8d855ec0
--- /dev/null
+++ b/static/firmware/bootloader.bin
Binary files differ
diff --git a/static/firmware/coinos-pos.bin b/static/firmware/coinos-pos.bin
new file mode 100644
index 00000000..de1ff071
--- /dev/null
+++ b/static/firmware/coinos-pos.bin
Binary files differ
diff --git a/static/firmware/manifest.json b/static/firmware/manifest.json
new file mode 100644
index 00000000..2ddcf9cc
--- /dev/null
+++ b/static/firmware/manifest.json
@@ -0,0 +1,12 @@
+{
+ "version": "2026.09.04-0ec9bae",
+ "commit": "0ec9bae",
+ "built": "2026-09-05T02:51:16Z",
+ "chip": "ESP32-C3",
+ "parts": [
+ { "path": "/firmware/bootloader.bin", "address": 0, "size": 18688 },
+ { "path": "/firmware/partitions.bin", "address": 32768, "size": 3072 },
+ { "path": "/firmware/boot_app0.bin", "address": 57344, "size": 8192 },
+ { "path": "/firmware/coinos-pos.bin", "address": 65536, "size": 1298928 }
+ ]
+}
diff --git a/static/firmware/partitions.bin b/static/firmware/partitions.bin
new file mode 100644
index 00000000..21800fab
--- /dev/null
+++ b/static/firmware/partitions.bin
Binary files differ
diff --git a/static/icon-512x512.webp b/static/icon-512x512.webp
new file mode 100644
index 00000000..1b86eec4
--- /dev/null
+++ b/static/icon-512x512.webp
Binary files differ
diff --git a/static/images/arrow.png b/static/images/arrow.png
new file mode 100644
index 00000000..63ae4c0f
--- /dev/null
+++ b/static/images/arrow.png
Binary files differ
diff --git a/static/images/bitcoin-logo.png b/static/images/bitcoin-logo.png
new file mode 100644
index 00000000..c9bdae0a
--- /dev/null
+++ b/static/images/bitcoin-logo.png
Binary files differ
diff --git a/static/images/lntap.png b/static/images/lntap.png
new file mode 100644
index 00000000..2255688f
--- /dev/null
+++ b/static/images/lntap.png
Binary files differ
diff --git a/static/images/nfc.png b/static/images/nfc.png
new file mode 100644
index 00000000..9d86d727
--- /dev/null
+++ b/static/images/nfc.png
Binary files differ
diff --git a/static/images/square.svg b/static/images/square.svg
new file mode 100644
index 00000000..14432609
--- /dev/null
+++ b/static/images/square.svg
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" height="800px" width="800px" version="1.1" id="XMLID_21_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 24 24" xml:space="preserve">
+<g id="payment-square">
+ <g>
+ <path d="M17,22H7c-2.8,0-5-2.2-5-5V7c0-2.8,2.2-5,5-5h10c2.8,0,5,2.2,5,5v10C22,19.7,19.8,22,17,22z M7,4C5.3,4,4,5.3,4,7v10
+ c0,1.7,1.3,3,3,3h10c1.7,0,3-1.3,3-3V7c0-1.7-1.3-3-3-3H7z"/>
+ </g>
+ <g>
+ <path d="M14,9h-4c-0.6,0-1,0.4-1,1v4c0,0.6,0.4,1,1,1h4c0.6,0,1-0.4,1-1v-4C15,9.4,14.6,9,14,9z"/>
+ </g>
+</g>
+</svg> \ No newline at end of file
diff --git a/static/images/tap.png b/static/images/tap.png
new file mode 100644
index 00000000..21962b2f
--- /dev/null
+++ b/static/images/tap.png
Binary files differ
diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest
index 2076ccf4..79b99de2 100644
--- a/static/manifest.webmanifest
+++ b/static/manifest.webmanifest
@@ -9,86 +9,18 @@
"short_name": "Coinos",
"icons": [
{
- "src": "/icon-192x192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": "/icon-256x256.png",
- "sizes": "256x256",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": "/icon-384x384.png",
- "sizes": "384x384",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": "/icon-512x512.png",
+ "src": "/icon-512x512.webp",
"sizes": "512x512",
- "type": "image/png",
+ "type": "image/webp",
"purpose": "maskable"
- },
- {
- "src": "/icon-192x192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "any"
- },
- {
- "src": "/icon-256x256.png",
- "sizes": "256x256",
- "type": "image/png",
- "purpose": "any"
- },
- {
- "src": "/icon-384x384.png",
- "sizes": "384x384",
- "type": "image/png",
- "purpose": "any"
- },
- {
- "src": "/icon-512x512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "any"
}
],
- "screenshots": [
- {
- "src": "/home.png",
- "sizes": "801x920",
- "type": "image/png",
- "label": "Homepage",
- "form_factor": "wide"
- },
- {
- "src": "/receive.png",
- "sizes": "801x920",
- "type": "image/png",
- "label": "Receive",
- "form_factor": "wide"
- },
+ "protocol_handlers": [
{
- "src": "/home.png",
- "sizes": "801x920",
- "type": "image/png",
- "label": "Homepage",
- "form_factor": "narrow"
+ "protocol": "web+bitcoin",
+ "url": "/send/%s"
},
{
- "src": "/receive.png",
- "sizes": "801x920",
- "type": "image/png",
- "label": "Receive",
- "form_factor": "narrow"
- }
- ],
- "protocol_handlers": [
- {
"protocol": "bitcoin",
"url": "/send/%s"
}
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index 0618574a..ed0ef79b 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -1,32 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{html,js,svelte}"],
- plugins: [require("tailwind-scrollbar"), require("daisyui")],
- daisyui: {
- themes: [
- {
- light: {
- ...require("daisyui/src/theming/themes").lofi,
- gradient: "#C3CFE2",
- error: "#b91c1c",
- },
- },
- {
- dark: {
- ...require("daisyui/src/theming/themes").black,
- secondary: "#CCC",
- gradient: "#C3CFE2",
- error: "#f87171",
- },
- },
- ], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"]
- darkTheme: "dark", // name of one of the included themes for dark mode
- base: true, // applies background color and foreground color for root element by default
- styled: true, // include daisyUI colors and design decisions for all components
- utils: true, // adds responsive and modifier utility classes
- prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
- logs: false, // Shows info about daisyUI version and used config in the console when building your CSS
- themeRoot: ":root", // The element that receives theme color CSS variables
- },
+ plugins: [require("tailwind-scrollbar")],
safelist: ["w-16", "h-16", "w-20", "h-20", "w-24", "h-24"],
};
diff --git a/vite.config.js b/vite.config.js
index 8909c652..7b04a036 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,10 +1,12 @@
import { sveltekit } from "@sveltejs/kit/vite";
+import tailwindcss from "@tailwindcss/vite";
+
+import { defineConfig } from "vite";
import path from "path";
-/** @type {import('vite').UserConfig} */
-const config = {
- plugins: [sveltekit()],
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
resolve: {
alias: {
$comp: path.resolve("src/components"),
@@ -28,6 +30,5 @@ const config = {
}
: undefined,
},
-};
+});
-export default config;