/f/[slug] participant page (no layout reset hack — whole app is naked)

Direct port from flexsiebels worktree. Imports getInstanceBySlug from
$lib/server/feedback (which uses fdb()) — schema rename happens at the
helper level, page code is identical.

Behaviour:
- LocalStorage: feedback:display_name (global) + feedback:session:<slug>
- 3s polling /posts?since=<latest_ts>; auto-scroll on new
- Hidden posts: '(Beitrag entfernt)' for others; own session sees body + note
- Honeypot 'company' input (CSS-hidden, aria-hidden)
- 423 → closed banner; 429 → rate-limit message; required-validation client+server
- noindex meta + no-referrer
- Question types: short_text, long_text, single_choice, multi_choice, scale, boolean

Root +layout.svelte already gives the naked shell (no sidebar/footer/bottom-nav)
so the +layout@.svelte reset trick is unnecessary here.

bun run check: 0 errors, 5 warnings (known false-positive 'data captured at
init' on $state — data from server load doesn't change client-side; same warning
pattern as flexsiebels).
This commit is contained in:
mAi
2026-05-05 11:35:30 +02:00
parent 946c755f17
commit 4c68b48417
2 changed files with 530 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { getInstanceBySlug } from '$lib/server/feedback';
export const load: PageServerLoad = async ({ params }) => {
const inst = await getInstanceBySlug(params.slug);
if (!inst) error(404, 'Feedback instance not found');
return {
slug: inst.slug,
title: inst.title,
description: inst.description,
form_definition: inst.form_definition,
chat_enabled: inst.chat_enabled,
status: inst.status,
closed_at: inst.closed_at,
};
};

View File

