Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/embed.forms.$formKey.tsx
T

316 lines
12 KiB
TypeScript

/**
* 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<Response> {
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<string, string> = {};
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<string, unknown>): 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 (
<input
type="text"
name={field.key}
tabIndex={-1}
aria-hidden="true"
autoComplete="off"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
/>
);
}
const labelEl = (
<label htmlFor={inputId} className="tcform-label">
{field.label}
{field.required ? <span aria-hidden="true"> *</span> : null}
</label>
);
const helpEl = field.helpText ? (
<div className="tcform-help" id={`${inputId}-help`}>
{field.helpText}
</div>
) : null;
if (field.type === "textarea") {
return (
<div className="tcform-row">
{labelEl}
<textarea
id={inputId}
name={field.key}
required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
minLength={field.minLength}
aria-describedby={helpEl ? `${inputId}-help` : undefined}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
{helpEl}
</div>
);
}
if (field.type === "select" || field.type === "radio") {
const options = field.options ?? [];
if (field.type === "select") {
return (
<div className="tcform-row">
{labelEl}
<select
id={inputId}
name={field.key}
required={field.required}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{field.placeholder ?? ""}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{helpEl}
</div>
);
}
return (
<fieldset className="tcform-row tcform-radio-group" aria-describedby={helpEl ? `${inputId}-help` : undefined}>
<legend>{field.label}</legend>
{options.map((opt) => (
<label key={opt.value} className="tcform-radio">
<input
type="radio"
name={field.key}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
required={field.required}
/>
<span>{opt.label}</span>
</label>
))}
{helpEl}
</fieldset>
);
}
if (field.type === "checkbox") {
return (
<div className="tcform-row tcform-checkbox">
<label className="tcform-label">
<input
type="checkbox"
name={field.key}
checked={value === true}
onChange={(e) => onChange(e.target.checked)}
required={field.required}
/>
<span>{field.label}</span>
</label>
{helpEl}
</div>
);
}
// text, email, number
const inputType = field.type === "email" ? "email" : field.type === "number" ? "number" : "text";
return (
<div className="tcform-row">
{labelEl}
<input
id={inputId}
type={inputType}
name={field.key}
required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
minLength={field.minLength}
aria-describedby={helpEl ? `${inputId}-help` : undefined}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
{helpEl}
</div>
);
}
export default function EmbeddedForm(): React.JSX.Element {
const { formKey, definition, theme } = useLoaderData() as LoaderResult;
const fetcher = useFetcher<{ ok?: boolean; error?: string }>();
const containerRef = useRef<HTMLDivElement | null>(null);
const [values, setValues] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
for (const f of definition.fields) {
init[f.key] = f.type === "checkbox" ? false : "";
}
return init;
});
const isSubmitting = fetcher.state !== "idle";
const submittedOk = fetcher.data?.ok === true;
const styleVars = useMemo(() => themeCssVars(theme), [theme]);
useEffect(() => {
if (typeof window === "undefined") return;
function emitResize() {
const h = containerRef.current?.offsetHeight ?? 0;
postToParent({ type: "tcform:resize", height: h, formKey });
}
emitResize();
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(emitResize) : null;
if (observer && containerRef.current) observer.observe(containerRef.current);
return () => observer?.disconnect();
}, [formKey]);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
if (fetcher.data.ok) {
postToParent({ type: "tcform:submitted", formKey });
} else if (fetcher.data.error) {
postToParent({ type: "tcform:error", formKey, error: fetcher.data.error });
}
}
}, [fetcher.state, fetcher.data, formKey]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>): void {
event.preventDefault();
fetcher.submit(
{ __payload: JSON.stringify({ values }) },
{ method: "post", action: `/api/embed/forms/${encodeURIComponent(formKey)}/submit`, encType: "application/x-www-form-urlencoded" },
);
}
return (
<div ref={containerRef} className="tcform-root" style={styleVars}>
<style>{`
.tcform-root { font-family: var(--tcform-font, system-ui, sans-serif); background: var(--tcform-bg, #fff); color: var(--tcform-text, #111); padding: 16px; box-sizing: border-box; }
.tcform-root h1 { font-size: 1.25rem; margin: 0 0 0.25em; }
.tcform-root p.tcform-description { margin: 0 0 1em; opacity: 0.8; }
.tcform-row { display: block; margin-bottom: 12px; }
.tcform-root .tcform-label { display: block; font-weight: 500; margin-bottom: 4px; }
.tcform-root input[type=text], .tcform-root input[type=email], .tcform-root input[type=number], .tcform-root textarea, .tcform-root select { width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: var(--tcform-radius, 6px); font: inherit; color: inherit; background: #fff; box-sizing: border-box; }
.tcform-root textarea { min-height: 100px; resize: vertical; }
.tcform-root .tcform-help { font-size: 0.85em; opacity: 0.7; margin-top: 4px; }
.tcform-root button { background: var(--tcform-primary, #2563eb); color: #fff; border: 0; padding: 10px 16px; border-radius: var(--tcform-radius, 6px); cursor: pointer; font: inherit; }
.tcform-root button[disabled] { opacity: 0.6; cursor: progress; }
.tcform-root .tcform-success { padding: 12px; border-radius: var(--tcform-radius, 6px); background: #ecfdf5; color: #065f46; }
.tcform-root .tcform-error { padding: 12px; border-radius: var(--tcform-radius, 6px); background: #fef2f2; color: #991b1b; margin-bottom: 12px; }
`}</style>
<h1>{definition.title}</h1>
{definition.description ? <p className="tcform-description">{definition.description}</p> : null}
{submittedOk ? (
<div className="tcform-success" role="status">
{definition.successMessage}
</div>
) : (
<form onSubmit={handleSubmit} noValidate={false}>
{fetcher.data?.error ? (
<div className="tcform-error" role="alert">
{fetcher.data.error}
</div>
) : null}
{definition.fields.map((field) => (
<FieldRenderer
key={field.key}
field={field}
value={values[field.key] ?? (field.type === "checkbox" ? false : "")}
onChange={(next) => setValues((prev) => ({ ...prev, [field.key]: next }))}
/>
))}
{/* Honeypot field auto-included via definition.fields if the builder added one
with key=HONEYPOT_FIELD_KEY; keep the constant referenced for build-tree pruning. */}
<input type="hidden" name="__hp_key" value={HONEYPOT_FIELD_KEY} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "…" : definition.submitLabel}
</button>
</form>
)}
</div>
);
}