aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)/settings
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2024-09-10 20:39:47 -0700
committerAdam Soltys <[email protected]>2024-09-17 09:18:36 -0700
commit1f63848ca9fb01336f04b41434de1cc06a5b3d93 (patch)
tree2affa3041863be365f33d99e1f32bda60cf93a32 /src/routes/(app)/settings
parent6e48c584605bec5623c12c470d04d07303983801 (diff)
downloadcoinos-ui-1f63848ca9fb01336f04b41434de1cc06a5b3d93.tar.gz
coinos-ui-1f63848ca9fb01336f04b41434de1cc06a5b3d93.zip
move routes
Diffstat (limited to 'src/routes/(app)/settings')
-rw-r--r--src/routes/(app)/settings/[...tab]/+page.server.ts34
-rw-r--r--src/routes/(app)/settings/[...tab]/+page.svelte257
-rw-r--r--src/routes/(app)/settings/[...tab]/_account.svelte190
-rw-r--r--src/routes/(app)/settings/[...tab]/_pos.svelte238
-rw-r--r--src/routes/(app)/settings/[...tab]/_security.svelte321
-rw-r--r--src/routes/(app)/settings/[...tab]/_shopify.svelte92
6 files changed, 1132 insertions, 0 deletions
diff --git a/src/routes/(app)/settings/[...tab]/+page.server.ts b/src/routes/(app)/settings/[...tab]/+page.server.ts
new file mode 100644
index 00000000..42639b38
--- /dev/null
+++ b/src/routes/(app)/settings/[...tab]/+page.server.ts
@@ -0,0 +1,34 @@
+import { fail, redirect } from "@sveltejs/kit";
+import { fd, auth, get, post } from "$lib/utils";
+
+export function load({ cookies, params, url }) {
+ if (url.pathname.endsWith("settings"))
+ redirect(307, url.pathname + "/account");
+ params.cookies = cookies.getAll();
+ return { cookies: cookies.getAll(), tab: params.tab };
+}
+
+export const actions = {
+ default: async ({ cookies, request }) => {
+ let form = await fd(request);
+
+ if (form.tab === "pos") {
+ form.notify = form.notify === "on";
+ form.nip5 = form.nip5 === "on";
+ form.prompt = form.prompt === "on";
+ form.autowithdraw = form.autowithdraw === "on";
+ }
+
+ let user = { ...(await get("/me", auth(cookies))), ...form };
+
+ try {
+ ({ user } = await post(`/user`, user, auth(cookies)));
+ } catch (e: any) {
+ return fail(400, { message: e.message });
+ }
+
+ if (user.language) cookies.set("lang", user.language, { path: "/" });
+
+ return { user, success: true };
+ },
+};
diff --git a/src/routes/(app)/settings/[...tab]/+page.svelte b/src/routes/(app)/settings/[...tab]/+page.svelte
new file mode 100644
index 00000000..c495a5cc
--- /dev/null
+++ b/src/routes/(app)/settings/[...tab]/+page.svelte
@@ -0,0 +1,257 @@
+<script>
+ import { browser } from "$app/environment";
+ import { onMount, tick } from "svelte";
+ import { fly } from "svelte/transition";
+ import { applyAction, deserialize } from "$app/forms";
+
+ import Icon from "$comp/Icon.svelte";
+ import Spinner from "$comp/Spinner.svelte";
+ import Pin from "$comp/Pin.svelte";
+ import { loading, t } from "$lib/translations";
+ import { fail, auth, post, sleep, warning, success } from "$lib/utils";
+ import { avatar, banner, password, pin } from "$lib/store";
+ import { upload } from "$lib/upload";
+ import { page } from "$app/stores";
+ import { sign, send, reEncryptEntropy, setNsec } from "$lib/nostr";
+ import { invalidateAll } from "$app/navigation";
+
+ import Account from "./_account.svelte";
+ import Pos from "./_pos.svelte";
+ import Security from "./_security.svelte";
+ import Shopify from "./_shopify.svelte";
+
+ import { PUBLIC_COINOS_URL } from "$env/static/public";
+
+ export let data;
+ export let form;
+
+ let submit;
+ let formElement;
+
+ let { user, token, rates, tab, cookies } = data;
+ let prev;
+
+ $: update(data);
+ let update = () => {
+ ({ user, token, rates, tab } = data);
+ prev = { ...user };
+ };
+
+ $: form?.user && ({ user } = form);
+
+ $: form?.success && throttledSuccess();
+
+ let justUpdated;
+ let throttledSuccess = () => {
+ if (!justUpdated) {
+ justUpdated = true;
+ success($t("user.settings.saved"), false);
+ setTimeout(() => (justUpdated = false), 5000);
+ }
+ };
+
+ $: form?.message && fail(form.message);
+
+ $: if (form?.message?.startsWith("Pin")) {
+ fail("Wrong pin, try again");
+ $pin = "";
+ }
+
+ let tabs = [
+ { name: "account", key: "ACCOUNT", comp: Account },
+ { name: "pos", key: "POINT_OF_SALE", comp: Pos },
+ { name: "security", key: "SECURITY", comp: Security },
+ { name: "shopify", key: "SHOPIFY", comp: Shopify },
+ ];
+
+ $: ({ comp } = tabs.find((t) => t.name === tab));
+
+ let { address, id, username } = user;
+ let submitting;
+
+ $: if (!$loading && $page.url.searchParams.get("verified"))
+ success($t("user.settings.verified"));
+
+ async function handleSubmit() {
+ try {
+ submitting = true;
+ let data = new FormData(formElement);
+
+ if (data.get("newNsec")) {
+ await setNsec(user, data.get("newNsec"));
+ data.set("nsec", user.nsec);
+ data.set("pubkey", user.pubkey);
+ }
+
+ if (data.get("password")) {
+ try {
+ data.set(
+ "cipher",
+ await reEncryptEntropy(user, data.get("password"))
+ );
+ } catch (e) {
+ console.log("Failed to encrypt keys with new password");
+ throw e;
+ }
+ }
+
+ if ($avatar) {
+ try {
+ let { hash } = JSON.parse(
+ await upload($avatar.file, $avatar.type, $avatar.progress, token)
+ );
+
+ data.set("profile", hash);
+
+ await fetch(`/api/public/${hash}.webp`, {
+ cache: "reload",
+ mode: "no-cors",
+ });
+ } catch (e) {
+ console.log("problem upsubmitting avatar", e);
+ }
+ }
+
+ if ($banner) {
+ try {
+ let { hash } = JSON.parse(
+ await upload($banner.file, $banner.type, $banner.progress, token)
+ );
+
+ data.set("banner", hash);
+
+ await fetch(`/api/public/${hash}.webp`, {
+ cache: "reload",
+ mode: "no-cors",
+ });
+ } catch (e) {
+ console.log("problem upsubmitting banner", e);
+ }
+ }
+
+ if (["username", "address", "profile"].some((a) => user[a] !== prev[a])) {
+ let event = {
+ pubkey: user.pubkey,
+ created_at: Math.floor(Date.now() / 1000),
+ kind: 0,
+ content: JSON.stringify({
+ name: user.username,
+ about: user.address,
+ picture: `${PUBLIC_COINOS_URL}/public/${user.profile}.webp`,
+ }),
+ tags: [],
+ };
+
+ try {
+ await sign({ event, user });
+ await send(event);
+ } catch (e) {
+ warning("Nostr profile could not be updated");
+ }
+ }
+
+ if (user.email !== prev.email) {
+ try {
+ cookies.get = function (n) {
+ return this.find((c) => c.name === n).value;
+ };
+
+ user.verified = false;
+
+ await post(
+ "/api/request",
+ { id: user.id, email: user.email },
+ auth(cookies)
+ );
+
+ warning($t("user.settings.verifying"), false);
+ } catch (e) {
+ fail(e.message);
+ console.log(e);
+ }
+ }
+
+ const response = await fetch(formElement.action, {
+ method: "POST",
+ body: data,
+ });
+
+ const result = deserialize(await response.text());
+
+ if (result.type === "success") {
+ await invalidateAll();
+ if (data.get("password")) $password = data.get("password");
+ }
+
+ applyAction(result);
+ } catch (e) {
+ console.log(e);
+ fail("Something went wrong");
+ }
+
+ submitting = false;
+ }
+</script>
+
+{#if user.haspin && $pin?.length !== 6}
+ <Pin />
+{/if}
+
+<form
+ method="POST"
+ class="mb-[154px] settings"
+ on:submit|preventDefault={handleSubmit}
+ bind:this={formElement}
+>
+ <input type="hidden" name="pin" value={$pin} />
+ <input type="hidden" name="tab" value={tab} />
+
+ <div class="mt-24 mb-20 px-3 md:px-0 w-full md:w-[400px] mx-auto space-y-8">
+ <div class="header">
+ <h1 class="text-center text-3xl md:text-4xl font-semibold mb-10">
+ {$t("user.settings.header")}
+ </h1>
+
+ <div
+ class="font-bold flex justify-around items-center border-b pb-3 text-secondary"
+ >
+ {#each tabs.filter((t) => t.name !== "shopify") as { name, key }}
+ <a href={`/settings/${name}`}>
+ <button
+ type="button"
+ class="hover:opacity-80"
+ class:text-black={tab === name ||
+ (tab === "shopify" && name === "pos")}
+ >{$t(`user.settings.${key}`)}</button
+ >
+ </a>
+ {/each}
+ </div>
+ </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={submitting}
+ >
+ {#if submitting}
+ <Spinner />
+ {:else}
+ <div class="my-auto">{$t("user.settings.saveSettings")}</div>
+ {/if}
+ </button>
+ </div>
+</form>
+
+<style>
+ .settings {
+ view-transition-name: settings;
+ }
+</style>
diff --git a/src/routes/(app)/settings/[...tab]/_account.svelte b/src/routes/(app)/settings/[...tab]/_account.svelte
new file mode 100644
index 00000000..8e3323c7
--- /dev/null
+++ b/src/routes/(app)/settings/[...tab]/_account.svelte
@@ -0,0 +1,190 @@
+<script>
+ import { tick } from "svelte";
+ import { avatar, banner } from "$lib/store";
+ import Icon from "$comp/Icon.svelte";
+ import { t } from "$lib/translations";
+ import { page } from "$app/stores";
+
+ export let user, rates, submit;
+
+ let { id } = user;
+
+ let avatarFile, avatarInput, bannerFile, bannerInput;
+
+ let selectAvatar = () => avatarInput.click();
+ let selectBanner = () => bannerInput.click();
+
+ let percent;
+ let progress = async (event) => {
+ percent = Math.round((event.loaded / event.total) * 100);
+ };
+
+ let tooLarge = {};
+
+ let handleFile = async ({ target }, type) => {
+ tooLarge[type] = false;
+ let file = target.files[0];
+ if (!file) return;
+
+ if (file.size > 10000000) return (tooLarge[type] = true);
+
+ if (type === "profile") {
+ $avatar = { id, file, type, progress };
+ } else if (type === "banner") {
+ $banner = { id, file, type, progress };
+ }
+
+ var reader = new FileReader();
+ reader.onload = async (e) => {
+ if (type === "profile") {
+ $avatar.src = e.target.result;
+ } else if (type === "banner") {
+ $banner.src = e.target.result;
+ }
+ };
+
+ reader.readAsDataURL(file);
+ };
+
+ if (!user.display) user.display = user.username;
+
+ $: url = `${$page.url.host}/${user.username}`;
+ $: full = `${$page.url.protocol}//${url}`;
+ $: addr = `${user.username}@${$page.url.host}`;
+</script>
+
+<div>
+ <label for="username" class="font-bold mb-1 block"
+ >{$t("user.settings.username")}</label
+ >
+ <div class="flex mb-2">
+ <input
+ type="text"
+ name="username"
+ bind:value={user.username}
+ class="w-auto min-w-0 pr-1 grow"
+ />
+ </div>
+</div>
+
+<div>
+ <label for="display" class="font-bold mb-1 block"
+ >{$t("user.settings.displayName")}</label
+ >
+ <input type="text" name="display" bind:value={user.display} />
+</div>
+
+<div class="space-y-1 relative">
+ <label for="email" class="font-bold block">{$t("user.settings.email")}</label>
+ <p class="text-secondary">
+ {$t("user.settings.emailDescription")}
+ </p>
+
+ <div class="relative">
+ <input type="text" name="email" bind:value={user.email} />
+ {#if user.verified}
+ <Icon icon="check" style="absolute right-5 top-3 w-8" />
+ {:else if user.email}
+ <Icon icon="orangeclock" style="absolute right-5 top-3 w-8" />
+ {/if}
+ </div>
+</div>
+
+<div>
+ <span class="font-bold">{$t("user.settings.profileImage")}</span>
+
+ <div class="flex">
+ {#if $avatar || user.profile}
+ <div
+ class="relative rounded-full overflow-hidden text-center w-20 h-20 my-auto hover:opacity-80 cursor-pointer"
+ on:click={selectAvatar}
+ on:keydown={selectAvatar}
+ >
+ <img
+ src={$avatar?.src || `/api/public/${user.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 from-primary to-gradient w-24 my-auto hover:opacity-80 cursor-pointer"
+ on:click={selectAvatar}
+ on:keydown={selectAvatar}
+ >
+ <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={selectAvatar}
+ on:keydown={selectAvatar}>{$t("user.settings.select")}</button
+ >
+ <input
+ type="file"
+ class="hidden"
+ bind:this={avatarInput}
+ on:change={(e) => handleFile(e, "profile")}
+ />
+ </div>
+ </div>
+
+ {#if tooLarge["avatar"]}
+ <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>
+
+ {#if $banner || user.banner}
+ <img
+ src={$banner ? $banner.src : `/api/public/${user.banner}.webp`}
+ class="w-full object-cover object-center visible overflow-hidden h-48 mb-4 hover:opacity-80"
+ on:click={selectBanner}
+ on:keydown={selectBanner}
+ alt="Banner"
+ />
+ {:else}
+ <div
+ class="bg-gradient-to-r from-primary to-gradient w-full h-48 mb-4 cursor-pointer hover:opacity-80"
+ on:click={selectBanner}
+ on:keydown={selectBanner}
+ alt="Banner"
+ />
+ {/if}
+
+ <button
+ type="button"
+ class="border rounded-2xl font-bold w-24 text-center px-0 py-2 hover:opacity-80"
+ on:click={selectBanner}
+ on:keydown={selectBanner}>{$t("user.settings.select")}</button
+ >
+ <input
+ type="file"
+ class="hidden"
+ bind:this={bannerInput}
+ on:change={(e) => handleFile(e, "banner")}
+ />
+
+ {#if tooLarge["banner"]}
+ <div class="text-red-600">Max file size 10MB</div>
+ {/if}
+</div>
+
+<div>
+ <label for="address" class="font-bold mb-1 block"
+ >{$t("user.settings.about")}</label
+ >
+ <textarea
+ type="text"
+ name="address"
+ bind:value={user.address}
+ placeholder={$t("user.settings.aboutPlaceholder")}
+ />
+</div>
diff --git a/src/routes/(app)/settings/[...tab]/_pos.svelte b/src/routes/(app)/settings/[...tab]/_pos.svelte
new file mode 100644
index 00000000..6a1c92a1
--- /dev/null
+++ b/src/routes/(app)/settings/[...tab]/_pos.svelte
@@ -0,0 +1,238 @@
+<script>
+ import { tick } from "svelte";
+ import Icon from "$comp/Icon.svelte";
+ import Numpad from "$comp/Numpad.svelte";
+ import LocaleSelector from "$comp/LocaleSelector.svelte";
+ import Toggle from "$comp/Toggle.svelte";
+ import { locale, t } from "$lib/translations";
+ import { success, fail } from "$lib/utils";
+ import { page } from "$app/stores";
+
+ export let user, rates, submit;
+
+ let { currency } = user;
+ let rate = rates[currency];
+
+ let fiats = Object.keys(rates).sort((a,b) => a.localeCompare(b));
+ $: user.language = $locale;
+ let keypress = (e) => e.key === "Enter" && (e.preventDefault() || el.click());
+
+ let editingReserve, editingThreshold, doneReserve, doneThreshold;
+ let doneEditing = () => {
+ editingReserve = false;
+ editingThreshold = false;
+ };
+
+ let editReserve = async () => {
+ editingReserve = true;
+ await tick();
+ reserveEl.focus();
+ };
+
+ let editThreshold = async () => {
+ editingThreshold = true;
+ await tick();
+ thresholdEl.focus();
+ };
+
+ if (!user.threshold) user.threshold = 1000000;
+ if (!user.reserve) user.reserve = 100000;
+ let reserveEl, thresholdEl;
+</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>
+
+<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"
+ value={user.currency}
+ >
+ {#each fiats as fiat}
+ <option value={fiat}>{fiat}</option>
+ {/each}
+ </select>
+</div>
+
+<div>
+ <label for="memoPrompt" class="font-bold mb-1 block"
+ >{$t("user.settings.memoPrompt")}</label
+ >
+ <textarea
+ name="memoPrompt"
+ placeholder={$t("user.settings.memoPromptPlaceholder")}
+ bind:value={user.memoPrompt}
+ />
+</div>
+
+<div>
+ <div class="flex justify-between items-center">
+ <span class="font-bold">{$t("user.settings.notifications")}</span>
+ <Toggle id="notify" bind:value={user.notify} />
+ </div>
+ <p class="text-secondary mt-1 w-9/12">
+ {$t("user.settings.notificationsDesc")}
+ </p>
+</div>
+
+<div>
+ <div class="flex justify-between items-center">
+ <span class="font-bold">{$t("user.settings.tipPrompt")}</span>
+ <Toggle id="prompt" bind:value={user.prompt} />
+ </div>
+ <p class="text-secondary mt-1 w-9/12">
+ {$t("user.settings.tipPromptDescription")}
+ </p>
+</div>
+
+<div>
+ <div class="flex justify-between items-center">
+ <span class="font-bold">{$t("user.settings.publishNip5")}</span>
+ <Toggle id="nip5" bind:value={user.nip5} />
+ </div>
+ <p class="text-secondary mt-1 w-9/12">
+ {$t("user.settings.nip5a")}
+ <span class="font-bold">{user.username}@{$page.url.host}</span>
+ {$t("user.settings.nip5b")}
+ </p>
+</div>
+
+<div>
+ <div class="flex justify-between items-center">
+ <span class="font-bold">{$t("user.settings.autoWithdraw")}</span>
+ <Toggle id="autowithdraw" bind:value={user.autowithdraw} />
+ </div>
+ <p class="text-secondary mt-1 w-9/12">
+ {$t("user.settings.autoWithdrawDescription")}
+ </p>
+</div>
+
+{#if user.autowithdraw}
+ <div class="mb-2">
+ <label for="display" class="font-bold mb-1 block"
+ >{$t("user.settings.destination")}</label
+ >
+ <textarea
+ name="destination"
+ placeholder={$t("user.settings.destinationPlaceholder")}
+ on:keypress={keypress}
+ class="w-full p-4 border rounded-xl h-48"
+ bind:value={user.destination}
+ />
+ </div>
+
+ <div>
+ <label for="display" class="font-bold mb-1 block"
+ >{$t("user.settings.threshold")}</label
+ >
+ <button type="button" class="flex w-full" on:click={editThreshold}>
+ <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-primary">
+ ⚡️
+ </div>
+ <div
+ class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left"
+ >
+ {user.threshold}
+ </div>
+ <input type="hidden" name="threshold" bind:value={user.threshold} />
+ </button>
+ <p class="text-secondary mt-1">
+ {$t("user.settings.thresholdDesc")}
+ </p>
+ </div>
+
+ <div>
+ <label for="display" class="font-bold mb-1 block"
+ >{$t("user.settings.reserve")}</label
+ >
+ <button type="button" class="flex w-full" on:click={editReserve}>
+ <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-primary">
+ ⚡️
+ </div>
+ <div
+ class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left"
+ >
+ {user.reserve}
+ </div>
+ <input type="hidden" name="reserve" bind:value={user.reserve} />
+ </button>
+ <p class="text-secondary mt-1">
+ {$t("user.settings.reserveDesc")}
+ </p>
+ </div>
+{/if}
+
+{#if editingThreshold}
+ <div
+ class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
+ >
+ <div
+ class="relative top-40 mx-auto p-5 border shadow-lg rounded-md bg-white space-y-5 w-lg max-w-lg"
+ >
+ <h1 class="text-center text-2xl font-semibold">
+ {$t("user.settings.threshold")}
+ </h1>
+ <Numpad
+ bind:amount={user.threshold}
+ bind:currency
+ bind:rate
+ bind:submit={doneReserve}
+ bind:element={thresholdEl}
+ />
+
+ <button
+ bind:this={doneReserve}
+ type="button"
+ on:click={doneEditing}
+ class="p-4 bg-black text-white rounded-2xl text-lg font-semibold w-full"
+ >Ok</button
+ >
+ </div>
+ </div>
+{/if}
+
+{#if editingReserve}
+ <div
+ class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
+ >
+ <div
+ class="relative top-40 mx-auto p-5 border shadow-lg rounded-md bg-white space-y-5 w-lg max-w-lg text-center"
+ >
+ <h1 class="text-2xl font-semibold">
+ {$t("user.settings.reserve")}
+ </h1>
+ <Numpad
+ bind:amount={user.reserve}
+ bind:currency
+ bind:rate
+ bind:submit={doneReserve}
+ bind:element={reserveEl}
+ />
+ <button
+ bind:this={doneReserve}
+ type="button"
+ on:click={doneEditing}
+ class="p-4 bg-black text-white rounded-2xl text-lg font-semibold w-full"
+ >Ok</button
+ >
+ </div>
+ </div>
+{/if}
+
+<a href="./shopify">
+ <button
+ type="button"
+ class="flex rounded-full border py-2 px-4 font-bold hover:opacity-80 w-full gap-2 my-5 justify-center"
+ >
+ <img src="/icons/shopify.svg" class="w-8" />
+ <div class="my-auto">{$t("user.settings.shopify")}</div>
+ </button>
+</a>
diff --git a/src/routes/(app)/settings/[...tab]/_security.svelte b/src/routes/(app)/settings/[...tab]/_security.svelte
new file mode 100644
index 00000000..6b44f830
--- /dev/null
+++ b/src/routes/(app)/settings/[...tab]/_security.svelte
@@ -0,0 +1,321 @@
+<script>
+ import { getMnemonic, getNsec } from "$lib/nostr";
+ import { tick } from "svelte";
+ import { t } from "$lib/translations";
+ import Pin from "$comp/Pin.svelte";
+ import Icon from "$comp/Icon.svelte";
+ import Qr from "$comp/Qr.svelte";
+ import { copy, post, success, fail } from "$lib/utils";
+ import { pin as current } from "$lib/store";
+ import { invalidate } from "$app/navigation";
+ import { page } from "$app/stores";
+
+ export let user, rates, submit;
+
+ let confirming2fa,
+ disabling2fa,
+ importing,
+ locked,
+ mnemonic,
+ newNsec,
+ nsec,
+ old,
+ otp,
+ password,
+ pin,
+ revealPassword,
+ revealSeed,
+ revealNsec,
+ token = "",
+ setting2fa,
+ settingPin,
+ verify;
+
+ let toggleImporting = () => {
+ revealNsec = false;
+ importing = !importing;
+ };
+
+ let toggleNsec = async () => {
+ try {
+ nsec = await getNsec(user);
+ revealNsec = !revealNsec;
+ importing = false;
+ } catch (e) {
+ console.log(e);
+ }
+ };
+
+ let togglePassword = () => (revealPassword = !revealPassword);
+ let toggleSeed = async () => {
+ try {
+ mnemonic = await getMnemonic(user);
+ revealSeed = !revealSeed;
+ } catch (e) {
+ console.log(e);
+ }
+ };
+
+ $: verifying = pin?.length > 5;
+ $: verify && checkPin(pin);
+ let checkPin = async () => {
+ old = $current;
+
+ try {
+ if (pin.length > 5 && pin === verify) {
+ user.haspin = true;
+ $current = pin;
+ pin = "";
+ verify = "";
+ submit.click();
+ settingPin = false;
+ verifying = false;
+ } else {
+ fail("Pin mismatch, try again");
+ pin = "";
+ verify = "";
+ verifying = false;
+ settingPin = true;
+ }
+ } catch (e) {
+ console.log(e);
+ fail("Problem setting PIN");
+ }
+ };
+
+ let reset = () => {
+ token = "";
+ return true;
+ };
+
+ let disablingPin = false;
+
+ let togglePin = async () => {
+ if (user.haspin) {
+ try {
+ disablingPin = true;
+ await tick();
+ submit.click();
+ } catch (e) {
+ console.log(e);
+ fail("Failed to disable pin");
+ }
+ } else {
+ settingPin = true;
+ disablingPin = false;
+ }
+ };
+
+ let startEnabling2fa = async () => {
+ reset();
+ try {
+ if (!otp) otp = await post("/otpsecret", { pin: $current });
+ } catch (e) {
+ console.log(e);
+ }
+
+ setting2fa = true;
+ };
+
+ let startDisabling2fa = () => (disabling2fa = true);
+ let startConfirming2fa = () => (confirming2fa = true);
+ let cancel = () => {
+ pin = null;
+ token = null;
+ verifying = false;
+ settingPin = false;
+ setting2fa = false;
+ confirming2fa = false;
+ disabling2fa = false;
+ };
+
+ $: enable2fa(token);
+ let enable2fa = async (twoFa) => {
+ try {
+ if (setting2fa && token.length === 6) {
+ await post("/enable2fa", { token });
+ success("2FA enabled");
+ user.twofa = 1;
+ cancel();
+ }
+ } catch (e) {
+ fail("Failed to enable 2FA, try again");
+ }
+ };
+
+ $: disable2fa(token);
+ let disable2fa = async () => {
+ try {
+ if (disabling2fa && token.length === 6) {
+ await post("/disable2fa", { token });
+ success("2FA disabled");
+ delete user.twofa;
+ disabling2fa = false;
+ }
+ } catch (e) {
+ console.log(e);
+ fail("Failed to disable 2FA, try again");
+ }
+ };
+</script>
+
+<input type="hidden" name="newpin" value={disablingPin ? "delete" : pin} />
+<input type="hidden" name="confirm" bind:value={password} />
+
+<div class="relative mb-5">
+ <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>
+ <span class="font-bold mb-1"
+ >{verifying
+ ? $t("user.settings.verifyPIN")
+ : $t("user.settings.securityPIN")}</span
+ >
+ <p class="text-secondary mb-1">
+ {$t("user.settings.securityPINDescription")}
+ </p>
+ {#if verifying}
+ <Pin bind:value={verify} {cancel} notify={false} />
+ {:else if settingPin}
+ <Pin
+ bind:value={pin}
+ title={$t("user.settings.setPIN")}
+ {cancel}
+ notify={false}
+ />
+ {:else}
+ <button type="button" class="primary" on:click={togglePin}
+ ><Icon icon="lock" style="mr-1" />
+ {user.haspin
+ ? $t("user.settings.disablePIN")
+ : $t("user.settings.enablePIN")}</button
+ >
+ {/if}
+</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={otp.uri}>
+ <Qr src={otp.qr} />
+ </a>
+
+ <div class="text-center my-4">
+ {$t("user.settings.accountId")}<br />
+ <b>{otp.secret}</b>
+ </div>
+
+ <button type="button" class="primary" on:click={startConfirming2fa}>
+ <Icon icon="numpad" style="w-8 mr-1" />
+ <div class="my-auto">Confirm</div>
+ </button>
+ {:else if user.twofa}
+ <button type="button" class="primary" on:click={startDisabling2fa}>
+ <Icon icon="mobile" style="w-8 mr-1" />
+ <div class="my-auto">{$t("user.settings.twofaDisable")}</div>
+ </button>
+ {:else}
+ <button type="button" class="primary" on:click={startEnabling2fa}>
+ <Icon icon="mobile" style="w-8 mr-1" />
+ <div class="my-auto">{$t("user.settings.twofaSetup")}</div>
+ </button>
+ {/if}
+
+ {#if confirming2fa || disabling2fa}
+ <Pin
+ bind:value={token}
+ title="Enter 2FA Code"
+ {cancel}
+ persist={false}
+ notify={false}
+ />
+ {/if}
+</div>
+
+<!-- <div> -->
+<!-- <label for="seedphrase" class="font-bold" -->
+<!-- >{$t("user.settings.seedPhrase")}</label -->
+<!-- > -->
+<!-- -->
+<!-- <p class="text-secondary mb-1"> -->
+<!-- {$t("user.settings.seedDescription")} -->
+<!-- </p> -->
+<!-- -->
+<!-- <button type="button" class="primary" on:click={toggleSeed}> -->
+<!-- <Icon icon="warning" style="mr-1 w-6 my-auto" /> -->
+<!-- {revealSeed ? $t("user.settings.hideSeed") : $t("user.settings.revealSeed")} -->
+<!-- </button> -->
+<!-- -->
+<!-- {#if revealSeed} -->
+<!-- <div class="text-lg"> -->
+<!-- {mnemonic} -->
+<!-- </div> -->
+<!-- -->
+<!-- <button type="button" class="primary" on:click={() => copy(mnemonic)}> -->
+<!-- <Icon icon="copy" style="mr-1 w-6 my-auto" /> -->
+<!-- Copy -->
+<!-- </button> -->
+<!-- {/if} -->
+<!-- </div> -->
+
+<div>
+ <label for="seedphrase" class="font-bold"
+ >{$t("user.settings.nostrKeys")}</label
+ >
+
+ <p class="text-secondary mb-1">
+ {$t("user.settings.nostrDescription")}
+ </p>
+
+ <div class="flex">
+ <button type="button" class="primary" on:click={toggleNsec}>
+ {#if revealNsec}
+ <Icon icon="eye-off" style="mr-1 w-6 my-auto" />
+ {$t("user.settings.hideNsec")}
+ {:else}
+ <Icon icon="warning" style="mr-1 w-6 my-auto" />
+ {$t("user.settings.revealNsec")}
+ {/if}
+ </button>
+
+ <button type="button" class="primary" on:click={toggleImporting}>
+ <Icon icon="save" style="mr-1 w-6 my-auto" />
+ {$t("user.settings.import")}
+ </button>
+ </div>
+
+ {#if importing}
+ <input name="newNsec" bind:value={newNsec} placeholder="nsec..." />
+ {/if}
+
+ {#if revealNsec}
+ <div class="text-lg break-all">
+ {nsec}
+ </div>
+
+ <button type="button" class="primary" on:click={() => copy(nsec)}>
+ <Icon icon="copy" style="mr-1 w-6 my-auto" />
+ Copy
+ </button>
+ {/if}
+</div>
diff --git a/src/routes/(app)/settings/[...tab]/_shopify.svelte b/src/routes/(app)/settings/[...tab]/_shopify.svelte
new file mode 100644
index 00000000..0266fa9c
--- /dev/null
+++ b/src/routes/(app)/settings/[...tab]/_shopify.svelte
@@ -0,0 +1,92 @@
+<script>
+ import { t } from "$lib/translations";
+ import Icon from "$comp/Icon.svelte";
+ import { page } from "$app/stores";
+
+ export let user;
+ $: paymentsUrl =
+ user.shopifyStore &&
+ "https://admin.shopify.com/store/" +
+ user.shopifyStore +
+ "/settings/payments";
+ $: checkoutUrl =
+ user.shopifyStore &&
+ "https://admin.shopify.com/store/" +
+ user.shopifyStore +
+ "/settings/checkout";
+ $: appsUrl =
+ user.shopifyStore &&
+ "https://admin.shopify.com/store/" +
+ user.shopifyStore +
+ "/settings/apps/development";
+
+ let script = `<script src="${$page.url.protocol}//${$page.url.host}/${user.username}/shopify.js"><\/script>`;
+</script>
+
+<div>
+ <label for="shopifyStore" class="font-bold mb-1 block">Store ID</label>
+ <div class="flex mb-2">
+ <div
+ class="text-gray-600 p-4 my-auto bg-gray-100 border rounded-l-2xl border-r-0 pr-1"
+ >
+ admin.shopify.com/store/
+ </div>
+ <input
+ type="text"
+ name="shopifyStore"
+ bind:value={user.shopifyStore}
+ class="border-l-0 rounded-l-none w-auto min-w-0 pl-1 grow"
+ />
+ </div>
+ <p class="text-secondary my-2">
+ Add Bitcoin under <a
+ class:active={!!paymentsUrl}
+ href={paymentsUrl || undefined}>Payments > Manual payment methods</a
+ > with a description like "Pay with Bitcoin".
+ </p>
+</div>
+
+<div>
+ <label for="display" class="font-bold mb-1 block">Additional scripts</label>
+ <p class="text-secondary my-2">
+ Copy this into <a class:active={!!paymentsUrl} href={checkoutUrl}
+ >Checkout -> Additional scripts</a
+ >
+ </p>
+ <textarea rows={5} type="text" name="shopifyScripts" readonly
+ >{script}</textarea
+ >
+</div>
+
+<div>
+ <label for="shopifyToken" class="font-bold mb-1 block"
+ >Admin API token <span class="font-normal">(Optional)</span></label
+ >
+ <p class="text-secondary my-2">
+ Coinos can automatically mark orders as Paid if you provide an API token.
+ <input
+ class="my-4"
+ type="text"
+ name="shopifyToken"
+ bind:value={user.shopifyToken}
+ />
+ </p>
+ <p class="text-secondary my-2">
+ Create a new app in <a class:active={!!appsUrl} href={appsUrl || undefined}
+ >Apps > App Development</a
+ >
+ </p>
+
+ <p class="text-secondary my-2">
+ Edit the Admin API scopes to enable <code class="text-black"
+ >write_orders</code
+ > permission then install the app into your store and copy the Admin API access
+ token here.
+ </p>
+</div>
+
+<style>
+ .active {
+ @apply text-blue-500;
+ }
+</style>