mAi: #4 - single-submission follow-up fixes
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.
This commit is contained in:
@@ -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 */
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user