From ace1b706e69a62976b8beba1dd9b832d3643f608 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 29 Apr 2026 02:38:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(forms):=20M8=20website-block=20=E2=80=94?= =?UTF-8?q?=20formEmbed=20bindet=20Mana-Formulare=20ein?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/modules/website/forms-embeds.ts | 115 ++++ .../web/src/lib/modules/website/publish.ts | 7 +- .../src/formEmbed/FormEmbed.svelte | 501 ++++++++++++++++++ .../src/formEmbed/FormEmbedInspector.svelte | 149 ++++++ .../website-blocks/src/formEmbed/index.ts | 25 + .../website-blocks/src/formEmbed/schema.ts | 102 ++++ packages/website-blocks/src/index.ts | 8 + packages/website-blocks/src/registry.ts | 2 + packages/website-blocks/src/schemas.test.ts | 3 +- packages/website-blocks/src/schemas.ts | 3 + 10 files changed, 913 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts create mode 100644 packages/website-blocks/src/formEmbed/FormEmbed.svelte create mode 100644 packages/website-blocks/src/formEmbed/FormEmbedInspector.svelte create mode 100644 packages/website-blocks/src/formEmbed/index.ts create mode 100644 packages/website-blocks/src/formEmbed/schema.ts diff --git a/apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts b/apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts new file mode 100644 index 000000000..39916002f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts @@ -0,0 +1,115 @@ +/** + * formEmbed resolver — client-side function that looks up the + * referenced Mana Form by its unlisted-share-token and inlines the + * public schema into `block.props.resolved` at publish time. + * + * Why client-side: the webapp owns both the website AND the forms; + * decrypting the form's title/fields/branching needs the master key, + * which only lives on the client. The resolver runs in the same + * publish transaction as `resolveEmbed` for moduleEmbed blocks. + * + * Token-only lookup (no formId): the block stores just the share- + * token, which is the canonical identity for a published+unlisted + * form. Token rotation invalidates the embed — the resolver reports + * a clear error and the public renderer falls back to its lazy fetch. + * + * Plan: docs/plans/forms-module.md M8. + */ + +import { decryptRecords } from '$lib/data/crypto'; +import { formTable } from '$lib/modules/forms/collections'; +import { toForm } from '$lib/modules/forms/queries'; +import type { FormEmbedProps } from '@mana/website-blocks'; +import type { LocalForm } from '$lib/modules/forms/types'; + +export async function resolveFormEmbed( + props: FormEmbedProps +): Promise> { + const now = new Date().toISOString(); + if (!props.token) { + return { + formTitle: '', + formDescription: null, + fields: [], + branching: [], + settings: { submitButtonLabel: 'Senden', successMessage: 'Danke!' }, + resolvedAt: now, + error: 'Kein Token gesetzt', + }; + } + + try { + // Linear scan over the user's forms — typical count is small + // (<100), Dexie has no index on unlistedToken. The publish + // pipeline runs once per click, so this stays cheap. + const all = (await formTable.toArray()).filter( + (f) => !f.deletedAt && f.unlistedToken === props.token + ); + if (all.length === 0) { + return { + formTitle: '', + formDescription: null, + fields: [], + branching: [], + settings: { submitButtonLabel: 'Senden', successMessage: 'Danke!' }, + resolvedAt: now, + error: 'Formular zum Token nicht gefunden', + }; + } + const decrypted = (await decryptRecords('forms', all)) as LocalForm[]; + const form = toForm(decrypted[0]); + + if (form.status !== 'published') { + return { + formTitle: form.title, + formDescription: form.description, + fields: [], + branching: [], + settings: { + submitButtonLabel: form.settings.submitButtonLabel, + successMessage: form.settings.successMessage, + }, + resolvedAt: now, + error: 'Formular ist nicht veröffentlicht', + }; + } + + return { + formTitle: form.title, + formDescription: form.description, + fields: form.fields.map((f) => ({ + id: f.id, + type: f.type, + label: f.label, + helpText: f.helpText, + required: f.required, + options: f.options, + config: f.config, + })), + branching: form.branching.map((r) => ({ + id: r.id, + ifFieldId: r.ifFieldId, + ifOperator: r.ifOperator, + ifValue: r.ifValue, + thenAction: r.thenAction, + thenFieldIds: r.thenFieldIds, + thenSkipToFieldId: r.thenSkipToFieldId, + })), + settings: { + submitButtonLabel: form.settings.submitButtonLabel, + successMessage: form.settings.successMessage, + }, + resolvedAt: now, + }; + } catch (err) { + return { + formTitle: '', + formDescription: null, + fields: [], + branching: [], + settings: { submitButtonLabel: 'Senden', successMessage: 'Danke!' }, + resolvedAt: now, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/apps/mana/apps/web/src/lib/modules/website/publish.ts b/apps/mana/apps/web/src/lib/modules/website/publish.ts index defdee1ba..0bc0d9fdf 100644 --- a/apps/mana/apps/web/src/lib/modules/website/publish.ts +++ b/apps/mana/apps/web/src/lib/modules/website/publish.ts @@ -17,6 +17,7 @@ import { getManaApiUrl } from '$lib/api/config'; import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections'; import { resolveEmbed } from './embeds'; +import { resolveFormEmbed } from './forms-embeds'; import type { LocalWebsite, LocalWebsiteBlock, @@ -26,7 +27,7 @@ import type { SiteSettings, PageSeo, } from './types'; -import type { ModuleEmbedProps } from '@mana/website-blocks'; +import type { FormEmbedProps, ModuleEmbedProps } from '@mana/website-blocks'; // ─── Snapshot shape ────────────────────────────────────── @@ -149,6 +150,10 @@ async function resolveEmbedsInTree(nodes: SnapshotBlockNode[]): Promise { const props = node.props as ModuleEmbedProps; const resolved = await resolveEmbed(props); node.props = { ...props, resolved }; + } else if (node.type === 'formEmbed') { + const props = node.props as FormEmbedProps; + const resolved = await resolveFormEmbed(props); + node.props = { ...props, resolved }; } if (node.children.length > 0) { await resolveEmbedsInTree(node.children); diff --git a/packages/website-blocks/src/formEmbed/FormEmbed.svelte b/packages/website-blocks/src/formEmbed/FormEmbed.svelte new file mode 100644 index 000000000..1cf80563b --- /dev/null +++ b/packages/website-blocks/src/formEmbed/FormEmbed.svelte @@ -0,0 +1,501 @@ + + + +
+
+ {#if !blockProps.token} +
+

+ Kein Form-Token gesetzt. Wähle im Inspector ein veröffentlichtes Mana-Formular aus. +

+
+ {:else if !isPublic} +
+

{headerTitle || 'Eingebettetes Mana-Formular'}

+

+ Token: {blockProps.token.slice(0, 8)}… +

+ {#if resolved?.fields?.length} +

+ {resolved.fields.length} Feld{resolved.fields.length === 1 ? '' : 'er'} — wird beim Veröffentlichen + frisch aufgelöst. +

+ {:else} +

Form-Schema wird beim Veröffentlichen abgerufen.

+ {/if} +
+ {:else if fallbackLoading} +

Lade Formular …

+ {:else if fallbackError} +

Formular konnte nicht geladen werden: {fallbackError}

+ {:else if !resolved} +

Form-Schema fehlt.

+ {:else if submitted} +
{resolved.settings.successMessage}
+ {:else} + {#if headerTitle} +

{headerTitle}

+ {/if} + {#if resolved.formDescription} +

{resolved.formDescription}

+ {/if} +
+ {#each resolved.fields as field (field.id)} +
+ {#if field.type === 'section'} +
+

{field.label}

+ {:else if field.type === 'consent'} + + {:else} + + {#if field.helpText} + {field.helpText} + {/if} + + {#if field.type === 'short_text'} + setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)} + /> + {:else if field.type === 'long_text'} + + {:else if field.type === 'email'} + setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)} + /> + {:else if field.type === 'number'} + { + const v = (e.currentTarget as HTMLInputElement).value; + setAnswer(field.id, v === '' ? null : Number(v)); + }} + /> + {:else if field.type === 'date'} + setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)} + /> + {:else if field.type === 'yes_no'} +
+ + +
+ {:else if field.type === 'rating'} +
+ {#each ratingScale(field) as n} + + {/each} +
+ {:else if field.type === 'single_choice'} +
+ {#each field.options ?? [] as opt (opt.id)} + + {/each} +
+ {:else if field.type === 'multi_choice'} +
+ {#each field.options ?? [] as opt (opt.id)} + {@const checked = ((answers[field.id] as string[] | undefined) ?? []).includes( + opt.id + )} + + {/each} +
+ {/if} + {/if} +
+ {/each} + +
+ + + + +
+ + {#if submitError} +

{submitError}

+ {/if} + + +
+ {/if} +
+
+ + diff --git a/packages/website-blocks/src/formEmbed/FormEmbedInspector.svelte b/packages/website-blocks/src/formEmbed/FormEmbedInspector.svelte new file mode 100644 index 000000000..35699dd29 --- /dev/null +++ b/packages/website-blocks/src/formEmbed/FormEmbedInspector.svelte @@ -0,0 +1,149 @@ + + + +
+ + + {#if resolved} +
+

Aufgelöstes Formular:

+

{resolved.formTitle || '(ohne Titel)'}

+

+ {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} +

+ {#if resolved.error} +

{resolved.error}

+ {/if} +
+ {/if} + + +
+ + diff --git a/packages/website-blocks/src/formEmbed/index.ts b/packages/website-blocks/src/formEmbed/index.ts new file mode 100644 index 000000000..d0269fc5f --- /dev/null +++ b/packages/website-blocks/src/formEmbed/index.ts @@ -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 = { + 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 }; diff --git a/packages/website-blocks/src/formEmbed/schema.ts b/packages/website-blocks/src/formEmbed/schema.ts new file mode 100644 index 000000000..cfb4fb4ca --- /dev/null +++ b/packages/website-blocks/src/formEmbed/schema.ts @@ -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; +export type FormEmbedField = z.infer; +export type FormEmbedBranching = z.infer; + +export const FORM_EMBED_DEFAULTS: FormEmbedProps = { + token: '', + titleOverride: '', +}; diff --git a/packages/website-blocks/src/index.ts b/packages/website-blocks/src/index.ts index 06ecc9613..5c3e08964 100644 --- a/packages/website-blocks/src/index.ts +++ b/packages/website-blocks/src/index.ts @@ -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, diff --git a/packages/website-blocks/src/registry.ts b/packages/website-blocks/src/registry.ts index 3f291b293..4f9b774fe 100644 --- a/packages/website-blocks/src/registry.ts +++ b/packages/website-blocks/src/registry.ts @@ -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[] = [ galleryBlockSpec, faqBlockSpec, formBlockSpec, + formEmbedBlockSpec, moduleEmbedBlockSpec, analyticsBlockSpec, columnsBlockSpec, diff --git a/packages/website-blocks/src/schemas.test.ts b/packages/website-blocks/src/schemas.test.ts index 1e0470bc7..a621cf887 100644 --- a/packages/website-blocks/src/schemas.test.ts +++ b/packages/website-blocks/src/schemas.test.ts @@ -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', diff --git a/packages/website-blocks/src/schemas.ts b/packages/website-blocks/src/schemas.ts index 955c9a01a..7f37da46f 100644 --- a/packages/website-blocks/src/schemas.ts +++ b/packages/website-blocks/src/schemas.ts @@ -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 = { gallery: GallerySchema, faq: FaqSchema, form: FormSchema, + formEmbed: FormEmbedSchema, moduleEmbed: ModuleEmbedSchema, analytics: AnalyticsSchema, columns: ColumnsSchema, @@ -44,6 +46,7 @@ export const BLOCK_DEFAULTS: Record = { gallery: GALLERY_DEFAULTS, faq: FAQ_DEFAULTS, form: FORM_DEFAULTS, + formEmbed: FORM_EMBED_DEFAULTS, moduleEmbed: MODULE_EMBED_DEFAULTS, analytics: ANALYTICS_DEFAULTS, columns: COLUMNS_DEFAULTS,