mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 01:06:28 +08:00
feat(ui): clearer onboarding across start, classroom, live-atc & bridge
- index: replace header GitHub link with a "Sign up / Log in" primary button - start: number the modes (1. Classroom / 2. Live ATC), add headset/mic equipment chips, and unlock Live ATC immediately for new accounts - classroom: replace brand logo + experience dropdown with a clear "Back / Switch mode" link back to /start; remove the mic/STT readback input so the readback is text-only again - live-atc: first-run coach mark pointing at the "Report issue" button - bridge: add a "How do you want to train?" split — flight sim via the Bridge vs. a browser-only web version (dry run, no live sim link) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { HoldSelectOption } from '~/components/HoldSelect.vue'
|
||||
import type { DisplayAirportFrequencyEntry } from '~/composables/useFrequencyPresets'
|
||||
import type { ReadbackFieldDetail } from '~/composables/useRadioBackend'
|
||||
@@ -97,6 +97,32 @@ const visibleTabs = computed(() => TABS)
|
||||
const showExpectedComm = computed(
|
||||
() => props.learningMode && Boolean(props.controllerSay || props.expectedPhrase),
|
||||
)
|
||||
|
||||
// First-run coach mark that points pilots at the "Report issue" button. Live ATC
|
||||
// is an early alpha and bug reports are how we improve it, so make it obvious on
|
||||
// the very first visit. Dismissed permanently once seen.
|
||||
const REPORT_HINT_STORAGE_KEY = 'os_live_atc_report_hint_seen'
|
||||
const showReportCoach = ref(false)
|
||||
|
||||
function dismissReportCoach() {
|
||||
showReportCoach.value = false
|
||||
try {
|
||||
window.localStorage.setItem(REPORT_HINT_STORAGE_KEY, '1')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function onReportClick() {
|
||||
dismissReportCoach()
|
||||
emit('open-bug-report')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!window.localStorage.getItem(REPORT_HINT_STORAGE_KEY)) {
|
||||
showReportCoach.value = true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -237,16 +263,38 @@ const showExpectedComm = computed(
|
||||
</v-tooltip>
|
||||
<!-- Report issue carries a filled treatment: it is the most-reached-for
|
||||
secondary tool, so it outranks the neighbouring ghost icons. -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn hud-report-btn"
|
||||
title="Fehler melden"
|
||||
:disabled="bugReportCapturing"
|
||||
@click="emit('open-bug-report')"
|
||||
>
|
||||
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
|
||||
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Report issue' }}</span>
|
||||
</button>
|
||||
<div class="hud-report-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn hud-report-btn"
|
||||
:class="{ 'is-coaching': showReportCoach }"
|
||||
title="Fehler melden"
|
||||
:disabled="bugReportCapturing"
|
||||
@click="onReportClick"
|
||||
>
|
||||
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
|
||||
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Report issue' }}</span>
|
||||
</button>
|
||||
<span v-if="showReportCoach" class="report-coach-ring" aria-hidden="true"></span>
|
||||
<transition name="coach-fade">
|
||||
<div v-if="showReportCoach" class="report-coach" role="dialog" aria-label="Report issues">
|
||||
<span class="report-coach-arrow" aria-hidden="true"></span>
|
||||
<div class="report-coach-body">
|
||||
<div class="report-coach-title">
|
||||
<v-icon size="16">mdi-bug-outline</v-icon>
|
||||
Found a bug?
|
||||
</div>
|
||||
<p class="report-coach-text">
|
||||
Live ATC is an early alpha. Hit <strong>Report issue</strong> anytime something
|
||||
feels off — it captures the session and helps us fix it.
|
||||
</p>
|
||||
<button type="button" class="report-coach-dismiss" @click="dismissReportCoach">
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost hud-icon-btn"
|
||||
@@ -511,6 +559,113 @@ const showExpectedComm = computed(
|
||||
border-color: rgba(180, 83, 9, 0.4);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* ── First-run "report issue" coach mark ─────────────────────────────── */
|
||||
.hud-report-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.hud-report-btn.is-coaching {
|
||||
animation: report-coach-bob 1.6s ease-in-out infinite;
|
||||
}
|
||||
.report-coach-ring {
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(245, 158, 11, 0.8);
|
||||
pointer-events: none;
|
||||
animation: report-coach-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes report-coach-pulse {
|
||||
0% { transform: scale(0.92); opacity: 0.9; }
|
||||
70% { transform: scale(1.18); opacity: 0; }
|
||||
100% { transform: scale(1.18); opacity: 0; }
|
||||
}
|
||||
@keyframes report-coach-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
.report-coach {
|
||||
position: absolute;
|
||||
top: calc(100% + 14px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: 260px;
|
||||
max-width: 78vw;
|
||||
}
|
||||
.report-coach-arrow {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 22px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--panel, #10161f);
|
||||
border-left: 1px solid rgba(245, 158, 11, 0.55);
|
||||
border-top: 1px solid rgba(245, 158, 11, 0.55);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.report-coach-body {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.55);
|
||||
background: var(--panel, #10161f);
|
||||
box-shadow: 0 18px 44px rgba(2, 6, 23, 0.5);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.report-coach-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.report-coach-text {
|
||||
margin: 6px 0 12px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text, #e5e7eb);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.report-coach-text strong {
|
||||
color: #fcd34d;
|
||||
}
|
||||
.report-coach-dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fcd34d;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
transition: background .2s ease, border-color .2s ease;
|
||||
}
|
||||
.report-coach-dismiss:hover {
|
||||
background: rgba(245, 158, 11, 0.28);
|
||||
border-color: rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
.pm-page[data-theme="light"] .report-coach-arrow,
|
||||
.pm-page[data-theme="light"] .report-coach-body {
|
||||
background: #ffffff;
|
||||
}
|
||||
.coach-fade-enter-active,
|
||||
.coach-fade-leave-active {
|
||||
transition: opacity .25s ease, transform .25s ease;
|
||||
}
|
||||
.coach-fade-enter-from,
|
||||
.coach-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hud-report-btn.is-coaching,
|
||||
.report-coach-ring {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.bridge-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,50 +4,18 @@
|
||||
<header class="hud" role="banner">
|
||||
<nav class="hud-inner" aria-label="Global">
|
||||
<div class="hud-left">
|
||||
<button class="hud-logo" type="button" title="Mission hub" @click="panel='hub'">
|
||||
<v-icon size="22" class="hud-logo-icon">mdi-radar</v-icon>
|
||||
</button>
|
||||
<NuxtLink class="hud-back" to="/start" title="Back to mode selection">
|
||||
<v-icon size="20" class="hud-back-icon">mdi-arrow-left</v-icon>
|
||||
<span class="hud-back-body">
|
||||
<span class="hud-back-label">Back</span>
|
||||
<span class="hud-back-sub">Switch mode</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<div class="hud-divider" aria-hidden="true"></div>
|
||||
<span class="brand">OpenSquawk</span>
|
||||
<span class="sep">|</span>
|
||||
<v-menu v-model="experienceMenu" :offset="[0, 8]" location="bottom start" transition="scale-transition">
|
||||
<template #activator="{ props }">
|
||||
<button
|
||||
class="mode-switch"
|
||||
type="button"
|
||||
v-bind="props"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="experienceMenu ? 'true' : 'false'"
|
||||
>
|
||||
<span class="mode-switch-label">{{ activeExperience.label }}</span>
|
||||
<v-icon size="16" class="mode-switch-icon">mdi-chevron-down</v-icon>
|
||||
</button>
|
||||
</template>
|
||||
<div class="experience-menu" role="menu" aria-label="Select experience">
|
||||
<button
|
||||
v-for="option in experiences"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
class="experience-option"
|
||||
:class="{ 'is-active': option.id === activeExperience.id }"
|
||||
:aria-checked="option.id === activeExperience.id"
|
||||
@click="handleExperienceSelect(option)"
|
||||
>
|
||||
<v-icon size="18" class="experience-option-icon">{{ option.icon }}</v-icon>
|
||||
<div class="experience-option-body">
|
||||
<div class="experience-option-title">{{ option.label }}</div>
|
||||
<div v-if="option.description" class="experience-option-sub">{{ option.description }}</div>
|
||||
</div>
|
||||
<v-icon
|
||||
v-if="option.id === activeExperience.id"
|
||||
size="16"
|
||||
class="experience-option-check"
|
||||
>mdi-check
|
||||
</v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</v-menu>
|
||||
<button class="hud-logo" type="button" title="Mission hub" @click="panel='hub'">
|
||||
<v-icon size="18" class="hud-logo-icon">mdi-radar</v-icon>
|
||||
<span class="brand">Classroom</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hud-center">
|
||||
@@ -1086,14 +1054,6 @@
|
||||
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
|
||||
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
|
||||
</v-icon>
|
||||
<v-icon
|
||||
v-if="sttFilledFields[segment.key]"
|
||||
size="14"
|
||||
class="blank-status stt-marker"
|
||||
title="Filled by mic"
|
||||
>
|
||||
mdi-microphone
|
||||
</v-icon>
|
||||
<small v-if="result" class="blank-feedback">
|
||||
Expected: {{ fieldExpectedValue(segment.key) }}
|
||||
</small>
|
||||
@@ -1126,105 +1086,6 @@
|
||||
<v-icon size="18">mdi-volume-high</v-icon>
|
||||
Speak answer
|
||||
</button>
|
||||
<button
|
||||
v-if="sttFeatureVisible"
|
||||
class="btn"
|
||||
:class="sttRecording ? 'danger' : 'ghost'"
|
||||
type="button"
|
||||
:disabled="sttTranscribing"
|
||||
@click="toggleSTTRecording"
|
||||
:title="sttRecording ? 'Stop recording' : 'Speak your readback into the mic'"
|
||||
>
|
||||
<v-icon size="18" :class="{ spin: sttTranscribing }">
|
||||
{{ sttTranscribing ? 'mdi-loading' : (sttRecording ? 'mdi-stop-circle' : 'mdi-microphone') }}
|
||||
</v-icon>
|
||||
<template v-if="sttTranscribing">Transcribing…</template>
|
||||
<template v-else-if="sttRecording">Stop ({{ formatSttDuration(sttRecordingSeconds) }})</template>
|
||||
<template v-else>Speak readback</template>
|
||||
</button>
|
||||
<span
|
||||
v-else-if="sttSupported && !sttServerAvailable"
|
||||
class="muted small stt-hint"
|
||||
title="The speech-to-text backend is currently unreachable"
|
||||
>
|
||||
<v-icon size="14">mdi-microphone-off</v-icon>
|
||||
Speech server unavailable
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- STT panel: shown once we have a transcription, an error, or
|
||||
are actively recording. Lets the pilot review what Whisper
|
||||
heard, edit any mistakes, and re-apply the mapping. -->
|
||||
<div
|
||||
v-if="sttFeatureVisible && (sttRecording || sttTranscribing || sttLastTranscription || sttError)"
|
||||
class="stt-panel"
|
||||
:class="{ 'is-recording': sttRecording, 'is-error': !!sttError }"
|
||||
>
|
||||
<div class="stt-panel-head">
|
||||
<div class="stt-panel-title">
|
||||
<v-icon size="16" :class="{ spin: sttTranscribing }">
|
||||
{{ sttRecording ? 'mdi-microphone' : (sttTranscribing ? 'mdi-loading' : (sttError ? 'mdi-alert-circle' : 'mdi-microphone-message')) }}
|
||||
</v-icon>
|
||||
<template v-if="sttRecording">Recording — {{ formatSttDuration(sttRecordingSeconds) }}</template>
|
||||
<template v-else-if="sttTranscribing">Transcribing your readback…</template>
|
||||
<template v-else-if="sttError">Couldn't transcribe</template>
|
||||
<template v-else>Whisper heard</template>
|
||||
</div>
|
||||
<div v-if="sttLastFillSummary" class="stt-summary">
|
||||
Filled {{ sttLastFillSummary.filled }} / {{ sttLastFillSummary.total }} field{{ sttLastFillSummary.total === 1 ? '' : 's' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-field readback debug: what was recognised vs missing. -->
|
||||
<div v-if="!sttRecording && !sttTranscribing && sttLastReport && sttLastReport.fields.length" class="stt-report">
|
||||
<div
|
||||
v-for="f in sttLastReport.fields"
|
||||
:key="f.key"
|
||||
class="stt-report-row"
|
||||
:class="f.matched ? 'is-ok' : 'is-missing'"
|
||||
>
|
||||
<v-icon size="13">{{ f.matched ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
|
||||
<span class="stt-report-field">{{ f.key }}</span>
|
||||
<span class="stt-report-expected">{{ f.expected || '—' }}</span>
|
||||
<span v-if="f.matched" class="stt-report-via">recognised ({{ f.view }})</span>
|
||||
<span v-else class="stt-report-via">not recognised</span>
|
||||
</div>
|
||||
<div class="stt-report-folded">folded: {{ sttLastReport.denormalized }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sttError" class="stt-error-body">{{ sttError }}</div>
|
||||
|
||||
<div v-else-if="sttRecording || sttTranscribing" class="stt-waiting">
|
||||
<span v-if="sttRecording" class="stt-rec-dot" aria-hidden="true"></span>
|
||||
<span v-if="sttRecording">Speak clearly into your microphone. Click <strong>Stop</strong> when done.</span>
|
||||
<span v-else>Sending audio to the speech server…</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="sttLastTranscription">
|
||||
<textarea
|
||||
v-model="sttEditableTranscription"
|
||||
class="stt-textarea"
|
||||
rows="2"
|
||||
aria-label="Transcribed readback (editable)"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
/>
|
||||
<div class="stt-panel-actions">
|
||||
<button class="btn primary" type="button" @click="applySttTranscription">
|
||||
<v-icon size="16">mdi-arrow-down-bold-circle</v-icon>
|
||||
Apply to fields
|
||||
</button>
|
||||
<button class="btn ghost" type="button" @click="toggleSTTRecording" :disabled="sttTranscribing">
|
||||
<v-icon size="16">mdi-microphone</v-icon>
|
||||
Record again
|
||||
</button>
|
||||
<button class="btn ghost" type="button" @click="clearSttResult">
|
||||
<v-icon size="16">mdi-close</v-icon>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="result" class="score">
|
||||
<div class="score-num">{{ result.score }}%</div>
|
||||
@@ -1926,53 +1787,6 @@ const manualSectionsOpen = reactive<Record<ManualSection, boolean>>({
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
type ExperienceId = 'classroom' | 'live'
|
||||
type ExperienceOption = {
|
||||
id: ExperienceId
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
to: string
|
||||
target?: '_self' | '_blank'
|
||||
matches: (path: string) => boolean
|
||||
}
|
||||
|
||||
const experiences: ExperienceOption[] = [
|
||||
{
|
||||
id: 'classroom',
|
||||
label: 'Classroom',
|
||||
description: 'Mission hub & drills',
|
||||
icon: 'mdi-school',
|
||||
to: '/classroom',
|
||||
target: '_self',
|
||||
matches: path => path.startsWith('/classroom') || path.startsWith('/classroom-introduction')
|
||||
},
|
||||
{
|
||||
id: 'live',
|
||||
label: 'Live ATC',
|
||||
description: 'Live radio with AI controllers',
|
||||
icon: 'mdi-radio-handheld',
|
||||
to: '/bridge',
|
||||
target: '_blank',
|
||||
matches: path => path.startsWith('/live-atc') || path.startsWith('/pm')
|
||||
}
|
||||
]
|
||||
|
||||
const experienceMenu = ref(false)
|
||||
const activeExperience = computed<ExperienceOption>(() => {
|
||||
const path = route.path
|
||||
return experiences.find(option => option.matches(path)) ?? experiences[0]
|
||||
})
|
||||
|
||||
async function handleExperienceSelect(option: ExperienceOption) {
|
||||
experienceMenu.value = false
|
||||
if (option.matches(route.path)) return
|
||||
if (option.target === '_blank') {
|
||||
window.open(option.to, '_blank')
|
||||
return
|
||||
}
|
||||
await router.push(option.to)
|
||||
}
|
||||
|
||||
const ROUTE_STATE_KEYS = ['panel', 'module', 'stage', 'lesson', 'plan'] as const
|
||||
type RouteStateKey = typeof ROUTE_STATE_KEYS[number]
|
||||
@@ -5244,16 +5058,64 @@ onBeforeUnmount(() => {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.hud-logo {
|
||||
.hud-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 14px 0 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: border-color .2s ease, background .2s ease, color .2s ease;
|
||||
}
|
||||
|
||||
.hud-back:hover,
|
||||
.hud-back:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 54%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hud-back:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.hud-back-icon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.hud-back-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.hud-back-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hud-back-sub {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .12em;
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.hud-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--t2);
|
||||
transition: border-color .2s ease, background .2s ease, color .2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,20 @@
|
||||
<v-icon icon="mdi-school-outline" size="20" class="text-cyan-200" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-base font-semibold text-white">Classroom</p>
|
||||
<p class="text-base font-semibold text-white">1. 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 class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="equip-chip equip-on">
|
||||
<v-icon icon="mdi-headphones" size="14" />
|
||||
Headphones
|
||||
</span>
|
||||
<span class="equip-chip equip-off">
|
||||
<v-icon icon="mdi-microphone-off" size="14" />
|
||||
No mic needed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="btn primary mt-1 self-start">
|
||||
Start Classroom
|
||||
@@ -67,11 +77,21 @@
|
||||
<v-icon icon="mdi-radar" size="20" class="text-amber-200" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-base font-semibold text-white">Live ATC</p>
|
||||
<p class="text-base font-semibold text-white">2. 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 class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="equip-chip equip-on equip-amber">
|
||||
<v-icon icon="mdi-headphones" size="14" />
|
||||
Headphones
|
||||
</span>
|
||||
<span class="equip-chip equip-on equip-amber">
|
||||
<v-icon icon="mdi-microphone" size="14" />
|
||||
Microphone
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="btn soft mt-1 self-start">
|
||||
Try Live ATC (Alpha)
|
||||
@@ -100,11 +120,21 @@
|
||||
<v-icon icon="mdi-radar" size="20" class="text-white/50" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-base font-semibold text-white">Live ATC</p>
|
||||
<p class="text-base font-semibold text-white">2. 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 class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="equip-chip equip-on">
|
||||
<v-icon icon="mdi-headphones" size="14" />
|
||||
Headphones
|
||||
</span>
|
||||
<span class="equip-chip equip-on">
|
||||
<v-icon icon="mdi-microphone" size="14" />
|
||||
Microphone
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-white/40">Unlocks {{ unlockEstimate }} after signup</p>
|
||||
</div>
|
||||
@@ -116,7 +146,6 @@
|
||||
<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' })
|
||||
@@ -125,29 +154,12 @@ 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`
|
||||
})
|
||||
// Both modes are unlocked immediately on signup.
|
||||
const liveAtcUnlocked = computed(() => true)
|
||||
const unlockEstimate = computed(() => '')
|
||||
|
||||
function goToClassroom() {
|
||||
const introCompleted = typeof window !== 'undefined' && localStorage.getItem(CLASSROOM_INTRO_STORAGE_KEY) === 'true'
|
||||
@@ -155,3 +167,38 @@ function goToClassroom() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.equip-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.equip-chip.equip-on {
|
||||
border-color: rgba(103, 232, 249, 0.4);
|
||||
background: rgba(34, 211, 238, 0.12);
|
||||
color: rgb(165, 243, 252);
|
||||
}
|
||||
|
||||
.equip-chip.equip-amber {
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: rgb(253, 230, 138);
|
||||
}
|
||||
|
||||
.equip-chip.equip-off {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user