← Files WixARCHIVED FILE

skills/wix-headless-templates/forms/app/wix/forms/forms-core.ts

35.6 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Form-schema rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./forms.ts
// (the SDK, managed Astro and React) and the REST twin in templates/forms/rest/forms.ts (fetch, a
// static site or a port to another language). Every rule about how a raw Form Schemas v4 entity
// flattens into a FormDto lives HERE, once — and so does the evaluation of the owner's conditional
// rules, which the store re-runs on every value change. A raw form may come from the SDK (`_id`) or
// from REST (`id`); the mappers accept both. Imports are type-only so a strip to JS emits no imports.
// docs: https://dev.wix.com/docs/api-reference/crm/forms/form-schemas/about-form-fields.md
import { ADDRESS_TEMPLATES } from "./address-templates.generated";
import type {
  FormAddressPart,
  FormChoice,
  FormControl,
  FormDto,
  FormFieldDto,
  FormRule,
  FormStep,
  FormSuccess,
  FormValidation,
  FormValues,
  RuleCondition,
  RuleEffect,
  AddressOverrides,
} from "./types";

/** A raw Form Schemas v4 entity as either transport returns it. */
export type Raw = Record<string, any>;
/** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
export type ImgSrc = (value: any, width: number, height: number) => string;

/** The Wix Forms app namespace. Every form this vertical touches lives here. */
export const FORMS_NAMESPACE = "wix.form_app.form";

const id = (raw: Raw | undefined): string => raw?._id ?? raw?.id ?? "";

// A field's settings nest TWO levels deep, under blocks named after its own enums:
//   inputOptions.<inputType block>.<componentType block>.label
// These tables resolve those names. Spelling them at a call site is one typo away from
// silently reading back no label at all — which is the whole reason this file exists.
const INPUT_BLOCK: Record<string, string> = {
  STRING: "stringOptions",
  NUMBER: "numberOptions",
  BOOLEAN: "booleanOptions",
  ARRAY: "arrayOptions",
  ADDRESS: "addressOptions",
  WIX_FILE: "wixFileOptions",
  PAYMENT: "paymentOptions",
  SCHEDULING: "schedulingOptions",
};

const COMPONENT_BLOCK: Record<string, string> = {
  TEXT_INPUT: "textInputOptions",
  PASSWORD: "passwordOptions",
  NUMBER_INPUT: "numberInputOptions",
  RATING_INPUT: "ratingInputOptions",
  PHONE_INPUT: "phoneInputOptions",
  DATE_INPUT: "dateInputOptions",
  DATE_PICKER: "datePickerOptions",
  DATE_TIME: "dateTimeOptions",
  TIME_INPUT: "timeInputOptions",
  CHECKBOX: "checkboxOptions",
  CHECKBOX_GROUP: "checkboxGroupOptions",
  RADIO_GROUP: "radioGroupOptions",
  DROPDOWN: "dropdownOptions",
  TAGS: "tagsOptions",
  MULTILINE_ADDRESS: "multilineAddressOptions",
  FILE_UPLOAD: "fileUploadOptions",
  SIGNATURE: "signatureOptions",
  FIXED_PAYMENT: "fixedPaymentOptions",
  PAYMENT_INPUT: "paymentInputOptions",
  DONATION_INPUT: "donationInputOptions",
  APPOINTMENT: "appointmentOptions",
  SERVICES_DROPDOWN: "servicesDropdownOptions",
  SERVICES_CHECKBOX_GROUP: "servicesCheckboxGroupOptions",
};

// componentType → the control to render. Several field kinds share one component (short and
// long answer are both TEXT_INPUT), so `format`, `identifier` and `inputType` refine it below.
const CONTROL: Record<string, FormControl> = {
  TEXT_INPUT: "text",
  PASSWORD: "password",
  NUMBER_INPUT: "number",
  RATING_INPUT: "rating",
  PHONE_INPUT: "phone",
  DATE_INPUT: "date",
  DATE_PICKER: "date",
  DATE_TIME: "datetime",
  TIME_INPUT: "time",
  CHECKBOX: "checkbox",
  CHECKBOX_GROUP: "checkboxGroup",
  RADIO_GROUP: "radio",
  DROPDOWN: "select",
  TAGS: "tags",
  MULTILINE_ADDRESS: "address",
  FILE_UPLOAD: "file",
  SIGNATURE: "signature",
  FIXED_PAYMENT: "payment",
  PAYMENT_INPUT: "payment",
  DONATION_INPUT: "payment",
  SERVICES_DROPDOWN: "select",
  SERVICES_CHECKBOX_GROUP: "checkboxGroup",
  APPOINTMENT: "appointment",
};

