Files
OpenSquawk/app/pages/index.vue
2025-10-18 14:03:14 +02:00

2377 lines
88 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="bg-[#0b1020] text-white antialiased selection:bg-cyan-400/30">
<!-- NAV -->
<header ref="headerRef"
class="fixed left-0 right-0 top-0 z-50 bg-[#0b1020]/70 backdrop-blur border-b border-white/10"
data-aos="fade-down">
<nav class="container-outer flex items-center justify-between py-3">
<NuxtLink to="#" class="flex items-center gap-2 font-semibold tracking-tight">
<v-icon icon="mdi-radar" size="28" class="text-cyan-400"/>
<span class="text-white">OpenSquawk</span>
</NuxtLink>
<div class="hidden lg:flex items-center gap-6 text-sm">
<NuxtLink
v-for="item in navLinks"
:key="item.to"
:to="item.to"
:class="['transition-colors hover:text-cyan-300', isNavLinkActive(item.to) ? 'text-cyan-300' : 'text-white/70']"
@click.prevent="handleAnchorNavigation(item.to)"
>
{{ item.label }}
</NuxtLink>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<NuxtLink
to="/login"
class="btn btn-primary whitespace-nowrap btn-compact"
aria-label="Login"
>
<v-icon icon="mdi-login" size="18"/>
<span class="hidden sm:inline">Login</span>
</NuxtLink>
<span
class="hidden lg:inline-block text-sm text-white/70"
title="OpenSquawk is open-source on GitHub"
>
<NuxtLink
:to="GITHUB_URL"
external
target="_blank"
rel="noopener"
class="btn btn-ghost btn-compact"
>
<v-icon icon="mdi-github"/>
GitHub
</NuxtLink></span>
<button
type="button"
class="mobile-toggle lg:hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b1020]"
:aria-expanded="isMobileNavOpen ? 'true' : 'false'"
aria-controls="mobile-navigation"
aria-label="Toggle navigation"
@click="toggleMobileNav"
>
<span class="sr-only">Toggle navigation</span>
<span class="hamburger" :class="{ 'is-open': isMobileNavOpen }">
<span class="hamburger-bar"></span>
<span class="hamburger-bar"></span>
<span class="hamburger-bar"></span>
</span>
</button>
</div>
</nav>
<Transition name="mobile-nav">
<div
v-if="isMobileNavOpen"
id="mobile-navigation"
class="lg:hidden border-t border-white/10 bg-[#0b1020]/95 backdrop-blur"
>
<div class="container-outer py-4 space-y-4">
<nav class="grid gap-2 text-sm">
<NuxtLink
v-for="item in mobileNavLinks"
:key="`mobile-${item.to}`"
:to="item.to"
:external="item.external"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener' : undefined"
class="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white/80 transition hover:bg-white/10"
@click="handleMobileNavLinkClick($event, item)"
>
<span class="flex items-center gap-3">
<v-icon v-if="item.icon" :icon="item.icon" size="18" class="text-white/60"/>
<span>{{ item.label }}</span>
</span>
<v-icon icon="mdi-chevron-right" size="18" class="text-white/60"/>
</NuxtLink>
</nav>
<div class="grid gap-2">
<NuxtLink to="/login" class="btn btn-primary w-full" @click="closeMobileNav">
<v-icon icon="mdi-login" size="18"/>
Login
</NuxtLink>
</div>
</div>
</div>
</Transition>
</header>
<!-- HERO -->
<section class="gradient-hero relative overflow-hidden">
<div class="hero-overlay absolute inset-0 pointer-events-none">
<div class="absolute -top-24 right-0 w-[600px] h-[600px] bg-cyan-500/10 rounded-full blur-3xl"/>
</div>
<div class="container-outer relative z-10 mt-8 pt-14 pb-10 sm:pt-20 sm:pb-16 md:pt-24 md:pb-20">
<div
class="hero-grid grid gap-10 lg:my-20 xl:gap-16 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)] lg:items-center">
<div class="hero-copy max-w-2xl" data-aos="fade-up">
<span class="chip mb-4">In development · Live AI ATC</span>
<h1 class="text-3xl sm:text-5xl md:text-6xl font-semibold leading-tight">
OpenSquawk<br/>
<span class="text-cyan-400">Live speech ATC for simulator pilots</span>
</h1>
<p class="mt-4 sm:mt-6 text-white/80 text-base sm:text-lg">
Push-to-talk in your sim, get authentic instructions back. Speech-to-text, an AI-guided state machine and
realistic text-to-speech keep every exchange inside real-world procedures.
</p>
<ul class="mt-6 space-y-2 text-white/70 text-sm sm:text-base">
<li class="flex items-start gap-2">
<v-icon icon="mdi-microphone" size="18" class="mt-[3px] text-cyan-300"/>
<span>Built for natural radio without losing procedural control.</span>
</li>
<li class="flex items-start gap-2">
<v-icon icon="mdi-microsoft" size="18" class="mt-[3px] text-cyan-300"/>
<span>MSFS plug-in streams flight state so the controller can react</span>
</li>
<li class="flex items-start gap-2">
<v-icon icon="mdi-package-variant" size="18" class="mt-[3px] text-cyan-300"/>
<span><strong>Product lineup</strong>: Live ATC (in development) and Classroom listening drills (alpha, invite only)</span>
</li>
</ul>
<p class="mt-2 text-sm text-white/70">
We build in the open and welcome collaborators engineers, ATC SMEs, voice nerds and sim pilots. Say hi
at
<a class="text-cyan-300 underline" href="mailto:info@opensquawk.de">info@opensquawk.de</a>.
</p>
<div class="mt-6 flex flex-col gap-2.5 sm:flex-row sm:items-center sm:gap-3">
<NuxtLink to="#cta" class="btn btn-primary text-base">
<v-icon icon="mdi-account-plus" size="20"/>
Join the Live ATC waitlist
</NuxtLink>
<NuxtLink to="#classroom" class="btn btn-ghost text-base">
<v-icon icon="mdi-school" size="20"/>
Classroom alpha info
</NuxtLink>
</div>
</div>
<div
class="hero-form w-full max-w-lg lg:justify-self-end"
data-aos="fade-left"
data-aos-delay="140"
>
<form class="hero-form-panel rounded-3xl p-5 sm:p-6 space-y-4"
@submit.prevent="submitUpdates">
<div class="space-y-3">
<h3 class="text-2xl font-semibold">
Hear about new features first!
</h3>
<p class="text-sm text-white/70">
Join the feature list and get updates on releases, drops & classroom content.
</p>
</div>
<div class="flex flex-col gap-3 py-2 sm:flex-row">
<input
v-model.trim="updatesForm.email"
type="email"
required
placeholder="your@email"
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
aria-label="Email for product updates"
/>
<button
type="submit"
class="btn btn-primary w-full sm:w-auto lg:w-48"
:disabled="updatesSubmitting"
>
<span v-if="updatesSubmitting" class="flex items-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white"/>
Saving
</span>
<span v-else class="flex items-center gap-2 w-28 justify-center">
<v-icon icon="mdi-bell-ring" size="18"/>
Notify me
</span>
</button>
</div>
<div
v-if="updatesCaptchaVisible"
class="space-y-2 rounded-2xl border border-white/10 bg-white/5 p-4 text-xs text-white/60"
>
<p class="text-sm font-medium text-white">Aviation captcha</p>
<p>
Answer with real ATC knowledge:
<span class="text-cyan-300">{{ updatesCaptcha.challenge.prompt }}</span>
</p>
<input
v-model.trim="updatesCaptcha.answer"
type="text"
required
aria-label="Aviation captcha answer for product updates"
placeholder="Type the answer"
class="w-full rounded-2xl border border-white/10 bg-[#0b1020]/40 px-4 py-3 text-sm text-white placeholder-white/40 outline-none focus:border-cyan-400"
/>
<p v-if="updatesCaptchaReminder" class="text-xs text-amber-200">
{{ updatesCaptchaReminder }}
</p>
<p v-else-if="updatesCaptcha.answer && !updatesCaptchaValid" class="text-xs text-red-300">
Check the aviation answer and try again.
</p>
</div>
<div class="space-y-2 text-xs text-white/60">
<label class="flex items-start gap-3">
<input type="checkbox" v-model="updatesForm.consentMarketing" class="mt-1" required/>
<span>
Yes, email me when new features, waitlist drops or classroom content go live.
</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" v-model="updatesForm.consentPrivacy" class="mt-1" required/>
<span>
I have read the
<NuxtLink to="/datenschutz" class="text-cyan-300 underline">privacy policy</NuxtLink>.
</span>
</label>
</div>
<p v-if="updatesSuccess" class="text-sm text-green-300">
Thanks! We will let you know when new features go live.
</p>
<p v-else-if="updatesError" class="text-sm text-red-300">{{ updatesError }}</p>
<p v-else class="text-xs text-white/50">
No spam only relevant product updates. Unsubscribe any time.
</p>
</form>
</div>
</div>
</div>
</section>
<!-- SOCIAL PROOF / LOGOS -->
<section class="py-8 sm:py-10 border-t border-white/10 bg-[#0a0f1c]" data-aos="fade-up">
<div class="container-outer">
<div
class="grid grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6 items-center opacity-80 text-xs sm:text-sm md:text-base">
<div class="flex items-center justify-center gap-2 text-white/60">
<v-icon icon="mdi-radar" class="opacity-70"/>
Live ATC · in development
</div>
<div class="flex items-center justify-center gap-2 text-white/60">
<v-icon icon="mdi-school" class="opacity-70"/>
Classroom · alpha invites running
</div>
<div class="flex items-center justify-center gap-2 text-white/60">
<v-icon icon="mdi-waveform" class="opacity-70"/>
STT LLM TTS Pipeline
</div>
<div class="flex items-center justify-center gap-2 text-white/60">
<v-icon icon="mdi-microsoft" class="opacity-70"/>
MSFS 2020 first · 2024+ roadmap
</div>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" class="py-12 sm:py-16 md:py-24 bg-gradient-to-b from-[#0a0f1c] to-[#0b1020]">
<div class="container-outer">
<div class="max-w-2xl mb-10" data-aos="fade-up">
<h2 class="text-3xl md:text-4xl font-semibold">
Built for real-sounding, state-aware radio work
</h2>
<p class="mt-3 text-white/80">
OpenSquawk combines open tooling with domain rules so every call feels like talking to a human controller
without giving the LLM free rein.
</p>
</div>
<div class="grid gap-4 md:grid-cols-3 md:gap-6">
<div class="card" data-aos="fade-up" data-aos-delay="0">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20">
<v-icon icon="mdi-waveform" class="text-cyan-300"/>
</div>
<h3 class="font-semibold text-lg">
Speech-in, speech-out
</h3>
</div>
<p class="mt-3 text-white/80">
Whisper-class STT cleans up radio audio, the LLM matches it to our curated decision tree and Coqui/Piper
voices read back the exact instruction.
</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="100">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20">
<v-icon icon="mdi-airplane-takeoff" class="text-cyan-300"/>
</div>
<h3 class="font-semibold text-lg">
Flight-state aware logic
</h3>
</div>
<p class="mt-3 text-white/80">
Our MSFS plug-in mirrors heading, altitude, flight plan and traffic data into the controller so ATC can
vector, warn or hand off even without a pilot call.
</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20">
<v-icon icon="mdi-source-repository" class="text-cyan-300"/>
</div>
<h3 class="font-semibold text-lg">
Open and auditable
</h3>
</div>
<p class="mt-3 text-white/80">
Everything ships as open source so simmers, instructors and researchers can inspect prompts, rules and
voices or self-host the full stack.
</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 md:gap-6 mt-6">
<div class="card" data-aos="fade-up" data-aos-delay="0">
<h3 class="font-semibold text-lg">
Simulator roadmap
</h3>
<p class="mt-3 text-white/80">
Live ATC launches with Microsoft Flight Simulator on Windows. We are mapping the same telemetry bridge to
MSFS 2024, then X-Plane and later FlightGear (Win/Linux/macOS).
</p>
<ul class="mt-3 space-y-2 text-white/70 text-sm list-disc list-inside">
<li>MSFS 2020: plug-in prototype streaming flight data & push-to-talk audio.</li>
<li>MSFS 2024: compatibility update shortly after release.</li>
<li>X-Plane & FlightGear: design underway, targeting Windows first, then Linux/macOS.</li>
</ul>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="100">
<h3 class="font-semibold text-lg">
Classroom alpha today
</h3>
<p class="mt-3 text-white/80">
The Classroom mode already runs listening drills: play ATC clips, extract clearances, compare with the
answer key and repeat until it sticks.
</p>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-headset"/>
Hearing exercises
</div>
<div class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-clipboard-text"/>
Instant answer check
</div>
<div class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-account-hard-hat"/>
Invite-only alpha
</div>
<div class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-source-branch"/>
Open source code
</div>
</div>
</div>
</div>
</div>
</section>
<!-- NEWS -->
<section id="news" class="py-12 sm:py-16 md:py-24 bg-[#0b1020] border-y border-white/10">
<div class="container-outer space-y-8">
<div class="max-w-2xl" data-aos="fade-up">
<h2 class="text-3xl md:text-4xl font-semibold">News & Updates</h2>
<p class="mt-3 text-white/80">
Content-driven posts keep you in the loop. All articles are stored in Markdown new stories show up here
automatically.
</p>
</div>
<div v-if="latestNews.length" class="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="post in latestNews"
:key="post.slug"
class="card flex flex-col gap-4"
data-aos="fade-up"
>
<div class="space-y-2">
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
<h3 class="text-xl font-semibold">{{ post.title }}</h3>
<p class="text-sm text-white/70">{{ post.excerpt }}</p>
</div>
<div class="mt-auto flex items-center justify-between text-xs text-white/60">
<span>{{ post.readingTime }}</span>
<NuxtLink :to="`/news/${post.slug}`" class="text-cyan-300 text-sm font-medium hover:underline">
Read more
</NuxtLink>
</div>
</article>
</div>
<div v-else class="card text-sm text-white/70" data-aos="fade-up">
No news published yet the first post is coming soon.
</div>
<div class="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:gap-3" data-aos="fade-up">
<NuxtLink to="/news" class="btn btn-ghost">
View all news
</NuxtLink>
<span class="text-xs text-white/50 sm:text-left text-center">
Tell us if you want to share your own experience.
</span>
</div>
</div>
</section>
<!-- ROADMAP -->
<section id="roadmap" class="py-12 sm:py-16 md:py-24 bg-[#0b1020] border-y border-white/10">
<div class="container-outer space-y-10">
<div class="max-w-3xl" data-aos="fade-up">
<h2 class="text-3xl md:text-4xl font-semibold">
Roadmap & community voting
</h2>
<p class="mt-3 text-white/80">
Vote on what should take priority next. We combine votes with timestamps to plan features for training,
immersion and infrastructure.
</p>
<div
class="mt-4 inline-flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/70">
<v-icon icon="mdi-account-group" size="18" class="text-cyan-300"/>
<span>{{ roadmapStatsLabel }}</span>
</div>
<div class="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<button
type="button"
class="btn btn-ghost w-full sm:w-auto"
@click="toggleRoadmap"
:aria-expanded="roadmapExpanded"
>
<span v-if="roadmapExpanded">Hide roadmap details</span>
<span v-else>Show roadmap details</span>
</button>
<span v-if="!roadmapExpanded" class="text-xs text-white/50 text-center sm:text-left">
Expand to explore priorities and submit your votes.
</span>
</div>
</div>
<div v-if="roadmapExpanded" class="space-y-6">
<div v-if="roadmapLoading" class="card text-white/70" data-aos="fade-up">
Loading current roadmap priorities
</div>
<template v-else>
<div class="grid gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-3">
<div
v-for="item in roadmapItems"
:key="item.key"
class="card flex flex-col gap-4"
data-aos="fade-up"
>
<div class="flex items-start justify-between gap-3">
<div>
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ item.category }}</span>
<h3 class="mt-2 flex items-center gap-2 text-lg font-semibold">
<v-icon v-if="item.icon" :icon="item.icon" size="22" class="text-cyan-300"/>
<span>{{ item.title }}</span>
</h3>
</div>
<div class="text-right text-xs text-white/60 space-y-1">
<div class="text-sm font-semibold text-white">
<template v-if="item.averageImportance !== null">
{{ formatAverage(item.averageImportance) }}/5
</template>
<template v-else></template>
</div>
<div>
<span>{{ formatNumber(item.votes) }} votes</span>
</div>
<div v-if="item.lastVoteAt" class="text-white/40">
<span>last vote {{ formatRelativeFromNow(item.lastVoteAt) }}</span>
</div>
<div v-else class="text-white/40">
no votes yet
</div>
</div>
</div>
<p class="text-sm text-white/70">{{ item.description }}</p>
<div class="h-2 w-full overflow-hidden rounded-full bg-white/10">
<div class="h-2 rounded-full bg-cyan-400 transition-all" :style="{ width: `${item.scorePercent}%` }"/>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between text-[11px] uppercase tracking-[0.3em] text-white/50">
<span>Choose priority</span>
<span v-if="roadmapTouched[item.key]" class="text-cyan-300">
marked
</span>
</div>
<div
class="roadmap-scale"
role="group"
:aria-label="`Priority for ${item.title}`"
>
<button
v-for="value in ROADMAP_SCALE"
:key="value"
type="button"
class="roadmap-pill"
:class="{ 'is-active': roadmapSelections[item.key] === value }"
:aria-pressed="roadmapSelections[item.key] === value"
@click="selectRoadmapImportance(item.key, value)"
>
<span class="text-sm font-semibold">{{ value }}</span>
<span class="label">{{ roadmapImportanceShortLabel(value) }}</span>
</button>
</div>
<div class="text-sm text-white/80">
{{ roadmapImportanceLabel(roadmapSelections[item.key]) }}
</div>
</div>
</div>
</div>
<div
class="card flex flex-col gap-4 border-white/10 bg-white/5 md:flex-row md:items-center md:justify-between"
data-aos="fade-up">
<div class="space-y-2">
<h4 class="text-lg font-semibold">
Save your selection
</h4>
<p class="text-sm text-white/70">
We store every vote individually with a timestamp, so we see what matters right now. Adjust several
cards and submit everything together.
</p>
</div>
<div class="flex w-full flex-col gap-2 md:w-auto">
<button
type="button"
class="btn btn-primary w-full md:w-auto"
@click="submitRoadmapVotes"
:disabled="!hasRoadmapVote || roadmapSubmitting"
>
<span v-if="roadmapSubmitting" class="flex items-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white"/>
Saving votes
</span>
<span v-else>Submit votes</span>
</button>
<p v-if="roadmapSuccess" class="text-center text-sm text-green-300 md:text-left">
Thanks! Your votes have been recorded.
</p>
<p v-else-if="roadmapError" class="text-center text-sm text-red-300 md:text-left">{{ roadmapError }}</p>
<p v-else class="text-center text-xs text-white/50 md:text-left">
Tip: Only adjust priorities when you have feedback for that topic.
</p>
</div>
</div>
</template>
<div class="card space-y-4" data-aos="fade-up">
<div class="space-y-2">
<h4 class="text-lg font-semibold">
Missing something on the roadmap?
</h4>
<p class="text-sm text-white/70">
Share the feature you are missing or a problem we should solve. We prioritise community ideas.
</p>
</div>
<form class="space-y-3" @submit.prevent="submitRoadmapSuggestion">
<input
v-model.trim="roadmapSuggestionForm.title"
type="text"
required
placeholder="Short title e.g. ATIS integration for EDDF"
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
aria-label="Title for roadmap suggestion"
/>
<textarea
v-model.trim="roadmapSuggestionForm.details"
rows="4"
required
placeholder="Describe why this matters or how it would help you."
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
/>
<div class="grid gap-3 sm:grid-cols-2">
<input
v-model.trim="roadmapSuggestionForm.email"
type="email"
placeholder="Email (optional)"
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
/>
<label class="flex items-start gap-3 text-xs text-white/60">
<input
type="checkbox"
v-model="roadmapSuggestionForm.allowContact"
class="mt-1"
:disabled="!roadmapSuggestionForm.email"
/>
<span>
Feel free to follow up by email if questions pop up.
</span>
</label>
</div>
<label class="flex items-start gap-3 text-xs text-white/60">
<input type="checkbox" v-model="roadmapSuggestionForm.consentPrivacy" class="mt-1" required/>
<span>
I have read the <NuxtLink to="/datenschutz"
class="text-cyan-300 underline">privacy policy</NuxtLink> and consent to the processing of my suggestion.
</span>
</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<button
type="submit"
class="btn btn-primary w-full sm:w-auto"
:disabled="!roadmapSuggestionFormValid || roadmapSuggestionSubmitting"
>
<span v-if="roadmapSuggestionSubmitting" class="flex items-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white"/>
Sending suggestion
</span>
<span v-else>Send suggestion</span>
</button>
<p v-if="roadmapSuggestionSuccess" class="text-sm text-green-300">
Thanks! We will review your suggestion and follow up if needed.
</p>
<p v-else-if="roadmapSuggestionError" class="text-sm text-red-300">{{ roadmapSuggestionError }}</p>
<p v-else class="text-xs text-white/50">
We read every suggestion personally. The optional contact field is only used for follow-ups.
</p>
</div>
</form>
</div>
</div>
</div>
</section>
<!-- CLASSROOM -->
<section id="classroom" class="py-12 sm:py-16 md:py-24 bg-[#0b1020]">
<div class="container-outer">
<div class="grid gap-6 md:grid-cols-2 md:gap-8 items-center">
<div data-aos="fade-right">
<h2 class="text-3xl md:text-4xl font-semibold">
Classroom
</h2>
<p class="mt-3 text-white/80">
Invitation-only cohorts practise listening comprehension: extract the clearance, compare with the answer
key and repeat until every call sticks.
</p>
<ol class="mt-5 space-y-3 text-white/80">
<li class="flex gap-3"><span class="chip">1</span><span><b>Basics</b> (concept): numbers, letters, standard readbacks.</span>
</li>
<li class="flex gap-3"><span class="chip">2</span><span><b>Simples</b> (alpha): taxi, takeoff, initial climb, basic vectors.</span>
</li>
<li class="flex gap-3"><span class="chip">3</span><span><b>Patterns</b> (bravo): full pattern work, touch-and-go, exit instructions.</span>
</li>
<li class="flex gap-3"><span class="chip">5</span><span><b>Full flight</b> clearance to destination, enroute calls, approach handoff.</span>
</li>
</ol>
<div class="mt-6 flex flex-col gap-2.5 sm:flex-row sm:gap-3">
<NuxtLink to="#cta" class="btn btn-primary">
Join Live ATC waitlist
</NuxtLink>
<a href="mailto:info@opensquawk.de" class="btn btn-ghost">
Request a Classroom invite
</a>
</div>
</div>
<NuxtImg src="/img/landing/simulator.jpeg" alt="Runway" class="rounded-lg card w-full object-cover" format="webp" />
</div>
</div>
</section>
<!-- PRICING -->
<section id="pricing" class="py-12 sm:py-16 md:py-24 bg-gradient-to-b from-[#0b1020] to-[#0a0f1c]">
<div class="container-outer">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4" data-aos="fade-up">
<div class="max-w-2xl">
<h2 class="text-3xl md:text-4xl font-semibold">
Product lineup & availability
</h2>
<p class="mt-3 text-white/80">
Two experiences, one stack. Classroom is flying today via invitation, Live ATC is the flagship in active
development and the open-source core stays free to inspect or host yourself.
</p>
</div>
</div>
<div class="mt-8 grid gap-4 md:grid-cols-3 md:gap-6 items-stretch">
<!-- Live ATC -->
<div class="card relative flex flex-col h-full pricing-card" data-aos="fade-up" data-aos-delay="0">
<div class="absolute -top-3 right-4 chip">Flagship</div>
<div class="flex flex-col flex-1">
<div class="flex flex-col flex-1">
<h3 class="text-xl font-semibold">
Live ATC (in development)
</h3>
<p class="mt-2 text-white/80 flex-1">
AI controller that listens to your PTT call, consults the state machine and answers with authentic
phraseology. Perfect for solo IFR/VFR practice when networks are offline.
</p>
<div class="mt-5 text-3xl font-semibold">
Closed alpha<span class="text-white/60 text-sm font-normal"> · waitlist open</span>
</div>
<ul class="mt-5 space-y-3 text-sm">
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>PTT STT LLM decision tree TTS pipeline</span>
</li>
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>MSFS 2020 plug-in streams live telemetry</span>
</li>
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Proactive calls for spacing, altitude and handoffs</span>
</li>
<li class="flex items-start gap-3 text-white/60">
<v-icon icon="mdi-close-circle" size="18" class="text-red-400 mt-[2px]"/>
<span>Public download (planned post-alpha)</span>
</li>
<li class="flex items-start gap-3 text-white/60">
<v-icon icon="mdi-close-circle" size="18" class="text-red-400 mt-[2px]"/>
<span>X-Plane / FlightGear builds (roadmap)</span>
</li>
</ul>
</div>
<NuxtLink to="#cta" class="btn btn-primary w-full mt-6">
Join the waitlist
</NuxtLink>
</div>
</div>
<!-- Classroom -->
<div
class="card border-2 border-cyan-400/40 relative flex flex-col h-full pricing-card shadow-[0_0_40px_rgba(34,211,238,.25)]"
data-aos="fade-up"
data-aos-delay="100"
>
<div class="absolute -top-3 right-4 chip bg-cyan-500/30 border-cyan-400/50">
Live today
</div>
<div class="flex flex-col flex-1">
<div class="flex flex-col flex-1">
<h3 class="text-xl font-semibold">Classroom (alpha invitation)</h3>
<p class="mt-2 text-white/80 flex-1">
Listening drills that sharpen comprehension: decode clearances, practise readbacks and compare against
official transcripts.
</p>
<div class="mt-5 text-3xl font-semibold">
Invitation only<span class="text-white/60 text-sm font-normal"> · alpha</span>
</div>
<ul class="mt-5 space-y-3 text-sm">
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Scenario packs: Ground, Departure, Approach</span>
</li>
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Instant feedback on key details (altitude, squawk, headings)</span>
</li>
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Invite-based cohorts for structured feedback</span>
</li>
<li class="flex items-start gap-3 text-white/60">
<v-icon icon="mdi-close-circle" size="18" class="text-red-400 mt-[2px]"/>
<span>Public signup (planned)</span>
</li>
<li class="flex items-start gap-3 text-white/60">
<v-icon icon="mdi-close-circle" size="18" class="text-red-400 mt-[2px]"/>
<span>Offline package download (researching)</span>
</li>
</ul>
</div>
<NuxtLink to="#classroom" class="btn btn-primary w-full mt-6">
Request an invite
</NuxtLink>
</div>
</div>
<div class="card relative flex flex-col h-full pricing-card" data-aos="fade-up" data-aos-delay="200">
<div class="absolute -top-3 right-4 chip">Open source</div>
<div class="flex flex-col flex-1">
<div class="flex flex-col flex-1">
<h3 class="text-xl font-semibold">
Self-host & contribute
</h3>
<p class="mt-2 text-white/80 flex-1">
Clone the repo, run Docker and modify the controller logic. Ideal for researchers, virtual airlines or
devs who want a fully local stack.
</p>
<div class="mt-5 text-3xl font-semibold">
0<span class="text-white/60 text-sm font-normal"> / always</span>
</div>
<ul class="mt-5 space-y-3 text-sm">
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Nuxt UI, Node services, STT/LLM/TTS pipeline all open</span>
</li>
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Decision tree & prompts ready for experimentation</span>
</li>
<li class="flex items-start gap-3 text-white/80">
<v-icon icon="mdi-check-circle" size="18" class="text-emerald-400 mt-[2px]"/>
<span>Plugin SDK hooks for future sims (X-Plane, FlightGear)</span>
</li>
<li class="flex items-start gap-3 text-white/60">
<v-icon icon="mdi-close-circle" size="18" class="text-red-400 mt-[2px]"/>
<span>Managed hosting or SLA</span>
</li>
<li class="flex items-start gap-3 text-white/60">
<v-icon icon="mdi-close-circle" size="18" class="text-red-400 mt-[2px]"/>
<span>Cloud voice/STT credits included</span>
</li>
</ul>
</div>
<NuxtLink
:to="GITHUB_URL"
external
target="_blank"
rel="noopener"
class="btn btn-ghost w-full mt-6"
>
View repository
</NuxtLink>
</div>
</div>
</div>
<div class="mt-8 text-sm text-white/70 space-y-2" data-aos="fade-up" data-aos-delay="300">
<p>
Live ATC alpha runs offline-first while we research licensing for networks such as VATSIM and IVAO. We will
only enable direct connectivity once community rules and legal reviews are satisfied.
</p>
<p>
Until then we double down on the state machine, simulator plug-ins and new Classroom scenarios shaped by
roadmap votes and alpha feedback.
</p>
</div>
</div>
</section>
<!-- OPEN SOURCE -->
<section id="opensource" class="py-12 sm:py-16 md:py-24 bg-[#0a0f1c]">
<div class="container-outer">
<div class="grid gap-6 md:grid-cols-2 md:gap-8 items-center">
<div data-aos="fade-right">
<h2 class="text-3xl md:text-4xl font-semibold">
Open-source, community-driven
</h2>
<p class="mt-3 text-white/80">
Transparent architecture, clear roadmap, open issues. Build your own voices, integrations and workflows.
</p>
<ul class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<li class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-license"/>
AGPL v3 license
</li>
<li class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-docker"/>
Dockerbased microservices
</li>
<li class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-puzzle"/>
Plugin SDK for sims
</li>
<li class="glass rounded-xl p-3 flex items-center gap-2">
<v-icon icon="mdi-console"/>
REST API for extensions
</li>
</ul>
<div class="mt-6 flex flex-col sm:flex-row gap-3">
<NuxtLink to="#cta" class="btn btn-primary">
Get involved
</NuxtLink>
<NuxtLink
to="https://github.com/OpenSquawk/OpenSquawk"
external
target="_blank"
rel="noopener"
class="btn btn-ghost"
>
GitHub repository
</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost">News</NuxtLink>
</div>
</div>
<div class="card" data-aos="fade-left">
<pre class="text-xs md:text-sm overflow-x-auto"><code>// Example: REST route for taxi paths
POST /api/route/taxi
{
"icao": "EDDF",
"from": { "type": "gate", "ref": "A20" },
"to": { "type": "runway", "ref": "25C" }
}
// → Response: list of segments, geometry, suggested readback</code></pre>
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section class="py-12 sm:py-16 md:py-24 bg-[#0b1020]">
<div class="container-outer">
<div class="max-w-2xl mb-10" data-aos="fade-up">
<h2 class="text-3xl md:text-4xl font-semibold">
Radio loop in four tightly controlled stages
</h2>
<p class="mt-3 text-white/80">
Every transmission passes through deterministic checkpoints so phraseology stays believable and safe.
</p>
</div>
<div class="grid md:grid-cols-4 gap-4 md:gap-6">
<div class="card" data-aos="fade-up" data-aos-delay="0">
<h3 class="font-semibold flex items-center gap-2">
<v-icon icon="mdi-radio-handheld"/>
1 · Capture
</h3>
<p class="mt-2 text-white/80">Push-to-talk audio plus simulator telemetry stream into the engine.</p></div>
<div class="card" data-aos="fade-up" data-aos-delay="100">
<h3 class="font-semibold flex items-center gap-2">
<v-icon icon="mdi-waveform"/>
2 · Understand
</h3>
<p class="mt-2 text-white/80">Whisper-class STT normalises radio noise; the LLM interprets intent.</p></div>
<div class="card" data-aos="fade-up" data-aos-delay="200">
<h3 class="font-semibold flex items-center gap-2">
<v-icon icon="mdi-brain"/>
3 · Decide
</h3>
<p class="mt-2 text-white/80">A curated state machine enforces valid clearances and cross-checks flight
data.</p></div>
<div class="card" data-aos="fade-up" data-aos-delay="300">
<h3 class="font-semibold flex items-center gap-2">
<v-icon icon="mdi-microphone"/>
4 · Speak
</h3>
<p class="mt-2 text-white/80">Coqui/Piper TTS deliver regionalised voices with crisp numbers.</p></div>
</div>
</div>
</section>
<!-- CONTRIBUTING -->
<section id="contributing" class="py-12 sm:py-16 md:py-24 bg-[#0a0f1c] border-y border-white/10">
<div class="container-outer">
<div class="grid gap-6 md:grid-cols-2 md:gap-8 items-start">
<div data-aos="fade-right">
<h2 class="text-3xl md:text-4xl font-semibold">
Contribute
</h2>
<p class="mt-3 text-white/80">
Issues with the label <code
class="text-xs bg-white/10 px-1.5 py-0.5 rounded">help-wanted</code> highlight tasks where we need
support. Everything happens transparently on GitHub.
</p>
<ul class="mt-5 space-y-3 text-white/80 text-sm">
<li class="flex gap-3">
<v-icon icon="mdi-nodejs" class="text-cyan-300 mt-[2px]"/>
<span>Node/Nuxt devs for backend & frontend.</span></li>
<li class="flex gap-3">
<v-icon icon="mdi-headset" class="text-cyan-300 mt-[2px]"/>
<span>ATC SMEs for phraseology, procedures and training feedback.</span></li>
<li class="flex gap-3">
<v-icon icon="mdi-test-tube" class="text-cyan-300 mt-[2px]"/>
<span>Testers for alpha builds & simulator integration.</span></li>
<li class="flex gap-3">
<v-icon icon="mdi-cash-multiple" class="text-cyan-300 mt-[2px]"/>
<span>Infra & cost benchmarking to keep hosting affordable.</span></li>
</ul>
<p class="mt-5 text-sm text-white/70">
Email us at <a href="mailto:info@opensquawk.de"
class="text-cyan-300 underline">info@opensquawk.de</a> or leave a comment directly on the
issue.
</p>
<div class="mt-6 flex flex-col gap-2.5 sm:flex-row sm:gap-3">
<NuxtLink to="#roadmap" class="btn btn-primary">
Shape the roadmap
</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost">
Latest updates
</NuxtLink>
</div>
</div>
<div class="card" data-aos="fade-left">
<h3 class="font-semibold text-lg">Current focus areas</h3>
<ul class="mt-3 space-y-2 text-white/70 text-sm list-disc list-inside">
<li>Stabilise the alpha prototype & simplify setup
</li>
<li>Verify Decision Tree logic
</li>
<li>Review & prioritise roadmap ideas
</li>
<li>Model hosting costs</li>
</ul>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section id="cta"
class="py-12 sm:py-16 md:py-24 bg-gradient-to-tr from-cyan-500/20 via-blue-500/10 to-transparent border-y border-white/10">
<div class="container-outer">
<div class="card grid gap-6 md:grid-cols-2 md:gap-8" data-aos="zoom-in">
<div class="space-y-4">
<div>
<h3 class="text-2xl md:text-3xl font-semibold">
Early access & waitlist
</h3>
<p class="mt-2 text-white/80">
Sign up to secure your spot for the alpha build. Right now (including internal tests) there are
<span class="font-semibold text-cyan-300">{{ waitlistCountDisplay }}</span>
interested pilots waiting for the next invite drop.
</p>
</div>
<div>
<div v-if="waitlistLoading" class="text-sm text-white/60">
Loading waitlist
</div>
<template v-else>
<div class="text-xs text-white/60">
Last signup:
<span class="font-medium text-white">{{ waitlistLastJoinedFormatted }}</span>
<span v-if="waitlistStats?.lastJoinedAt" class="text-white/40">
({{ formatRelativeFromNow(waitlistStats?.lastJoinedAt) }})
</span>
</div>
</template>
</div>
<p class="text-xs text-white/60">
We send invites in batches, prioritise active waitlist spots and deliver invite codes via email.
</p>
</div>
<form class="space-y-4" @submit.prevent="submitWaitlist">
<div class="grid gap-3">
<input
v-model.trim="waitlistForm.name"
aria-label="Name"
type="text"
placeholder="First and last name (optional)"
class="w-full px-4 py-3 rounded-2xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400"
/>
<input
v-model.trim="waitlistForm.email"
aria-label="Email"
type="email"
required
placeholder="your@email"
class="w-full px-4 py-3 rounded-2xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400"
/>
<textarea
v-model.trim="waitlistForm.notes"
rows="3"
placeholder="What do you want to learn with OpenSquawk? (optional)"
class="w-full px-4 py-3 rounded-2xl bg-white/5 border border-white/10 placeholder-white/40 outline-none focus:border-cyan-400"
/>
</div>
<div
v-if="waitlistCaptchaVisible"
class="space-y-2 rounded-2xl border border-white/10 bg-white/5 p-4 text-xs text-white/60"
>
<p class="text-sm font-medium text-white">Aviation captcha</p>
<p>
Answer to join the pattern:
<span class="text-cyan-300">{{ waitlistCaptcha.challenge.prompt }}</span>
</p>
<input
v-model.trim="waitlistCaptcha.answer"
type="text"
required
aria-label="Aviation captcha answer for waitlist"
placeholder="Type the answer"
class="w-full rounded-2xl border border-white/10 bg-[#0b1020]/40 px-4 py-3 text-sm text-white placeholder-white/40 outline-none focus:border-cyan-400"
/>
<p v-if="waitlistCaptchaReminder" class="text-xs text-amber-200">
{{ waitlistCaptchaReminder }}
</p>
<p v-else-if="waitlistCaptcha.answer && !waitlistCaptchaValid" class="text-xs text-red-300">
Check the ATC question and try again.
</p>
</div>
<div class="space-y-2 text-xs text-white/60">
<label class="flex items-start gap-3">
<input type="checkbox" v-model="waitlistForm.subscribeUpdates" class="mt-1"/>
<span>
Yes, keep me posted when new features, drops or classroom content go live.
</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" v-model="waitlistForm.consentTerms" class="mt-1" required/>
<span>
I accept the <NuxtLink to="/agb" class="text-cyan-300 underline">terms of service</NuxtLink> of OpenSquawk.
</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" v-model="waitlistForm.consentPrivacy" class="mt-1" required/>
<span>
I have read the <NuxtLink to="/datenschutz"
class="text-cyan-300 underline">privacy policy</NuxtLink> and consent to storing my details for contact purposes.
</span>
</label>
</div>
<button
type="submit"
class="btn btn-primary w-full"
:disabled="waitlistSubmitting"
>
<span v-if="waitlistSubmitting" class="flex items-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white"/>
Sending data
</span>
<span v-else>Join the waitlist</span>
</button>
<p v-if="waitlistSuccess" class="text-sm text-green-300">
Thank you! You are on the waitlist and we will reach out as soon as slots open up.
<span v-if="waitlistLastOptInUpdates" class="mt-1 block text-xs text-green-200/80">
You will also receive product updates when new features go live.
</span>
</p>
<p v-else-if="waitlistError" class="text-sm text-red-300">{{ waitlistError }}</p>
</form>
</div>
</div>
</section>
<!-- FAQ -->
<section id="faq" class="py-12 sm:py-16 md:py-24 bg-[#0a0f1c]">
<div class="container-outer">
<div class="max-w-2xl mb-10" data-aos="fade-up">
<h2 class="text-3xl md:text-4xl font-semibold">FAQ</h2>
<p class="mt-3 text-white/80">
Quick answers.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2 md:gap-6">
<div class="card" data-aos="fade-up" data-aos-delay="0">
<h3 class="font-semibold">
Is this for real-world aviation?
</h3>
<p class="mt-2 text-white/80">
No, OpenSquawk is for flight simulators and training. It is not intended for real-world radio comms.
</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="100">
<h3 class="font-semibold">
Which simulators are supported?
</h3>
<p class="mt-2 text-white/80">
MSFS first (alpha focus), X-Plane next. Additional simulators will follow community demand.
</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="200">
<h3 class="font-semibold">
Can I self-host?
</h3>
<p class="mt-2 text-white/80">
Alpha: Docker Compose + Node services are available. Docs and installers are in progress. Hosting options
will follow.
</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="300">
<h3 class="font-semibold">
How do I access the Classroom?
</h3>
<p class="mt-2 text-white/80">
The Classroom is invitation-only during alpha. Join the waitlist and mention Classroom or email
<a href="mailto:info@opensquawk.de" class="text-cyan-300 underline">info@opensquawk.de</a> to request an
invite.
</p>
</div>
</div>
</div>
</section>
<!-- FOOTER -->
<footer class="bg-[#070b16] border-t border-white/10 text-white" data-aos="fade-up">
<div class="container-outer py-12 sm:py-16">
<div class="flex flex-col gap-10">
<div class="footer-brand">
<div class="inline-flex items-center justify-center gap-2 sm:justify-start lg:flex-none">
<v-icon icon="mdi-radar" size="26" class="text-cyan-400"/>
<span class="text-lg font-semibold">OpenSquawk</span>
</div>
<p class="mx-auto max-w-2xl text-sm text-white/70 sm:mx-0 lg:flex-1 lg:max-w-3xl">
OpenSquawk builds Live ATC and Classroom training as open-source projects: speech-to-text, LLM-guided
state machine and natural TTS ready for sim pilots.
</p>
<div class="footer-brand-actions lg:flex-none">
<NuxtLink
:to="GITHUB_URL"
external
target="_blank"
rel="noopener"
class="footer-action"
>
<v-icon icon="mdi-github" size="18" class="text-white/70"/>
<span>GitHub</span>
</NuxtLink>
<NuxtLink to="/login" class="footer-action">
<v-icon icon="mdi-login" size="18" class="text-white/70"/>
<span>Login</span>
</NuxtLink>
</div>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
<h4 class="mb-3 font-semibold">Product</h4>
<ul class="space-y-2 text-sm text-white/70">
<li>
<NuxtLink to="#features" class="hover:text-cyan-300">
Live ATC
</NuxtLink>
</li>
<li>
<NuxtLink to="#classroom" class="hover:text-cyan-300">
Classroom
</NuxtLink>
</li>
<li>
<NuxtLink to="#pricing" class="hover:text-cyan-300">
Product lineup
</NuxtLink>
</li>
<li>
<NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink>
</li>
</ul>
</div>
<div>
<h4 class="mb-3 font-semibold">Resources</h4>
<ul class="space-y-2 text-sm text-white/70">
<li>
<NuxtLink to="#news" class="hover:text-cyan-300">News</NuxtLink>
</li>
<li>
<NuxtLink to="#contributing" class="hover:text-cyan-300">
Get involved
</NuxtLink>
</li>
<li>
<NuxtLink to="/feedback" class="hover:text-cyan-300">
Feedback &amp; ideas
</NuxtLink>
</li>
<li><a href="mailto:info@opensquawk.de" class="hover:text-cyan-300">info@opensquawk.de</a></li>
</ul>
</div>
<div>
<h4 class="mb-3 font-semibold">Legal</h4>
<ul class="space-y-2 text-sm text-white/70">
<li>
<NuxtLink to="/impressum" class="hover:text-cyan-300">
Imprint
</NuxtLink>
</li>
<li>
<NuxtLink to="/datenschutz" class="hover:text-cyan-300">
Privacy
</NuxtLink>
</li>
<li>
<NuxtLink to="/agb" class="hover:text-cyan-300">
Terms
</NuxtLink>
</li>
<li>
<NuxtLink to="/api-docs" class="hover:text-cyan-300">
API documentation
</NuxtLink>
</li>
</ul>
</div>
</div>
</div>
<div class="mt-10 border-t border-white/10 pt-6 text-center text-xs text-white/60 sm:text-left">
© {{ year }} OpenSquawk. Not for real-world aviation. *VATSIM/IVAO: trademarks of their respective owners.
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, computed, onMounted, watch, onBeforeUnmount, nextTick} from 'vue'
import {useHead, useRoute, useRouter} from '#imports'
import {useApi} from '~/composables/useApi'
import {getAllNews} from '~~/shared/utils/news'
import type {NewsPost} from '~~/shared/utils/news'
const api = useApi()
const GITHUB_URL = 'https://github.com/OpenSquawk/OpenSquawk'
interface NavLink {
label: string
to: string
}
interface ExtendedNavLink extends NavLink {
external?: boolean
icon?: string
}
const navLinks: NavLink[] = [
{label: 'Live ATC', to: '#features'},
{label: 'Roadmap', to: '#roadmap'},
{label: 'Classroom', to: '#classroom'},
{label: 'Lineup', to: '#pricing'},
{label: 'Get involved', to: '#contributing'},
{label: 'FAQ', to: '#faq'},
]
const mobileNavLinks = computed<ExtendedNavLink[]>(() => [
...navLinks,
{label: 'GitHub', to: GITHUB_URL, external: true, icon: 'mdi-github'},
])
const router = useRouter()
const headerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
const activeSection = ref<string>('')
const SCROLL_MARGIN = 24
const isNavLinkActive = (hash: string) => activeSection.value === hash
interface ScrollOptions {
updateUrl?: boolean
}
let sectionElements: { hash: string; element: HTMLElement }[] = []
let isInternalNavigation = false
const updateHeaderHeight = () => {
if (!import.meta.client) return
headerHeight.value = headerRef.value?.offsetHeight ?? 0
}
const updateSectionElements = () => {
if (!import.meta.client) return
sectionElements = navLinks
.map((link) => {
if (!link.to.startsWith('#')) return null
const element = document.querySelector<HTMLElement>(link.to)
return element ? {hash: link.to, element} : null
})
.filter((value): value is { hash: string; element: HTMLElement } => value !== null)
}
const updateActiveSection = () => {
if (!import.meta.client) return
if (!sectionElements.length) {
activeSection.value = ''
return
}
const scrollPosition = window.scrollY + headerHeight.value + SCROLL_MARGIN + 1
for (let index = sectionElements.length - 1; index >= 0; index -= 1) {
const {hash, element} = sectionElements[index]
if (scrollPosition >= element.offsetTop) {
activeSection.value = hash
return
}
}
activeSection.value = ''
}
const handleScroll = () => {
updateActiveSection()
}
const handleResize = () => {
updateHeaderHeight()
updateSectionElements()
updateActiveSection()
}
const scrollToSection = (hash: string, behavior: ScrollBehavior = 'smooth', options: ScrollOptions = {}) => {
if (!import.meta.client || !hash.startsWith('#')) return
const target = document.querySelector<HTMLElement>(hash)
if (!target) return
const top = target.getBoundingClientRect().top + window.scrollY - headerHeight.value - SCROLL_MARGIN
window.scrollTo({
top: Math.max(0, top),
behavior,
})
activeSection.value = hash
if (options.updateUrl !== false && route.hash !== hash) {
isInternalNavigation = true
router.replace({hash}).catch(() => {
}).finally(() => {
isInternalNavigation = false
})
}
}
const handleAnchorNavigation = (hash: string, behavior: ScrollBehavior = 'smooth', options: ScrollOptions = {}) => {
if (!import.meta.client) return
if (!hash || hash === '#') {
window.scrollTo({top: 0, behavior})
updateActiveSection()
return
}
scrollToSection(hash, behavior, options)
}
const handleDocumentClick = (event: MouseEvent) => {
if (!import.meta.client || event.defaultPrevented) return
const target = event.target as HTMLElement | null
if (!target) return
const anchor = target.closest('a[href]') as HTMLAnchorElement | null
if (!anchor) return
if (anchor.target === '_blank' || anchor.hasAttribute('download') || anchor.getAttribute('data-scroll-ignore') === 'true') {
return
}
const href = anchor.getAttribute('href') ?? ''
if (!href) return
const isHashOnly = href.startsWith('#')
const samePage = anchor.host === window.location.host && anchor.pathname === window.location.pathname
const hash = anchor.hash || (isHashOnly ? href : '')
if (!hash || (!isHashOnly && !samePage)) return
event.preventDefault()
handleAnchorNavigation(hash)
}
const handleHashChange = () => {
if (!import.meta.client || isInternalNavigation) return
const {hash} = window.location
if (!hash) {
updateActiveSection()
return
}
handleAnchorNavigation(hash, 'auto', {updateUrl: false})
}
const isMobileNavOpen = ref(false)
const toggleMobileNav = () => {
isMobileNavOpen.value = !isMobileNavOpen.value
}
const closeMobileNav = () => {
isMobileNavOpen.value = false
}
const handleMobileNavLinkClick = (event: MouseEvent, item: ExtendedNavLink) => {
if (item.external) {
closeMobileNav()
return
}
if (item.to.startsWith('#')) {
event.preventDefault()
handleAnchorNavigation(item.to)
closeMobileNav()
return
}
closeMobileNav()
}
const route = useRoute()
watch(
() => route.fullPath,
() => {
closeMobileNav()
},
)
onMounted(() => {
if (!import.meta.client) return
rotateCaptchaChallenge(updatesCaptcha)
rotateCaptchaChallenge(waitlistCaptcha)
nextTick(() => {
updateHeaderHeight()
updateSectionElements()
updateActiveSection()
if (route.hash) {
handleAnchorNavigation(route.hash, 'auto', {updateUrl: false})
}
window.addEventListener('scroll', handleScroll, {passive: true})
window.addEventListener('resize', handleResize)
window.addEventListener('hashchange', handleHashChange)
document.addEventListener('click', handleDocumentClick)
window.setTimeout(() => {
updateHeaderHeight()
updateSectionElements()
updateActiveSection()
}, 300)
})
})
if (import.meta.client) {
watch(isMobileNavOpen, (open) => {
document.body.classList.toggle('overflow-hidden', open)
nextTick(() => {
updateHeaderHeight()
updateActiveSection()
})
})
onBeforeUnmount(() => {
document.body.classList.remove('overflow-hidden')
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
window.removeEventListener('hashchange', handleHashChange)
document.removeEventListener('click', handleDocumentClick)
})
}
const numberFormatter = computed(() => new Intl.NumberFormat('en-US'))
const formatNumber = (value: number | null | undefined) => numberFormatter.value.format(Math.max(0, Math.round(value ?? 0)))
const ROADMAP_SCALE = [1, 2, 3, 4, 5] as const
const dateLocale = 'en-US'
const shortDateOptions: Intl.DateTimeFormatOptions = {day: '2-digit', month: 'short', year: 'numeric'}
interface WaitlistStats {
count: number
displayCount: number
syntheticBoost: number
recent7Days: number
recent30Days: number
lastJoinedAt: string | null
generatedAt: string
}
interface RoadmapItemWithStats {
key: string
title: string
description: string
category: string
icon: string
votes: number
averageImportance: number | null
scorePercent: number
lastVoteAt: string | null
}
interface RoadmapResponse {
items: RoadmapItemWithStats[]
totalVotes: number
recentVotes7Days: number
}
interface CaptchaChallenge {
id: string
prompt: string
answers: string[]
}
interface CaptchaState {
challenge: CaptchaChallenge
answer: string
}
const CAPTCHA_CHALLENGES: CaptchaChallenge[] = [
{
id: 'vfr-visual',
prompt: 'What does the “V” in VFR stand for?',
answers: ['visual', 'visual flight rules'],
},
{
id: 'ifr-instrument',
prompt: 'What does the “I” in IFR stand for?',
answers: ['instrument', 'instrument flight rules'],
},
{
id: 'atc-meaning',
prompt: 'What does the A in ATC stand for?',
answers: ['air', 'air traffic control'],
},
{
id: 'affirm',
prompt: 'How do pilots say “yes” on the radio?',
answers: ['affirm', 'affirmative'],
},
{
id: 'roger-copy',
prompt: 'Which word means “I received your transmission” on the radio?',
answers: ['roger', 'roger that', 'roger copy','copy' ],
},
{
id: 'wilco',
prompt: 'Which word means “I will comply with your instruction”?',
answers: ['wilco'],
},
{
id: 'niner',
prompt: 'How do pilots say the number 9 on the radio?',
answers: ['niner','nine'],
},
{
id: 'phonetic-alpha',
prompt: 'What is the NATO phonetic word for the letter “A”?',
answers: ['alpha'],
},
{
id: 'phonetic-bravo',
prompt: 'What is the NATO phonetic word for the letter “B”?',
answers: ['bravo'],
},
]
const DEFAULT_CAPTCHA = CAPTCHA_CHALLENGES[0]
const createCaptchaState = () => reactive({
challenge: DEFAULT_CAPTCHA,
answer: '',
}) as CaptchaState
const normaliseCaptchaValue = (value: string) =>
value
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
const isCaptchaAnswerValid = (state: CaptchaState) => {
const response = normaliseCaptchaValue(state.answer)
if (!response) return false
return state.challenge.answers.some((answer) => normaliseCaptchaValue(answer) === response)
}
const pickRandomChallenge = (excludeId?: string) => {
const pool = excludeId
? CAPTCHA_CHALLENGES.filter((challenge) => challenge.id !== excludeId)
: CAPTCHA_CHALLENGES
const available = pool.length ? pool : CAPTCHA_CHALLENGES
const index = Math.floor(Math.random() * available.length)
return available[index]
}
const rotateCaptchaChallenge = (state: CaptchaState) => {
const nextChallenge = pickRandomChallenge(state.challenge?.id)
state.challenge = nextChallenge
state.answer = ''
}
const year = new Date().getFullYear()
const waitlistForm = reactive({
name: '',
email: '',
notes: '',
consentPrivacy: false,
consentTerms: false,
subscribeUpdates: false,
})
const waitlistCaptcha = createCaptchaState()
const waitlistCaptchaVisible = ref(false)
const waitlistCaptchaReminder = ref('')
const waitlistSubmitting = ref(false)
const waitlistSuccess = ref(false)
const waitlistError = ref('')
const waitlistLastOptInUpdates = ref(false)
const waitlistStats = ref<WaitlistStats | null>(null)
const waitlistLoading = ref(false)
const updatesForm = reactive({
email: '',
consentPrivacy: false,
consentMarketing: false,
})
const updatesCaptcha = createCaptchaState()
const updatesCaptchaVisible = ref(false)
const updatesCaptchaReminder = ref('')
const updatesSubmitting = ref(false)
const updatesSuccess = ref(false)
const updatesError = ref('')
const roadmapSuggestionForm = reactive({
title: '',
details: '',
email: '',
allowContact: false,
consentPrivacy: false,
})
const roadmapSuggestionSubmitting = ref(false)
const roadmapSuggestionSuccess = ref(false)
const roadmapSuggestionError = ref('')
const allNewsEntries = computed<NewsPost[]>(() => getAllNews())
const latestNews = computed<NewsPost[]>(() => allNewsEntries.value.slice(0, 3))
const waitlistCountDisplay = computed(() => formatNumber(waitlistStats.value?.displayCount ?? 0))
const waitlistLastJoinedFormatted = computed(() => {
const iso = waitlistStats.value?.lastJoinedAt
if (!iso) return ''
return formatWaitlistDate(iso)
})
const waitlistCaptchaValid = computed(() => isCaptchaAnswerValid(waitlistCaptcha))
const updatesCaptchaValid = computed(() => isCaptchaAnswerValid(updatesCaptcha))
watch(
() => updatesForm.email,
(value) => {
const hasValue = value?.trim().length > 0
if (hasValue) {
updatesCaptchaVisible.value = true
} else {
updatesCaptchaVisible.value = false
updatesCaptcha.answer = ''
updatesCaptchaReminder.value = ''
}
},
)
watch(
() => updatesCaptcha.answer,
(value) => {
if (updatesCaptchaReminder.value && value?.trim().length) {
updatesCaptchaReminder.value = ''
}
},
)
watch(
() => waitlistForm.email,
(value) => {
const hasValue = value?.trim().length > 0
if (hasValue) {
waitlistCaptchaVisible.value = true
} else {
waitlistCaptchaVisible.value = false
waitlistCaptcha.answer = ''
waitlistCaptchaReminder.value = ''
}
},
)
watch(
() => waitlistCaptcha.answer,
(value) => {
if (waitlistCaptchaReminder.value && value?.trim().length) {
waitlistCaptchaReminder.value = ''
}
},
)
const roadmapSuggestionFormValid = computed(() => {
const title = roadmapSuggestionForm.title.trim()
const details = roadmapSuggestionForm.details.trim()
const email = roadmapSuggestionForm.email.trim()
const wantsContact = roadmapSuggestionForm.allowContact
return (
title.length >= 4 &&
details.length >= 20 &&
roadmapSuggestionForm.consentPrivacy &&
(!wantsContact || email.length > 0)
)
})
watch(
() => roadmapSuggestionForm.email,
(value) => {
if (!value) {
roadmapSuggestionForm.allowContact = false
}
},
)
async function loadWaitlistStats() {
try {
waitlistLoading.value = true
const data = (await api.get('/api/service/waitlist', {auth: false})) as WaitlistStats
waitlistStats.value = data
} catch (err) {
console.warn('Waitlist stats unavailable', err)
} finally {
waitlistLoading.value = false
}
}
async function submitWaitlist() {
if (waitlistSubmitting.value) return
waitlistError.value = ''
waitlistSuccess.value = false
waitlistCaptchaReminder.value = ''
waitlistLastOptInUpdates.value = false
const email = waitlistForm.email.trim()
if (!email) {
waitlistError.value = 'Please add your email so we can reach you.'
return
}
if (!waitlistForm.consentPrivacy || !waitlistForm.consentTerms) {
waitlistError.value = 'Please confirm the required checkboxes.'
return
}
if (!waitlistCaptchaVisible.value) {
waitlistCaptchaVisible.value = true
}
const hasCaptchaAnswer = waitlistCaptcha.answer.trim().length > 0
if (!waitlistCaptchaValid.value) {
if (!hasCaptchaAnswer) {
waitlistCaptchaReminder.value = 'Wir erhalten gerade viel Spam bitte beantworte kurz die kleine ATC-Frage.'
}
return
}
waitlistSubmitting.value = true
try {
const wantsProductUpdates = waitlistForm.subscribeUpdates
await api.post(
'/api/service/waitlist',
{
name: waitlistForm.name,
email,
notes: waitlistForm.notes,
consentPrivacy: waitlistForm.consentPrivacy,
consentTerms: waitlistForm.consentTerms,
wantsProductUpdates,
},
{auth: false},
)
waitlistLastOptInUpdates.value = wantsProductUpdates
waitlistSuccess.value = true
await loadWaitlistStats()
waitlistForm.name = ''
waitlistForm.email = ''
waitlistForm.notes = ''
waitlistForm.consentPrivacy = false
waitlistForm.consentTerms = false
waitlistForm.subscribeUpdates = false
waitlistCaptchaReminder.value = ''
rotateCaptchaChallenge(waitlistCaptcha)
} catch (err: any) {
const fallback = 'Registration failed'
const message = err?.data?.statusMessage || err?.message || fallback
waitlistError.value = message
} finally {
waitlistSubmitting.value = false
}
}
async function submitUpdates() {
if (updatesSubmitting.value) return
updatesError.value = ''
updatesSuccess.value = false
updatesCaptchaReminder.value = ''
const email = updatesForm.email.trim()
if (!email) {
updatesError.value = 'Please add your email so we can reach you.'
return
}
if (!updatesForm.consentPrivacy || !updatesForm.consentMarketing) {
updatesError.value = 'Please confirm the required checkboxes.'
return
}
if (!updatesCaptchaVisible.value) {
updatesCaptchaVisible.value = true
}
const hasCaptchaAnswer = updatesCaptcha.answer.trim().length > 0
if (!updatesCaptchaValid.value) {
if (!hasCaptchaAnswer) {
updatesCaptchaReminder.value = 'Wir erhalten gerade viel Spam bitte beantworte kurz die kleine ATC-Frage.'
}
return
}
updatesSubmitting.value = true
try {
await api.post(
'/api/service/updates',
{
email,
consentPrivacy: updatesForm.consentPrivacy,
consentMarketing: updatesForm.consentMarketing,
source: 'landing-hero',
},
{auth: false},
)
updatesSuccess.value = true
updatesForm.email = ''
updatesForm.consentPrivacy = false
updatesForm.consentMarketing = false
updatesCaptchaReminder.value = ''
rotateCaptchaChallenge(updatesCaptcha)
} catch (err: any) {
const fallback = 'Could not save sign-up'
const message = err?.data?.statusMessage || err?.message || fallback
updatesError.value = message
} finally {
updatesSubmitting.value = false
}
}
const formatWaitlistDate = (iso: string) => {
if (!iso) return 'unknown'
const parsed = new Date(iso)
if (Number.isNaN(parsed.getTime())) {
return 'unknown'
}
return parsed.toLocaleDateString(dateLocale, shortDateOptions)
}
const formatRelativeFromNow = (iso?: string | null) => {
if (!iso) return ''
const target = new Date(iso)
if (Number.isNaN(target.getTime())) return ''
const diff = Date.now() - target.getTime()
const minute = 1000 * 60
const hour = minute * 60
const day = hour * 24
if (diff < minute) return 'just now'
if (diff < hour) {
const mins = Math.round(diff / minute)
return `${mins} min${mins === 1 ? '' : 's'} ago`
}
if (diff < day) {
const hours = Math.round(diff / hour)
return `${hours} hr${hours === 1 ? '' : 's'} ago`
}
if (diff < day * 14) {
const days = Math.round(diff / day)
return `${days} day${days === 1 ? '' : 's'} ago`
}
const weeks = Math.round(diff / (day * 7))
if (weeks < 9) {
return `${weeks} wk${weeks === 1 ? '' : 's'} ago`
}
const months = Math.round(diff / (day * 30))
return `${months} mo${months === 1 ? '' : 's'} ago`
}
const formatAverage = (value: number) => value.toFixed(1)
const formatNewsDate = (iso: string) => {
if (!iso) return 'tbd'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return 'tbd'
return date.toLocaleDateString(dateLocale, shortDateOptions)
}
const roadmapExpanded = ref(false)
const roadmapItems = ref<RoadmapItemWithStats[]>([])
const roadmapLoading = ref(false)
const roadmapTotals = ref(0)
const roadmapRecent7Days = ref(0)
const roadmapSelections = reactive<Record<string, number>>({})
const roadmapTouched = reactive<Record<string, boolean>>({})
const roadmapSubmitting = ref(false)
const roadmapSuccess = ref(false)
const roadmapError = ref('')
const hasRoadmapVote = computed(() => Object.values(roadmapTouched).some(Boolean))
const roadmapStatsLabel = computed(() => {
const total = formatNumber(roadmapTotals.value)
const recent = formatNumber(roadmapRecent7Days.value)
return `${total} votes submitted`
})
const roadmapImportanceLabel = (value?: number) => {
const labels: Record<number, string> = {
1: 'Nice to have, not urgent',
2: 'Can wait',
3: 'Important for me',
4: 'Very important',
5: 'Top priority',
}
return labels[value ?? 0] || 'Not rated yet'
}
const roadmapImportanceShortLabel = (value: number) => {
const labels: Record<number, string> = {
1: 'No',
2: 'Meh',
3: 'Later',
4: 'Now',
5: 'Top',
}
return labels[value] || ''
}
const selectRoadmapImportance = (key: string, value: number) => {
if (!ROADMAP_SCALE.includes(value as (typeof ROADMAP_SCALE)[number])) {
return
}
roadmapSelections[key] = value
roadmapTouched[key] = true
roadmapSuccess.value = false
}
const toggleRoadmap = () => {
roadmapExpanded.value = !roadmapExpanded.value
}
async function loadRoadmap() {
try {
roadmapLoading.value = true
const data = (await api.get('/api/service/roadmap', {auth: false})) as RoadmapResponse
roadmapItems.value = data.items ?? []
roadmapTotals.value = data.totalVotes ?? 0
roadmapRecent7Days.value = data.recentVotes7Days ?? 0
const activeKeys = new Set<string>()
for (const item of roadmapItems.value) {
activeKeys.add(item.key)
if (typeof roadmapSelections[item.key] !== 'number') {
roadmapSelections[item.key] = 3
}
if (roadmapTouched[item.key] === undefined) {
roadmapTouched[item.key] = false
}
}
for (const key of Object.keys(roadmapSelections)) {
if (!activeKeys.has(key)) {
delete roadmapSelections[key]
delete roadmapTouched[key]
}
}
} catch (err) {
console.warn('Roadmap stats unavailable', err)
} finally {
roadmapLoading.value = false
}
}
async function submitRoadmapVotes() {
if (!hasRoadmapVote.value || roadmapSubmitting.value) return
const votes = Object.entries(roadmapSelections)
.filter(([key]) => roadmapTouched[key])
.map(([key, importance]) => ({key, importance}))
if (!votes.length) {
return
}
roadmapSubmitting.value = true
roadmapError.value = ''
roadmapSuccess.value = false
try {
await api.post('/api/service/roadmap', {votes}, {auth: false})
roadmapSuccess.value = true
Object.keys(roadmapTouched).forEach((key) => {
roadmapTouched[key] = false
})
await loadRoadmap()
} catch (err: any) {
const fallback = 'Could not submit votes'
const message = err?.data?.statusMessage || err?.message || fallback
roadmapError.value = message
} finally {
roadmapSubmitting.value = false
}
}
async function submitRoadmapSuggestion() {
if (!roadmapSuggestionFormValid.value || roadmapSuggestionSubmitting.value) return
roadmapSuggestionSubmitting.value = true
roadmapSuggestionError.value = ''
roadmapSuggestionSuccess.value = false
try {
await api.post(
'/api/service/roadmap-suggestions',
{
title: roadmapSuggestionForm.title,
details: roadmapSuggestionForm.details,
email: roadmapSuggestionForm.email || undefined,
allowContact: roadmapSuggestionForm.allowContact,
consentPrivacy: roadmapSuggestionForm.consentPrivacy,
},
{auth: false},
)
roadmapSuggestionSuccess.value = true
roadmapSuggestionForm.title = ''
roadmapSuggestionForm.details = ''
roadmapSuggestionForm.email = ''
roadmapSuggestionForm.allowContact = false
roadmapSuggestionForm.consentPrivacy = false
} catch (err: any) {
const fallback = 'Could not save suggestion'
const message = err?.data?.statusMessage || err?.message || fallback
roadmapSuggestionError.value = message
} finally {
roadmapSuggestionSubmitting.value = false
}
}
useHead(() => ({
htmlAttrs: {lang: 'en'},
title: 'OpenSquawk Open-source, low-cost AI ATC for flight simulation',
meta: [
{
name: 'description',
content: 'We are building OpenSquawk: open-source, low-cost AI ATC for flight sim pilots. Community roadmap, self-host today, hosted options in planning.'
},
{name: 'theme-color', content: '#0ea5e9'},
{property: 'og:title', content: 'OpenSquawk Open-source, low-cost AI ATC'},
{
property: 'og:description',
content: 'Alpha prototype for sim pilots. Community-driven features, self-host today, hosted options tomorrow.'
},
{property: 'og:type', content: 'website'},
{property: 'og:image', content: 'https://opensquawk.example.com/cover.png'},
{name: 'twitter:card', content: 'summary_large_image'},
{name: 'twitter:title', content: 'OpenSquawk Open-source, low-cost AI ATC'},
{name: 'twitter:description', content: 'Alpha prototype with community roadmap. Self-host today, hosted later.'},
{name: 'twitter:image', content: 'https://opensquawk.example.com/cover.png'},
],
}))
onMounted(async () => {
await Promise.all([loadWaitlistStats(), loadRoadmap()])
// @ts-ignore optional fallback
if (!('AOS' in window)) {
const [{default: AOS}] = await Promise.all([
import('aos'),
import('aos/dist/aos.css')
])
AOS.init({once: true, duration: 600, easing: 'ease-out'})
}
})
</script>
<style scoped>
.container-outer {
@apply mx-auto w-full max-w-screen-xl px-4 sm:px-6 md:px-8;
}
.gradient-hero {
background: radial-gradient(1200px 600px at 12% -20%, rgba(6, 182, 212, .32), transparent),
radial-gradient(900px 520px at 100% 5%, rgba(59, 130, 246, .22), transparent),
linear-gradient(180deg, rgba(11, 16, 32, 0.92) 0%, rgba(11, 16, 32, 0.94) 42%, rgba(11, 16, 32, 0.98) 68%, #0a0f1c 100%);
}
.gradient-hero::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(7, 11, 24, 0.05) 0%, rgba(9, 14, 28, 0.1) 55%, rgba(11, 16, 32, 0.5) 100%),
url('/img/learn/missions/full-flight/briefing-weather.png') center/cover no-repeat;
opacity: 1;
pointer-events: none;
z-index: 0;
}
.hero-overlay {
background: linear-gradient(90deg, rgba(11, 16, 32, 0.88) 0%, rgba(11, 16, 32, 0.62) 46%, rgba(11, 16, 32, 0.22) 100%);
z-index: 1;
}
.hero-form {
position: relative;
z-index: 2;
}
.hero-form-panel {
background: linear-gradient(140deg, rgba(15, 23, 42, 0.72), rgba(12, 20, 38, 0.48));
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(24px);
box-shadow: 0 42px 120px rgba(5, 12, 32, 0.45);
}
.glass {
background: rgba(255, 255, 255, .06);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, .08);
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-2xl font-medium transition px-4 py-2.5 sm:px-5 sm:py-3;
}
.btn-primary {
@apply bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_40px_rgba(34,211,238,.25)];
}
.btn-ghost {
@apply bg-white/5 text-white hover:bg-white/10;
}
.btn-compact {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
}
@media (min-width: 640px) {
.btn-compact {
padding: 0.6rem 1rem;
font-size: 0.875rem;
}
}
.card {
@apply glass rounded-2xl p-4 sm:p-5 md:p-6;
}
.pricing-card {
overflow: visible;
}
.chip {
@apply inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/15 text-white px-3 py-1 text-xs;
}
.roadmap-scale {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
}
.roadmap-pill {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 0.55rem 0.65rem;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.75);
font-size: 0.875rem;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.roadmap-pill:hover {
background: rgba(34, 211, 238, 0.18);
border-color: rgba(34, 211, 238, 0.4);
color: #fff;
}
.roadmap-pill.is-active {
background: rgba(34, 211, 238, 0.28);
border-color: rgba(34, 211, 238, 0.65);
color: #fff;
box-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
}
.roadmap-pill .label {
font-size: 0.6rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
}
.roadmap-pill.is-active .label {
color: rgba(224, 242, 254, 0.85);
}
.mobile-nav-enter-active,
.mobile-nav-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.mobile-nav-enter-from,
.mobile-nav-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.mobile-toggle {
@apply inline-flex items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-white transition hover:bg-white/10;
width: 44px;
height: 44px;
}
@media (min-width: 1024px) {
.mobile-toggle {
display: none;
}
}
.hamburger {
position: relative;
width: 20px;
height: 14px;
}
.hamburger-bar {
position: absolute;
left: 0;
width: 100%;
height: 2px;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.9);
transition: top 0.25s ease, transform 0.25s ease, opacity 0.25s ease;
}
.hamburger-bar:nth-child(1) {
top: 0;
}
.hamburger-bar:nth-child(2) {
top: 6px;
}
.hamburger-bar:nth-child(3) {
top: 12px;
}
.hamburger.is-open .hamburger-bar:nth-child(1) {
top: 6px;
transform: rotate(45deg);
}
.hamburger.is-open .hamburger-bar:nth-child(2) {
opacity: 0;
transform: translateX(-6px);
}
.hamburger.is-open .hamburger-bar:nth-child(3) {
top: 6px;
transform: rotate(-45deg);
}
@media (prefers-reduced-motion: reduce) {
.hamburger-bar {
transition: none;
}
}
.footer-brand {
@apply flex flex-col items-center gap-4 text-center sm:items-start sm:text-left lg:flex-row lg:items-center lg:justify-between lg:gap-8;
}
.footer-brand-actions {
@apply flex flex-wrap items-center justify-center gap-3 sm:justify-start lg:justify-end lg:ml-auto;
}
.footer-action {
@apply inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/80 transition hover:border-cyan-400/60 hover:bg-white/10;
}
@media (max-width: 480px) {
.roadmap-scale {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.roadmap-pill {
padding: 0.5rem 0.6rem;
}
}
.chip.absolute {
background: #434752;
}
img.card {
padding: 0
}
</style>