feat(forms): M9 form-as-conversation — Typeform-Chat-Render

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-06 15:22:56 +02:00
parent 7d8e562091
commit c1ed45e574
10 changed files with 774 additions and 6 deletions

View file

@ -333,5 +333,11 @@ async function buildFormBlob(recordId: string): Promise<Record<string, unknown>>
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;
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -0,0 +1,701 @@
<!--
ConversationFormView — Typeform-style linearer Chat-Flow für public
Forms (M9). Statt aller Felder gleichzeitig läuft der User Frage für
Frage durch. Branching greift wie im SharedFormView (gleicher
resolver) — wir picken einfach das nächste sichtbare Feld nach jedem
Schritt aus dem Visible-Subset.
Field-Type-Handling:
- short_text / long_text / email / number: Freitext-Input
- date: native datepicker
- yes_no: zwei Quick-Reply-Buttons
- rating: Skala-Buttons (1..5 oder 1..10)
- single_choice: Quick-Reply-Buttons pro Option
- multi_choice: Toggle-Chips + "Weiter"-Button
- section: zeigt Heading-Bubble, "Verstanden"-Button geht weiter
- consent: Yes-Button (required) bzw. Yes/Skip-Buttons (optional)
M9b polish (deferred): LLM-Extraction für free-text → strikten Typ
(z.B. "Ich nehme den zweiten Vorschlag" → option-id), via einem
/api/v1/forms/public/:token/conversation/extract endpoint.
-->
<script lang="ts">
import { resolveVisibleFields } from './lib/branching';
import type { AnswerValue, BranchingRule, FormField, FormSettings } from './types';
interface FormBlob {
title: string;
description: string | null;
fields: FormField[];
branching: BranchingRule[];
settings: Pick<FormSettings, 'submitButtonLabel' | 'successMessage'>;
}
let {
blob,
token,
expiresAt,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const form = $derived(blob as unknown as FormBlob);
let answers = $state<Record<string, AnswerValue>>({});
let submitterEmail = $state('');
let submitterName = $state('');
// Step pointer + per-step transient input state.
let stepIndex = $state(0);
let textDraft = $state('');
let multiDraft = $state<string[]>([]);
let submitting = $state(false);
let submitted = $state(false);
let submitError = $state<string | null>(null);
// Conversation history — pairs of (questionFieldId, displayValue)
// rendered as chat bubbles above the active step.
type Bubble = { kind: 'q'; text: string } | { kind: 'a'; text: string };
let history = $state<Bubble[]>([]);
const visibleFields = $derived(resolveVisibleFields(form.fields, form.branching ?? [], answers));
const currentField = $derived<FormField | null>(visibleFields[stepIndex] ?? null);
const isAtSubmitter = $derived(stepIndex >= visibleFields.length);
const isAtEnd = $derived(stepIndex >= visibleFields.length + 1);
$effect(() => {
// When we land on a new question, push it as a fresh bubble.
// Effect tracks currentField identity — branching changes that
// flip the visible set may shift step membership but the
// already-pushed bubbles stay (history is append-only).
void currentField;
void stepIndex;
if (currentField && !history.some((h) => h.kind === 'q' && h.text === currentField.label)) {
history = [...history, { kind: 'q', text: currentField.label }];
}
});
function apiBaseUrl(): string {
const env = import.meta.env as Record<string, string | undefined>;
const fromEnv = env.PUBLIC_MANA_API_URL;
if (fromEnv) return fromEnv.replace(/\/$/, '');
if (typeof window !== 'undefined') {
return window.location.origin.replace(/:5173/, ':3060');
}
return 'http://localhost:3060';
}
function pushAnswerBubble(text: string) {
history = [...history, { kind: 'a', text }];
}
function setAnswerAndAdvance(value: AnswerValue, displayText: string) {
if (!currentField) return;
answers = { ...answers, [currentField.id]: value };
pushAnswerBubble(displayText || '—');
textDraft = '';
multiDraft = [];
stepIndex += 1;
}
function handleTextSubmit() {
if (!currentField) return;
const trimmed = textDraft.trim();
if (currentField.required && !trimmed) return;
if (currentField.type === 'number') {
if (!trimmed) {
setAnswerAndAdvance(null, '(übersprungen)');
return;
}
const n = Number(trimmed);
if (!Number.isFinite(n)) return;
setAnswerAndAdvance(n, String(n));
} else {
setAnswerAndAdvance(trimmed, trimmed);
}
}
function handleQuickPick(value: AnswerValue, label: string) {
setAnswerAndAdvance(value, label);
}
function toggleMulti(optionId: string) {
multiDraft = multiDraft.includes(optionId)
? multiDraft.filter((v) => v !== optionId)
: [...multiDraft, optionId];
}
function handleMultiSubmit() {
if (!currentField) return;
const opts = currentField.options ?? [];
const labels = multiDraft.map((id) => opts.find((o) => o.id === id)?.label ?? id).join(', ');
setAnswerAndAdvance(multiDraft.slice(), labels || '—');
}
function handleSectionAck() {
if (!currentField) return;
// Sections don't carry an answer; just skip past.
pushAnswerBubble('OK');
stepIndex += 1;
}
function handleConsent(accepted: boolean) {
if (!currentField) return;
setAnswerAndAdvance(accepted, accepted ? 'Ja' : 'Nein');
}
function back() {
if (stepIndex === 0 && history.length === 0) return;
// Drop the last (q, a) pair — naive but works for linear flow.
stepIndex = Math.max(0, stepIndex - 1);
history = history.slice(0, Math.max(0, history.length - 2));
const prev = visibleFields[stepIndex];
if (prev) {
delete answers[prev.id];
answers = { ...answers };
}
}
async function handleFinalSubmit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
submitError = null;
try {
const url = `${apiBaseUrl()}/api/v1/forms/public/${encodeURIComponent(token)}/submit`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
answers,
submitterEmail: submitterEmail.trim() || null,
submitterName: submitterName.trim() || null,
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => '');
let msg: string | undefined;
try {
msg = JSON.parse(txt)?.message;
} catch {
msg = txt.slice(0, 200);
}
submitError = msg || `Übertragung fehlgeschlagen (${res.status})`;
return;
}
submitted = true;
} catch (err) {
submitError = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.';
} finally {
submitting = false;
}
}
function ratingScale(field: FormField): number[] {
const max = field.config?.ratingScale ?? 5;
return Array.from({ length: max }, (_, i) => i + 1);
}
function fmtExpiry(iso: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
const progress = $derived(
visibleFields.length === 0
? 0
: Math.min(100, Math.round((stepIndex / (visibleFields.length + 1)) * 100))
);
</script>
<article class="conv-form">
<header class="conv-hero">
<h1>{form.title}</h1>
{#if form.description}
<p class="conv-description">{form.description}</p>
{/if}
</header>
<div class="conv-progress" aria-hidden="true">
<div class="conv-progress__fill" style="width:{progress}%"></div>
</div>
<div class="conv-thread" aria-live="polite">
{#each history as bubble, i (i)}
<div class="conv-bubble" data-kind={bubble.kind}>{bubble.text}</div>
{/each}
</div>
{#if submitted}
<div class="conv-thanks">{form.settings.successMessage}</div>
{:else if isAtEnd}
<form class="conv-final" onsubmit={handleFinalSubmit}>
<p class="conv-prompt">
Fast fertig. Magst du noch deinen Namen / deine E-Mail dalassen? (optional)
</p>
<input
type="text"
bind:value={submitterName}
placeholder="Anna Mustermann"
class="conv-input"
/>
<input
type="email"
bind:value={submitterEmail}
placeholder="anna@example.com"
class="conv-input"
/>
{#if submitError}
<p class="conv-error">{submitError}</p>
{/if}
<button type="submit" class="conv-submit" disabled={submitting}>
{submitting ? 'Sende …' : form.settings.submitButtonLabel}
</button>
<button type="button" class="conv-back" onclick={back}> Zurück</button>
</form>
{:else if isAtSubmitter}
<!-- Should not reach here independently — kept for safety -->
<p class="conv-prompt"></p>
{:else if currentField}
<div class="conv-step" data-type={currentField.type}>
{#if currentField.helpText}
<p class="conv-help">{currentField.helpText}</p>
{/if}
{#if currentField.type === 'short_text' || currentField.type === 'long_text' || currentField.type === 'email'}
<div class="conv-input-row">
{#if currentField.type === 'long_text'}
<textarea
class="conv-input"
rows="3"
bind:value={textDraft}
placeholder="Antwort eingeben …"
maxlength={currentField.config?.maxLength ?? 4000}
></textarea>
{:else}
<input
class="conv-input"
type={currentField.type === 'email' ? 'email' : 'text'}
bind:value={textDraft}
placeholder="Antwort eingeben …"
maxlength={currentField.config?.maxLength ?? 200}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTextSubmit();
}
}}
/>
{/if}
<button
type="button"
class="conv-next"
onclick={handleTextSubmit}
disabled={currentField.required && !textDraft.trim()}
>
Weiter
</button>
</div>
{:else if currentField.type === 'number'}
<div class="conv-input-row">
<input
class="conv-input"
type="number"
bind:value={textDraft}
min={currentField.config?.min}
max={currentField.config?.max}
placeholder="Zahl …"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTextSubmit();
}
}}
/>
<button
type="button"
class="conv-next"
onclick={handleTextSubmit}
disabled={currentField.required && !textDraft.trim()}
>
Weiter
</button>
</div>
{:else if currentField.type === 'date'}
<div class="conv-input-row">
<input
class="conv-input"
type="date"
bind:value={textDraft}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTextSubmit();
}
}}
/>
<button
type="button"
class="conv-next"
onclick={handleTextSubmit}
disabled={currentField.required && !textDraft.trim()}
>
Weiter
</button>
</div>
{:else if currentField.type === 'yes_no'}
<div class="conv-quick">
<button type="button" class="conv-quick-btn" onclick={() => handleQuickPick(true, 'Ja')}>
Ja
</button>
<button
type="button"
class="conv-quick-btn"
onclick={() => handleQuickPick(false, 'Nein')}
>
Nein
</button>
</div>
{:else if currentField.type === 'rating'}
<div class="conv-quick">
{#each ratingScale(currentField) as n}
<button
type="button"
class="conv-quick-btn"
onclick={() => handleQuickPick(n, String(n))}
>
{n}
</button>
{/each}
</div>
{:else if currentField.type === 'single_choice'}
<div class="conv-quick conv-quick--column">
{#each currentField.options ?? [] as opt (opt.id)}
<button
type="button"
class="conv-quick-btn"
onclick={() => handleQuickPick(opt.id, opt.label)}
>
{opt.label}
</button>
{/each}
</div>
{:else if currentField.type === 'multi_choice'}
<div class="conv-multi">
{#each currentField.options ?? [] as opt (opt.id)}
{@const checked = multiDraft.includes(opt.id)}
<button
type="button"
class="conv-multi-chip"
class:active={checked}
onclick={() => toggleMulti(opt.id)}
>
{opt.label}
</button>
{/each}
<button
type="button"
class="conv-next"
onclick={handleMultiSubmit}
disabled={currentField.required && multiDraft.length === 0}
>
Weiter
</button>
</div>
{:else if currentField.type === 'section'}
<button type="button" class="conv-next" onclick={handleSectionAck}> Verstanden </button>
{:else if currentField.type === 'consent'}
<div class="conv-quick">
<button type="button" class="conv-quick-btn" onclick={() => handleConsent(true)}>
Ja, einverstanden
</button>
{#if !currentField.required}
<button
type="button"
class="conv-quick-btn conv-quick-btn--secondary"
onclick={() => handleConsent(false)}
>
Nein
</button>
{/if}
</div>
{/if}
{#if stepIndex > 0 && currentField.type !== 'section'}
<button type="button" class="conv-back" onclick={back}> Vorherige</button>
{/if}
</div>
{/if}
{#if expiresAt && !submitted}
<p class="conv-expiry">Dieser Link läuft am {fmtExpiry(expiresAt)} ab.</p>
{/if}
<footer class="conv-brand">
<a href="https://mana.how/forms" class="conv-brand-link">via Mana Forms</a>
</footer>
</article>
<style>
.conv-form {
max-width: 640px;
margin: 2rem auto;
padding: 1.5rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
color: #1a1a1a;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.conv-hero {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.875rem;
margin-bottom: 1rem;
}
.conv-hero h1 {
margin: 0 0 0.375rem;
font-size: 1.5rem;
font-weight: 600;
}
.conv-description {
margin: 0;
color: #4b5563;
font-size: 0.9375rem;
white-space: pre-wrap;
}
.conv-progress {
height: 4px;
background: #f3f4f6;
border-radius: 2px;
overflow: hidden;
margin: 0 0 1rem;
}
.conv-progress__fill {
height: 100%;
background: #14b8a6;
transition: width 0.25s ease;
}
.conv-thread {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.conv-bubble {
max-width: 85%;
padding: 0.625rem 0.875rem;
border-radius: 1rem;
font-size: 0.9375rem;
line-height: 1.4;
white-space: pre-wrap;
}
.conv-bubble[data-kind='q'] {
align-self: flex-start;
background: #f3f4f6;
color: #111827;
border-bottom-left-radius: 0.25rem;
}
.conv-bubble[data-kind='a'] {
align-self: flex-end;
background: #14b8a6;
color: white;
border-bottom-right-radius: 0.25rem;
}
.conv-step {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.625rem;
}
.conv-help {
margin: 0;
font-size: 0.8125rem;
color: #6b7280;
}
.conv-input-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.conv-input {
flex: 1;
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
color: inherit;
font-size: 0.9375rem;
font-family: inherit;
resize: vertical;
}
.conv-input:focus {
outline: none;
border-color: #14b8a6;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.18);
}
.conv-next {
padding: 0.5rem 1rem;
background: #14b8a6;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.conv-next:hover:not(:disabled) {
background: #0f766e;
}
.conv-next:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.conv-back {
align-self: flex-start;
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
color: #6b7280;
font-size: 0.8125rem;
cursor: pointer;
}
.conv-back:hover {
color: #14b8a6;
}
.conv-quick {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.conv-quick--column {
flex-direction: column;
}
.conv-quick-btn {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 9999px;
color: inherit;
font-size: 0.9375rem;
cursor: pointer;
text-align: left;
}
.conv-quick-btn:hover {
background: #f9fafb;
border-color: #14b8a6;
color: #14b8a6;
}
.conv-quick-btn--secondary {
color: #6b7280;
}
.conv-multi {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.conv-multi-chip {
padding: 0.375rem 0.75rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 9999px;
color: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.conv-multi-chip.active {
background: #14b8a6;
border-color: #14b8a6;
color: white;
}
.conv-final {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.625rem;
}
.conv-prompt {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
color: #374151;
}
.conv-submit {
padding: 0.625rem 1.25rem;
background: #14b8a6;
color: white;
border: none;
border-radius: 9999px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
align-self: flex-start;
}
.conv-submit:hover:not(:disabled) {
background: #0f766e;
}
.conv-submit:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.conv-error {
margin: 0;
padding: 0.5rem 0.75rem;
background: #fee2e2;
border: 1px solid #fca5a5;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.conv-thanks {
text-align: center;
padding: 2rem 1rem;
font-size: 1rem;
color: #1a1a1a;
white-space: pre-wrap;
}
.conv-expiry {
margin: 1rem 0 0;
text-align: center;
font-size: 0.75rem;
color: #9ca3af;
}
.conv-brand {
margin-top: 1.5rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
text-align: center;
}
.conv-brand-link {
font-size: 0.75rem;
color: #6b7280;
text-decoration: none;
}
.conv-brand-link:hover {
color: #14b8a6;
}
</style>

View file

@ -269,6 +269,30 @@
</span>
</label>
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.settings.experience', { default: 'Public-Render-Modus' })}
</span>
<select
value={settings.experience ?? 'classic'}
onchange={(e) =>
onchange({
experience: (e.currentTarget as HTMLSelectElement).value as 'classic' | 'conversation',
})}
>
<option value="classic">
{$_('forms.builder.settings.experienceClassic', {
default: 'Klassisch — alle Felder gleichzeitig',
})}
</option>
<option value="conversation">
{$_('forms.builder.settings.experienceConversation', {
default: 'Conversation — Chat, eine Frage nach der anderen (KI-Hilfe)',
})}
</option>
</select>
</label>
<div class="recurrence-block">
<p class="block-title">
{$_('forms.builder.recurrence.title', { default: 'Wiederkehr — Antworten in Wellen' })}

View file

@ -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';

View file

@ -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<string, unknown> | undefined)?.experience as
| 'classic'
| 'conversation'
| undefined) ?? 'classic')
: 'classic'
);
</script>
{#if data.collection === 'events'}
@ -26,7 +38,11 @@
{:else if data.collection === 'lasts'}
<SharedLastView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'forms'}
{#if formExperience === 'conversation'}
<ConversationFormView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<SharedFormView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{/if}
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>