mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
57b7a43147
commit
ace1b706e6
10 changed files with 913 additions and 2 deletions
501
packages/website-blocks/src/formEmbed/FormEmbed.svelte
Normal file
501
packages/website-blocks/src/formEmbed/FormEmbed.svelte
Normal 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>
|
||||
149
packages/website-blocks/src/formEmbed/FormEmbedInspector.svelte
Normal file
149
packages/website-blocks/src/formEmbed/FormEmbedInspector.svelte
Normal 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>
|
||||
25
packages/website-blocks/src/formEmbed/index.ts
Normal file
25
packages/website-blocks/src/formEmbed/index.ts
Normal 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 };
|
||||
102
packages/website-blocks/src/formEmbed/schema.ts
Normal file
102
packages/website-blocks/src/formEmbed/schema.ts
Normal 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: '',
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue