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
co-authored by Claude Opus 4.8
parent 2715f2d847
commit 2cffa79d9d
9 changed files with 256 additions and 114 deletions
+40
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
}
+7
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)
+10 -8
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>
+10 -6
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(() => {
+30 -19
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(() => {})
}
@@ -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
+7
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[]
@@ -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[]