feat(forms): M8 website-block — formEmbed bindet Mana-Formulare ein

Neuer Block-Type `formEmbed` im Website-Builder
(docs/plans/forms-module.md M8):

- @mana/website-blocks/src/formEmbed/:
  - schema.ts: FormEmbedSchema mit token (32-char base64url) +
    titleOverride + optional resolved-Block (formTitle, fields,
    branching, settings.{submitButtonLabel, successMessage}).
    FormFieldEmbedSchema duplicated leichtgewichtig statt cross-
    package import — website-blocks bleibt self-contained.
  - FormEmbed.svelte: edit/preview rendert Placeholder-Card mit
    Token-Snippet und resolved-Status; public rendert die kompletten
    11 Field-Types inkl. Live-Branching-aware-Render. Submitter-
    Block (Name+Email optional). Submit POSTet an
    /api/v1/forms/public/:token/submit. Lazy-Fallback fetcht
    /api/v1/unlisted/public/:token wenn die publish-resolver-blob
    fehlt. Bot-Honeypot bleibt M8-Polish.
  - FormEmbedInspector.svelte: Token-Input mit base64url-Validierung
    bei blur, optional titleOverride, resolved-Card mit
    Field-Count + Logik-Regel-Count.
- BLOCK_SPECS + BLOCK_SCHEMAS + BLOCK_DEFAULTS um formEmbed
  erweitert. schemas.test.ts erwartet jetzt 12 Block-Types.
- apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts:
  resolveFormEmbed scant formTable nach unlistedToken (linear scan
  ist günstig bei <100 forms pro user, kein Index nötig), dekrypted,
  validiert published-status, gibt resolved-Block zurück.
- publish.ts.resolveEmbedsInTree erweitert um formEmbed-Branch — ruft
  resolveFormEmbed parallel zu resolveEmbed (moduleEmbed) im selben
  Walk.

Trade-offs:
- Token statt formId: bei Token-Rotation (M4b) muss der User den Block
  neu konfigurieren. Der formEmbed-Block-Resolver erkennt das + setzt
  resolved.error; public-Renderer fällt auf lazy-fetch zurück.
- Plaintext stored: das resolved-Blob landet als plaintext im
  public-snapshot, gleiches Trust-Modell wie moduleEmbed (öffentliche
  Website per Definition).

Tests: website-blocks 50/50 grün (12 schema-block-types + per-type
defaults validation). svelte-check 0 errors. forms 26/26 unverändert.

Use-Case: Vereins-Sommerfest. User legt /forms/anmeldung an,
publisht, setzt unlisted, kopiert Token. Im Website-Builder fügt er
einen formEmbed-Block auf der Event-Seite ein, paste Token → bei
Publish wird der Form-Schema inlined → Besucher submitten direkt
auf der Vereins-Website.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 02:38:28 +02:00
parent 57b7a43147
commit ace1b706e6
10 changed files with 913 additions and 2 deletions

View file

