Revise landing messaging and add news feed

This commit is contained in:
Remi
2025-09-17 15:44:41 +02:00
parent 6882a93417
commit e6755d2042
17 changed files with 1286 additions and 117 deletions

View File

@@ -86,8 +86,10 @@
const publicEndpoints = [
{ method: 'POST', path: '/api/service/waitlist', description: 'Person auf die Warteliste setzen. Erfordert Zustimmung zu AGB & Datenschutz.' },
{ method: 'GET', path: '/api/service/waitlist', description: 'Aggregierte Wartelistenstatistiken mit Gesamtanzahl, Wachstum und sichtbarem Puffer abrufen.' },
{ method: 'POST', path: '/api/service/updates', description: 'E-Mail für Produkt-Updates und neue Features eintragen (Einwilligung erforderlich).' },
{ method: 'GET', path: '/api/service/roadmap', description: 'Roadmap-Items inklusive Community-Durchschnitt, Gesamtstimmen und letzter Aktivität auslesen.' },
{ method: 'POST', path: '/api/service/roadmap', description: 'Wichtigkeit (15) für einzelne Roadmap-Punkte voten; speichert jeden Vote mit Zeitstempel.' },
{ method: 'POST', path: '/api/service/roadmap-suggestions', description: 'Neuen Roadmap-Vorschlag mit optionaler Kontaktadresse einreichen.' },
{ method: 'POST', path: '/api/service/auth/login', description: 'Login mit E-Mail & Passwort. Gibt JWT zurück und setzt Refresh-Cookie.' },
{ method: 'POST', path: '/api/service/auth/register', description: 'Registrierung mit Einladungscode und Einwilligungen.' },
{ method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' },

View File

@@ -21,7 +21,9 @@
<h2 class="text-xl font-semibold">2. Verarbeitete Daten</h2>
<ul class="list-disc space-y-2 pl-6 text-white/70">
<li><strong>Warteliste:</strong> Name (optional), E-Mail-Adresse, freiwillige Hinweise, Zeitpunkt des Eintrags, Einwilligungen.</li>
<li><strong>Feature-Benachrichtigungen:</strong> E-Mail-Adresse, optionaler Name, Opt-in-Zeitpunkt, Zustimmung für Produkt-Updates.</li>
<li><strong>Nutzerkonto:</strong> Name, E-Mail-Adresse, Passwort-Hash, Einladungsstatus, Erstellungs- und Login-Zeitpunkte, Einladungscode-Historie.</li>
<li><strong>Roadmap-Vorschläge:</strong> Titel, Beschreibung, optional angegebene Kontaktadresse, Einwilligungen und Zeitstempel.</li>
<li><strong>Kommunikationsdaten:</strong> Sämtliche eingegebenen oder per Push-to-Talk übermittelten Funksprüche (Transkripte, normalisierte Texte, Metaangaben wie Modul, Lesson-ID, Stärke, Entscheidungskontext).</li>
<li><strong>Technische Protokolle:</strong> Geräteinformationen (Browser, OS), Zeitstempel, Request-IDs, Fehlerlogs.</li>
</ul>
@@ -33,7 +35,7 @@
<ul class="space-y-3 text-white/70">
<li><strong>Bereitstellung des Dienstes (Art. 6 Abs. 1 lit. b DSGVO):</strong> Verwaltung von Warteliste, Accounts und Sessions, Ausgabe von Einladungscodes, Abrechnung zukünftiger Tarife.</li>
<li><strong>Produktverbesserung & Sicherheit (Art. 6 Abs. 1 lit. f DSGVO):</strong> Analyse und Logging sämtlicher Funksprüche, Fehlerüberwachung, Missbrauchsprävention.</li>
<li><strong>Kommunikation (Art. 6 Abs. 1 lit. a DSGVO):</strong> Versand von Wartelisten-Updates, Einladungen und produktspezifischen Informationen nach ausdrücklicher Einwilligung.</li>
<li><strong>Kommunikation & Community-Feedback (Art. 6 Abs. 1 lit. a DSGVO):</strong> Versand von Wartelisten-Updates und Produkt-News nach ausdrücklicher Einwilligung sowie Auswertung eingereichter Roadmap-Vorschläge inkl. optionaler Kontaktaufnahme.</li>
</ul>
</section>
@@ -41,6 +43,8 @@
<h2 class="text-xl font-semibold">4. Speicherdauer</h2>
<ul class="space-y-2 text-white/70">
<li>Wartelisteneinträge: bis zum Widerruf oder maximal 24 Monate nach letzter Aktivität.</li>
<li>Feature-Benachrichtigungen: bis zur Abmeldung (Opt-out) oder Widerruf der Einwilligung.</li>
<li>Roadmap-Vorschläge: bis zur Umsetzung oder spätestens 18 Monate nach Einreichung.</li>
<li>Accountdaten: solange das Nutzerkonto besteht, anschließend entsprechend gesetzlicher Aufbewahrungspflichten.</li>
<li>Kommunikationslogs: mindestens 12 Monate zur Qualitätsverbesserung; bei Supportfällen oder Missbrauchsvorwürfen bis zur abschließenden Klärung.</li>
</ul>
@@ -51,6 +55,9 @@
<p class="text-white/70">
Wir hosten OpenSquawk auf europäischen Cloud-Plattformen (derzeit Hetzner Cloud, Deutschland). Kommunikationsdaten werden in unserer MongoDB-Datenbank gespeichert. Externe KI-Dienstleister (z. B. OpenAI) erhalten ausschließlich pseudonymisierte Texte zur Verarbeitung von TTS/LLM-Funktionen. Es gelten entsprechende Auftragsverarbeitungsverträge. Eine Übermittlung in Drittstaaten erfolgt nur unter Nutzung von EU-Standardvertragsklauseln.
</p>
<p class="text-xs text-white/60">
Hinweis: Formularübermittlungen (Warteliste, Feature-Benachrichtigungen, Roadmap-Vorschläge) lösen eine interne Benachrichtigungs-E-Mail an opensquawk@faktorxmensch.com aus, versendet über den konfigurierten SMTP- oder Transaktionsmail-Anbieter. Enthalten sind ausschließlich die von dir eingegebenen Angaben zur zügigen Bearbeitung.
</p>
</section>
<section class="space-y-4 rounded-2xl border border-white/10 bg-white/5 p-6">
@@ -75,7 +82,7 @@
</template>
<script setup lang="ts">
const lastUpdated = new Date('2025-01-15').toLocaleDateString('de-DE')
const lastUpdated = new Date('2025-02-20').toLocaleDateString('de-DE')
</script>
<style scoped>

View File

@@ -27,7 +27,7 @@
<h2 class="text-xl font-semibold">Kontakt</h2>
<div class="space-y-2 text-white/70">
<p>Telefon: +49 251 981 157 912 0</p>
<p>E-Mail: info@faktorxmensch.com</p>
<p>E-Mail: opensquawk@faktorxmensch.com</p>
</div>
</section>

View File

@@ -8,15 +8,17 @@
<span class="text-white">OpenSquawk</span>
</NuxtLink>
<div class="hidden md:flex gap-6 text-sm">
<NuxtLink to="#features" class="hover:text-cyan-300">Features</NuxtLink>
<NuxtLink to="#features" class="hover:text-cyan-300">Vision</NuxtLink>
<NuxtLink to="#roadmap" class="hover:text-cyan-300">Roadmap</NuxtLink>
<NuxtLink to="#news" class="hover:text-cyan-300">News</NuxtLink>
<NuxtLink to="#learn" class="hover:text-cyan-300">Lernpfad</NuxtLink>
<NuxtLink to="#pricing" class="hover:text-cyan-300">Preise</NuxtLink>
<NuxtLink to="#opensource" class="hover:text-cyan-300">OpenSource</NuxtLink>
<NuxtLink to="#contributing" class="hover:text-cyan-300">Mitmachen</NuxtLink>
<NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink>
</div>
<div class="flex items-center gap-2">
<NuxtLink to="#demo" class="btn btn-ghost text-sm">LiveDemo</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost text-sm">News</NuxtLink>
<NuxtLink to="#cta" class="btn btn-primary text-sm">Frühzugang</NuxtLink>
</div>
</nav>
@@ -30,20 +32,77 @@
<div class="container-outer pt-16 pb-12 md:pt-24 md:pb-20">
<div class="max-w-2xl" data-aos="fade-up">
<span class="chip mb-4">OpenSource · AIATC · Simulator</span>
<h1 class="text-4xl md:text-6xl font-semibold leading-tight">OpenSquawk<br/><span class="text-cyan-400">AIATC, offen & bezahlbar</span></h1>
<p class="mt-4 md:mt-6 text-white/80 text-base md:text-lg">Offene Alternative für AIATC im Flugsimulator. Lerne echte Phraseologie, übe sichere Abläufe und steige Schritt für Schritt Richtung VATSIM ein. Optional als gehosteter Plan so günstig wie möglich.</p>
<span class="chip mb-4">Alpha · Open-Source AI ATC</span>
<h1 class="text-4xl md:text-6xl font-semibold leading-tight">
OpenSquawk<br/>
<span class="text-cyan-400">Open-source, low-cost AI ATC</span>
</h1>
<p class="mt-4 md:mt-6 text-white/80 text-base md:text-lg">
Wir bauen eine offene, bezahlbare KI-Flugsicherung für Flugsimulatoren community-getrieben, mit selbst gehosteten
und gehosteten Optionen in Planung.
</p>
<ul class="mt-6 space-y-2 text-white/70 text-sm md:text-base">
<li class="flex items-start gap-2">
<v-icon icon="mdi-account-group" size="18" class="mt-[3px] text-cyan-300" />
<span>Community-Roadmap bestimmt Prioritäten. Features landen nur, wenn sie euch wirklich helfen.</span>
</li>
<li class="flex items-start gap-2">
<v-icon icon="mdi-airplane" size="18" class="mt-[3px] text-cyan-300" />
<span><strong>Simulator-Support</strong>: MSFS zuerst, X-Plane als nächstes mit Blick auf VATSIM-kompatible Trainings.</span>
</li>
</ul>
<p class="mt-4 text-sm text-cyan-200/80">
Alpha-Prototyp verfügbar läuft lokal und setzt leichte Programmierkenntnisse voraus.
</p>
<p class="mt-2 text-sm text-white/70">
Wir suchen Mitentwickler:innen (Node/Nuxt, ATC SMEs, Tester, Infra/Kosten). Meld dich via
<a class="text-cyan-300 underline" href="mailto:opensquawk@faktorxmensch.com">opensquawk@faktorxmensch.com</a>.
</p>
<div class="flex flex-col sm:flex-row gap-3 mt-6">
<NuxtLink to="#cta" class="btn btn-primary text-base">Frühzugang sichern</NuxtLink>
<NuxtLink to="#video" class="btn btn-ghost text-base">1MinÜberblick ansehen</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost text-base">News lesen</NuxtLink>
</div>
<div class="mt-6 flex items-center gap-4 text-white/70 text-sm">
<div class="flex -space-x-3">
<img alt="avatar" src="https://i.pravatar.cc/40?img=1" class="w-8 h-8 rounded-full border border-white/10"/>
<img alt="avatar" src="https://i.pravatar.cc/40?img=2" class="w-8 h-8 rounded-full border border-white/10"/>
<img alt="avatar" src="https://i.pravatar.cc/40?img=3" class="w-8 h-8 rounded-full border border-white/10"/>
</div>
<p><b>1500+</b> Pilot:innen im EarlyAccess</p>
<div class="mt-8 max-w-xl" data-aos="fade-up" data-aos-delay="120">
<form class="rounded-2xl border border-white/10 bg-black/30 p-5 space-y-4" @submit.prevent="submitUpdates">
<div class="space-y-1">
<h3 class="text-lg font-semibold">Neue Features zuerst erfahren</h3>
<p class="text-sm text-white/70">Trag dich in die Feature-Liste ein und erhalte Updates zu Releases, Drops & Lerninhalten.</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<input
v-model.trim="updatesForm.email"
type="email"
required
placeholder="dein@email"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
aria-label="E-Mail für Produkt-Updates"
/>
<button
type="submit"
class="btn btn-primary w-full sm:w-auto"
:disabled="!updatesFormValid || updatesSubmitting"
>
<span v-if="updatesSubmitting" class="flex items-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white" />
Speichere
</span>
<span v-else>Benachrichtigt mich</span>
</button>
</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>Ja, informiert mich per E-Mail über neue Features, Wartelisten-Drops & Lerninhalte.</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" v-model="updatesForm.consentPrivacy" class="mt-1" required />
<span>Ich habe die <NuxtLink to="/datenschutz" class="text-cyan-300 underline">Datenschutzerklärung</NuxtLink> gelesen.</span>
</label>
</div>
<p v-if="updatesSuccess" class="text-sm text-green-300">Danke! Wir informieren dich, sobald neue Features live gehen.</p>
<p v-else-if="updatesError" class="text-sm text-red-300">{{ updatesError }}</p>
<p v-else class="text-xs text-white/50">Kein Spam maximal relevante Produkt-Updates. Abmeldung jederzeit per Link möglich.</p>
</form>
</div>
</div>
<div class="hidden mt-10 md:mt-16" data-aos="zoom-in" data-aos-delay="100">
@@ -59,11 +118,11 @@
<!-- SOCIAL PROOF / LOGOS -->
<section class="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-6 items-center opacity-80">
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-microsoft-xbox" class="opacity-70"/>MSFS</div>
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-airplane" class="opacity-70"/>XPlane</div>
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-headset" class="opacity-70"/>VATSIM* (Lernpfad)</div>
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-radar" class="opacity-70"/>IVAO* (Lernpfad)</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 items-center opacity-80 text-sm md:text-base">
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-test-tube" class="opacity-70"/>Alpha-Status</div>
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-microsoft-xbox" class="opacity-70"/>MSFS (Fokus)</div>
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-airplane" class="opacity-70"/>XPlane (als nächstes)</div>
<div class="flex items-center justify-center gap-2 text-white/60"><v-icon icon="mdi-headset" class="opacity-70"/>VATSIM-Lernpfad (geplant)</div>
</div>
</div>
</section>
@@ -72,58 +131,93 @@
<section id="features" class="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">Warum OpenSquawk?</h2>
<p class="mt-3 text-white/80">EchtzeitFunk mit KI, lernfreundliche Erklärungen und offene Architektur damit du souverän taxi, departure, approach & landing beherrschst.</p>
<h2 class="text-3xl md:text-4xl font-semibold">Vision & Fokus</h2>
<p class="mt-3 text-white/80">Open-source, low-cost AI ATC für Flugsimulatoren. Community-getriebene Features, transparente Kosten und Wahlfreiheit zwischen Self-host und späterem Hosting.</p>
</div>
<div class="grid md:grid-cols-3 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-tower-fire" class="text-cyan-300"/></div>
<h3 class="font-semibold text-lg">AIATC in Echtzeit</h3>
<h3 class="font-semibold text-lg">Offene AI-ATC-Grundlage</h3>
</div>
<p class="mt-3 text-white/80">StreamingASR, LLMVerständnis & TTS Stimmen. Klar, schnell, latenzarm optimiert für Phraseologie.</p>
<p class="mt-3 text-white/80">Wir entwickeln eine erschwingliche Kernplattform für Funkanalyse, Entscheidungslogik und Voices, die jede:r inspizieren und erweitern kann.</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-school" class="text-cyan-300"/></div>
<h3 class="font-semibold text-lg">Lernpfad VATSIM</h3>
<div class="p-2 rounded-lg bg-cyan-500/20 border border-cyan-400/20"><v-icon icon="mdi-vote" class="text-cyan-300"/></div>
<h3 class="font-semibold text-lg">Community entscheidet</h3>
</div>
<p class="mt-3 text-white/80">Geführte Übungen, Checks & Erklärungen vom Readback bis zu komplexen Clearances. Schrittweise Richtung OnlineNetzwerke.</p>
<p class="mt-3 text-white/80">Roadmap-Votes und Vorschläge aus der Community lenken unsere Prioritäten Training, Tools oder Integrationen entstehen mit euch.</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">OpenSource + Hosted</h3>
<h3 class="font-semibold text-lg">Self-host & Hosted Optionen</h3>
</div>
<p class="mt-3 text-white/80">Selbst hosten oder unseren günstigen Plan nutzen. Transparente Architektur, CommunityPlugins & APIs.</p>
<p class="mt-3 text-white/80">Beta-Ziel: eigene Instanz per Docker in Minuten oder später fair kalkulierte Hosting-Pläne offen, transparent, bezahlbar.</p>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mt-6">
<div class="card" data-aos="fade-up" data-aos-delay="0">
<h3 class="font-semibold text-lg">TaxiRouten & Ground Awareness</h3>
<p class="mt-3 text-white/80">OSM/apt.datbasierte TaxiwayGraphen, A*Routing zu Holding Points, progressive Anweisungen mit VisualOverlay.</p>
<h3 class="font-semibold text-lg">Simulator-Support (Plan)</h3>
<p class="mt-3 text-white/80">Wir fokussieren zuerst Microsoft Flight Simulator, arbeiten parallel am X-Plane-Konzept und sammeln Feedback für weitere Plattformen.</p>
<ul class="mt-3 space-y-2 text-white/70 text-sm list-disc list-inside">
<li>Taxi to RWY 25C via A, A5, B2</li>
<li>HotspotVermeidung & progressive Readbacks</li>
<li>Optional: NOTAM/ATIS Einbindung</li>
<li>MSFS Alpha: lokale Anbindung + Funkworkflows im Test</li>
<li>X-Plane: Plugin-Skizze, Community-Review willkommen</li>
<li>VATSIM/IVAO: Lernpfad & Phraseologie-Übungen geplant</li>
</ul>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="100">
<h3 class="font-semibold text-lg">TechStack</h3>
<p class="mt-3 text-white/80">StreamingASR, LLMDialog, TTS orchestriert mit WebRTC/WS. OfflineFallbacks möglich.</p>
<h3 class="font-semibold text-lg">Alpha-Prototyp</h3>
<p class="mt-3 text-white/80">Der aktuelle Build läuft lokal (Node/Nuxt + Services). Zum Setup brauchst du Terminal-Basics und Lust auf Experimente.</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-waveform"/>ASR: Whisper/Alternativen</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-robot"/>LLM: GPT5Nano/4oMini</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-microphone"/>TTS: neural voices</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-routes"/>Routing: A* / Dijkstra</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-console"/>Setup via CLI</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-docker"/>Docker Compose optional</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-account-hard-hat"/>Fehler melden erwünscht</div>
<div class="glass rounded-xl p-3 flex items-center gap-2"><v-icon icon="mdi-source-branch"/>PRs & Roadmap-Votes willkommen</div>
</div>
</div>
</div>
</div>
</section>
<!-- NEWS -->
<section id="news" class="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 Beiträge halten dich auf dem Laufenden. Alle Artikel liegen als Markdown vor neue Stories landen automatisch hier.</p>
</div>
<div v-if="latestNews.length" class="grid 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">Weiterlesen</NuxtLink>
</div>
</article>
</div>
<div v-else class="card text-sm text-white/70" data-aos="fade-up">
Noch keine News veröffentlicht der erste Beitrag folgt in Kürze.
</div>
<div class="flex items-center gap-3" data-aos="fade-up">
<NuxtLink to="/news" class="btn btn-ghost">Alle News ansehen</NuxtLink>
<span class="text-xs text-white/50">Schreib uns, falls du eigene Erfahrungsberichte teilen willst.</span>
</div>
</div>
</section>
<!-- ROADMAP -->
<section id="roadmap" class="py-16 md:py-24 bg-[#0b1020] border-y border-white/10">
<div class="container-outer space-y-10">
@@ -179,22 +273,25 @@
</div>
<div class="space-y-3">
<div class="flex items-center justify-between text-[11px] uppercase tracking-[0.3em] text-white/50">
<span>Weniger</span>
<span>Priorität</span>
<span>Priorität wählen</span>
<span v-if="roadmapTouched[item.key]" class="text-cyan-300">markiert</span>
</div>
<div class="roadmap-scale" role="group" :aria-label="`Priorität für ${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>
<input
type="range"
min="1"
max="5"
step="1"
v-model.number="roadmapSelections[item.key]"
@change="markRoadmapTouched(item.key)"
class="roadmap-range"
:aria-label="`Priorität für ${item.title}`"
/>
<div class="text-sm text-white/80">
{{ roadmapImportanceLabel(roadmapSelections[item.key]) }}
<span v-if="roadmapTouched[item.key]" class="text-cyan-300"> markiert</span>
</div>
</div>
</div>
@@ -223,11 +320,71 @@
<p v-if="roadmapSuccess" class="text-center text-sm text-green-300 md:text-left">Danke! Deine Stimmen sind angekommen.</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">
Tipp: Slider nur verändern, wenn du zu dem Punkt Feedback hast.
Tipp: Prioritäten nur anpassen, wenn du zu dem Punkt Feedback hast.
</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">Fehlt dir etwas in der Roadmap?</h4>
<p class="text-sm text-white/70">Teile deinen Feature-Wunsch oder ein Problem, das wir lösen sollen. Wir priorisieren Community-Ideen.</p>
</div>
<form class="space-y-3" @submit.prevent="submitRoadmapSuggestion">
<input
v-model.trim="roadmapSuggestionForm.title"
type="text"
required
placeholder="Kurzer Titel z. B. 'ATIS-Integration für EDDF'"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-white/40 outline-none focus:border-cyan-400"
aria-label="Titel für Roadmap-Vorschlag"
/>
<textarea
v-model.trim="roadmapSuggestionForm.details"
rows="4"
required
placeholder="Beschreibe, warum das wichtig ist oder wie es dir helfen würde."
class="w-full rounded-xl 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="E-Mail (optional)"
class="w-full rounded-xl 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>Gern per E-Mail nachhaken, falls Rückfragen auftauchen.</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>Ich habe die <NuxtLink to="/datenschutz" class="text-cyan-300 underline">Datenschutzerklärung</NuxtLink> gelesen und willige in die Verarbeitung meines Vorschlags ein.</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" />
Sende Vorschlag
</span>
<span v-else>Vorschlag senden</span>
</button>
<p v-if="roadmapSuggestionSuccess" class="text-sm text-green-300">Danke! Wir prüfen deinen Vorschlag und melden uns ggf. zurück.</p>
<p v-else-if="roadmapSuggestionError" class="text-sm text-red-300">{{ roadmapSuggestionError }}</p>
<p v-else class="text-xs text-white/50">Wir lesen jeden Vorschlag persönlich. Optionales Kontaktfeld nur für Rückfragen.</p>
</div>
</form>
</div>
</div>
</div>
</section>
@@ -237,18 +394,18 @@
<div class="container-outer">
<div class="grid md:grid-cols-2 gap-8 items-center">
<div data-aos="fade-right">
<h2 class="text-3xl md:text-4xl font-semibold">ATC verstehen Schritt für Schritt</h2>
<p class="mt-3 text-white/80">Geführter Lernmodus: Hören, Nachsprechen, Feedback. Vom ersten Ready to taxi bis zur komplexen STAR/ApproachFreigabe.</p>
<h2 class="text-3xl md:text-4xl font-semibold">Lernpfad (in Arbeit)</h2>
<p class="mt-3 text-white/80">Gemeinsam mit der Community strukturieren wir Trainingsmodule vom ersten Funkkontakt bis zu Netzwerken wie VATSIM. Feedback aus Tests fließt direkt in jedes Kapitel.</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>: Alphabet, Zahlen, StandardPhrasen, Readback.</span></li>
<li class="flex gap-3"><span class="chip">2</span><span><b>Ground</b>: TaxiFlows, Hotspots, Holdingshort.</span></li>
<li class="flex gap-3"><span class="chip">3</span><span><b>Departure</b>: SID, Altitudes, Heading/Speed.</span></li>
<li class="flex gap-3"><span class="chip">4</span><span><b>Arrival</b>: STAR, Vectors, Approach Briefing.</span></li>
<li class="flex gap-3"><span class="chip">5</span><span><b>VATSIM</b>: Checklisten, Etiquette, LiveÜbungen.</span></li>
<li class="flex gap-3"><span class="chip">1</span><span><b>Basics</b> (Konzept): Funkalphabet, Zahlen, Standard-Readbacks.</span></li>
<li class="flex gap-3"><span class="chip">2</span><span><b>Ground</b> (Alpha): Taxi-Flows, Hotspots, Holding-short.</span></li>
<li class="flex gap-3"><span class="chip">3</span><span><b>Departure</b> (geplant): SID-Handling, Altitude-/Speed-Calls.</span></li>
<li class="flex gap-3"><span class="chip">4</span><span><b>Arrival</b> (geplant): STARs, Vectors, Approach-Briefing.</span></li>
<li class="flex gap-3"><span class="chip">5</span><span><b>VATSIM</b> (Community-Beta): Checklisten, Etiquette, Live-Übungen.</span></li>
</ol>
<div class="mt-6 flex gap-3">
<NuxtLink to="/learn" class="btn btn-primary">Jetzt loslegen</NuxtLink>
<NuxtLink to="#faq" class="btn btn-ghost">FAQ</NuxtLink>
<NuxtLink to="#roadmap" class="btn btn-primary">Roadmap ansehen</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost">News lesen</NuxtLink>
</div>
</div>
<div class="card" data-aos="fade-left">
@@ -268,8 +425,8 @@
<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">Preise</h2>
<p class="mt-3 text-white/80">OpenSource bleibt frei. Der gehostete Plan ist so günstig wie möglich kalkuliert. Umschalten:
<h2 class="text-3xl md:text-4xl font-semibold">Preise (Zielbild)</h2>
<p class="mt-3 text-white/80">Wir arbeiten an einer fairen Kalkulation für Hosting. Zahlen unten sind grobe Zielwerte Feedback willkommen.
<button @click="yearly=!yearly" class="chip ml-2"><span>{{ yearly ? 'jährlich' : 'monatlich' }}</span></button>
</p>
</div>
@@ -293,26 +450,26 @@
<div class="card border-2 border-cyan-400/40 relative 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">Empfohlen</div>
<h3 class="text-xl font-semibold">Hosted Basic</h3>
<p class="mt-2 text-white/80">Alles fertig eingerichtet. Ideal zum Lernen & Üben.</p>
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '4,00€' : '4,50€' }}</span><span class="text-white/60 text-sm font-normal"> / Monat</span></div>
<p class="mt-2 text-white/80">Geplanter Rundum-Service: Kein Setup, Updates inklusive. Aktuell nur als Konzept.</p>
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '4€' : '4,50€' }}</span><span class="text-white/60 text-sm font-normal"> / Monat*</span></div>
<ul class="mt-5 space-y-2 text-white/80 text-sm">
<li> FairUse AudioMinuten</li>
<li> Lernpfad & Fortschritt</li>
<li> Updates & CloudScaling</li>
<li> Fair-Use Audio-Minuten (Ausarbeitung)</li>
<li> Lernpfad & Fortschritt (abhängig von Community-Feedback)</li>
<li> Updates & Cloud-Scaling (Kostenmodell in Arbeit)</li>
</ul>
<NuxtLink to="#cta" class="btn btn-primary w-full mt-6">Kostenlos testen</NuxtLink>
<p class="mt-3 text-xs text-white/60">Preisziel: minimal + Betriebskosten. Änderungen möglich.</p>
<NuxtLink to="#cta" class="btn btn-primary w-full mt-6">Interesse melden</NuxtLink>
<p class="mt-3 text-xs text-white/60">* Zielwert, finale Preise folgen nach Kostentest.</p>
</div>
<!-- Hosted Pro -->
<div class="card relative" data-aos="fade-up" data-aos-delay="200">
<div class="absolute -top-3 right-4 chip">PowerUser</div>
<h3 class="text-xl font-semibold">Hosted Pro</h3>
<p class="mt-2 text-white/80">Mehr Kontingente, eigene Stimmen, APIZugriff.</p>
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '12€' : '14€' }}</span><span class="text-white/60 text-sm font-normal"> / Monat</span></div>
<p class="mt-2 text-white/80">Konzept für größere Gruppen & virtuelle Airlines. Preise werden gemeinsam kalkuliert.</p>
<div class="mt-5 text-3xl font-semibold"><span>{{ yearly ? '12€' : '14€' }}</span><span class="text-white/60 text-sm font-normal"> / Monat*</span></div>
<ul class="mt-5 space-y-2 text-white/80 text-sm">
<li> Höhere Limits</li>
<li> TeamSeats</li>
<li> Priorisierter Support</li>
<li> Höhere Limits (nach Bedarf)</li>
<li> Team-Seats & Rollen (Diskussion offen)</li>
<li> Priorisierter Support (Community + optional SLA)</li>
</ul>
<NuxtLink to="#cta" class="btn btn-ghost w-full mt-6">Kontakt aufnehmen</NuxtLink>
</div>
@@ -335,7 +492,7 @@
</ul>
<div class="mt-6 flex gap-3">
<NuxtLink to="#cta" class="btn btn-primary">Mitmachen</NuxtLink>
<NuxtLink to="#demo" class="btn btn-ghost">Demo</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost">News</NuxtLink>
</div>
</div>
<div class="card" data-aos="fade-left">
@@ -368,6 +525,38 @@ POST /api/route/taxi
</div>
</section>
<!-- CONTRIBUTING -->
<section id="contributing" class="py-16 md:py-24 bg-[#0a0f1c] border-y border-white/10">
<div class="container-outer">
<div class="grid md:grid-cols-2 gap-8 items-start">
<div data-aos="fade-right">
<h2 class="text-3xl md:text-4xl font-semibold">Mitentwickeln</h2>
<p class="mt-3 text-white/80">Issues labeled <code class="text-xs bg-white/10 px-1.5 py-0.5 rounded">help-wanted</code> markieren Aufgaben, bei denen wir Support brauchen. Alles passiert offen auf 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 für Backend & Frontend.</span></li>
<li class="flex gap-3"><v-icon icon="mdi-headset" class="text-cyan-300 mt-[2px]"/><span>ATC SMEs für Phraseologie, Verfahren und Trainingsfeedback.</span></li>
<li class="flex gap-3"><v-icon icon="mdi-test-tube" class="text-cyan-300 mt-[2px]"/><span>Tester:innen für 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- & Kosten-Benchmarking, damit Hosting bezahlbar bleibt.</span></li>
</ul>
<p class="mt-5 text-sm text-white/70">Schreib uns an <a href="mailto:opensquawk@faktorxmensch.com" class="text-cyan-300 underline">opensquawk@faktorxmensch.com</a> oder kommentiere direkt im Issue.</p>
<div class="mt-6 flex gap-3">
<NuxtLink to="#roadmap" class="btn btn-primary">Roadmap mitgestalten</NuxtLink>
<NuxtLink to="/news" class="btn btn-ghost">Neuigkeiten</NuxtLink>
</div>
</div>
<div class="card" data-aos="fade-left">
<h3 class="font-semibold text-lg">Aktuelle Fokus-Themen</h3>
<ul class="mt-3 space-y-2 text-white/70 text-sm list-disc list-inside">
<li>Alpha-Prototyp stabilisieren & Setup vereinfachen</li>
<li>MSFS & X-Plane Anbindung verifizieren</li>
<li>Roadmap-Ideen bewerten & priorisieren</li>
<li>Kostenmodell für Hosting durchrechnen</li>
</ul>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section id="cta" class="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">
@@ -376,9 +565,9 @@ POST /api/route/taxi
<div>
<h3 class="text-2xl md:text-3xl font-semibold">Frühzugang & Warteliste</h3>
<p class="mt-2 text-white/80">
Trag dich ein und sichere dir deinen Platz. Aktuell warten
Trag dich ein und sichere dir deinen Platz für den Alpha-Build. Aktuell (inkl. interner Tests) warten
<span class="font-semibold text-cyan-300">{{ waitlistCountDisplay }}</span>
Pilot:innen auf den nächsten Invite-Drop.
Interessierte auf den nächsten Invite-Drop.
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-4">
@@ -439,6 +628,10 @@ POST /api/route/taxi
/>
</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>Ja, informiert mich, wenn neue Features, Drops oder Lerninhalte starten.</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" v-model="waitlistForm.consentTerms" class="mt-1" required />
<span>
@@ -465,6 +658,7 @@ POST /api/route/taxi
</button>
<p v-if="waitlistSuccess" class="text-sm text-green-300">
Danke! Wir haben dich auf der Warteliste eingetragen und melden uns, sobald Plätze frei werden.
<span v-if="waitlistLastOptInUpdates" class="mt-1 block text-xs text-green-200/80">Zusätzlich erhältst du Produkt-Updates, sobald neue Features live gehen.</span>
</p>
<p v-else-if="waitlistError" class="text-sm text-red-300">{{ waitlistError }}</p>
</form>
@@ -486,15 +680,15 @@ POST /api/route/taxi
</div>
<div class="card" data-aos="fade-up" data-aos-delay="100">
<h3 class="font-semibold">Welche Simulatoren werden unterstützt?</h3>
<p class="mt-2 text-white/80">MSFS, XPlane (weitere geplant). Integrationen per PluginSDK.</p>
<p class="mt-2 text-white/80">MSFS zuerst (Alpha-Fokus), XPlane als nächstes. Weitere Simulatoren richten wir nach Community-Bedarf aus.</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="200">
<h3 class="font-semibold">Kann ich selbst hosten?</h3>
<p class="mt-2 text-white/80">Ja. DockerCompose/Helm bereitgestellt. HostedPlan als bequeme Alternative.</p>
<p class="mt-2 text-white/80">Alpha: Docker-Compose + Node-Services sind verfügbar. Doku & Installer werden gerade ergänzt. Hosting-Optionen folgen.</p>
</div>
<div class="card" data-aos="fade-up" data-aos-delay="300">
<h3 class="font-semibold">Wie günstig ist Hosted Basic?</h3>
<p class="mt-2 text-white/80">Ziel: knapp über Betriebskosten. Pilotphase mit 45 mtl. (vorläufig).</p>
<p class="mt-2 text-white/80">Zielwert aktuell ~45 monatlich, final nach Kosten-Benchmarking. Wir teilen Ergebnisse transparent im Blog.</p>
</div>
</div>
</div>
@@ -506,22 +700,24 @@ POST /api/route/taxi
<div class="grid md:grid-cols-4 gap-6">
<div>
<div class="flex items-center gap-2 font-semibold"><v-icon icon="mdi-radar" class="text-cyan-400"/>OpenSquawk</div>
<p class="mt-3 text-white/70 text-sm">OpenSource AIATC für Simulatorpiloten. Lernpfad & günstiger HostedPlan.</p>
<p class="mt-3 text-white/70 text-sm">Open-source, low-cost AI ATC für Flugsimulatoren. Alpha-Prototyp verfügbar benötigt leichte Programmierkenntnisse.</p>
</div>
<div>
<h4 class="font-semibold mb-3">Produkt</h4>
<ul class="space-y-2 text-white/70 text-sm">
<li><NuxtLink to="#features" class="hover:text-cyan-300">Features</NuxtLink></li>
<li><NuxtLink to="#features" class="hover:text-cyan-300">Vision</NuxtLink></li>
<li><NuxtLink to="#learn" class="hover:text-cyan-300">Lernpfad</NuxtLink></li>
<li><NuxtLink to="#pricing" class="hover:text-cyan-300">Preise</NuxtLink></li>
<li><NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-3">Ressourcen</h4>
<ul class="space-y-2 text-white/70 text-sm">
<li><NuxtLink to="#opensource" class="hover:text-cyan-300">OpenSource</NuxtLink></li>
<li><NuxtLink to="#demo" class="hover:text-cyan-300">Demo</NuxtLink></li>
<li><NuxtLink to="#faq" class="hover:text-cyan-300">FAQ</NuxtLink></li>
<li><NuxtLink to="#news" class="hover:text-cyan-300">News</NuxtLink></li>
<li><NuxtLink to="#contributing" class="hover:text-cyan-300">Mitmachen</NuxtLink></li>
<li><a href="mailto:opensquawk@faktorxmensch.com" class="hover:text-cyan-300">opensquawk@faktorxmensch.com</a></li>
</ul>
</div>
<div>
@@ -542,14 +738,17 @@ POST /api/route/taxi
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useHead } from '#imports'
import { useApi } from '~/composables/useApi'
import { getAllNews } from '~~/shared/utils/news'
import type { NewsPost } from '~~/shared/utils/news'
const api = useApi()
const numberFormatter = new Intl.NumberFormat('de-DE')
const formatNumber = (value: number | null | undefined) => numberFormatter.format(Math.max(0, Math.round(value ?? 0)))
const ROADMAP_SCALE = [1, 2, 3, 4, 5] as const
interface WaitlistStats {
count: number
@@ -588,14 +787,39 @@ const waitlistForm = reactive({
notes: '',
consentPrivacy: false,
consentTerms: false,
subscribeUpdates: false,
})
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 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 waitlistRecent7 = computed(() => waitlistStats.value?.recent7Days ?? 0)
const waitlistRecent30 = computed(() => waitlistStats.value?.recent30Days ?? 0)
@@ -608,6 +832,32 @@ const waitlistLastJoinedFormatted = computed(() => {
const waitlistFormValid = computed(() =>
Boolean(waitlistForm.email && waitlistForm.consentPrivacy && waitlistForm.consentTerms)
)
const updatesFormValid = computed(() =>
Boolean(updatesForm.email && updatesForm.consentPrivacy && updatesForm.consentMarketing)
)
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 {
@@ -627,9 +877,23 @@ async function submitWaitlist() {
waitlistSubmitting.value = true
waitlistError.value = ''
waitlistSuccess.value = false
waitlistLastOptInUpdates.value = false
try {
await api.post('/api/service/waitlist', { ...waitlistForm }, { auth: false })
const wantsProductUpdates = waitlistForm.subscribeUpdates
await api.post(
'/api/service/waitlist',
{
name: waitlistForm.name,
email: waitlistForm.email,
notes: waitlistForm.notes,
consentPrivacy: waitlistForm.consentPrivacy,
consentTerms: waitlistForm.consentTerms,
wantsProductUpdates,
},
{ auth: false },
)
waitlistLastOptInUpdates.value = wantsProductUpdates
waitlistSuccess.value = true
await loadWaitlistStats()
waitlistForm.name = ''
@@ -637,6 +901,7 @@ async function submitWaitlist() {
waitlistForm.notes = ''
waitlistForm.consentPrivacy = false
waitlistForm.consentTerms = false
waitlistForm.subscribeUpdates = false
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Anmeldung fehlgeschlagen'
waitlistError.value = message
@@ -645,6 +910,36 @@ async function submitWaitlist() {
}
}
async function submitUpdates() {
if (!updatesFormValid.value || updatesSubmitting.value) return
updatesSubmitting.value = true
updatesError.value = ''
updatesSuccess.value = false
try {
await api.post(
'/api/service/updates',
{
email: updatesForm.email,
consentPrivacy: updatesForm.consentPrivacy,
consentMarketing: updatesForm.consentMarketing,
source: 'landing-hero',
},
{ auth: false },
)
updatesSuccess.value = true
updatesForm.email = ''
updatesForm.consentPrivacy = false
updatesForm.consentMarketing = false
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Konnte Anmeldung nicht speichern'
updatesError.value = message
} finally {
updatesSubmitting.value = false
}
}
const formatWaitlistDate = (iso: string) => {
if (!iso) return 'unbekannt'
return new Date(iso).toLocaleDateString('de-DE', {
@@ -685,6 +980,17 @@ const formatRelativeFromNow = (iso?: string | null) => {
const formatAverage = (value: number) => value.toFixed(1).replace('.', ',')
const formatNewsDate = (iso: string) => {
if (!iso) return 'tbd'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return 'tbd'
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}
const roadmapItems = ref<RoadmapItemWithStats[]>([])
const roadmapLoading = ref(false)
const roadmapTotals = ref(0)
@@ -707,8 +1013,22 @@ const roadmapImportanceLabel = (value?: number) => {
}
return labels[value ?? 0] || 'Noch nicht bewertet'
}
const roadmapImportanceShortLabel = (value: number) => {
const labels: Record<number, string> = {
1: 'Optional',
2: 'Später',
3: 'Wichtig',
4: 'Dringend',
5: 'Top',
}
return labels[value] || ''
}
const markRoadmapTouched = (key: string) => {
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
}
@@ -774,18 +1094,51 @@ async function submitRoadmapVotes() {
}
}
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 message = err?.data?.statusMessage || err?.message || 'Konnte Vorschlag nicht speichern'
roadmapSuggestionError.value = message
} finally {
roadmapSuggestionSubmitting.value = false
}
}
useHead({
title: 'OpenSquawk OpenSource AIATC für Simulatorpiloten',
title: 'OpenSquawk Open-source, low-cost AI ATC für Flugsimulatoren',
meta: [
{ name: 'description', content: 'OpenSquawk ist die offene, günstige Alternative für AIATC im Flugsimulator mit Lernpfad zu echter Funkphraseologie und sanftem Einstieg Richtung VATSIM.' },
{ name: 'description', content: 'Wir bauen OpenSquawk: Open-source, low-cost AI ATC für Flugsimulatoren. Community-Roadmap, Self-host & Hosting in Planung, Alpha-Prototyp verfügbar.' },
{ name: 'theme-color', content: '#0ea5e9' },
{ property: 'og:title', content: 'OpenSquawk OpenSource AIATC' },
{ property: 'og:description', content: 'Open, günstig, lernfreundlich. AIATC mit Lernpfad & gehostetem Plan.' },
{ property: 'og:title', content: 'OpenSquawk Open-source, low-cost AI ATC' },
{ property: 'og:description', content: 'Alpha-Prototyp für Simulatorpiloten. Community-getriebene Features, Self-host & geplante Hosting-Optionen.' },
{ 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 OpenSource AIATC' },
{ name: 'twitter:description', content: 'Open, günstig, lernfreundlich. AIATC mit Lernpfad & gehostetem Plan.' },
{ name: 'twitter:title', content: 'OpenSquawk Open-source, low-cost AI ATC' },
{ name: 'twitter:description', content: 'Alpha-Prototyp mit Community-Roadmap. Self-host heute, Hosting morgen.' },
{ name: 'twitter:image', content: 'https://opensquawk.example.com/cover.png' }
]
})
@@ -817,28 +1170,42 @@ onMounted(async () => {
.btn-ghost { @apply bg-white/5 text-white hover:bg-white/10; }
.card { @apply glass rounded-2xl p-5 md:p-6; }
.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-range {
width: 100%;
accent-color: #22d3ee;
cursor: pointer;
.roadmap-scale {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
}
.roadmap-range:focus {
outline: none;
.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-range::-webkit-slider-thumb {
width: 16px;
height: 16px;
background: #22d3ee;
border-radius: 9999px;
border: none;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.25);
.roadmap-pill:hover {
background: rgba(34, 211, 238, 0.18);
border-color: rgba(34, 211, 238, 0.4);
color: #fff;
}
.roadmap-range::-moz-range-thumb {
width: 16px;
height: 16px;
background: #22d3ee;
border-radius: 9999px;
border: none;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.25);
.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);
}
</style>

73
app/pages/news/[slug].vue Normal file
View File

@@ -0,0 +1,73 @@
<template>
<div class="bg-[#0b1020] text-white min-h-screen">
<div class="container-outer py-10 md:py-16 space-y-8">
<NuxtLink to="/news" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zu News & Updates
</NuxtLink>
<article v-if="post" class="card space-y-6">
<header class="space-y-2">
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
<h1 class="text-3xl md:text-4xl font-semibold">{{ post.title }}</h1>
<p class="text-sm text-white/60">{{ post.readingTime }}</p>
</header>
<div class="news-body" v-html="post.html" />
</article>
<div v-else class="card space-y-4 text-white/70 text-sm">
<h1 class="text-2xl font-semibold text-white">Beitrag nicht gefunden</h1>
<p>Der angeforderte News-Artikel existiert nicht (mehr). Schau dir stattdessen den aktuellen Feed an.</p>
<NuxtLink to="/news" class="btn btn-primary w-fit">Zur Übersicht</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useHead, useRoute } from '#imports'
import { getNewsBySlug } from '~~/shared/utils/news'
const route = useRoute()
const slug = computed(() => String(route.params.slug ?? ''))
const post = computed(() => getNewsBySlug(slug.value))
useHead(() => {
if (!post.value) {
return {
title: 'Newsbeitrag nicht gefunden OpenSquawk',
meta: [{ name: 'robots', content: 'noindex' }],
}
}
const description = post.value.excerpt || 'Update aus dem OpenSquawk Projekt.'
return {
title: `${post.value.title} OpenSquawk News`,
meta: [
{ name: 'description', content: description },
{ property: 'og:title', content: `${post.value.title} OpenSquawk News` },
{ property: 'og:description', content: description },
],
}
})
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
</script>
<style scoped>
.container-outer { @apply mx-auto max-w-screen-lg px-4; }
.card { @apply glass rounded-2xl p-6 md:p-10; }
.btn { @apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-3 font-medium transition; }
.btn-primary { @apply bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_40px_rgba(34,211,238,.25)]; }
.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; }
.news-body :deep(h2) { @apply text-2xl md:text-3xl font-semibold mt-6 mb-3 text-white; }
.news-body :deep(h3) { @apply text-xl font-semibold mt-5 mb-2 text-white; }
.news-body :deep(p) { @apply text-white/80 leading-relaxed mb-4; }
.news-body :deep(ul) { @apply list-disc list-inside text-white/75 mb-4 space-y-2; }
.news-body :deep(code) { @apply bg-white/10 px-1 py-0.5 rounded text-xs; }
.news-body :deep(a) { @apply text-cyan-300 underline; }
.glass { background: rgba(255,255,255,.06); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,.08); }
</style>

76
app/pages/news/index.vue Normal file
View File

@@ -0,0 +1,76 @@
<template>
<div class="bg-[#0b1020] text-white min-h-screen">
<header class="border-b border-white/10 bg-[#0b1020]/80 backdrop-blur">
<div class="container-outer py-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-cyan-300">
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zur Startseite
</NuxtLink>
<h1 class="text-3xl md:text-4xl font-semibold mt-2">News & Updates</h1>
<p class="text-white/70 text-sm md:text-base mt-2">
Content-getriebene Updates zu Alpha-Builds, Simulator-Support und Community-Entscheidungen.
</p>
</div>
<NuxtLink to="#feed" class="btn btn-primary w-fit">Zum aktuellen Feed</NuxtLink>
</div>
</header>
<main class="container-outer py-12 md:py-16 space-y-10" id="feed">
<section>
<div v-if="posts.length" class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="post in posts"
:key="post.slug"
class="card h-full flex flex-col gap-4"
>
<div class="space-y-2">
<span class="chip text-[10px] uppercase tracking-[0.3em]">{{ formatNewsDate(post.publishedAt) }}</span>
<h2 class="text-xl font-semibold">{{ post.title }}</h2>
<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">Weiterlesen</NuxtLink>
</div>
</article>
</div>
<div v-else class="card text-white/70 text-sm">
Noch keine Beiträge veröffentlicht. Schau bald wieder vorbei!
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useHead } from '#imports'
import { getAllNews } from '~~/shared/utils/news'
const posts = computed(() => getAllNews())
useHead({
title: 'News & Updates OpenSquawk',
meta: [
{ name: 'description', content: 'Neuigkeiten zum OpenSquawk Alpha-Prototyp, Simulator-Support und Community-Updates.' },
{ name: 'robots', content: 'index,follow' },
{ property: 'og:title', content: 'News & Updates OpenSquawk' },
{ property: 'og:description', content: 'Open-source, low-cost AI ATC: Wir teilen Fortschritte, Milestones und Aufrufe zum Mitmachen.' },
],
})
const formatNewsDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
</script>
<style scoped>
.container-outer { @apply mx-auto max-w-screen-xl px-4; }
.card { @apply glass rounded-2xl p-5 md:p-6; }
.btn { @apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-3 font-medium transition; }
.btn-primary { @apply bg-cyan-500 text-white hover:bg-cyan-400 shadow-[0_0_40px_rgba(34,211,238,.25)]; }
.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; }
.glass { background: rgba(255,255,255,.06); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,.08); }
</style>

View File

@@ -0,0 +1,33 @@
---
title: "Alpha-Prototyp bereit für frühe Tests"
date: "2025-01-20"
summary: "Unser erster OpenSquawk Alpha-Build läuft lokal für MSFS und zeigt, wohin die Reise Richtung community-getriebener AI-ATC geht."
---
Wir haben den Alpha-Prototyp von OpenSquawk intern stabilisiert und geben ihn jetzt an neugierige Mitentwickler:innen raus. Der Build kombiniert eine Nuxt-Oberfläche mit Node-Services für Speech-to-Text, Entscheidungslogik und TTS. Alles lässt sich lokal betreiben, solange du ein wenig Terminal-Erfahrung mitbringst.
## Was dich erwartet
- Self-host Setup: `yarn install`, `.env` befüllen, danach `yarn dev` oder `docker compose up` für das volle Paket.
- Simulator-Fokus: Microsoft Flight Simulator (MSFS) zuerst, X-Plane steht bereits in der Konzeptphase.
- Zielbild: Community-driven Features, damit Trainingsrichtung VATSIM realitätsnah bleibt und Hosting langfristig bezahlbar wird.
## Startvoraussetzungen
- Grundkenntnisse in Node/Nuxt (Logs lesen, Pakete aktualisieren, `.env` konfigurieren).
- Zugriff auf MSFS (PC) für die ersten Funkexperimente.
- Bereitschaft, Issues zu dokumentieren und kleine Tweaks per Pull Request zu liefern.
## Wie du helfen kannst
- Teste den Alpha-Build und melde Findings als Issues am besten mit Logs oder kurzen Screenshots.
- Schau in die Issues labeled `help-wanted`, dort liegen konkrete Aufgaben für Node/Nuxt Devs, ATC SMEs, Tester:innen und Infra-/Kosten-Benchmarking.
- Teile eigene Feature-Ideen direkt in der Roadmap oder schreib uns via [opensquawk@faktorxmensch.com](mailto:opensquawk@faktorxmensch.com).
## Nächste Schritte
- X-Plane Plugin als nächstes Milestone.
- Lernpfad-Module iterieren (Ground → Departure → Arrival → VATSIM).
- Hosting-Kosten transparent benchmarken und im Blog teilen.
Danke für jedes Feedback gemeinsam bringen wir OpenSquawk Richtung „Open-source, low-cost AI ATC für Flugsimulatoren".

View File

@@ -0,0 +1,60 @@
import { readBody, createError } from 'h3'
import { RoadmapSuggestion } from '../../models/RoadmapSuggestion'
import { sendAdminNotification } from '../../utils/notifications'
interface RoadmapSuggestionBody {
title?: string
details?: string
email?: string
allowContact?: boolean
consentPrivacy?: boolean
}
const MIN_TITLE_LENGTH = 4
const MIN_DETAILS_LENGTH = 20
export default defineEventHandler(async (event) => {
const body = await readBody<RoadmapSuggestionBody>(event)
const title = body.title?.trim()
const details = body.details?.trim()
const email = body.email?.trim().toLowerCase()
const allowContact = Boolean(body.allowContact && email)
if (!title || title.length < MIN_TITLE_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' })
}
if (!details || details.length < MIN_DETAILS_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
}
if (body.allowContact && !email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
}
const suggestion = await RoadmapSuggestion.create({
title,
details,
email,
allowContact,
consentPrivacy: true,
})
const lines = [
`Titel: ${title}`,
`Beschreibung: ${details}`,
`Kontakt: ${email || '—'}`,
allowContact ? 'Kontaktaufnahme erwünscht' : 'Keine Kontaktaufnahme erwünscht',
]
await sendAdminNotification('[OpenSquawk] Neuer Roadmap-Vorschlag', lines.join('\n'))
return {
success: true,
suggestionId: suggestion._id.toString(),
}
})

View File

@@ -0,0 +1,52 @@
import { readBody, createError } from 'h3'
import { registerUpdateSubscriber } from '../../utils/subscribers'
import { sendAdminNotification } from '../../utils/notifications'
interface UpdatesRequestBody {
email?: string
name?: string
consentPrivacy?: boolean
consentMarketing?: boolean
source?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<UpdatesRequestBody>(event)
const email = body.email?.trim().toLowerCase()
const name = body.name?.trim()
const source = body.source?.trim() || 'landing-updates'
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
}
if (!body.consentMarketing) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
}
const result = await registerUpdateSubscriber({
email,
name,
source,
consentPrivacy: true,
consentMarketing: true,
})
if (result.created) {
const lines = [
`E-Mail: ${email}`,
name ? `Name: ${name}` : null,
`Quelle: ${source}`,
].filter(Boolean)
await sendAdminNotification('[OpenSquawk] Neue Updates-Liste', lines.join('\n'))
}
return {
success: true,
alreadySubscribed: !result.created,
}
})

