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:
co-authored by
Claude Opus 4.8
parent
2715f2d847
commit
2cffa79d9d
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user