@vectoriox/iox-ui 4.32.0 → 4.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -307,5 +307,70 @@ declare function addableActions(existing?: readonly {
307
307
  type: string;
308
308
  }[]): ActionDescriptor[];
309
309
 
310
- export { ACTION_DESCRIPTORS, ControlType, DEFAULT_EN_MESSAGES, FIELD_NODE_TYPE, FORM_ERROR_KEYS, FORM_NODE_TYPE, FeedbackTiming, IMPLIED_PATTERN, INITIAL_FORM_STATE, MESSAGE_NAMESPACE, MULTI_VALUE_CONTROLS, PATTERN_MESSAGE_KEYS, PATTERN_PRESETS, PATTERN_PRESET_OPTIONS, PatternPreset, RULE_DESCRIPTORS, RULE_MESSAGE_KEYS, RuleName, RuleParamKind, STORE_ACTION, addableActions, addableRules, applyServerErrors, collectFieldNodes, collectFormSpec, deriveFieldName, firstInvalidField, onFieldBlur, onFieldChange, onSubmitAttempt, resetFormState, rulesFor, shouldShowError, uniqueFieldName, validateField, validateForm };
311
- export type { ActionDescriptor, FieldError, FieldOption, FieldOptionSource, FieldResult, FieldSpec, FormNodeLike, FormResult, FormRuntimeState, FormSpec, RuleDescriptor, SubmitAction, ValidationRule };
310
+ /**
311
+ * The submit transport CONTRACT — what a host must provide to make a form actually send.
312
+ *
313
+ * The Form component deliberately owns no HTTP: the builder canvas must never post (an author
314
+ * clicking Submit while designing would file a real submission), and the SSR engine posts through
315
+ * Angular's HttpClient. What IS shared is everything around the call — the endpoint's shape, the
316
+ * anti-spam fields, and how the server's answer maps onto the form's state. Those live here so the
317
+ * two hosts cannot drift, and so a third host (a hand-built integration) has one thing to read.
318
+ *
319
+ * ⚠️ Framework-free, like the rest of forms-core. No Angular, no RxJS, no fetch.
320
+ */
321
+
322
+ /** The field a honeypot input uses. Must match `iox-cms` `anti-spam.ts` — hence the comment there. */
323
+ declare const HONEYPOT_FIELD = "_iox_hp";
324
+ /** When the form was rendered, so the server can measure fill time. */
325
+ declare const RENDERED_AT_FIELD = "_iox_t";
326
+ /**
327
+ * What the form hands its host. There is deliberately no `orgId`: the tenant is the HOST's
328
+ * knowledge, not the form's, and a form that had to be told its org would be one more trait an
329
+ * author could get wrong.
330
+ */
331
+ interface SubmitRequest {
332
+ formId: string;
333
+ values: Record<string, any>;
334
+ }
335
+ /**
336
+ * The server's answer, normalised. Mirrors `SubmitResult` in `iox-cms`
337
+ * `form-submissions.service.ts` — the two are one contract, so change them together.
338
+ */
339
+ interface SubmitResponse {
340
+ ok: boolean;
341
+ /** Per-field verdicts from the server's own validation pass, keyed by field name. */
342
+ errors?: Record<string, FieldError>;
343
+ /** Where to send the visitor after a successful submit, when the form has a redirect action. */
344
+ redirect?: string;
345
+ }
346
+ /**
347
+ * The one function a host implements. Rejecting (or throwing) means the call never reached the
348
+ * server — the form shows its Error state, which is the honest thing to show for a dropped network.
349
+ */
350
+ type FormTransport = (request: SubmitRequest) => Promise<SubmitResponse>;
351
+ /**
352
+ * The public submit path. Built here rather than interpolated at each call site, so the engine and
353
+ * any future host cannot disagree about it — and so changing it is one edit.
354
+ *
355
+ * Deliberately relative and WITHOUT a leading slash: the engine mounts under a per-org base href.
356
+ */
357
+ declare function submitEndpointPath(orgId: string, formId: string): string;
358
+ /**
359
+ * Add the anti-spam fields to the collected values.
360
+ *
361
+ * Both are invisible to a real visitor and cost them nothing (decision D12). `renderedAt` is the
362
+ * time the form was rendered, NOT the time of submit — the server measures the difference to catch
363
+ * a script that fills and posts instantly.
364
+ */
365
+ declare function withAntiSpamFields(values: Record<string, any>, renderedAt: number, honeypotValue?: string): Record<string, any>;
366
+ /**
367
+ * Normalise whatever came back into a `SubmitResponse`.
368
+ *
369
+ * Fails CLOSED: anything that is not a recognisable success is treated as a failure. A malformed
370
+ * body must never show the visitor a Success state for a submission that was not stored — that is
371
+ * the one lie this feature must not tell.
372
+ */
373
+ declare function interpretSubmitResponse(body: any): SubmitResponse;
374
+
375
+ export { ACTION_DESCRIPTORS, ControlType, DEFAULT_EN_MESSAGES, FIELD_NODE_TYPE, FORM_ERROR_KEYS, FORM_NODE_TYPE, FeedbackTiming, HONEYPOT_FIELD, IMPLIED_PATTERN, INITIAL_FORM_STATE, MESSAGE_NAMESPACE, MULTI_VALUE_CONTROLS, PATTERN_MESSAGE_KEYS, PATTERN_PRESETS, PATTERN_PRESET_OPTIONS, PatternPreset, RENDERED_AT_FIELD, RULE_DESCRIPTORS, RULE_MESSAGE_KEYS, RuleName, RuleParamKind, STORE_ACTION, addableActions, addableRules, applyServerErrors, collectFieldNodes, collectFormSpec, deriveFieldName, firstInvalidField, interpretSubmitResponse, onFieldBlur, onFieldChange, onSubmitAttempt, resetFormState, rulesFor, shouldShowError, submitEndpointPath, uniqueFieldName, validateField, validateForm, withAntiSpamFields };
376
+ export type { ActionDescriptor, FieldError, FieldOption, FieldOptionSource, FieldResult, FieldSpec, FormNodeLike, FormResult, FormRuntimeState, FormSpec, FormTransport, RuleDescriptor, SubmitAction, SubmitRequest, SubmitResponse, ValidationRule };
package/index.d.ts CHANGED
@@ -988,6 +988,44 @@ interface FormRuntimeState {
988
988
  submitting: boolean;
989
989
  }
