mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 01:55:51 +08:00
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:
@@ -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"
|
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>
|
>Orientation</span>
|
||||||
<h1 class="text-3xl font-semibold leading-tight sm:text-4xl md:text-5xl">
|
<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>
|
</h1>
|
||||||
<p class="max-w-2xl text-base text-white/80 sm:text-lg">
|
<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
|
This is your training playground. Build phraseology, timing and confidence at your own pace. I will
|
||||||
to your simulator. You can always switch later — Classroom is where most pilots start.
|
guide you through the flow before you dive into the hub.
|
||||||
</p>
|
</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">
|
<ul class="grid gap-3 text-sm text-white/70 sm:grid-cols-2 sm:text-base">
|
||||||
<li class="flex items-start gap-3">
|
<li class="flex items-start gap-3">
|
||||||
<v-icon icon="mdi-compass-outline" size="20" class="mt-1 text-cyan-300" />
|
<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]" />
|
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
|
||||||
Start guided tour
|
Start guided tour
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink to="/classroom" class="btn ghost" @click="handleClassroomEntry">
|
|
||||||
Skip to Classroom
|
|
||||||
<v-icon icon="mdi-arrow-right" size="16" />
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="startDisabled && voiceMode === 'radio'" class="mt-2 text-xs text-amber-200/80">
|
<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.
|
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">
|
<p v-if="speechError" class="mt-2 text-xs text-rose-200/80">
|
||||||
{{ speechError }}
|
{{ speechError }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -537,11 +499,6 @@ function handleClassroomEntry() {
|
|||||||
markClassroomIntroComplete()
|
markClassroomIntroComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToOrientation() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
document.getElementById('orientation')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const voiceMode = ref<VoiceMode>('text')
|
const voiceMode = ref<VoiceMode>('text')
|
||||||
const radioLevel = ref(5)
|
const radioLevel = ref(5)
|
||||||
const hasCompletedRadioCheck = ref(false)
|
const hasCompletedRadioCheck = ref(false)
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
<main class="mx-auto w-full max-w-screen-md px-4 py-10 sm:px-6">
|
<main class="mx-auto w-full max-w-screen-md px-4 py-10 sm:px-6">
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<div v-if="!finished" class="mb-10">
|
<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 items-center gap-3 text-xs text-white/70">
|
||||||
<div class="flex-1 overflow-hidden rounded-full bg-white/10">
|
<div class="flex-1 overflow-hidden rounded-full bg-white/10">
|
||||||
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
|
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
|
||||||
@@ -169,7 +178,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn primary mt-8" @click="saveAnswer({ networkExperience: answers.networkExperience })">
|
<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" />
|
<v-icon icon="mdi-arrow-right" size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +378,10 @@ async function saveAnswer(partial: Record<string, unknown>) {
|
|||||||
currentStep.value = Math.min(currentStep.value + 1, ONBOARDING_TOTAL_STEPS - 1)
|
currentStep.value = Math.min(currentStep.value + 1, ONBOARDING_TOTAL_STEPS - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
currentStep.value = Math.max(0, currentStep.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
function continueCockpit() {
|
function continueCockpit() {
|
||||||
const payload: { simulator: Simulator | null; hardware: HardwareItem[]; os?: string | null } = {
|
const payload: { simulator: Simulator | null; hardware: HardwareItem[]; os?: string | null } = {
|
||||||
simulator: answers.simulator,
|
simulator: answers.simulator,
|
||||||
@@ -396,7 +409,7 @@ function finishAndLeave() {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(CLASSROOM_INTRO_STORAGE_KEY, 'false')
|
localStorage.setItem(CLASSROOM_INTRO_STORAGE_KEY, 'false')
|
||||||
}
|
}
|
||||||
router.replace('/classroom-introduction')
|
router.replace('/start?firstTime=1')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function skip() {
|
async function skip() {
|
||||||
@@ -437,11 +450,29 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.option-card {
|
.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 {
|
.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 {
|
.option-card--sm {
|
||||||
|
|||||||
126
app/pages/start.vue
Normal file
126
app/pages/start.vue
Normal 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>
|
||||||
@@ -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: '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: '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: '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
|
export const ONBOARDING_TOTAL_STEPS = 7
|
||||||
|
|||||||
Reference in New Issue
Block a user