From c1ed45e5746073a652ddfdf7311db8e7c7baac5f Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 6 May 2026 15:22:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(forms):=20M9=20form-as-conversation=20?= =?UTF-8?q?=E2=80=94=20Typeform-Chat-Render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-Form-Variante als linearer Chat-Flow (M9, KF4 aus dem Plan). Owner wählt im Builder zwischen "klassisch" (alle Felder gleichzeitig, M4b-View) und "conversation" (eine Frage nach der anderen, mobile-friendly). LLM-gestützte free-text → typed-Antwort-Extraktion (z.B. "Ich nehme den zweiten Vorschlag" → option-id) bleibt M9b — die jetzige Implementierung nutzt typed widgets pro Field-Type für einen deterministischen ersten Wurf. - types.ts: FormSettings.experience: 'classic' | 'conversation' (default 'classic'). Reist im Settings-Blob mitverschlüsselt. - data/unlisted/resolvers.ts: buildFormBlob whitelistet experience ins public-snapshot — nur ein Enum, kein PII. - SharedFormView (M4b) bleibt der classic-Renderer. - ConversationFormView (neu, ~600 Zeilen): - Linear: stepIndex zeigt durch das Visible-Subset von resolveVisibleFields (gleicher branching-resolver wie classic). - Pro Step: question-bubble + Field-Type-spezifischer Widget: short_text/long_text/email/number → Free-Text-Input mit Enter-Submit, date → datepicker, yes_no → 2 Quick-Reply-Buttons, rating → Skala-Buttons, single_choice → vertikale Quick-Reply-Liste, multi_choice → Toggle-Chips + "Weiter", section → "Verstanden"-Step, consent → Yes(/Nein optional). - Answer-Bubble nach Submit; "← Vorherige" droppt das letzte Q/A-Pair und löscht die Antwort, damit der branching-resolver den nächsten Step neu berechnet. - Final-Step: Submitter-Name+Email (optional) + bestehender POST /api/v1/forms/public/:token/submit. - Progress-Bar oben, "via Mana Forms"-Footer. - routes/share/[token]/+page.svelte: dispatched bei collection='forms' auf experience-Wert — 'conversation' → ConversationFormView, sonst SharedFormView. - SettingsPanel: dropdown unter den Anonymous-Toggle, dt./eng./es./ fr./it. (15 neue i18n-Keys × 5 Locales = 6498 keys aligned). Trade-offs: - Branching reagiert pro-step: wenn der User auf einer späteren Frage zurückgeht und die Quelle einer Hide-Regel ändert, fällt der zwischenzeitlich gerenderte Pfad weg — eventuell taucht eine neue Frage als "next" auf. Dokumentiert als linearer "tree-walk" statt WYSIWYG-Snapshot, üblich für Typeform-Klone. - Ohne LLM-Extraction (M9b) sind die Quick-Replies nicht fluide; das ist intent: deterministic > magical for first ship. Forms-Tests 61/61. svelte-check 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/data/unlisted/resolvers.ts | 6 + .../web/src/lib/i18n/locales/forms/de.json | 5 +- .../web/src/lib/i18n/locales/forms/en.json | 5 +- .../web/src/lib/i18n/locales/forms/es.json | 5 +- .../web/src/lib/i18n/locales/forms/fr.json | 5 +- .../web/src/lib/i18n/locales/forms/it.json | 5 +- .../modules/forms/ConversationFormView.svelte | 701 ++++++++++++++++++ .../forms/components/SettingsPanel.svelte | 24 + .../apps/web/src/lib/modules/forms/types.ts | 6 + .../web/src/routes/share/[token]/+page.svelte | 18 +- 10 files changed, 774 insertions(+), 6 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts index 4d68ca1f0..864da1620 100644 --- a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -333,5 +333,11 @@ async function buildFormBlob(recordId: string): Promise> if (settings.recurrence?.frequency) { blob.recurrence = { frequency: settings.recurrence.frequency }; } + // M9 — `experience` controls the public render mode (classic vs + // conversation). Whitelisted so the public dispatcher can pick the + // right view; the value is just an enum, no PII. + if (settings.experience) { + blob.experience = settings.experience; + } return blob; } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json index cadcdadca..426f25604 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -69,7 +69,10 @@ "successMessage": "Bestätigungstext nach Absenden", "requireEmail": "E-Mail-Adresse vom Absender abfragen", "allowMultiple": "Mehrere Antworten pro Person erlauben", - "anonymous": "Anonym — Submitter-Daten nicht speichern" + "anonymous": "Anonym — Submitter-Daten nicht speichern", + "experience": "Public-Render-Modus", + "experienceClassic": "Klassisch — alle Felder gleichzeitig", + "experienceConversation": "Conversation — Chat, eine Frage nach der anderen" }, "visibility": { "title": "Sichtbarkeit & Teilen", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json index 769fb87cd..da50e815d 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -69,7 +69,10 @@ "successMessage": "Confirmation text after submit", "requireEmail": "Ask submitter for email address", "allowMultiple": "Allow multiple submissions per person", - "anonymous": "Anonymous — don't store submitter data" + "anonymous": "Anonymous — don't store submitter data", + "experience": "Public render mode", + "experienceClassic": "Classic — all fields at once", + "experienceConversation": "Conversation — chat, one question at a time" }, "visibility": { "title": "Visibility & Sharing", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json index 3260f52c9..627f1fa08 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -69,7 +69,10 @@ "successMessage": "Mensaje de confirmación tras el envío", "requireEmail": "Pedir correo electrónico al remitente", "allowMultiple": "Permitir varias respuestas por persona", - "anonymous": "Anónimo — no guardar datos del remitente" + "anonymous": "Anónimo — no guardar datos del remitente", + "experience": "Modo de visualización pública", + "experienceClassic": "Clásico — todos los campos a la vez", + "experienceConversation": "Conversación — chat, una pregunta a la vez" }, "visibility": { "title": "Visibilidad y compartir", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json index ff1b63cf4..c4135d678 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -69,7 +69,10 @@ "successMessage": "Message de confirmation après envoi", "requireEmail": "Demander l'adresse e-mail au destinataire", "allowMultiple": "Autoriser plusieurs réponses par personne", - "anonymous": "Anonyme — ne pas conserver les données du destinataire" + "anonymous": "Anonyme — ne pas conserver les données du destinataire", + "experience": "Mode de rendu public", + "experienceClassic": "Classique — tous les champs à la fois", + "experienceConversation": "Conversation — chat, une question à la fois" }, "visibility": { "title": "Visibilité et partage", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json index a012fc6b0..d8247b92b 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -69,7 +69,10 @@ "successMessage": "Messaggio di conferma dopo l'invio", "requireEmail": "Chiedi l'indirizzo e-mail al mittente", "allowMultiple": "Permetti più risposte per persona", - "anonymous": "Anonimo — non memorizzare i dati del mittente" + "anonymous": "Anonimo — non memorizzare i dati del mittente", + "experience": "Modalità di visualizzazione pubblica", + "experienceClassic": "Classica — tutti i campi insieme", + "experienceConversation": "Conversazione — chat, una domanda alla volta" }, "visibility": { "title": "Visibilità e condivisione", diff --git a/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte b/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte new file mode 100644 index 000000000..65e051c70 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/ConversationFormView.svelte @@ -0,0 +1,701 @@ + + + +
+
+