// `uploadFileFormats` family → the <input accept> list Wix's own uploader uses (form-fields
// file-format.tsx). Video/image/audio are wildcard mime types; documents and archives are
// extension lists because their mime types are inconsistent across browsers.
export const FILE_FORMAT_ACCEPT: Record<string, string> = {
  VIDEO: "video/*",
  IMAGE: "image/*",
  AUDIO: "audio/*",
  DOCUMENT:
    ".ai,.cdr,.csv,.doc,.docb,.docx,.dot,.dotx,.dwg,.eps,.epub,.fla,.gpx,.ical,.icalendar,.ics,.ifb,.indd,.ipynb,.key,.kml,.kmz,.mobi,.mtf,.mtx,.numbers,.odg,.odp,.ods,.odt,.otp,.ots,.ott,.oxps,.pages,.pdf,.pdn,.pkg,.pot,.potx,.pps,.ppsx,.ppt,.pptx,.psd,.pub,.rtf,.sldx,.txt,.json,.vcf,.xcf,.xls,.xlsx,.xlt,.xltx,.xlw,.xps,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/pdf,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel",
  ARCHIVE: ".zip,.rar,.tar,.tar.gz,.gz,.gzip,.jar,.7z,.fgz,.webarchive",
};

/**
 * Every country an address may name when the owner set no `allowedCountries` — the list Wix's
 * own address field enumerates (form-multiline-address country-codes.ts). ISO 3166-1 alpha-2.
 */
export const COUNTRY_CODES: readonly string[] = ["AD","AE","AF","AG","AI","AL","AM","AN","AO","AQ","AR","AS","AT","AU","AW","AX","AZ","BA","BB","BD","BE","BF","BG","BH","BI","BJ","BL","BM","BN","BO","BQ","BR","BS","BT","BV","BW","BY","BZ","CA","CC","CD","CF","CG","CH","CI","CK","CL","CM","CN","CO","CR","CV","CW","CX","CY","CZ","DE","DJ","DK","DM","DO","DZ","EC","EE","EG","EH","ER","ES","ET","FI","FJ","FK","FM","FO","FR","GA","GB","GD","GE","GF","GG","GH","GI","GL","GM","GN","GP","GQ","GR","GS","GT","GU","GW","GY","HK","HM","HN","HR","HT","HU","ID","IE","IL","IM","IN","IO","IQ","IS","IT","JE","JM","JO","JP","KE","KG","KH","KI","KM","KN","KR","KW","KY","KZ","LA","LB","LC","LI","LK","LR","LS","LT","LU","LV","LY","MA","MC","MD","ME","MF","MG","MH","MK","ML","MM","MN","MO","MP","MQ","MR","MS","MT","MU","MV","MW","MX","MY","MZ","NA","NC","NE","NF","NG","NI","NL","NO","NP","NR","NU","NZ","OM","PA","PE","PF","PG","PH","PK","PL","PM","PN","PR","PS","PT","PW","PY","QA","RE","RO","RS","RU","RW","SA","SB","SC","SD","SE","SG","SH","SI","SJ","SK","SL","SM","SN","SO","SR","SS","ST","SV","SX","SZ","TC","TD","TF","TG","TH","TJ","TK","TL","TM","TN","TO","TR","TT","TV","TW","TZ","UA","UG","UM","US","UY","UZ","VA","VC","VE","VG","VI","VN","VU","WF","WS","XK","YE","YT","ZA","ZM","ZW"];

/**
 * Every subfield an address can have. WHICH ones a given address shows is per country
 * (`addressPartsForCountry`): Wix's address field loads one of twelve templates by country, and
 * the submission API rejects any other key as "additional properties".
 */
export const ADDRESS_SUBFIELDS = ["country", "addressLine", "addressLine2", "streetName", "streetNumber", "city", "subdivision", "postalCode"] as const;

// An enum missing from a table means Wix added a type. Say so ONCE — a silent empty block
// renders a field labelled by its storage key with none of its settings.
const warned = new Set<string>();
function blockName(table: Record<string, string>, key: string | undefined, what: string): string | undefined {
  const name = key ? table[key] : undefined;
  if (!name && key && !warned.has(what + key)) {
    warned.add(what + key);
    console.warn(
      `forms: no ${what} block mapped for "${key}" — that field renders without its label or ` +
        `options. Add it to the table in wix/forms/forms-core.ts.`,
    );
  }
  return name;
}

/**
 * Ricos rich content → plain text. Labels, descriptions, the owner's thank-you and "form closed"
 * messages all arrive as rich content; block nodes (paragraphs, headings, list items) become
 * lines so a multi-paragraph thank-you keeps its breaks. Links and decorations are dropped.
 */
export function plainText(label: unknown): string {
  if (typeof label === "string") return label;
  const nodes = (label as Raw)?.nodes;
  if (!Array.isArray(nodes)) return "";
  const BLOCK = /^(PARAGRAPH|HEADING|LIST_ITEM|BLOCKQUOTE|CODE_BLOCK)$/;
  const walk = (list: Raw[]): string =>
    list
      .map((n) => (n?.textData?.text ?? "") + (Array.isArray(n?.nodes) ? walk(n.nodes) : "") + (BLOCK.test(n?.type ?? "") ? "\n" : ""))
      .join("");
  return walk(nodes).replace(/\n{3,}/g, "\n\n").trim();
}

