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:
archipelago 2026-06-22 10:26:35 -04:00
parent 2715f2d847
commit 2cffa79d9d
9 changed files with 256 additions and 114 deletions

View File

@ -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')

View File

@ -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
}

View File

@ -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)

View File

@ -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>

View File

@ -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(() => {

View File

@ -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(() => {})
}

View File

@ -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

View File

@ -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[]

View File

@ -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[]