{form.title}

+ {#if form.description} +

{form.description}

+ {/if} +
+ + + +
+ {#each history as bubble, i (i)} +
{bubble.text}
+ {/each} +
+ + {#if submitted} +
{form.settings.successMessage}
+ {:else if isAtEnd} +
+

+ Fast fertig. Magst du noch deinen Namen / deine E-Mail dalassen? (optional) +

+ + + {#if submitError} +

{submitError}

+ {/if} + + +
+ {:else if isAtSubmitter} + +

+ {:else if currentField} +
+ {#if currentField.helpText} +

{currentField.helpText}

+ {/if} + + {#if currentField.type === 'short_text' || currentField.type === 'long_text' || currentField.type === 'email'} +
+ {#if currentField.type === 'long_text'} + + {:else} + { + if (e.key === 'Enter') { + e.preventDefault(); + handleTextSubmit(); + } + }} + /> + {/if} + +
+ {:else if currentField.type === 'number'} +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + handleTextSubmit(); + } + }} + /> + +
+ {:else if currentField.type === 'date'} +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + handleTextSubmit(); + } + }} + /> + +
+ {:else if currentField.type === 'yes_no'} +
+ + +
+ {:else if currentField.type === 'rating'} +
+ {#each ratingScale(currentField) as n} + + {/each} +
+ {:else if currentField.type === 'single_choice'} +
+ {#each currentField.options ?? [] as opt (opt.id)} + + {/each} +
+ {:else if currentField.type === 'multi_choice'} +
+ {#each currentField.options ?? [] as opt (opt.id)} + {@const checked = multiDraft.includes(opt.id)} + + {/each} + +
+ {:else if currentField.type === 'section'} + + {:else if currentField.type === 'consent'} +
+ + {#if !currentField.required} + + {/if} +
+ {/if} + + {#if stepIndex > 0 && currentField.type !== 'section'} + + {/if} +
+ {/if} + + {#if expiresAt && !submitted} +

Dieser Link läuft am {fmtExpiry(expiresAt)} ab.

+ {/if} + + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte index 4e2f69df1..d52480a72 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte @@ -269,6 +269,30 @@ + +

{$_('forms.builder.recurrence.title', { default: 'Wiederkehr — Antworten in Wellen' })} diff --git a/apps/mana/apps/web/src/lib/modules/forms/types.ts b/apps/mana/apps/web/src/lib/modules/forms/types.ts index b115bab41..6a82f59b5 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/types.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/types.ts @@ -114,6 +114,12 @@ export interface FormSettings { autoSync?: AutoSyncConfig; responsesPublic?: boolean; recurrence?: RecurrenceConfig; + /** + * M9 — render the public form as a Typeform-style chat conversation + * (one question at a time, free-text + LLM-extraction) instead of + * the classic side-by-side fields layout. Default 'classic'. + */ + experience?: 'classic' | 'conversation'; } export type FormStatus = 'draft' | 'published' | 'closed'; diff --git a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte index 91c88e679..ef7517ef2 100644 --- a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte +++ b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte @@ -10,9 +10,21 @@ import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte'; import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte'; import SharedFormView from '$lib/modules/forms/SharedFormView.svelte'; + import ConversationFormView from '$lib/modules/forms/ConversationFormView.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); + + // M9 — pick the form-render variant from the snapshot's experience + // flag. Default 'classic' so existing forms keep their look. + const formExperience = $derived( + data.collection === 'forms' + ? (((data.blob as Record | undefined)?.experience as + | 'classic' + | 'conversation' + | undefined) ?? 'classic') + : 'classic' + ); {#if data.collection === 'events'} @@ -26,7 +38,11 @@ {:else if data.collection === 'lasts'} {:else if data.collection === 'forms'} - + {#if formExperience === 'conversation'} + + {:else} + + {/if} {:else}

Unbekannter Link-Typ