aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2022-11-02 19:47:29 -0700
committerAdam Soltys <[email protected]>2022-11-02 19:48:18 -0700
commitf0d88333750a2865d3016e92620a87269160effa (patch)
tree1cc276efeb5287e1fc176ec60d46aed091ab08ca /src/routes/(app)
parentb66477f0912349ef91adaff8c57ac787b5a56447 (diff)
downloadcoinos-ui-f0d88333750a2865d3016e92620a87269160effa.tar.gz
coinos-ui-f0d88333750a2865d3016e92620a87269160effa.zip
move to app layout
Diffstat (limited to 'src/routes/(app)')
-rw-r--r--src/routes/(app)/+error.svelte26
-rw-r--r--src/routes/(app)/+layout.server.js42
-rw-r--r--src/routes/(app)/+layout.svelte87
-rw-r--r--src/routes/(app)/[username]/+layout.svelte7
-rw-r--r--src/routes/(app)/[username]/+page.server.js6
-rw-r--r--src/routes/(app)/[username]/+page.svelte85
-rw-r--r--src/routes/(app)/[username]/bitcoin/+page.server.js4
-rw-r--r--src/routes/(app)/[username]/bitcoin/+page.svelte0
-rw-r--r--src/routes/(app)/[username]/customers/+page.svelte0
-rw-r--r--src/routes/(app)/[username]/dashboard/+page.server.js1
-rw-r--r--src/routes/(app)/[username]/dashboard/+page.svelte76
-rw-r--r--src/routes/(app)/[username]/receive/+page.server.js5
-rw-r--r--src/routes/(app)/[username]/receive/+page.svelte30
-rw-r--r--src/routes/(app)/[username]/receive/[network]/[amount]/+page.server.js0
-rw-r--r--src/routes/(app)/[username]/receive/[network]/[amount]/+page.svelte1
-rw-r--r--src/routes/(app)/[username]/settings/+page.server.js21
-rw-r--r--src/routes/(app)/[username]/settings/+page.svelte104
-rw-r--r--src/routes/(app)/[username]/settings/_account.svelte255
-rw-r--r--src/routes/(app)/[username]/settings/_pos.svelte27
-rw-r--r--src/routes/(app)/[username]/settings/_security.svelte218
-rw-r--r--src/routes/(app)/[username]/support/+page.server.js13
-rw-r--r--src/routes/(app)/[username]/support/+page.svelte76
-rw-r--r--src/routes/(app)/[username]/transactions/[...page]/+page.server.js24
-rw-r--r--src/routes/(app)/[username]/transactions/[...page]/+page.svelte164
-rw-r--r--src/routes/(app)/disable2fa/+server.js8
-rw-r--r--src/routes/(app)/enable2fa/+server.js8
-rw-r--r--src/routes/(app)/invoice/[id]/+layout.server.js14
-rw-r--r--src/routes/(app)/invoice/[id]/+layout.svelte1
-rw-r--r--src/routes/(app)/invoice/[id]/+page.server.js5
-rw-r--r--src/routes/(app)/invoice/[id]/+page.svelte134
-rw-r--r--src/routes/(app)/invoice/[id]/paid/+page.svelte40
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.server.js5
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.svelte251
-rw-r--r--src/routes/(app)/jobs/+page.svelte30
-rw-r--r--src/routes/(app)/launch/+page.server.js6
-rw-r--r--src/routes/(app)/launch/+page.svelte103
-rw-r--r--src/routes/(app)/launch/purchase/+page.server.js45
-rw-r--r--src/routes/(app)/launch/purchase/+page.svelte67
-rw-r--r--src/routes/(app)/launch/purchase/card/+page.server.js44
-rw-r--r--src/routes/(app)/launch/purchase/card/+page.svelte77
-rw-r--r--src/routes/(app)/launch/register/+page.svelte1
-rw-r--r--src/routes/(app)/launch/thanks/[asset]/+page.server.js1
-rw-r--r--src/routes/(app)/launch/thanks/[asset]/+page.svelte29
-rw-r--r--src/routes/(app)/launch/ticket/+page.server.js9
-rw-r--r--src/routes/(app)/launch/ticket/+page.svelte35
-rw-r--r--src/routes/(app)/locations/+server.js7
-rw-r--r--src/routes/(app)/login/+page.server.js18
-rw-r--r--src/routes/(app)/login/+page.svelte7
-rw-r--r--src/routes/(app)/logout/+page.server.js12
-rw-r--r--src/routes/(app)/logout/+page.svelte0
-rw-r--r--src/routes/(app)/news/+page.svelte115
-rw-r--r--src/routes/(app)/register/+page.server.js28
-rw-r--r--src/routes/(app)/register/+page.svelte15
-rw-r--r--src/routes/(app)/scan/+page.svelte44
-rw-r--r--src/routes/(app)/send/+layout.svelte18
-rw-r--r--src/routes/(app)/send/[...text]/+page.server.js60
-rw-r--r--src/routes/(app)/send/[...text]/+page.svelte94
-rw-r--r--src/routes/(app)/send/[uuid=uuid]/+page.server.js28
-rw-r--r--src/routes/(app)/send/[uuid=uuid]/+page.svelte101
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/+page.server.js16
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/+page.svelte58
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/+page.server.js27
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/+page.svelte70
-rw-r--r--src/routes/(app)/send/lnurl/+page.svelte0
-rw-r--r--src/routes/(app)/send/user/[username]/[...amount]/+page.server.js29
-rw-r--r--src/routes/(app)/send/user/[username]/[...amount]/+page.svelte103
-rw-r--r--src/routes/(app)/sent/+page.svelte34
-rw-r--r--src/routes/(app)/user/+server.js15
-rw-r--r--src/routes/(app)/withdraw/+server.js8
69 files changed, 3092 insertions, 0 deletions
diff --git a/src/routes/(app)/+error.svelte b/src/routes/(app)/+error.svelte
new file mode 100644
index 00000000..d561b111
--- /dev/null
+++ b/src/routes/(app)/+error.svelte
@@ -0,0 +1,26 @@
+<script>
+ import { page } from '$app/stores';
+ import { Icon, PageNotFound } from '$comp';
+ import { t } from '$lib/translations';
+ import { back } from '$lib/utils';
+
+ export let data;
+ let { user } = data;
+</script>
+
+{#if $page.error.message.includes('not found') || $page.status === 404}
+ <PageNotFound />
+{:else}
+ <div class="container px-4 max-w-md mx-auto mt-20 text-center">
+ <h1 class="text-center text-3xl md:text-4xl font-semibold mb-8">
+ {$t('error.header')}
+ </h1>
+
+ <p class="px-1 break-all mb-8">{$page.error.message}</p>
+
+ <button
+ class="rounded-full bg-black text-white py-2 px-5 font-bold hover:opacity-80 mb-2"
+ on:click={back}>Back</button
+ >
+ </div>
+{/if}
diff --git a/src/routes/(app)/+layout.server.js b/src/routes/(app)/+layout.server.js
new file mode 100644
index 00000000..6d1fc3e0
--- /dev/null
+++ b/src/routes/(app)/+layout.server.js
@@ -0,0 +1,42 @@
+import { error, redirect } from '@sveltejs/kit';
+import { protectedRoutes } from '$lib/utils';
+import { get, post, auth } from '$lib/utils';
+import { env } from '$env/dynamic/public';
+
+export const { PUBLIC_BTC: btc } = env;
+
+export async function load({ cookies, locals, request, url, params }) {
+ url.pathname;
+ let token = cookies.get('token');
+ let user;
+ let rates;
+ let subject;
+
+ if (params.username) {
+ subject = await get(`/users/${params.username}`);
+ }
+
+ if (token && token !== 'undefined') {
+ user = locals.user;
+
+ if (protectedRoutes.find((p) => url.pathname.match(p))) {
+ if (user.username !== params.username) throw error(404, 'user not found');
+ }
+
+ if (['/login', '/register'].includes(url.pathname))
+ throw redirect(307, `/${user.username}/dashboard`);
+
+ let account = user.accounts.find((a) => a.asset === btc && !a.pubkey);
+ if (account) {
+ let { id } = account;
+ if (user.account_id !== id) {
+ user = await post('/shiftAccount', { id }, auth(cookies));
+ }
+ }
+ }
+
+ let rate = await get('/rate');
+ rates = await get('/rates');
+
+ return { subject, user, token, rate, rates };
+}
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
new file mode 100644
index 00000000..10b16449
--- /dev/null
+++ b/src/routes/(app)/+layout.svelte
@@ -0,0 +1,87 @@
+<script>
+ import { SvelteToast } from '@zerodevx/svelte-toast';
+ import { onDestroy, onMount } from 'svelte';
+ import { close, connect, send } from '$lib/socket';
+ import { last, selectedRate, rate, user } from '$lib/store';
+ import { page } from '$app/stores';
+ import { browser } from '$app/environment';
+ import { goto } from '$app/navigation';
+ import { LoadingSplash } from '$comp';
+ import { warning, protectedRoutes } from '$lib/utils';
+ import { t, locale } from '$lib/translations';
+
+ const localeLocalStorageKey = 'sveltekit-i18n-locale';
+
+ export let data;
+
+ let { subject } = $page.data;
+ let r, u, token, rates;
+
+ $: update(data);
+ let update = () => {
+ ({ user: u, rate: r, token, rates } = data);
+ $rate = r;
+ $user = u;
+ };
+
+ $: $page.url.searchParams.get('logout') && close();
+
+ $: $selectedRate = r * (rates[u?.currency || 'USD'] / rates.USD);
+
+ onMount(() => {
+ let localStorageLocale = localStorage.getItem(localeLocalStorageKey);
+ if (localStorageLocale) locale.set(localStorageLocale);
+
+ locale.subscribe((lng) => {
+ if (lng) localStorage.setItem(localeLocalStorageKey, lng);
+ });
+
+ browser && checkSocket();
+ });
+
+ $: browser && connect(token);
+
+ let lost,
+ timer,
+ counter = 0;
+
+ let checkSocket = () => {
+ counter++;
+ lost = Date.now() - $last > 15000;
+ if (lost) close();
+ if (counter > 5) {
+ send('heartbeat');
+ counter = 0;
+ }
+ timer = setTimeout(checkSocket, 1000);
+ };
+
+ onDestroy(() => browser && clearTimeout(timer));
+</script>
+
+<svelte:head>
+ {#if subject}
+ <title>coinos - {subject.username}</title>
+ <meta name="lightning" content={`lnurlp:${subject.username}@coinos.io`} />
+ {:else}
+ <title>coinos</title>
+ {/if}
+
+ {#if subject?.profile}
+ <meta name="og:image" content={`/api/public/${subject.username}-profile.webp`} />
+ {:else}
+ <meta property="og:image" content="/icons/logo.svg" />
+ {/if}
+</svelte:head>
+
+<SvelteToast options={{ reversed: true, intro: { y: 192 } }} />
+
+{#if lost}
+ <div class="fixed bottom-12 right-12 text-red-600 bg-white z-50 px-4 py-2 rounded-full border">
+ Lost connection to server, try refreshing
+ </div>
+{/if}
+
+<main data-sveltekit-prefetch>
+ <slot />
+</main>
diff --git a/src/routes/(app)/[username]/+layout.svelte b/src/routes/(app)/[username]/+layout.svelte
new file mode 100644
index 00000000..b2efa894
--- /dev/null
+++ b/src/routes/(app)/[username]/+layout.svelte
@@ -0,0 +1,7 @@
+<script>
+ import { AppHeader } from '$comp';
+ export let data;
+</script>
+
+<AppHeader {data} />
+<slot />
diff --git a/src/routes/(app)/[username]/+page.server.js b/src/routes/(app)/[username]/+page.server.js
new file mode 100644
index 00000000..0840161a
--- /dev/null
+++ b/src/routes/(app)/[username]/+page.server.js
@@ -0,0 +1,6 @@
+import { get, post } from '$lib/utils';
+
+export async function load({ params }) {
+ let { username } = params;
+ return get(`/users/${username}`);
+}
diff --git a/src/routes/(app)/[username]/+page.svelte b/src/routes/(app)/[username]/+page.svelte
new file mode 100644
index 00000000..810fb567
--- /dev/null
+++ b/src/routes/(app)/[username]/+page.svelte
@@ -0,0 +1,85 @@
+<script>
+ import { browser } from '$app/environment';
+ import { onMount } from 'svelte';
+ import { Icon, PageNotFound } from '$comp';
+ import { copy } from '$lib/utils';
+ import { t } from '$lib/translations';
+ export let data;
+
+ let { subject } = data;
+
+ let lightningAddress = `${data?.username}@coinos.io`;
+
+ onMount(async () => {
+ if (browser) {
+ let { default: QRCodeStyling } = await import('qr-code-styling');
+ const qrCode = new QRCodeStyling({
+ width: window.screen.width < 640 ? 250 : 300,
+ type: 'canvas',
+ data: lightningAddress,
+ image: '/images/invoice.svg',
+ backgroundOptions: {
+ color: 'rgba(0, 0, 0, 0)'
+ },
+ dotsOptions: {
+ type: 'rounded'
+ },
+ imageOptions: {
+ hideBackgroundDots: false
+ }
+ });
+
+ qrCode.append(document.getElementById('qr'));
+ }
+ });
+
+ let followed = false;
+</script>
+
+<div class="container px-4 flex flex-wrap">
+ <div class="p-10 pt-20">
+ <h1 class="text-3xl font-bold">{subject.username}</h1>
+
+ <p class="text-secondary w-64 mb-4">
+ {subject.address && subject.address !== 'null' ? subject.address : ''}
+ </p>
+
+ <div class="flex gap-2">
+ <button
+ class="border rounded-full py-2 w-28 font-semibold flex justify-center items-center text-sm"
+ on:click={() => (followed = !followed)}
+ ><Icon icon={followed ? 'minus' : 'plus'} style="mr-1" />
+ {$t('user.' + (followed ? 'following' : 'follow'))}
+ </button>
+
+ <a href={`/${subject.username}/receive`}>
+ <button
+ class="border rounded-full py-2 w-28 font-semibold flex justify-center items-center text-sm"
+ >
+ <Icon icon="send" style="mr-1" />
+ Pay
+ </button>
+ </a>
+ </div>
+ </div>
+
+ <div class="space-y-5 md:mt-20 mx-auto w-[350px]">
+ <div
+ on:click={() => copy(lightningAddress)}
+ id="qr"
+ class="cursor-pointer w-[292px] md:w-[342px] h-[342px] border border-lightgrey rounded-3xl flex p-5 justify-center items-center relative mx-auto"
+ />
+
+ <div class="bg-primary font-semibold rounded-xl text-sm p-3 flex justify-between items-center">
+ <span class="mx-auto">{lightningAddress}</span>
+ <button class="hover:opacity-80" on:click={() => copy(lightningAddress)}>
+ <Icon icon="copy" style="ml-2" />
+ </button>
+ </div>
+ <p class="text-center">
+ This is a <a href="https://lightningaddress.com/" class="underline" target="_blank"
+ >lightning address</a
+ >
+ </p>
+ </div>
+</div>
diff --git a/src/routes/(app)/[username]/bitcoin/+page.server.js b/src/routes/(app)/[username]/bitcoin/+page.server.js
new file mode 100644
index 00000000..7ef65346
--- /dev/null
+++ b/src/routes/(app)/[username]/bitcoin/+page.server.js
@@ -0,0 +1,4 @@
+import { redirect } from '@sveltejs/kit';
+export async function load({ params }) {
+ throw redirect(303, `/${params.username}`);
+}
diff --git a/src/routes/(app)/[username]/bitcoin/+page.svelte b/src/routes/(app)/[username]/bitcoin/+page.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/(app)/[username]/bitcoin/+page.svelte
diff --git a/src/routes/(app)/[username]/customers/+page.svelte b/src/routes/(app)/[username]/customers/+page.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/(app)/[username]/customers/+page.svelte
diff --git a/src/routes/(app)/[username]/dashboard/+page.server.js b/src/routes/(app)/[username]/dashboard/+page.server.js
new file mode 100644
index 00000000..b6206324
--- /dev/null
+++ b/src/routes/(app)/[username]/dashboard/+page.server.js
@@ -0,0 +1 @@
+export const load = ({ parent }) => parent();
diff --git a/src/routes/(app)/[username]/dashboard/+page.svelte b/src/routes/(app)/[username]/dashboard/+page.svelte
new file mode 100644
index 00000000..160887e3
--- /dev/null
+++ b/src/routes/(app)/[username]/dashboard/+page.svelte
@@ -0,0 +1,76 @@
+<script>
+ import { f, s, post, failure, sats } from '$lib/utils';
+ import { tick } from 'svelte';
+ import { Icon } from '$comp';
+ import { rate, selectedRate } from '$lib/store';
+ import { goto } from '$app/navigation';
+ import { t } from '$lib/translations';
+
+ export let data;
+
+ let ease = (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t);
+ let interval;
+
+ $: animateRate($selectedRate);
+ let animateRate = (newRate) => {
+ if (!accountBalanceFiat) return;
+ let t = 0;
+ let newBalance = (user.account.balance * newRate) / sats;
+ let oldBalance = parseFloat(accountBalanceFiat);
+ let diff = oldBalance - newBalance;
+
+ clearInterval(interval);
+ interval = setInterval(() => {
+ let delta = diff * ease(t / 100);
+ accountBalanceFiat = (oldBalance - delta).toFixed(2);
+ if (t > 80) clearInterval(interval);
+ t++;
+ }, 10);
+ };
+
+ $: user = data.user;
+ $: accountBalanceFiat =
+ accountBalanceFiat ||
+ (user && $selectedRate && ((user.account.balance * $selectedRate) / sats).toFixed(2));
+
+ let payreq = '',
+ payreqField,
+ loading;
+
+ $: btcPrice = f($selectedRate, user.currency);
+
+ $: accountBalanceBtc = user.account.balance / sats;
+
+ $: accountBalanceSats = s(user.account.balance);
+</script>
+
+<div class="container px-4 max-w-md mx-auto mt-20">
+ <div class="text-5xl font-bold mb-2">
+ {$selectedRate ? f(accountBalanceFiat, user.currency) : $t('user.dashboard.fetchingRate')}
+ </div>
+
+ <div class="text-secondary text-2xl">
+ {accountBalanceSats} SAT
+ </div>
+
+ <div class="grid sm:grid-cols-2 gap-2 mt-8">
+ <a href={`/${user.username}/receive`}>
+ <button class="rounded-full border py-2 px-5 font-bold hover:opacity-80 w-full mb-2"
+ >{$t('user.dashboard.receive')}</button
+ >
+ </a>
+ {#if user.account.balance > 0}
+ <a href="/send">
+ <button class="rounded-full border py-2 px-5 font-bold hover:opacity-80 w-full mb-2"
+ >{$t('user.dashboard.withdraw')}</button
+ >
+ </a>
+ {:else}
+ <a href={`/${user.username}/settings`}>
+ <button class="rounded-full border py-2 px-5 font-bold hover:opacity-80 w-full mb-2"
+ >Setup Account</button
+ >
+ </a>
+ {/if}
+ </div>
+</div>
diff --git a/src/routes/(app)/[username]/receive/+page.server.js b/src/routes/(app)/[username]/receive/+page.server.js
new file mode 100644
index 00000000..1068f273
--- /dev/null
+++ b/src/routes/(app)/[username]/receive/+page.server.js
@@ -0,0 +1,5 @@
+import invoice from '$lib/invoice';
+
+export const actions = {
+ default: invoice
+};
diff --git a/src/routes/(app)/[username]/receive/+page.svelte b/src/routes/(app)/[username]/receive/+page.svelte
new file mode 100644
index 00000000..11d3a6db
--- /dev/null
+++ b/src/routes/(app)/[username]/receive/+page.svelte
@@ -0,0 +1,30 @@
+<script>
+ import { Icon, Numpad } from '$comp';
+ import { t } from '$lib/translations';
+ import { enhance } from '$app/forms';
+
+ export let data;
+ let amount;
+
+ let { currency, username } = data.subject;
+</script>
+
+<form method="POST" class="flex justify-center items-center mt-24 mb-3 px-3" use:enhance>
+ <input type="hidden" name="amount" value={amount} />
+ <input type="hidden" name="currency" value={currency} />
+ <input type="hidden" name="username" value={username} />
+ <input type="hidden" name="prompt" value="true" />
+
+ <div class="space-y-3 w-full md:w-[300px] mx-auto">
+ <Numpad bind:amount bind:currency />
+ <button
+ type="submit"
+ class="bg-black text-white rounded-xl w-full h-[48px] flex justify-center items-center font-semibold {!amount
+ ? 'opacity-50'
+ : 'opacity-100 hover:opacity-80'}"
+ disabled={!amount}
+ >
+ Next
+ </button>
+ </div>
+</form>
diff --git a/src/routes/(app)/[username]/receive/[network]/[amount]/+page.server.js b/src/routes/(app)/[username]/receive/[network]/[amount]/+page.server.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/(app)/[username]/receive/[network]/[amount]/+page.server.js
diff --git a/src/routes/(app)/[username]/receive/[network]/[amount]/+page.svelte b/src/routes/(app)/[username]/receive/[network]/[amount]/+page.svelte
new file mode 100644
index 00000000..45b983be
--- /dev/null
+++ b/src/routes/(app)/[username]/receive/[network]/[amount]/+page.svelte
@@ -0,0 +1 @@
+hi
diff --git a/src/routes/(app)/[username]/settings/+page.server.js b/src/routes/(app)/[username]/settings/+page.server.js
new file mode 100644
index 00000000..ae7e0a78
--- /dev/null
+++ b/src/routes/(app)/[username]/settings/+page.server.js
@@ -0,0 +1,21 @@
+import { invalid } from '@sveltejs/kit';
+import { auth, get, post } from '$lib/utils';
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ let form = Object.fromEntries(await request.formData());
+
+ let user = {
+ ...(await get('/me', auth(cookies))),
+ ...form
+ };
+
+ try {
+ await post(`/user`, user, auth(cookies));
+ } catch (e) {
+ return invalid(400, { message: e.message });
+ }
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/(app)/[username]/settings/+page.svelte b/src/routes/(app)/[username]/settings/+page.svelte
new file mode 100644
index 00000000..86705b1d
--- /dev/null
+++ b/src/routes/(app)/[username]/settings/+page.svelte
@@ -0,0 +1,104 @@
+<script>
+ import { onMount } from 'svelte';
+ import { fly } from 'svelte/transition';
+ import { enhance } from '$app/forms';
+ import Account from './_account.svelte';
+ import Pos from './_pos.svelte';
+ import Security from './_security.svelte';
+
+ import { Icon, Spinner, Pin } from '$comp';
+ import { t } from '$lib/translations';
+ import { failure, success } from '$lib/utils';
+ import { avatarUpload, bannerUpload, pin } from '$lib/store';
+ import { upload } from '$lib/upload';
+
+ export let data;
+ export let form;
+
+ let { user, rates, token } = data;
+ $: update(data);
+ let update = () => ({ user, rates, token } = data);
+
+ $: if (form?.message?.startsWith('Pin')) {
+ failure('Wrong pin, try again');
+ $pin = '';
+ }
+
+ let tab = 'account';
+
+ let tabs = [
+ { name: 'account', key: 'ACCOUNT', comp: Account },
+ { name: 'pos', key: 'POINT_OF_SALE', comp: Pos },
+ { name: 'security', key: 'SECURITY', comp: Security }
+ ];
+
+ $: ({ comp } = tabs.find((t) => t.name === tab));
+
+ let loading, submit;
+ let save = async (msg) => {
+ loading = true;
+ if (typeof msg !== 'string') msg = 'Settings saved';
+
+ try {
+ if ($avatarUpload) {
+ await upload($avatarUpload.file, $avatarUpload.type, $avatarUpload.progress, token);
+ }
+
+ if ($bannerUpload) {
+ await upload($bannerUpload.file, $bannerUpload.type, $bannerUpload.progress, token);
+ }
+
+ success(msg);
+ } catch (e) {
+ console.log(e);
+ failure('Something went wrong');
+ }
+
+ loading = false;
+ };
+
+ let loaded;
+ onMount(() => setTimeout(() => (loaded = true), 50));
+</script>
+
+{#if loaded && user.haspin && $pin?.length !== 6}
+ <Pin />
+{/if}
+
+<form method="POST" class="mb-[154px]" use:enhance on:submit={save}>
+ <input type="hidden" name="pin" value={$pin} />
+
+ <div class="mt-24 mb-20 px-3 md:px-0 w-full md:w-[400px] mx-auto space-y-8">
+ <h1 class="text-center text-3xl md:text-4xl font-semibold mb-10">
+ {$t('user.settings.header')}
+ </h1>
+
+ <div class="font-bold flex justify-between items-center border-b pb-3 text-secondary">
+ {#each tabs as { name, key }}
+ <button
+ class="hover:opacity-80"
+ class:text-black={tab === name}
+ on:click|preventDefault={() => (tab = name)}>{$t(`user.settings.${key}`)}</button
+ >
+ {/each}
+ </div>
+
+ <svelte:component this={comp} {user} {rates} {submit} />
+ </div>
+ <div
+ class="z-10 fixed bottom-0 bg-white shadow border-t w-full flex justify-center items-center py-3"
+ >
+ <button
+ bind:this={submit}
+ type="submit"
+ class="border-2 border-black rounded-xl font-semibold mx-auto py-3 w-40 hover:opacity-80"
+ class:bg-black={loading}
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ <div class="my-auto">Save Settings</div>
+ {/if}
+ </button>
+ </div>
+</form>
diff --git a/src/routes/(app)/[username]/settings/_account.svelte b/src/routes/(app)/[username]/settings/_account.svelte
new file mode 100644
index 00000000..e49205ca
--- /dev/null
+++ b/src/routes/(app)/[username]/settings/_account.svelte
@@ -0,0 +1,255 @@
+<script>
+ import { tick } from 'svelte';
+ import { colorTheme, tempProfileFiles, avatarUpload, bannerUpload } from '$lib/store';
+ import { Icon, Toggle, LocaleSelector } from '$comp';
+ import { t } from '$lib/translations';
+ import { upload } from '$lib/upload';
+
+ export let user;
+
+ let selectedTheme = 1;
+ let revealPassword = false;
+ let password;
+ let profileFile;
+ let select = (type) => files[type].el.click();
+
+ $: profile = files.profile.src;
+ $: banner = files.banner.src;
+ $: $tempProfileFiles = { profile: files.profile.src, banner: files.banner.src };
+
+ let togglePassword = () => (revealPassword = !revealPassword);
+ let colorThemes = [
+ { theme: 1, color1: 'from-[#F5F7FA]', color2: 'to-[#C3CFE2]' },
+ { theme: 2, color1: 'from-[#FDFCFB]', color2: 'to-[#E2D1C3]' },
+ { theme: 3, color1: 'from-[#E6E9F0]', color2: 'to-[#EEF1F5]' },
+ { theme: 4, color1: 'from-[#D5D4D0]', color2: 'to-[#EEEEEC]' },
+ { theme: 5, color1: 'from-[#F3E7E9]', color2: 'to-[#E3EEFF]' },
+ { theme: 6, color1: 'from-[#F5F7FA]', color2: 'via-[#EAF2FF] to-[#DEDFFF]' }
+ ];
+
+ let handleThemeClick = (colors) => {
+ selectedTheme = colors.theme;
+ $colorTheme = colors.color1 + ' ' + colors.color2;
+ };
+
+ let percent;
+ let progress = async (event) => {
+ percent = Math.round((event.loaded / event.total) * 100);
+ };
+
+ let tooLarge = {};
+
+ let files = { profile: {}, banner: {} };
+ let handleFile = async ({ target }, type) => {
+ tooLarge[type] = false;
+ let file = target.files[0];
+ if (file.size > 10000000) return (tooLarge[type] = true);
+ var reader = new FileReader();
+ reader.onload = async (e) => {
+ files[type].src = e.target.result;
+ };
+ reader.readAsDataURL(file);
+
+ files[type].filename = file.name;
+ if (type === 'profile') {
+ $avatarUpload = { file, type, progress };
+ } else if (type === 'banner') {
+ $bannerUpload = { file, type, progress };
+ }
+ };
+
+ $: user.password = user.confirm = password;
+</script>
+
+<div>
+ <label for="language" class="font-bold block mb-1">{$t('user.settings.locale')}</label>
+ <LocaleSelector style="select-styles block py-3 w-full" />
+</div>
+
+<!-- TODO
+<div>
+ <label for="email" class="font-bold mb-1 block">{$t('user.settings.email')}</label>
+ <input type="email" name="email" bind:value={user.email} />
+</div> -->
+
+<!-- TODO fix breaking changes when user updates this field -->
+<div>
+ <label for="username" class="font-bold mb-1 block">{$t('user.settings.username')}</label>
+ <input disabled type="text" name="username" bind:value={user.username} />
+ <input type="hidden" name="username" bind:value={user.username} />
+</div>
+
+<div class="relative">
+ <label for="password" class="block font-bold block mb-1">{$t('user.settings.newPassword')}</label>
+ {#if revealPassword}
+ <input name="password" type="text" bind:value={password} />
+ {:else}
+ <input name="password" type="password" bind:value={password} />
+ {/if}
+ <button
+ type="button"
+ on:click={togglePassword}
+ on:keydown={togglePassword}
+ class="absolute right-5 top-11"
+ >
+ <Icon icon={revealPassword ? 'eye' : 'eye-off'} />
+ </button>
+</div>
+
+<div>
+ <label for="address" class="font-bold mb-1 block">{$t('user.settings.businessAddress')}</label>
+ <input type="text" name="address" bind:value={user.address} />
+</div>
+
+<!-- TODO -->
+<!-- <div> -->
+<!-- <div class="flex justify-between items-center"> -->
+<!-- <span class="font-bold">{$t('user.settings.emailNotifications')}</span> -->
+<!-- <Toggle id="email-notify" /> -->
+<!-- </div> -->
+<!-- <p class="text-secondary mt-1 w-9/12"> -->
+<!-- {$t('user.settings.emailNotificationsDescription')} -->
+<!-- </p> -->
+<!-- </div> -->
+<!-- -->
+<!-- <div> -->
+<!-- <div class="flex justify-between items-center"> -->
+<!-- <span class="font-bold">{$t('user.settings.smsNotifications')}</span> -->
+<!-- <Toggle id="sms-notify" /> -->
+<!-- </div> -->
+<!-- <p class="text-secondary mt-1 w-9/12"> -->
+<!-- {$t('user.settings.smsNotificationsDescription')} -->
+<!-- </p> -->
+<!-- </div> -->
+
+<div>
+ <span class="font-bold">{$t('user.settings.profileImage')}</span>
+
+ <div class="flex">
+ {#if profile}
+ <div
+ class="relative rounded-full overflow-hidden text-center w-20 h-20 my-auto hover:opacity-80 cursor-pointer"
+ on:click={() => select('profile')}
+ on:keydown={() => select('profile')}
+ >
+ <img
+ src={profile}
+ class="absolute w-full h-full object-cover object-center visible overflow-hidden"
+ alt={user.username}
+ />
+ </div>
+ {:else if user.profile}
+ <div
+ class="relative rounded-full overflow-hidden text-center w-20 h-20 my-auto hover:opacity-80 cursor-pointer"
+ on:click={() => select('profile')}
+ on:keydown={() => select('profile')}
+ >
+ <img
+ src={`/api/public/${user.username}-profile.webp`}
+ class="absolute w-full h-full object-cover object-center visible overflow-hidden"
+ alt={user.username}
+ />
+ </div>
+ {:else}
+ <div
+ class="rounded-full border-4 border-white p-4 bg-gradient-to-r {$colorTheme} w-24 my-auto hover:opacity-80 cursor-pointer"
+ on:click={() => select('profile')}
+ on:keydown={() => select('profile')}
+ >
+ <Icon icon="logo-symbol-white" style="mx-auto" />
+ </div>
+ {/if}
+ <div class="ml-2 p-2">
+ <!-- found missing translation -->
+ <button
+ type="button"
+ class="border rounded-2xl font-bold w-24 text-center px-0 py-2 hover:opacity-80"
+ on:click={() => select('profile')}
+ on:keydown={() => select('profile')}>Select</button
+ >
+ <input
+ type="file"
+ class="hidden"
+ bind:this={files.profile.el}
+ on:change={(e) => handleFile(e, 'profile')}
+ />
+ {#if files.profile.filename}
+ <div class="mt-2 text-sm">{files.profile.filename}</div>
+ {/if}
+ </div>
+ </div>
+
+ {#if tooLarge['profile']}
+ <div class="text-red-600">Max file size 10MB</div>
+ {/if}
+</div>
+
+<div>
+ <div class="flex justify-between items-center">
+ <span class="font-bold">{$t('user.settings.bannerImage')}</span>
+ </div>
+ <!-- found missing translation -->
+
+ {#if banner}
+ <img
+ src={banner}
+ class="w-full object-cover object-center visible overflow-hidden h-48 mb-4 hover:opacity-80"
+ on:click={() => select('banner')}
+ on:keydown={() => select('banner')}
+ alt="Banner"
+ />
+ {:else if user.banner}
+ <img
+ src={`/api/public/${user.username}-banner.webp`}
+ class="w-full object-cover object-center visible overflow-hidden h-48 mb-4 hover:opacity-80"
+ on:click={() => select('banner')}
+ on:keydown={() => select('banner')}
+ alt="Banner"
+ />
+ {:else}
+ <div
+ class="bg-gradient-to-r {$colorTheme} w-full h-48 mb-4 cursor-pointer hover:opacity-80"
+ on:click={() => select('banner')}
+ on:keydown={() => select('banner')}
+ alt="Banner"
+ />
+ {/if}
+
+ <!-- found missing translation -->
+ <button
+ type="button"
+ class="border rounded-2xl font-bold w-24 text-center px-0 py-2 hover:opacity-80"
+ on:click={() => select('banner')}
+ on:keydown={() => select('banner')}>Select</button
+ >
+ <input
+ type="file"
+ class="hidden"
+ bind:this={files.banner.el}
+ on:change={(e) => handleFile(e, 'banner')}
+ />
+ {#if files.banner.filename}
+ <div class="mt-2 text-sm">{files.banner.filename}</div>
+ {/if}
+
+ {#if tooLarge['banner']}
+ <div class="text-red-600">Max file size 10MB</div>
+ {/if}
+</div>
+
+<!-- TODO
+<div>
+ <span class="font-bold mb-1 block">{$t('user.settings.theme')}</span>
+ <p class="text-secondary mb-1">{$t('user.settings.themeDescription')}</p>
+ {#each colorThemes as colors}
+ <button
+ type="button"
+ class="hover:opacity-80 mr-2 w-10 h-10 bg-gradient-to-r {colors.color1} {colors.color2} rounded-xl border-2 {selectedTheme ===
+ colors.theme
+ ? 'border-black'
+ : ''}"
+ on:click={() => handleThemeClick(colors)}
+ on:keydown={() => handleThemeClick(colors)}
+ />
+ {/each}
+</div> -->
diff --git a/src/routes/(app)/[username]/settings/_pos.svelte b/src/routes/(app)/[username]/settings/_pos.svelte
new file mode 100644
index 00000000..cf4a5ece
--- /dev/null
+++ b/src/routes/(app)/[username]/settings/_pos.svelte
@@ -0,0 +1,27 @@
+<script>
+ import { Icon, Toggle } from '$comp';
+ import { t } from '$lib/translations';
+ import { success, failure } from '$lib/utils';
+
+ export let rates, user;
+
+ const fiats = Object.keys(rates);
+</script>
+
+<div>
+ <label for="currency" class="font-bold block mb-1">{$t('user.settings.localCurrency')}</label>
+ <select name="currency" class="select-styles block py-3 w-full">
+ {#each fiats as fiat}
+ <option value={fiat} selected={user.currency === fiat}>{fiat}</option>
+ {/each}
+ </select>
+</div>
+
+<!-- TODO
+<div>
+ <label for="unit" class="font-bold block mb-1">{$t('user.settings.btcUnit')}</label>
+ <select disabled name="unit" class="select-styles block py-3 w-full" bind:value={user.unit}>
+ <option value="SAT">{$t('user.settings.satoshis')} (SAT)</option>
+ <option value="BTC">{$t('user.settings.bitcoin')} (BTC)</option>
+ </select>
+</div> -->
diff --git a/src/routes/(app)/[username]/settings/_security.svelte b/src/routes/(app)/[username]/settings/_security.svelte
new file mode 100644
index 00000000..98b9b503
--- /dev/null
+++ b/src/routes/(app)/[username]/settings/_security.svelte
@@ -0,0 +1,218 @@
+<script>
+ import { tick } from 'svelte';
+ import { t } from '$lib/translations';
+ import { Icon, Qr } from '$comp';
+ import { Pincode, PincodeInput } from 'svelte-pincode';
+ import { post, success, failure } from '$lib/utils';
+ import { pin as current } from '$lib/store';
+
+ export let user, submit;
+
+ let settingPin, setting2fa, disabling2fa;
+
+ let token = '';
+ let pinCode = [];
+ let verifyCode = [];
+ let tokenInput, pinInput, verifyInput;
+ let verifying = false;
+ let old;
+
+ let startVerifying = () => {
+ verifying = true;
+ };
+
+ $: pin = pinCode.join('');
+
+ let locked;
+ $: verifyCode.join('').length > 5 && checkPin();
+ let checkPin = async () => {
+ old = $current;
+ if (locked) return;
+ locked = true;
+
+ let verify = verifyCode.join('');
+
+ pinCode = ['', '', '', '', '', ''];
+ verifyCode = ['', '', '', '', '', ''];
+ settingPin = false;
+
+ try {
+ if (pin.length > 5 && pin === verify) {
+ user.haspin = true;
+ $current = pin;
+ submit.click();
+ } else {
+ failure('Pin mismatch, try again');
+ settingPin = true;
+ pinInput.focusFirstInput();
+ }
+ } catch (e) {
+ failure('Problem setting PIN');
+ }
+
+ verifying = false;
+
+ await new Promise((r) => setTimeout(r, 500));
+ locked = false;
+ };
+
+ $: if (verifying) verifyInput && tick().then(verifyInput.focusFirstInput);
+ $: if (settingPin) pinInput && tick().then(pinInput.focusFirstInput);
+ $: if (setting2fa || disabling2fa) tokenInput && tick().then(tokenInput?.focusFirstInput);
+
+ let reset = () => {
+ token = '';
+ return true;
+ };
+
+ let togglePin = async () => {
+ if (user.haspin) {
+ try {
+ pin = null;
+ submit.click();
+ } catch (e) {
+ failure('Failed to disable pin');
+ }
+ } else {
+ settingPin = true;
+ }
+ };
+
+ let toggleEnabling = () => reset() && (setting2fa = !setting2fa);
+ let toggleDisabling = () => reset() && (disabling2fa = !disabling2fa);
+
+ $: enable2fa(token);
+ let enable2fa = async (twoFa) => {
+ try {
+ if (setting2fa && token.length === 6) {
+ await post('/enable2fa', { token });
+ success('2FA enabled');
+ user.twofa = 1;
+ toggleEnabling();
+ }
+ } catch (e) {
+ failure('Failed to enable 2FA, try again');
+ }
+ };
+
+ $: disable2fa(token);
+ let disable2fa = async () => {
+ try {
+ if (disabling2fa && token.length === 6) {
+ await post('/disable2fa', { token });
+ success('2FA disabled');
+ user.twofa = 0;
+ toggleDisabling();
+ }
+ } catch (e) {
+ failure('Failed to disable 2FA, try again');
+ }
+ };
+
+ $: otpUri = `otpauth://totp/coinos:${user.username}?secret=${user.otpsecret}&period=30&digits=6&algorithm=SHA1&issuer=coinos`;
+</script>
+
+<div>
+ <input type="hidden" name="newpin" value={pin} />
+
+ <span class="font-bold mb-1">{verifying ? 'Verify' : 'Security'} PIN</span>
+ <p class="text-secondary mb-1">
+ {$t('user.settings.securityPINDescription')}
+ </p>
+ {#if settingPin}
+ <div class="flex my-4">
+ <div class="mx-auto">
+ <div class:hidden={verifying}>
+ <Pincode on:complete={startVerifying} bind:code={pinCode} bind:this={pinInput}>
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ </Pincode>
+ </div>
+ <div class:hidden={!verifying}>
+ <Pincode bind:code={verifyCode} bind:this={verifyInput}>
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ </Pincode>
+ </div>
+ </div>
+ </div>
+ {:else}
+ <button type="button" class="primary" on:click={togglePin}
+ ><Icon icon="lock" style="mr-1" /> {user.haspin ? 'Disable Pin' : 'Enable Pin'}</button
+ >
+ {/if}
+</div>
+
+<!-- TODO
+<div>
+ <label for="auto-lock" class="font-bold mb-1">{$t('user.settings.autoLock')}</label>
+ <p class="text-secondary mb-1">
+ {$t('user.settings.autoLockDescription')}
+ </p>
+ <select name="auto-lock" class="select-styles block py-3 w-full">
+ <option value="5">5 min</option>
+ <option value="10">10 min</option>
+ <option value="30">30 min</option>
+ <option value="60">1 h</option>
+ <option value="480">8 h</option>
+ </select>
+</div> -->
+
+<div>
+ <span class="font-bold mb-1">{$t('user.settings.twofa')}</span>
+ <p class="text-secondary mb-4">
+ {$t('user.settings.twofaDescription')}
+ </p>
+ {#if setting2fa}
+ <a href={otpUri}>
+ <Qr text={otpUri} />
+ </a>
+
+ <div class="text-center my-4">
+ {$t('user.settings.accountId')}<br />
+ <b>{user.otpsecret}</b>
+ </div>
+
+ <div class="text-center my-4">
+ {$t('user.settings.oneTimeCode')}<br />
+ <Pincode bind:value={token} bind:this={tokenInput}>
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ </Pincode>
+ </div>
+ {:else if disabling2fa}
+ <div class="text-center my-4">
+ {$t('user.settings.oneTimeCode')}<br />
+ <Pincode bind:value={token} bind:this={tokenInput}>
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ <PincodeInput />
+ </Pincode>
+ </div>
+ {:else if user.twofa}
+ <button type="button" class="primary" on:click={toggleDisabling}>
+ <Icon icon="mobile" style="mr-1" />
+ {$t('user.settings.twofaDisable')}
+ </button>
+ {:else}
+ <button type="button" class="primary" on:click={toggleEnabling}>
+ <Icon icon="mobile" style="mr-1" />
+ {$t('user.settings.twofaSetup')}
+ </button>
+ {/if}
+</div>
diff --git a/src/routes/(app)/[username]/support/+page.server.js b/src/routes/(app)/[username]/support/+page.server.js
new file mode 100644
index 00000000..a335ec96
--- /dev/null
+++ b/src/routes/(app)/[username]/support/+page.server.js
@@ -0,0 +1,13 @@
+export const actions = {
+ default: async ({ cookies, request }) => {
+ let form = Object.fromEntries(await request.formData());
+
+ try {
+ await post('/email', form);
+ } catch (e) {
+ return invalid(400, { failed: true });
+ }
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/(app)/[username]/support/+page.svelte b/src/routes/(app)/[username]/support/+page.svelte
new file mode 100644
index 00000000..cd1cc69f
--- /dev/null
+++ b/src/routes/(app)/[username]/support/+page.svelte
@@ -0,0 +1,76 @@
+<script>
+ import { enhance } from '$app/forms';
+ import { scale } from 'svelte/transition';
+ import { Icon } from '$comp';
+ import { t } from '$lib/translations';
+
+ export let data, form;
+
+ let { user } = data;
+ let email;
+ let message;
+</script>
+
+<div class="container px-4 max-w-lg mx-auto mt-20">
+ {#if form?.success}
+ <h1 class="text-center text-3xl md:text-4xl font-semibold mb-8">Thank you!</h1>
+ <p class="text-center mb-8">Someone will be in touch shortly.</p>
+
+ <a href={`/${user.username}/dashboard`}>
+ <button class="rounded-full border py-2 px-5 font-bold hover:opacity-80 w-full mb-2"
+ >Done</button
+ >
+ </a>
+ {:else}
+ <h1 class="text-center text-3xl md:text-4xl font-semibold mb-8">
+ {$t('user.support.header')}
+ </h1>
+ <form method="POST" use:enhance>
+ <div class="mb-4">
+ <label for="account" class="font-semibold">{$t('user.support.accountName')}</label>
+ <input
+ class="block border rounded-xl p-2 w-full"
+ type="text"
+ name="account"
+ required
+ disabled
+ value={user.username}
+ />
+ </div>
+
+ <div class="mb-4">
+ <label for="email" class="font-semibold">{$t('user.support.email')}</label>
+ <input
+ class="block border rounded-xl p-2 w-full"
+ type="email"
+ name="email"
+ bind:value={email}
+ required
+ />
+ </div>
+
+ <div class="mb-4">
+ <label for="message" class="font-semibold">{$t('user.support.message')}</label>
+ <textarea
+ rows={5}
+ class="block border rounded-xl p-2 w-full"
+ type="text"
+ name="message"
+ bind:value={message}
+ required
+ />
+ </div>
+
+ <button
+ type="submit"
+ disabled={!email || !message}
+ class="{!email || !message
+ ? 'opacity-50'
+ : 'opacity-100'} bg-black text-white font-bold rounded-xl py-3 w-full mx-auto {email &&
+ message
+ ? 'hover:opacity-80'
+ : ''}">{$t('user.support.send')}</button
+ >
+ </form>
+ {/if}
+</div>
diff --git a/src/routes/(app)/[username]/transactions/[...page]/+page.server.js b/src/routes/(app)/[username]/transactions/[...page]/+page.server.js
new file mode 100644
index 00000000..6883dfa9
--- /dev/null
+++ b/src/routes/(app)/[username]/transactions/[...page]/+page.server.js
@@ -0,0 +1,24 @@
+import { get, auth } from '$lib/utils';
+
+let limit = 25;
+
+export async function load({ cookies, params, request }) {
+ let parts = params.page.split('/');
+
+ let start, end, page;
+ if (parts.length === 1) [page] = parts;
+ if (parts.length === 2) [start, page] = parts;
+ if (parts.length === 3) [start, end, page] = parts;
+ if (!parseInt(page)) page = 1;
+
+ let offset = (page - 1) * limit;
+ if (!start) start = Date.now() / 1000 - 24 * 60 * 60;
+
+ let url = `/payments?v2=true&start=${start * 1000}&limit=${limit}&offset=${offset}`;
+ if (end) url += `&end=${end * 1000}`;
+
+ let { total, transactions } = await get(url, auth(cookies));
+ let pages = new Array(Math.ceil(total / limit));
+
+ return { total, transactions, page, pages, start, end };
+}
diff --git a/src/routes/(app)/[username]/transactions/[...page]/+page.svelte b/src/routes/(app)/[username]/transactions/[...page]/+page.svelte
new file mode 100644
index 00000000..dd58509a
--- /dev/null
+++ b/src/routes/(app)/[username]/transactions/[...page]/+page.svelte
@@ -0,0 +1,164 @@
+<script>
+ import { Icon } from '$comp';
+ import { onMount } from 'svelte';
+ import { formatDistance, parseISO } from 'date-fns';
+ import { newPayment } from '$lib/store';
+ import { t } from '$lib/translations';
+ import { f, s, sats } from '$lib/utils';
+ import { page } from '$app/stores';
+ import { differenceInDays, getUnixTime, sub } from 'date-fns';
+ import { goto } from '$app/navigation';
+
+ export let data;
+
+ let { start, end, user } = data;
+ let change = ({ target: { value } }) => goto(value);
+
+ let presets = [
+ { title: 'Today', start: null, end: null },
+ { title: 'Week', start: sub(new Date(), { days: 7 }), end: null },
+ { title: 'Month', start: sub(new Date(), { months: 1 }), end: null },
+ { title: 'All', start: sub(new Date(), { years: 5 }), end: null }
+ ];
+
+ $: selection = start
+ ? presets.findIndex((p) => Math.abs(differenceInDays(new Date(start * 1000), p.start)) < 1)
+ : 0;
+
+ let p,
+ total,
+ transactions = [],
+ pages = [];
+ $: data && ({ page: p, pages, start, end, total, transactions } = data);
+
+ $: $page && ($newPayment = false);
+ $: $newPayment && invalidate(`/users/${user.username}`);
+
+ $: path = $page.url.pathname.substring(0, $page.url.pathname.lastIndexOf('/'));
+ $: console.log(path);
+
+ let csv = () => {
+ const keys = ['hash', 'updatedAt', 'rate', 'currency', 'amount', 'fee', 'tip'];
+ const csv =
+ keys.map((k) => `"${k}"`).join(',') +
+ '\n' +
+ transactions.map((r) => keys.map((k) => `"${r[k]}"`).join(',')).join('\n');
+
+ const filename = 'transactions.csv';
+ let blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+ if (navigator.msSaveBlob) {
+ navigator.msSaveBlob(blob, filename);
+ } else {
+ let link = document.createElement('a');
+ if (link.download !== undefined) {
+ let url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', filename);
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ }
+ };
+</script>
+
+<div class="mt-24 mb-20">
+ <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold mb-10">
+ {$t('user.transactions.header')}
+ </h1>
+
+ <div class="container w-full mx-auto md:text-lg px-4 max-w-xl">
+ <div class="flex text-md text-secondary flex-wrap mb-4">
+ <select class="md:hidden bg-white border p-4 rounded-full" on:change={change}>
+ {#each presets as { start, end, title }, i}
+ <option
+ selected={selection === i}
+ value={`/${user.username}/transactions/${start ? getUnixTime(start) + '/' : ''}1`}
+ >{title}</option
+ >
+ {/each}
+ </select>
+ <div class="hidden md:block flex flex-wrap gap-2">
+ {#each presets as { start, end, title }, i}
+ <a
+ class:active={selection === i}
+ href={`/${user.username}/transactions/${start ? getUnixTime(start) + '/' : ''}1`}
+ >
+ <button class="rounded-full border py-2 px-5 hover:opacity-80 mb-2">
+ <div class="my-auto">{title}</div>
+ </button>
+ </a>
+ {/each}
+ </div>
+ <button class="ml-auto rounded-xl border py-2 px-5 hover:opacity-80 mb-2 flex" on:click={csv}>
+ <Icon icon="save" style="opacity-50 mr-2" />
+ <div class="my-auto">Export</div>
+ </button>
+ </div>
+
+ <div class="flex flex-wrap justify-center mb-8">
+ {#if pages.length > 1}
+ {#each pages as _, i}
+ <a class="mr-1 last:mr-0" href={`${path}/${i + 1}`} class:active={parseInt(p) === i + 1}>
+ <div class="border py-2 rounded-full border-2 w-12 h-12 hover:opacity-80 text-center">
+ {i + 1}
+ </div>
+ </a>
+ {/each}
+ {/if}
+ </div>
+
+ <div class="text-secondary grid grid-cols-3 mb-5">
+ <h2>{$t('user.transactions.AMOUNT')}</h2>
+ <h2 class="text-center" />
+ <h2 class="text-right">{$t('user.transactions.TIME')}</h2>
+ </div>
+
+ <div class="space-y-10">
+ {#if transactions.length}
+ {#each transactions as tx}
+ <div class="grid grid-cols-3 border-b pb-5">
+ <div class="font-bold">
+ <span class="block mb-1"
+ >{tx.amount > 0 ? '+' : ''}{f(tx.amount * (tx.rate / sats), tx.currency)}
+ </span>
+
+ <span class="text-secondary"
+ >{tx.amount > 0 ? '+' : ''}{s(tx.amount)}
+ SAT
+ </span>
+ </div>
+
+ <div class="text-center">
+ <span class="text-secondary"
+ >{$t('user.transactions.' + (tx.amount < 0 ? 'sent' : 'received'))}</span
+ >
+ </div>
+
+ <div class="text-secondary text-right">
+ {formatDistance(parseISO(tx.createdAt), new Date(), {
+ includeSeconds: true,
+ addSuffix: true
+ })}
+ </div>
+ </div>
+ {/each}
+ {:else}
+ <p class="text-secondary text-lg text-center">{$t('user.transactions.empty')}</p>
+ {/if}
+ </div>
+ </div>
+</div>
+
+<style>
+ .active * {
+ @apply bg-black text-white border-black;
+ }
+
+ select {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ }
+</style>
diff --git a/src/routes/(app)/disable2fa/+server.js b/src/routes/(app)/disable2fa/+server.js
new file mode 100644
index 00000000..237da445
--- /dev/null
+++ b/src/routes/(app)/disable2fa/+server.js
@@ -0,0 +1,8 @@
+import { auth, post } from '$lib/utils';
+
+export async function PUT({ cookies, request }) {
+ let body = await request.json();
+ let res = await post('/disable2fa', body, auth(cookies));
+
+ return new Response(JSON.stringify(res));
+}
diff --git a/src/routes/(app)/enable2fa/+server.js b/src/routes/(app)/enable2fa/+server.js
new file mode 100644
index 00000000..344850da
--- /dev/null
+++ b/src/routes/(app)/enable2fa/+server.js
@@ -0,0 +1,8 @@
+import { auth, post } from '$lib/utils';
+
+export async function POST({ cookies, request }) {
+ let body = await request.json();
+ let res = await post('/2fa', body, auth(cookies));
+
+ return new Response(JSON.stringify(res));
+}
diff --git a/src/routes/(app)/invoice/[id]/+layout.server.js b/src/routes/(app)/invoice/[id]/+layout.server.js
new file mode 100644
index 00000000..690deda0
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/+layout.server.js
@@ -0,0 +1,14 @@
+import Qr from 'qrcode-svg';
+import { get } from '$lib/utils';
+
+export async function load({ params }) {
+ let { id } = params;
+ let invoice = await get(`/invoice?uuid=${id}`);
+ let svg = new Qr({
+ content: invoice.text,
+ join: true,
+ container: 'svg-viewbox'
+ }).svg();
+
+ return { id, invoice, svg };
+}
diff --git a/src/routes/(app)/invoice/[id]/+layout.svelte b/src/routes/(app)/invoice/[id]/+layout.svelte
new file mode 100644
index 00000000..4fa864ce
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/+layout.svelte
@@ -0,0 +1 @@
+<slot />
diff --git a/src/routes/(app)/invoice/[id]/+page.server.js b/src/routes/(app)/invoice/[id]/+page.server.js
new file mode 100644
index 00000000..1068f273
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/+page.server.js
@@ -0,0 +1,5 @@
+import invoice from '$lib/invoice';
+
+export const actions = {
+ default: invoice
+};
diff --git a/src/routes/(app)/invoice/[id]/+page.svelte b/src/routes/(app)/invoice/[id]/+page.svelte
new file mode 100644
index 00000000..bb41a116
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/+page.svelte
@@ -0,0 +1,134 @@
+<script>
+ import { send } from '$lib/socket';
+ import { back, copy, f, get, reverseFormat, s, sats } from '$lib/utils';
+ import { onMount, onDestroy } from 'svelte';
+ import { browser } from '$app/environment';
+ import { invoices, last } from '$lib/store';
+ import { Icon, Heart, Image, Qr } from '$comp';
+ import { goto } from '$app/navigation';
+ import { t } from '$lib/translations';
+ import screenfull from 'screenfull';
+
+ export let data;
+ $: refresh(data);
+ let { invoice, id, user, svg } = data;
+ let {
+ amount,
+ rate,
+ received,
+ prompt,
+ text,
+ tip,
+ user: { username, currency }
+ } = invoice;
+
+ let qr;
+ let tipPercent = 0;
+
+ let refresh = (data) => {
+ ({ invoice, id } = data);
+ ({
+ amount,
+ rate,
+ received,
+ prompt,
+ text,
+ tip,
+ user: { username, currency }
+ } = invoice);
+
+ tipPercent = (tip / amount) * 100;
+ };
+
+ $invoices[id] = { amount, id, rate, received, text, tip, username };
+ $: browser &&
+ $invoices[id]?.received >= $invoices[id]?.amount &&
+ goto(($invoices[id].memo === 'launch' && '/launch/purchase') || `/invoice/${id}/paid`);
+
+ $: amountFiat = parseFloat(((amount * rate) / sats).toFixed(2));
+ $: tipAmount = ((tip * rate) / sats).toFixed(2);
+
+ let full = () => {
+ screenfull.toggle(qr);
+ let el = document.getElementById('qr');
+ el.style.width = '300px';
+ };
+
+ let subbed;
+ onMount(() => {
+ browser &&
+ last.subscribe((v) => {
+ if (!v || subbed) return;
+ subbed = true;
+ send('subscribe', invoice);
+ });
+ });
+</script>
+
+<button
+ class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80"
+ on:click={back}
+>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+<div class="container mx-auto max-w-lg px-4 space-y-10 lg:space-y-0">
+ <div class="space-y-10 lg:space-y-0">
+ <div class="text-center">
+ <span class="text-secondary block text-2xl break-all"
+ >Scan to pay <a href="/{username}" class="text-black font-semibold hover:opacity-80"
+ >{username}</a
+ ></span
+ >
+
+ <div id="qr" class="w-80 mx-auto" bind:this={qr} on:click={full}>
+ <div class="max-w-lg mx-auto">
+ {@html svg}
+ </div>
+ </div>
+
+ <div class="mb-10">
+ <button
+ class="flex rounded-full border py-2 px-5 font-bold hover:opacity-80 mb-2 mx-auto"
+ on:click={() => copy(text)}
+ ><Icon icon="copy" style="mr-1" />
+ <div>Copy</div></button
+ >
+ </div>
+
+ <div class="px-5 space-y-3 w-80 mx-auto">
+ {#if invoice.tip}
+ <div class="flex justify-between">
+ <span class="font-semibold text-sm">{$t('invoice.invoice')}</span>
+ <span class="font-semibold text-sm"
+ >{f(amountFiat, currency)}
+ <span class="text-secondary font-normal">{`(${s(amount)} SAT)`}</span></span
+ >
+ </div>
+
+ <div class="flex justify-between">
+ <span class="font-semibold text-sm">{$t('invoice.tip')}</span>
+ <span class="font-semibold text-sm"
+ >{f(tipAmount, currency)}
+ <span class="text-secondary font-normal">{`(${s(tip)} SAT)`}</span></span
+ >
+ </div>
+ {/if}
+
+ <div class="flex flex-wrap justify-between">
+ <span class="font-bold mr-1">{$t('invoice.total')}</span>
+ <span class="font-bold"
+ >{f(amountFiat + parseFloat(tipAmount), currency)}
+ <span class="text-secondary font-normal">{`(${s(tip + amount)} SAT)`}</span></span
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ .full-shadow {
+ box-shadow: inset 0 0 0 1000px rgba(0, 0, 0, 0.25);
+ }
+</style>
diff --git a/src/routes/(app)/invoice/[id]/paid/+page.svelte b/src/routes/(app)/invoice/[id]/paid/+page.svelte
new file mode 100644
index 00000000..d619b7ff
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/paid/+page.svelte
@@ -0,0 +1,40 @@
+<script>
+ import { scale } from 'svelte/transition';
+ import { Icon } from '$comp';
+ import { toast } from '@zerodevx/svelte-toast';
+ import { browser } from '$app/environment';
+ import { t } from '$lib/translations';
+ import { f, s, sats } from '$lib/utils';
+ import { goto } from '$app/navigation';
+ import { invoices } from '$lib/store';
+
+ export let data;
+ let { invoice } = data;
+ let { amount, rate } = invoice;
+
+ toast.pop(0);
+
+ $: amountFiat = parseFloat(((amount * rate) / sats).toFixed(2));
+
+ const handleDoneClick = () => {
+ delete $invoices[invoice.uuid];
+ goto(`/${invoice.user.username}/receive`);
+ };
+</script>
+
+<div class="container px-4 text-center mx-auto">
+ <div class="flex w-full py-20 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('invoice.paymentSuccessful')}</h1>
+ <h2 class="text-2xl md:text-3xl font-semibold">
+ {f(amountFiat, invoice.currency)}
+ </h2>
+ <h3 class="text-secondary md:text-lg mb-6 mt-1">({s(invoice.amount)} SAT)</h3>
+ <button
+ class="bg-black text-white rounded-2xl w-20 py-3 font-bold hover:opacity-80"
+ on:click={handleDoneClick}
+ >
+ {$t('invoice.done')}
+ </button>
+</div>
diff --git a/src/routes/(app)/invoice/[id]/tip/+page.server.js b/src/routes/(app)/invoice/[id]/tip/+page.server.js
new file mode 100644
index 00000000..1068f273
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/tip/+page.server.js
@@ -0,0 +1,5 @@
+import invoice from '$lib/invoice';
+
+export const actions = {
+ default: invoice
+};
diff --git a/src/routes/(app)/invoice/[id]/tip/+page.svelte b/src/routes/(app)/invoice/[id]/tip/+page.svelte
new file mode 100644
index 00000000..6ea3aae5
--- /dev/null
+++ b/src/routes/(app)/invoice/[id]/tip/+page.svelte
@@ -0,0 +1,251 @@
+<script>
+ import { browser } from '$app/environment';
+ import { invoices } from '$lib/store';
+ import { back, copy, f, get, reverseFormat, s, sats } from '$lib/utils';
+ import { Icon } from '$comp';
+ import { goto } from '$app/navigation';
+ import { t } from '$lib/translations';
+ import { enhance } from '$app/forms';
+ import { tick } from 'svelte';
+
+ const tipAmounts = ['None', '10%', '15%', '20%'];
+ let showCustomAmount;
+
+ let customInput;
+ let customTipAmount;
+
+ let apply = async () => {
+ if (customTipAmount > 0) {
+ tipPercent = (customTipAmount / amountFiat) * 100;
+ showCustomAmount = false;
+ }
+ };
+
+ let active = (amount) =>
+ !customTipAmount && Math.round(tipPercent) === parseInt(amount.slice(0, 2));
+
+ const handleCustomTipAmount = (e) => {
+ tipPercent = 0;
+ customTipAmount = e.target.value;
+ };
+
+ let timeout;
+ const handleSlide = (e) => {
+ customTipAmount = '';
+ if (customInput) {
+ customInput.value = '';
+ }
+ tipPercent = e.target.value;
+
+ clearTimeout(timeout);
+ timeout = setTimeout(update, 800);
+ };
+
+ let submitting;
+ const handleTipButtonClick = async (amount) => {
+ submitting = true;
+ if (amount === 'None') {
+ tipPercent = 0;
+ } else {
+ tipPercent = parseInt(amount.slice(0, 2));
+ }
+
+ await tick();
+ submit.click();
+ };
+
+ let submit;
+ let update = async () => {
+ await tick();
+ //submit.click();
+ };
+
+ export let data;
+ $: refresh(data);
+ let { invoice, id, user } = data;
+ let {
+ amount,
+ rate,
+ received,
+ prompt,
+ text,
+ tip,
+ user: { username, currency }
+ } = invoice;
+
+ let qr;
+ let tipPercent = 0;
+
+ let fullscreen;
+
+ let refresh = (data) => {
+ ({ invoice, id } = data);
+ ({
+ amount,
+ rate,
+ received,
+ prompt,
+ text,
+ tip,
+ user: { username, currency }
+ } = invoice);
+
+ tipPercent = (tip / amount) * 100;
+ };
+
+ $: tip = Math.round((amount / 100) * tipPercent);
+
+ $invoices[id] = { amount, id, rate, received, text, tip, username };
+ $: browser &&
+ $invoices[id]?.received >= $invoices[id]?.amount &&
+ goto(($invoices[id].memo === 'launch' && '/launch/purchase') || `/invoice/${id}/paid`);
+
+ $: amountFiat = parseFloat(((amount * rate) / sats).toFixed(2));
+
+ $: tipAmount = ((tip * rate) / sats).toFixed(2);
+
+ $: invoiceAmountFiatFormatted = f(amountFiat, currency);
+</script>
+
+<button
+ class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80"
+ on:click={back}
+>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+<div class="container px-4 max-w-lg text-center mx-auto">
+ <form method="POST" use:enhance class:invisible={submitting}>
+ <input type="hidden" name="amount" value={amount} />
+ <input type="hidden" name="tip" value={tip} />
+ <input type="hidden" name="username" value={username} />
+ <input type="hidden" name="rate" value={rate} />
+ <input type="hidden" name="prompt" value="false" />
+
+ <h1 class="text-4xl font-semibold my-8">{$t('invoice.addTipq')}</h1>
+ {#if !showCustomAmount}
+ <div class="my-2 text-xl">{Math.round(tipPercent)}%</div>
+
+ <input
+ id="slider"
+ type="range"
+ min="0"
+ max="100"
+ value={tipPercent}
+ class="px-0 py-0 my-5"
+ style="--bgPercent: {tipPercent}%;"
+ on:input={handleSlide}
+ />
+
+ <div class="flex mb-8 text-sm">
+ <div>
+ <span class="mr-1">
+ {f(amountFiat, currency)}
+ <span class="font-semibold">+{f(tipAmount, currency)}</span>
+ </span>
+ </div>
+
+ <div class="ml-auto">
+ {s(amount)}
+ <span class="font-semibold">+{s(tip)} SAT</span>
+ </div>
+ </div>
+
+ {#if tip}
+ <div>
+ <button type="button" on:click={() => submit.click()}>Next</button>
+ <button type="button" on:click={() => (tipPercent = 0)}>Reset</button>
+ </div>
+ {:else}
+ {#each tipAmounts as amount}
+ <button
+ type="button"
+ class:active={active(amount, tipPercent)}
+ on:click={() => handleTipButtonClick(amount)}>{amount}</button
+ >
+ {/each}
+ {/if}
+
+ <div>
+ <button type="button" on:click={() => (showCustomAmount = true)}>Custom</button>
+ </div>
+ {/if}
+
+ <button type="submit" bind:this={submit} />
+ </form>
+
+ {#if showCustomAmount}
+ <form on:submit|preventDefault={apply}>
+ <div class="relative">
+ <span
+ class="absolute top-[18px] left-5 font-semibold {!customTipAmount
+ ? 'opacity-50'
+ : 'opacity-100'}">$</span
+ >
+ <input
+ bind:this={customInput}
+ type="number"
+ step="0.01"
+ on:input={(e) => handleCustomTipAmount(e)}
+ class="pl-10"
+ placeholder="Custom amount"
+ autofocus
+ />
+ <button
+ type="submit"
+ class="w-full mt-2 bg-black text-white font-semibold py-3 px-7 rounded-2xl {!customTipAmount
+ ? 'opacity-50'
+ : 'opacity-100 hover:opacity-80'}"
+ disabled={!customTipAmount}>Apply</button
+ >
+ </div>
+ </form>
+ {/if}
+</div>
+
+<style>
+ #slider {
+ -webkit-appearance: none;
+ width: 100%;
+ height: 6px;
+ border-radius: 5px;
+ background: linear-gradient(
+ to right,
+ black 0%,
+ black var(--bgPercent),
+ #f5f7fa var(--bgPercent),
+ #f5f7fa 100%
+ );
+ outline: none;
+ }
+
+ #slider:active {
+ background-color: black;
+ }
+
+ #slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background: black;
+ cursor: pointer;
+ }
+
+ #slider::-moz-range-thumb {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background: black;
+ cursor: pointer;
+ }
+
+ .active {
+ @apply !bg-black !text-white;
+ }
+
+ .container button[type='button'] {
+ @apply w-full mt-2 bg-primary text-black hover:bg-black hover:text-white font-semibold py-5 px-7 rounded-2xl;
+ }
+</style>
diff --git a/src/routes/(app)/jobs/+page.svelte b/src/routes/(app)/jobs/+page.svelte
new file mode 100644
index 00000000..fc82f9d5
--- /dev/null
+++ b/src/routes/(app)/jobs/+page.svelte
@@ -0,0 +1,30 @@
+<script>
+ import { Icon, Footer } from '$comp';
+ import { t } from '$lib/translations';
+</script>
+
+<a href="/">
+ <Icon icon="logo" style="mx-auto mt-10" />
+</a>
+<div class="w-11/12 md:w-3/4 lg:w-1/2 2xl:w-1/3 max-w-3xl mx-auto my-20 space-y-5">
+ <h1 class="text-5xl font-medium">{$t('jobs.header')}</h1>
+
+ <h2 class="text-2xl font-medium">{$t('jobs.subheader')}</h2>
+
+ <p class="text-justify text-xl text-secondary">
+ {$t('jobs.pitch1')}
+ <br /><br />
+ {$t('jobs.pitch2')}
+ <a
+ href="https://github.com/coinos"
+ target="_blank"
+ rel="noreferrer"
+ class="underline underline-offset-4">{$t('jobs.github')}</a
+ >.
+ <br /><br />
+ {$t('jobs.pitch3')}
+ <a href="mailto:[email protected]" class="underline underline-offset-4">[email protected]</a>.
+ </p>
+</div>
+
+<Footer />
diff --git a/src/routes/(app)/launch/+page.server.js b/src/routes/(app)/launch/+page.server.js
new file mode 100644
index 00000000..2b44dfae
--- /dev/null
+++ b/src/routes/(app)/launch/+page.server.js
@@ -0,0 +1,6 @@
+import { auth, get } from '$lib/utils';
+
+export const load = async ({ parent }) => {
+ let tickets = await get('/tickets');
+ return { ...(await parent()), tickets };
+};
diff --git a/src/routes/(app)/launch/+page.svelte b/src/routes/(app)/launch/+page.svelte
new file mode 100644
index 00000000..3469f0bc
--- /dev/null
+++ b/src/routes/(app)/launch/+page.svelte
@@ -0,0 +1,103 @@
+<script>
+ import { LandingHeader } from '$comp';
+ import { loginRedirect } from '$lib/store';
+ import { beforeNavigate } from '$app/navigation';
+ import { info } from '$lib/utils';
+ import { Icon } from '$comp';
+ import { t } from '$lib/translations';
+ import { goto } from '$app/navigation';
+ import { scroll } from '$lib/utils';
+
+ export let data;
+ let { user, tickets } = data;
+ $loginRedirect = '/launch/purchase';
+
+ let hasTicket = !!user?.accounts.find((a) => tickets.includes(a.asset));
+ $: link = hasTicket ? '/launch/ticket' : user ? $loginRedirect : '/register';
+ $: text = (hasTicket ? 'View' : 'Purchase') + ' Ticket';
+
+ beforeNavigate(({ to }) => {
+ if (to?.routeId === 'register') info('Please sign in so we can get you a ticket');
+ });
+
+ let showMobileMenu = false;
+ const mobileMenuButtonClick = (section) => {
+ showMobileMenu = false;
+ scroll(section);
+ };
+</script>
+
+<header class="w-full lg:w-5/6 py-5 mx-auto fixed md:sticky z-10 top-0 bg-white/90">
+ <nav class="block xl:flex flex-wrap justify-between items-center">
+ <div class="flex justify-start md:justify-center items-center md:space-x-10">
+ <a href="/" class="hidden md:block">
+ <Icon icon="logo" style="mb-5 xl:mb-0" />
+ </a>
+ <Icon icon="logo" style="block md:hidden ml-5 w-48" />
+ </div>
+ </nav>
+</header>
+
+<div class="mx-auto flex items-center justify-center px-8 mt-20">
+ <div class="flex flex-col w-full bg-white rounded shadow-lg sm:w-3/4 lg:w-3/5">
+ <div class="flex flex-col w-full md:flex-row">
+ <div
+ class="flex flex-row justify-around p-4 font-bold leading-none text-gray-800 uppercase bg-gray-400 rounded md:flex-col md:items-center md:justify-center md:w-1/4"
+ >
+ <div class="md:text-3xl">Oct</div>
+ <div class="md:text-6xl">26</div>
+ <div class="md:text-xl">6 pm</div>
+ </div>
+ <div class="p-4 font-normal text-gray-800 md:w-3/4 space-y-5">
+ <h1 class="mb-4 text-4xl font-bold leading-none tracking-tight text-gray-800">
+ Launch Party!
+ </h1>
+
+ <p class="leading-normal mb-2">
+ Come join us at our launch party celebrating our new design and 10 years serving the
+ Vancouver Bitcoin community!
+ </p>
+
+ <div class="flex">
+ <div class="space-y-5">
+ <div class="ml-8">
+ <p><b>When</b></p>
+ <p>6pm October 26</p>
+ </div>
+ <div class="ml-8">
+ <p><b>Where</b></p>
+ <p>The Loft Lounge</p>
+ <p>1184 Denman Street, #203</p>
+ <p>Vancouver, BC</p>
+ </div>
+ </div>
+
+ <img src="/icons/party.svg" alt="Partygoers" class="w-[120px] mx-auto" />
+ </div>
+
+ <p>
+ We'll introduce our new interface and then network and enjoy tasty food and drinks from <a
+ href="https://loftondenman.com/"
+ class="underline">The Loft</a
+ >!
+ </p>
+
+ <p>
+ The Loft will be accepting Lightning payments at the event and going forward into the
+ future as well! We're charging $6 for for an NFT ticket that gets you entry, appies, and
+ the chance to win some swag and sats.
+ </p>
+
+ <div class="flex w-full my-8">
+ <a href={link} class="mx-auto">
+ <button
+ class="bg-white text-black border rounded-full px-8 py-4 font-bold hover:opacity-80 text-2xl"
+ >
+ {text}
+ </button>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/routes/(app)/launch/purchase/+page.server.js b/src/routes/(app)/launch/purchase/+page.server.js
new file mode 100644
index 00000000..2ecce695
--- /dev/null
+++ b/src/routes/(app)/launch/purchase/+page.server.js
@@ -0,0 +1,45 @@
+import { auth, get, post } from '$lib/utils';
+import tickets from '$lib/tickets';
+import { redirect } from '@sveltejs/kit';
+import { env } from '$env/dynamic/private';
+import { invalidateAll } from '$app/navigation';
+import { browser } from '$app/environment';
+import { createTicket } from '$lib/ticket';
+
+export const load = async ({ parent }) => {
+ let tickets = await get('/tickets');
+ let { user } = await parent();
+ let hasTicket = !!user?.accounts.find((a) => tickets.includes(a.asset));
+ if (hasTicket) throw redirect(307, '/launch/ticket');
+ return { tickets };
+};
+
+export const actions = {
+ internal: async ({ cookies, request }) => {
+ let form = await request.formData();
+ let asset = await createTicket(cookies, form.get('username'));
+ await post('/send', { amount: 22000, username: 'launch' }, auth(cookies));
+
+ throw redirect(307, `/launch/thanks/${asset}`);
+ },
+
+ lightning: async ({ cookies, request }) => {
+ let form = await request.formData();
+
+ let rates = await get('/rates');
+
+ let invoice = {
+ amount: 22000,
+ memo: 'launch',
+ network: 'lightning',
+ prompt: false,
+ rate: rates[form.get('currency')]
+ };
+
+ let user = { username: form.get('username') };
+
+ let { uuid } = await post('/invoice', { invoice, user }, auth(cookies));
+
+ throw redirect(307, `/invoice/${uuid}`);
+ }
+};
diff --git a/src/routes/(app)/launch/purchase/+page.svelte b/src/routes/(app)/launch/purchase/+page.svelte
new file mode 100644
index 00000000..3273752a
--- /dev/null
+++ b/src/routes/(app)/launch/purchase/+page.svelte
@@ -0,0 +1,67 @@
+<script>
+ import { onMount } from 'svelte';
+ import { enhance } from '$app/forms';
+ import { selectedRate } from '$lib/store';
+ import { post } from '$lib/utils';
+ import { goto } from '$app/navigation';
+
+ export let data;
+
+ let amount = 22000;
+ let {
+ currency,
+ account: { balance },
+ username
+ } = data.user;
+
+ let { tickets } = data;
+ let ticket = tickets.length;
+</script>
+
+<div class="mx-auto h-screen flex items-center justify-center px-4">
+ <div class="font-normal text-gray-800 mx-auto mb-8 space-y-8">
+ <h1 class="mb-4 text-4xl font-bold leading-none tracking-tight text-gray-800 text-center">
+ Purchase Ticket
+ </h1>
+
+ <img
+ src={`/tickets/${(ticket + 1).toString().padStart(2, '0')}.png`}
+ class="w-full max-w-[800px]"
+ alt="Ticket"
+ />
+
+ {#if balance >= amount}
+ <form action="?/internal" method="POST" use:enhance>
+ <input type="hidden" name="username" value={username} />
+
+ <div class="w-full flex">
+ <button
+ class="bg-black text-white border rounded-full px-8 py-4 font-bold hover:opacity-80 mx-auto text-2xl"
+ >
+ Count me in
+ </button>
+ </div>
+ </form>
+ {:else}
+ <h1 class="mb-4 text-xl font-bold leading-none tracking-tight text-gray-800 text-center">
+ Payment Options
+ </h1>
+
+ <div class="flex justify-center gap-4">
+ <form action="?/lightning" method="POST">
+ <input type="hidden" name="currency" value={currency} />
+ <input type="hidden" name="username" value={username} />
+ <button class="border rounded-full px-8 py-4 font-bold hover:opacity-80 mx-auto text-2xl">
+ Lightning
+ </button>
+ </form>
+
+ <a href={`purchase/card`}>
+ <button class="border rounded-full px-8 py-4 font-bold hover:opacity-80 mx-auto text-2xl">
+ Credit Card
+ </button>
+ </a>
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/src/routes/(app)/launch/purchase/card/+page.server.js b/src/routes/(app)/launch/purchase/card/+page.server.js
new file mode 100644
index 00000000..1f564f78
--- /dev/null
+++ b/src/routes/(app)/launch/purchase/card/+page.server.js
@@ -0,0 +1,44 @@
+import { env } from '$env/dynamic/private';
+import { error, redirect } from '@sveltejs/kit';
+import { post, auth } from '$lib/utils';
+import { createTicket } from '$lib/ticket';
+
+let stripe = 'https://api.stripe.com/v1';
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ let form = await request.formData();
+
+ let headers = new Headers();
+ headers.set('Authorization', 'Basic ' + new Buffer(env.STRIPE + ':').toString('base64'));
+
+ let body = new URLSearchParams({
+ 'card[number]': form.get('number').replace(/\s+/g, ''),
+ 'card[exp_month]': form.get('expiry').slice(0, 2),
+ 'card[exp_year]': form.get('expiry').slice(-2),
+ 'card[cvc]': form.get('cvc')
+ });
+
+ let method = 'POST';
+
+ let { id: source } = await fetch(`${stripe}/tokens`, { body, headers, method }).then((r) =>
+ r.json()
+ );
+
+ body = new URLSearchParams({
+ amount: 600,
+ currency: 'cad',
+ source,
+ description: 'launch ticket'
+ });
+
+ let { status } = await fetch(`${stripe}/charges`, { body, headers, method }).then((r) =>
+ r.json()
+ );
+
+ if (status !== 'succeeded') throw error(500, { message: 'Card payment failed' });
+ let asset = await createTicket(cookies, form.get('username'));
+
+ throw redirect(307, `/launch/thanks/${asset}`);
+ }
+};
diff --git a/src/routes/(app)/launch/purchase/card/+page.svelte b/src/routes/(app)/launch/purchase/card/+page.svelte
new file mode 100644
index 00000000..05d09025
--- /dev/null
+++ b/src/routes/(app)/launch/purchase/card/+page.svelte
@@ -0,0 +1,77 @@
+<script>
+ import { enhance } from '$app/forms';
+ import { Lock, Card, Calendar, Spinner } from '$comp';
+
+ export let data;
+
+ let { username } = data.user;
+ let loading;
+</script>
+
+<div class="container max-w-lg px-4 mx-auto space-y-5">
+ <form
+ method="POST"
+ class="flex flex-wrap gap-5 w-full p-5 mt-20"
+ use:enhance={() => {
+ loading = true;
+ return ({ update }) => update() && (loading = false);
+ }}
+ >
+ <input type="hidden" name="username" value={username} />
+ <label class="relative w-full flex flex-col">
+ <span class="font-bold mb-3">Card number</span>
+ <input
+ class="rounded-md peer pl-12 pr-2 py-2 border-2 border-gray-200 placeholder-gray-300"
+ type="text"
+ name="number"
+ placeholder="0000 0000 0000 0000"
+ />
+ <Card />
+ </label>
+
+ <label class="relative flex-1 flex flex-col">
+ <span class="font-bold mb-3">Expire date</span>
+ <input
+ class="rounded-md peer pl-12 pr-2 py-2 border-2 border-gray-200 placeholder-gray-300"
+ type="text"
+ name="expiry"
+ placeholder="MM/YY"
+ />
+ <Calendar />
+ </label>
+
+ <label class="relative flex-1 flex flex-col">
+ <span class="font-bold flex items-center gap-3 mb-3">
+ CVC/CVV
+ <span class="relative group">
+ <span
+ class="hidden group-hover:flex justify-center items-center px-2 py-1 text-xs absolute -right-2 transform translate-x-full -translate-y-1/2 w-max top-1/2 bg-black text-white"
+ >
+ 3-digit number on the back of your card</span
+ >
+ </span>
+ </span>
+ <input
+ class="rounded-md peer pl-12 pr-2 py-2 border-2 border-gray-200 placeholder-gray-300"
+ type="text"
+ name="cvc"
+ placeholder="&bull;&bull;&bull;"
+ />
+ <Lock />
+ </label>
+
+ <div class="w-full flex mt-8">
+ <button
+ class="bg-black text-white border rounded-full px-8 py-4 font-bold hover:opacity-80 mx-auto text-xl"
+ type="submit"
+ disabled={loading}
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ Submit
+ {/if}
+ </button>
+ </div>
+ </form>
+</div>
diff --git a/src/routes/(app)/launch/register/+page.svelte b/src/routes/(app)/launch/register/+page.svelte
new file mode 100644
index 00000000..437a48f1
--- /dev/null
+++ b/src/routes/(app)/launch/register/+page.svelte
@@ -0,0 +1 @@
+We need to get you registered
diff --git a/src/routes/(app)/launch/thanks/[asset]/+page.server.js b/src/routes/(app)/launch/thanks/[asset]/+page.server.js
new file mode 100644
index 00000000..e9e01376
--- /dev/null
+++ b/src/routes/(app)/launch/thanks/[asset]/+page.server.js
@@ -0,0 +1 @@
+// export const load = ({ parent }) => parent();
diff --git a/src/routes/(app)/launch/thanks/[asset]/+page.svelte b/src/routes/(app)/launch/thanks/[asset]/+page.svelte
new file mode 100644
index 00000000..43f10057
--- /dev/null
+++ b/src/routes/(app)/launch/thanks/[asset]/+page.svelte
@@ -0,0 +1,29 @@
+<script>
+ import { page } from '$app/stores';
+
+ export let data;
+
+ let { user } = data;
+
+ let { asset } = $page.params;
+</script>
+
+<div class="container mx-auto max-w-lg px-4 mt-20">
+ <div class="font-normal text-gray-800 mx-auto mb-8 space-y-8">
+ <h1 class="mb-4 text-4xl font-bold leading-none tracking-tight text-gray-800 text-center">
+ Thanks!
+ </h1>
+
+ <p>We look forward to seeing you!</p>
+ </div>
+
+ <div class="w-full flex">
+ <a href="/launch/ticket" class="mx-auto">
+ <button
+ class="bg-black text-white border rounded-full px-8 py-4 font-bold hover:opacity-80 text-2xl"
+ >
+ Done
+ </button>
+ </a>
+ </div>
+</div>
diff --git a/src/routes/(app)/launch/ticket/+page.server.js b/src/routes/(app)/launch/ticket/+page.server.js
new file mode 100644
index 00000000..0362eb7d
--- /dev/null
+++ b/src/routes/(app)/launch/ticket/+page.server.js
@@ -0,0 +1,9 @@
+import { get } from '$lib/utils';
+
+export const load = async ({ cookies, parent }) => {
+ let { user } = await parent();
+ let tickets = await get('/tickets');
+ let ticket = tickets.findIndex((t) => user.accounts.find((a) => a.asset === t));
+ let asset = tickets[ticket];
+ return { asset, ticket };
+};
diff --git a/src/routes/(app)/launch/ticket/+page.svelte b/src/routes/(app)/launch/ticket/+page.svelte
new file mode 100644
index 00000000..3879abcd
--- /dev/null
+++ b/src/routes/(app)/launch/ticket/+page.svelte
@@ -0,0 +1,35 @@
+<script>
+ export let data;
+ let { asset, ticket } = data;
+</script>
+
+<div class="mx-auto h-screen flex items-center justify-center px-4">
+ <div class="font-normal text-gray-800 mx-auto mb-8 space-y-8">
+ <h1 class="mb-4 text-4xl font-bold leading-none tracking-tight text-gray-800 text-center">
+ Your Ticket
+ </h1>
+
+ <p>
+ We've minted your ticket as an <a
+ href={`https://blockstream.info/liquid/asset/${asset}`}
+ class="underline">NFT on the Liquid Blockchain</a
+ > as a proof of purchase and commemorative token.
+ </p>
+
+ <img
+ src={`/tickets/${(ticket + 1).toString().padStart(2, '0')}.png`}
+ class="w-full max-w-[800px]"
+ alt="Ticket"
+ />
+
+ <div class="w-full flex">
+ <a href="/" class="mx-auto">
+ <button
+ class="bg-black text-white border rounded-full px-8 py-4 font-bold hover:opacity-80 text-2xl"
+ >
+ Done
+ </button>
+ </a>
+ </div>
+ </div>
+</div>
diff --git a/src/routes/(app)/locations/+server.js b/src/routes/(app)/locations/+server.js
new file mode 100644
index 00000000..381919ab
--- /dev/null
+++ b/src/routes/(app)/locations/+server.js
@@ -0,0 +1,7 @@
+import { get } from "$lib/utils";
+import { json } from '@sveltejs/kit';
+
+export async function GET({ fetch, setHeaders }) {
+ setHeaders({ 'cache-control': 'public, max-age=600' });
+ return json(await get('/locations'));
+}
diff --git a/src/routes/(app)/login/+page.server.js b/src/routes/(app)/login/+page.server.js
new file mode 100644
index 00000000..b46af49e
--- /dev/null
+++ b/src/routes/(app)/login/+page.server.js
@@ -0,0 +1,18 @@
+import { invalid, redirect } from '@sveltejs/kit';
+import { login } from '$lib/utils';
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ let form = Object.fromEntries(await request.formData());
+ let { username, password, token, loginRedirect } = form;
+ let user = { username, password, token };
+
+ try {
+ let r = await login(user, cookies);
+ } catch (e) {
+ return invalid(400, { error: 'Login failed', message: e.message, ...form });
+ }
+
+ throw redirect(303, loginRedirect || `/${user.username}/dashboard`);
+ }
+};
diff --git a/src/routes/(app)/login/+page.svelte b/src/routes/(app)/login/+page.svelte
new file mode 100644
index 00000000..75998cd0
--- /dev/null
+++ b/src/routes/(app)/login/+page.svelte
@@ -0,0 +1,7 @@
+<script>
+ import { RegisterOrLogin } from '$comp';
+
+ export let form;
+</script>
+
+<RegisterOrLogin pageID="signIn" bind:form />
diff --git a/src/routes/(app)/logout/+page.server.js b/src/routes/(app)/logout/+page.server.js
new file mode 100644
index 00000000..1b4a1dc1
--- /dev/null
+++ b/src/routes/(app)/logout/+page.server.js
@@ -0,0 +1,12 @@
+import { redirect } from '@sveltejs/kit';
+
+const opts = {
+ expires: new Date(0),
+ path: '/'
+};
+
+export async function load({ cookies }) {
+ cookies.set('token', '', opts);
+ cookies.set('refresh_token', '', opts);
+ throw redirect(307, '/');
+}
diff --git a/src/routes/(app)/logout/+page.svelte b/src/routes/(app)/logout/+page.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/(app)/logout/+page.svelte
diff --git a/src/routes/(app)/news/+page.svelte b/src/routes/(app)/news/+page.svelte
new file mode 100644
index 00000000..7eb1b7d9
--- /dev/null
+++ b/src/routes/(app)/news/+page.svelte
@@ -0,0 +1,115 @@
+<script>
+ import { Icon, Footer, Check, Quote } from '$comp';
+ import { t } from '$lib/translations';
+</script>
+
+<a href="/">
+ <Icon icon="logo" style="mx-auto mt-10" />
+</a>
+<div class="w-11/12 md:w-3/4 lg:w-1/2 2xl:w-1/3 max-w-3xl mx-auto my-20 space-y-5">
+ <h1 class="text-5xl font-medium">{$t('news.header')}</h1>
+
+ <h2 class="text-2xl font-medium">{$t('news.subheader')}</h2>
+
+ <p class="text-xl font-semibold">Press Release - coinos Redesign - September 23, 2022</p>
+
+ <p class="text-xl text-secondary">
+ Today coinos is launching a new and improved interface for their open source web wallet,
+ allowing businesses to accept bitcoin with no setup or merchant fees.
+ </p>
+
+ <p class="text-xl text-secondary">
+ coinos is a web-based bitcoin payment application that is mobile and tablet friendly with handy
+ currency conversion and reporting features. bitcoin is the world's leading crypto currency. It
+ offers a secure and inexpensive way for people all over the globe to make and accept payments.
+ </p>
+
+ <blockquote
+ class="p-4 my-4 bg-gray-50 border-l-4 border-gray-300 dark:border-gray-500 dark:bg-gray-800 text-xl text-secondary"
+ >
+ <Quote /> coinos has enabled businesses to accept bitcoin since 2012, but now we have made it even
+ easier with a new simple, friendly and customizable interface for both businesses and customers to
+ use.
+ <figcaption class="flex justify-center items-center mt-6 space-x-3">
+ <img class="w-6 h-6 rounded-full" src="/images/adam.webp" alt="Adam Soltys" />
+ <div class="flex items-center divide-x-2 divide-gray-500 dark:divide-gray-700">
+ <cite class="pr-3 font-medium text-gray-900 dark:text-white">Adam Soltys</cite>
+ <cite class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">Founder and CTO</cite
+ >
+ </div>
+ </figcaption>
+ </blockquote>
+
+ <p class="text-xl text-secondary">
+ coinos started as an indie bitcoin wallet created by Adam Soltys with the intention of providing
+ people with a fast, cheap, and easy way to get started with bitcoin. It's free to register an
+ account and you can sign up with just a username and password.
+ </p>
+
+ <h3 class="text-xl font-semibold">Why accept bitcoin?</h3>
+ <blockquote
+ class="p-4 my-4 bg-gray-50 border-l-4 border-gray-300 dark:border-gray-500 dark:bg-gray-800 text-xl text-secondary"
+ >
+ <Quote />
+ We have already brought on some local businesses, here in Vancouver, and the business owners are
+ excited about the opportunity coinos is giving them to attract new customers by being able to accept
+ bitcoin. With coinos there are no set up fees, no merchant fees and transactions are safe and secure
+ with payments going directly to the business owner's wallet. There is no middle person.
+ <figcaption class="flex justify-center items-center mt-6 space-x-3">
+ <img class="w-6 h-6 rounded-full" src="/images/cole.webp" alt="Adam Soltys" />
+ <div class="flex items-center divide-x-2 divide-gray-500 dark:divide-gray-700">
+ <cite class="pr-3 font-medium text-gray-900 dark:text-white">Cole Goodwin</cite>
+ <cite class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400"
+ >Bitcoin Professional</cite
+ >
+ </div>
+ </figcaption>
+ </blockquote>
+
+ <h3 class="text-xl font-semibold">coinos Timeline</h3>
+ <ul class="text-xl text-secondary">
+ <li class="flex items-center space-x-3">
+ <Check />
+ 2012 Coding started
+ </li>
+ <li class="flex items-center space-x-3">
+ <Check />
+ 2013 Used by merchants in Vancouver to accept on-chain bitcoin
+ </li>
+ <li class="flex items-center space-x-3">
+ <Check />
+ 2018 Lightning network supported
+ </li>
+ <li class="flex items-center space-x-3">
+ <Check />
+ 2020 Incorporated in BC and registered with Fintrac
+ </li>
+ <li class="flex items-center space-x-3">
+ <Check />
+ 2021 Liquid Network and asset issuance supported
+ </li>
+ <li class="flex items-center space-x-3">
+ <Check />
+ 2022 10 year anniversary and UI redesign
+ </li>
+ </ul>
+
+ <h3 class="text-xl font-semibold">Contacts</h3>
+
+ <p class="text-xl text-secondary">
+ <b>Adam Soltys</b> - Founder & CTO<br />
+ <a class="hover:opacity-80" href="mailto:[email protected] ">[email protected]</a>
+ </p>
+
+ <p class="text-xl text-secondary">
+ <b>Cole Goodwin</b> - Bitcoin Professional<br />
+ <a class="hover:opacity-80" href="mailto:[email protected]">[email protected]</a>
+ </p>
+
+ <p class="text-xl text-secondary">
+ <b>Shauna Collister</b> - Operations & Marketing<br />
+ <a class="hover:opacity-80" href="mailto:[email protected]">[email protected]</a>
+ </p>
+</div>
+
+<Footer />
diff --git a/src/routes/(app)/register/+page.server.js b/src/routes/(app)/register/+page.server.js
new file mode 100644
index 00000000..c5751d4c
--- /dev/null
+++ b/src/routes/(app)/register/+page.server.js
@@ -0,0 +1,28 @@
+import { invalid, redirect } from '@sveltejs/kit';
+import { post, login } from '$lib/utils';
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ let form = Object.fromEntries(await request.formData());
+ let { username, password, loginRedirect } = form;
+
+ let user = { username, password };
+ let error;
+
+ try {
+ await post('/register', { user });
+ } catch (e) {
+ if (e.message.includes('taken')) error = e.message;
+ }
+
+ try {
+ await login(user, cookies);
+ error = null;
+ } catch (e) {
+ error ||= e.message;
+ }
+
+ if (error) return invalid(400, { error });
+ throw redirect(307, loginRedirect || `/${user.username}/dashboard`);
+ }
+};
diff --git a/src/routes/(app)/register/+page.svelte b/src/routes/(app)/register/+page.svelte
new file mode 100644
index 00000000..fd9e4417
--- /dev/null
+++ b/src/routes/(app)/register/+page.svelte
@@ -0,0 +1,15 @@
+<script>
+ import { afterNavigate, invalidateAll } from '$app/navigation';
+ import { RegisterOrLogin } from '$comp';
+ export let form;
+
+ afterNavigate(async () => {
+ try {
+ await invalidateAll();
+ } catch (e) {
+ console.log(e);
+ }
+ });
+</script>
+
+<RegisterOrLogin pageID="register" bind:form />
diff --git a/src/routes/(app)/scan/+page.svelte b/src/routes/(app)/scan/+page.svelte
new file mode 100644
index 00000000..10c288dd
--- /dev/null
+++ b/src/routes/(app)/scan/+page.svelte
@@ -0,0 +1,44 @@
+<script>
+ import { Icon } from '$comp';
+ import QrScanner from 'qr-scanner';
+ import { onMount, onDestroy } from 'svelte';
+ import { back } from '$lib/utils';
+ import { goto } from '$app/navigation';
+
+ let scanner, vid;
+ onMount(() => {
+ scanner = new QrScanner(vid, ({ data }) => goto(`/send/${data}`), {
+ highlightScanRegion: true,
+ highlightCodeOutline: true
+ });
+ scanner.start();
+ });
+
+ onDestroy(() => scanner?.stop());
+</script>
+
+<button class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80" on:click={back}>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+<div class="flex w-full mb-4 px-4">
+ <div class="bg-black mx-auto rounded-3xl">
+ <video
+ bind:this={vid}
+ class="border-4 rounded-3xl border-black md:max-w-[600px] !max-h-[300px]"
+ />
+ </div>
+</div>
+
+<div class="flex w-full mb-4">
+ <button class="mx-auto border rounded-full px-6 py-2 font-bold hover:opacity-80" on:click={back}>
+ Cancel
+ </button>
+</div>
+
+<style>
+ video {
+ transition: transform 0.3s ease-out;
+ transform: scale(1);
+ }
+</style>
diff --git a/src/routes/(app)/send/+layout.svelte b/src/routes/(app)/send/+layout.svelte
new file mode 100644
index 00000000..c9122466
--- /dev/null
+++ b/src/routes/(app)/send/+layout.svelte
@@ -0,0 +1,18 @@
+<script>
+ import { Pin } from '$comp';
+ import { pin } from '$lib/store';
+ import { onMount } from 'svelte';
+
+ export let data;
+
+ let loaded;
+ let { user } = data;
+
+ onMount(() => setTimeout(() => (loaded = true), 50));
+</script>
+
+{#if loaded && user.haspin && $pin?.length !== 6}
+ <Pin />
+{/if}
+
+<slot />
diff --git a/src/routes/(app)/send/[...text]/+page.server.js b/src/routes/(app)/send/[...text]/+page.server.js
new file mode 100644
index 00000000..b909ab4d
--- /dev/null
+++ b/src/routes/(app)/send/[...text]/+page.server.js
@@ -0,0 +1,60 @@
+import validate from 'bitcoin-address-validation';
+import bip21 from 'bip21';
+import { get, post } from '$lib/utils';
+import { invalid, redirect } from '@sveltejs/kit';
+
+let parse = async (t) => {
+ if (!t) return;
+ let user, uuid;
+
+ // lightning
+ if (t.startsWith('ln')) {
+ try {
+ ({ uuid } = await get(`/invoice/${t}`));
+ } catch (e) {
+ throw redirect(303, `/send/lightning/${t}`);
+ }
+
+ if (uuid) throw redirect(303, `/send/${uuid}`);
+ }
+
+ // bitcoin
+ if (validate(t)) {
+ try {
+ ({ uuid } = await get(`/invoice/${t}`));
+ } catch (e) {
+ throw redirect(303, `/send/bitcoin/${t}`);
+ }
+
+ if (uuid) throw redirect(303, `/send/${uuid}`);
+ }
+
+ // ln address
+ if (t.includes('@') && t.includes('.')) {
+ let [name, domain] = t.split('@');
+ try {
+ let lnurl = await get(`/encode?domain=${domain}&name=${name}`);
+ } catch (e) {}
+ }
+
+ // user
+ try {
+ user = await get(`/users/${t}`);
+ } catch (e) {}
+
+ if (user) throw redirect(303, `/send/user/${t}`);
+};
+
+export async function load({ params }) {
+ let { text } = params;
+ await parse(text);
+}
+
+export const actions = {
+ default: async ({ request }) => {
+ const form = await request.formData();
+ await parse(form.get('text'));
+
+ return invalid(403, { error: 'Does not compute, try again' });
+ }
+};
diff --git a/src/routes/(app)/send/[...text]/+page.svelte b/src/routes/(app)/send/[...text]/+page.svelte
new file mode 100644
index 00000000..1311422e
--- /dev/null
+++ b/src/routes/(app)/send/[...text]/+page.svelte
@@ -0,0 +1,94 @@
+<script>
+ import { fly } from 'svelte/transition';
+ import { enhance } from '$app/forms';
+ import { onMount } from 'svelte';
+ import { browser } from '$app/environment';
+ import { t } from '$lib/translations';
+ import { Icon, Spinner } from '$comp';
+ import { back, failure } from '$lib/utils';
+
+ export let form;
+
+ let el;
+ let placeholder = 'Paste a bitcoin address, lightning invoice or coinos username';
+ let text;
+
+ let focus = () => text === placeholder && (text = '');
+ let keypress = (e) => e.key === 'Enter' && (e.preventDefault() || el.click());
+
+ let paste = async () => {
+ text = await navigator.clipboard.readText();
+ };
+</script>
+
+<button class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80" on:click={back}>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+<h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold mb-8">
+ Send bitcoin or lightning
+</h1>
+
+<form method="POST" class="container px-4 max-w-xl mx-auto" use:enhance>
+ {#if form?.error}
+ <div class="text-red-600 text-center" in:fly>
+ {form.error}
+ </div>
+ {/if}
+ <div class="mb-2">
+ <label for="invoice" class="font-bold mb-1 block">To</label>
+ <input type="hidden" name="text" bind:value={text} />
+
+ <textarea
+ {placeholder}
+ on:keypress={keypress}
+ class="w-full p-4 border rounded-xl h-48"
+ bind:value={text}
+ />
+ </div>
+
+ <div class="flex justify-end">
+ <a href="/scan">
+ <button
+ type="button"
+ class="flex border rounded-full px-6 py-2 font-bold hover:opacity-80 mr-1"
+ >
+ <Icon icon="scan" style="mr-2 w-6 my-auto" />
+ <div class="my-auto">Scan</div>
+ </button>
+ </a>
+
+ <button
+ type="button"
+ class="flex border rounded-full px-6 py-2 font-bold hover:opacity-80 mr-1"
+ on:click={paste}
+ >
+ <Icon icon="paste" style="mr-2 w-6 my-auto" />
+ <div class="my-auto">Paste</div>
+ </button>
+
+ <button
+ bind:this={el}
+ type="submit"
+ class="{!text
+ ? 'opacity-50'
+ : 'opacity-100 hover:opacity-80'} bg-black text-white border rounded-full px-6 py-2 font-bold"
+ >
+ Next
+ </button>
+ </div>
+</form>
+
+<style>
+ .expandable-textarea {
+ font-family: inherit;
+ font-size: inherit;
+
+ resize: both;
+ display: block;
+ overflow: hidden;
+ min-height: 48px;
+ line-height: 20px;
+ @apply block border rounded-2xl p-3 py-4 w-full;
+ }
+</style>
diff --git a/src/routes/(app)/send/[uuid=uuid]/+page.server.js b/src/routes/(app)/send/[uuid=uuid]/+page.server.js
new file mode 100644
index 00000000..c9c77f70
--- /dev/null
+++ b/src/routes/(app)/send/[uuid=uuid]/+page.server.js
@@ -0,0 +1,28 @@
+import { error, invalid, redirect } from '@sveltejs/kit';
+import { btc as asset, auth, get, post } from '$lib/utils';
+
+export async function load({ params, parent }) {
+ let { user } = await parent();
+ let { amount, address, text, user: recipient } = await get(`/invoice/${params.uuid}`);
+ if (recipient.username === user.username) throw error(500, { message: 'Cannot send to self' });
+ return { amount, address, payreq: text, recipient };
+}
+
+export const actions = {
+ default: async ({ cookies, params, request }) => {
+ try {
+ let body = Object.fromEntries(await request.formData());
+ let { confirmed } = body;
+
+ if (!confirmed) {
+ return invalid(403, { amount, confirm: true });
+ }
+
+ await post('/send', body, auth(cookies));
+ } catch (e) {
+ return invalid(400, { message: e.message });
+ }
+
+ throw redirect(307, '/sent');
+ }
+};
diff --git a/src/routes/(app)/send/[uuid=uuid]/+page.svelte b/src/routes/(app)/send/[uuid=uuid]/+page.svelte
new file mode 100644
index 00000000..73712f71
--- /dev/null
+++ b/src/routes/(app)/send/[uuid=uuid]/+page.svelte
@@ -0,0 +1,101 @@
+<script>
+ import { goto } from '$app/navigation';
+ import { pin, selectedRate } from '$lib/store';
+ import { enhance } from '$app/forms';
+ import { Icon, Numpad, Spinner } from '$comp';
+ import { page } from '$app/stores';
+ import { back, f, s, sats } from '$lib/utils';
+ export let data;
+ export let form;
+
+ let { address, payreq, recipient, user } = data;
+ let { currency } = user;
+
+ let amount = form?.amount || data.amount;
+ let a,
+ af,
+ amountFiat = amount * ($selectedRate / sats),
+ fiat = !amount;
+
+ let setAmount = () => {
+ amount = a;
+ amountFiat = af;
+ };
+
+ let handleBack = () => {
+ if (amount) {
+ amount = undefined;
+ goto(`/send/user/${recipient.username}`);
+ } else back();
+ };
+
+ let loading;
+ let submit = () => (loading = true);
+
+ $: update(form);
+ let update = () => {
+ if (form?.message.includes('pin')) $pin = undefined;
+ loading = false;
+ };
+</script>
+
+<button class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80" on:click={handleBack}>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+{#if form?.message}
+ <div class="text-red-600 text-center">
+ {form.message}
+ </div>
+{/if}
+
+<div class="container px-4 mt-20 max-w-xl mx-auto">
+ {#if amount}
+ <div class="text-center mb-8">
+ <h1 class="text-xl md:text-2xl text-secondary mb-2">Send</h1>
+ <p class="text-6xl break-words mb-4">
+ {fiat ? f(amountFiat, currency) : s(amount)}
+ {#if !fiat}
+ <span class="text-xl md:text-2xl text-secondary">sats</span>
+ {/if}
+ </p>
+
+ <h1 class="text-xl md:text-2xl text-secondary mb-2">to</h1>
+ <p class="text-6xl break-words">{recipient.username}</p>
+ </div>
+ {:else}
+ <Numpad bind:amount={a} bind:amountFiat={af} {currency} bind:fiat />
+ {/if}
+
+ <form method="POST" use:enhance on:submit={submit}>
+ <input name="address" value={address} type="hidden" />
+ <input name="amount" value={amount} type="hidden" />
+ <input name="payreq" value={payreq} type="hidden" />
+ <input name="username" value={recipient.username} type="hidden" />
+ <input name="confirmed" value={form?.confirm} type="hidden" />
+ <input name="pin" value={$pin} type="hidden" />
+
+ <div class="flex w-full">
+ {#if amount}
+ <button
+ type="submit"
+ class="opacity-100 hover:opacity-80'} rounded-2xl border py-3 font-bold mx-auto mt-2 bg-black text-white px-4 w-24"
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ Send
+ {/if}
+ </button>
+ {:else}
+ <button
+ type="button"
+ class="opacity-100 hover:opacity-80'} rounded-2xl border py-3 font-bold mx-auto mt-2 bg-black text-white px-4 w-24"
+ on:click={setAmount}
+ >
+ Next
+ </button>
+ {/if}
+ </div>
+ </form>
+</div>
diff --git a/src/routes/(app)/send/bitcoin/[address]/+page.server.js b/src/routes/(app)/send/bitcoin/[address]/+page.server.js
new file mode 100644
index 00000000..642593ab
--- /dev/null
+++ b/src/routes/(app)/send/bitcoin/[address]/+page.server.js
@@ -0,0 +1,16 @@
+import { invalid, redirect } from '@sveltejs/kit';
+import { auth, post } from '$lib/utils';
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ try {
+ let { address, amount, pin } = Object.fromEntries(await request.formData());
+ let { tx } = await post('/bitcoin/fee', { address, amount }, auth(cookies));
+ await post('/bitcoin/send', { address, pin, tx }, auth(cookies));
+ } catch (e) {
+ return invalid(400, { message: e.message });
+ }
+
+ throw redirect(307, '/sent');
+ }
+};
diff --git a/src/routes/(app)/send/bitcoin/[address]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
new file mode 100644
index 00000000..744bfe9d
--- /dev/null
+++ b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
@@ -0,0 +1,58 @@
+<script>
+ import { enhance } from '$app/forms';
+ import { Icon, Numpad, Spinner } from '$comp';
+ import { page } from '$app/stores';
+ import { back } from '$lib/utils';
+ import { pin } from '$lib/store';
+
+ export let data;
+ export let form;
+
+ let { address } = $page.params;
+ let { currency } = data.user;
+ let amount, loading;
+
+ let submit = () => (loading = true);
+ $: update(form);
+ let update = () => {
+ if (form?.message.includes('pin')) $pin = undefined;
+ loading = false;
+ };
+</script>
+
+<button class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80" on:click={back}>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+{#if form?.message}
+ <div class="text-red-600 text-center">
+ {form.message}
+ </div>
+{/if}
+
+<div class="container px-4 mt-20 max-w-xl mx-auto">
+ <div class="text-center mb-8">
+ <h1 class="text-3xl md:text-4xl font-semibold mb-2">Send to</h1>
+ <p class="text-lg text-secondary">{address}</p>
+ </div>
+ <Numpad bind:amount {currency} />
+
+ <form method="POST" use:enhance on:submit={submit}>
+ <input name="address" value={address} type="hidden" />
+ <input name="amount" value={amount} type="hidden" />
+ <input name="pin" value={$pin} type="hidden" />
+
+ <div class="flex w-full">
+ <button
+ type="submit"
+ class="opacity-100 hover:opacity-80'} rounded-2xl border py-3 font-bold mx-auto mt-2 bg-black text-white px-4 w-24"
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ Send
+ {/if}
+ </button>
+ </div>
+ </form>
+</div>
diff --git a/src/routes/(app)/send/lightning/[payreq]/+page.server.js b/src/routes/(app)/send/lightning/[payreq]/+page.server.js
new file mode 100644
index 00000000..8d60628b
--- /dev/null
+++ b/src/routes/(app)/send/lightning/[payreq]/+page.server.js
@@ -0,0 +1,27 @@
+import { invalid, redirect } from '@sveltejs/kit';
+import { btc as asset, auth, post } from '$lib/utils';
+
+export async function load({ params }) {
+ return post('/lightning/parse', params);
+}
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ try {
+ let body = Object.fromEntries(await request.formData());
+ let { amount, confirmed } = body;
+
+ if (!confirmed) {
+ return invalid(403, { amount, confirm: true });
+ }
+
+ await post('/lightning/send', body, auth(cookies));
+ } catch (e) {
+ if (e.message.includes('unusable'))
+ e.message = 'Failed to route payment, try sending a lower amount';
+ return invalid(400, { message: e.message });
+ }
+
+ throw redirect(307, '/sent');
+ }
+};
diff --git a/src/routes/(app)/send/lightning/[payreq]/+page.svelte b/src/routes/(app)/send/lightning/[payreq]/+page.svelte
new file mode 100644
index 00000000..e738c9a3
--- /dev/null
+++ b/src/routes/(app)/send/lightning/[payreq]/+page.svelte
@@ -0,0 +1,70 @@
+<script>
+ import { enhance } from '$app/forms';
+ import { Icon, Numpad, Spinner } from '$comp';
+ import { page } from '$app/stores';
+ import { back, s } from '$lib/utils';
+ import { pin } from '$lib/store';
+
+ export let data;
+ export let form;
+
+ let { payreq } = $page.params;
+ let { alias } = data;
+
+ let { currency } = data.user;
+ let amount = form?.amount || data.amount;
+
+ let loading;
+ let submit = () => (loading = true);
+ $: update(form);
+ let update = () => {
+ if (form?.message.includes('pin')) $pin = undefined;
+ loading = false;
+ };
+</script>
+
+<button class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80" on:click={back}>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+{#if form?.message}
+ <div class="text-red-600 text-center">
+ {form.message}
+ </div>
+{/if}
+
+<div class="container px-4 mt-20 max-w-xl mx-auto">
+ {#if amount}
+ <div class="text-center mb-8">
+ <h1 class="text-xl md:text-2xl text-secondary mb-2">Send</h1>
+ <p class="text-6xl break-words mb-4">
+ {s(amount)} <span class="text-xl md:text-2xl text-secondary">sats</span>
+ </p>
+ <h1 class="text-xl md:text-2xl text-secondary mb-2">to</h1>
+ <p class="text-6xl break-words">{alias}</p>
+ </div>
+ {:else}
+ <Numpad bind:amount {currency} />
+ {/if}
+
+ <form method="POST" use:enhance on:submit={submit}>
+ <input name="payreq" value={payreq} type="hidden" />
+ <input name="amount" value={amount} type="hidden" />
+ <input name="confirmed" value={form?.confirm} type="hidden" />
+ <input name="pin" value={$pin} type="hidden" />
+
+ <div class="flex w-full">
+ <button
+ type="submit"
+ class="opacity-100 hover:opacity-80'} rounded-2xl border py-3 font-bold mx-auto mt-2 bg-black text-white px-4 w-24"
+ disabled={loading}
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ Send
+ {/if}
+ </button>
+ </div>
+ </form>
+</div>
diff --git a/src/routes/(app)/send/lnurl/+page.svelte b/src/routes/(app)/send/lnurl/+page.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/(app)/send/lnurl/+page.svelte
diff --git a/src/routes/(app)/send/user/[username]/[...amount]/+page.server.js b/src/routes/(app)/send/user/[username]/[...amount]/+page.server.js
new file mode 100644
index 00000000..a659eef3
--- /dev/null
+++ b/src/routes/(app)/send/user/[username]/[...amount]/+page.server.js
@@ -0,0 +1,29 @@
+import { error, invalid, redirect } from '@sveltejs/kit';
+import { btc as asset, auth, get, post } from '$lib/utils';
+
+export async function load({ params, parent }) {
+ let { user } = await parent();
+ let { amount } = params;
+ let recipient = await get(`/users/${params.username}`);
+ if (recipient.username === user?.username) throw error(500, { message: 'Cannot send to self' });
+ return { amount, recipient };
+}
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ try {
+ let body = Object.fromEntries(await request.formData());
+ let { amount, confirmed } = body;
+
+ if (!confirmed) {
+ return invalid(403, { amount, confirm: true });
+ }
+
+ await post('/send', body, auth(cookies));
+ } catch (e) {
+ return invalid(400, { message: e.message });
+ }
+
+ throw redirect(307, '/sent');
+ }
+};
diff --git a/src/routes/(app)/send/user/[username]/[...amount]/+page.svelte b/src/routes/(app)/send/user/[username]/[...amount]/+page.svelte
new file mode 100644
index 00000000..89cec12f
--- /dev/null
+++ b/src/routes/(app)/send/user/[username]/[...amount]/+page.svelte
@@ -0,0 +1,103 @@
+<script>
+ import { goto } from '$app/navigation';
+ import { pin, selectedRate } from '$lib/store';
+ import { enhance } from '$app/forms';
+ import { Avatar, Icon, Numpad, Spinner } from '$comp';
+ import { page } from '$app/stores';
+ import { back, f, s, sats } from '$lib/utils';
+
+ export let data;
+ export let form;
+
+ let { recipient, user } = data;
+ let currency = user?.currency || 'USD';
+
+ let amount = form?.amount || data.amount;
+ let a,
+ af,
+ amountFiat = amount * ($selectedRate / sats),
+ fiat = !amount;
+
+ let setAmount = () => {
+ amount = a;
+ amountFiat = af;
+ };
+
+ let handleBack = () => {
+ if (amount) {
+ amount = undefined;
+ goto(`/send/user/${recipient.username}`);
+ } else back();
+ };
+
+ let loading;
+ let submit = () => (loading = true);
+
+ $: update(form);
+ let update = () => {
+ if (form?.message.includes('pin')) $pin = undefined;
+ loading = false;
+ };
+</script>
+
+<button class="ml-5 md:ml-20 mt-5 md:mt-10 hover:opacity-80" on:click={handleBack}>
+ <Icon icon="arrow-left" style="w-10" />
+</button>
+
+{#if form?.message}
+ <div class="text-red-600 text-center">
+ {form.message}
+ </div>
+{/if}
+
+<div class="container px-4 mt-20 max-w-xl mx-auto space-y-8">
+ {#if amount}
+ <div class="text-center mb-8 space-y-5">
+ <p class="text-5xl break-words">
+ {fiat ? f(amountFiat, currency) : s(amount)}
+ {#if !fiat}
+ <span class="text-xl md:text-2xl text-secondary">sats</span>
+ {/if}
+ </p>
+
+ <h1 class="text-xl md:text-2xl text-secondary mb-2">to</h1>
+
+ <div class="flex p-1 gap-2 justify-center">
+ <Avatar user={recipient} size={'20'} />
+ <p class="text-4xl break-words my-auto">{recipient.username}</p>
+ </div>
+ </div>
+ {:else}
+ <Numpad bind:amount={a} bind:amountFiat={af} {currency} bind:fiat />
+ {/if}
+
+ <form method="POST" use:enhance on:submit={submit}>
+ <input name="amount" value={amount} type="hidden" />
+ <input name="username" value={recipient.username} type="hidden" />
+ <input name="confirmed" value={form?.confirm} type="hidden" />
+ <input name="pin" value={$pin} type="hidden" />
+
+ <div class="flex w-full">
+ {#if amount}
+ <button
+ type="submit"
+ class="opacity-100 hover:opacity-80'} rounded-2xl border py-3 font-bold mx-auto mt-2 bg-black text-white px-4 w-24"
+ >
+ {#if loading}
+ <Spinner />
+ {:else}
+ Send
+ {/if}
+ </button>
+ {:else}
+ <button
+ type="button"
+ class="opacity-100 hover:opacity-80'} rounded-2xl border py-3 font-bold mx-auto mt-2 bg-black text-white px-4 w-24"
+ on:click={setAmount}
+ >
+ Next
+ </button>
+ {/if}
+ </div>
+ </form>
+</div>
diff --git a/src/routes/(app)/sent/+page.svelte b/src/routes/(app)/sent/+page.svelte
new file mode 100644
index 00000000..5c2301fa
--- /dev/null
+++ b/src/routes/(app)/sent/+page.svelte
@@ -0,0 +1,34 @@
+<script>
+ import { f, s, sats } from '$lib/utils';
+ import { scale } from 'svelte/transition';
+ import { Icon } from '$comp';
+ import { toast } from '@zerodevx/svelte-toast';
+ import { t } from '$lib/translations';
+
+ export let data;
+ let { user } = data;
+
+ let payment = user.payments[0];
+ let { amount, rate } = payment;
+ amount = Math.abs(amount);
+
+ let fiat = (amount * rate) / sats;
+
+ toast.pop(0);
+</script>
+
+<div class="text-center mt-20 md:mt-0">
+ <div class="flex w-full max-w-[200px] mx-auto py-20" in:scale={{ start: 0.5 }}>
+ <Icon icon="check" style="mx-auto" />
+ </div>
+ <h1 class="text-3xl md:text-4xl font-bold mb-6">Sent!</h1>
+ <h2 class="text-2xl md:text-3xl font-semibold">
+ {f(fiat, user.currency)}
+ </h2>
+ <h3 class="text-secondary md:text-lg mb-6 mt-1">({s(amount)} SAT)</h3>
+ <a href={`/${user.username}/transactions`}>
+ <button class="bg-black text-white rounded-2xl w-20 py-3 font-bold hover:opacity-80">
+ {$t('invoice.done')}
+ </button>
+ </a>
+</div>
diff --git a/src/routes/(app)/user/+server.js b/src/routes/(app)/user/+server.js
new file mode 100644
index 00000000..b9c37e6e
--- /dev/null
+++ b/src/routes/(app)/user/+server.js
@@ -0,0 +1,15 @@
+import { auth, post } from '$lib/utils';
+
+export async function PUT({ cookies, request }) {
+ let user = await request.json();
+ let res = await post('/user', user, auth(cookies));
+
+ let maxAge = 30 * 24 * 60 * 60;
+
+ let expires = new Date();
+ expires.setSeconds(expires.getSeconds() + maxAge);
+
+ cookies.set('token', res.token, { expires, path: '/' });
+
+ return new Response(JSON.stringify(res));
+}
diff --git a/src/routes/(app)/withdraw/+server.js b/src/routes/(app)/withdraw/+server.js
new file mode 100644
index 00000000..718707d0
--- /dev/null
+++ b/src/routes/(app)/withdraw/+server.js
@@ -0,0 +1,8 @@
+import { auth, post } from '$lib/utils';
+
+export async function POST({ cookies, request }) {
+ let body = await request.json();
+ await post('/lightning/send', body, auth(cookies));
+
+ return new Response(JSON.stringify('ok'), { headers: { 'content-type': 'application/json' } });
+}