feat(demo): typing splash + intro on every fresh visit; neutral desktop Cloud tabs
- Demo boots at '/' (first visit or a browser refresh) always start with the typing splash into the onboarding intro — the per-calendar-day gate is gone. In-session SPA navigation never replays it, and deep-route refreshes keep the visitor's place. - Cloud top-level tabs return to the standard mode-switcher style on desktop; the orange full-width switcher stays mobile-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6e5a99ef71
commit
c13ee58edf
@ -398,8 +398,13 @@ onMounted(async () => {
|
|||||||
// One-shot "Replay intro" request from the login screen — must survive the
|
// One-shot "Replay intro" request from the login screen — must survive the
|
||||||
// auto-re-mark below (which otherwise instantly suppresses the replay on any
|
// auto-re-mark below (which otherwise instantly suppresses the replay on any
|
||||||
// onboarded node).
|
// onboarded node).
|
||||||
const replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
||||||
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
||||||
|
// Public demo: every fresh boot at the root (first visit or a browser
|
||||||
|
// refresh) starts with the typing splash for the full effect. In-session SPA
|
||||||
|
// navigation never remounts App, and deep-route refreshes keep their place.
|
||||||
|
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||||
|
if (IS_DEMO && route.path === '/') replayRequested = true
|
||||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||||
const splashCandidate = !seenIntro
|
const splashCandidate = !seenIntro
|
||||||
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Public-demo helpers.
|
* Public-demo helpers.
|
||||||
*
|
*
|
||||||
* The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but
|
* The demo build (VITE_DEMO=1) replays the typing splash + intro/onboarding on
|
||||||
* only once per calendar day per browser — tracked in localStorage so it
|
* EVERY fresh boot of the app at '/' (first visit or browser refresh) — see
|
||||||
* survives the short-lived backend session. Also exposes the shared demo
|
* App.vue and RootRedirect.demoRoute. Also exposes the shared demo credentials
|
||||||
* credentials shown on the login screen.
|
* shown on the login screen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const IS_DEMO =
|
export const IS_DEMO =
|
||||||
@ -13,36 +13,10 @@ export const IS_DEMO =
|
|||||||
/** Memorable shared password for the public demo (must match the mock backend). */
|
/** Memorable shared password for the public demo (must match the mock backend). */
|
||||||
export const DEMO_PASSWORD = 'entertoexit'
|
export const DEMO_PASSWORD = 'entertoexit'
|
||||||
|
|
||||||
const INTRO_DATE_KEY = 'demo_intro_date'
|
/** Forget any legacy per-day gate marker (pre-2026-07 builds stored one). */
|
||||||
|
|
||||||
function todayKey(): string {
|
|
||||||
// Local calendar day, e.g. "2026-06-22".
|
|
||||||
const d = new Date()
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if this browser already watched the intro earlier today. */
|
|
||||||
export function demoIntroSeenToday(): boolean {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(INTRO_DATE_KEY) === todayKey()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record that the intro has been seen today, so it won't replay until tomorrow. */
|
|
||||||
export function markDemoIntroSeen(): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(INTRO_DATE_KEY, todayKey())
|
|
||||||
} catch {
|
|
||||||
/* ignore (private mode / storage disabled) */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */
|
|
||||||
export function clearDemoIntroSeen(): void {
|
export function clearDemoIntroSeen(): void {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(INTRO_DATE_KEY)
|
localStorage.removeItem('demo_intro_date')
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,12 +6,12 @@
|
|||||||
<!-- Desktop: page tabs + category tabs + search on one row -->
|
<!-- Desktop: page tabs + category tabs + search on one row -->
|
||||||
<div class="app-header-desktop items-center gap-4">
|
<div class="app-header-desktop items-center gap-4">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="cloud-tab-switcher hidden md:inline-flex">
|
<div class="mode-switcher hidden md:inline-flex">
|
||||||
<button
|
<button
|
||||||
v-for="tab in TABS"
|
v-for="tab in TABS"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
class="cloud-tab-btn"
|
class="mode-switcher-btn"
|
||||||
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
|
:class="{ 'mode-switcher-btn-active': activeTab === tab.id }"
|
||||||
@click="activeTab = tab.id"
|
@click="activeTab = tab.id"
|
||||||
>{{ tab.name }}</button>
|
>{{ tab.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -789,8 +789,9 @@ defineExpose({ loadPeers })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Top-level tab switcher — deliberately a DIFFERENT look from the neutral
|
/* Mobile-only top-level tab switcher — deliberately a DIFFERENT look from the
|
||||||
category pills below it: orange-tinted container + active state. */
|
neutral category pills below it (orange tint). Desktop keeps the standard
|
||||||
|
mode-switcher styling. */
|
||||||
.cloud-tab-switcher {
|
.cloud-tab-switcher {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
|||||||
@ -56,16 +56,13 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
import { playNavSound } from '@/composables/useNavSounds'
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
|
import { IS_DEMO } 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
|
const isDemo = IS_DEMO
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Demo: once the visitor has seen the intro today, don't auto-replay it again
|
|
||||||
// until tomorrow (they can still use "Replay Intro" on the login screen).
|
|
||||||
if (IS_DEMO) markDemoIntroSeen()
|
|
||||||
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ctaButton.value?.focus({ preventScroll: true })
|
ctaButton.value?.focus({ preventScroll: true })
|
||||||
|
|||||||
@ -16,20 +16,20 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||||
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro'
|
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||||
import BootScreen from '@/components/BootScreen.vue'
|
import BootScreen from '@/components/BootScreen.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const showBootScreen = ref(false)
|
const showBootScreen = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public demo: replay the intro on every visit, but at most once per calendar
|
* Public demo: every fresh boot of the app (first visit OR browser refresh at
|
||||||
* day per browser. If already seen today → straight to login; otherwise → intro.
|
* the root) replays the intro for the full effect. In-session SPA navigation
|
||||||
|
* never re-triggers it — this only runs when the app boots at '/'.
|
||||||
*/
|
*/
|
||||||
function demoRoute() {
|
function demoRoute() {
|
||||||
const dest = demoIntroSeenToday() ? '/login' : '/onboarding/intro'
|
log('demoRoute', { dest: '/onboarding/intro' })
|
||||||
log('demoRoute', { dest })
|
router.replace('/onboarding/intro').catch(() => {})
|
||||||
router.replace(dest).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(msg: string, data?: unknown) {
|
function log(msg: string, data?: unknown) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user