mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 07:29:39 +02:00
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>
102 lines
3.2 KiB
TypeScript
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: '',
|
|
};
|