feat(demo): app launch UIs, "No demo" gating, onboarding skip, 12 nodes
App launching (DEMO): - resolveAppUrl routes every app to its demo target: mock UIs for Bitcoin Core, ElectrumX, Fedimint (served by the backend), IndeeHub → iframe indee.tx1138.com, Mempool → mempool.space/testnet (new tab); all others → a generic "Demo preview" notice page. - Non-demoable apps show a disabled "No demo" install button (marketplace details, app grid, featured apps). Onboarding: - Demo treats the visitor as fully set up so the onboarding WIZARD (seed/identity) is never forced; the welcome intro still replays per day. Intro CTA goes straight to login; wizard entry points + login restart-onboarding link hidden in demo. Network: - federation.list-nodes now returns 12 trusted/federated nodes (9 trusted, 3 observer); transport.peers already at 5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2715f2d847
commit
2cffa79d9d
@ -182,10 +182,11 @@ const SEED_BTCRELAY = {
|
||||
}
|
||||
|
||||
// User state (simulated file-based storage). Returns a fresh object per session.
|
||||
// In DEMO mode the effective dev mode is "onboarding" so the intro/onboarding can
|
||||
// play for each new visitor (the per-day replay gate lives in the frontend).
|
||||
// In DEMO the visitor is always treated as fully set up ("existing") so the
|
||||
// onboarding WIZARD (seed/identity/backup) is never forced by the route guard.
|
||||
// The welcome INTRO still shows via the frontend's per-day replay gate.
|
||||
function seedUserState() {
|
||||
const mode = DEMO ? 'onboarding' : DEV_MODE
|
||||
const mode = DEMO ? 'existing' : DEV_MODE
|
||||
switch (mode) {
|
||||
case 'setup':
|
||||
// Setup mode: user needs to set a password (simple setup, not onboarding).
|
||||
@ -982,6 +983,117 @@ app.get('/app/bitcoin-ui/', async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Mock app UIs served in the in-app iframe (DEMO) ─────────────────────────
|
||||
function demoAppShell(title, accent, bodyHtml) {
|
||||
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0b0f1a;color:#e6e8ee;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh;padding:28px}
|
||||
.wrap{max-width:860px;margin:0 auto}
|
||||
.hd{display:flex;align-items:center;gap:14px;margin-bottom:22px}
|
||||
.dot{width:11px;height:11px;border-radius:50%;background:${accent};box-shadow:0 0 12px ${accent}}
|
||||
h1{font-size:22px;font-weight:650}
|
||||
.sub{color:#8b93a7;font-size:13px;margin-top:2px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:18px}
|
||||
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:16px}
|
||||
.k{color:#8b93a7;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.v{font-size:20px;font-weight:650;margin-top:6px}
|
||||
.v.mono{font-family:ui-monospace,Menlo,monospace;font-size:13px;word-break:break-all;font-weight:500}
|
||||
.bar{height:8px;border-radius:6px;background:rgba(255,255,255,.08);overflow:hidden;margin-top:10px}
|
||||
.bar>i{display:block;height:100%;background:${accent}}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;background:rgba(34,197,94,.15);color:#86efac;border:1px solid rgba(34,197,94,.3);padding:4px 10px;border-radius:999px;font-size:12px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
td,th{text-align:left;padding:9px 6px;border-bottom:1px solid rgba(255,255,255,.06)}
|
||||
th{color:#8b93a7;font-weight:500;font-size:11px;text-transform:uppercase}
|
||||
.demo-tag{position:fixed;bottom:14px;right:16px;font-size:11px;color:#6b7280}
|
||||
</style></head><body><div class="wrap">${bodyHtml}</div>
|
||||
<div class="demo-tag">Archipelago demo · signet</div></body></html>`
|
||||
}
|
||||
|
||||
// 12 trusted/federated nodes for the demo Federation view.
|
||||
function demoFederationNodes() {
|
||||
const names = [
|
||||
['archy-198', 'trusted', ['bitcoin-knots', 'lnd', 'mempool', 'electrs']],
|
||||
['arch-tailscale-1', 'trusted', ['bitcoin-knots', 'lnd', 'nextcloud']],
|
||||
['bunker-alpha', 'trusted', ['bitcoin-knots', 'vaultwarden']],
|
||||
['seaside-node', 'trusted', ['bitcoin-knots', 'lnd', 'fedimint']],
|
||||
['highland-relay', 'trusted', ['bitcoin-knots', 'electrs', 'mempool']],
|
||||
['orchard-12', 'trusted', ['bitcoin-knots', 'immich']],
|
||||
['nimbus-vps', 'trusted', ['bitcoin-knots', 'lnd', 'thunderhub']],
|
||||
['rivertown', 'trusted', ['bitcoin-knots', 'jellyfin']],
|
||||
['granite-pi', 'trusted', ['bitcoin-knots', 'lnd']],
|
||||
['saltmarsh', 'observer', ['bitcoin-knots']],
|
||||
['dustbowl-node', 'observer', ['bitcoin-knots', 'electrs']],
|
||||
['frontier-7', 'observer', ['bitcoin-knots', 'mempool']],
|
||||
]
|
||||
return names.map(([name, trust, apps], i) => {
|
||||
const seenAgo = 60000 + i * 95000
|
||||
return {
|
||||
did: `did:key:z6Mk${randomHex(20)}`,
|
||||
pubkey: randomHex(32),
|
||||
onion: `${name.replace(/[^a-z0-9]/g, '')}${randomHex(8)}.onion`,
|
||||
trust_level: trust,
|
||||
added_at: new Date(Date.now() - (i + 2) * 86400000).toISOString(),
|
||||
name,
|
||||
last_seen: new Date(Date.now() - seenAgo).toISOString(),
|
||||
last_state: {
|
||||
timestamp: new Date(Date.now() - seenAgo).toISOString(),
|
||||
apps: apps.map(id => ({ id, status: 'running', version: '1.0' })),
|
||||
cpu_usage_percent: Math.round((6 + (i * 7) % 55) * 10) / 10,
|
||||
mem_used_bytes: (2 + (i % 6)) * 1_000_000_000,
|
||||
mem_total_bytes: (i % 2 ? 32 : 16) * 1_000_000_000,
|
||||
disk_used_bytes: (400 + i * 90) * 1_000_000_000,
|
||||
disk_total_bytes: (i % 2 ? 2000 : 1000) * 1_000_000_000,
|
||||
uptime_secs: 86400 * (i + 1),
|
||||
tor_active: trust === 'trusted',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.get(['/app/electrumx/', '/app/electrs/', '/app/archy-electrs-ui/'], (_req, res) => {
|
||||
res.type('html').send(demoAppShell('Electrs — Electrum Server', '#22d3ee', `
|
||||
<div class="hd"><span class="dot"></span><div><h1>Electrs</h1><div class="sub">Electrum server in Rust · signet</div></div></div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">● Serving clients</span></div></div>
|
||||
<div class="card"><div class="k">Indexed height</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
|
||||
<div class="card"><div class="k">RPC port</div><div class="v mono">50002 (SSL)</div></div>
|
||||
<div class="card"><div class="k">Connected wallets</div><div class="v">3</div></div>
|
||||
<div class="card"><div class="k">Mempool txs</div><div class="v">12,840</div></div>
|
||||
<div class="card"><div class="k">DB size</div><div class="v">58.2 GB</div></div>
|
||||
</div>
|
||||
<div class="card"><div class="k" style="margin-bottom:8px">Recent client subscriptions</div>
|
||||
<table><thead><tr><th>Wallet</th><th>Address type</th><th>Subscribed</th></tr></thead><tbody>
|
||||
<tr><td>Sparrow</td><td>P2WPKH</td><td>2 min ago</td></tr>
|
||||
<tr><td>BlueWallet</td><td>P2TR</td><td>9 min ago</td></tr>
|
||||
<tr><td>Electrum</td><td>P2WPKH</td><td>21 min ago</td></tr>
|
||||
</tbody></table>
|
||||
</div>`))
|
||||
})
|
||||
|
||||
app.get(['/app/fedimint/', '/app/fedimintd/'], (_req, res) => {
|
||||
res.type('html').send(demoAppShell('Fedimint — Guardian', '#a78bfa', `
|
||||
<div class="hd"><span class="dot"></span><div><h1>Fedimint Guardian</h1><div class="sub">Archipelago Federation · 4-of-5 consensus</div></div></div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="k">Consensus</div><div class="v"><span class="badge">● Online</span></div></div>
|
||||
<div class="card"><div class="k">Epoch</div><div class="v">48,217</div></div>
|
||||
<div class="card"><div class="k">Guardians online</div><div class="v">5 / 5</div></div>
|
||||
<div class="card"><div class="k">E-cash issued</div><div class="v">12,480,000 sat</div></div>
|
||||
<div class="card"><div class="k">Lightning gateway</div><div class="v"><span class="badge">● Connected</span></div></div>
|
||||
<div class="card"><div class="k">Block sync</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="k" style="margin-bottom:8px">Guardians</div>
|
||||
<table><thead><tr><th>Guardian</th><th>Peer</th><th>Status</th></tr></thead><tbody>
|
||||
<tr><td>Guardian Alpha</td><td>peer-0</td><td><span class="badge">online</span></td></tr>
|
||||
<tr><td>Guardian Beta</td><td>peer-1</td><td><span class="badge">online</span></td></tr>
|
||||
<tr><td>Guardian Gamma</td><td>peer-2</td><td><span class="badge">online</span></td></tr>
|
||||
<tr><td>Guardian Delta</td><td>peer-3</td><td><span class="badge">online</span></td></tr>
|
||||
<tr><td>Guardian Epsilon</td><td>peer-4</td><td><span class="badge">online</span></td></tr>
|
||||
</tbody></table>
|
||||
</div>`))
|
||||
})
|
||||
|
||||
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
@ -2003,84 +2115,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
// Federation (multi-node clusters)
|
||||
// =====================================================================
|
||||
case 'federation.list-nodes': {
|
||||
return res.json({
|
||||
result: {
|
||||
nodes: [
|
||||
{
|
||||
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
|
||||
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
onion: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion',
|
||||
trust_level: 'trusted',
|
||||
added_at: '2026-02-15T10:30:00Z',
|
||||
name: 'archy-198',
|
||||
last_seen: new Date(Date.now() - 120000).toISOString(),
|
||||
last_state: {
|
||||
timestamp: new Date(Date.now() - 120000).toISOString(),
|
||||
apps: [
|
||||
{ id: 'bitcoin-knots', status: 'running', version: '27.1' },
|
||||
{ id: 'lnd', status: 'running', version: '0.18.0' },
|
||||
{ id: 'mempool', status: 'running', version: '3.0' },
|
||||
{ id: 'electrs', status: 'running', version: '0.10.0' },
|
||||
],
|
||||
cpu_usage_percent: 18.3,
|
||||
mem_used_bytes: 6_200_000_000,
|
||||
mem_total_bytes: 16_000_000_000,
|
||||
disk_used_bytes: 820_000_000_000,
|
||||
disk_total_bytes: 1_800_000_000_000,
|
||||
uptime_secs: 604800,
|
||||
tor_active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
|
||||
pubkey: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
|
||||
onion: 'peer2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion',
|
||||
trust_level: 'trusted',
|
||||
added_at: '2026-03-01T14:00:00Z',
|
||||
name: 'arch-tailscale-1',
|
||||
last_seen: new Date(Date.now() - 300000).toISOString(),
|
||||
last_state: {
|
||||
timestamp: new Date(Date.now() - 300000).toISOString(),
|
||||
apps: [
|
||||
{ id: 'bitcoin-knots', status: 'running', version: '27.1' },
|
||||
{ id: 'lnd', status: 'running', version: '0.18.0' },
|
||||
{ id: 'nextcloud', status: 'running', version: '29.0' },
|
||||
],
|
||||
cpu_usage_percent: 42.1,
|
||||
mem_used_bytes: 10_500_000_000,
|
||||
mem_total_bytes: 32_000_000_000,
|
||||
disk_used_bytes: 1_200_000_000_000,
|
||||
disk_total_bytes: 2_000_000_000_000,
|
||||
uptime_secs: 259200,
|
||||
tor_active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
|
||||
pubkey: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
onion: 'peer3mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion',
|
||||
trust_level: 'observer',
|
||||
added_at: '2026-03-10T09:15:00Z',
|
||||
name: 'bunker-alpha',
|
||||
last_seen: new Date(Date.now() - 3600000).toISOString(),
|
||||
last_state: {
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
apps: [
|
||||
{ id: 'bitcoin-knots', status: 'running', version: '27.1' },
|
||||
{ id: 'vaultwarden', status: 'running', version: '1.31.0' },
|
||||
],
|
||||
cpu_usage_percent: 5.8,
|
||||
mem_used_bytes: 2_100_000_000,
|
||||
mem_total_bytes: 8_000_000_000,
|
||||
disk_used_bytes: 450_000_000_000,
|
||||
disk_total_bytes: 1_000_000_000_000,
|
||||
uptime_secs: 1209600,
|
||||
tor_active: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return res.json({ result: { nodes: demoFederationNodes() } })
|
||||
}
|
||||
|
||||
case 'federation.invite': {
|
||||
@ -3826,6 +3861,29 @@ app.get('/app/thunderhub/api/invoices', (req, res) => res.json(MOCK_LND_DATA.inv
|
||||
app.get('/app/thunderhub/api/payments', (req, res) => res.json(MOCK_LND_DATA.payments))
|
||||
app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding))
|
||||
|
||||
// Generic app shell for any launched app without a dedicated mock UI — a clean
|
||||
// "not interactive in the demo" notice with the app's icon. Registered after all
|
||||
// specific /app/... routes so those win; only bare /app/<id>[/] reaches here.
|
||||
app.get(['/app/:id', '/app/:id/'], (req, res) => {
|
||||
const id = String(req.params.id || '').replace(/[^a-z0-9._-]/gi, '')
|
||||
const title = id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
res.type('html').send(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}
|
||||
body{background:#0b0f1a;color:#e6e8ee;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:flex;align-items:center;justify-content:center}
|
||||
.card{text-align:center;padding:48px 40px;max-width:380px}
|
||||
img{width:84px;height:84px;border-radius:20px;object-fit:cover;margin-bottom:20px;background:rgba(255,255,255,.05)}
|
||||
h1{font-size:22px;font-weight:650;margin-bottom:8px}
|
||||
p{color:#8b93a7;font-size:14px;line-height:1.5}
|
||||
.tag{margin-top:18px;display:inline-block;font-size:12px;color:#fbbf24;border:1px solid rgba(251,191,36,.3);background:rgba(251,191,36,.1);padding:5px 12px;border-radius:999px}
|
||||
</style></head><body><div class="card">
|
||||
<img src="/assets/img/app-icons/${id}.png" onerror="this.onerror=null;this.src='/assets/icon/favico-black.svg'" alt="">
|
||||
<h1>${title}</h1>
|
||||
<p>This app isn't interactive in the demo, but it runs fully on a real Archipelago node.</p>
|
||||
<div class="tag">Demo preview</div>
|
||||
</div></body></html>`)
|
||||
})
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).send('healthy')
|
||||
|
||||
@ -47,3 +47,43 @@ export function clearDemoIntroSeen(): void {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Demoable apps ───────────────────────────────────────────────────────────
|
||||
// Only these apps actually do something in the demo (a mock UI or a real
|
||||
// external site). Everything else shows "No demo" on a disabled install button
|
||||
// and is not launchable.
|
||||
const DEMO_EXTERNAL_URLS: Record<string, string> = {
|
||||
// Real, public sites that we open instead of mocking.
|
||||
indeedhub: 'https://indee.tx1138.com/',
|
||||
mempool: 'https://mempool.space/testnet',
|
||||
'mempool-web': 'https://mempool.space/testnet',
|
||||
}
|
||||
|
||||
// Apps with a same-origin mock UI served by the demo backend.
|
||||
const DEMO_MOCK_UI: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'bitcoin-core': '/app/bitcoin-ui/',
|
||||
bitcoin: '/app/bitcoin-ui/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
electrs: '/app/electrumx/',
|
||||
electrumx: '/app/electrumx/',
|
||||
'archy-electrs-ui': '/app/electrumx/',
|
||||
fedimint: '/app/fedimint/',
|
||||
fedimintd: '/app/fedimint/',
|
||||
filebrowser: '/app/filebrowser/',
|
||||
}
|
||||
|
||||
/** Apps that open in a new tab (external real sites) rather than an iframe. */
|
||||
export function isDemoExternal(appId: string): boolean {
|
||||
return appId === 'mempool' || appId === 'mempool-web'
|
||||
}
|
||||
|
||||
/** Can this app be launched/installed in the demo? */
|
||||
export function isDemoApp(appId: string): boolean {
|
||||
return appId in DEMO_EXTERNAL_URLS || appId in DEMO_MOCK_UI
|
||||
}
|
||||
|
||||
/** Resolve the demo launch URL for an app, or null if it isn't demoable. */
|
||||
export function demoAppUrl(appId: string): string | null {
|
||||
return DEMO_EXTERNAL_URLS[appId] ?? DEMO_MOCK_UI[appId] ?? null
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||
@ -222,6 +223,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
function openSession(appId: string) {
|
||||
recordAppLaunch(appId)
|
||||
const mobile = isMobileViewport()
|
||||
// Demo: apps backed by a real external site that blocks iframing (mempool.space)
|
||||
// open in a new tab; everything else demoable renders in the in-app session.
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) { openExternal(ext); return }
|
||||
}
|
||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||
if (launchUrl && !mobile) {
|
||||
openExternal(launchUrl)
|
||||
|
||||
@ -208,14 +208,16 @@
|
||||
>
|
||||
{{ t('login.replayIntro') }}
|
||||
</button>
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@click="restartOnboarding"
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||
</button>
|
||||
<template v-if="!isDemo">
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@click="restartOnboarding"
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -63,8 +63,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@ -74,7 +74,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,8 +129,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@ -140,7 +140,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -351,6 +351,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@ -486,6 +487,9 @@ const installBlockedReason = computed(() => {
|
||||
return electrumxArchiveWarning
|
||||
})
|
||||
|
||||
// Demo: only demoable apps can be installed; the rest show "No demo".
|
||||
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
|
||||
|
||||
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -22,27 +22,30 @@
|
||||
@click="goToOptions"
|
||||
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
|
||||
>
|
||||
Unlock your sovereignty →
|
||||
{{ isDemo ? 'Enter the demo →' : 'Unlock your sovereignty →' }}
|
||||
</button>
|
||||
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||
@click="goToLogin"
|
||||
@keydown.enter="goToLogin"
|
||||
>
|
||||
Already set up? Log in
|
||||
</a>
|
||||
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
|
||||
<template v-if="!isDemo">
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||
@click="goToLogin"
|
||||
@keydown.enter="goToLogin"
|
||||
>
|
||||
Already set up? Log in
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,6 +60,7 @@ import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||
const isDemo = IS_DEMO
|
||||
|
||||
onMounted(() => {
|
||||
// Demo: once the visitor has seen the intro today, don't auto-replay it again
|
||||
@ -70,6 +74,13 @@ onMounted(() => {
|
||||
|
||||
function goToOptions() {
|
||||
playNavSound('action')
|
||||
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
||||
// to login, which is prefilled with the demo password.
|
||||
if (isDemo) {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/** Static configuration maps for app session routing and display */
|
||||
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
@ -76,6 +77,12 @@ export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
||||
// Demo: route to the app's mock UI or real external site (mempool.space,
|
||||
// indee.tx1138.com). Non-demoable apps fall through to a generic notice page.
|
||||
if (IS_DEMO) {
|
||||
return demoAppUrl(id) || `/app/${id}/`
|
||||
}
|
||||
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
@ -116,6 +116,12 @@
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Demo: app not demoable -->
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>No demo</button>
|
||||
<!-- Install button -->
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
||||
@ -158,6 +164,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
filteredApps: MarketplaceApp[]
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
</svg>
|
||||
Checking...
|
||||
</button>
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium opacity-50 cursor-not-allowed"
|
||||
>No demo</button>
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
||||
data-controller-install-btn
|
||||
@ -99,6 +104,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
featuredApps: FeaturedApp[]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user