aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js173
-rw-r--r--src/features/messages.js22
-rw-r--r--src/features/names.js2
-rw-r--r--src/i18n.js27
-rw-r--r--src/style.css31
5 files changed, 253 insertions, 2 deletions
diff --git a/src/app.js b/src/app.js
index 89b0dc3..7e39e59 100644
--- a/src/app.js
+++ b/src/app.js
@@ -348,7 +348,7 @@ function render() {
? vaultScreen()
: ui.screen === 'howItWorks'
? howItWorksScreen()
- : unlockScreen());
+ : shouldOnboard() ? onboardScreen() : unlockScreen());
// Navigation animates; background repaints must not. The key is every
// ui field that decides which page is on screen.
const navKey = [ui.screen, ui.tab === 'settings', ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity,
@@ -1996,7 +1996,175 @@ function vaultScreen() {
);
}
+// ================================================================ onboarding
+// First visit on a fresh device: a full-screen wizard — welcome, nostr
+// yes/no (sign in or silently mint a wallet), username, avatar, confetti —
+// then a three-stop coach-mark tour of the home screen. One time only.
+const ONBOARDED_KEY = 'btc-wallet-onboarded';
+function shouldOnboard() {
+ try { if (localStorage.getItem(ONBOARDED_KEY)) return false; } catch {}
+ return !ui.onbSkip && accounts.length === 0 && !hasVault();
+}
+
+const PUNK_PICKS = [7, 14, 21, 3, 33, 40, 47, 55, 61, 26, 12, 50];
+const punkImg = (n) => `https://coinos.io/api/public/punks/${n}.webp`;
+
+async function onbUpload(file) {
+ const fd = new FormData();
+ fd.append('file', file);
+ const r = await fetch('https://nostr.build/api/v2/upload/files', { method: 'POST', body: fd });
+ const j = await r.json();
+ const url = j?.data?.[0]?.url;
+ if (!url) throw new Error(t('onbUploadFailed'));
+ return url;
+}
+
+function onboardScreen() {
+ ui.onb ||= { step: 'welcome' };
+ const o = ui.onb;
+ // steps that were waiting on something advance the moment it exists
+ if ((o.step === 'nostr' || o.step === 'signin') && ui.screen === 'wallet' && activeAccount()) {
+ o.step = 'username';
+ o.enterAddr = featureHook('namesAddress') || null;
+ }
+ if (o.step === 'username') {
+ const addr = featureHook('namesAddress');
+ if (o.enterAddr === undefined) o.enterAddr = addr || null;
+ // Only a real (custom) name advances by itself — the background
+ // auto-claim of the npub-shaped default just makes the Keep button
+ // appear. A restored login whose custom name recovers skips ahead too.
+ if (addr && addr !== o.enterAddr && !/^npub1/.test(addr)) o.step = 'avatar';
+ }
+ const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids);
+ const title = (txt) => h('h2', { class: 'onb-title' }, txt);
+
+ if (o.step === 'welcome') {
+ return page([
+ h('div', { class: 'onb-hero' }, brandHeader(false)),
+ title(t('onbWelcomeTitle')),
+ h('p', { class: 'muted', style: 'margin:0;font-size:16px' }, t('onbWelcomeBody')),
+ h('button', { class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { o.step = 'nostr'; render(); } }, t('onbStart')),
+ h('button', { class: 'linklike small', onClick: () => { ui.onbSkip = true; ui.onb = null; render(); } }, t('onbHaveWallet')),
+ ]);
+ }
+ if (o.step === 'nostr') {
+ return page([
+ title(t('onbNostrTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('onbNostrBody')),
+ h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => {
+ o.step = 'signin';
+ ui.nostrLoginOpen = true;
+ render();
+ } }, t('onbNostrYes')),
+ h('button', { class: 'btn-block', style: 'padding:14px', disabled: ui.onbBusy, onClick: async () => {
+ ui.onbBusy = true; render();
+ try { await enterWallet(newMnemonic(), '', { generated: true }); } catch (e) { ui.onbError = e.message; }
+ ui.onbBusy = false; render();
+ } }, ui.onbBusy ? h('span', { class: 'spinner sm' }) : t('onbNostrNo')),
+ ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
+ h('button', { class: 'linklike small', onClick: () => { o.step = 'welcome'; render(); } }, t('back')),
+ ]);
+ }
+ if (o.step === 'signin') {
+ return page([
+ title(t('onbSigninTitle')),
+ featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'),
+ h('button', { class: 'linklike small', onClick: () => { o.step = 'nostr'; render(); } }, t('back')),
+ ]);
+ }
+ if (o.step === 'username') {
+ const addr = featureHook('namesAddress');
+ return page([
+ title(t('onbNameTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('onbNameBody')),
+ featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
+ addr ? h('button', { class: 'btn-block', onClick: () => { o.step = 'avatar'; render(); } }, t('onbKeepName', { addr })) : null,
+ ]);
+ }
+ if (o.step === 'avatar') {
+ const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey());
+ const sel = o.avatar;
+ return page([
+ title(t('onbAvatarTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t('onbAvatarBody')),
+ h('div', { class: 'onb-punks' },
+ ...PUNK_PICKS.map((n) =>
+ h('img', {
+ class: 'onb-punk' + (sel === punkImg(n) ? ' sel' : ''),
+ src: punkImg(n), alt: '',
+ onClick: () => { o.avatar = punkImg(n); render(); },
+ }))),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-block grow', disabled: ui.onbBusy, onClick: () => document.getElementById('onb-file')?.click() }, t('onbUpload')),
+ h('input', { type: 'file', id: 'onb-file', accept: 'image/*', style: 'display:none', onChange: async (e) => {
+ const f = e.target.files && e.target.files[0];
+ if (!f) return;
+ ui.onbBusy = true; ui.onbError = ''; render();
+ try { o.avatar = await onbUpload(f); } catch (err) { ui.onbError = err.message; }
+ ui.onbBusy = false; render();
+ } })),
+ o.avatar ? h('img', { class: 'onb-preview', src: o.avatar, alt: '' }) : null,
+ ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
+ h('button', { class: 'btn-primary btn-block', style: 'padding:14px', disabled: ui.onbBusy, onClick: async () => {
+ ui.onbBusy = true; ui.onbError = ''; render();
+ try {
+ const addr = featureHook('namesAddress');
+ const fields = { name: addr ? addr.split('@')[0] : undefined, picture: o.avatar || undefined };
+ if (fields.name || fields.picture) await featureHook('publishProfile', fields);
+ o.step = 'success';
+ } catch (e) { ui.onbError = e.message; }
+ ui.onbBusy = false; render();
+ } }, ui.onbBusy ? h('span', { class: 'spinner sm' }) : (o.avatar ? t('onbContinue') : t('onbSkipAvatar'))),
+ ]);
+ }
+ // success
+ return page([
+ h('div', { class: 'onb-confetti' }, ...Array.from({ length: 18 }, () => h('i'))),
+ h('div', { class: 'check-badge', style: 'align-self:center' }, '✓'),
+ title(t('onbDoneTitle')),
+ h('p', { class: 'muted', style: 'margin:0;text-align:center' }, t('onbDoneBody')),
+ h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => {
+ try { localStorage.setItem(ONBOARDED_KEY, '1'); } catch {}
+ ui.onb = null;
+ ui.tour = 0;
+ ui.tab = 'receive';
+ render();
+ } }, t('onbEnter')),
+ ]);
+}
+
+// ---- the coach-mark tour: three stops, measured against the live DOM ----
+const TOUR = [
+ { sel: '.header-avatar', key: 'tourAvatar' },
+ { sel: '.balance-seg', key: 'tourBalance' },
+ { sel: '.tabs', key: 'tourTabs' },
+];
+function tourOverlay() {
+ const step = TOUR[ui.tour];
+ if (!step) return null;
+ const card = h('div', { class: 'tour-card' },
+ h('div', {}, t(step.key)),
+ h('div', { class: 'row gap6', style: 'justify-content:flex-end;margin-top:10px' },
+ h('button', { class: 'btn-sm', onClick: () => { ui.tour = null; render(); } }, t('tourSkip')),
+ h('button', { class: 'btn-sm btn-primary', onClick: () => {
+ ui.tour = ui.tour + 1 < TOUR.length ? ui.tour + 1 : null;
+ render();
+ } }, ui.tour + 1 < TOUR.length ? t('tourNext') : t('done'))));
+ const ring = h('div', { class: 'tour-ring' });
+ const wrap = h('div', { class: 'tour-overlay' }, ring, card);
+ requestAnimationFrame(() => {
+ const el = document.querySelector(step.sel);
+ if (!el || !wrap.isConnected) return;
+ const r = el.getBoundingClientRect();
+ ring.style.cssText = `top:${r.top - 6}px;left:${r.left - 6}px;width:${r.width + 12}px;height:${r.height + 12}px`;
+ const below = r.bottom + 12;
+ card.style.top = (below + 180 < innerHeight ? below : Math.max(12, r.top - 130)) + 'px';
+ });
+ return wrap;
+}
+
function walletScreen() {
+ if (ui.onb) return onboardScreen();
// A feature can hold the wallet behind a required onboarding step (picking
// a username). Imported wallets skip it once their name is recovered.
const ob = featureHook('onboardingView');
@@ -2038,7 +2206,8 @@ function walletScreen() {
h('div', { class: 'mt16' }, balanceCard()),
ui.offlineFallback && wallet.offline ? offlineBanner() : null,
tabsBar(),
- pane
+ pane,
+ ui.tour != null ? tourOverlay() : null
);
}
diff --git a/src/features/messages.js b/src/features/messages.js
index 6ff94a4..46f7399 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1356,6 +1356,28 @@ export function messagesFeature(ctx) {
// Anyone (ark's history, other features) can open a profile or render a
// small clickable identity chip.
showProfile(pk) { openProfile(pk); return true; },
+ // Publish (merge) kind-0 fields for the current identity — the onboarding
+ // wizard sets name + picture through this.
+ async publishProfile(fields) {
+ const id = await identity();
+ if (!id) throw new Error(t('msgNoIdentity'));
+ const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
+ const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
+ let base = {};
+ try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const merged = { ...base };
+ for (const [k, v] of Object.entries(fields || {})) {
+ if (v) merged[k] = v;
+ }
+ if (merged.name) merged.display_name = merged.name;
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
+ if (!ok) throw new Error(t('msgSendFailed'));
+ fullProfiles.set(id.pubkey, merged);
+ profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
+ return true;
+ },
profileChip(pk) {
return h('span', {
class: 'zap-chip',
diff --git a/src/features/names.js b/src/features/names.js
index 3952214..a30038e 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -310,6 +310,8 @@ export function namesFeature(ctx) {
namesAdoptIdentity(signer, npub) { return adoptIdentity(signer, npub); },
// the claimed payment address, for anyone prefilling a lightning address
namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN}` : null; },
+ // the onboarding wizard renders the same claim form on its username step
+ namesClaimForm() { return claimForm(true); },
stop() { clearTimeout(retryTimer); clearTimeout(deadline); deadline = null; },
receiveModes() {
if (!available()) return [];
diff --git a/src/i18n.js b/src/i18n.js
index 9db02d4..d1587a0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -479,6 +479,33 @@ const STR = {
arkLnRefunded: 'Payment failed — your money is back in Spending.',
arkLnPaidTitle: 'Paid!',
nlSignIn: 'Sign in with Nostr',
+ onbWelcomeTitle: 'Welcome to coinos',
+ onbWelcomeBody: 'A bitcoin wallet that lives in your browser: instant payments, encrypted chat, and your keys never leave the device. Ready to get started?',
+ onbStart: "Let's go",
+ onbHaveWallet: 'I already have a wallet',
+ onbNostrTitle: 'Do you already have a nostr account?',
+ onbNostrBody: 'Sign in with it and your wallet, name, and profile come with you. No idea what nostr is? No problem — we\u2019ll set everything up fresh.',
+ onbNostrYes: 'Yes — sign me in',
+ onbNostrNo: 'No — create my wallet',
+ onbSigninTitle: 'Sign in with Nostr',
+ onbNameTitle: 'Claim your username',
+ onbNameBody: 'Your payment address — people can send you bitcoin with just this.',
+ onbNameWait: 'Getting things ready…',
+ onbKeepName: 'Keep {addr} for now',
+ onbAvatarTitle: 'Choose your look',
+ onbAvatarBody: 'Pick a punk or upload your own picture. You can change it anytime.',
+ onbUpload: 'Upload a photo',
+ onbUploadFailed: 'Upload failed — try a punk for now',
+ onbContinue: 'Continue',
+ onbSkipAvatar: 'Continue with my default',
+ onbDoneTitle: "You're all set!",
+ onbDoneBody: 'Your wallet is ready, your address is live, and the coinos community chat is waiting for you.',
+ onbEnter: 'Enter coinos',
+ tourAvatar: 'Your avatar lives here — profile, messages, settings, and logout are all behind it.',
+ tourBalance: 'Two balances: Spending is instant and free, Savings is on-chain. Tap to switch — everything follows.',
+ tourTabs: 'Receive, send, and your history. Tip: swipe left and right to move between them.',
+ tourNext: 'Next',
+ tourSkip: 'Skip',
nlConnectApp: '📱 Connect a signer app',
nlOpenSigner: 'Open signer app',
nlWaitingSigner: 'Waiting for your signer to connect…',
diff --git a/src/style.css b/src/style.css
index 9b6558e..3dbeaaa 100644
--- a/src/style.css
+++ b/src/style.css
@@ -475,3 +475,34 @@ body::after {
@keyframes tab-in-right { from { opacity: .2; transform: translateX(-26px); } }
.anim-tab-left { animation: tab-in-left .26s cubic-bezier(.25, .8, .35, 1) both; }
.anim-tab-right { animation: tab-in-right .26s cubic-bezier(.25, .8, .35, 1) both; }
+
+/* ---- onboarding wizard + tour ---- */
+.onb { max-width: 420px; margin: 8vh auto 0; padding: 0 8px; }
+.onb-title { margin: 0; font-size: 26px; letter-spacing: -0.02em; }
+.onb-hero { margin-bottom: 8px; }
+.onb-punks { display: flex; gap: 10px; overflow-x: auto; padding: 6px 2px; }
+.onb-punk { width: 56px; height: 56px; border-radius: 50%; flex-shrink: 0; cursor: pointer; border: 3px solid transparent; transition: transform .15s, border-color .15s; }
+.onb-punk:hover { transform: scale(1.08); }
+.onb-punk.sel { border-color: var(--accent); transform: scale(1.08); }
+.onb-preview { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; align-self: center; box-shadow: var(--shadow); }
+.onb-confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
+.onb-confetti i { position: absolute; top: -12px; width: 9px; height: 14px; opacity: .9; animation: confetti-fall 2.8s ease-in forwards; }
+.onb-confetti i:nth-child(4n) { background: #f7931a; }
+.onb-confetti i:nth-child(4n+1) { background: #ffd166; }
+.onb-confetti i:nth-child(4n+2) { background: var(--green); }
+.onb-confetti i:nth-child(4n+3) { background: #b84fe8; }
+.onb-confetti i:nth-child(1) { left: 6%; animation-delay: 0s; } .onb-confetti i:nth-child(2) { left: 12%; animation-delay: .3s; }
+.onb-confetti i:nth-child(3) { left: 19%; animation-delay: .1s; } .onb-confetti i:nth-child(4) { left: 26%; animation-delay: .5s; }
+.onb-confetti i:nth-child(5) { left: 33%; animation-delay: .2s; } .onb-confetti i:nth-child(6) { left: 40%; animation-delay: .65s; }
+.onb-confetti i:nth-child(7) { left: 47%; animation-delay: .05s; } .onb-confetti i:nth-child(8) { left: 54%; animation-delay: .4s; }
+.onb-confetti i:nth-child(9) { left: 61%; animation-delay: .15s; } .onb-confetti i:nth-child(10) { left: 68%; animation-delay: .55s; }
+.onb-confetti i:nth-child(11) { left: 75%; animation-delay: .25s; } .onb-confetti i:nth-child(12) { left: 82%; animation-delay: .7s; }
+.onb-confetti i:nth-child(13) { left: 89%; animation-delay: .35s; } .onb-confetti i:nth-child(14) { left: 95%; animation-delay: .6s; }
+.onb-confetti i:nth-child(15) { left: 15%; animation-delay: .8s; } .onb-confetti i:nth-child(16) { left: 45%; animation-delay: .9s; }
+.onb-confetti i:nth-child(17) { left: 70%; animation-delay: 1s; } .onb-confetti i:nth-child(18) { left: 30%; animation-delay: 1.1s; }
+@keyframes confetti-fall { 0% { transform: translateY(-10px) rotate(0); } 100% { transform: translateY(105vh) rotate(560deg); } }
+.tour-overlay { position: fixed; inset: 0; z-index: 80; background: rgba(0, 0, 0, .45); animation: rise-in .25s both; }
+.tour-ring { position: fixed; border-radius: 14px; box-shadow: 0 0 0 3px #f7931a, 0 0 0 9999px rgba(0, 0, 0, .45); background: transparent; }
+.tour-overlay { background: transparent; }
+.tour-card { position: fixed; left: 50%; transform: translateX(-50%); width: min(90vw, 360px); background: var(--paper); color: var(--ink); border: 1px solid var(--line-strong); border-radius: 14px; padding: 14px 16px; box-shadow: var(--shadow); z-index: 81; }
+@media (prefers-reduced-motion: reduce) { .onb-confetti i { animation: none; display: none; } }