@ -0,0 +1,501 @@
<!--
FormEmbed — public-mode renders a Mana Form inline on a published
website. Edit/preview shows a placeholder card so the editor doesn't
fire fetches against the public-form endpoint.
M8 minimal: the block carries `resolved.fields/branching/settings`
filled at publish-time (see snapshot resolver). If `resolved` is
missing, the renderer falls back to fetching the snapshot via the
public unlisted endpoint client-side. That fallback exists for the
bootstrap window where someone publishes a website with a fresh
form-embed before the publish-resolver has the chance to inline.
-->
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { FormEmbedField, FormEmbedProps } from './schema';
let { block, mode }: BlockRenderProps<FormEmbedProps> = $props();
const isPublic = $derived(mode === 'public');
const blockProps = $derived(block.props);
type AnswerValue = string | string[] | number | boolean | null;
let answers = $state<Record<string, AnswerValue>>({});
let submitterEmail = $state('');
let submitterName = $state('');
let submitting = $state(false);
let submitted = $state(false);
let submitError = $state<string | null>(null);
// Lazy-resolve fallback — only fires in public mode + when the
// publish-resolver didn't inline the form schema.
let fallbackResolved = $state<FormEmbedProps['resolved'] | null>(null);
let fallbackLoading = $state(false);
let fallbackError = $state<string | null>(null);
const resolved = $derived(blockProps.resolved ?? fallbackResolved ?? undefined);
$effect(() => {
if (!isPublic) return;
if (blockProps.resolved) return;
if (fallbackResolved || fallbackLoading) return;
if (!blockProps.token) return;
void loadFallback();
});
async function loadFallback() {
fallbackLoading = true;
fallbackError = null;
try {
const res = await fetch(`/api/v1/unlisted/public/${encodeURIComponent(blockProps.token)}`);
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(`(${res.status}) ${txt.slice(0, 200)}`);
}
const data = (await res.json()) as { collection?: string; blob?: unknown };
if (data.collection !== 'forms' || !data.blob) {
throw new Error('Token gehört nicht zu einem Formular');
}
const blob = data.blob as {
title?: string;
description?: string | null;
fields?: FormEmbedField[];
branching?: FormEmbedProps['resolved'] extends infer R
? R extends { branching: infer B }
? B
: never
: never;
settings?: { submitButtonLabel?: string; successMessage?: string };
};
fallbackResolved = {
formTitle: blob.title ?? '',
formDescription: blob.description ?? null,
fields: blob.fields ?? [],
branching: (blob.branching as never) ?? [],
settings: {
submitButtonLabel: blob.settings?.submitButtonLabel ?? 'Senden',
successMessage: blob.settings?.successMessage ?? 'Danke!',
},
};
} catch (err) {
fallbackError = err instanceof Error ? err.message : String(err);
} finally {
fallbackLoading = false;
}
}
function setAnswer(fieldId: string, value: AnswerValue) {
answers = { ...answers, [fieldId]: value };
}
function toggleMulti(fieldId: string, optionId: string) {
const current = (answers[fieldId] as string[] | undefined) ?? [];
const next = current.includes(optionId)
? current.filter((v) => v !== optionId)
: [...current, optionId];
setAnswer(fieldId, next);
}
function ratingScale(field: FormEmbedField): number[] {
const max = field.config?.ratingScale ?? 5;
return Array.from({ length: max }, (_, i) => i + 1);
}
function isFieldFilled(field: FormEmbedField): boolean {
const v = answers[field.id];
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim().length > 0;
if (Array.isArray(v)) return v.length > 0;
if (typeof v === 'boolean') return v === true;
return true;
}
const allRequiredFilled = $derived(
(resolved?.fields ?? []).filter((f) => f.required && f.type !== 'section').every(isFieldFilled)
);
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (!isPublic) return;
if (submitting) return;
submitting = true;
submitError = null;
try {
const res = await fetch(
`/api/v1/forms/public/${encodeURIComponent(blockProps.token)}/submit`,
{
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;
}
}
const headerTitle = $derived(blockProps.titleOverride.trim() || resolved?.formTitle || '');
</script>
<section class="wb-form-embed" data-mode={mode}>
<div class="wb-form-embed__inner">
{#if !blockProps.token}
<div class="wb-form-embed__placeholder">
<p class="wb-form-embed__hint">
Kein Form-Token gesetzt. Wähle im Inspector ein veröffentlichtes Mana-Formular aus.
</p>
</div>
{:else if !isPublic}
<div class="wb-form-embed__placeholder">
<h2>{headerTitle || 'Eingebettetes Mana-Formular'}</h2>
<p class="wb-form-embed__hint">
Token: <code>{blockProps.token.slice(0, 8)}</code>
</p>
{#if resolved?.fields?.length}
<p class="wb-form-embed__hint">
{resolved.fields.length} Feld{resolved.fields.length === 1 ? '' : 'er'} — wird beim Veröffentlichen
frisch aufgelöst.
</p>
{:else}
<p class="wb-form-embed__hint">Form-Schema wird beim Veröffentlichen abgerufen.</p>
{/if}
</div>
{:else if fallbackLoading}
<p class="wb-form-embed__hint">Lade Formular …</p>
{:else if fallbackError}
<p class="wb-form-embed__error">Formular konnte nicht geladen werden: {fallbackError}</p>
{:else if !resolved}
<p class="wb-form-embed__error">Form-Schema fehlt.</p>
{:else if submitted}
<div class="wb-form-embed__success">{resolved.settings.successMessage}</div>
{:else}
{#if headerTitle}
<h2>{headerTitle}</h2>
{/if}
{#if resolved.formDescription}
<p class="wb-form-embed__description">{resolved.formDescription}</p>
{/if}
<form onsubmit={onSubmit} novalidate>
{#each resolved.fields as field (field.id)}
<div class="wb-form-embed__field" data-type={field.type}>
{#if field.type === 'section'}
<hr class="wb-form-embed__divider" />
<h3 class="wb-form-embed__section-title">{field.label}</h3>
{:else if field.type === 'consent'}
<label class="wb-form-embed__consent">
<input
type="checkbox"
checked={answers[field.id] === true}
onchange={(e) =>
setAnswer(field.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<span
>{field.label}{#if field.required}<span class="wb-form-embed__req">*</span
>{/if}</span
>
</label>
{:else}
<label class="wb-form-embed__label">
{field.label}
{#if field.required}<span class="wb-form-embed__req">*</span>{/if}
</label>
{#if field.helpText}
<small class="wb-form-embed__help">{field.helpText}</small>
{/if}
{#if field.type === 'short_text'}
<input
type="text"
maxlength={field.config?.maxLength ?? 200}
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
/>
{:else if field.type === 'long_text'}
<textarea
rows="4"
maxlength={field.config?.maxLength ?? 4000}
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) =>
setAnswer(field.id, (e.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else if field.type === 'email'}
<input
type="email"
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
/>
{:else if field.type === 'number'}
<input
type="number"
min={field.config?.min}
max={field.config?.max}
value={(answers[field.id] as number | string | undefined) ?? ''}
oninput={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
setAnswer(field.id, v === '' ? null : Number(v));
}}
/>
{:else if field.type === 'date'}
<input
type="date"
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
/>
{:else if field.type === 'yes_no'}
<div class="wb-form-embed__yn">
<button
type="button"
class:active={answers[field.id] === true}
onclick={() => setAnswer(field.id, true)}>Ja</button
>
<button
type="button"
class:active={answers[field.id] === false}
onclick={() => setAnswer(field.id, false)}>Nein</button
>
</div>
{:else if field.type === 'rating'}
<div class="wb-form-embed__rating">
{#each ratingScale(field) as n}
<button
type="button"
class:active={(answers[field.id] as number | undefined) === n}
onclick={() => setAnswer(field.id, n)}>{n}</button
>
{/each}
</div>
{:else if field.type === 'single_choice'}
<div class="wb-form-embed__options">
{#each field.options ?? [] as opt (opt.id)}
<label>
<input
type="radio"
name={field.id}
checked={answers[field.id] === opt.id}
onchange={() => setAnswer(field.id, opt.id)}
/>
<span>{opt.label}</span>
</label>
{/each}
</div>
{:else if field.type === 'multi_choice'}
<div class="wb-form-embed__options">
{#each field.options ?? [] as opt (opt.id)}
{@const checked = ((answers[field.id] as string[] | undefined) ?? []).includes(
opt.id
)}
<label>
<input
type="checkbox"
{checked}
onchange={() => toggleMulti(field.id, opt.id)}
/>
<span>{opt.label}</span>
</label>
{/each}
</div>
{/if}
{/if}
</div>
{/each}
<div class="wb-form-embed__submitter">
<label class="wb-form-embed__label">Dein Name <small>(optional)</small></label>
<input type="text" bind:value={submitterName} placeholder="Anna Mustermann" />
<label class="wb-form-embed__label">Deine E-Mail <small>(optional)</small></label>
<input type="email" bind:value={submitterEmail} placeholder="anna@example.com" />
</div>
{#if submitError}
<p class="wb-form-embed__error">{submitError}</p>
{/if}
<button
type="submit"
class="wb-form-embed__submit"
disabled={!allRequiredFilled || submitting}
>
{submitting ? 'Sende …' : resolved.settings.submitButtonLabel}
</button>
</form>
{/if}
</div>
</section>
<style>
.wb-form-embed {
padding: 3rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-form-embed__inner {
max-width: 36rem;
width: 100%;
}
.wb-form-embed h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.wb-form-embed__description {
margin: 0 0 1.5rem;
opacity: 0.7;
line-height: 1.5;
white-space: pre-wrap;
}
.wb-form-embed__placeholder {
padding: 1.5rem;
background: var(--wb-surface, rgba(127, 127, 127, 0.04));
border: 1px dashed var(--wb-border, rgba(127, 127, 127, 0.3));
border-radius: 0.5rem;
}
.wb-form-embed__hint {
margin: 0.25rem 0;
font-size: 0.875rem;
opacity: 0.7;
}
.wb-form-embed__hint code {
font-family: ui-monospace, monospace;
opacity: 0.85;
}
.wb-form-embed__error {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgba(220, 38, 38, 0.1);
border: 1px solid rgba(220, 38, 38, 0.3);
border-radius: 0.375rem;
color: rgb(220, 38, 38);
font-size: 0.875rem;
}
.wb-form-embed__success {
padding: 1rem 1.25rem;
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 0.5rem;
color: rgb(16, 185, 129);
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-form-embed__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-form-embed__label {
font-size: 0.8125rem;
font-weight: 500;
}
.wb-form-embed__req {
color: rgb(220, 38, 38);
margin-left: 0.15rem;
}
.wb-form-embed__help {
font-size: 0.75rem;
opacity: 0.65;
}
.wb-form-embed__field input[type='text'],
.wb-form-embed__field input[type='email'],
.wb-form-embed__field input[type='number'],
.wb-form-embed__field input[type='date'],
.wb-form-embed__field textarea,
.wb-form-embed__submitter input {
padding: 0.625rem 0.75rem;
border-radius: var(--wb-radius, 0.375rem);
border: 1px solid var(--wb-border, rgba(127, 127, 127, 0.25));
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
color: inherit;
font-family: inherit;
font-size: 0.9375rem;
}
.wb-form-embed__divider {
border: none;
border-top: 1px solid var(--wb-border, rgba(127, 127, 127, 0.2));
margin: 0.5rem 0 0;
}
.wb-form-embed__section-title {
margin: 0.25rem 0 0;
font-size: 1rem;
}
.wb-form-embed__consent {
display: flex;
gap: 0.5rem;
align-items: flex-start;
font-size: 0.875rem;
}
.wb-form-embed__yn,
.wb-form-embed__rating {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.wb-form-embed__yn button,
.wb-form-embed__rating button {
min-width: 2.5rem;
padding: 0.375rem 0.75rem;
border-radius: var(--wb-radius, 0.375rem);
border: 1px solid var(--wb-border, rgba(127, 127, 127, 0.25));
background: var(--wb-surface, rgba(255, 255, 255, 0.04));
color: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.wb-form-embed__yn button.active,
.wb-form-embed__rating button.active {
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
color: var(--wb-primary-fg, white);
border-color: var(--wb-primary, rgba(99, 102, 241, 0.9));
}
.wb-form-embed__options {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.wb-form-embed__options label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.wb-form-embed__submitter {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding-top: 0.5rem;
border-top: 1px solid var(--wb-border, rgba(127, 127, 127, 0.15));
}
.wb-form-embed__submit {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 9999px;
background: var(--wb-primary, rgba(99, 102, 241, 0.9));
color: var(--wb-primary-fg, white);
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
align-self: flex-start;
}
.wb-form-embed__submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,149 @@
<!--
FormEmbedInspector — paste a Mana Forms share-token to embed an
existing form. Future M-fancier: a form-picker that lists the user's
published+unlisted forms with one-click selection. For M8 minimal, a
text input + helper text pointing to /forms is enough.
-->
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { FormEmbedProps } from './schema';
let { block, onChange }: BlockInspectorProps<FormEmbedProps> = $props();
const TOKEN_REGEX = /^[A-Za-z0-9_-]{32}$/;
let tokenInput = $state(block.props.token);
let tokenError = $state<string | null>(null);
$effect(() => {
// Re-sync when the block changes upstream (drag/clone).
tokenInput = block.props.token;
});
function commitToken() {
const trimmed = tokenInput.trim();
if (!trimmed) {
tokenError = null;
onChange({ token: '', resolved: undefined });
return;
}
if (!TOKEN_REGEX.test(trimmed)) {
tokenError = 'Token muss 32 Zeichen lang sein (base64url)';
return;
}
tokenError = null;
// Reset resolved on token change — the publish-resolver re-fills.
onChange({ token: trimmed, resolved: undefined });
}
function onTitleInput(e: Event) {
onChange({ titleOverride: (e.currentTarget as HTMLInputElement).value });
}
const resolved = $derived(block.props.resolved);
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Mana-Form Share-Token</span>
<input
type="text"
class="wb-mono"
value={tokenInput}
oninput={(e) => (tokenInput = (e.currentTarget as HTMLInputElement).value)}
onblur={commitToken}
placeholder="32-Zeichen base64url Token"
/>
{#if tokenError}
<small class="wb-error">{tokenError}</small>
{:else}
<small class="wb-hint">
Erstelle ein Formular unter /forms, setze die Sichtbarkeit auf "unlisted" und kopiere den
Token aus dem Share-Link.
</small>
{/if}
</label>
{#if resolved}
<div class="wb-resolved">
<p class="wb-resolved-label">Aufgelöstes Formular:</p>
<p class="wb-resolved-title">{resolved.formTitle || '(ohne Titel)'}</p>
<p class="wb-hint">
{resolved.fields.length} Feld{resolved.fields.length === 1 ? '' : 'er'}
{#if resolved.branching.length > 0}
· {resolved.branching.length} Logik-Regel{resolved.branching.length === 1 ? '' : 'n'}
{/if}
</p>
{#if resolved.error}
<p class="wb-error">{resolved.error}</p>
{/if}
</div>
{/if}
<label class="wb-field">
<span>Titel-Override <small class="wb-hint">(optional)</small></span>
<input
type="text"
value={block.props.titleOverride}
oninput={onTitleInput}
placeholder={resolved?.formTitle ?? 'Formular-Titel überschreiben'}
/>
<small class="wb-hint"> Leer lassen → der Titel des Mana-Formulars wird verwendet. </small>
</label>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8125rem;
}
.wb-field > span {
font-weight: 500;
}
.wb-field input {
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(127, 127, 127, 0.3);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 0.875rem;
font-family: inherit;
}
.wb-mono {
font-family: ui-monospace, monospace;
}
.wb-hint {
font-size: 0.75rem;
opacity: 0.65;
}
.wb-error {
font-size: 0.75rem;
color: rgb(220, 38, 38);
}
.wb-resolved {
padding: 0.625rem 0.75rem;
background: rgba(127, 127, 127, 0.06);
border: 1px solid rgba(127, 127, 127, 0.2);
border-radius: 0.375rem;
}
.wb-resolved-label {
margin: 0 0 0.25rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.5;
}
.wb-resolved-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,25 @@
import type { BlockSpec } from '../types';
import FormEmbed from './FormEmbed.svelte';
import FormEmbedInspector from './FormEmbedInspector.svelte';
import {
FormEmbedSchema,
FORM_EMBED_DEFAULTS,
type FormEmbedProps,
type FormEmbedField,
type FormEmbedBranching,
} from './schema';
export const formEmbedBlockSpec: BlockSpec<FormEmbedProps> = {
type: 'formEmbed',
label: 'Mana-Formular',
icon: 'clipboard',
category: 'form',
schema: FormEmbedSchema,
schemaVersion: 1,
defaults: FORM_EMBED_DEFAULTS,
Component: FormEmbed,
Inspector: FormEmbedInspector,
};
export type { FormEmbedProps, FormEmbedField, FormEmbedBranching };
export { FormEmbedSchema, FORM_EMBED_DEFAULTS };

View file

@ -0,0 +1,102 @@
/**
* formEmbed embed an existing Mana Form by its share-token.
*
* Different from the inline `form` block: that one is a self-contained
* fields list rendered from the block's own props. formEmbed REFERENCES
* a Form created in the /forms module so the same form can be reused
* across multiple website pages, the response-inbox lives at
* /forms/[id]/responses, and Mana features (branching, auto-sync to
* contacts/events, AI tools) all apply.
*
* The block stores the unlisted-share-token (32 chars base64url). At
* publish time, the resolver fetches the form schema via the public
* unlisted endpoint and inlines it into `resolved` the public
* renderer reads `resolved` directly without an extra round-trip on
* each visitor pageview.
*
* Plan: docs/plans/forms-module.md M8.
*/
import { z } from 'zod';
const TOKEN_REGEX = /^[A-Za-z0-9_-]{32}$/;
export const FormFieldEmbedSchema = z.object({
id: z.string(),
type: z.enum([
'short_text',
'long_text',
'single_choice',
'multi_choice',
'number',
'date',
'email',
'yes_no',
'rating',
'section',
'consent',
]),
label: z.string(),
helpText: z.string().optional(),
required: z.boolean().optional(),
options: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
config: z
.object({
minLength: z.number().optional(),
maxLength: z.number().optional(),
min: z.number().optional(),
max: z.number().optional(),
ratingScale: z.union([z.literal(5), z.literal(10)]).optional(),
})
.optional(),
});
export const BranchingRuleEmbedSchema = z.object({
id: z.string(),
ifFieldId: z.string(),
ifOperator: z.enum(['equals', 'not_equals', 'contains', 'is_empty']),
ifValue: z.union([z.string(), z.array(z.string())]).optional(),
thenAction: z.enum(['show', 'hide', 'skip_to']),
thenFieldIds: z.array(z.string()).optional(),
thenSkipToFieldId: z.string().optional(),
});
export const FormEmbedResolvedSchema = z.object({
formTitle: z.string(),
formDescription: z.string().nullable().optional(),
fields: z.array(FormFieldEmbedSchema),
branching: z.array(BranchingRuleEmbedSchema).default([]),
settings: z
.object({
submitButtonLabel: z.string().default('Senden'),
successMessage: z.string().default('Danke! Deine Antwort wurde übermittelt.'),
})
.default({ submitButtonLabel: 'Senden', successMessage: 'Danke!' }),
resolvedAt: z.string().optional(),
error: z.string().optional(),
});
export const FormEmbedSchema = z.object({
/** Mana Forms unlisted-share-token. Required to identify the form. */
token: z
.string()
.regex(TOKEN_REGEX, 'Token muss 32 Zeichen base64url sein')
.or(z.string().length(0)),
/** Optional override of the form's title in the website rendering. */
titleOverride: z.string().max(160).default(''),
/**
* Filled at publish time. Public renderer reads this directly so a
* visitor pageview doesn't trigger a fetch on the unlisted endpoint
* for every form on every page.
*/
resolved: FormEmbedResolvedSchema.optional(),
});
export type FormEmbedProps = z.infer<typeof FormEmbedSchema>;
export type FormEmbedField = z.infer<typeof FormFieldEmbedSchema>;
export type FormEmbedBranching = z.infer<typeof BranchingRuleEmbedSchema>;
export const FORM_EMBED_DEFAULTS: FormEmbedProps = {
token: '',
titleOverride: '',
};

View file

@ -44,6 +44,14 @@ export {
type GalleryImage,
} from './gallery';
export { formBlockSpec, FormSchema, FORM_DEFAULTS, type FormProps, type FormField } from './form';
export {
formEmbedBlockSpec,
FormEmbedSchema,
FORM_EMBED_DEFAULTS,
type FormEmbedProps,
type FormEmbedField,
type FormEmbedBranching,
} from './formEmbed';
export {
moduleEmbedBlockSpec,
ModuleEmbedSchema,

View file

@ -8,6 +8,7 @@ import { faqBlockSpec } from './faq';
import { columnsBlockSpec } from './columns';
import { galleryBlockSpec } from './gallery';
import { formBlockSpec } from './form';
import { formEmbedBlockSpec } from './formEmbed';
import { moduleEmbedBlockSpec } from './moduleEmbed';
import { analyticsBlockSpec } from './analytics';
@ -27,6 +28,7 @@ export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
galleryBlockSpec,
faqBlockSpec,
formBlockSpec,
formEmbedBlockSpec,
moduleEmbedBlockSpec,
analyticsBlockSpec,
columnsBlockSpec,

View file

@ -12,7 +12,7 @@ import { describe, it, expect } from 'vitest';
import { BLOCK_SCHEMAS, BLOCK_DEFAULTS, safeValidateSchema } from './schemas';
describe('registry shape', () => {
it('has the expected 11 block types', () => {
it('has the expected 12 block types', () => {
const types = Object.keys(BLOCK_SCHEMAS).sort();
expect(types).toEqual([
'analytics',
@ -20,6 +20,7 @@ describe('registry shape', () => {
'cta',
'faq',
'form',
'formEmbed',
'gallery',
'hero',
'image',

View file

@ -16,6 +16,7 @@ import { ImageSchema, IMAGE_DEFAULTS } from './image/schema';
import { GallerySchema, GALLERY_DEFAULTS } from './gallery/schema';
import { FaqSchema, FAQ_DEFAULTS } from './faq/schema';
import { FormSchema, FORM_DEFAULTS } from './form/schema';
import { FormEmbedSchema, FORM_EMBED_DEFAULTS } from './formEmbed/schema';
import { ModuleEmbedSchema, MODULE_EMBED_DEFAULTS } from './moduleEmbed/schema';
import { AnalyticsSchema, ANALYTICS_DEFAULTS } from './analytics/schema';
import { ColumnsSchema, COLUMNS_DEFAULTS } from './columns/schema';
@ -30,6 +31,7 @@ export const BLOCK_SCHEMAS: Record<string, ZodTypeAny> = {
gallery: GallerySchema,
faq: FaqSchema,
form: FormSchema,
formEmbed: FormEmbedSchema,
moduleEmbed: ModuleEmbedSchema,
analytics: AnalyticsSchema,
columns: ColumnsSchema,
@ -44,6 +46,7 @@ export const BLOCK_DEFAULTS: Record<string, unknown> = {
gallery: GALLERY_DEFAULTS,
faq: FAQ_DEFAULTS,
form: FORM_DEFAULTS,
formEmbed: FORM_EMBED_DEFAULTS,
moduleEmbed: MODULE_EMBED_DEFAULTS,
analytics: ANALYTICS_DEFAULTS,
columns: COLUMNS_DEFAULTS,