View File

@@ -1,5 +1,7 @@
import { readBody, createError } from 'h3'
import { WaitlistEntry } from '../../models/WaitlistEntry'
import { sendAdminNotification } from '../../utils/notifications'
import { registerUpdateSubscriber } from '../../utils/subscribers'
interface WaitlistRequestBody {
email?: string
@@ -8,6 +10,7 @@ interface WaitlistRequestBody {
consentPrivacy?: boolean
consentTerms?: boolean
source?: string
wantsProductUpdates?: boolean
}
export default defineEventHandler(async (event) => {
@@ -16,6 +19,7 @@ export default defineEventHandler(async (event) => {
const name = body.name?.trim()
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
@@ -30,12 +34,41 @@ export default defineEventHandler(async (event) => {
const existing = await WaitlistEntry.findOne({ email })
if (existing) {
const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates)
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = source
existing.consentPrivacy = true
existing.consentTerms = true
if (wantsProductUpdates && !previouslyWantedUpdates) {
existing.wantsProductUpdates = true
existing.updatesOptedInAt = now
}
await existing.save()
if (wantsProductUpdates) {
const updateResult = await registerUpdateSubscriber({
email,
name,
source: `${source}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
if (!previouslyWantedUpdates && updateResult.created) {
const lines = [
`E-Mail: ${email}`,
name ? `Name: ${name}` : null,
notes ? `Notizen: ${notes}` : null,
`Quelle: ${source}`,
].filter(Boolean)
await sendAdminNotification(
'[OpenSquawk] Neue Updates-Liste (via Warteliste)',
`${lines.join('\n')}\nOpt-In: Produkt-Updates`,
)
}
}
return {
success: true,
alreadyJoined: true,
@@ -51,8 +84,29 @@ export default defineEventHandler(async (event) => {
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
wantsProductUpdates,
updatesOptedInAt: wantsProductUpdates ? now : undefined,
})
if (wantsProductUpdates) {
await registerUpdateSubscriber({
email,
name,
source: `${source}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
}
const lines = [
`E-Mail: ${email}`,
name ? `Name: ${name}` : null,
notes ? `Notizen: ${notes}` : null,
`Quelle: ${source}`,
wantsProductUpdates ? 'Opt-In: Produkt-Updates' : 'Opt-In: nur Warteliste',
].filter(Boolean)
await sendAdminNotification('[OpenSquawk] Neue Wartelisten-Anmeldung', lines.join('\n'))
return {
success: true,
alreadyJoined: false,

View File

@@ -0,0 +1,25 @@
import mongoose from 'mongoose'
export interface RoadmapSuggestionDocument extends mongoose.Document {
title: string
details: string
email?: string
allowContact: boolean
consentPrivacy: boolean
submittedAt: Date
}
const roadmapSuggestionSchema = new mongoose.Schema<RoadmapSuggestionDocument>({
title: { type: String, required: true, trim: true, maxlength: 160 },
details: { type: String, required: true, trim: true, maxlength: 4000 },
email: { type: String, trim: true, lowercase: true },
allowContact: { type: Boolean, default: false },
consentPrivacy: { type: Boolean, default: false },
submittedAt: { type: Date, default: () => new Date() },
})
roadmapSuggestionSchema.index({ submittedAt: -1 })
export const RoadmapSuggestion =
(mongoose.models.RoadmapSuggestion as mongoose.Model<RoadmapSuggestionDocument> | undefined) ||
mongoose.model<RoadmapSuggestionDocument>('RoadmapSuggestion', roadmapSuggestionSchema)

View File

@@ -0,0 +1,32 @@
import mongoose from 'mongoose'
export interface UpdateSubscriberDocument extends mongoose.Document {
email: string
name?: string
source: string
consentPrivacy: boolean
consentMarketing: boolean
subscribedAt: Date
lastUpdatedAt: Date
}
const updateSubscriberSchema = new mongoose.Schema<UpdateSubscriberDocument>({
email: { type: String, required: true, lowercase: true, trim: true, unique: true },
name: { type: String, trim: true },
source: { type: String, default: 'landing-updates' },
consentPrivacy: { type: Boolean, default: false },
consentMarketing: { type: Boolean, default: false },
subscribedAt: { type: Date, default: () => new Date() },
lastUpdatedAt: { type: Date, default: () => new Date() },
})
updateSubscriberSchema.pre('save', function updateTimestamp(this: UpdateSubscriberDocument, next) {
if (this.isModified()) {
this.set('lastUpdatedAt', new Date())
}
next()
})
export const UpdateSubscriber =
(mongoose.models.UpdateSubscriber as mongoose.Model<UpdateSubscriberDocument> | undefined) ||
mongoose.model<UpdateSubscriberDocument>('UpdateSubscriber', updateSubscriberSchema)

View File

@@ -9,6 +9,8 @@ export interface WaitlistEntryDocument extends mongoose.Document {
consentTerms: boolean
joinedAt: Date
activatedAt?: Date
wantsProductUpdates?: boolean
updatesOptedInAt?: Date
}
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
@@ -20,6 +22,8 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
consentTerms: { type: Boolean, default: false },
joinedAt: { type: Date, default: () => new Date() },
activatedAt: { type: Date },
wantsProductUpdates: { type: Boolean, default: false },
updatesOptedInAt: { type: Date },
})
export const WaitlistEntry =

View File

@@ -0,0 +1,103 @@
const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com'
const RESEND_ENDPOINT = 'https://api.resend.com/emails'
async function sendViaResend(subject: string, text: string) {
const apiKey = process.env.NOTIFY_RESEND_API_KEY
if (!apiKey) {
return false
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
try {
const response = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to,
subject,
text,
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
console.error('Failed to send notification via Resend API', response.status, errorText)
return false
}
return true
} catch (error) {
console.error('Error while sending notification via Resend API', error)
return false
}
}
async function sendViaSmtp(subject: string, text: string) {
const host = process.env.NOTIFY_SMTP_HOST
if (!host) {
return false
}
const user = process.env.NOTIFY_SMTP_USER
const pass = process.env.NOTIFY_SMTP_PASS
if (!user || !pass) {
console.warn('SMTP notification is configured without credentials skipping send.')
return false
}
const port = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10)
const secure = process.env.NOTIFY_SMTP_SECURE === 'true'
let nodemailer: any = null
try {
const module = await import('nodemailer')
nodemailer = module.default ?? module
} catch (error) {
console.warn('nodemailer is not available. Skipping SMTP notification.', error)
return false
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
try {
const transporter = nodemailer.createTransport({
host,
port: Number.isNaN(port) ? secure ? 465 : 587 : port,
secure,
auth: {
user,
pass,
},
})
await transporter.sendMail({ from, to, subject, text })
return true
} catch (error) {
console.error('Failed to send notification via SMTP', error)
return false
}
}
export async function sendAdminNotification(subject: string, text: string) {
const sentViaResend = await sendViaResend(subject, text)
if (sentViaResend) {
return true
}
const sentViaSmtp = await sendViaSmtp(subject, text)
if (sentViaSmtp) {
return true
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
return false
}

View File

@@ -0,0 +1,38 @@
import { UpdateSubscriber, UpdateSubscriberDocument } from '../models/UpdateSubscriber'
interface RegisterSubscriberOptions {
email: string
name?: string
source?: string
consentPrivacy: boolean
consentMarketing: boolean
}
export async function registerUpdateSubscriber(options: RegisterSubscriberOptions) {
const { email, name, source = 'landing-updates', consentPrivacy, consentMarketing } = options
const now = new Date()
const existing = await UpdateSubscriber.findOne({ email })
if (existing) {
existing.name = name || existing.name
existing.source = source || existing.source
existing.consentPrivacy = existing.consentPrivacy || consentPrivacy
existing.consentMarketing = existing.consentMarketing || consentMarketing
existing.lastUpdatedAt = now
await existing.save()
return { created: false, document: existing as UpdateSubscriberDocument }
}
const document = await UpdateSubscriber.create({
email,
name,
source,
consentPrivacy,
consentMarketing,
subscribedAt: now,
lastUpdatedAt: now,
})
return { created: true, document }
}

238
shared/utils/news.ts Normal file
View File

@@ -0,0 +1,238 @@
export interface NewsPost {
slug: string
title: string
excerpt: string
publishedAt: string
body: string
html: string
readingTime: string
}
interface FrontMatter {
title?: string
date?: string
summary?: string
excerpt?: string
description?: string
readingTime?: string
}
const rawNewsModules = import.meta.glob('~~/content/news/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>
const parsedNews: NewsPost[] = Object.entries(rawNewsModules)
.map(([path, raw]) => parseNewsFile(path, raw))
.filter((entry): entry is NewsPost => Boolean(entry))
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
export function getAllNews(): NewsPost[] {
return parsedNews.map((post) => ({ ...post }))
}
export function getNewsBySlug(slug: string): NewsPost | null {
const found = parsedNews.find((post) => post.slug === slug)
return found ? { ...found } : null
}
function parseNewsFile(path: string, raw: string): NewsPost | null {
if (!raw) {
return null
}
const { body, attributes } = extractFrontMatter(raw)
const slug = extractSlug(path)
const title = attributes.title?.trim() || toTitleFromSlug(slug)
const publishedAt = parseDate(attributes.date) || new Date().toISOString()
const cleanBody = body.trim()
const excerptSource = attributes.summary || attributes.excerpt || attributes.description || ''
const excerpt = truncateText(excerptSource || firstParagraph(cleanBody), 220)
const html = markdownToHtml(cleanBody)
const readingTime = attributes.readingTime || formatReadingTime(cleanBody)
return {
slug,
title,
excerpt,
publishedAt,
body: cleanBody,
html,
readingTime,
}
}
function extractFrontMatter(raw: string): { body: string; attributes: FrontMatter } {
const FRONT_MATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/
const match = raw.match(FRONT_MATTER_REGEX)
if (!match) {
return { body: raw, attributes: {} }
}
const [, frontMatter] = match
const rest = raw.slice(match[0].length)
return { body: rest, attributes: parseFrontMatter(frontMatter) }
}
function parseFrontMatter(source: string): FrontMatter {
const attributes: Record<string, string> = {}
const lines = source.split(/\r?\n/)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const colonIndex = trimmed.indexOf(':')
if (colonIndex === -1) continue
const key = trimmed.slice(0, colonIndex).trim()
const rawValue = trimmed.slice(colonIndex + 1).trim()
const value = rawValue.replace(/^['"]|['"]$/g, '').trim()
if (key) {
attributes[key] = value
}
}
return attributes
}
function extractSlug(path: string): string {
const normalized = path.replace(/\\/g, '/').split('/')
const file = normalized[normalized.length - 1] || ''
return file.replace(/\.md$/i, '')
}
function toTitleFromSlug(slug: string): string {
return slug
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ')
}
function parseDate(value?: string): string | null {
if (!value) return null
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed.toISOString()
}
function firstParagraph(body: string): string {
const lines = body.split(/\r?\n/)
const collected: string[] = []
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) {
if (collected.length) break
continue
}
if (line.startsWith('#') || line.startsWith('- ')) {
if (collected.length) break
continue
}
collected.push(line)
if (line.endsWith('.')) break
}
return collected.join(' ')
}
function truncateText(text: string, maxLength: number): string {
const trimmed = text.trim()
if (!trimmed) return ''
if (trimmed.length <= maxLength) return trimmed
return `${trimmed.slice(0, maxLength - 1).trimEnd()}`
}
function formatReadingTime(content: string): string {
const words = content.split(/\s+/).filter(Boolean)
const minutes = Math.max(1, Math.round(words.length / 180))
return `${minutes} Min Lesezeit`
}
function markdownToHtml(markdown: string): string {
const lines = markdown.split(/\r?\n/)
let html = ''
let inList = false
const closeList = () => {
if (inList) {
html += '</ul>'
inList = false
}
}
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) {
closeList()
continue
}
if (line.startsWith('#')) {
closeList()
const level = Math.min(line.match(/^#+/)?.[0].length ?? 1, 6)
const content = line.replace(/^#+/, '').trim()
html += `<h${level}>${formatInline(content)}</h${level}>`
continue
}
if (line.startsWith('- ')) {
if (!inList) {
html += '<ul>'
inList = true
}
html += `<li>${formatInline(line.slice(2).trim())}</li>`
continue
}
closeList()
html += `<p>${formatInline(line)}</p>`
}
closeList()
return html
}
function formatInline(value: string): string {
let text = escapeHtml(value)
text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, (_match, label, href) => {
const url = escapeAttribute(String(href))
return `<a href="${url}" class="text-cyan-300 underline">${String(label)}</a>`
})
text = text.replace(/`([^`]+)`/g, (_match, code) => `<code>${String(code)}</code>`)
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>')
return text
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case '\'':
return '&#39;'
default:
return char
}
})
}
function escapeAttribute(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case '\'':
return '&#39;'
default:
return char
}
})
}

5
types/nodemailer.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module 'nodemailer' {
const nodemailer: any
export function createTransport(options: any): any
export default nodemailer
}