aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/404.svelte16
-rw-r--r--src/components/About.svelte73
-rw-r--r--src/components/AppHeader.svelte88
-rw-r--r--src/components/Footer.svelte39
-rw-r--r--src/components/LandingHeader.svelte106
-rw-r--r--src/components/LandingHero.svelte21
-rw-r--r--src/components/LoadingSplash.svelte7
-rw-r--r--src/components/RegisterOrLogin.svelte43
-rw-r--r--src/components/index.js4
-rw-r--r--src/hooks/index.js25
-rw-r--r--src/lib/socket.js34
-rw-r--r--src/lib/store.js22
-rw-r--r--src/lib/utils.js49
-rw-r--r--src/routes/[username]/customers.svelte5
-rw-r--r--src/routes/[username]/dashboard.svelte5
-rw-r--r--src/routes/[username]/index.js14
-rw-r--r--src/routes/[username]/index.svelte95
-rw-r--r--src/routes/[username]/receive.svelte187
-rw-r--r--src/routes/[username]/settings.svelte5
-rw-r--r--src/routes/[username]/transactions.svelte5
-rw-r--r--src/routes/__error.svelte38
-rw-r--r--src/routes/__layout.svelte42
-rw-r--r--src/routes/community.svelte0
-rw-r--r--src/routes/index.svelte53
-rw-r--r--src/routes/invoice.js20
-rw-r--r--src/routes/invoice.svelte18
-rw-r--r--src/routes/invoice/[id].js14
-rw-r--r--src/routes/invoice/[id].svelte357
-rw-r--r--src/routes/invoice/index.js14
-rw-r--r--src/routes/jobs.svelte0
-rw-r--r--src/routes/login.js27
-rw-r--r--src/routes/logout/done.svelte13
-rw-r--r--src/routes/logout/index.js26
-rw-r--r--src/routes/logout/index.svelte10
-rw-r--r--src/routes/receive.svelte75
-rw-r--r--src/routes/register.js14
-rw-r--r--src/routes/register.svelte1
-rw-r--r--src/routes/support.svelte5
38 files changed, 1357 insertions, 213 deletions
diff --git a/src/components/404.svelte b/src/components/404.svelte
new file mode 100644
index 00000000..503316bb
--- /dev/null
+++ b/src/components/404.svelte
@@ -0,0 +1,16 @@
+<script>
+ import { Icon } from '$comp';
+</script>
+
+<div class="flex justify-center items-center mt-20">
+ <div class="space-y-5 text-center">
+ <a href="/">
+ <Icon icon="logo" />
+ </a>
+
+ <Icon icon="question" style="mx-auto w-full" />
+
+ <h1 class="font-bold text-5xl">404</h1>
+ <p>Page not found.</p>
+ </div>
+</div>
diff --git a/src/components/About.svelte b/src/components/About.svelte
new file mode 100644
index 00000000..3731bf9b
--- /dev/null
+++ b/src/components/About.svelte
@@ -0,0 +1,73 @@
+<script>
+ import { Image, Icon } from '$comp';
+
+ const aboutAdam = [
+ 'Running Bitcoin since 2011',
+ '20+ years web development experience in government and industry',
+ 'Early Bitcoin Vancouver meetup organizer',
+ 'Founder of The Bitcoin Co-op and early supporter of Decentral Vancouver',
+ 'Yogi, permaculturist, anarchist, decentralist',
+ 'Husband, father, brother, son'
+ ];
+</script>
+
+<h3 class="text-5xl font-medium mb-10 text-center">About</h3>
+<div
+ class="md:px-10 lg:px-0 block lg:flex justify-center text-secondary text-xl lg:space-x-20 mx-auto max-w-7xl relative"
+>
+ <p class="w-full lg:w-5/12">
+ <strong>CoinOS is</strong> an <strong>open source</strong> bitcoin web wallet, point of sale,
+ ecommerce marketplace and exchange platform. Development started in Vancouver in September 2012
+ as a way to provide local merchants with a convenient way to accept bitcoin payments.
+ <br /><br />
+ The feature set has grown over the years to accomodate a wide variety of use cases with the goal
+ of making it as easy as possible for anyone to get started using Bitcoin quickly, cheaply, and securely.
+ <br /><br />
+ </p>
+ <p class="w-full lg:w-5/12">
+ Anyone can download the source code and host their own instance of the platform backed by their
+ own bitcoin, liquid or lightning nodes, or reach out and we'll gladly set this up for you.
+ <br /><br />
+ Intra-account transfers between custodial wallets of different users on our server are completely
+ free, instant, and unlimited.
+ </p>
+ <Image image="swoosh" style="w-32 absolute -bottom-20 lg:bottom-0 right-0 md:right-10" />
+</div>
+
+<div class="px-5 block lg:flex justify-center lg:space-x-32 items-center mt-40 lg:mt-20">
+ <div class="space-y-5 mb-5 lg:mb-0">
+ <h3 class="text-5xl font-medium mb-10 text-center lg:text-right">The Founder</h3>
+
+ <Image image="adam" style="rounded-full mx-auto lg:mx-0 lg:ml-auto w-40" />
+
+ <div class="space-y-2">
+ <span class="text-2xl text-center font-semibold block lg:text-right">Adam Soltys</span>
+ <span class="text-xl text-center text-secondary block lg:text-right"
+ >Chief Technical Officer</span
+ >
+ </div>
+
+ <div class="flex justify-center lg:justify-end items-center space-x-5">
+ <a
+ href="https://twitter.com/adamsoltys"
+ target="_blank"
+ rel="noreferrer"
+ class="rounded-full p-2 bg-[#1D9BF0]"><Icon icon="twitter" style="w-6" /></a
+ >
+ <a
+ href="https://github.com/asoltys"
+ target="_blank"
+ rel="noreferrer"
+ class="rounded-full p-2 bg-gray-800"><Icon icon="github" style="w-6" /></a
+ >
+ </div>
+ </div>
+
+ <ul
+ class="w-full mx-auto md:w-[700px] lg:w-[500px] xl:w-auto text-secondary px-1 md:px-10 text-xl list-disc space-y-5"
+ >
+ {#each aboutAdam as about}
+ <li>{about}</li>
+ {/each}
+ </ul>
+</div>
diff --git a/src/components/AppHeader.svelte b/src/components/AppHeader.svelte
index 249e2826..242e6924 100644
--- a/src/components/AppHeader.svelte
+++ b/src/components/AppHeader.svelte
@@ -1,32 +1,88 @@
<script>
// we will want to dynamically use the users avatar on this screen if they have uploaded one
+ import { user } from '$lib/store';
import { goto } from '$app/navigation';
import { Icon, LocaleSelector } from '$comp';
- import { locale, locales } from 'svelte-i18n';
+ export let avatarPosition = 'left-[calc(50vw-48px)]';
+
+ const menuButtons = [
+ { title: 'Customers', icon: 'customers', goto: 'customers' },
+ { title: 'Dashboard', icon: 'dash', goto: 'dashboard' },
+ { title: 'Settings', icon: 'settings', goto: 'settings' },
+ { title: 'Support', icon: 'support', goto: 'support' },
+ { title: 'Sign out', icon: 'logout', goto: 'logout' }
+ ];
+
+ let showMenu = false;
+
+ const handleMenuItemClick = (item) => {
+ if (item === 'logout') {
+ goto('/logout');
+ } else {
+ goto(`/${$user.username + '/' + item}`);
+ }
+ };
</script>
-<header class="bg-gradient-to-r from-primary to-gradient h-[20vh] w-full relative">
+<header class="bg-gradient-to-r from-primary to-gradient h-[175px] w-full relative">
<nav class="flex justify-end items-center space-x-4 p-5">
<LocaleSelector />
-
- <button class="bg-white p-2 rounded-full w-12 h-12" on:click={() => goto('/receive')}
- ><Icon icon="numpad" style="mx-auto" /></button
- >
- <button class="bg-white p-2 rounded-full w-12 h-12"
- ><Icon icon="clock" style="mx-auto" /></button
- >
- <button class="bg-white p-2 rounded-full w-12 h-12"
- ><Icon icon="menu" style="mx-auto" /></button
- >
- <button class="bg-white p-2 rounded-full w-12 h-12"
- ><Icon icon="profile" style="mx-auto" /></button
- >
+ {#if $user}
+ <a href={`/${$user.username}/receive`}>
+ <button class="bg-white p-2 rounded-full w-12 h-12"
+ ><Icon icon="numpad" style="mx-auto" />
+ </button>
+ </a>
+ <a href={`/${$user.username}/transactions`}>
+ <button class="bg-white p-2 rounded-full w-12 h-12"
+ ><Icon icon="clock" style="mx-auto" />
+ </button>
+ </a>
+ <a href={`/${$user.username}`}>
+ <button class="bg-white p-2 rounded-full w-12 h-12"
+ ><Icon icon="profile" style="mx-auto" />
+ </button>
+ </a>
+ <div class="relative">
+ <button class="bg-white p-2 rounded-full w-12 h-12" on:click={() => (showMenu = !showMenu)}
+ ><Icon icon="menu" style="mx-auto" />
+ </button>
+ <div
+ class="{showMenu
+ ? 'block'
+ : 'hidden'} absolute top-14 right-0 bg-white rounded-3xl p-8 shadow-xl z"
+ >
+ <ul class="space-y-5 w-32">
+ {#each menuButtons as button}
+ <li>
+ <button
+ class="flex justify-center items-center font-semibold text-sm"
+ on:click={() => handleMenuItemClick(button.goto)}
+ ><Icon icon={button.icon} style="mr-1" /> {button.title}
+ </button>
+ </li>
+ {/each}
+ </ul>
+ </div>
+ </div>
+ {:else}
+ <a href="/login">
+ <button class="bg-white px-5 py-2 rounded-xl font-semibold text-sm">Sign in </button>
+ </a>
+ {/if}
+>>>>>>> master
</nav>
<div
- class="absolute top-[calc(20vh-48px)] left-[calc(50vw-48px)] rounded-full border-4 border-white p-4 bg-gradient-to-r from-primary to-gradient w-24 h-24"
+ class="absolute top-[calc(175px-48px)] {avatarPosition} rounded-full border-4 border-white p-4 bg-gradient-to-r from-primary to-gradient w-24 h-24"
>
<Icon icon="logo-symbol-white" style="mx-auto" />
</div>
</header>
+
+<style>
+ .z {
+ z-index: 100;
+ }
+</style>
diff --git a/src/components/Footer.svelte b/src/components/Footer.svelte
new file mode 100644
index 00000000..0c682179
--- /dev/null
+++ b/src/components/Footer.svelte
@@ -0,0 +1,39 @@
+<script>
+ import { Icon } from '$comp';
+
+ const followLinks = [
+ { url: 'https://twitter.com/coinoswallet', title: 'Twitter' },
+ { url: 'https://github.com/coinos', title: 'GitHub' },
+ { url: 'https://t.me/coinos', title: 'Telegram' }
+ ];
+
+ const companyLinks = [
+ { url: 'mailto:[email protected]', title: 'Support' },
+ { url: '/community', title: 'Community' },
+ { url: '/jobs', title: 'Jobs' }
+ ];
+</script>
+
+<footer class="space-y-10 md:space-y-0 text-xl block md:flex justify-around items-center mb-20">
+ <Icon icon="logo" style="w-48 mx-auto md:mx-0" />
+
+ <div class="text-center md:text-left">
+ <p class="font-semibold">Follow Us</p>
+ <ul class="mt-5 text-secondary space-y-3 font-medium">
+ {#each followLinks as link}
+ <li><a href={link.url} target="_blank" rel="noreferrer">{link.title}</a></li>
+ {/each}
+ </ul>
+ </div>
+
+ <div class="text-center md:text-left">
+ <p class="font-semibold">Company</p>
+ <ul class="mt-5 text-secondary space-y-3">
+ {#each companyLinks as link}
+ <li><a href={link.url}>{link.title}</a></li>
+ {/each}
+ </ul>
+ </div>
+
+ <Icon icon="shield" style="mx-auto md:mx-0" />
+</footer>
diff --git a/src/components/LandingHeader.svelte b/src/components/LandingHeader.svelte
index ca9d431e..352b200c 100644
--- a/src/components/LandingHeader.svelte
+++ b/src/components/LandingHeader.svelte
@@ -2,34 +2,112 @@
import { goto } from '$app/navigation';
import { scroll } from '$lib/utils';
import { Icon, LocaleSelector } from '$comp';
-
- import { _, locale, locales } from 'svelte-i18n';
+ import { user } from '$lib/store';
+ import { _ } from 'svelte-i18n';
export let howItWorks;
export let faq;
+ export let about;
+
+ let showMobileMenu = false;
+ const mobileMenuButtonClick = (section) => {
+ showMobileMenu = false;
+ scroll(section);
+ };
</script>
-<header class="w-full lg:w-5/6 pt-10 mx-auto">
+<header
+ class="w-full lg:w-5/6 py-5 xl:pb-0 xl:pt-10 mx-auto fixed md:sticky z-10 top-0 bg-white/90"
+>
<nav class="block xl:flex flex-wrap justify-between items-center">
- <a href="/">
+ <a href="/" class="hidden md:block">
<Icon icon="logo" style="mx-auto mb-5 xl:mb-0" />
</a>
+ <Icon icon="logo" style="block md:hidden ml-5 w-48" />
<LocaleSelector />
+ <!-- desktop nav -->
<div class="hidden space-x-10 md:flex flex-wrap justify-center items-center font-bold">
<button on:click={() => scroll(howItWorks)}>{$_('howItWorks')}</button>
<button on:click={() => scroll(faq)}>{$_('FAQ')}</button>
- <button>{$_('about')}</button>
- <button class="border rounded-full px-6 py-2 font-bold" on:click={() => goto('/register')}
- >{$_('startInSeconds')}</button
- >
- <button
- class="bg-black text-white border rounded-full px-6 py-2 font-bold"
- on:click={() => goto('/login')}
- >
- {$_('signIn')}
- </button>
+ <button on:click={() => scroll(about)}>{$_('about')}</button>
+ {#if !$user}
+ <button class="border rounded-full px-6 py-2 font-bold" on:click={() => goto('/register')}
+ >{$_('startInSeconds')}
+ </button>
+ <button
+ class="bg-black text-white border rounded-full px-6 py-2 font-bold"
+ on:click={() => goto('/login')}
+ >
+ {$_('signIn')}
+ </button>
+ {:else}
+ <button
+ class="border rounded-full px-6 py-2 font-bold"
+ on:click={() => goto(`/${$user.username}/dashboard`)}
+ >Account
+ </button>
+ <button
+ class="bg-black text-white border rounded-full px-6 py-2 font-bold"
+ on:click={() => goto('/logout')}
+ >
+ Sign out
+ </button>
+ {/if}
+ </div>
+
+ <!-- mobile nav -->
+ <button
+ class="block md:hidden absolute top-[34.5px] right-10 z"
+ on:click={() => (showMobileMenu = !showMobileMenu)}
+ ><Icon icon={!showMobileMenu ? 'menu' : 'close'} />
+ </button>
+
+ <div
+ class="block md:hidden absolute top-0 {showMobileMenu
+ ? 'right-0'
+ : 'right-[-100%]'} transition-all ease-in-out duration-300 h-[100vh] w-full bg-white"
+ >
+ <div class="space-y-8 mt-24 ml-10 font-bold text-xl">
+ <button on:click={() => mobileMenuButtonClick(howItWorks)} class="block"
+ >{$_('howItWorks')}</button
+ >
+ <button on:click={() => mobileMenuButtonClick(faq)} class="block">{$_('FAQ')}</button>
+ <button on:click={() => mobileMenuButtonClick(about)} class="block">{$_('about')}</button>
+ {#if !$user}
+ <button
+ class="border rounded-full px-6 py-2 font-bold block"
+ on:click={() => goto('/register')}
+ >{$_('startInSeconds')}
+ </button>
+ <button
+ class="bg-black text-white border rounded-full px-6 py-2 font-bold block"
+ on:click={() => goto('/login')}
+ >
+ {$_('startInSeconds')}
+ </button>
+ {:else}
+ <button
+ class="border rounded-full px-6 py-2 font-bold block"
+ on:click={() => goto(`/${$user.username}/dashboard`)}
+ >Account
+ </button>
+ <button
+ class="bg-black text-white border rounded-full px-6 py-2 font-bold block"
+ on:click={() => goto('/logout')}
+ >
+ Sign out
+ </button>
+ {/if}
+ </div>
+>>>>>>> master
</div>
</nav>
</header>
+
+<style>
+ .z {
+ z-index: 100;
+ }
+</style>
diff --git a/src/components/LandingHero.svelte b/src/components/LandingHero.svelte
index 2c2e1ab5..93bea8c2 100644
--- a/src/components/LandingHero.svelte
+++ b/src/components/LandingHero.svelte
@@ -1,9 +1,10 @@
<script>
import { goto } from '$app/navigation';
import { Icon, Image } from '$comp';
+ import { user } from '$lib/store';
</script>
-<div>
+<div class="mt-[96.88px] md:mt-0">
<div class="space-y-10">
<h1
class="text-5xl md:text-6xl lg:text-7xl 2xl:text-8xl font-medium text-center w-full md:w-3/4 lg:w-1/2 max-w-7xl mx-auto leading-tight"
@@ -19,14 +20,16 @@
Grow your customer base and revenue by accepting Bitcoin with an easy-to-use and managed Point
of Sale. No software or hardware required.
</h2>
- <div class="flex justify-center">
- <button
- class="bg-black text-white border rounded-full px-6 py-2 font-bold"
- on:click={() => goto('/register')}
- >
- Start in Seconds
- </button>
- </div>
+ {#if !$user}
+ <div class="flex justify-center">
+ <button
+ class="bg-black text-white border rounded-full px-6 py-2 font-bold"
+ on:click={() => goto('/register')}
+ >
+ Start in Seconds
+ </button>
+ </div>
+ {/if}
</div>
<!-- hero image -->
diff --git a/src/components/LoadingSplash.svelte b/src/components/LoadingSplash.svelte
new file mode 100644
index 00000000..2ab4c739
--- /dev/null
+++ b/src/components/LoadingSplash.svelte
@@ -0,0 +1,7 @@
+<script>
+ import { Icon } from '$comp';
+</script>
+
+<div class="flex justify-center items-center">
+ <Icon icon="logo" style="mt-[calc(50vh-72px)]" />
+</div>
diff --git a/src/components/RegisterOrLogin.svelte b/src/components/RegisterOrLogin.svelte
index 7cc41709..157c2821 100644
--- a/src/components/RegisterOrLogin.svelte
+++ b/src/components/RegisterOrLogin.svelte
@@ -1,13 +1,15 @@
<script>
+ import { toast } from '@zerodevx/svelte-toast';
import { Icon } from '$comp';
import { post } from '$lib/utils';
- import { token } from '$lib/store';
- import { connect } from '$lib/socket';
+ import { user, token } from '$lib/store';
+ import { auth } from '$lib/socket';
import { goto } from '$app/navigation';
export let page;
- let username, password;
+ let username = 'bob',
+ password = 'pw';
let revealPassword = false;
@@ -16,20 +18,27 @@
'Sign in': '/login'
}[page];
- let submit = () =>
- post(url, { username, password }).then((r) =>
- r.json().then((r) => {
- $token = r.token;
- connect();
- goto('/receive');
- })
- );
+ let submit = async () => {
+ try {
+ let r = await post(url, { username, password });
+ if (!r.token) r = await post('/login', { username, password });
+ $token = r.token;
+ auth();
+ } catch (e) {
+ if (!e.message) e.message = 'Login failed';
+ toast.push(e.message);
+ }
+ };
+
+ $: if ($user) goto(`/${username}/receive`);
</script>
<div class="pt-10">
- <a href="/">
- <Icon icon="logo" style="mx-auto mb-10" />
- </a>
+ <div class="w-[243px] mx-auto mb-10">
+ <a href="/">
+ <Icon icon="logo" />
+ </a>
+ </div>
<div class="flex justify-center items-center">
<div class="shadow-xl rounded-3xl p-10 pb-12 space-y-5 w-full mx-5 md:mx-0 md:w-[400px]">
@@ -38,11 +47,13 @@
<form class="space-y-5" on:submit|preventDefault={submit}>
<div>
<label for="username" class="font-semibold">Username</label>
+ <!-- svelte-ignore a11y-autofocus -->
<input
type="text"
required
class="bg-primary p-4 rounded-2xl w-full"
bind:value={username}
+ autofocus
/>
</div>
@@ -78,7 +89,9 @@
</p>
{:else}
<div class="flex justify-end items-center">
- <a href="" class="underline underline-offset-4 text-black text-sm">Forgot Password?</a>
+ <a href="/forgot" class="underline underline-offset-4 text-black text-sm"
+ >Forgot Password?</a
+ >
</div>
{/if}
diff --git a/src/components/index.js b/src/components/index.js
index f0e86183..1d5b369d 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -8,3 +8,7 @@ export { default as Image } from './Image.svelte';
export { default as RegisterOrLogin } from './RegisterOrLogin.svelte';
export { default as AppHeader } from './AppHeader.svelte';
export { default as LocaleSelector } from './LocaleSelector.svelte';
+export { default as LoadingSplash } from './LoadingSplash.svelte';
+export { default as PageNotFound } from './404.svelte';
+export { default as About } from './About.svelte';
+export { default as Footer } from './Footer.svelte';
diff --git a/src/hooks/index.js b/src/hooks/index.js
new file mode 100644
index 00000000..2284d88d
--- /dev/null
+++ b/src/hooks/index.js
@@ -0,0 +1,25 @@
+import cookie from 'cookie';
+import { get } from '$lib/utils';
+import { protectedRoutes } from '$lib/utils';
+
+export async function handle({ event, resolve }) {
+ let {
+ params,
+ request: { headers }
+ } = event;
+
+ const cookies = cookie.parse(headers.get('cookie') || '');
+ let { token } = cookies;
+ event.locals = { token };
+
+ if (protectedRoutes.find((p) => event.url.pathname.match(p))) {
+ let user = await get('/me', { accept: 'application/json', authorization: `Bearer ${token}` });
+ if (user.username !== params.username) throw new Error('user not found');
+ }
+
+ return resolve(event);
+}
+
+export const getSession = ({ locals: { token } }) => {
+ return { token };
+};
diff --git a/src/lib/socket.js b/src/lib/socket.js
index efc2651b..66091d76 100644
--- a/src/lib/socket.js
+++ b/src/lib/socket.js
@@ -1,23 +1,31 @@
-import { get } from "svelte/store";
-import { rate, user, token, ws } from '$lib/store';
+import { get } from 'svelte/store';
+import { rate, user, token, invoices, preferredCurrency } from '$lib/store';
let interval, socket;
+export const auth = () => {
+ let t = get(token);
+ if (t) send('login', t);
+};
+
+export const send = (type, data) => {
+ socket?.readyState === 1 && socket.send(JSON.stringify({ type, data }));
+};
+
export const messages = (data) => ({
rate() {
rate.set(data);
},
payment() {
- console.log(data);
+ invoices.set({ ...get(invoices), [data.invoice.uuid]: data.invoice });
},
- connected() {
- socket.send(JSON.stringify({ type: 'login', data: { token: get(token) } }));
- },
+ connected: auth,
login() {
user.set(data);
+ preferredCurrency.set(data.currency);
}
});
@@ -29,18 +37,18 @@ let currentReconnectDelay = initialReconnectDelay;
export function connect() {
clearInterval(interval);
- if (socket) socket.close();
+ if (socket) return auth();
+
socket = new WebSocket('ws://localhost:3119/ws');
socket.addEventListener('open', onWebsocketOpen);
socket.addEventListener('close', onWebsocketClose);
socket.addEventListener('message', onWebsocketMessage);
- interval = setInterval(
- () => socket.readyState === 1 && socket.send(JSON.stringify({ type: 'heartbeat' })),
- 5000
- );
+ interval = setInterval(() => send('heartbeat'), 5000);
+}
- ws.set(socket);
+export function close() {
+ if (socket) socket.close();
}
function onWebsocketMessage(msg) {
@@ -53,7 +61,7 @@ function onWebsocketOpen() {
}
function onWebsocketClose() {
- ws.set(null);
+ socket = null;
setTimeout(() => {
reconnectToWebsocket();
}, currentReconnectDelay + Math.floor(Math.random() * 3000));
diff --git a/src/lib/store.js b/src/lib/store.js
index 3963f55a..b20fd10f 100644
--- a/src/lib/store.js
+++ b/src/lib/store.js
@@ -1,6 +1,22 @@
-import { writable } from 'svelte/store';
+import { get, writable } from 'svelte/store';
+import { browser } from '$app/env';
+
+const persisted = (k, i) => {
+ let s = writable(
+ browser && sessionStorage.getItem(k) && sessionStorage.getItem(k) !== 'undefined'
+ ? JSON.parse(sessionStorage.getItem(k))
+ : i
+ );
+
+ s.subscribe((v) => browser && sessionStorage.setItem(k, JSON.stringify(v)));
+
+ return s;
+};
export const rate = writable();
export const user = writable();
-export const token = writable();
-export const ws = writable();
+export const invoiceAmount = writable();
+export const invoiceAmountFiat = writable();
+export const token = persisted('token');
+export const invoices = writable({});
+export const preferredCurrency = writable();
diff --git a/src/lib/utils.js b/src/lib/utils.js
index 595d2dd8..669b2b5e 100644
--- a/src/lib/utils.js
+++ b/src/lib/utils.js
@@ -1,10 +1,53 @@
+import { browser } from '$app/env';
+
export function scroll(section) {
section.scrollIntoView({ behavior: 'smooth' });
}
+const base = browser ? '' : import.meta.env.VITE_COINOS_URL;
+
+export const get = (url, headers = { accept: 'application/json' }) =>
+ fetch(base + url, { headers })
+ .then((r) => r.text())
+ .then((body) => {
+ try {
+ return JSON.parse(body);
+ } catch (e) {
+ throw new Error(body);
+ }
+ });
+
export const post = (url, body) =>
- fetch(url, {
+ fetch(base + url, {
method: 'POST',
body: JSON.stringify(body),
- headers: { 'accept': 'application/json', 'content-type': 'application/json' }
- });
+ headers: { accept: 'application/json', 'content-type': 'application/json' }
+ })
+ .then((r) => r.text())
+ .then((body) => {
+ try {
+ body = JSON.parse(body);
+ } catch (e) {
+ throw new Error(body);
+ }
+
+ if (body instanceof Error) throw body;
+ if (body.name === 'Error') throw new Error(body.message);
+
+ return body;
+ });
+
+export const copy = (text) => {
+ navigator.clipboard.writeText(text);
+};
+
+export function reverseFormat(val, locale) {
+ let parts = new Intl.NumberFormat(locale).formatToParts(1111.1);
+ let group = parts.find((part) => part.type === 'group').value;
+ let decimal = parts.find((part) => part.type === 'decimal').value;
+ let reversedVal = val.replace(new RegExp('\\' + group, 'g'), '');
+ reversedVal = reversedVal.replace(new RegExp('\\' + decimal, 'g'), '.');
+ return Number.isNaN(reversedVal) ? 0 : +reversedVal;
+}
+
+export let protectedRoutes = [/receive/, /customers/, /dashboard/, /settings/, /transactions/];
diff --git a/src/routes/[username]/customers.svelte b/src/routes/[username]/customers.svelte
new file mode 100644
index 00000000..1935b156
--- /dev/null
+++ b/src/routes/[username]/customers.svelte
@@ -0,0 +1,5 @@
+<script>
+ import { AppHeader } from '$comp';
+</script>
+
+<AppHeader />
diff --git a/src/routes/[username]/dashboard.svelte b/src/routes/[username]/dashboard.svelte
new file mode 100644
index 00000000..1935b156
--- /dev/null
+++ b/src/routes/[username]/dashboard.svelte
@@ -0,0 +1,5 @@
+<script>
+ import { AppHeader } from '$comp';
+</script>
+
+<AppHeader />
diff --git a/src/routes/[username]/index.js b/src/routes/[username]/index.js
new file mode 100644
index 00000000..5de1316f
--- /dev/null
+++ b/src/routes/[username]/index.js
@@ -0,0 +1,14 @@
+import { get } from '$lib/utils';
+
+export async function GET({ params }) {
+ let { username } = params;
+ let body;
+
+ try {
+ body = await get(`/users/${username}`);
+ } catch (e) {
+ body = {};
+ }
+
+ return { body };
+}
diff --git a/src/routes/[username]/index.svelte b/src/routes/[username]/index.svelte
new file mode 100644
index 00000000..6002efdc
--- /dev/null
+++ b/src/routes/[username]/index.svelte
@@ -0,0 +1,95 @@
+<script>
+ import { toast } from '@zerodevx/svelte-toast';
+ import { browser } from '$app/env';
+ import { onMount } from 'svelte';
+ import { AppHeader, Icon, PageNotFound } from '$comp';
+ import { user } from '$lib/store';
+ import { copy } from '$lib/utils';
+ export let username;
+
+ let lightningAddress = `${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: 'svg',
+ data: 'add static qr here',
+ 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;
+
+ let qrSkeleton = true;
+ setTimeout(() => (qrSkeleton = false), 100);
+
+ const handleCopy = () => {
+ copy(lightningAddress);
+ toast.push('Copied!', {
+ theme: {
+ '--toastBarBackground': '#2F855A'
+ }
+ });
+ };
+</script>
+
+{#if username}
+ <AppHeader avatarPosition="left-[calc(15vw-48px)]" />
+
+ <div class="flex xl:block justify-center items-center my-20 px-3">
+ <div class="space-y-10 xl:space-y-0">
+ <div class="xl:ml-[calc(15vw-48px)] space-y-2 w-full md:w-72">
+ <h1 class="text-3xl font-bold">{username}</h1>
+
+ <p class="text-secondary">Address goes here</p>
+
+ <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" />
+ {followed ? 'Remove' : 'Follow'}
+ </button>
+ </div>
+
+ <div class="space-y-5 static xl:absolute top-[255px] left-[calc(50vw-150px)]">
+ <div
+ id="qr"
+ class="border border-lightgrey rounded-3xl flex md:p-5 justify-center items-center relative"
+ >
+ <div
+ class="z-100 h-[300px] md:h-[342px] w-[250px] md:w-[342px] animate-pulse absolute top-0 left-0 bg-gray-400 rounded-3xl {qrSkeleton
+ ? 'block'
+ : 'hidden'}"
+ />
+ </div>
+
+ <p class="text-secondary text-center font-semibold">Scan to pay with Bitcoin</p>
+
+ <div
+ class="bg-primary font-semibold rounded-xl text-sm py-2 px-3 flex flex-wrap justify-center items-center"
+ >
+ <span>{lightningAddress}</span>
+ <button on:click={handleCopy}>
+ <Icon icon="copy" style="ml-2" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+{:else}
+ <PageNotFound />
+{/if}
diff --git a/src/routes/[username]/receive.svelte b/src/routes/[username]/receive.svelte
new file mode 100644
index 00000000..50fddbed
--- /dev/null
+++ b/src/routes/[username]/receive.svelte
@@ -0,0 +1,187 @@
+<script>
+ // in the future we will want to allow users to set their fiat currency in the settings
+ import { AppHeader, Icon } from '$comp';
+ import { goto } from '$app/navigation';
+ import { invoiceAmount, invoiceAmountFiat, rate, user } from '$lib/store';
+ import { post } from '$lib/utils';
+ import { page } from '$app/stores';
+
+ let useFiat = true;
+ let amountFiat = 0;
+ let amountSats = 0;
+ let message = '';
+
+ $: amount = useFiat ? Math.round(amountFiat / ($rate / 100000000)) : amountSats;
+
+ $: $invoiceAmount = parseInt(amountSats === 0 ? amountFiat / ($rate / 100000000) : amountSats);
+ $: $invoiceAmountFiat = parseInt(
+ amountFiat === 0 ? amountSats * ($rate / 100000000) : amountFiat
+ );
+ $: amountSatsFormatted = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(
+ amountSats
+ );
+
+ $: amountFiatConverted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD'
+ }).format(amountSats * ($rate / 100000000));
+
+ $: amountSatsConverted = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(
+ amountFiat / ($rate / 100000000)
+ );
+
+ const numPad = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '<'];
+
+ const handleInput = (value) => {
+ if (message) {
+ message = '';
+ }
+ if (useFiat) {
+ if (amountFiat === 0 && value !== '.' && value !== '<' && value !== '0') {
+ amountFiat = value;
+ } else if ((amountFiat === 0 || amountFiat === '0') && value === '0') {
+ return;
+ } else if (amountFiat === 0 && value === '.') {
+ amountFiat = '0.';
+ } else if (amountFiat !== 0 && amountFiat.includes('.') && value === '.') {
+ return;
+ } else if (value === '<') {
+ if (amountFiat !== 0) {
+ amountFiat = amountFiat.slice(0, amountFiat.length - 1);
+ if (amountFiat.length === 0) {
+ amountFiat = 0;
+ amountSats = 0;
+ }
+ }
+ } else if (value !== '.' && value !== '<' && parseInt(amountFiat + value) > $rate) {
+ message = 'Please enter an amount less than 1 bitcoin.';
+ } else if (amountFiat !== 0 && amountFiat.match(/\.../)) {
+ return;
+ } else {
+ amountFiat = amountFiat + value;
+ }
+ } else {
+ if (value === '.') {
+ return;
+ } else if (amountSats === 0 && value !== '<' && value !== '0') {
+ amountSats = value;
+ } else if (value === '<') {
+ if (amountSats !== 0) {
+ amountSats = amountSats.slice(0, amountSats.length - 1);
+ if (amountSats.length === 0) {
+ amountSats = 0;
+ amountFiat = 0;
+ }
+ }
+ } else if (value !== '<' && parseInt(amountSats + value) > 100000000) {
+ message = 'Please enter an amount less than 1 bitcoin.';
+ } else {
+ amountSats = amountSats + value;
+ }
+ }
+ };
+
+ let submit = async () => {
+ let { id } = await post('/invoice', { amount, rate: $rate, username: $page.params.username });
+ goto(`/invoice/${id}`);
+ };
+</script>
+
+{#if $user}
+ <AppHeader />
+
+ <div class="flex justify-center items-center mt-20 mb-3 px-3">
+ {#if $rate}
+ <div class="space-y-5">
+ <!-- amounts -->
+ <div class="text-center">
+ <div class="text-5xl md:text-6xl font-semibold tracking-widest mb-1">
+ {useFiat ? `$${amountFiat}` : amountSatsFormatted}<span
+ class="tracking-normal text-base font-normal">{useFiat ? 'USD' : 'SATS'}</span
+ >
+ </div>
+ <span class="text-secondary mr-1"
+ >{useFiat ? `${amountSatsConverted} SATS` : `${amountFiatConverted} USD`}</span
+ >
+ <button
+ on:click={() => {
+ if (useFiat) {
+ amountSats = (amountFiat / ($rate / 100000000)).toFixed(0).toString();
+ } else {
+ amountFiat =
+ (amountSats * ($rate / 100000000)).toFixed(2) > 0.0
+ ? (amountSats * ($rate / 100000000)).toFixed(2).toString()
+ : 0;
+ }
+ useFiat = !useFiat;
+ }}><Icon icon="swap" style="inline" /></button
+ >
+ </div>
+
+ <!-- error message -->
+ {#if message}
+ <div
+ class="text-orange-500 text-sm text-center font-bold bg-primary p-2 rounded-xl flex justify-center items-center space-x-1"
+ >
+ <Icon icon="info" style="w-5" />
+ <p>{message}</p>
+ </div>
+ {/if}
+
+ <!-- numpad -->
+ <div class="grid grid-cols-3 gap-2 w-full md:w-[300px] mx-auto">
+ {#each numPad as value}
+ {#if value === '<'}
+ <button
+ class="bg-primary rounded-xl py-4 px-8 font-semibold active:bg-black active:text-white flex justify-center items-center"
+ on:click={() => handleInput(value)}
+ >
+ <svg
+ class="w-5"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M15 19L8 12L15 5"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </button>
+ {:else}
+ <button
+ class="bg-primary rounded-xl py-4 px-8 font-semibold active:bg-black active:text-white"
+ on:click={() => handleInput(value)}>{value}</button
+ >
+ {/if}
+ {/each}
+ </div>
+
+ <!-- buttons -->
+ <div class="space-y-3 w-full md:w-[300px] mx-auto">
+ <button
+ class="bg-black text-white rounded-xl w-full h-[48px] flex justify-center items-center font-semibold text-sm {$invoiceAmount ===
+ 0
+ ? 'opacity-50'
+ : 'opacity-100'}"
+ on:click={submit}
+ disabled={$invoiceAmount === 0}><Icon icon="qr" style="mr-2" /> Show QR</button
+ >
+ <button
+ class="bg-primary rounded-xl w-full h-[48px] flex justify-center items-center font-semibold text-sm"
+ disabled={true}><Icon icon="send" style="mr-2" /> Send Invoice</button
+ >
+ </div>
+ </div>
+ {:else}
+ <div
+ class="w-full md:w-[300px] h-[475px] md:h-[485px] animate-pulse bg-gray-400 rounded-xl"
+ />
+ {/if}
+ </div>
+{/if}
diff --git a/src/routes/[username]/settings.svelte b/src/routes/[username]/settings.svelte
new file mode 100644
index 00000000..1935b156
--- /dev/null
+++ b/src/routes/[username]/settings.svelte
@@ -0,0 +1,5 @@
+<script>
+ import { AppHeader } from '$comp';
+</script>
+
+<AppHeader />
diff --git a/src/routes/[username]/transactions.svelte b/src/routes/[username]/transactions.svelte
new file mode 100644
index 00000000..1935b156
--- /dev/null
+++ b/src/routes/[username]/transactions.svelte
@@ -0,0 +1,5 @@
+<script>
+ import { AppHeader } from '$comp';
+</script>
+
+<AppHeader />
diff --git a/src/routes/__error.svelte b/src/routes/__error.svelte
new file mode 100644
index 00000000..2d617f9d
--- /dev/null
+++ b/src/routes/__error.svelte
@@ -0,0 +1,38 @@
+<script context="module">
+ export function load({ error, status }) {
+ if (error.message.includes('not found')) {
+ error.message = 'Page not found';
+ status = 404;
+ }
+
+ return {
+ props: {
+ error,
+ status
+ }
+ };
+ }
+</script>
+
+<script>
+ import { Icon, PageNotFound } from '$comp';
+ export let status, error;
+</script>
+
+{#if status === 404}
+ <PageNotFound />
+{:else}
+ <div class="flex justify-center items-center mt-20">
+ <div class="space-y-5 text-center">
+ <a href="/">
+ <Icon icon="logo" />
+ </a>
+
+ <Icon icon="error" style="mx-auto w-full" />
+
+ <h1 class="font-bold text-5xl">Error</h1>
+ <p>{error.message}</p>
+ <p>Please try again or contact support.</p>
+ </div>
+ </div>
+{/if}
diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte
index ed1ad188..9ec36c57 100644
--- a/src/routes/__layout.svelte
+++ b/src/routes/__layout.svelte
@@ -1,11 +1,47 @@
<script>
+ import { SvelteToast } from '@zerodevx/svelte-toast';
+ import { toast } from '@zerodevx/svelte-toast';
import '../app.css';
+ import '$lib/i18n';
import { onMount } from 'svelte';
import { connect } from '$lib/socket';
+ import { user, token } from '$lib/store';
+ import { page, session } from '$app/stores';
+ import { browser } from '$app/env';
+ import { goto } from '$app/navigation';
+ import { LoadingSplash } from '$comp';
+ import { protectedRoutes } from '$lib/utils';
- import '$lib/i18n';
+ browser && ($token ||= $session.token);
+ let ready = false;
- onMount(connect);
+ onMount(() => {
+ if (protectedRoutes.find((p) => $page.url.pathname.match(p))) {
+ if (!$token) {
+ goto('/login');
+ toast.push('Please sign in to continue.');
+ }
+ user.subscribe((u) => u?.username && (ready = true));
+ } else {
+ ready = true;
+ }
+
+ connect();
+ });
</script>
-<slot />
+{#if ready}
+ <SvelteToast options={{ reversed: true, intro: { y: 192 } }} />
+ <slot />
+{:else}
+ <LoadingSplash />
+{/if}
+
+<style>
+ :root {
+ --toastContainerTop: auto;
+ --toastContainerRight: auto;
+ --toastContainerBottom: 8rem;
+ --toastContainerLeft: calc(50vw - 8rem);
+ }
+</style>
diff --git a/src/routes/community.svelte b/src/routes/community.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/community.svelte
diff --git a/src/routes/index.svelte b/src/routes/index.svelte
index cda885a3..faa881bb 100644
--- a/src/routes/index.svelte
+++ b/src/routes/index.svelte
@@ -1,5 +1,14 @@
<script>
- import { LandingHeader, LandingHero, LandingInfoCard, HowItWorksCard, FaqCard } from '$comp';
+ import {
+ LandingHeader,
+ LandingHero,
+ LandingInfoCard,
+ HowItWorksCard,
+ FaqCard,
+ Image,
+ About,
+ Footer
+ } from '$comp';
let howItWorks;
let faq;
@@ -42,9 +51,9 @@
];
</script>
-<LandingHeader {howItWorks} {faq} />
+<LandingHeader {howItWorks} {faq} {about} />
-<main class="space-y-40 py-20 md:py-32 lg:py-40 px-5 md:px-0">
+<main class="space-y-40 py-20 md:py-32 lg:py-36 xl:py-40 px-5 md:px-0">
<!-- hero -->
<LandingHero />
@@ -73,15 +82,18 @@
</LandingInfoCard>
<!-- landing info 3 -->
- <LandingInfoCard
- image="customize"
- title="Brand it your way!"
- description="Customize your point of sale look and feel, or keep it on default - totally up to you."
- />
+ <div>
+ <LandingInfoCard
+ image="customize"
+ title="Brand it your way!"
+ description="Customize your point of sale look and feel, or keep it on default - totally up to you."
+ />
+ <div bind:this={howItWorks} />
+ </div>
<!-- how it works -->
- <div bind:this={howItWorks}>
- <h3 class="text-5xl font-medium mb-20 text-center">How it works</h3>
+ <div>
+ <h3 class="text-5xl font-medium mb-10 text-center">How it works</h3>
<div
class="block lg:flex justify-center items-center space-y-10 lg:space-y-0 lg:space-x-10 text-center"
>
@@ -89,13 +101,24 @@
<HowItWorksCard image={step.image} step={step.step} description={step.description} />
{/each}
</div>
+ <div bind:this={faq} />
</div>
<!-- faq -->
- <div class="space-y-10" bind:this={faq}>
- <h3 class="text-5xl font-medium text-center">FAQ</h3>
- {#each faqQuestions as question}
- <FaqCard question={question.question} answer={question.answer} />
- {/each}
+ <div>
+ <div class="space-y-10">
+ <h3 class="text-5xl font-medium text-center">FAQ</h3>
+ {#each faqQuestions as question}
+ <FaqCard question={question.question} answer={question.answer} />
+ {/each}
+ </div>
+ <div bind:this={about} />
+ </div>
+
+ <!-- about -->
+ <div>
+ <About />
</div>
</main>
+
+<Footer />
diff --git a/src/routes/invoice.js b/src/routes/invoice.js
deleted file mode 100644
index 49bcfe70..00000000
--- a/src/routes/invoice.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import got from 'got';
-
-const username = 'bob',
- network = 'lightning';
-
-export async function GET({ params }) {
- let text = await got
- .post('http://localhost:3119/lightning/invoice', {
- json: { amount: 100 }
- })
- .text();
-
- await got
- .post('http://localhost:3119/invoice', {
- json: { invoice: { text, network }, user: { username } }
- })
- .json();
-
- return { body: { text } };
-}
diff --git a/src/routes/invoice.svelte b/src/routes/invoice.svelte
deleted file mode 100644
index b8f9e54b..00000000
--- a/src/routes/invoice.svelte
+++ /dev/null
@@ -1,18 +0,0 @@
-<script>
- import { rate, user } from '$lib/store';
- import { onMount } from 'svelte';
-
- export let text;
-</script>
-
-QR code
-
-{text}
-
-{$rate}
-
-{$user?.username}
-
-{#each $user.payments as p}
- {p.amount}<br />
-{/each}
diff --git a/src/routes/invoice/[id].js b/src/routes/invoice/[id].js
new file mode 100644
index 00000000..a58d02a1
--- /dev/null
+++ b/src/routes/invoice/[id].js
@@ -0,0 +1,14 @@
+import { get } from '$lib/utils';
+
+export async function GET({ params }) {
+ let { id } = params;
+ let {
+ amount,
+ rate,
+ status,
+ text,
+ user: { username }
+ } = await get(`/invoice?uuid=${id}`);
+
+ return { body: { amount, id, rate, username, status, text } };
+}
diff --git a/src/routes/invoice/[id].svelte b/src/routes/invoice/[id].svelte
new file mode 100644
index 00000000..17c7de1f
--- /dev/null
+++ b/src/routes/invoice/[id].svelte
@@ -0,0 +1,357 @@
+<script>
+ import { toast } from '@zerodevx/svelte-toast';
+ import { get, post, reverseFormat } from '$lib/utils';
+ import { browser } from '$app/env';
+ import { invoices, user, preferredCurrency } from '$lib/store';
+ import { Icon, Image } from '$comp';
+ import { goto } from '$app/navigation';
+ import { onMount } from 'svelte';
+ import { copy } from '$lib/utils';
+
+ export let amount, id, rate, status, text, username;
+
+ $invoices[id] = { amount, id, rate, status, text, username };
+
+ $: amountFiat = parseFloat(((amount * rate) / 100000000).toFixed(2));
+
+ let tipPercent = 0;
+ let qr;
+
+ $: tipAmount = customTipAmount
+ ? customTipAmount < 0
+ ? (customTipAmount = 0)
+ : customTipAmount
+ : (amountFiat / 100) * tipPercent;
+
+ $: tipAmountFormatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD'
+ }).format(tipAmount);
+
+ $: tipAmountSats = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(
+ tipAmount / (rate / 100000000)
+ );
+
+ $: totalAmountSats = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(
+ amount + parseFloat(tipAmountSats.replace(/,/g, ''))
+ );
+
+ $: update(tipAmountSats);
+ let update = async (n) => {
+ let { id } = await post('/invoice', {
+ amount: amount + reverseFormat(n),
+ rate,
+ text,
+ username
+ });
+ ({ text } = await get(`/invoice/${id}`));
+ };
+
+ $: totalAmountFormatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD'
+ }).format(amountFiat + parseFloat(tipAmount));
+
+ const amountFormatted = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 0
+ }).format(amount);
+
+ $: invoiceAmountFiatFormatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD'
+ }).format(amountFiat);
+
+ let customTipAmount;
+
+ const tipAmounts = ['None', '10%', '15%', '20%'];
+
+ const handleTipButtonClick = (amount) => {
+ customTipAmount = '';
+ customInput.value = '';
+
+ if (amount === 'None') {
+ tipPercent = 0;
+ } else {
+ tipPercent = amount.slice(0, 2);
+ }
+ };
+
+ const handleCustomTipAmount = (e) => {
+ tipPercent = 0;
+ customTipAmount = e.target.value;
+ };
+
+ $: generate(text);
+ let generate = async (ready) => {
+ console.log('GENERATE', text);
+ if (!ready) return;
+ let { default: QRCodeStyling } = await import('qr-code-styling');
+
+ const qrCode = new QRCodeStyling({
+ width: window.screen.width < 640 ? 250 : 300,
+ type: 'canvas',
+ data: text,
+ image: '/images/invoice.svg',
+ backgroundOptions: {
+ color: 'rgba(0, 0, 0, 0)'
+ },
+ dotsOptions: {
+ type: 'rounded'
+ },
+ imageOptions: {
+ hideBackgroundDots: false
+ }
+ });
+
+ qrCode.append(qr);
+ };
+
+ let showMobileTip = false;
+
+ const handleBackButton = () => {
+ if (showMobileTip) {
+ tipPercent = 0;
+ customInput.value = '';
+ customTipAmount = 0;
+ tipAmount = 0;
+ showMobileTip = false;
+ } else {
+ goto(`/${$user.username}/receive`);
+ }
+ };
+
+ let customInput;
+
+ const handleCopy = () => {
+ copy(text);
+ toast.push('Copied!', {
+ theme: {
+ '--toastBarBackground': '#2F855A'
+ }
+ });
+ };
+
+ const handleDoneClick = () => {
+ if ($user) {
+ goto(`/${$user.username}/receive`);
+ } else {
+ goto('/');
+ }
+ };
+</script>
+
+{#if $invoices[id]?.status !== 'paid'}
+ <div class:full-shadow={showMobileTip}>
+ <button
+ class="ml-5 md:ml-20 mt-5 md:mt-10"
+ class:invisible={!$user}
+ on:click={handleBackButton}
+ >
+ <Icon icon="arrow-left" style="w-10" />
+ </button>
+
+ <div class="flex justify-center items-center mb-20 md:mt-[20px] px-3">
+ <div
+ class="block lg:flex justify-center items-center space-y-10 lg:space-y-0 space-x-0 lg:space-x-24"
+ >
+ <!-- tipping section -->
+ <div
+ class="w-[100%] md:w-auto absolute {showMobileTip
+ ? 'bottom-0'
+ : '-bottom-full'} bg-white p-6 md:p-0 rounded-t-3xl md:rounded-none transition-all md:transition-none ease-in-out duration-500 left-0 md:static text-center space-y-5"
+ >
+ <h1 class="hidden md:block text-4xl font-semibold">Add a tip?</h1>
+
+ <div>
+ <span class="font-semibold mr-1">{tipAmountFormatted}</span><span
+ class="text-secondary font-semibold text-sm">{`(${tipAmountSats} SATS)`}</span
+ >
+ <span class="block text-secondary text-sm">{tipPercent}%</span>
+ </div>
+
+ <input
+ id="slider"
+ type="range"
+ min="0"
+ max="100"
+ value={tipPercent}
+ class="px-2"
+ on:input={(e) => {
+ customTipAmount = '';
+ customInput.value = '';
+ tipPercent = e.target.value;
+ }}
+ />
+
+ <div>
+ {#each tipAmounts as amount}
+ <button
+ class="bg-primary w-16 md:w-20 py-3 m-1 rounded-2xl font-semibold {customTipAmount
+ ? ''
+ : tipPercent === amount.slice(0, 2) ||
+ ((tipPercent === 0 || tipPercent === '0') && amount === 'None')
+ ? '!bg-black text-white'
+ : ''}"
+ on:click={() => handleTipButtonClick(amount)}>{amount}</button
+ >
+ {/each}
+
+ <div class="relative">
+ <span
+ class="absolute top-[20px] left-5 font-semibold {!customTipAmount
+ ? 'opacity-50'
+ : 'opacity-100'}">$</span
+ >
+ <input
+ bind:this={customInput}
+ type="number"
+ min="0"
+ on:input={(e) => handleCustomTipAmount(e)}
+ class="bg-primary rounded-2xl py-3 px-10 font-semibold mt-2 mr-1 w-full md:w-auto"
+ placeholder="Other"
+ />
+ <button
+ class="w-full md:w-auto mt-2 md:mt-0 bg-black text-white font-semibold py-3 px-7 rounded-2xl {showMobileTip
+ ? !customTipAmount && !tipAmount
+ ? 'opacity-50'
+ : 'opacity-100'
+ : !customTipAmount
+ ? 'opacity-50'
+ : 'opacity-100'}"
+ disabled={showMobileTip ? !customTipAmount && !tipAmount : !customTipAmount}
+ on:click={() => (showMobileTip = false)}>Done</button
+ >
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="hidden md:block h-[1px] lg:h-[600px] w-full lg:w-[1px] bg-lightgrey rounded-xl"
+ id="section-divider"
+ />
+
+ <!-- invoice section -->
+ <div class="text-center space-y-5">
+ <button
+ class="block md:hidden bg-black {showMobileTip
+ ? 'text-white/50'
+ : 'text-white'} rounded-xl px-6 py-3 block text-sm font-semibold mx-auto flex justify-center items-center"
+ on:click={() => (showMobileTip = true)}
+ disabled={showMobileTip}
+ >
+ <svg
+ width="24"
+ height="24"
+ class="mr-1"
+ viewBox="0 0 24 24"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M4.31802 6.31802C2.56066 8.07538 2.56066 10.9246 4.31802 12.682L12.0001 20.364L19.682 12.682C21.4393 10.9246 21.4393 8.07538 19.682 6.31802C17.9246 4.56066 15.0754 4.56066 13.318 6.31802L12.0001 7.63609L10.682 6.31802C8.92462 4.56066 6.07538 4.56066 4.31802 6.31802Z"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg> Add a tip
+ </button>
+
+ <span class="text-secondary block"
+ >Scan to pay <span class="text-black font-semibold">{username}</span></span
+ >
+
+ <div
+ bind:this={qr}
+ on:click={handleCopy}
+ class="border {showMobileTip
+ ? 'border-gray-400'
+ : 'border-lightgrey'} rounded-3xl block md:flex justify-center items-center cursor-pointer"
+ />
+
+ <div class="px-5 space-y-3">
+ <div class="flex justify-between">
+ <span class="font-semibold text-sm">Invoice</span>
+ <span class="font-semibold text-sm"
+ >{invoiceAmountFiatFormatted}
+ <span class="text-secondary font-normal">{`(${amountFormatted} SATS)`}</span></span
+ >
+ </div>
+
+ <div class="flex justify-between">
+ <span class="font-semibold text-sm">Tip</span>
+ <span class="font-semibold text-sm"
+ >{tipAmountFormatted}
+ <span class="text-secondary font-normal">{`(${tipAmountSats} SATS)`}</span></span
+ >
+ </div>
+
+ <div class="flex justify-between">
+ <span class="font-bold">Total</span>
+ <span class="font-bold"
+ >{totalAmountFormatted}
+ <span class="text-secondary font-normal">{`(${totalAmountSats} SATS)`}</span></span
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+{:else}
+ <div class="text-center mt-20 md:mt-0">
+ <Image image="success" style="w-full md:w-3/4 xl:w-1/2 mx-auto max-w-3xl" />
+ <h1 class="text-3xl md:text-4xl font-bold mb-6">Payment Successful!</h1>
+ <h2 class="text-2xl md:text-3xl font-semibold">{totalAmountFormatted} {$preferredCurrency}</h2>
+ <h3 class="text-secondary md:text-lg mb-6 mt-1">({totalAmountSats} SATS)</h3>
+ <button class="bg-black text-white rounded-2xl w-20 py-3 font-bold" on:click={handleDoneClick}>
+ Done
+ </button>
+ </div>
+{/if}
+
+<style>
+ #slider {
+ -webkit-appearance: none;
+ width: 100%;
+ height: 6px;
+ border-radius: 5px;
+ background: #eaecef;
+ outline: none;
+ }
+
+ #slider:active {
+ background-color: black;
+ }
+
+ #slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 25px;
+ height: 25px;
+ border-radius: 50%;
+ background: black;
+ cursor: pointer;
+ }
+
+ #slider::-moz-range-thumb {
+ width: 25px;
+ height: 25px;
+ border-radius: 50%;
+ background: black;
+ cursor: pointer;
+ }
+
+ input[type='number']::-webkit-inner-spin-button,
+ input[type='number']::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ }
+
+ input[type='number'] {
+ -moz-appearance: textfield;
+ }
+
+ .full-shadow {
+ box-shadow: inset 0 0 0 1000px rgba(0, 0, 0, 0.25);
+ }
+</style>
diff --git a/src/routes/invoice/index.js b/src/routes/invoice/index.js
new file mode 100644
index 00000000..cf26b3c6
--- /dev/null
+++ b/src/routes/invoice/index.js
@@ -0,0 +1,14 @@
+import { post } from '$lib/utils';
+
+let network = 'lightning';
+
+export async function POST({ request }) {
+ let { amount, rate, username, text } = await request.json();
+ if (!text) ({ text } = await post('/lightning/invoice', { amount }));
+ let { uuid: id } = await post('/invoice', {
+ invoice: { amount, currency: 'USD', network, rate, text },
+ user: { username }
+ });
+
+ return { body: { id } };
+}
diff --git a/src/routes/jobs.svelte b/src/routes/jobs.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/jobs.svelte
diff --git a/src/routes/login.js b/src/routes/login.js
index e8c74365..db1fbb6c 100644
--- a/src/routes/login.js
+++ b/src/routes/login.js
@@ -1,13 +1,24 @@
-import got from 'got';
+import { post } from '$lib/utils';
+import cookie from 'cookie';
+
+let maxAge = 30 * 24 * 60 * 60;
export async function POST({ request }) {
- let json = await request.json();
+ let body = await post('/login', await request.json());
- let body = await got
- .post('http://localhost:3119/login', {
- json
- })
- .json();
+ let expires = new Date();
+ expires.setSeconds(expires.getSeconds() + maxAge);
- return { body };
+ return {
+ body,
+ headers: {
+ 'set-cookie': cookie.serialize('token', body.token, {
+ httpOnly: true,
+ maxAge,
+ sameSite: 'lax',
+ path: '/',
+ expires
+ })
+ }
+ };
}
diff --git a/src/routes/logout/done.svelte b/src/routes/logout/done.svelte
new file mode 100644
index 00000000..fe526357
--- /dev/null
+++ b/src/routes/logout/done.svelte
@@ -0,0 +1,13 @@
+<script>
+ import { onMount } from 'svelte';
+ import { user, token } from '$lib/store';
+ import { goto } from '$app/navigation';
+ import { close } from '$lib/socket';
+
+ onMount(() => {
+ $token = null;
+ $user = null;
+ close();
+ });
+ goto('/');
+</script>
diff --git a/src/routes/logout/index.js b/src/routes/logout/index.js
new file mode 100644
index 00000000..6cbae8f2
--- /dev/null
+++ b/src/routes/logout/index.js
@@ -0,0 +1,26 @@
+import cookie from 'cookie';
+
+const opts = {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/'
+};
+
+export async function GET({ request: { headers } }) {
+ return {
+ status: 302,
+ headers: {
+ location: '/logout/done',
+ 'set-cookie': [
+ cookie.serialize('token', '', {
+ ...opts,
+ expires: new Date(0)
+ }),
+ cookie.serialize('refresh_token', '', {
+ ...opts,
+ expires: new Date(0)
+ })
+ ]
+ }
+ };
+}
diff --git a/src/routes/logout/index.svelte b/src/routes/logout/index.svelte
new file mode 100644
index 00000000..2c6a7085
--- /dev/null
+++ b/src/routes/logout/index.svelte
@@ -0,0 +1,10 @@
+<script>
+ import { onMount } from 'svelte';
+ import { browser } from '$app/env';
+ import { user, token } from '$lib/store';
+
+ onMount(() => {
+ $user = null;
+ $token = null;
+ });
+</script>
diff --git a/src/routes/receive.svelte b/src/routes/receive.svelte
deleted file mode 100644
index 73d3cc06..00000000
--- a/src/routes/receive.svelte
+++ /dev/null
@@ -1,75 +0,0 @@
-<script>
- // in the future we will want to allow users to set their fiat currency in the settings
- import { AppHeader, Icon } from '$comp';
- import { goto } from '$app/navigation';
-
- let useFiat = true;
- let amountFiat = 0;
- let amountSats = 0;
-
- const numPad = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '<'];
-</script>
-
-<AppHeader />
-
-<!-- step 1 input amount and generate QR code -->
-<div class="flex justify-center items-center mt-20 mb-3 px-3">
- <div class="space-y-5">
- <!-- amounts -->
- <div class="text-center">
- <div class="text-6xl font-semibold tracking-widest mb-1">
- {useFiat ? `$${amountFiat}` : amountSats}<span class="tracking-normal text-base font-normal"
- >{useFiat ? 'CAD' : 'SATS'}</span
- >
- </div>
- <span class="text-secondary mr-1"
- >{useFiat ? `${amountSats} SATS` : `$${amountFiat} CAD`}</span
- > <button on:click={() => (useFiat = !useFiat)}><Icon icon="swap" style="inline" /></button>
- </div>
-
- <!-- numpad -->
- <div class="grid grid-cols-3 gap-2">
- {#each numPad as value}
- {#if value === '<'}
- <button
- class="bg-primary rounded-xl py-4 px-8 font-semibold active:bg-black active:text-white"
- >
- <svg
- class="w-5"
- width="24"
- height="24"
- viewBox="0 0 24 24"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path
- d="M15 19L8 12L15 5"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
- </svg></button
- >
- {:else}
- <button
- class="bg-primary rounded-xl py-4 px-8 font-semibold active:bg-black active:text-white"
- >{value}</button
- >
- {/if}
- {/each}
- </div>
-
- <!-- buttons -->
- <div class="space-y-3">
- <button
- class="bg-black text-white rounded-xl w-full h-[48px] flex justify-center items-center font-semibold text-sm"
- on:click={() => goto('/invoice')}><Icon icon="qr" style="mr-2" /> Show QR</button
- >
- <button
- class="bg-primary rounded-xl w-full h-[48px] flex justify-center items-center font-semibold text-sm"
- ><Icon icon="send" style="mr-2" /> Send Invoice</button
- >
- </div>
- </div>
-</div>
diff --git a/src/routes/register.js b/src/routes/register.js
new file mode 100644
index 00000000..1bb03ba6
--- /dev/null
+++ b/src/routes/register.js
@@ -0,0 +1,14 @@
+import { post } from '$lib/utils';
+
+export async function POST({ request }) {
+ try {
+ let user = await request.json();
+ let body = await post('/register', { user });
+ return { body };
+ } catch (e) {
+ return {
+ status: 500,
+ body: e
+ };
+ }
+}
diff --git a/src/routes/register.svelte b/src/routes/register.svelte
index 60a81ced..69ec5fd2 100644
--- a/src/routes/register.svelte
+++ b/src/routes/register.svelte
@@ -1,6 +1,5 @@
<script>
import { RegisterOrLogin } from '$comp';
- import { post } from "$lib/utils";
</script>
<RegisterOrLogin page="Register" />
diff --git a/src/routes/support.svelte b/src/routes/support.svelte
new file mode 100644
index 00000000..1935b156
--- /dev/null
+++ b/src/routes/support.svelte
@@ -0,0 +1,5 @@
+<script>
+ import { AppHeader } from '$comp';
+</script>
+
+<AppHeader />