/** `postalCode` → "Postal code". A subfield is a key; the schema carries no label for it. */
export function humanizeSub(sub: string): string {
  const words = sub.replace(/([A-Z])|(\d+)/g, " $1$2").toLowerCase().trim();
  return words.charAt(0).toUpperCase() + words.slice(1);
}

/**
 * "US" → "United States". From the shipped data first — the same string on the server and in the
 * browser, which Intl.DisplayNames does not guarantee (Node and Chrome spell Hong Kong, Macao,
 * Palestine and the Falklands differently, and a server-rendered list the browser disagrees with
 * is a hydration mismatch). Intl for a code the data lacks; the code itself otherwise.
 */
export function countryName(code: string): string {
  const known = ADDRESS_TEMPLATES.countryNames[code];
  if (known) return known;
  try {
    const dn = (Intl as any).DisplayNames ? new (Intl as any).DisplayNames(["en"], { type: "region" }) : null;
    return (dn?.of(code) as string | undefined) ?? code;
  } catch {
    return code;
  }
}

const RELATIVE_DATE = /^\$now(?:([+-])(\d{1,2})([yMdmh]))?$/;

/**
 * A date bound or default as the schema stores it — an ISO string, or `$now`, `$now+2d`,
 * `$now-1M` (units y M d m h; string-format-options-mapper.ts) — resolved to a static value in
 * the field's own format: `YYYY-MM-DD` (date), `HH:mm:ss` (time), `YYYY-MM-DDTHH:mm:ss` (datetime).
 * An ISO input is returned unchanged; anything else (an unsupported expression) is dropped.
 */
export function resolveDate(value: unknown, control: string, now: Date = new Date()): string | undefined {
  if (typeof value !== "string" || !value) return undefined;
  const m = value.match(RELATIVE_DATE);
  if (!m) return /^\d{4}-\d{2}-\d{2}|^\d{2}:\d{2}/.test(value) ? value : undefined;
  const d = new Date(now.getTime());
  const sign = m[1] === "-" ? -1 : 1;
  const n = m[2] ? sign * parseInt(m[2], 10) : 0;
  switch (m[3]) {
    case "y": d.setUTCFullYear(d.getUTCFullYear() + n); break;
    case "M": d.setUTCMonth(d.getUTCMonth() + n); break;
    case "d": d.setUTCDate(d.getUTCDate() + n); break;
    case "h": d.setUTCHours(d.getUTCHours() + n); break;
    case "m": d.setUTCMinutes(d.getUTCMinutes() + n); break;
  }
  const iso = d.toISOString(); // YYYY-MM-DDTHH:mm:ss.sssZ
  if (control === "time") return iso.slice(11, 19);
  if (control === "datetime") return iso.slice(0, 19);
  return iso.slice(0, 10);
}

/** The submitted value of a choice field's free-text "Other" entry: `"<Other label>: <text>"` (radio-group-field-headless.tsx). */
export function otherValue(field: FormFieldDto, text: string): string {
  return `${field.otherOption?.label ?? "Other"}: ${text}`;
}

/** The visitor's text back out of an "Other" value, or null when the value is a listed choice. */
export function otherText(field: FormFieldDto, value: unknown): string | null {
  if (!field.otherOption || typeof value !== "string" || !value) return null;
  if (field.choices.some((c) => c.value === value)) return null;
  const prefix = `${field.otherOption.label}: `;
  return value.startsWith(prefix) ? value.slice(prefix.length) : value;
}

/** The owner's address settings, as `addressPartsForCountry` needs them. */
function addressOverrides(rules: Raw, component: Raw): AddressOverrides {
  const fields: Raw = rules.fields ?? {};
  const settings: Raw = component.fieldSettings ?? {};
  return {
    required: Object.fromEntries(Object.keys(fields).map((k) => [k, fields[k]?.required as boolean | undefined])),
    show: Object.fromEntries(Object.keys(settings).map((k) => [k, settings[k]?.show as boolean | undefined])),
    allowedCountries: Array.isArray(rules.allowedCountries) ? rules.allowedCountries.map(String) : [],
  };
}

/**
 * The subfields of an address for ONE country, in Wix's order: `country` first (Wix prepends it
 * to every template and requires it exactly when the address is required, get-country-field.ts),
 * then the country's template — Israel: street name, street number, city, postal code; the United
 * States: address line, city, state, postal code — with the owner's `validation.fields` deciding
 * requiredness and `fieldSettings` visibility over the template's defaults. A `subdivision` part
 * carries the country's states / provinces / regions as `choices` (ISO 3166-2 codes) when Wix has
 * them on record. No country yet → Wix's common template.
 */
