mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
7d8e562091
commit
c1ed45e574
10 changed files with 774 additions and 6 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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' })}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue