managarten/packages/website-blocks/src/formEmbed/schema.ts
Till JS ace1b706e6 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>
2026-04-29 02:38:28 +02:00

102 lines
3.2 KiB
TypeScript

/**
* 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: '',
};