export function addressPartsForCountry(country: string | undefined, o: AddressOverrides, required: boolean): FormAddressPart[] {
  const code = country ? country.toUpperCase() : "";
  const templateName = ADDRESS_TEMPLATES.byCountry[code] ?? ADDRESS_TEMPLATES.defaultTemplate;
  const template = ADDRESS_TEMPLATES.templates[templateName] ?? ADDRESS_TEMPLATES.templates[ADDRESS_TEMPLATES.defaultTemplate] ?? [];
  const allowed = o.allowedCountries.length ? o.allowedCountries : [...COUNTRY_CODES];
  const parts: FormAddressPart[] = [{
    sub: "country",
    label: "Country",
    required: o.required.country ?? required,
    choices: allowed.map((c) => ({ value: c, label: countryName(c) })),
  }];
  const subdivisions = ADDRESS_TEMPLATES.subdivisions[code];
  for (const t of template) {
    if (!(o.show[t.sub] ?? !t.hidden)) continue;
    const isSubdivision = t.sub === "subdivision";
    parts.push({
      sub: t.sub,
      label: isSubdivision && subdivisions ? subdivisions.label : humanizeSub(t.sub),
      required: o.required[t.sub] ?? t.required,
      ...(isSubdivision && subdivisions ? { choices: subdivisions.list.map(([k, name]) => ({ value: `${code}-${k}`, label: name })) } : {}),
    });
  }
  return parts;
}

function addressParts(rules: Raw, component: Raw, required: boolean): FormAddressPart[] {
  // The owner's pre-selected country when the field has one; else the common template until the
  // visitor picks a country (the store recomputes the parts on every change).
  const preset = component.defaultCountryConfig?.countryOptions;
  return addressPartsForCountry(typeof preset === "string" ? preset : undefined, addressOverrides(rules, component), required);
}

export function toField(raw: Raw, imgSrc: ImgSrc, stepId: string): FormFieldDto {
  const input = raw.inputOptions ?? {};
  const inputType: string = input.inputType ?? "";
  const optionsBlock: Raw = input[blockName(INPUT_BLOCK, inputType, "inputType") ?? ""] ?? {};
  const componentType: string = optionsBlock.componentType ?? "";
  const component: Raw = optionsBlock[blockName(COMPONENT_BLOCK, componentType, "componentType") ?? ""] ?? {};
  const rules: Raw = optionsBlock.validation ?? {};
  const identifier: string = raw.identifier ?? "";
  const required: boolean = input.required ?? false;

  let control: FormControl = CONTROL[componentType] ?? "unknown";
  // A product list is a PAYMENT field wearing a CHECKBOX_GROUP component — typed by its
  // component it would look like a multi-choice and be bound to an array of strings, when what
  // it submits is a payment structure. The input type wins.
  if (inputType === "PAYMENT") control = "payment";
  if (control === "text") {
    // A long answer is NOT flagged on the component (v4 TextInput has no `multiline` or
    // `numberOfLines`); only `identifier: TEXT_AREA` says so.
    if (identifier === "TEXT_AREA") control = "textarea";
    else if (rules.format === "EMAIL") control = "email";
    else if (rules.format === "URL") control = "url";
    else if (rules.format === "PHONE") control = "phone";
  }

  // Date bounds live under the format's own options block; `$now±N` is resolved here, once, so
  // the DTO carries a real ISO value for `min`/`max` attributes and for the client check.
  const dateRules: Raw = rules.dateOptions ?? rules.dateTimeOptions ?? rules.timeOptions ?? rules.dateOptionalTimeOptions ?? {};
  const fileFormats: string[] = Array.isArray(rules.uploadFileFormats) ? rules.uploadFileFormats : [];
  const isBirthdate = identifier === "CONTACTS_BIRTHDATE";
  const validation: FormValidation = {
    ...(rules.format ? { format: rules.format } : {}),
    ...(rules.minLength != null ? { minLength: rules.minLength } : {}),
    ...(rules.maxLength != null ? { maxLength: rules.maxLength } : {}),
    ...(rules.pattern ? { pattern: rules.pattern } : {}),
    ...(rules.validationMessages?.pattern ? { patternMessage: String(rules.validationMessages.pattern) } : {}),
    ...(rules.minimum != null ? { minimum: rules.minimum } : {}),
    ...(rules.maximum != null ? { maximum: rules.maximum } : {}),
    ...(rules.multipleOf != null ? { multipleOf: rules.multipleOf } : {}),
    // CONTACTS_BIRTHDATE is bounded 1900-01-01..today by Wix's own field (contacts-birthdate-validation.tsx).
    ...(resolveDate(dateRules.minimum, control) || isBirthdate
      ? { minDate: resolveDate(dateRules.minimum, control) ?? "1900-01-01" } : {}),
    ...(resolveDate(dateRules.maximum, control) || isBirthdate
      ? { maxDate: resolveDate(dateRules.maximum, control) ?? resolveDate("$now", "date") } : {}),
    ...(rules.fileLimit != null ? { fileLimit: rules.fileLimit } : {}),
    ...(fileFormats.length
      ? { fileFormats, accept: fileFormats.map((f) => FILE_FORMAT_ACCEPT[f]).filter(Boolean).join(",") }
      : control === "file" || control === "signature" ? { accept: "" } : {}),
    ...(rules.minItems != null ? { minItems: rules.minItems } : {}),
    ...(rules.maxItems != null ? { maxItems: rules.maxItems } : {}),
    // "Must be checked" is a BOOLEAN enum of [true]; `required` alone only checks presence.
    ...(inputType === "BOOLEAN" && Array.isArray(rules.enum) && rules.enum.length === 1 && rules.enum[0] === true ? { mustBeTrue: true } : {}),
    ...(Array.isArray(rules.phoneOptions?.allowedCountryCodes) && rules.phoneOptions.allowedCountryCodes.length
      ? { allowedCountryCodes: [...rules.phoneOptions.allowedCountryCodes] } : {}),
  };

  const options: Raw[] = Array.isArray(component.options) ? component.options : [];
  const choices: FormChoice[] = options.map((o: Raw) => ({
    value: String(o.value),
    label: String(o.label ?? o.value),
    // IMAGE_CHOICE options carry `media.image` (a wix:image URI) — resolved to a URL here.
    ...(o.media ? { imageUrl: imgSrc(o.media, 400, 400) } : {}),
  }));

  const target: string = input.target ?? "";
  const label = plainText(component.label) || target;

  // Every control is controlled from the first render, so each field starts at a value of the
  // right SHAPE. The prefill key differs per component (make-view-of-input-field.ts): choice
  // components mark `options[].default`, a checkbox has `checked`, a rating `defaultValue`,
  // text / number / date have `default` (a date default may be `$now+2d`).
  const defaultValue =
    control === "address" ? {} :
    inputType === "ARRAY" ? options.filter((o) => o.default).map((o) => String(o.value)) :
    inputType === "WIX_FILE" ? [] :
    inputType === "BOOLEAN" ? component.checked === true :
    control === "select" || control === "radio" ? String(options.find((o) => o.default)?.value ?? "") :
    control === "rating" ? ((component.defaultValue as number | undefined) ?? "") :
    control === "date" || control === "time" || control === "datetime" ? (resolveDate(component.default, control) ?? "") :
    (component.default as string | number | undefined) ?? "";

  return {
    target,
    label,
    showLabel: component.showLabel !== false,
    control,
    required,
    readOnly: input.readOnly === true,
    hidden: raw.hidden === true,
    stepId,
    ...(component.placeholder ? { placeholder: String(component.placeholder) } : {}),
    ...(component.description ? { description: plainText(component.description) } : {}),
    defaultValue,
    choices,
    ...(component.customOption
      ? { otherOption: { label: String(component.customOption.label ?? "Other"), ...(component.customOption.placeholder ? { placeholder: String(component.customOption.placeholder) } : {}) } }
      : {}),
    addressParts: control === "address" ? addressParts(rules, component, required) : [],
    ...(control === "address" ? { addressOverrides: addressOverrides(rules, component) } : {}),
    validation,
    ...(component.defaultCountryCode ? { phoneCountry: String(component.defaultCountryCode) } : {}),
    ...(component.buttonText ? { buttonText: String(component.buttonText) } : {}),
    ...(component.explanationText ? { explanationText: String(component.explanationText) } : {}),
    identifier,
    inputType,
    componentType,
  };
}

