316 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|