990
990
 
991
+ /**
992
+ * The submit transport CONTRACT — what a host must provide to make a form actually send.
993
+ *
994
+ * The Form component deliberately owns no HTTP: the builder canvas must never post (an author
995
+ * clicking Submit while designing would file a real submission), and the SSR engine posts through
996
+ * Angular's HttpClient. What IS shared is everything around the call — the endpoint's shape, the
997
+ * anti-spam fields, and how the server's answer maps onto the form's state. Those live here so the
998
+ * two hosts cannot drift, and so a third host (a hand-built integration) has one thing to read.
999
+ *
1000
+ * ⚠️ Framework-free, like the rest of forms-core. No Angular, no RxJS, no fetch.
1001
+ */
1002
+
1003
+ /**
1004
+ * What the form hands its host. There is deliberately no `orgId`: the tenant is the HOST's
1005
+ * knowledge, not the form's, and a form that had to be told its org would be one more trait an
1006
+ * author could get wrong.
1007
+ */
1008
+ interface SubmitRequest {
1009
+ formId: string;
1010
+ values: Record<string, any>;
1011
+ }
1012
+ /**
1013
+ * The server's answer, normalised. Mirrors `SubmitResult` in `iox-cms`
1014
+ * `form-submissions.service.ts` — the two are one contract, so change them together.
1015
+ */
1016
+ interface SubmitResponse {
1017
+ ok: boolean;
1018
+ /** Per-field verdicts from the server's own validation pass, keyed by field name. */
1019
+ errors?: Record<string, FieldError>;
1020
+ /** Where to send the visitor after a successful submit, when the form has a redirect action. */
1021
+ redirect?: string;
1022
+ }
1023
+ /**
1024
+ * The one function a host implements. Rejecting (or throwing) means the call never reached the
1025
+ * server — the form shows its Error state, which is the honest thing to show for a dropped network.
1026
+ */
1027
+ type FormTransport = (request: SubmitRequest) => Promise<SubmitResponse>;
1028
+
991
1029
  /**
992
1030
  * How a message KEY becomes text a visitor reads.
993
1031
  *
@@ -1049,6 +1087,15 @@ declare class FormRuntimeService {
1049
1087
  static ɵprov: i0.ɵɵInjectableDeclaration<FormRuntimeService>;
1050
1088
  }
1051
1089
 
1090
+ /**
1091
+ * How a host makes the form actually send. The SSR engine provides one (an HttpClient POST); the
1092
+ * builder canvas deliberately does NOT — an author pressing Submit while designing must never file
1093
+ * a real submission. Without it the component keeps its original behaviour and only emits
1094
+ * `formSubmit`, which is also the escape hatch for a host that wants the transport itself.
1095
+ */
1096
+ declare const IOX_FORM_TRANSPORT: InjectionToken<FormTransport>;
1097
+ /** Where a successful submit sends the visitor. Provided by the host; absent on the canvas. */
1098
+ declare const IOX_FORM_NAVIGATOR: InjectionToken<(url: string) => void>;
1052
1099
  /** Which of the form's four states the canvas / runtime is showing. */