/**
 * Display order comes from `steps[].layout`, NOT from `formFields[]` array order. Sort WITHIN
 * each step and concatenate in step order: `row` restarts at 0 in every step, so one sort
 * across the flattened list interleaves them. Wix reads the `large` layout (`small` only on a
 * phone, when present) and does not render a field absent from it; a field the owner never
 * placed sorts LAST here, on the last step — it still stores values. The submit button is
 * `fieldType: "DISPLAY"`, so filtering to INPUT drops it automatically. A hidden step's fields
 * are skipped.
 */
export function orderedInputs(raw: Raw): { field: Raw; stepId: string }[] {
  const steps = ((raw.steps ?? []) as Raw[]).filter((s) => !s.hidden);
  const placed = new Map<string, { order: number; stepId: string }>();
  let i = 0;
  for (const s of steps) {
    const items = ((s.layout?.large?.items ?? s.layout?.small?.items ?? []) as Raw[]).slice().sort((a, b) => a.row - b.row || a.column - b.column);
    for (const item of items) placed.set(item.fieldId as string, { order: i++, stepId: id(s) });
  }
  const lastStep = id(steps[steps.length - 1]);
  return ((raw.formFields ?? []) as Raw[])
    .filter((f) => f.fieldType === "INPUT")
    .map((f) => ({ field: f, order: placed.get(id(f))?.order ?? Infinity, stepId: placed.get(id(f))?.stepId ?? lastStep }))
    .sort((a, b) => a.order - b.order)
    .map(({ field, stepId }) => ({ field, stepId }));
}

