diff options
| author | Adam Soltys <[email protected]> | 2022-11-02 19:47:29 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2022-11-02 19:48:18 -0700 |
| commit | f0d88333750a2865d3016e92620a87269160effa (patch) | |
| tree | 1cc276efeb5287e1fc176ec60d46aed091ab08ca /src/routes/(app) | |
| parent | b66477f0912349ef91adaff8c57ac787b5a56447 (diff) | |
| download | coinos-ui-f0d88333750a2865d3016e92620a87269160effa.tar.gz coinos-ui-f0d88333750a2865d3016e92620a87269160effa.zip | |
move to app layout
Diffstat (limited to 'src/routes/(app)')
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="•••" + /> + <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' } }); +} |
