diff options
| -rw-r--r-- | src/app.js | 173 | ||||
| -rw-r--r-- | src/features/messages.js | 22 | ||||
| -rw-r--r-- | src/features/names.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 27 | ||||
| -rw-r--r-- | src/style.css | 31 |
5 files changed, 253 insertions, 2 deletions
@@ -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; } } |
