From 778df213da212bf6910f69d9ed210c75e362b09f Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 6 May 2026 15:44:36 +0200 Subject: [PATCH] mAi: #4 - single-submission follow-up fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bugs from m's smoke pass on the just-shipped single-submission feature: 1. Reload showed the legacy "you can submit again" branch instead of the read-only summary, because the client never refetched its previous submission. Fix: page server load now does an IP+UA backstop lookup so first paint is correct; client onMount supplements with a session_id lookup against the new GET ?session_id= variant for the cleared-cookies-but-same-browser case. Renamed JSON field previous_submission to keep server/client shape symmetric. Same parametised .eq() pattern as the submit handler — no PostgREST .or() with a user-controlled session id. 2. Trailing colon in "Du hast schon abgesendet. Du kannst trotzdem nochmal antworten:" reads like an unfinished sentence. Rewrote as a question: "Du hast bereits abgesendet. Möchtest du eine weitere Antwort senden?" The branch is now also gated on !singleSubmission — when the toggle is on it never fires (the previous_submission branch wins). 3. The .fb-already card looked like a form replica (boxes around values). Replaced with a confirmation summary: ✓-icon header ("Antwort gesendet" + timestamp), then a definition list with muted labels above plain values, no input outlines. On ≥560px the rows become a two-column grid with light dividers. 4. The "Noch eine Antwort senden" ghost button on the success card was misleading when single_submission is on (clicking it 409s on next submit). Hidden when singleSubmission is true; the success banner alone now stands. bun check 0 errors, bun test 25 pass, bun build OK. --- src/lib/styles/feedback.css | 86 ++++++++++++++---- .../api/public/feedback/[slug]/+server.ts | 69 ++++++++++++-- src/routes/f/[slug]/+page.server.ts | 37 +++++++- src/routes/f/[slug]/+page.svelte | 89 ++++++++++++++----- 4 files changed, 237 insertions(+), 44 deletions(-) diff --git a/src/lib/styles/feedback.css b/src/lib/styles/feedback.css index 68d46b7..a8676ab 100644 --- a/src/lib/styles/feedback.css +++ b/src/lib/styles/feedback.css @@ -899,42 +899,94 @@ body { min-height: 100vh; } } } -/* "Already submitted" read-only summary card on /f/[slug]. */ +/* Confirmation summary card on /f/[slug] when single_submission is on and the + participant has already answered. Reads as "thanks, here's what you said" — not + as a re-render of the form. */ -.fb-already { +.fb-summary { + background: var(--color-bg-secondary); + border-radius: var(--radius-lg); + padding: 1.5rem; display: flex; flex-direction: column; - gap: 0.6rem; - margin-top: 0.6rem; + gap: 1.25rem; } -.fb-already__name { - font-size: 0.9rem; +.fb-summary__head { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.fb-summary__check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + background: var(--color-success, #22c55e); + color: #fff; + font-weight: 700; + font-size: 1.1rem; + flex: 0 0 auto; +} + +.fb-summary__title { + font-size: 1.05rem; + font-weight: 600; + color: var(--color-text-primary); + line-height: 1.2; +} + +.fb-summary__subtitle { + font-size: 0.85rem; color: var(--fb-muted); + margin-top: 0.1rem; } -.fb-already__row { +.fb-summary__list { + margin: 0; + padding: 0; display: flex; flex-direction: column; - gap: 0.2rem; - padding: 0.6rem 0.75rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-bg-primary); } -.fb-already__label { - font-size: 0.78rem; +.fb-summary__row { + display: grid; + grid-template-columns: 1fr; + gap: 0.15rem; + padding: 0.7rem 0; + border-top: 1px solid var(--color-border-primary); +} + +.fb-summary__row:first-child { + border-top: none; + padding-top: 0; +} + +.fb-summary__term { + font-size: 0.82rem; + font-weight: 500; color: var(--fb-muted); - text-transform: uppercase; - letter-spacing: 0.04em; + margin: 0; } -.fb-already__value { +.fb-summary__desc { font-size: 0.95rem; color: var(--color-text-primary); + margin: 0; white-space: pre-wrap; word-break: break-word; + line-height: 1.4; +} + +@media (min-width: 560px) { + .fb-summary__row { + grid-template-columns: minmax(8rem, 14rem) minmax(0, 1fr); + gap: 1rem; + align-items: baseline; + } } /* date_ranked_choice — view toggle + calendar + bars */ diff --git a/src/routes/api/public/feedback/[slug]/+server.ts b/src/routes/api/public/feedback/[slug]/+server.ts index 4014d2a..b49c852 100644 --- a/src/routes/api/public/feedback/[slug]/+server.ts +++ b/src/routes/api/public/feedback/[slug]/+server.ts @@ -1,26 +1,85 @@ /** - * GET /api/public/feedback/ - * - * Returns the public-facing instance config. No auth — slug is the access token. + * GET /api/public/feedback/ — public-facing instance config (slug = access token). + * GET /api/public/feedback/?session_id= — also resolves the caller's previous submission + * when single_submission is on, so the participant + * page can render the read-only summary on first + * paint instead of falling through to the legacy + * "submit again" branch on reload. */ import type { RequestHandler } from './$types'; import { json } from '$lib/server/response'; import { handleApiError, notFound } from '$lib/server/errors'; -import { getInstanceBySlug } from '$lib/server/feedback'; +import { getInstanceBySlug, clampUserAgent } from '$lib/server/feedback'; +import { fdb } from '$lib/server/fdb'; -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, url, request, getClientAddress }) => { try { const inst = await getInstanceBySlug(params.slug); if (!inst) return notFound('Feedback instance not found'); + let previousSubmission: + | { submitted_at: string; display_name: string | null; answers: unknown } + | null = null; + + if (inst.single_submission) { + const cols = 'answers, display_name, created_at'; + const sessionId = url.searchParams.get('session_id'); + + if (sessionId && sessionId.length > 0 && sessionId.length <= 100) { + const r = await fdb() + .from('feedback_submissions') + .select(cols) + .eq('instance_id', inst.id) + .eq('client_session_id', sessionId) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (r.error) throw r.error; + if (r.data) { + previousSubmission = { + submitted_at: r.data.created_at, + display_name: r.data.display_name, + answers: r.data.answers, + }; + } + } + + // Back-stop: same IP + UA. Catches the "cleared LocalStorage but same browser" case. + if (!previousSubmission) { + const ip = getClientAddress(); + const ua = clampUserAgent(request.headers.get('user-agent')); + if (ua) { + const r = await fdb() + .from('feedback_submissions') + .select(cols) + .eq('instance_id', inst.id) + .eq('client_ip', ip) + .eq('user_agent', ua) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (r.error) throw r.error; + if (r.data) { + previousSubmission = { + submitted_at: r.data.created_at, + display_name: r.data.display_name, + answers: r.data.answers, + }; + } + } + } + } + return json({ title: inst.title, description: inst.description, form_definition: inst.form_definition, chat_enabled: inst.chat_enabled, live_results_enabled: inst.live_results_enabled, + single_submission: inst.single_submission, status: inst.status, closed_at: inst.closed_at, + previous_submission: previousSubmission, }); } catch (e) { return handleApiError(e, 'feedback GET'); diff --git a/src/routes/f/[slug]/+page.server.ts b/src/routes/f/[slug]/+page.server.ts index a195cf0..cf179ee 100644 --- a/src/routes/f/[slug]/+page.server.ts +++ b/src/routes/f/[slug]/+page.server.ts @@ -1,11 +1,42 @@ import type { PageServerLoad } from './$types'; import { error } from '@sveltejs/kit'; -import { getInstanceBySlug } from '$lib/server/feedback'; +import { getInstanceBySlug, clampUserAgent } from '$lib/server/feedback'; +import { fdb } from '$lib/server/fdb'; -export const load: PageServerLoad = async ({ params }) => { +export const load: PageServerLoad = async ({ params, request, getClientAddress }) => { const inst = await getInstanceBySlug(params.slug); if (!inst) error(404, 'Feedback instance not found'); + // Server-side IP + UA fallback so first paint after a reload shows the read-only summary + // without an extra client round-trip. The session_id-based lookup still happens on the + // client (it lives in LocalStorage and isn't sent as a cookie). + let previousSubmission: + | { submitted_at: string; display_name: string | null; answers: unknown } + | null = null; + + if (inst.single_submission) { + const ua = clampUserAgent(request.headers.get('user-agent')); + if (ua) { + const ip = getClientAddress(); + const r = await fdb() + .from('feedback_submissions') + .select('answers, display_name, created_at') + .eq('instance_id', inst.id) + .eq('client_ip', ip) + .eq('user_agent', ua) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (!r.error && r.data) { + previousSubmission = { + submitted_at: r.data.created_at, + display_name: r.data.display_name, + answers: r.data.answers, + }; + } + } + } + return { slug: inst.slug, title: inst.title, @@ -13,7 +44,9 @@ export const load: PageServerLoad = async ({ params }) => { form_definition: inst.form_definition, chat_enabled: inst.chat_enabled, live_results_enabled: inst.live_results_enabled, + single_submission: inst.single_submission, status: inst.status, closed_at: inst.closed_at, + previous_submission: previousSubmission, }; }; diff --git a/src/routes/f/[slug]/+page.svelte b/src/routes/f/[slug]/+page.svelte index 5a65b66..6592d91 100644 --- a/src/routes/f/[slug]/+page.svelte +++ b/src/routes/f/[slug]/+page.svelte @@ -11,6 +11,7 @@ const formDef = data.form_definition as FeedbackFormDefinition | null; const chatEnabled = data.chat_enabled; const liveResultsEnabled = data.live_results_enabled === true; + const singleSubmission = data.single_submission === true; const NAME_KEY = 'feedback:display_name'; const sessionKey = `feedback:session:${data.slug}`; @@ -28,7 +29,15 @@ display_name: string | null; submitted_at: string; } - let previousSubmission = $state(null); + let previousSubmission = $state( + data.previous_submission + ? { + answers: (data.previous_submission.answers as Record) ?? {}, + display_name: data.previous_submission.display_name, + submitted_at: data.previous_submission.submitted_at, + } + : null, + ); interface ChatPost { id: string; @@ -448,11 +457,34 @@ }); }); + async function fetchPreviousSubmissionBySession(): Promise { + if (!singleSubmission || previousSubmission || !sessionId) return; + try { + const url = new URL(`/api/public/feedback/${data.slug}`, location.origin); + url.searchParams.set('session_id', sessionId); + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) return; + const j = (await res.json()) as { previous_submission: PreviousSubmission | null }; + if (j.previous_submission) { + previousSubmission = { + answers: (j.previous_submission.answers as Record) ?? {}, + display_name: j.previous_submission.display_name, + submitted_at: j.previous_submission.submitted_at, + }; + alreadySubmitted = true; + saveSession(); + startResultsPolling(); + } + } catch { + // network blip — page still renders correctly + } + } + onMount(() => { displayName = loadName(); const s = loadSession(); sessionId = s.id; - alreadySubmitted = s.submitted; + alreadySubmitted = s.submitted || previousSubmission !== null; saveSession(); if (chatEnabled) { @@ -460,6 +492,10 @@ pollHandle = setInterval(() => fetchPosts(false), 3000); } if (alreadySubmitted) startResultsPolling(); + + // Server-side load only had IP+UA. Cover the (cleared cookies, new IP) reload case + // by also looking up via the LocalStorage session id. + void fetchPreviousSubmissionBySession(); }); onDestroy(() => { @@ -507,33 +543,46 @@ {#if formDef}
{#if previousSubmission} -
- Du hast am {fmtSubmittedAt(previousSubmission.submitted_at)} bereits abgesendet. Eine erneute Antwort ist für dieses Formular nicht vorgesehen. -
-
- {#if previousSubmission.display_name} -
Name: {previousSubmission.display_name}
- {/if} - {#each formDef.questions as q (q.id)} -
-
{q.label}
-
{summariseSubmittedAnswer(q, previousSubmission.answers?.[q.id])}
+
+
+ +
+
Antwort gesendet
+
+ am {fmtSubmittedAt(previousSubmission.submitted_at)} +
- {/each} +
+
+ {#if previousSubmission.display_name} +
+
Name
+
{previousSubmission.display_name}
+
+ {/if} + {#each formDef.questions as q (q.id)} +
+
{q.label}
+
{summariseSubmittedAnswer(q, previousSubmission.answers?.[q.id])}
+
+ {/each} +
{:else if submitSuccess}
Danke für dein Feedback!
- - {:else if alreadySubmitted} + {#if !singleSubmission} + + {/if} + {:else if alreadySubmitted && !singleSubmission}
- Du hast schon abgesendet. Du kannst trotzdem nochmal antworten: + Du hast bereits abgesendet. Möchtest du eine weitere Antwort senden?
{:else if isClosed}
Das Formular ist geschlossen.