// Legacy `rules[]` (json-rules-engine spelling, form-conditions/condition-operators.ts) → v4
// operator names. Unknown names pass through and evaluate to false.
const LEGACY_OPERATOR: Record<string, string> = {
  equal: "EQUAL", notEqual: "NOT_EQUAL", empty: "EMPTY", notEmpty: "NOT_EMPTY",
  contains: "CONTAINS", notContains: "NOT_CONTAINS",
  greaterThan: "GREATER_THAN", greaterThanOrEqual: "GREATER_THAN_OR_EQUALS",
  lessThan: "LESS_THAN", lessThanOrEqual: "LESS_THAN_OR_EQUALS",
  after: "AFTER", afterOrEqual: "AFTER_OR_EQUAL", before: "BEFORE", beforeOrEqual: "BEFORE_OR_EQUAL",
  between: "BETWEEN", any: "ANY", arrayEqual: "ARRAY_EQUAL", arrayNotEqual: "ARRAY_NOT_EQUAL",
  checked: "CHECKED", notChecked: "NOT_CHECKED", in: "IN", notIn: "NOT_IN",
  isDateNewerThan: "IS_DATE_NEWER_THAN", isDateOlderThan: "IS_DATE_OLDER_THAN",
  isDateNewerThanOrEqual: "IS_DATE_NEWER_THAN_OR_EQUAL", isDateOlderThanOrEqual: "IS_DATE_OLDER_THAN_OR_EQUAL",
};

/**
 * The owner's rules, normalized from both spellings the schema carries into one serializable
 * shape keyed by TARGET (so the store never needs field ids):
 *   - `formRules[]` (v4 `Rule`): `expression` is a ConditionNode tree (and / or / condition
 *     {target, operator, value}); overrides are FIELD entries with fieldId + propertyType
 *     REQUIRED / HIDDEN / ALLOWED_VALUES.
 *   - `rules[]` (deprecated `FormRule`, still what the runtime evaluates): `condition` is a
 *     json-rules-engine tree ({and|or: [{fact: <field id>, operator, value}]}); overrides are
 *     `valueChanges` keyed by path (`hidden`, `validation.required`, `validation.string.enum`,
 *     `view.options`; transform-path-to-v2.ts).
 * A rule referencing a field that no longer exists is dropped, as Wix does (isFormRuleValid).
 */
export function toRules(raw: Raw): FormRule[] {
  const targetOf = new Map<string, string>(
    ((raw.formFields ?? []) as Raw[]).filter((f) => f.inputOptions?.target).map((f) => [id(f), f.inputOptions.target as string]),
  );
  const targets = new Set(targetOf.values());
  const out: FormRule[] = [];

  const v4Node = (n: Raw | undefined): RuleCondition | null => {
    if (!n) return null;
    if (n.and?.conditions) { const list = (n.and.conditions as Raw[]).map(v4Node); return list.every(Boolean) && list.length ? { and: list as RuleCondition[] } : null; }
    if (n.or?.conditions) { const list = (n.or.conditions as Raw[]).map(v4Node); return list.every(Boolean) && list.length ? { or: list as RuleCondition[] } : null; }
    const c = n.condition;
    // A condition target may be dotted for a nested value (`address.city`); the root must be a field.
    if (!c?.target || !c.operator || !targets.has(String(c.target).split(".")[0])) return null;
    return { target: String(c.target), operator: String(c.operator), value: c.value };
  };
  for (const rule of (raw.formRules ?? []) as Raw[]) {
    const when = v4Node(rule.expression);
    const then: RuleEffect[] = ((rule.overrides ?? []) as Raw[]).flatMap((o): RuleEffect[] => {
      const f = o.fieldOptions;
      const target = f?.fieldId ? targetOf.get(f.fieldId) : undefined;
      if (!target) return [];
      if (f.propertyType === "HIDDEN") return [{ target, hidden: f.hiddenOptions?.hidden === true }];
      if (f.propertyType === "REQUIRED") return [{ target, required: f.requiredOptions?.required === true }];
      if (f.propertyType === "ALLOWED_VALUES") return [{ target, allowedValues: ((f.allowedValuesOptions?.allowedValues ?? []) as unknown[]).map(String) }];
      return [];
    });
    if (when && then.length) out.push({ id: id(rule), when, then });
  }

  const legacyNode = (n: Raw | undefined): RuleCondition | null => {
    if (!n) return null;
    if (Array.isArray(n.and)) { const list = (n.and as Raw[]).map(legacyNode); return list.every(Boolean) && list.length ? { and: list as RuleCondition[] } : null; }
    if (Array.isArray(n.or)) { const list = (n.or as Raw[]).map(legacyNode); return list.every(Boolean) && list.length ? { or: list as RuleCondition[] } : null; }
    const target = n.fact ? targetOf.get(String(n.fact)) : undefined;
    if (!target || !n.operator) return null;
    return { target, operator: LEGACY_OPERATOR[String(n.operator)] ?? String(n.operator), value: n.value };
  };
  for (const rule of (raw.rules ?? []) as Raw[]) {
    const when = legacyNode(rule.condition);
    const then: RuleEffect[] = ((rule.overrides ?? []) as Raw[]).flatMap((o) => {
      if (o.entityType !== "FIELD" || !o.entityId) return [];
      const target = targetOf.get(String(o.entityId));
      if (!target) return [];
      const ch: Raw = o.valueChanges ?? {};
      const effect: RuleEffect = { target };
      if (typeof ch.hidden === "boolean") effect.hidden = ch.hidden;
      if (typeof ch["validation.required"] === "boolean") effect.required = ch["validation.required"];
      if (Array.isArray(ch["validation.string.enum"])) effect.allowedValues = (ch["validation.string.enum"] as unknown[]).map(String);
      else if (Array.isArray(ch["view.options"])) effect.allowedValues = (ch["view.options"] as Raw[]).map((opt) => String(opt?.value ?? opt?.label ?? opt));
      return Object.keys(effect).length > 1 ? [effect] : [];
    });
    if (when && then.length) out.push({ id: id(rule), when, then });
  }
  return out;
}

