Merge mai/hermes/single-sub-fixes: reload-shows-summary + copy + card redesign + hide answer-again on success

This commit is contained in:
mAi
2026-05-06 15:44:42 +02:00
4 changed files with 237 additions and 44 deletions

View File

@@ -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; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.6rem; gap: 1.25rem;
margin-top: 0.6rem;
} }
.fb-already__name { .fb-summary__head {
font-size: 0.9rem; 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); color: var(--fb-muted);
margin-top: 0.1rem;
} }
.fb-already__row { .fb-summary__list {
margin: 0;
padding: 0;
display: flex; display: flex;
flex-direction: column; 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 { .fb-summary__row {
font-size: 0.78rem; 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); color: var(--fb-muted);
text-transform: uppercase; margin: 0;
letter-spacing: 0.04em;
} }
.fb-already__value { .fb-summary__desc {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text-primary); color: var(--color-text-primary);
margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; 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 */ /* date_ranked_choice — view toggle + calendar + bars */

View File

@@ -1,26 +1,85 @@
/** /**
* GET /api/public/feedback/<slug> * GET /api/public/feedback/<slug> — public-facing instance config (slug = access token).
* * GET /api/public/feedback/<slug>?session_id= — also resolves the caller's previous submission
* Returns the public-facing instance config. No auth — slug is the access token. * 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 type { RequestHandler } from './$types';
import { json } from '$lib/server/response'; import { json } from '$lib/server/response';
import { handleApiError, notFound } from '$lib/server/errors'; 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 { try {
const inst = await getInstanceBySlug(params.slug); const inst = await getInstanceBySlug(params.slug);
if (!inst) return notFound('Feedback instance not found'); 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({ return json({
title: inst.title, title: inst.title,
description: inst.description, description: inst.description,
form_definition: inst.form_definition, form_definition: inst.form_definition,
chat_enabled: inst.chat_enabled, chat_enabled: inst.chat_enabled,
live_results_enabled: inst.live_results_enabled, live_results_enabled: inst.live_results_enabled,
single_submission: inst.single_submission,
status: inst.status, status: inst.status,
closed_at: inst.closed_at, closed_at: inst.closed_at,
previous_submission: previousSubmission,
}); });
} catch (e) { } catch (e) {
return handleApiError(e, 'feedback GET'); return handleApiError(e, 'feedback GET');

View File

@@ -1,11 +1,42 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit'; 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); const inst = await getInstanceBySlug(params.slug);
if (!inst) error(404, 'Feedback instance not found'); 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 { return {
slug: inst.slug, slug: inst.slug,
title: inst.title, title: inst.title,
@@ -13,7 +44,9 @@ export const load: PageServerLoad = async ({ params }) => {
form_definition: inst.form_definition, form_definition: inst.form_definition,
chat_enabled: inst.chat_enabled, chat_enabled: inst.chat_enabled,
live_results_enabled: inst.live_results_enabled, live_results_enabled: inst.live_results_enabled,
single_submission: inst.single_submission,
status: inst.status, status: inst.status,
closed_at: inst.closed_at, closed_at: inst.closed_at,
previous_submission: previousSubmission,
}; };
}; };

View File

@@ -11,6 +11,7 @@
const formDef = data.form_definition as FeedbackFormDefinition | null; const formDef = data.form_definition as FeedbackFormDefinition | null;
const chatEnabled = data.chat_enabled; const chatEnabled = data.chat_enabled;
const liveResultsEnabled = data.live_results_enabled === true; const liveResultsEnabled = data.live_results_enabled === true;
const singleSubmission = data.single_submission === true;
const NAME_KEY = 'feedback:display_name'; const NAME_KEY = 'feedback:display_name';
const sessionKey = `feedback:session:${data.slug}`; const sessionKey = `feedback:session:${data.slug}`;
@@ -28,7 +29,15 @@
display_name: string | null; display_name: string | null;
submitted_at: string; submitted_at: string;
} }
let previousSubmission = $state<PreviousSubmission | null>(null); let previousSubmission = $state<PreviousSubmission | null>(
data.previous_submission
? {
answers: (data.previous_submission.answers as Record<string, unknown>) ?? {},
display_name: data.previous_submission.display_name,
submitted_at: data.previous_submission.submitted_at,
}
: null,
);
interface ChatPost { interface ChatPost {
id: string; id: string;
@@ -448,11 +457,34 @@
}); });
}); });
async function fetchPreviousSubmissionBySession(): Promise<void> {
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<string, unknown>) ?? {},
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(() => { onMount(() => {
displayName = loadName(); displayName = loadName();
const s = loadSession(); const s = loadSession();
sessionId = s.id; sessionId = s.id;
alreadySubmitted = s.submitted; alreadySubmitted = s.submitted || previousSubmission !== null;
saveSession(); saveSession();
if (chatEnabled) { if (chatEnabled) {
@@ -460,6 +492,10 @@
pollHandle = setInterval(() => fetchPosts(false), 3000); pollHandle = setInterval(() => fetchPosts(false), 3000);
} }
if (alreadySubmitted) startResultsPolling(); 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(() => { onDestroy(() => {
@@ -507,33 +543,46 @@
{#if formDef} {#if formDef}
<section class="fb-participant__col fb-participant__col--form" aria-label="Formular"> <section class="fb-participant__col fb-participant__col--form" aria-label="Formular">
{#if previousSubmission} {#if previousSubmission}
<div class="fb-banner"> <div class="fb-summary">
Du hast am <strong>{fmtSubmittedAt(previousSubmission.submitted_at)}</strong> bereits abgesendet. Eine erneute Antwort ist für dieses Formular nicht vorgesehen. <div class="fb-summary__head">
</div> <div class="fb-summary__check" aria-hidden="true"></div>
<div class="fb-already"> <div>
{#if previousSubmission.display_name} <div class="fb-summary__title">Antwort gesendet</div>
<div class="fb-already__name">Name: <strong>{previousSubmission.display_name}</strong></div> <div class="fb-summary__subtitle">
{/if} am {fmtSubmittedAt(previousSubmission.submitted_at)}
{#each formDef.questions as q (q.id)} </div>
<div class="fb-already__row">
<div class="fb-already__label">{q.label}</div>
<div class="fb-already__value">{summariseSubmittedAnswer(q, previousSubmission.answers?.[q.id])}</div>
</div> </div>
{/each} </div>
<dl class="fb-summary__list">
{#if previousSubmission.display_name}
<div class="fb-summary__row">
<dt class="fb-summary__term">Name</dt>
<dd class="fb-summary__desc">{previousSubmission.display_name}</dd>
</div>
{/if}
{#each formDef.questions as q (q.id)}
<div class="fb-summary__row">
<dt class="fb-summary__term">{q.label}</dt>
<dd class="fb-summary__desc">{summariseSubmittedAnswer(q, previousSubmission.answers?.[q.id])}</dd>
</div>
{/each}
</dl>
</div> </div>
{:else if submitSuccess} {:else if submitSuccess}
<div class="fb-banner fb-form-banner--success"> <div class="fb-banner fb-form-banner--success">
Danke für dein Feedback! Danke für dein Feedback!
</div> </div>
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}> {#if !singleSubmission}
Noch eine Antwort senden <button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
</button> Noch eine Antwort senden
{:else if alreadySubmitted} </button>
{/if}
{:else if alreadySubmitted && !singleSubmission}
<div class="fb-banner"> <div class="fb-banner">
Du hast schon abgesendet. Du kannst trotzdem nochmal antworten: Du hast bereits abgesendet. Möchtest du eine weitere Antwort senden?
</div> </div>
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}> <button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
Erneut antworten Weitere Antwort senden
</button> </button>
{:else if isClosed} {:else if isClosed}
<div class="fb-banner">Das Formular ist geschlossen.</div> <div class="fb-banner">Das Formular ist geschlossen.</div>