/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:
18
src/routes/f/[slug]/+page.server.ts
Normal file
18
src/routes/f/[slug]/+page.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
512
src/routes/f/[slug]/+page.svelte
Normal file
512
src/routes/f/[slug]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user