// ---- rule evaluation (form-conditions operators, without the engine) -----------------------------

const missing = (v: unknown): boolean => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);

/** A value as a number for ordering: numbers as they are, strings as timestamps (time-only gets a fixed day). */
function ordinal(v: unknown): number | null {
  if (typeof v === "number") return v;
  if (typeof v !== "string" || !v) return null;
  const t = new Date(/^\d{2}:\d{2}(:\d{2})?$/.test(v) ? `2000-01-01 ${v}` : v).getTime();
  return Number.isNaN(t) ? null : t;
}

function dayDiff(given: unknown, spec: unknown, now: Date): { date: Date; pivot: Date } | null {
  if (!Array.isArray(spec) || spec.length !== 2 || typeof given !== "string") return null;
  const units = Number(spec[0]);
  const unit = spec[1];
  const date = new Date(given);
  if (Number.isNaN(date.getTime()) || Number.isNaN(units) || (unit !== "day" && unit !== "month")) return null;
  const pivot = new Date(now.getTime());
  if (unit === "day") pivot.setDate(pivot.getDate() + units); else pivot.setMonth(pivot.getMonth() + units);
  const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
  return { date: day(date), pivot: day(pivot) };
}

/** Evaluate one comparison exactly as Wix's operators do (form-conditions/src/lib/operators). */
export function holds(operator: string, given: unknown, expected: unknown, now: Date = new Date()): boolean {
  const g = given, e = expected;
  const contains = (): boolean => {
    if (missing(g)) return false;
    if (isObj(g)) return Object.values(g).includes(e);
    if (Array.isArray(g) || typeof g === "string") return (g as any).indexOf(e) > -1;
    return false;
  };
  const within = (): boolean => {
    if (isObj(e)) return Object.values(e).includes(g);
    if (Array.isArray(e) || typeof e === "string") return (e as any).indexOf(g) > -1;
    return false;
  };
  const cmp = (op: (a: number, b: number) => boolean): boolean => {
    if (missing(g)) return false;
    const a = ordinal(g), b = ordinal(e);
    return a !== null && b !== null && op(a, b);
  };
  const arrayEq = (): boolean => {
    if (missing(g) || !Array.isArray(g) || !Array.isArray(e) || g.length !== e.length) return false;
    return e.every((v) => g.includes(v));
  };
  switch (operator) {
    case "EQUAL": return g === e;
    case "NOT_EQUAL": return g !== e;
    case "EMPTY": return missing(g);
    case "NOT_EMPTY": return !missing(g);
    case "CONTAINS": return contains();
    case "NOT_CONTAINS": return !contains();
    case "GREATER_THAN": case "AFTER": return cmp((a, b) => a > b);
    case "GREATER_THAN_OR_EQUALS": case "AFTER_OR_EQUAL": return cmp((a, b) => a >= b);
    case "LESS_THAN": case "BEFORE": return cmp((a, b) => a < b);
    case "LESS_THAN_OR_EQUALS": case "BEFORE_OR_EQUAL": return cmp((a, b) => a <= b);
    case "BETWEEN": {
      if (missing(g) || !Array.isArray(e) || e.length !== 2) return false;
      const a = ordinal(g), lo = ordinal(e[0]), hi = ordinal(e[1]);
      return a !== null && lo !== null && hi !== null && a > Math.min(lo, hi) && a < Math.max(lo, hi);
    }
    case "ANY": {
      if (missing(g) || !Array.isArray(e)) return false;
      return (Array.isArray(g) ? g : [g]).some((v) => e.includes(v));
    }
    case "ARRAY_EQUAL": return arrayEq();
    case "ARRAY_NOT_EQUAL": return !arrayEq();
    case "CHECKED": return Boolean(g);
    case "NOT_CHECKED": return !g;
    case "IN": return within();
    case "NOT_IN": return !within();
    case "IS_DATE_NEWER_THAN": case "IS_DATE_NEWER_THAN_OR_EQUAL": {
      const d = dayDiff(g, e, now);
      return !!d && (operator.endsWith("OR_EQUAL") ? d.date >= d.pivot : d.date > d.pivot);
    }
    case "IS_DATE_OLDER_THAN": case "IS_DATE_OLDER_THAN_OR_EQUAL": {
      const d = dayDiff(g, Array.isArray(e) ? [-Number(e[0]), e[1]] : e, now);
      return !!d && (operator.endsWith("OR_EQUAL") ? d.date <= d.pivot : d.date < d.pivot);
    }
    default: return false;
  }
}

/** A dotted condition target (`address.city`) read out of the values. */
function valueAt(values: FormValues, target: string): unknown {
  return target.split(".").reduce<unknown>((v, k) => (isObj(v) ? v[k] : undefined), values);
}

