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.
|
// 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
|
// In DEMO the visitor is always treated as fully set up ("existing") so the
|
||||||
// play for each new visitor (the per-day replay gate lives in the frontend).
|
// 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() {
|
function seedUserState() {
|
||||||
const mode = DEMO ? 'onboarding' : DEV_MODE
|
const mode = DEMO ? 'existing' : DEV_MODE
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'setup':
|
case 'setup':
|
||||||
// Setup mode: user needs to set a password (simple setup, not onboarding).
|
// 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) => {
|
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -2003,84 +2115,7 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
// Federation (multi-node clusters)
|
// Federation (multi-node clusters)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
case 'federation.list-nodes': {
|
case 'federation.list-nodes': {
|
||||||
return res.json({
|
return res.json({ result: { nodes: demoFederationNodes() } })
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'federation.invite': {
|
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/payments', (req, res) => res.json(MOCK_LND_DATA.payments))
|
||||||
app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding))
|
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
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.status(200).send('healthy')
|
res.status(200).send('healthy')
|
||||||
|
|||||||
@ -47,3 +47,43 @@ export function clearDemoIntroSeen(): void {
|
|||||||
/* ignore */
|
/* 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 router from '@/router'
|
||||||
import { recordAppLaunch } from '@/utils/appUsage'
|
import { recordAppLaunch } from '@/utils/appUsage'
|
||||||
import { requestExternalOpen } from '@/api/remote-relay'
|
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
|
* 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) {
|
function openSession(appId: string) {
|
||||||
recordAppLaunch(appId)
|
recordAppLaunch(appId)
|
||||||
const mobile = isMobileViewport()
|
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
|
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||||
if (launchUrl && !mobile) {
|
if (launchUrl && !mobile) {
|
||||||
openExternal(launchUrl)
|
openExternal(launchUrl)
|
||||||
|
|||||||
@ -208,14 +208,16 @@
|
|||||||
>
|
>
|
||||||
{{ t('login.replayIntro') }}
|
{{ t('login.replayIntro') }}
|
||||||
</button>
|
</button>
|
||||||
<span class="text-white/30">|</span>
|
<template v-if="!isDemo">
|
||||||
<button
|
<span class="text-white/30">|</span>
|
||||||
@click="restartOnboarding"
|
<button
|
||||||
:disabled="isResettingOnboarding"
|
@click="restartOnboarding"
|
||||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
: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>
|
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -63,8 +63,8 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="installBlockedReason || undefined"
|
: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"
|
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">
|
<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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -129,8 +129,8 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="installBlockedReason || undefined"
|
: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"
|
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">
|
<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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -351,6 +351,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
@ -486,6 +487,9 @@ const installBlockedReason = computed(() => {
|
|||||||
return electrumxArchiveWarning
|
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
|
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -22,27 +22,30 @@
|
|||||||
@click="goToOptions"
|
@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"
|
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>
|
</button>
|
||||||
|
|
||||||
<a
|
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
|
||||||
tabindex="0"
|
<template v-if="!isDemo">
|
||||||
role="button"
|
<a
|
||||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
tabindex="0"
|
||||||
@click="goToRestore"
|
role="button"
|
||||||
@keydown.enter="goToRestore"
|
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||||
>
|
@click="goToRestore"
|
||||||
Restore from seed phrase
|
@keydown.enter="goToRestore"
|
||||||
</a>
|
>
|
||||||
<a
|
Restore from seed phrase
|
||||||
tabindex="0"
|
</a>
|
||||||
role="button"
|
<a
|
||||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
tabindex="0"
|
||||||
@click="goToLogin"
|
role="button"
|
||||||
@keydown.enter="goToLogin"
|
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||||
>
|
@click="goToLogin"
|
||||||
Already set up? Log in
|
@keydown.enter="goToLogin"
|
||||||
</a>
|
>
|
||||||
|
Already set up? Log in
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -57,6 +60,7 @@ import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
const isDemo = IS_DEMO
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Demo: once the visitor has seen the intro today, don't auto-replay it again
|
// Demo: once the visitor has seen the intro today, don't auto-replay it again
|
||||||
@ -70,6 +74,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
function goToOptions() {
|
function goToOptions() {
|
||||||
playNavSound('action')
|
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(() => {})
|
router.push('/onboarding/path').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/** Static configuration maps for app session routing and display */
|
/** Static configuration maps for app session routing and display */
|
||||||
|
|
||||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
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'
|
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) */
|
/** Resolve app URL using direct port mapping (source of truth) */
|
||||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
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
|
// External HTTPS apps
|
||||||
const ext = EXTERNAL_URLS[id]
|
const ext = EXTERNAL_URLS[id]
|
||||||
if (ext) return ext
|
if (ext) return ext
|
||||||
|
|||||||
@ -116,6 +116,12 @@
|
|||||||
Checking...
|
Checking...
|
||||||
</span>
|
</span>
|
||||||
</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 -->
|
<!-- Install button -->
|
||||||
<button
|
<button
|
||||||
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
||||||
@ -158,6 +164,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MarketplaceApp } from './types'
|
import type { MarketplaceApp } from './types'
|
||||||
import { handleImageError } from '@/views/apps/appsConfig'
|
import { handleImageError } from '@/views/apps/appsConfig'
|
||||||
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
filteredApps: MarketplaceApp[]
|
filteredApps: MarketplaceApp[]
|
||||||
|
|||||||
@ -74,6 +74,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Checking...
|
Checking...
|
||||||
</button>
|
</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
|
<button
|
||||||
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
||||||
data-controller-install-btn
|
data-controller-install-btn
|
||||||
@ -99,6 +104,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||||
import { handleImageError } from '@/views/apps/appsConfig'
|
import { handleImageError } from '@/views/apps/appsConfig'
|
||||||
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
featuredApps: FeaturedApp[]
|
featuredApps: FeaturedApp[]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user