226 lines
8.0 KiB
TypeScript
226 lines
8.0 KiB
TypeScript
/**
|
|
* Preview tab for the form-widget builder. Renders the in-flight
|
|
* definition using the SAME field-rendering pipeline + inline CSS as
|
|
* the embed route at `/embed/forms/$formKey`. That is intentional:
|
|
* what the customer sees in their iframe must match what the operator
|
|
* sees in the admin preview.
|
|
*
|
|
* The component is deliberately self-contained — it is rendered
|
|
* outside the sandboxed iframe so no postMessage / parent-resize
|
|
* concerns apply, and form-submission is disabled (preview is for
|
|
* visual inspection only).
|
|
*/
|
|
import type * as React from "react";
|
|
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { HONEYPOT_FIELD_KEY, type FormDefinition, type FormField, type FormTheme } from "@toolctrl/form-widget";
|
|
|
|
export type PreviewTabProps = {
|
|
definition: FormDefinition;
|
|
theme: FormTheme;
|
|
};
|
|
|
|
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 PreviewField({
|
|
field,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
field: FormField;
|
|
value: string | boolean;
|
|
onChange: (next: string | boolean) => void;
|
|
}): React.JSX.Element | null {
|
|
const inputId = `tcform-preview-${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") {
|
|
const options = field.options ?? [];
|
|
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>
|
|
);
|
|
}
|
|
if (field.type === "radio") {
|
|
const options = field.options ?? [];
|
|
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>
|
|
);
|
|
}
|
|
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 function PreviewTab({ definition, theme }: PreviewTabProps): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const styleVars = useMemo(() => themeCssVars(theme), [theme]);
|
|
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;
|
|
});
|
|
|
|
return (
|
|
<div className="border rounded bg-white p-3 max-w-xl">
|
|
<p className="text-[11px] text-gray-500 mb-2">
|
|
{t(
|
|
"settings.forms.builder.preview.description",
|
|
"Preview renders with the same CSS variables and field components as the embedded form route. Submission is disabled.",
|
|
)}
|
|
</p>
|
|
<div 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; border: 1px dashed #e5e7eb; border-radius: 6px; }
|
|
.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; }
|
|
`}</style>
|
|
<h1>{definition.title}</h1>
|
|
{definition.description ? <p className="tcform-description">{definition.description}</p> : null}
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
{definition.fields.map((field) => (
|
|
<PreviewField
|
|
key={field.key}
|
|
field={field}
|
|
value={values[field.key] ?? (field.type === "checkbox" ? false : "")}
|
|
onChange={(next) => setValues((prev) => ({ ...prev, [field.key]: next }))}
|
|
/>
|
|
))}
|
|
<input type="hidden" name="__hp_key" value={HONEYPOT_FIELD_KEY} />
|
|
<button type="button" disabled>
|
|
{definition.submitLabel}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|