1053
1100
  declare enum FormRenderState {
1054
1101
  Idle = "idle",
@@ -1072,9 +1119,11 @@ declare enum FormRenderState {
1072
1119
  * ⚠️ `novalidate` is deliberate: the browser's native bubbles would pre-empt our own messages,
1073
1120
  * which are styleable, translated and announced. Validation still runs — ours does it.
1074
1121
  */
1075
- declare class IoxFormComponent implements OnChanges {
1122
+ declare class IoxFormComponent implements OnChanges, OnInit {
1076
1123
  private readonly runtime;
1077
1124
  private readonly host;
1125
+ private readonly transport;
1126
+ private readonly navigate;
1078
1127
  nodeId: string;
1079
1128
  /** Stable id the submit endpoint resolves the saved spec by. */
1080
1129
  formId: string;
@@ -1094,16 +1143,35 @@ declare class IoxFormComponent implements OnChanges {
1094
1143
  /** Emitted when a submit attempt was refused locally, so a host can react (analytics, focus). */
1095
1144
  validationFailed: EventEmitter<Record<string, any>>;
1096
1145
  childrenHost: ViewContainerRef;
1146
+ /** Exposed to the template so the input's name and the server's check cannot drift. */
1147
+ readonly honeypotField = "_iox_hp";
1148
+ /** When this form was rendered — the fill-time signal the server checks. */
1149
+ private renderedAt;
1150
+ /** Guards against a double submit while a POST is in flight. */
1151
+ private inFlight;
1097
1152
  /**
1098
1153
  * Constructor injection rather than `inject()`, for the same reason as Field: this library's
1099
1154
  * specs construct components directly. `runtime` is provided by THIS component (see
1100
1155
  * `providers`), so every Field created into `childrenHost` resolves this instance by ancestry.
1101
1156
  */
1102
- constructor(runtime: FormRuntimeService, host: ElementRef<HTMLElement>);
1157
+ constructor(runtime: FormRuntimeService, host: ElementRef<HTMLElement>, transport?: FormTransport | null, navigate?: ((url: string) => void) | null);
1158
+ ngOnInit(): void;
1103
1159
  ngOnChanges(): void;
1104
1160
  onSubmit(event: Event): void;
1161
+ /**
1162
+ * The submit lifecycle, owned HERE so both hosts get the same one: submitting → the server's
1163
+ * verdict → success (and an optional redirect) or error. Only the HTTP call itself is the
1164
+ * host's, which is the one part that genuinely differs between the engine and anything else.
1165
+ */
1166
+ private send;
1167
+ /** Turn the server's answer into what the visitor sees. Public so a host-owned transport can reuse it. */
1168
+ applyResponse(response: SubmitResponse): void;
1169
+ /** The honeypot input's value, if the host rendered one. Absent reads as empty — never as spam. */
1170
+ private honeypotValue;
1171
+ /** Blank the real DOM controls after a success. The runtime state alone would leave them filled. */
1172
+ private resetControls;
1105
1173
  private focusFirstInvalid;
1106
- static ɵfac: i0.ɵɵFactoryDeclaration<IoxFormComponent, never>;
1174
+ static ɵfac: i0.ɵɵFactoryDeclaration<IoxFormComponent, [null, null, { optional: true; }, { optional: true; }]>;
1107
1175
  static ɵcmp: i0.ɵɵComponentDeclaration<IoxFormComponent, "form[iox-form]", never, { "nodeId": { "alias": "nodeId"; "required": false; }; "formId": { "alias": "formId"; "required": false; }; "name": { "alias": "name"; "required": false; }; "renderState": { "alias": "renderState"; "required": false; }; "feedback": { "alias": "feedback"; "required": false; }; }, { "formSubmit": "formSubmit"; "validationFailed": "validationFailed"; }, never, never, false, never>;
1108
1176
  }
1109
1177
 
@@ -2373,5 +2441,5 @@ declare class GalleryLoop {
2373
2441
  private emit;
2374
2442
  }
2375
2443
 
2376
- export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, defaultMessageResolver, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
2444
+ export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, defaultMessageResolver, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
2377
2445
  export type { BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OverrideBreakpoint, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "4.32.0",
3
+ "version": "4.33.1",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -16,3 +16,4 @@ export * from './validate';
16
16
  export * from './collect';
17
17
  export * from './runtime';
18
18
  export * from './rule-catalog';
19
+ export * from './transport';
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The submit transport CONTRACT — what a host must provide to make a form actually send.
3
+ *
4
+ * The Form component deliberately owns no HTTP: the builder canvas must never post (an author
5
+ * clicking Submit while designing would file a real submission), and the SSR engine posts through
6
+ * Angular's HttpClient. What IS shared is everything around the call — the endpoint's shape, the
7
+ * anti-spam fields, and how the server's answer maps onto the form's state. Those live here so the
8
+ * two hosts cannot drift, and so a third host (a hand-built integration) has one thing to read.
9
+ *
10
+ * ⚠️ Framework-free, like the rest of forms-core. No Angular, no RxJS, no fetch.
11
+ */
12
+
13
+ import { FieldError } from './validate';
14
+
15
+ /** The field a honeypot input uses. Must match `iox-cms` `anti-spam.ts` — hence the comment there. */
16
+ export const HONEYPOT_FIELD = '_iox_hp';
17
+ /** When the form was rendered, so the server can measure fill time. */
18
+ export const RENDERED_AT_FIELD = '_iox_t';
19
+
20
+ /**
21
+ * What the form hands its host. There is deliberately no `orgId`: the tenant is the HOST's
22
+ * knowledge, not the form's, and a form that had to be told its org would be one more trait an
23
+ * author could get wrong.
24
+ */
25
+ export interface SubmitRequest {
26
+ formId: string;
27
+ values: Record<string, any>;
28
+ }
29
+
30
+ /**
31
+ * The server's answer, normalised. Mirrors `SubmitResult` in `iox-cms`
32
+ * `form-submissions.service.ts` — the two are one contract, so change them together.
33
+ */
34
+ export interface SubmitResponse {
35
+ ok: boolean;
36
+ /** Per-field verdicts from the server's own validation pass, keyed by field name. */
37
+ errors?: Record<string, FieldError>;
38
+ /** Where to send the visitor after a successful submit, when the form has a redirect action. */
39
+ redirect?: string;
40
+ }
41
+
42
+ /**
43
+ * The one function a host implements. Rejecting (or throwing) means the call never reached the
44
+ * server — the form shows its Error state, which is the honest thing to show for a dropped network.
45
+ */
46
+ export type FormTransport = (request: SubmitRequest) => Promise<SubmitResponse>;
47
+
48
+ /**
49
+ * The public submit path. Built here rather than interpolated at each call site, so the engine and
50
+ * any future host cannot disagree about it — and so changing it is one edit.
51
+ *
52
+ * Deliberately relative and WITHOUT a leading slash: the engine mounts under a per-org base href.
53
+ */
54
+ export function submitEndpointPath(orgId: string, formId: string): string {
55
+ return `api/site/${encodeURIComponent(orgId)}/forms/${encodeURIComponent(formId)}/submit`;
56
+ }
57
+
58
+ /**
59
+ * Add the anti-spam fields to the collected values.
60
+ *
61
+ * Both are invisible to a real visitor and cost them nothing (decision D12). `renderedAt` is the
62
+ * time the form was rendered, NOT the time of submit — the server measures the difference to catch
63
+ * a script that fills and posts instantly.
64
+ */
65
+ export function withAntiSpamFields(
66
+ values: Record<string, any>,
67
+ renderedAt: number,
68
+ honeypotValue = '',
69
+ ): Record<string, any> {
70
+ return { ...values, [HONEYPOT_FIELD]: honeypotValue, [RENDERED_AT_FIELD]: renderedAt };
71
+ }
72
+
73
+ /**
74
+ * Normalise whatever came back into a `SubmitResponse`.
75
+ *
76
+ * Fails CLOSED: anything that is not a recognisable success is treated as a failure. A malformed
77
+ * body must never show the visitor a Success state for a submission that was not stored — that is
78
+ * the one lie this feature must not tell.
79
+ */
80
+ export function interpretSubmitResponse(body: any): SubmitResponse {
81
+ if (!body || typeof body !== 'object' || body.ok !== true) {
82
+ return {
83
+ ok: false,
84
+ errors: normaliseErrors(body?.errors),
85
+ };
86
+ }
87
+ const redirect = typeof body.redirect === 'string' && body.redirect.trim()
88
+ ? body.redirect.trim()
89
+ : undefined;
90
+ return { ok: true, redirect };
91
+ }
92
+
93
+ /** Keep only entries that actually carry a message, so a junk key cannot blank a field's error. */
94
+ function normaliseErrors(raw: any): Record<string, FieldError> | undefined {
95
+ if (!raw || typeof raw !== 'object') return undefined;
96
+ const errors: Record<string, FieldError> = {};
97
+ for (const [name, value] of Object.entries(raw as Record<string, any>)) {
98
+ const messageKey = typeof value?.messageKey === 'string' ? value.messageKey : '';
99
+ if (!messageKey) continue;
100
+ errors[name] = value?.params ? { messageKey, params: value.params } : { messageKey };
101
+ }
102
+ return Object.keys(errors).length ? errors : undefined;
103
+ }