export function conditionHolds(when: RuleCondition, values: FormValues, now: Date = new Date()): boolean {
  if ("and" in when) return when.and.every((c) => conditionHolds(c, values, now));
  if ("or" in when) return when.or.some((c) => conditionHolds(c, values, now));
  return holds(when.operator, valueAt(values, when.target), when.value, now);
}

/**
 * The form's fields with every rule whose condition holds applied, in rule order (a later rule
 * wins on the same property — apply-overrides.ts reduces the same way). Hidden / required flip;
 * ALLOWED_VALUES narrows `choices` to the listed values. Pure: call it on every value change and
 * render the result.
 */
export function applyRules(form: FormDto, values: FormValues, now: Date = new Date()): FormFieldDto[] {
  if (!form.rules.length) return form.fields;
  const byTarget = new Map(form.fields.map((f) => [f.target, { ...f }]));
  for (const rule of form.rules) {
    if (!conditionHolds(rule.when, values, now)) continue;
    for (const effect of rule.then) {
      const f = byTarget.get(effect.target);
      if (!f) continue;
      if (effect.hidden !== undefined) f.hidden = effect.hidden;
      if (effect.required !== undefined) f.required = effect.required;
      if (effect.allowedValues) {
        const allowed = new Set(effect.allowedValues);
        f.choices = f.choices.filter((c) => allowed.has(c.value));
      }
    }
  }
  return form.fields.map((f) => byTarget.get(f.target) as FormFieldDto);
}

function toSuccess(settings: Raw | undefined): FormSuccess {
  const action: string = settings?.submitSuccessAction ?? "NO_ACTION";
  const out: FormSuccess = { action };
  if (action === "THANK_YOU_MESSAGE") {
    const message = plainText(settings?.thankYouMessageOptions?.richContent);
    if (message) out.message = message;
    const seconds = Number(settings?.thankYouMessageOptions?.durationInSeconds ?? 0);
    if (seconds > 0) out.durationSeconds = seconds;
  }
  if (action === "REDIRECT" && settings?.redirectOptions?.redirectUrl) {
    // Wix strips any scheme and always opens https:// (use-submit/utils.ts redirectToExternalUrl).
    out.redirectUrl = `https://${String(settings.redirectOptions.redirectUrl).replace(/^https?:\/\//, "")}`;
    out.newTab = settings.redirectOptions.target !== "SELF";
  }
  return out;
}

export function toForm(raw: Raw, imgSrc: ImgSrc): FormDto {
  const submit = ((raw.formFields ?? []) as Raw[]).find((f) => f.identifier === "SUBMIT_BUTTON");
  const nav: Raw = submit?.displayOptions?.pageNavigationOptions ?? {};
  const ordered = orderedInputs(raw);
  const fields = ordered.map(({ field, stepId }) => toField(field, imgSrc, stepId));
  const steps: FormStep[] = ((raw.steps ?? []) as Raw[])
    .filter((s) => !s.hidden)
    .map((s) => ({ id: id(s), name: s.name ?? "", targets: fields.filter((f) => f.stepId === id(s)).map((f) => f.target) }));
  const limitation: Raw = raw.limitationRule ?? {};
  const deadline = limitation.dateTimeDeadline ? new Date(limitation.dateTimeDeadline).toISOString() : undefined;
  const indicator: Raw = raw.requiredIndicatorProperties ?? {};
  return {
    id: id(raw),
    name: raw.name ?? "",
    fields,
    steps: steps.length ? steps : [{ id: "", name: "", targets: fields.map((f) => f.target) }],
    rules: toRules(raw),
    // The button wording lives on a DISPLAY field, nested under pageNavigationOptions because
    // one control drives both multi-page navigation and the final submit.
    submitText: nav.submitText ?? "",
    nextText: nav.nextPageText ?? "",
    previousText: nav.previousPageText ?? "",
    // `enabled` (default true) replaces the older `properties.disabled`; read both.
    enabled: raw.enabled != null ? raw.enabled !== false : raw.properties?.disabled !== true,
    disabledMessage: plainText(raw.disabledFormMessage),
    limits: {
      ...(deadline ? { deadline } : {}),
      ...(limitation.maxAllowedSubmissions != null ? { maxSubmissions: Number(limitation.maxAllowedSubmissions) } : {}),
      ...(limitation.submissionLimitPerUser != null ? { perVisitor: Number(limitation.submissionLimitPerUser) } : {}),
    },
    requiredIndicator: indicator.requiredIndicator ?? "ASTERISK",
    requiredIndicatorBefore: indicator.requiredIndicatorPlacement === "BEFORE_FIELD_TITLE",
    success: toSuccess(raw.submitSettings),
  };
}

/** A form that is not accepting submissions: switched off, or past its deadline (form-validator.ts isFormDisabled). */
export function isClosed(form: FormDto, now: Date = new Date()): boolean {
  return !form.enabled || (!!form.limits.deadline && new Date(form.limits.deadline).getTime() <= now.getTime());
}

SHA-256: 7ab3c95b3e2dab8df2ceedec8b896192553a57af2284f7896eccbe4af9d29f3d