feat(onboarding): add /start mode chooser, fix pilot-profile-setup UX

- Fix missing icon for the free-tier/self-host pricing option
- Make selected cards visually obvious (checkmark badge, stronger border)
- Add Back navigation between onboarding steps
- Network-experience screen: button reads "No, not yet" instead of an
  ambiguous "Continue" when nothing is selected
- classroom-introduction reverts to Classroom-only content; the
  Classroom/Live ATC chooser moves to a new /start page instead
- Remove the "Skip to Classroom" button in favor of a subtle text link
- New /start page: choose Classroom or Live ATC after login (when
  there's no specific redirect target) or right after finishing
  onboarding. Shows a "start with Classroom" hint only for first-time
  arrivals from pilot-profile-setup. Live ATC is locked for 24h after
  registration.
This commit is contained in:
itsrubberduck
2026-07-09 23:53:12 +02:00
parent c2717d6a89
commit 83d9b8ae09
4 changed files with 172 additions and 58 deletions

View File

@@ -30,54 +30,13 @@
class="inline-flex items-center gap-2 rounded-full border border-cyan-300/40 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.3em] text-cyan-200/80"
>Orientation</span>
<h1 class="text-3xl font-semibold leading-tight sm:text-4xl md:text-5xl">
Choose how you want to get started
Get comfortable with the Classroom
</h1>
<p class="max-w-2xl text-base text-white/80 sm:text-lg">
Practice dry radio calls at your own pace in the Classroom, or jump into our Live ATC alpha test connected
to your simulator. You can always switch later Classroom is where most pilots start.
This is your training playground. Build phraseology, timing and confidence at your own pace. I will
guide you through the flow before you dive into the hub.
</p>
<div class="grid gap-4 sm:grid-cols-2">
<div class="relative flex flex-col gap-3 rounded-2xl border border-cyan-300/40 bg-cyan-400/10 p-5">
<span
class="absolute right-4 top-4 rounded-full border border-cyan-300/40 bg-cyan-400/15 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-cyan-200"
>Default</span>
<div class="flex h-10 w-10 items-center justify-center rounded-xl border border-cyan-300/40 bg-cyan-400/15">
<v-icon icon="mdi-school-outline" size="20" class="text-cyan-200" />
</div>
<div>
<p class="text-base font-semibold text-white">Classroom</p>
<p class="mt-1 text-sm text-white/70">
Structured, self-paced radio practice. No simulator required this is where most pilots start.
</p>
</div>
<button type="button" class="btn primary mt-1 self-start" @click="scrollToOrientation">
Start Classroom
<v-icon icon="mdi-arrow-right" size="16" class="text-[#061318]" />
</button>
</div>
<div class="relative flex flex-col gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/[0.06] p-5">
<span
class="absolute right-4 top-4 rounded-full border border-amber-300/40 bg-amber-400/15 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-200"
>Alpha</span>
<div class="flex h-10 w-10 items-center justify-center rounded-xl border border-amber-300/40 bg-amber-400/15">
<v-icon icon="mdi-radar" size="20" class="text-amber-200" />
</div>
<div>
<p class="text-base font-semibold text-white">Live ATC</p>
<p class="mt-1 text-sm text-white/70">
Connect your simulator and fly against our live AI controller. Early alpha not everything works
reliably yet. We'd love your bug reports; this is also how we figure out where to take it next.
</p>
</div>
<NuxtLink to="/pm" class="btn soft mt-1 self-start">
Try Live ATC (Alpha)
<v-icon icon="mdi-arrow-right" size="16" class="text-amber-100" />
</NuxtLink>
</div>
</div>
<ul class="grid gap-3 text-sm text-white/70 sm:grid-cols-2 sm:text-base">
<li class="flex items-start gap-3">
<v-icon icon="mdi-compass-outline" size="20" class="mt-1 text-cyan-300" />
@@ -195,10 +154,6 @@
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
Start guided tour
</button>
<NuxtLink to="/classroom" class="btn ghost" @click="handleClassroomEntry">
Skip to Classroom
<v-icon icon="mdi-arrow-right" size="16" />
</NuxtLink>
</div>
<p v-if="startDisabled && voiceMode === 'radio'" class="mt-2 text-xs text-amber-200/80">
Run the radio check once so I know you can hear me before we roll.
@@ -206,6 +161,13 @@
<p v-if="speechError" class="mt-2 text-xs text-rose-200/80">
{{ speechError }}
</p>
<NuxtLink
to="/classroom"
class="mt-4 block text-center text-xs text-white/30 transition hover:text-white/60"
@click="handleClassroomEntry"
>
Skip to Classroom
</NuxtLink>
</div>
</div>
</div>
@@ -537,11 +499,6 @@ function handleClassroomEntry() {
markClassroomIntroComplete()
}
function scrollToOrientation() {
if (typeof window === 'undefined') return
document.getElementById('orientation')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const voiceMode = ref<VoiceMode>('text')
const radioLevel = ref(5)
const hasCompletedRadioCheck = ref(false)

View File

@@ -15,6 +15,15 @@
<main class="mx-auto w-full max-w-screen-md px-4 py-10 sm:px-6">
<template v-if="!loading">
<div v-if="!finished" class="mb-10">
<button
v-if="currentStep > 0"
type="button"
class="mb-3 inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white/70"
@click="goBack"
>
<v-icon icon="mdi-arrow-left" size="14" />
Back
</button>
<div class="flex items-center gap-3 text-xs text-white/70">
<div class="flex-1 overflow-hidden rounded-full bg-white/10">
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
@@ -169,7 +178,7 @@
</button>
</div>
<button type="button" class="btn primary mt-8" @click="saveAnswer({ networkExperience: answers.networkExperience })">
Continue
{{ answers.networkExperience.length === 0 ? "No, not yet" : 'Continue' }}
<v-icon icon="mdi-arrow-right" size="18" />
</button>
</div>
@@ -369,6 +378,10 @@ async function saveAnswer(partial: Record<string, unknown>) {
currentStep.value = Math.min(currentStep.value + 1, ONBOARDING_TOTAL_STEPS - 1)
}
function goBack() {
currentStep.value = Math.max(0, currentStep.value - 1)
}
function continueCockpit() {
const payload: { simulator: Simulator | null; hardware: HardwareItem[]; os?: string | null } = {
simulator: answers.simulator,
@@ -396,7 +409,7 @@ function finishAndLeave() {
if (typeof window !== 'undefined') {
localStorage.setItem(CLASSROOM_INTRO_STORAGE_KEY, 'false')
}
router.replace('/classroom-introduction')
router.replace('/start?firstTime=1')
}
async function skip() {
@@ -437,11 +450,29 @@ onMounted(async () => {
<style scoped>
.option-card {
@apply flex flex-col items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-5 text-center text-sm text-white/80 transition hover:border-cyan-300/50 hover:bg-white/10;
@apply relative flex flex-col items-center gap-2 rounded-2xl border-2 border-white/10 bg-white/5 px-4 py-5 text-center text-sm text-white/80 transition hover:border-cyan-300/50 hover:bg-white/10;
}
.option-card--active {
@apply border-cyan-300/60 bg-cyan-400/10 text-white shadow-lg shadow-cyan-500/10;
@apply border-cyan-300 bg-cyan-400/20 text-white shadow-lg shadow-cyan-500/20;
}
.option-card--active::after {
content: '\2713';
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: #22d3ee;
color: #061318;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.option-card--sm {

126
app/pages/start.vue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<div class="learn-theme flex min-h-screen items-center justify-center bg-[#070d1a] px-4 py-16 text-white">
<div class="w-full max-w-3xl space-y-8">
<div class="space-y-3 text-center">
<span
class="inline-flex items-center gap-2 rounded-full border border-cyan-300/40 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.3em] text-cyan-200/80"
>Choose your mode</span>
<h1 class="text-3xl font-semibold leading-tight sm:text-4xl">How do you want to start?</h1>
<p v-if="isFirstTime" class="mx-auto max-w-xl text-base text-white/80">
New here? We'd recommend starting with the Classroom before jumping into the Live ATC alpha.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<button
type="button"
class="relative flex flex-col gap-3 rounded-2xl border border-cyan-300/40 bg-cyan-400/10 p-5 text-left transition hover:border-cyan-300/60 hover:bg-cyan-400/15"
@click="goToClassroom"
>
<span
class="absolute right-4 top-4 rounded-full border border-cyan-300/40 bg-cyan-400/15 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-cyan-200"
>Default</span>
<div class="flex h-10 w-10 items-center justify-center rounded-xl border border-cyan-300/40 bg-cyan-400/15">
<v-icon icon="mdi-school-outline" size="20" class="text-cyan-200" />
</div>
<div>
<p class="text-base font-semibold text-white">Classroom</p>
<p class="mt-1 text-sm text-white/70">
Structured, self-paced radio practice. No simulator required this is where most pilots start.
</p>
</div>
<span class="btn primary mt-1 self-start">
Start Classroom
<v-icon icon="mdi-arrow-right" size="16" class="text-[#061318]" />
</span>
</button>
<NuxtLink
v-if="liveAtcUnlocked"
to="/pm"
class="relative flex flex-col gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/[0.06] p-5 transition hover:border-amber-400/50 hover:bg-amber-400/10"
>
<span
class="absolute right-4 top-4 rounded-full border border-amber-300/40 bg-amber-400/15 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-200"
>Alpha</span>
<div class="flex h-10 w-10 items-center justify-center rounded-xl border border-amber-300/40 bg-amber-400/15">
<v-icon icon="mdi-radar" size="20" class="text-amber-200" />
</div>
<div>
<p class="text-base font-semibold text-white">Live ATC</p>
<p class="mt-1 text-sm text-white/70">
Connect your simulator and fly against our live AI controller. Early alpha not everything works
reliably yet. We'd love your bug reports; this is also how we figure out where to take it next.
</p>
</div>
<span class="btn soft mt-1 self-start">
Try Live ATC (Alpha)
<v-icon icon="mdi-arrow-right" size="16" class="text-amber-100" />
</span>
</NuxtLink>
<div
v-else
class="relative flex flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 p-5 opacity-60"
aria-disabled="true"
>
<span
class="absolute right-4 top-4 rounded-full border border-white/15 bg-white/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-white/60"
>Alpha</span>
<div class="flex h-10 w-10 items-center justify-center rounded-xl border border-white/15 bg-white/10">
<v-icon icon="mdi-radar" size="20" class="text-white/50" />
</div>
<div>
<p class="text-base font-semibold text-white">Live ATC</p>
<p class="mt-1 text-sm text-white/70">
Connect your simulator and fly against our live AI controller. Early alpha — not everything works
reliably yet. We'd love your bug reports; this is also how we figure out where to take it next.
</p>
</div>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-white/40">Unlocks {{ unlockEstimate }} after signup</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import { useAuthStore } from '~/stores/auth'
import { CLASSROOM_INTRO_STORAGE_KEY } from '~~/shared/constants/storage'
definePageMeta({ middleware: 'require-auth' })
useHead({ title: 'Choose your mode • OpenSquawk' })
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const LIVE_ATC_UNLOCK_AFTER_MS = 24 * 60 * 60 * 1000
const isFirstTime = computed(() => route.query.firstTime === '1')
const liveAtcUnlocked = computed(() => {
const createdAt = auth.user?.createdAt
if (!createdAt) return true
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return true
return Date.now() - createdAtMs >= LIVE_ATC_UNLOCK_AFTER_MS
})
const unlockEstimate = computed(() => {
const createdAt = auth.user?.createdAt
if (!createdAt) return ''
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return ''
const remainingMs = LIVE_ATC_UNLOCK_AFTER_MS - (Date.now() - createdAtMs)
const remainingHours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1000)))
return `in ~${remainingHours}h`
})
function goToClassroom() {
const introCompleted = typeof window !== 'undefined' && localStorage.getItem(CLASSROOM_INTRO_STORAGE_KEY) === 'true'
router.push(introCompleted ? '/classroom' : '/classroom-introduction')
}
</script>

View File

@@ -99,7 +99,7 @@ export const PRICING_PREFERENCE_OPTIONS: OnboardingOption<PricingPreference>[] =
{ value: 'one_time', label: 'One-time unlock — I own it', icon: 'mdi-lock-open-variant-outline' },
{ value: 'monthly', label: 'Small monthly add-on, always latest', icon: 'mdi-calendar-sync-outline' },
{ value: 'season_pass', label: 'Bundle / season pass', icon: 'mdi-ticket-confirmation-outline' },
{ value: 'free_self_host', label: "I'd rather stay free-tier / self-host", icon: 'mdi-server-network-outline' },
{ value: 'free_self_host', label: "I'd rather stay free-tier / self-host", icon: 'mdi-source-branch' },
]
export const ONBOARDING_TOTAL_STEPS = 7