Add feedback page and gate classroom behind orientation

This commit is contained in:
Remi
2025-10-05 13:55:00 +02:00
committed by itsrubberduck
parent b1848196d9
commit 913a118124
7 changed files with 404 additions and 9 deletions

View File

@@ -0,0 +1,10 @@
import {CLASSROOM_INTRO_STORAGE_KEY} from '~~/shared/constants/storage'
export default defineNuxtRouteMiddleware(() => {
if (process.server) return
const introComplete = localStorage.getItem(CLASSROOM_INTRO_STORAGE_KEY) === 'true'
if (!introComplete) {
return navigateTo('/classroom-introduction')
}
})

View File

@@ -13,7 +13,7 @@
<p class="text-sm text-white/60">Guided preflight briefing</p>
</div>
</div>
<NuxtLink to="/classroom" class="btn primary">
<NuxtLink to="/classroom" class="btn primary" @click="handleClassroomEntry">
Enter Classroom hub
<v-icon icon="mdi-launch" size="18" class="text-[#061318]" />
</NuxtLink>
@@ -167,7 +167,7 @@
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
Start guided tour
</button>
<NuxtLink to="/classroom" class="btn ghost">
<NuxtLink to="/classroom" class="btn ghost" @click="handleClassroomEntry">
Skip to Classroom
<v-icon icon="mdi-arrow-right" size="16" />
</NuxtLink>
@@ -382,10 +382,10 @@
Next stop
<v-icon icon="mdi-arrow-right" size="18" class="text-[#061318]" />
</button>
<NuxtLink v-else to="/classroom" class="btn primary">
<NuxtLink v-else to="/classroom" class="btn primary" @click="handleClassroomEntry">
Enter Classroom hub
<v-icon icon="mdi-launch" size="18" class="text-[#061318]" />
</NuxtLink>
</NuxtLink>
</div>
</div>
</div>
@@ -403,6 +403,7 @@
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useHead } from '#imports'
import { useApi } from '~/composables/useApi'
import { CLASSROOM_INTRO_STORAGE_KEY } from '~~/shared/constants/storage'
import type { PizzicatoLite } from '~~/shared/utils/pizzicatoLite'
import { loadPizzicatoLite } from '~~/shared/utils/pizzicatoLite'
import { clampReadability, createNoiseGenerators, getReadabilityProfile } from '~~/shared/utils/radioEffects'
@@ -420,6 +421,8 @@ interface StageStop {
voiceLine: string
}
definePageMeta({ middleware: 'require-auth' })
useHead({ title: 'Classroom orientation • OpenSquawk' })
const stages: StageStop[] = [
@@ -497,6 +500,15 @@ const stages: StageStop[] = [
const api = useApi()
function markClassroomIntroComplete() {
if (typeof window === 'undefined') return
localStorage.setItem(CLASSROOM_INTRO_STORAGE_KEY, 'true')
}
function handleClassroomEntry() {
markClassroomIntroComplete()
}
const voiceMode = ref<VoiceMode>('text')
const radioLevel = ref(3)
const hasCompletedRadioCheck = ref(false)

View File

@@ -122,6 +122,10 @@
</div>
<div class="hud-right">
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
<v-icon size="18">mdi-message-draw</v-icon>
Feedback
</NuxtLink>
<!-- ATC Einstellungen -->
<button class="btn ghost" @click="showSettings=true" title="Settings">
@@ -1165,6 +1169,8 @@
<div v-else class="container footer-container">
<div class="footer-meta">
<span class="muted small">&copy; 2025 OpenSquawk. All rights reserved.</span>
<span aria-hidden="true" class="muted small">·</span>
<NuxtLink to="/feedback" class="link small">Feedback &amp; ideas</NuxtLink>
</div>
</div>
</footer>
@@ -1278,7 +1284,7 @@ import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
definePageMeta({middleware: 'require-auth'})
definePageMeta({middleware: ['require-auth', 'require-classroom-intro']})
type Objective = {
id: string

338
app/pages/feedback.vue Normal file
View File

@@ -0,0 +1,338 @@
<template>
<div class="min-h-screen bg-[#070d1a] text-white">
<div class="relative isolate overflow-hidden">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-cyan-500/10 via-transparent to-indigo-500/20"></div>
<div class="relative z-10 mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-14 sm:px-6 md:px-8 lg:py-20">
<header class="space-y-6">
<div class="inline-flex items-center gap-3 rounded-2xl border border-cyan-400/30 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-500/10">
<v-icon icon="mdi-message-bulleted" size="22" class="text-cyan-200" />
<span>Feedback makes the product</span>
</div>
<div class="space-y-4">
<h1 class="text-3xl font-semibold sm:text-4xl md:text-5xl">Help us shape OpenSquawk</h1>
<p class="max-w-3xl text-base text-white/80 sm:text-lg">
We are trying to build something wonderful that stays free and if you want it hosted by us, the goal is to keep it as affordable as possible without compromising on quality. Your feedback is unbelievably valuable and helps us make smart decisions about what to ship next.
</p>
<p class="max-w-3xl text-base font-semibold text-cyan-100/80">
Seriously: every insight you share steers the roadmap. Thank you for lending us your perspective.
</p>
<p class="max-w-3xl text-sm text-white/70 sm:text-base">
Thank you for testing in spite of the rough edges. So many things are still work-in-progress and your notes make it possible to keep leveling up the experience for everyone. If you would like to jump on a quick call or even co-build with us (coding experience not required!), send a message to <a class="text-cyan-300 underline" href="mailto:info@opensquawk.de">info@opensquawk.de</a>.
</p>
<p class="max-w-3xl text-xs uppercase tracking-[0.3em] text-cyan-100/70">
We are currently a German development team, but all public docs and tools start in English so we can reach as many people as possible.
</p>
</div>
<div class="rounded-2xl border border-amber-400/30 bg-amber-400/10 p-5 text-amber-100 shadow-lg shadow-amber-500/10">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-[0.25em] text-amber-50/80">Quick heads-up</p>
<p class="text-sm text-amber-50/80">All notes are sent by email. When you submit below we prepare an email to <strong>info@opensquawk.de</strong> with your answers, just like the rest of our forms.</p>
</div>
<a
class="inline-flex items-center gap-2 rounded-xl border border-amber-200/40 bg-amber-200/10 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-200/20"
href="mailto:info@opensquawk.de?subject=OpenSquawk%20feedback%20call"
>
<v-icon icon="mdi-phone" size="18" class="text-amber-100" />
Request a call
</a>
</div>
</div>
</header>
<form class="space-y-10 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-xl shadow-cyan-500/10 backdrop-blur" @submit.prevent="handleSubmit">
<div class="grid gap-6 md:grid-cols-2">
<div class="space-y-2">
<label for="name" class="text-xs font-semibold uppercase tracking-[0.3em] text-white/50">Name (optional)</label>
<input
id="name"
v-model.trim="form.name"
type="text"
placeholder="How should we address you?"
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-cyan-400 focus:outline-none"
/>
</div>
<div class="space-y-2">
<label for="email" class="text-xs font-semibold uppercase tracking-[0.3em] text-white/50">Email (optional)</label>
<input
id="email"
v-model.trim="form.email"
type="email"
placeholder="Where we can reach you if needed"
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-cyan-400 focus:outline-none"
/>
</div>
</div>
<div class="space-y-4">
<p class="text-sm font-semibold uppercase tracking-[0.35em] text-white/60">Overall excitement</p>
<p class="text-sm text-white/70">How excited are you about OpenSquawk right now?</p>
<div class="flex flex-wrap gap-3">
<label
v-for="score in ratingScale"
:key="score"
class="flex items-center gap-2 rounded-2xl border border-white/10 bg-black/40 px-4 py-2 text-sm font-medium text-white/80 transition hover:border-cyan-400/70 hover:text-white"
>
<input
class="accent-cyan-400"
type="radio"
name="excitement"
:value="score"
v-model.number="form.excitement"
/>
<span>{{ score }}</span>
</label>
</div>
</div>
<div class="grid gap-8 lg:grid-cols-2">
<fieldset class="space-y-4">
<legend class="text-sm font-semibold uppercase tracking-[0.35em] text-white/60">What works well</legend>
<p class="text-sm text-white/70">Tick anything that feels promising already and tell us why.</p>
<div class="grid gap-3">
<label
v-for="option in highlightOptions"
:key="option"
class="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white/80 transition hover:border-cyan-400/60"
>
<input type="checkbox" class="mt-1 accent-cyan-400" :value="option" v-model="form.highlightSelections" />
<span>{{ option }}</span>
</label>
</div>
<textarea
v-model.trim="form.highlightNotes"
rows="4"
placeholder="Share the story behind the highlights"
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-cyan-400 focus:outline-none"
></textarea>
</fieldset>
<fieldset class="space-y-4">
<legend class="text-sm font-semibold uppercase tracking-[0.35em] text-white/60">Where it hurts</legend>
<p class="text-sm text-white/70">Checkbox anything that feels rough or confusing and describe it.</p>
<div class="grid gap-3">
<label
v-for="option in frictionOptions"
:key="option"
class="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white/80 transition hover:border-rose-400/60"
>
<input type="checkbox" class="mt-1 accent-rose-400" :value="option" v-model="form.frictionSelections" />
<span>{{ option }}</span>
</label>
</div>
<textarea
v-model.trim="form.frictionNotes"
rows="4"
placeholder="Help us understand what should be fixed first"
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-rose-400 focus:outline-none"
></textarea>
</fieldset>
</div>
<div class="space-y-4">
<label for="classroom-notes" class="text-sm font-semibold uppercase tracking-[0.35em] text-white/60">Classroom stories</label>
<p class="text-sm text-white/70">Tell us about the missions you tried, the radio training flow, anything that stood out.</p>
<textarea
id="classroom-notes"
v-model.trim="form.classroomNotes"
rows="5"
placeholder="What happened in the Classroom? What should we double down on?"
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-cyan-400 focus:outline-none"
></textarea>
</div>
<div class="space-y-4">
<label for="hosting-interest" class="text-sm font-semibold uppercase tracking-[0.35em] text-white/60">Hosting or deployment</label>
<p class="text-sm text-white/70">If you are planning to self-host or need us to host for you, what would make it a no-brainer?</p>
<textarea
id="hosting-interest"
v-model.trim="form.hostingInterest"
rows="4"
placeholder="Tell us about your setup, budget expectations, or blockers"
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-cyan-400 focus:outline-none"
></textarea>
</div>
<div class="space-y-4">
<label for="other-ideas" class="text-sm font-semibold uppercase tracking-[0.35em] text-white/60">Big ideas &amp; requests</label>
<p class="text-sm text-white/70">Dream loud features, integrations, workflows, anything that would make OpenSquawk remarkable for you.</p>
<textarea
id="other-ideas"
v-model.trim="form.otherIdeas"
rows="5"
placeholder="Share every idea. Wild and small ones welcome."
class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-cyan-400 focus:outline-none"
></textarea>
</div>
<div class="space-y-3">
<label class="flex items-start gap-3 text-sm text-white/80">
<input type="checkbox" class="mt-1 accent-cyan-400" v-model="form.contactConsent" />
<span>I am happy for you to email me back about this feedback or to invite me to jam on the roadmap.</span>
</label>
<p class="text-xs text-white/50">Submitting prepares an email draft to <strong>info@opensquawk.de</strong>. Please review it and hit send from your client.</p>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-cyan-400 px-6 py-3 text-base font-semibold text-slate-950 shadow-[0_16px_40px_rgba(56,189,248,0.35)] transition hover:bg-cyan-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<v-icon icon="mdi-send" size="20" class="text-slate-950" />
Prepare email
</button>
</div>
</form>
<section v-if="submissionState !== 'idle'" class="rounded-3xl border border-white/10 bg-white/5 p-6 text-sm text-white/80 shadow-xl shadow-cyan-500/10">
<h2 class="text-lg font-semibold text-white">Email preview</h2>
<p class="mt-2 text-white/70">
Your email client should open automatically. If not, copy the text below and send it to <a class="text-cyan-300 underline" href="mailto:info@opensquawk.de">info@opensquawk.de</a>.
</p>
<div class="mt-4 rounded-2xl border border-white/10 bg-black/50 p-4">
<pre class="whitespace-pre-wrap break-words text-sm text-white/80">{{ emailBody }}</pre>
</div>
<button
type="button"
class="mt-4 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition hover:border-cyan-400 hover:text-cyan-100"
@click="copyToClipboard"
>
<v-icon :icon="copyStatus === 'copied' ? 'mdi-check' : 'mdi-content-copy'" size="18" />
<span v-if="copyStatus === 'copied'">Copied to clipboard</span>
<span v-else-if="copyStatus === 'failed'">Copy not available</span>
<span v-else>Copy email text</span>
</button>
<p v-if="copyStatus === 'failed'" class="mt-2 text-xs text-rose-200/80">Please copy the text manually if your browser blocks clipboard access.</p>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useHead } from '#imports'
useHead({ title: 'Feedback & ideas • OpenSquawk' })
const ratingScale = [1, 2, 3, 4, 5] as const
const highlightOptions = [
'Classroom orientation and mission flow',
'Lesson console and radio playback',
'Voice quality or radio effects',
'Guidance from the instructor (Avery)',
'UI design and accessibility',
'Setup or invite experience',
]
const frictionOptions = [
'Finding the right lessons or missions',
'Audio reliability or clarity',
'Latency or responsiveness',
'Understanding the scoring and feedback',
'Installation or setup hurdles',
'Anything else that feels broken',
]
const form = reactive({
name: '',
email: '',
excitement: 4,
highlightSelections: [] as string[],
highlightNotes: '',
frictionSelections: [] as string[],
frictionNotes: '',
classroomNotes: '',
hostingInterest: '',
otherIdeas: '',
contactConsent: true,
})
const submissionState = ref<'idle' | 'ready'>('idle')
const emailBody = ref('')
const copyStatus = ref<'idle' | 'copied' | 'failed'>('idle')
const formattedHighlights = computed(() => (form.highlightSelections.length ? form.highlightSelections.join(', ') : '—'))
const formattedFrictions = computed(() => (form.frictionSelections.length ? form.frictionSelections.join(', ') : '—'))
function clipboardAvailable() {
return typeof navigator !== 'undefined' && !!navigator.clipboard && 'writeText' in navigator.clipboard
}
function buildEmailBody() {
const lines: string[] = []
lines.push('Hey OpenSquawk team,', '')
lines.push(`Name: ${form.name || '—'}`)
lines.push(`Email: ${form.email || '—'}`)
lines.push(`Okay to follow up: ${form.contactConsent ? 'Yes' : 'No'}`)
lines.push('')
lines.push(`Overall excitement (1-5): ${form.excitement}`)
lines.push(`What feels great: ${formattedHighlights.value}`)
if (form.highlightNotes) {
lines.push(form.highlightNotes)
}
lines.push('')
lines.push(`What feels rough: ${formattedFrictions.value}`)
if (form.frictionNotes) {
lines.push(form.frictionNotes)
}
lines.push('')
lines.push('Classroom stories:')
lines.push(form.classroomNotes || '—')
lines.push('')
lines.push('Hosting & deployment thoughts:')
lines.push(form.hostingInterest || '—')
lines.push('')
lines.push('Big ideas & requests:')
lines.push(form.otherIdeas || '—')
lines.push('')
lines.push('Thank you for building something ambitious and keeping it accessible!')
while (lines[lines.length - 1] === '') {
lines.pop()
}
return lines.join('\n')
}
async function handleSubmit() {
const body = buildEmailBody()
emailBody.value = body
submissionState.value = 'ready'
copyStatus.value = 'idle'
const subject = `OpenSquawk feedback excitement ${form.excitement}`
const mailto = `mailto:info@opensquawk.de?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
if (typeof window !== 'undefined') {
try {
if (clipboardAvailable()) {
await navigator.clipboard.writeText(body)
copyStatus.value = 'copied'
}
} catch {
copyStatus.value = 'failed'
}
window.location.href = mailto
}
}
async function copyToClipboard() {
if (!clipboardAvailable()) {
copyStatus.value = 'failed'
return
}
try {
await navigator.clipboard.writeText(emailBody.value)
copyStatus.value = 'copied'
} catch {
copyStatus.value = 'failed'
}
}
</script>
<style scoped>
pre {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, SFMono, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
</style>

View File

@@ -1185,6 +1185,11 @@ POST /api/route/taxi
Get involved
</NuxtLink>
</li>
<li>
<NuxtLink to="/feedback" class="hover:text-cyan-300">
Feedback &amp; ideas
</NuxtLink>
</li>
<li><a href="mailto:info@opensquawk.de" class="hover:text-cyan-300">info@opensquawk.de</a></li>
</ul>
</div>

View File

@@ -288,6 +288,7 @@ import {useRoute, useRouter} from 'vue-router'
import {useHead} from '#imports'
import {useAuthStore} from '~/stores/auth'
import {useApi} from '~/composables/useApi'
import {CLASSROOM_INTRO_STORAGE_KEY} from '~~/shared/constants/storage'
type Mode = 'login' | 'register'
@@ -355,7 +356,7 @@ async function submitLogin() {
try {
await auth.login({...loginForm})
await auth.fetchUser()
const target = redirectTarget.value || '/classroom'
const target = resolvePostAuthTarget(redirectTarget.value || null)
await router.replace(target)
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Login failed'
@@ -367,6 +368,28 @@ async function submitLogin() {
const img = '/img/login/img' + (Math.ceil(Math.random() * 3)) + '.jpeg'
function hasCompletedClassroomIntroduction(): boolean {
if (typeof window === 'undefined') return false
return localStorage.getItem(CLASSROOM_INTRO_STORAGE_KEY) === 'true'
}
function setClassroomIntroductionComplete(completed: boolean) {
if (typeof window === 'undefined') return
localStorage.setItem(CLASSROOM_INTRO_STORAGE_KEY, completed ? 'true' : 'false')
}
function resolvePostAuthTarget(preferred?: string | null) {
const introCompleted = hasCompletedClassroomIntroduction()
const fallback = introCompleted ? '/classroom' : '/classroom-introduction'
let target = preferred || fallback
if (!introCompleted && target.startsWith('/classroom') && target !== '/classroom-introduction') {
target = '/classroom-introduction'
}
return target
}
async function checkInvitationCode() {
if (!registerForm.invitationCode) {
invitationStatus.value = 'unknown'
@@ -408,8 +431,8 @@ async function submitRegister() {
acceptPrivacy: registerForm.acceptPrivacy,
})
await auth.fetchUser()
const target = redirectTarget.value || '/classroom'
await router.replace(target)
setClassroomIntroductionComplete(false)
await router.replace('/classroom-introduction')
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Registration failed'
registerError.value = message
@@ -427,7 +450,7 @@ useHead({
onMounted(() => {
if (auth.isAuthenticated) {
const target = redirectTarget.value || '/classroom'
const target = resolvePostAuthTarget(redirectTarget.value || null)
router.replace(target)
}
})

View File

@@ -0,0 +1 @@
export const CLASSROOM_INTRO_STORAGE_KEY = 'os_classroom_intro_completed'