/** * Embedded form renderer (Phase 05 Task #58 — layer 2 of 4). * * This is the React mini-app that lives inside the sandboxed iframe * inserted by `apps/web/public/embed/widget.js`. It is intentionally * standalone: no app chrome, no auth, no globals beyond the form theme. * * Flow: * 1. Loader resolves the form by its public `key` (cuid). 404 when the * form is missing or disabled. * 2. Renderer paints each field from the validated `FormDefinition`. * 3. On submit, the form POSTs JSON to `/api/embed/forms/:formKey/submit` * (action handler below) and emits postMessage events the parent * bootstrap loader re-broadcasts as DOM CustomEvents on the host page. * * Security: definitions are zod-validated on read; every text node is * rendered via React (escaped); the honeypot field is rendered with * `aria-hidden`/`tabindex=-1`/inline CSS off-screen. */ import type * as React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useFetcher, useLoaderData } from "react-router"; import { FormDefinitionSchema, FormThemeSchema, HONEYPOT_FIELD_KEY, type FormDefinition, type FormField, type FormTheme, } from "@toolctrl/form-widget"; import { db } from "~/utils/db.server"; type LoaderResult = { formKey: string; definition: FormDefinition; theme: FormTheme; }; export async function loader({ params }: { params: { formKey?: string } }): Promise { const formKey = params.formKey; if (!formKey) { throw new Response(null, { status: 404 }); } const form = await db.form.findUnique({ where: { key: formKey }, select: { key: true, isEnabled: true, definition: true, theme: true }, }); if (!form || !form.isEnabled) { throw new Response(null, { status: 404 }); } const def = FormDefinitionSchema.safeParse(form.definition); if (!def.success) { throw new Response(null, { status: 500 }); } const theme = FormThemeSchema.safeParse(form.theme ?? {}); const headers = new Headers({ // Allow embedding from any origin — origin-allowlisting is enforced on // the SUBMIT endpoint, not the render endpoint (a public marketing site // legitimately wants to display the form before a visitor submits). "x-frame-options": "ALLOWALL", "cache-control": "private, max-age=60", }); return Response.json( { formKey: form.key, definition: def.data, theme: theme.success ? theme.data : {} } satisfies LoaderResult, { headers }, ); } function themeCssVars(theme: FormTheme): React.CSSProperties { const vars: Record = {}; if (theme.primaryColor) vars["--tcform-primary"] = theme.primaryColor; if (theme.backgroundColor) vars["--tcform-bg"] = theme.backgroundColor; if (theme.textColor) vars["--tcform-text"] = theme.textColor; if (theme.fontFamily) vars["--tcform-font"] = theme.fontFamily; if (theme.borderRadius) vars["--tcform-radius"] = theme.borderRadius; return vars as React.CSSProperties; } function postToParent(message: Record): void { if (typeof window === "undefined" || !window.parent || window.parent === window) return; try { window.parent.postMessage(message, "*"); } catch { /* ignore — sandboxed parent without same-origin scripting */ } } function FieldRenderer({ field, value, onChange }: { field: FormField; value: string | boolean; onChange: (next: string | boolean) => void; }): React.JSX.Element | null { const inputId = `tcform-${field.key}`; if (field.type === "honeypot") { return ( onChange(e.target.value)} style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }} /> ); } const labelEl = ( ); const helpEl = field.helpText ? (
{field.helpText}
) : null; if (field.type === "textarea") { return (
{labelEl}