mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +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"
|
||||
>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)
|
||||
|
||||
@@ -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
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: '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
|
||||
|
||||
Reference in New Issue
Block a user