@@ -0,0 +1,512 @@
<script lang="ts">
import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte';
import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/server/schemas';
let { data }: { data: PageData } = $props();
const isClosed = data.status === 'closed';
const formDef = data.form_definition as FeedbackFormDefinition | null;
const chatEnabled = data.chat_enabled;
const NAME_KEY = 'feedback:display_name';
const sessionKey = `feedback:session:${data.slug}`;
let displayName = $state('');
let sessionId = $state('');
let alreadySubmitted = $state(false);
let submitError = $state<string | null>(null);
let submitSuccess = $state(false);
let submitInFlight = $state(false);
let answers = $state<Record<string, unknown>>({});
interface ChatPost {
id: string;
display_name: string | null;
client_session_id: string;
body: string | null;
hidden: boolean;
created_at: string;
}
let posts = $state<ChatPost[]>([]);
let chatBody = $state('');
let chatError = $state<string | null>(null);
let chatStatus = $state<'open' | 'closed'>(data.status);
let chatPostInFlight = $state(false);
let pollHandle: ReturnType<typeof setInterval> | null = null;
let lastSeenAt: string | null = null;
let chatListEl = $state<HTMLDivElement | null>(null);
function uuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
function loadName(): string {
try {
return localStorage.getItem(NAME_KEY) ?? '';
} catch {
return '';
}
}
function loadSession(): { id: string; submitted: boolean } {
try {
const raw = localStorage.getItem(sessionKey);
if (raw) {
const parsed = JSON.parse(raw) as { id?: string; submitted?: boolean };
return { id: parsed.id ?? uuid(), submitted: parsed.submitted === true };
}
} catch {
// fall through
}
return { id: uuid(), submitted: false };
}
function saveSession(): void {
try {
localStorage.setItem(
sessionKey,
JSON.stringify({ id: sessionId, submitted: alreadySubmitted }),
);
} catch {
// localStorage disabled — silently ignore, anonymous-only path still works
}
}
function saveName(): void {
try {
localStorage.setItem(NAME_KEY, displayName);
} catch {
// silent
}
}
async function fetchPosts(initial = false): Promise<void> {
try {
const url = new URL(`/api/public/feedback/${data.slug}/posts`, location.origin);
if (lastSeenAt) url.searchParams.set('since', lastSeenAt);
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const body = (await res.json()) as { posts: ChatPost[]; status: 'open' | 'closed' };
chatStatus = body.status;
if (body.posts.length > 0) {
if (initial || !lastSeenAt) {
posts = body.posts;
} else {
const seen = new Set(posts.map((p) => p.id));
const fresh = body.posts.filter((p) => !seen.has(p.id));
if (fresh.length) posts = [...posts, ...fresh];
}
lastSeenAt = body.posts[body.posts.length - 1].created_at;
queueMicrotask(() => {
if (chatListEl) chatListEl.scrollTop = chatListEl.scrollHeight;
});
}
} catch {
// network blip — next poll retries
}
}
async function postChat(): Promise<void> {
const trimmed = chatBody.trim();
if (!trimmed || chatPostInFlight) return;
chatPostInFlight = true;
chatError = null;
try {
const res = await fetch(`/api/public/feedback/${data.slug}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
display_name: displayName.trim() || null,
client_session_id: sessionId,
body: trimmed,
}),
});
if (res.status === 423) {
chatStatus = 'closed';
chatError = 'Diese Sitzung wurde geschlossen.';
return;
}
if (res.status === 429) {
chatError = 'Zu viele Beiträge in kurzer Zeit. Bitte kurz warten.';
return;
}
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
chatError = j.error ?? `Fehler ${res.status}`;
return;
}
const j = (await res.json()) as { post: ChatPost };
const seen = new Set(posts.map((p) => p.id));
if (!seen.has(j.post.id)) {
posts = [...posts, j.post];
lastSeenAt = j.post.created_at;
}
chatBody = '';
saveName();
queueMicrotask(() => {
if (chatListEl) chatListEl.scrollTop = chatListEl.scrollHeight;
});
} catch (e) {
chatError = e instanceof Error ? e.message : 'Netzwerkfehler';
} finally {
chatPostInFlight = false;
}
}
async function submitForm(): Promise<void> {
if (submitInFlight) return;
submitInFlight = true;
submitError = null;
if (formDef) {
for (const q of formDef.questions) {
if (q.required) {
const v = answers[q.id];
const empty =
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '') ||
(Array.isArray(v) && v.length === 0);
if (empty) {
submitError = `Bitte beantworte: ${q.label}`;
submitInFlight = false;
return;
}
}
}
}
try {
const res = await fetch(`/api/public/feedback/${data.slug}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
display_name: displayName.trim() || null,
client_session_id: sessionId,
answers,
}),
});
if (res.status === 423) {
submitError = 'Diese Sitzung wurde geschlossen.';
return;
}
if (res.status === 429) {
submitError = 'Zu viele Antworten in kurzer Zeit. Bitte kurz warten.';
return;
}
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
submitError = j.error ?? `Fehler ${res.status}`;
return;
}
submitSuccess = true;
alreadySubmitted = true;
saveSession();
saveName();
} catch (e) {
submitError = e instanceof Error ? e.message : 'Netzwerkfehler';
} finally {
submitInFlight = false;
}
}
function resetSubmissionUi(): void {
submitSuccess = false;
alreadySubmitted = false;
answers = {};
saveSession();
}
function isMine(p: ChatPost): boolean {
return p.client_session_id === sessionId;
}
function fmtTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function toggleMultiChoice(q: FeedbackQuestion, opt: string): void {
if (q.type !== 'multi_choice') return;
const cur = (answers[q.id] as string[] | undefined) ?? [];
const next = cur.includes(opt) ? cur.filter((x) => x !== opt) : [...cur, opt];
answers = { ...answers, [q.id]: next };
}
function setAnswer(qid: string, value: unknown): void {
answers = { ...answers, [qid]: value };
}
onMount(() => {
displayName = loadName();
const s = loadSession();
sessionId = s.id;
alreadySubmitted = s.submitted;
saveSession();
if (chatEnabled) {
fetchPosts(true);
pollHandle = setInterval(() => fetchPosts(false), 3000);
}
});
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
});
</script>
<svelte:head>
<title>{data.title} — Feedback</title>
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<h1>{data.title}</h1>
{#if data.description}
<p>{data.description}</p>
{/if}
</header>
{#if isClosed || chatStatus === 'closed'}
<div class="fb-banner fb-banner--closed">
Diese Feedback-Sitzung ist geschlossen — neue Beiträge sind nicht mehr möglich.
</div>
{/if}
<div class="fb-section">
<div class="fb-name-row">
<label for="fb-name">Dein Name (optional):</label>
<input
id="fb-name"
type="text"
class="fb-input"
placeholder="Anonym"
maxlength="80"
bind:value={displayName}
oninput={saveName}
/>
</div>
</div>
{#if chatEnabled}
<section class="fb-section" aria-label="Live-Chat">
<h2>Live-Feedback</h2>
<div class="fb-chat">
<div class="fb-chat__list" bind:this={chatListEl}>
{#if posts.length === 0}
<div class="fb-chat__empty">Noch keine Beiträge — sei der erste.</div>
{/if}
{#each posts as p (p.id)}
<div
class="fb-chat__post {isMine(p) ? 'fb-chat__post--mine' : ''} {p.hidden && !isMine(p)
? 'fb-chat__post--hidden'
: ''}"
>
<div class="fb-chat__meta">
<span class="fb-chat__name">{p.display_name ?? 'anonym'}</span>
<span>{fmtTime(p.created_at)}</span>
</div>
<div class="fb-chat__body">
{#if p.hidden && !isMine(p)}
<em>(Beitrag entfernt)</em>
{:else if p.hidden && isMine(p)}
<em>(von Moderation entfernt — du siehst es noch)</em><br />{p.body}
{:else}
{p.body}
{/if}
</div>
</div>
{/each}
</div>
{#if chatStatus === 'open'}
<form
class="fb-chat__form"
onsubmit={(e) => {
e.preventDefault();
postChat();
}}
>
<textarea
class="fb-textarea"
placeholder="Dein Beitrag …"
maxlength="2000"
bind:value={chatBody}
rows="2"
></textarea>
{#if chatError}
<div class="fb-banner fb-banner--error">{chatError}</div>
{/if}
<div class="fb-chat__form-actions">
<button
type="submit"
class="fb-btn"
disabled={chatPostInFlight || chatBody.trim().length === 0}
>
{chatPostInFlight ? 'Sende …' : 'Senden'}
</button>
</div>
</form>
{/if}
</div>
</section>
{/if}
{#if formDef}
<section class="fb-section" aria-label="Formular">
<h2>Fragebogen</h2>
{#if submitSuccess}
<div class="fb-banner" style="background: #dcfce7; color: #14532d; border-color: #bbf7d0;">
Danke für dein Feedback!
</div>
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
Noch eine Antwort senden
</button>
{:else if alreadySubmitted}
<div class="fb-banner">
Du hast schon abgesendet. Du kannst trotzdem nochmal antworten:
</div>
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
Erneut antworten
</button>
{:else if isClosed}
<div class="fb-banner">Das Formular ist geschlossen.</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
submitForm();
}}
>
<input
type="text"
name="company"
class="fb-honeypot"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
{#each formDef.questions as q (q.id)}
<div class="fb-question">
<label class="fb-question__label" for={`q-${q.id}`}>
{q.label}{#if q.required}<span class="fb-question__required">*</span>{/if}
</label>
{#if q.type === 'short_text'}
<input
id={`q-${q.id}`}
type="text"
class="fb-input"
placeholder={q.placeholder ?? ''}
maxlength="500"
value={(answers[q.id] as string) ?? ''}
oninput={(e) => setAnswer(q.id, (e.target as HTMLInputElement).value)}
/>
{:else if q.type === 'long_text'}
<textarea
id={`q-${q.id}`}
class="fb-textarea"
placeholder={q.placeholder ?? ''}
maxlength="5000"
rows="4"
value={(answers[q.id] as string) ?? ''}
oninput={(e) => setAnswer(q.id, (e.target as HTMLTextAreaElement).value)}
></textarea>
{:else if q.type === 'single_choice'}
<div class="fb-options">
{#each q.options as opt (opt)}
<label class="fb-option-row">
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === opt}
onchange={() => setAnswer(q.id, opt)}
/>
<span>{opt}</span>
</label>
{/each}
</div>
{:else if q.type === 'multi_choice'}
<div class="fb-options">
{#each q.options as opt (opt)}
<label class="fb-option-row">
<input
type="checkbox"
checked={Array.isArray(answers[q.id]) &&
(answers[q.id] as string[]).includes(opt)}
onchange={() => toggleMultiChoice(q, opt)}
/>
<span>{opt}</span>
</label>
{/each}
</div>
{:else if q.type === 'scale'}
<div class="fb-scale">
{#each Array.from({ length: q.max - q.min + 1 }, (_, i) => i + q.min) as v (v)}
<button
type="button"
class="fb-scale__btn {answers[q.id] === v ? 'fb-scale__btn--active' : ''}"
onclick={() => setAnswer(q.id, v)}
>
{v}
</button>
{/each}
</div>
{#if q.min_label || q.max_label}
<div class="fb-scale__labels">
<span>{q.min_label ?? q.min}</span>
<span>{q.max_label ?? q.max}</span>
</div>
{/if}
{:else if q.type === 'boolean'}
<div class="fb-options">
<label class="fb-option-row">
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === true}
onchange={() => setAnswer(q.id, true)}
/>
<span>Ja</span>
</label>
<label class="fb-option-row">
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === false}
onchange={() => setAnswer(q.id, false)}
/>
<span>Nein</span>
</label>
</div>
{/if}
{#if q.help}
<div class="fb-question__help">{q.help}</div>
{/if}
</div>
{/each}
{#if submitError}
<div class="fb-banner fb-banner--error">{submitError}</div>
{/if}
<button type="submit" class="fb-btn" disabled={submitInFlight}>
{submitInFlight ? 'Sende …' : 'Absenden'}
</button>
</form>
{/if}
</section>
{/if}
<div class="fb-foot">flexsiebels.de · per-Link Feedback</div>
</div>