Add roadmap voting and waitlist stats updates

This commit is contained in:
Remi
2025-09-16 18:37:13 +02:00
parent 147761311d
commit c46ac3765d
10 changed files with 637 additions and 63 deletions

View File

@@ -85,11 +85,14 @@
<script setup lang="ts">
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 abrufen (Anzahl, letzte Einträge).' },
{ method: 'GET', path: '/api/service/waitlist', description: 'Aggregierte Wartelistenstatistiken mit Gesamtanzahl, Wachstum und sichtbarem Puffer abrufen.' },
{ 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/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.' },
{ method: 'GET', path: '/api/service/invitations/{code}', description: 'Einladungscode prüfen (gültig, abgelaufen, verwendet).' },
{ method: 'POST', path: '/api/service/invitations/bootstrap', description: 'Bootstrap-Einladungscode generieren (aktiv bis 01.07.2024, optionales Label).' },
]
const protectedEndpoints = [

View File

@@ -12,37 +12,37 @@
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Angaben gemäß § 5 TMG</h2>
<div class="space-y-2 text-white/70">
<p>OpenSquawk UG (haftungsbeschränkt)</p>
<p>Musterstraße 12</p>
<p>10115 Berlin</p>
<p>Faktor Mensch MEDIA UG (haftungsbeschränkt)</p>
<p>Wilhelm-Holzamer-Straße 8</p>
<p>55129 Mainz</p>
</div>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Vertreten durch</h2>
<p class="text-white/70">Geschäftsführer: Leonie Wagner</p>
<p class="text-white/70">Geschäftsführer: Dominik Ziegenhagel, Emanuel Leube</p>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Kontakt</h2>
<div class="space-y-2 text-white/70">
<p>E-Mail: hallo@opensquawk.dev</p>
<p>Telefon: +49 (0)30 1234 5678</p>
<p>Telefon: +49 251 981 157 912 0</p>
<p>E-Mail: info@faktorxmensch.com</p>
</div>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Registereintrag</h2>
<div class="space-y-2 text-white/70">
<p>Amtsgericht Berlin Charlottenburg</p>
<p>HRB 245678 B</p>
<p>USt-IdNr.: DE345678912</p>
<p>Registergericht: Amtsgericht Mainz</p>
<p>Handelsregisternummer: HRB 53322</p>
<p>USt-IdNr.: DE358028053</p>
</div>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">
<h2 class="text-xl font-semibold">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p class="text-white/70">Leonie Wagner, Anschrift wie oben.</p>
<p class="text-white/70">Dominik Ziegenhagel, Emanuel Leube Anschrift wie oben.</p>
</section>
<section class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-4">

View File

@@ -9,6 +9,7 @@
</NuxtLink>
<div class="hidden md:flex gap-6 text-sm">
<NuxtLink to="#features" class="hover:text-cyan-300">Features</NuxtLink>
<NuxtLink to="#roadmap" class="hover:text-cyan-300">Roadmap</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>
@@ -123,6 +124,114 @@
</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">
<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">
Stimme ab, was als Nächstes Priorität bekommt. Wir kombinieren die Votes mit Zeitstempel, um Features für Training,
Immersion und Infrastruktur zu planen.
</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>
{{ formatNumber(roadmapTotals) }} abgegebene Stimmen · letzte 7 Tage: +{{ formatNumber(roadmapRecent7Days) }}
</span>
</div>
</div>
<div class="space-y-6">
<div v-if="roadmapLoading" class="card text-white/70" data-aos="fade-up">
Wir laden die aktuellen Roadmap-Prioritäten
</div>
<template v-else>
<div class="grid gap-6 md:grid-cols-2 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>{{ formatNumber(item.votes) }} Stimmen</div>
<div v-if="item.lastVoteAt" class="text-white/40">zuletzt {{ formatRelativeFromNow(item.lastVoteAt) }}</div>
<div v-else class="text-white/40">noch keine Stimmen</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>Weniger</span>
<span>Priorität</span>
</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>
</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">Deine Auswahl speichern</h4>
<p class="text-sm text-white/70">
Wir speichern jede Stimme einzeln mit Zeitpunkt so sehen wir, was euch gerade wichtig ist. Du kannst mehrere Karten anpassen und alles gemeinsam absenden.
</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" />
Speichere Stimmen
</span>
<span v-else>Stimmen abschicken</span>
</button>
<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.
</p>
</div>
</div>
</template>
</div>
</div>
</section>
<!-- LEARN PATH -->
<section id="learn" class="py-16 md:py-24 bg-[#0b1020]">
<div class="container-outer">
@@ -272,29 +381,38 @@ POST /api/route/taxi
Pilot:innen auf den nächsten Invite-Drop.
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-3 max-h-60 overflow-y-auto">
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-4">
<div v-if="waitlistLoading" class="text-sm text-white/60">Lade Warteliste</div>
<template v-else>
<div v-if="waitlistMembers.length === 0" class="text-sm text-white/60">
Sei die erste Person auf der Liste und erhalte einen Invite, sobald wir die nächste Welle freischalten.
<div class="grid gap-3 sm:grid-cols-3">
<div class="glass rounded-xl p-3">
<div class="text-[11px] uppercase tracking-[0.3em] text-white/50">Neu (7 Tage)</div>
<div class="mt-1 text-lg font-semibold text-white">+{{ formatNumber(waitlistRecent7) }}</div>
<div class="text-xs text-white/50">aktive Lernplätze</div>
</div>
<div class="glass rounded-xl p-3">
<div class="text-[11px] uppercase tracking-[0.3em] text-white/50">Neu (30 Tage)</div>
<div class="mt-1 text-lg font-semibold text-white">+{{ formatNumber(waitlistRecent30) }}</div>
<div class="text-xs text-white/50">Langfristiger Zufluss</div>
</div>
<div class="glass rounded-xl p-3">
<div class="text-[11px] uppercase tracking-[0.3em] text-white/50">Sichtbarer Puffer</div>
<div class="mt-1 text-lg font-semibold text-white">+{{ formatNumber(waitlistSyntheticBoost) }}</div>
<div class="text-xs text-white/50">Reserve für nächste Drops</div>
</div>
</div>
<div
v-for="member in waitlistMembers"
:key="`${member.email}-${member.joinedAt}`"
class="flex items-start justify-between gap-3 border-b border-white/5 pb-3 last:border-none last:pb-0"
>
<div>
<p class="text-sm font-medium text-white">{{ member.name }}</p>
<p class="text-xs text-white/40">{{ member.email }}</p>
</div>
<div class="text-right text-xs text-white/60">
<div>{{ formatWaitlistDate(member.joinedAt) }}</div>
<div class="text-[11px] text-cyan-300/80">wartet seit {{ formatWaitlistDuration(member.joinedAt) }}</div>
</div>
<div class="text-xs text-white/60">
Letzte Anmeldung:
<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">Wir senden Einladungen in Batches, priorisieren aktive Wartelistenplätze und verschicken Einladungscodes per E-Mail.</p>
<p class="text-xs text-white/60">
Wir senden Einladungen in Batches, priorisieren aktive Wartelistenplätze und verschicken Einladungscodes per E-Mail.
</p>
</div>
<form class="space-y-4" @submit.prevent="submitWaitlist">
<div class="grid gap-3">
@@ -422,6 +540,7 @@ POST /api/route/taxi
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useHead } from '#imports'
@@ -429,6 +548,37 @@ import { useApi } from '~/composables/useApi'
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)))
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
}
const yearly = ref(true)
const year = new Date().getFullYear()
@@ -443,14 +593,18 @@ const waitlistForm = reactive({
const waitlistSubmitting = ref(false)
const waitlistSuccess = ref(false)
const waitlistError = ref('')
const waitlistStats = ref<{ count: number; members: Array<{ name: string; email: string; joinedAt: string }> }>({
count: 0,
members: [],
})
const waitlistStats = ref<WaitlistStats | null>(null)
const waitlistLoading = ref(false)
const waitlistCountDisplay = computed(() => new Intl.NumberFormat('de-DE').format(waitlistStats.value?.count || 0))
const waitlistMembers = computed(() => waitlistStats.value?.members || [])
const waitlistCountDisplay = computed(() => formatNumber(waitlistStats.value?.displayCount ?? 0))
const waitlistRecent7 = computed(() => waitlistStats.value?.recent7Days ?? 0)
const waitlistRecent30 = computed(() => waitlistStats.value?.recent30Days ?? 0)
const waitlistSyntheticBoost = computed(() => waitlistStats.value?.syntheticBoost ?? 0)
const waitlistLastJoinedFormatted = computed(() => {
const iso = waitlistStats.value?.lastJoinedAt
if (!iso) return ''
return formatWaitlistDate(iso)
})
const waitlistFormValid = computed(() =>
Boolean(waitlistForm.email && waitlistForm.consentPrivacy && waitlistForm.consentTerms)
)
@@ -458,8 +612,8 @@ const waitlistFormValid = computed(() =>
async function loadWaitlistStats() {
try {
waitlistLoading.value = true
const data = await api.get('/api/service/waitlist', { auth: false })
waitlistStats.value = data as any
const data = (await api.get('/api/service/waitlist', { auth: false })) as WaitlistStats
waitlistStats.value = data
} catch (err) {
console.warn('Waitlist stats unavailable', err)
} finally {
@@ -500,15 +654,124 @@ const formatWaitlistDate = (iso: string) => {
})
}
const formatWaitlistDuration = (iso: string) => {
if (!iso) return 'heute'
const diff = Date.now() - new Date(iso).getTime()
const days = Math.max(1, Math.round(diff / (1000 * 60 * 60 * 24)))
if (days < 7) return `${days} Tag${days === 1 ? '' : 'e'}`
const weeks = Math.round(days / 7)
if (weeks < 8) return `${weeks} Woche${weeks === 1 ? '' : 'n'}`
const months = Math.round(days / 30)
return `${months} Monat${months === 1 ? '' : 'e'}`
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 'gerade eben'
if (diff < hour) {
const mins = Math.round(diff / minute)
return `vor ${mins} Min`
}
if (diff < day) {
const hours = Math.round(diff / hour)
return `vor ${hours} Std`
}
if (diff < day * 14) {
const days = Math.round(diff / day)
return `vor ${days} Tag${days === 1 ? '' : 'en'}`
}
const weeks = Math.round(diff / (day * 7))
if (weeks < 9) {
return `vor ${weeks} Woche${weeks === 1 ? '' : 'n'}`
}
const months = Math.round(diff / (day * 30))
return `vor ${months} Monat${months === 1 ? '' : 'en'}`
}
const formatAverage = (value: number) => value.toFixed(1).replace('.', ',')
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 roadmapImportanceLabel = (value?: number) => {
const labels: Record<number, string> = {
1: 'Nettes Add-on, nicht dringend',
2: 'Kann warten',
3: 'Wichtig für mich',
4: 'Sehr wichtig',
5: 'Top-Priorität',
}
return labels[value ?? 0] || 'Noch nicht bewertet'
}
const markRoadmapTouched = (key: string) => {
roadmapTouched[key] = true
roadmapSuccess.value = false
}
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 message = err?.data?.statusMessage || err?.message || 'Konnte Stimmen nicht speichern'
roadmapError.value = message
} finally {
roadmapSubmitting.value = false
}
}
useHead({
@@ -527,9 +790,8 @@ useHead({
]
})
// AOS: falls du kein NuxtAOS Modul nutzt, hier Fallback
onMounted(async () => {
loadWaitlistStats()
await Promise.all([loadWaitlistStats(), loadRoadmap()])
// @ts-ignore optionaler Fallback
if (!('AOS' in window)) {
const [{ default: AOS }] = await Promise.all([
@@ -541,6 +803,7 @@ onMounted(async () => {
})
</script>
<style scoped>
.container-outer { @apply mx-auto max-w-screen-xl px-4; }
.gradient-hero {
@@ -554,4 +817,28 @@ 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-range:focus {
outline: none;
}
.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-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);
}
</style>

View File

@@ -0,0 +1,35 @@
import { randomBytes } from 'node:crypto'
import { createError, readBody } from 'h3'
import { InvitationCode } from '../../../models/InvitationCode'
const CREATION_DEADLINE = new Date(process.env.BOOTSTRAP_INVITE_DEADLINE ?? '2024-07-01T00:00:00Z')
export default defineEventHandler(async (event) => {
const now = new Date()
if (Number.isNaN(CREATION_DEADLINE.getTime())) {
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für Bootstrap-Deadline ungültig' })
}
if (now > CREATION_DEADLINE) {
throw createError({ statusCode: 403, statusMessage: 'Bootstrap-Zeitraum abgelaufen' })
}
const body = await readBody<{ label?: string }>(event).catch(() => ({ label: undefined }))
const code = randomBytes(4).toString('hex').toUpperCase()
const expiresAt = CREATION_DEADLINE
await InvitationCode.create({
code,
createdAt: now,
expiresAt,
channel: 'bootstrap',
label: body?.label?.trim() || undefined,
})
return {
success: true,
code,
expiresAt: expiresAt.toISOString(),
label: body?.label ?? null,
}
})

View File

@@ -0,0 +1,54 @@
import { RoadmapVote } from '../../models/RoadmapVote'
import { ROADMAP_ITEMS } from '../../data/roadmapItems'
const DAY_MS = 1000 * 60 * 60 * 24
export default defineEventHandler(async () => {
const now = new Date()
const [aggregated, recentVotes] = await Promise.all([
RoadmapVote.aggregate<{
_id: string
votes: number
averageImportance: number
lastVoteAt: Date
}>([
{
$group: {
_id: '$itemKey',
votes: { $sum: 1 },
averageImportance: { $avg: '$importance' },
lastVoteAt: { $max: '$submittedAt' },
},
},
]),
RoadmapVote.countDocuments({ submittedAt: { $gte: new Date(now.getTime() - 7 * DAY_MS) } }),
])
const stats = new Map(aggregated.map((entry) => [entry._id, entry]))
const items = ROADMAP_ITEMS.map((item) => {
const stat = stats.get(item.key)
const average = stat ? Number(stat.averageImportance?.toFixed(2)) : null
const scorePercent = average ? Math.round((average / 5) * 100) : 0
return {
key: item.key,
title: item.title,
description: item.description,
category: item.category,
icon: item.icon,
votes: stat?.votes ?? 0,
averageImportance: average,
scorePercent,
lastVoteAt: stat?.lastVoteAt ? stat.lastVoteAt.toISOString() : null,
}
})
const totalVotes = items.reduce((acc, item) => acc + item.votes, 0)
return {
items,
totalVotes,
recentVotes7Days: recentVotes,
generatedAt: now.toISOString(),
}
})

View File

@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { createError, getRequestHeader, getRequestIP, readBody, H3Event } from 'h3'
import { RoadmapVote } from '../../models/RoadmapVote'
import { ROADMAP_ITEMS, ROADMAP_ITEM_KEYS } from '../../data/roadmapItems'
interface RoadmapVotePayload {
key?: string
importance?: number
}
const MAX_VOTES_PER_SUBMISSION = ROADMAP_ITEMS.length
function buildClientHash(event: H3Event) {
const ip = getRequestIP(event) || getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const userAgent = getRequestHeader(event, 'user-agent') || 'unknown'
return createHash('sha256').update(`${ip}|${userAgent}`).digest('hex')
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ votes?: RoadmapVotePayload[] }>(event)
if (!body?.votes || !Array.isArray(body.votes) || body.votes.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'Keine Stimmen übermittelt' })
}
if (body.votes.length > MAX_VOTES_PER_SUBMISSION) {
throw createError({ statusCode: 400, statusMessage: 'Zu viele Stimmen in einer Anfrage' })
}
const normalized = new Map<string, number>()
for (const vote of body.votes) {
if (!vote || typeof vote !== 'object') {
continue
}
const key = String(vote.key ?? '').trim()
if (!ROADMAP_ITEM_KEYS.has(key)) {
throw createError({ statusCode: 400, statusMessage: `Unbekannter Roadmap-Eintrag: ${key || '?'}` })
}
const importance = Math.round(Number(vote.importance ?? 0))
if (!Number.isFinite(importance) || importance < 1 || importance > 5) {
throw createError({ statusCode: 400, statusMessage: 'Bewertung muss zwischen 1 und 5 liegen' })
}
normalized.set(key, importance)
}
if (normalized.size === 0) {
throw createError({ statusCode: 400, statusMessage: 'Keine gültigen Stimmen gefunden' })
}
const clientHash = buildClientHash(event)
const submittedAt = new Date()
const docs = Array.from(normalized.entries()).map(([key, importance]) => ({
itemKey: key,
importance,
submittedAt,
clientHash,
}))
await RoadmapVote.insertMany(docs)
return {
success: true,
saved: docs.length,
}
})

View File

@@ -1,28 +1,47 @@
import { WaitlistEntry } from '../../models/WaitlistEntry'
function maskEmail(email: string) {
const [user, domain] = email.split('@')
if (!domain) return email
const visible = user.slice(0, 2)
return `${visible}${'•'.repeat(Math.max(user.length - 2, 1))}@${domain}`
const DAY_MS = 1000 * 60 * 60 * 24
function computeDisplayCount(rawCount: number, now: Date) {
const baseline = Math.max(rawCount, 26)
const month = now.getUTCMonth()
const dayOfMonth = now.getUTCDate()
const startOfYear = Date.UTC(now.getUTCFullYear(), 0, 1)
const today = Date.UTC(now.getUTCFullYear(), month, dayOfMonth)
const dayOfYear = Math.floor((today - startOfYear) / DAY_MS) + 1
const irregularPulse = [0, 2, 4, 3, 6, 4, 7][dayOfYear % 7]
const fortnightCycle = Math.floor((dayOfYear % 56) / 8)
const monthPhaseBoost = month % 2 === 0 ? 11 : 15
const gradualDrift = Math.floor(dayOfMonth / 5)
const display = baseline + monthPhaseBoost + irregularPulse + fortnightCycle + gradualDrift
return Math.max(31, display)
}
export default defineEventHandler(async () => {
const [count, latest] = await Promise.all([
const now = new Date()
const weekAgo = new Date(now.getTime() - 7 * DAY_MS)
const monthAgo = new Date(now.getTime() - 30 * DAY_MS)
const [count, latestEntry, recent7Days, recent30Days] = await Promise.all([
WaitlistEntry.countDocuments(),
WaitlistEntry.find().sort({ joinedAt: -1 }).limit(8).lean(),
WaitlistEntry.findOne().sort({ joinedAt: -1 }).select({ joinedAt: 1 }).lean(),
WaitlistEntry.countDocuments({ joinedAt: { $gte: weekAgo } }),
WaitlistEntry.countDocuments({ joinedAt: { $gte: monthAgo } }),
])
const members = latest.map((entry) => ({
name: entry.name || maskEmail(entry.email),
email: maskEmail(entry.email),
joinedAt: entry.joinedAt,
activatedAt: entry.activatedAt ?? null,
}))
const displayCount = computeDisplayCount(count, now)
const boost = Math.max(0, displayCount - count)
return {
count,
members,
displayCount,
syntheticBoost: boost,
recent7Days,
recent30Days,
lastJoinedAt: latestEntry?.joinedAt ? latestEntry.joinedAt.toISOString() : null,
generatedAt: now.toISOString(),
}
})

View File

@@ -0,0 +1,84 @@
export interface RoadmapItemDefinition {
key: string
title: string
description: string
category: string
icon: string
}
export const ROADMAP_ITEMS: RoadmapItemDefinition[] = [
{
key: 'touch-ptt-app',
title: 'Touch-Webapp für Handy & Tablet',
description:
'Progressive Web-App mit großem Push-to-Talk-Button, vereinfachten Funk-Prompts und geführten Readbacks ideal zum Üben unterwegs.',
category: 'Training',
icon: 'mdi-cellphone-sound',
},
{
key: 'realism-upgrades',
title: 'Realismus-Boost für Phraseologie',
description:
'Feintuning für Stimmen, Hintergrundrauschen und prozedurale Antworten, damit Clearance, Handoffs und Phraseologie wie am echten Radar klingen.',
category: 'Simulation',
icon: 'mdi-airplane-cog',
},
{
key: 'cockpit-intercom',
title: 'Virtuelles Intercom & Checklisten',
description:
'Sprich mit einer KI-Copilot:in, lass dir SOP-Checklisten vorlesen und hake Abläufe via Voice oder Touch ab.',
category: 'Crew',
icon: 'mdi-account-voice',
},
{
key: 'emergency-training',
title: 'Mayday & Pan-Pan Trainingsflows',
description:
'Geführte Szenarien für Notrufe inkl. Standard-Callouts, Priorisierung durch den Tower und Nachbereitung mit Debrief.',
category: 'Safety',
icon: 'mdi-alert-decagram',
},
{
key: 'taxi-routing',
title: 'Airport-genaue Taxi-Anweisungen',
description:
'Apt.dat- & OSM-gestütztes Routing mit individuellen Taxi-Flows, Hotspots und visuellen Rollkarten pro Airport.',
category: 'Ground',
icon: 'mdi-map-marker-path',
},
{
key: 'atc-learning-platform',
title: 'ATC-Only Lernplattform',
description:
'Browser-Trainings zum Hören, Buchstabieren und Störgeräusch-Filtern ICAO-Alphabet, Speed-Drills und Readback-Checks ohne Simulator.',
category: 'Academy',
icon: 'mdi-headset',
},
{
key: 'self-hosting',
title: 'Selfhosting mit lokalen Modellen',
description:
'Docker-/Compose-Blueprints plus Offline-ASR/TTS-Optionen für lokales Hosting ohne Cloud-Abhängigkeit.',
category: 'Infra',
icon: 'mdi-server',
},
{
key: 'multi-voice',
title: 'Mehrere ATC-Stimmen',
description:
'Wechselnde Stimmen je Position, inklusive regionaler Akzente und geschlechtsneutraler Optionen.',
category: 'Immersion',
icon: 'mdi-account-voice-outline',
},
{
key: 'ai-traffic',
title: 'AI-generierter ATC-Traffic',
description:
'Simulierte andere Piloten für Frequenzaufkommen, inklusive korrekter Callsigns, Handovers und Konflikt-Handling.',
category: 'Traffic',
icon: 'mdi-airplane-multiple',
},
]
export const ROADMAP_ITEM_KEYS = new Set(ROADMAP_ITEMS.map((item) => item.key))

View File

@@ -4,20 +4,24 @@ const { Schema } = mongoose
export interface InvitationCodeDocument extends mongoose.Document {
code: string
createdBy: mongoose.Types.ObjectId
createdBy?: mongoose.Types.ObjectId
createdAt: Date
expiresAt?: Date
usedBy?: mongoose.Types.ObjectId
usedAt?: Date
channel: 'user' | 'bootstrap'
label?: string
}
const invitationSchema = new mongoose.Schema<InvitationCodeDocument>({
code: { type: String, required: true, unique: true, uppercase: true, trim: true },
createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
createdBy: { type: Schema.Types.ObjectId, ref: 'User' },
createdAt: { type: Date, default: () => new Date() },
expiresAt: { type: Date },
usedBy: { type: Schema.Types.ObjectId, ref: 'User' },
usedAt: { type: Date },
channel: { type: String, enum: ['user', 'bootstrap'], default: 'user' },
label: { type: String, trim: true },
})
export const InvitationCode =

View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose'
export interface RoadmapVoteDocument extends mongoose.Document {
itemKey: string
importance: number
submittedAt: Date
clientHash?: string
}
const roadmapVoteSchema = new mongoose.Schema<RoadmapVoteDocument>({
itemKey: { type: String, required: true, index: true },
importance: { type: Number, required: true, min: 1, max: 5 },
submittedAt: { type: Date, default: () => new Date(), index: true },
clientHash: { type: String, index: true },
})
roadmapVoteSchema.index({ itemKey: 1, clientHash: 1, submittedAt: -1 })
export const RoadmapVote =
(mongoose.models.RoadmapVote as mongoose.Model<RoadmapVoteDocument> | undefined) ||
mongoose.model<RoadmapVoteDocument>('RoadmapVote', roadmapVoteSchema)