PluginProbe
The Innovative Form Builder – IvyForms / 1.1.1
The Innovative Form Builder – IvyForms v1.1.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / public / FormRender.vue

FormRender.vue in The Innovative Form Builder – IvyForms 1.1.1, at frontend/src/views/public/FormRender.vue

2,645 lines 91.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div ref="formWrapperRef" :class="formWrapperClasses">
3 <div v-if="showSuccessMessage" class="ivyforms-success-message">
4 <SafeHTML :html="successMessage || getLabel('thank_you')" field-type="confirmation" />
5 </div>
6 <template v-else>
7 <div
8 v-if="showSuccessMessageReset"
9 class="ivyforms-success-message ivyforms-mb-24 ivyforms-flex ivyforms-flex-direction-column ivyforms-align-items-center ivyforms-justify-content-center ivyforms-border-radius-8"
10 >
11 <SafeHTML :html="successMessage || getLabel('thank_you')" field-type="confirmation" />
12 </div>
13
14 <h3
15 v-if="formData.showTitle"
16 :class="['ivyforms-form-title', 'ivyforms-form-title-' + formData.id]"
17 >
18 {{ formData.name }}
19 </h3>
20 <p
21 v-if="formData.showDescription"
22 :class="['ivyforms-form-description', 'ivyforms-form-description-' + formData.id]"
23 >
24 {{ formData.description }}
25 </p>
26
27 <!-- Progress Indicator (for multi-page forms, Pro only) -->
28 <div
29 v-if="progressIndicatorRenderModel.render"
30 :class="progressIndicatorRenderModel.wrapperClass"
31 >
32 <component
33 :is="progressIndicatorRenderModel.component"
34 :key="progressIndicatorRenderModel.key"
35 v-bind="progressIndicatorRenderModel.props"
36 v-on="progressIndicatorRenderModel.events"
37 />
38 </div>
39
40 <IvyForm
41 :id="'ivyforms-form-' + formData.id"
42 ref="formRef"
43 :class="'ivyforms-form ivyforms-form-' + formData.id"
44 :data-form-id="formData.id"
45 :rules="formRules"
46 :model="formValues"
47 :label-position="getFormLabelPosition()"
48 @submit.prevent="validateAndSubmit"
49 >
50 <template v-for="(row, rowIdx) in fieldsByRow" :key="'row-' + rowIdx">
51 <div
52 class="ivyforms-form-row ivyforms-flex ivyforms-width-100 ivyforms-flex-wrap-wrap"
53 :style="{ gap: horizontalGapCss }"
54 >
55 <template v-for="field in row" :key="field.type + '_' + field.fieldIndex">
56 <div
57 class="ivyforms-form-field"
58 :style="{
59 width:
60 row.length > 1
61 ? `calc(${field.width || 100}% - (${horizontalGapCss} * ${(row.length - 1) / row.length}))`
62 : `${field.width || 100}%`,
63 flex: `0 0 auto`,
64 }"
65 >
66 <component
67 :is="formFieldsTemplates[field.type]"
68 ref="formFieldsCollectorRefs"
69 v-model="formValues[field.type + '_' + field.fieldIndex]"
70 :field="field"
71 :error="fieldErrors[field.type + '_' + field.fieldIndex]"
72 :field-errors="fieldErrors"
73 :label-position="getFormLabelPosition()"
74 :horizontal-gap="horizontalGapCss"
75 ></component>
76 </div>
77 </template>
78 </div>
79 </template>
80
81 <!-- Page Navigation (Previous/Next/Submit buttons, Pro only) -->
82 <div
83 v-if="pageNavigationRenderModel.render"
84 :class="pageNavigationRenderModel.wrapperClass"
85 >
86 <component
87 :is="pageNavigationRenderModel.component"
88 :key="pageNavigationRenderModel.key"
89 v-bind="pageNavigationRenderModel.props"
90 v-on="pageNavigationRenderModel.events"
91 />
92 </div>
93 <!-- Fallback: Single submit button for Lite or single-page forms -->
94 <div
95 v-else-if="showFallbackSubmitButton"
96 :class="[
97 'ivyforms-form-submit ivyforms-flex',
98 `ivyforms-form-submit--${formData.formActionButtons?.submitButtonSettings?.position || 'default'}`,
99 ]"
100 >
101 <IvyButtonAction
102 :id="'ivyforms-form-submit-button-' + formData.id"
103 priority="tertiary"
104 type="border"
105 :disabled="isSubmitting"
106 @click.prevent.stop="validateAndSubmit"
107 >
108 {{ formData.formActionButtons?.submitButtonSettings?.label || getLabel('submit') }}
109 <IvyCircleLoader
110 v-if="isSubmitting"
111 :completed="isSubmitCompleted"
112 class="ivyforms-ml-8"
113 />
114 </IvyButtonAction>
115 </div>
116 </IvyForm>
117 </template>
118 </div>
119 </template>
120
121 <script setup lang="ts">
122 import {
123 type Component,
124 type ComponentPublicInstance,
125 computed,
126 markRaw,
127 nextTick,
128 onMounted,
129 provide,
130 reactive,
131 ref,
132 watch,
133 } from 'vue'
134 import IvyForm from '@/views/_components/form/IvyForm.vue'
135 import IvyButtonAction from '@/views/_components/button/IvyButtonAction.vue'
136 import { useApiClient } from '@/composables/useApiClient'
137 import { useWcagColors } from '@/composables/useWcagColors'
138 // * Form Field templates
139 import IvyTextFormField from '../public/fields/TextFormField.vue'
140 import IvyParagraphFormField from '../public/fields/ParagraphFormField.vue'
141 import IvyEmailFormField from '../public/fields/EmailFormField.vue'
142 import IvyNumberFormField from '../public/fields/NumberFormField.vue'
143 import IvyPhoneFormField from '../public/fields/PhoneFormField.vue'
144 import IvyWebsiteFormField from '../public/fields/WebsiteFormField.vue'
145 import IvyRadioFormField from '../public/fields/RadioFormField.vue'
146 import NameFormField from '../public/fields/NameFormField.vue'
147 import RecaptchaFormField from '../public/fields/RecaptchaFormField.vue'
148 import TurnstileFormField from '../public/fields/TurnstileFormField.vue'
149 import HCaptchaFormField from '../public/fields/HCaptchaFormField.vue'
150 import IvyAddressFormField from '../public/fields/AddressFormField.vue'
151 import CheckboxFormField from '../public/fields/CheckboxFormField.vue'
152 import GdprFormField from '../public/fields/GdprFormField.vue'
153 import TimeFormField from '../public/fields/TimeFormField.vue'
154 import DateFormField from '../public/fields/DateFormField.vue'
155 import RatingFormField from '../public/fields/RatingFormField.vue'
156 import HtmlFormField from '../public/fields/HtmlFormField.vue'
157 import SliderFormField from '../public/fields/SliderFormField.vue'
158 import type { Field } from '@/types/field'
159 import type { FormPage } from '@/types/form-page'
160 import type { Confirmation } from '@/types/confirmations'
161 import type { FormStyleSettings } from '@/types/form-style'
162 import { useLabels } from '@/composables/useLabels'
163 import { useTimeField } from '@/composables/useTimeField'
164 import { useDateField } from '@/composables/useDateField'
165 import IvyMessage from '@/views/_components/message/ivyMessage.ts'
166 import SelectFormField from '@/views/public/fields/SelectFormField.vue'
167 import { applyFormStyles, parseCssClassList } from '@/utils/formStyleGenerator'
168 import { buildFormPopperScopeClass } from '@/utils/form-styles/selectors'
169 import IvyCircleLoader from '@/views/_components/loader/IvyCircleLoader.vue'
170 import { useMultiPageComponents } from '@/composables/useMultiPageComponents'
171 import { resolveHorizontalSpacing } from '@/composables/useHorizontalSpacing'
172 import type { FormSubmitData } from '@/composables/IvyFormAPI'
173 import api from '@/composables/IvyFormAPI'
174 import {
175 processWebsiteFieldValues,
176 useValidateBeforeSubmit,
177 } from '@/composables/useValidateBeforeSubmit'
178 import {
179 getChildFieldSettings,
180 resolveUniqueNameFieldKey,
181 sortChildrenByDisplayOrder,
182 } from '@/utils/nameFieldKeyUtils'
183
184 // Initialize WCAG color system for public forms
185 const { startWatching } = useWcagColors()
186 startWatching()
187
188 // Global templates storage - stored on window to persist across script re-evaluations
189 // This is critical for Elementor/Gutenberg editor refreshes where scripts reload
190 // but Pro components were already registered in a previous load
191 // On regular frontend pages, we use a local store since persistence isn't needed
192 type GlobalTemplateStore = Record<string, Component>
193
194 // Check if we're in an editor preview context (Gutenberg or Elementor)
195 const isEditorContext =
196 typeof window !== 'undefined' &&
197 (window.IvyForms?.isGutenbergEditor || window.IvyForms?.isElementorEditor)
198
199 const getGlobalTemplatesStore = (): GlobalTemplateStore => {
200 // Only use window storage in editor context where script re-evaluations occur
201 if (isEditorContext && typeof window !== 'undefined') {
202 const w = window as Window & { _ivyFormsGlobalTemplates?: GlobalTemplateStore }
203 if (!w._ivyFormsGlobalTemplates) {
204 w._ivyFormsGlobalTemplates = {}
205 }
206 return w._ivyFormsGlobalTemplates
207 }
208 // On regular frontend pages, use local store
209 return {}
210 }
211 const globalTemplatesStore = getGlobalTemplatesStore()
212
213 // Hook to allow Pro plugin to register additional field components
214 const registerFieldTemplateGlobal = (fieldType: string, component: Component) => {
215 if (component && fieldType) {
216 const wrappedComponent = markRaw(component)
217 globalTemplatesStore[fieldType] = wrappedComponent
218
219 // Also update the reactive templates if available
220 const w = window as Window & {
221 _updateFormTemplates?: (fieldType: string, component: Component) => void
222 }
223 if (w._updateFormTemplates) {
224 w._updateFormTemplates(fieldType, wrappedComponent)
225 }
226
227 // Trigger a global event to notify any listening components
228 if (typeof window !== 'undefined') {
229 window.dispatchEvent(
230 new CustomEvent('ivyforms:template_registered', {
231 detail: { fieldType, component: wrappedComponent },
232 }),
233 )
234 }
235 }
236 }
237
238 // Set up global hook immediately
239 if (typeof window !== 'undefined') {
240 const w = window as Window & {
241 IvyForms?: {
242 api?: {
243 registerFieldTemplate?: (fieldType: string, component: Component) => void
244 fields?: {
245 registerPublic?: (fieldType: string, component: Component) => void
246 }
247 hooks?: {
248 registerFieldInit?: (
249 callback: (
250 field: Field,
251 context: {
252 formValues: Record<string, unknown>
253 formRules: Record<string, unknown>
254 getLabel: (key: string) => string
255 fieldErrors: Record<string, unknown>
256 },
257 ) => void,
258 ) => void
259 registerProFieldType?: (fieldType: string) => void
260 }
261 }
262 hooks?: {
263 fieldInit?: Array<
264 (
265 field: Field,
266 context: {
267 formValues: Record<string, unknown>
268 formRules: Record<string, unknown>
269 getLabel: (key: string) => string
270 fieldErrors: Record<string, unknown>
271 },
272 ) => void
273 >
274 }
275 _publicFieldQueue?: Array<{ fieldType: string; component: Component }>
276 _proFieldTypes?: Set<string>
277 }
278 }
279 // Initialize IvyForms globals with proper structure (types defined in global.d.ts)
280 // Note: Only minimal public API is initialized here, full API is in admin context
281 // These objects are populated dynamically at runtime, so we cast through 'unknown'
282 // which is type-safe since they will be fully initialized before use
283 if (!w.IvyForms) {
284 w.IvyForms = {
285 api: {
286 fields: { admin: {}, public: {} },
287 hooks: { actions: {}, filters: {} },
288 fieldOptions: { registry: {} },
289 },
290 hooks: {
291 actions: {},
292 filters: {},
293 fieldInit: [],
294 },
295 fields: {},
296 } as unknown as typeof w.IvyForms
297 }
298 if (!w.IvyForms.api) {
299 w.IvyForms.api = {
300 fields: { admin: {}, public: {} },
301 hooks: { actions: {}, filters: {} },
302 fieldOptions: { registry: {} },
303 } as unknown as typeof w.IvyForms.api
304 }
305 if (!w.IvyForms.api.fields) {
306 w.IvyForms.api.fields = {
307 admin: {},
308 public: {},
309 } as unknown as typeof w.IvyForms.api.fields
310 }
311
312 // Make it available globally
313 w.IvyForms.api.fields.registerPublic = registerFieldTemplateGlobal
314
315 // Process any queued registrations
316 const queue = w.IvyForms._publicFieldQueue || []
317 queue.forEach(({ fieldType, component }) => registerFieldTemplateGlobal(fieldType, component))
318
319 // Set up hooks system
320 if (!w.IvyForms.hooks) {
321 w.IvyForms.hooks = {
322 actions: {},
323 filters: {},
324 fieldInit: [],
325 } as unknown as typeof w.IvyForms.hooks
326 }
327 if (!w.IvyForms.hooks.fieldInit) {
328 w.IvyForms.hooks.fieldInit = []
329 }
330 // Initialize Pro field types registry
331 if (!w.IvyForms._proFieldTypes) {
332 w.IvyForms._proFieldTypes = new Set<string>()
333 }
334
335 // Add API to register field initialization hooks
336 if (!w.IvyForms.api.hooks) {
337 w.IvyForms.api.hooks = {
338 actions: {},
339 filters: {},
340 } as unknown as typeof w.IvyForms.api.hooks
341 }
342 w.IvyForms.api.hooks.registerFieldInit = (
343 callback: (
344 field: Field,
345 context: {
346 formValues: Record<string, unknown>
347 formRules: Record<string, unknown>
348 getLabel: (key: string) => string
349 fieldErrors: Record<string, unknown>
350 },
351 ) => void,
352 ) => {
353 if (typeof callback === 'function') {
354 w.IvyForms!.hooks!.fieldInit!.push(callback)
355 }
356 }
357 // Add API to register Pro field types (fields that skip Lite validation)
358 w.IvyForms.api.hooks.registerProFieldType = (fieldType: string) => {
359 if (fieldType && typeof fieldType === 'string') {
360 w.IvyForms!._proFieldTypes!.add(fieldType)
361 }
362 }
363 }
364
365 interface RecaptchaFieldComponent extends ComponentPublicInstance {
366 $props: {
367 field: {
368 type: string
369 fieldIndex: number
370 }
371 }
372 executeRecaptcha?: () => Promise<string>
373 }
374
375 interface TurnstileFieldComponent extends ComponentPublicInstance {
376 $props: {
377 field: {
378 type: string
379 fieldIndex: number
380 }
381 modelValue?: string
382 }
383 }
384
385 interface HCaptchaFieldComponent extends ComponentPublicInstance {
386 $props: {
387 field: {
388 type: string
389 fieldIndex: number
390 }
391 modelValue?: string
392 }
393 }
394
395 const { getLabel } = useLabels()
396 const { parseTimeToMinutes, isValidTimeFormat } = useTimeField()
397 const { isValidDateFormat, isDateInValidRange } = useDateField()
398 const { request } = useApiClient()
399 const skipValidation = ref(false)
400 const isSubmitting = ref(false)
401 const isSubmitCompleted = ref(false)
402
403 // Helper: normalize backend fields for preview (merge Name children into parent)
404 function normalizePreviewFields(rawFields: Field[]): Field[] {
405 if (!Array.isArray(rawFields) || rawFields.length === 0) return []
406
407 // Preserve original order index to stably order children without subFieldIndex
408 const apiOrder = new Map<Field, number>()
409 rawFields.forEach((f, i) => apiOrder.set(f, i))
410
411 const isChild = (f: Field) => f.parentId !== undefined && f.parentId !== null
412 const parents = rawFields.filter((f) => !isChild(f))
413
414 const result: Field[] = []
415
416 for (const parent of parents) {
417 // Collect children by matching parentId to parent.id or parent.fieldIndex
418 const children = rawFields.filter(
419 (c) =>
420 isChild(c) &&
421 (c.parentId === (parent as Field).id || c.parentId === (parent as Field).fieldIndex),
422 )
423
424 // Propagate common settings to top-level field if provided
425 const pSettings = (parent.settings || {}) as Record<string, unknown>
426 if (pSettings) {
427 if (parent.hideLabel === undefined && typeof pSettings.hideLabel !== 'undefined') {
428 ;(parent as Record<string, unknown>).hideLabel = !!pSettings.hideLabel
429 }
430 if (parent.readOnly === undefined && typeof pSettings.readOnly !== 'undefined') {
431 ;(parent as Record<string, unknown>).readOnly = !!(pSettings.readOnly as boolean)
432 }
433 if (
434 (parent as Record<string, unknown>).description === undefined &&
435 typeof pSettings.description !== 'undefined'
436 ) {
437 ;(parent as Record<string, unknown>).description = String(pSettings.description || '')
438 }
439 if (
440 (parent as Record<string, unknown>).cssClasses === undefined &&
441 typeof pSettings.cssClasses !== 'undefined'
442 ) {
443 ;(parent as Record<string, unknown>).cssClasses = String(pSettings.cssClasses || '')
444 }
445 }
446
447 // Apply Pro filters to parse field (e.g., conditional logic parser)
448 let processedParent = parent
449 if (window.IvyForms?.hooks?.applyFilters) {
450 processedParent = window.IvyForms.hooks.applyFilters(
451 'ivyforms/form-render/filter_parse_field',
452 parent,
453 ) as Field
454 }
455
456 // Use processedParent for rest of function
457 const parentField = processedParent
458
459 // Merge children for name and address fields
460 if (children.length >= 2 || parentField.type === 'name' || parentField.type === 'address') {
461 const ordered = sortChildrenByDisplayOrder(children, apiOrder)
462
463 if (parentField.type === 'name') {
464 // Merge as name field
465 const nameFieldTypes: string[] = []
466 ordered.forEach((child, idx) => {
467 const cs = getChildFieldSettings(child)
468 const key = resolveUniqueNameFieldKey(
469 { type: child.nameFieldType, label: child.label },
470 cs,
471 idx,
472 nameFieldTypes,
473 )
474 nameFieldTypes.push(key)
475 const prec = parentField as unknown as Record<string, unknown>
476 prec[`${key}Label`] = (child.label as unknown) || ''
477 prec[`${key}Placeholder`] = (child.placeholder as unknown) || ''
478 prec[`${key}Required`] = !!child.required
479 // Required message: prefer settings.requiredMessage, fallback to legacy top-level
480 if (typeof cs.requiredMessage !== 'undefined') {
481 prec[`${key}RequiredMessage`] = cs.requiredMessage
482 } else if (typeof (child as Record<string, unknown>).requiredMessage !== 'undefined') {
483 prec[`${key}RequiredMessage`] = (child as Record<string, unknown>).requiredMessage
484 }
485 // Default value
486 if (typeof child.defaultValue !== 'undefined') {
487 prec[`${key}Value`] = child.defaultValue as unknown
488 }
489 // Hide sublabel (support both settings.hideLabel and legacy child.hideLabel)
490 const childHasSettingsHide = typeof cs.hideLabel !== 'undefined'
491 const childHasTopLevelHide =
492 typeof (child as Record<string, unknown>).hideLabel !== 'undefined'
493 if (childHasSettingsHide || childHasTopLevelHide) {
494 const childHideLabel = childHasSettingsHide
495 ? !!(cs.hideLabel as boolean)
496 : !!((child as Record<string, unknown>).hideLabel as boolean)
497 prec[`${key}HideLabel`] = childHideLabel
498 }
499 // Subfield description
500 if (typeof (child as Record<string, unknown>).description !== 'undefined') {
501 prec[`${key}Description`] = String(
502 ((child as Record<string, unknown>).description as string) || '',
503 )
504 }
505 // Visibility
506 if (typeof (child as Record<string, unknown>).description !== 'undefined') {
507 prec[`${key}Visible`] = String(
508 ((child as Record<string, unknown>).visible as boolean) || true,
509 )
510 }
511 })
512 ;(parentField as Record<string, unknown>).nameFieldTypes = nameFieldTypes
513 parentField.type = 'name'
514 } else if (parentField.type === 'address') {
515 // Merge as address field
516 // Use canonical order for address fields (must match IvyAddressFormField.vue and builder)
517 const canonicalOrder = ['streetAddress', 'addressLine2', 'city', 'state', 'zip', 'country']
518 const addressFieldTypes = []
519 // If there are NO children, still populate all 6 subfields on the parent
520 if (ordered.length === 0) {
521 canonicalOrder.forEach((fieldType) => {
522 addressFieldTypes.push(fieldType)
523 const prec = parentField as unknown as Record<string, unknown>
524 prec[`${fieldType}Label`] =
525 (parentField as Record<string, unknown>)[`${fieldType}Label`] || ''
526 prec[`${fieldType}Placeholder`] =
527 (parentField as Record<string, unknown>)[`${fieldType}Placeholder`] || ''
528 prec[`${fieldType}Required`] =
529 (parentField as Record<string, unknown>)[`${fieldType}Required`] || false
530 prec[`${fieldType}RequiredMessage`] =
531 (parentField as Record<string, unknown>)[`${fieldType}RequiredMessage`] || ''
532 prec[`${fieldType}Value`] =
533 (parentField as Record<string, unknown>)[`${fieldType}Value`] || ''
534 prec[`${fieldType}HideLabel`] =
535 (parentField as Record<string, unknown>)[`${fieldType}HideLabel`] || false
536 prec[`${fieldType}Description`] =
537 (parentField as Record<string, unknown>)[`${fieldType}Description`] || ''
538 prec[`${fieldType}Visible`] =
539 (parentField as Record<string, unknown>)[`${fieldType}Visible`] || true
540 })
541 ;(parentField as Record<string, unknown>).addressFieldTypes = canonicalOrder
542 parentField.type = 'address'
543 } else {
544 canonicalOrder.forEach((fieldType, idx) => {
545 // Find child by settings.fieldType, or by label (case-insensitive, spaces removed), or fallback to ordered[idx] if exists
546 let child =
547 ordered.find((c) => {
548 const settings = parseSettings((c as unknown as Record<string, unknown>).settings)
549 return (
550 (settings && settings.fieldType === fieldType) ||
551 (c.label && c.label.toLowerCase().replace(/\s/g, '') === fieldType.toLowerCase())
552 )
553 }) || ordered.find((c) => c.type === fieldType)
554 // Only fallback to ordered[idx] if it exists and is not undefined
555 if (!child && Array.isArray(ordered) && ordered.length > idx && ordered[idx]) {
556 child = ordered[idx]
557 }
558 addressFieldTypes.push(fieldType)
559 const cs = child && child.settings ? parseSettings(child.settings) : {}
560 const prec = parentField as unknown as Record<string, unknown>
561 prec[`${fieldType}Label`] = child ? (child.label as unknown) || '' : ''
562 prec[`${fieldType}Placeholder`] = child ? (child.placeholder as unknown) || '' : ''
563 prec[`${fieldType}Required`] = child ? !!child.required : false
564 if (child && typeof cs.requiredMessage !== 'undefined') {
565 prec[`${fieldType}RequiredMessage`] = cs.requiredMessage
566 } else if (
567 child &&
568 typeof (child as Record<string, unknown>).requiredMessage !== 'undefined'
569 ) {
570 prec[`${fieldType}RequiredMessage`] = (
571 child as Record<string, unknown>
572 ).requiredMessage
573 } else {
574 prec[`${fieldType}RequiredMessage`] = ''
575 }
576 if (child && typeof child.defaultValue !== 'undefined') {
577 prec[`${fieldType}Value`] = child.defaultValue as unknown
578 } else {
579 prec[`${fieldType}Value`] = ''
580 }
581 const childHasSettingsHide = child && typeof cs.hideLabel !== 'undefined'
582 const childHasTopLevelHide =
583 child && typeof (child as Record<string, unknown>).hideLabel !== 'undefined'
584 if (childHasSettingsHide || childHasTopLevelHide) {
585 const childHideLabel = childHasSettingsHide
586 ? !!(cs.hideLabel as boolean)
587 : !!((child as Record<string, unknown>).hideLabel as boolean)
588 prec[`${fieldType}HideLabel`] = childHideLabel
589 } else {
590 prec[`${fieldType}HideLabel`] = false
591 }
592 if (child && typeof (child as Record<string, unknown>).description !== 'undefined') {
593 prec[`${fieldType}Description`] = String(
594 ((child as Record<string, unknown>).description as string) || '',
595 )
596 } else {
597 prec[`${fieldType}Description`] = ''
598 }
599 if (child && typeof (child as Record<string, unknown>).visible !== 'undefined') {
600 prec[`${fieldType}Visible`] = !!((child as Record<string, unknown>)
601 .visible as boolean)
602 } else {
603 prec[`${fieldType}Visible`] = true
604 }
605 })
606 ;(parentField as Record<string, unknown>).addressFieldTypes = canonicalOrder
607 parentField.type = 'address'
608 }
609 }
610 }
611
612 result.push(parentField)
613 }
614
615 return result
616 }
617
618 // Safely parse settings which may be stored as JSON string or as an object
619 const parseSettings = (s: unknown): Record<string, unknown> => {
620 if (!s) return {}
621 if (typeof s === 'string') {
622 try {
623 return JSON.parse(s) as Record<string, unknown>
624 } catch {
625 return {}
626 }
627 }
628 if (typeof s === 'object') {
629 return s as Record<string, unknown>
630 }
631 return {}
632 }
633
634 // Form data
635 const formFields = ref([])
636 const formValues = ref({})
637 const formRules = ref({})
638 const fieldErrors = ref({})
639 const isLoading = ref(true)
640 const formRef = ref(null)
641 const formWrapperRef = ref(null)
642 const formFieldsCollectorRefs = ref([])
643 const formNonce = ref(null)
644 const formConfirmation = ref([])
645 const showSuccessMessage = ref(false)
646 const showSuccessMessageReset = ref(false)
647 const successMessage = ref('')
648 const postId = ref('')
649 const refererUrl = ref('')
650
651 // Computed property to filter visible fields
652 // Hook: ivyforms_fields_visible can be used by Pro to add conditional logic filtering
653 const visibleFields = computed(() => {
654 if (!formFields.value || formFields.value.length === 0) {
655 return []
656 }
657
658 // Allow Pro plugin to filter fields (e.g., conditional logic)
659 let fields = [...formFields.value]
660
661 if (window.IvyForms?.hooks?.applyFilters) {
662 fields = window.IvyForms.hooks.applyFilters(
663 'ivyforms/form-render/filter_fields_visible',
664 fields,
665 formValues.value,
666 )
667 }
668
669 // Filter fields by current page
670 if (pages.value.length > 1) {
671 fields = fields.filter((field) => field.pageId === currentPageId.value)
672 }
673
674 return fields
675 })
676
677 // Organize fields into rows based on rowIndex and columnIndex
678 const fieldsByRow = computed(() => {
679 const fields = visibleFields.value
680 if (!fields || fields.length === 0) {
681 return []
682 }
683
684 // Group fields by rowIndex
685 const rowsMap: Map<number, Field[]> = new Map()
686
687 fields.forEach((field) => {
688 const rowIdx = field.rowIndex ?? 0
689 if (!rowsMap.has(rowIdx)) {
690 rowsMap.set(rowIdx, [])
691 }
692 rowsMap.get(rowIdx)!.push(field)
693 })
694
695 // Sort rows by rowIndex and sort fields within each row by columnIndex
696 return Array.from(rowsMap.entries())
697 .sort(([aIdx], [bIdx]) => aIdx - bIdx)
698 .map(([, fields]) => {
699 return fields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
700 })
701 })
702
703 // Multi-page computed properties
704 const currentPageIndex = computed(() => {
705 return pages.value.findIndex((page) => page.id === currentPageId.value)
706 })
707
708 // Multi-page navigation functions
709 const getPageFields = (pageId: string) => {
710 let fields = [...formFields.value]
711
712 if (window.IvyForms?.hooks?.applyFilters) {
713 fields = window.IvyForms.hooks.applyFilters(
714 'ivyforms/form-render/filter_fields_visible',
715 fields,
716 formValues.value,
717 )
718 }
719
720 if (pages.value.length > 1) {
721 fields = fields.filter((field) => field.pageId === pageId)
722 }
723
724 return fields
725 }
726
727 const getFieldValidationKeys = (field: Field): string[] => {
728 const baseKey = `${field.type}_${field.fieldIndex}`
729
730 if (field.type === 'name') {
731 const nameTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
732 return nameTypes
733 .filter((nfType: string) => (field as Record<string, unknown>)[`${nfType}Visible`] !== false)
734 .map((nfType: string) => `${baseKey}.${nfType}`)
735 }
736
737 if (field.type === 'address') {
738 const addressTypes = field.addressFieldTypes || [
739 'streetAddress',
740 'addressLine2',
741 'city',
742 'state',
743 'zip',
744 'country',
745 ]
746 return addressTypes
747 .filter((afType: string) => (field as Record<string, unknown>)[`${afType}Visible`] !== false)
748 .map((afType: string) => `${baseKey}.${afType}`)
749 }
750
751 return [baseKey]
752 }
753
754 const getValidationKeysForPage = (pageId: string): string[] => {
755 const rules = formRules.value as Record<string, unknown>
756 const keys = getPageFields(pageId).flatMap(getFieldValidationKeys)
757 return [...new Set(keys.filter((key) => key in rules))]
758 }
759
760 const getValidationMessage = (error: unknown): string => {
761 if (typeof error === 'string') return error
762 if (error instanceof Error && error.message) return error.message
763 if (Array.isArray(error) && error.length > 0) {
764 const first = error[0] as { message?: string } | string
765 if (typeof first === 'string') return first
766 if (first && typeof first.message === 'string') return first.message
767 }
768 return getLabel('invalid_input')
769 }
770
771 const validatePageByRules = async (pageId: string, applyErrors: boolean): Promise<boolean> => {
772 if (!formRef.value) return true
773
774 const keys = getValidationKeysForPage(pageId)
775 if (keys.length === 0) return true
776
777 let hasErrors = false
778
779 for (const key of keys) {
780 try {
781 await formRef.value.validateField(key)
782 if (applyErrors && (fieldErrors.value as Record<string, unknown>)[key]) {
783 delete (fieldErrors.value as Record<string, unknown>)[key]
784 }
785 } catch (error) {
786 hasErrors = true
787 if (applyErrors) {
788 ;(fieldErrors.value as Record<string, string>)[key] = getValidationMessage(error)
789 }
790 }
791 }
792
793 return !hasErrors
794 }
795
796 const nextPage = async () => {
797 const isCurrentPageValid = await validatePageByRules(currentPageId.value, true)
798
799 if (!isCurrentPageValid) {
800 IvyMessage({
801 message: getLabel('please_fill_required_fields'),
802 type: 'error',
803 })
804 return
805 }
806
807 const duplicateOutcome = await validateDuplicatesBeforeProceed(currentPageId.value)
808 if (duplicateOutcome !== 'ok') {
809 handleDuplicateValidationFailure(duplicateOutcome)
810 return
811 }
812
813 if (!completedPages.value.includes(currentPageId.value)) {
814 completedPages.value.push(currentPageId.value)
815 }
816
817 if (currentPageIndex.value < pages.value.length - 1) {
818 currentPageId.value = pages.value[currentPageIndex.value + 1].id
819 // Scroll to top of form
820 formWrapperRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
821 }
822 }
823
824 const previousPage = () => {
825 if (currentPageIndex.value > 0) {
826 currentPageId.value = pages.value[currentPageIndex.value - 1].id
827 // Scroll to top of form
828 formWrapperRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
829 }
830 }
831
832 interface ProgressIndicator {
833 type: 'progress-bar' | 'tabs' | 'rootline' | 'none'
834 showPageTitles: boolean
835 hidePageNumbers: boolean
836 hideConnectingLines: boolean
837 }
838
839 interface FormData {
840 id: string | number
841 name?: string
842 description?: string
843 showTitle?: boolean
844 showDescription?: boolean
845 storeEntries?: boolean
846 defaultValue?: string
847 counter?: number
848 fields?: Field[]
849 confirmations?: Confirmation[]
850 pages?: FormPage[]
851 progressIndicator?: ProgressIndicator
852 formActionButtons?: {
853 submitButtonSettings?: {
854 label: string
855 position: 'default' | 'left' | 'center' | 'right'
856 }
857 pageNavigationSettings?: Record<string, Record<string, unknown>>
858 }
859 styleSettings?: unknown
860 }
861
862 // Define the shape of entries provided by the global window variable
863 interface GlobalFormEntry {
864 id: string | number
865 form: FormData
866 nonce: string
867 fields: Field[]
868 confirmations: Confirmation[]
869 }
870
871 // Update your refs with proper typing
872 const formData = ref<FormData>({
873 id: '',
874 name: '',
875 description: '',
876 showTitle: true,
877 showDescription: false,
878 storeEntries: true,
879 defaultValue: '',
880 counter: 0,
881 fields: [],
882 confirmations: [],
883 formActionButtons: {
884 submitButtonSettings: {
885 label: getLabel('submit'),
886 position: 'default',
887 },
888 pageNavigationSettings: {},
889 },
890 })
891
892 // Multi-page state
893
894 const pages = ref<FormPage[]>([
895 {
896 id: 'page-1',
897 label: 'Page 1',
898 closable: false,
899 },
900 ])
901 const currentPageId = ref<string>('page-1')
902 const progressIndicator = ref<ProgressIndicator>({
903 type: 'none',
904 showPageTitles: true,
905 hidePageNumbers: false,
906 hideConnectingLines: false,
907 })
908 const completedPages = ref<string[]>([])
909
910 const { validateDuplicatesBeforeProceed, applyDuplicateErrors } = useValidateBeforeSubmit({
911 request,
912 getLabel,
913 formData,
914 formFields,
915 formValues,
916 formNonce,
917 fieldErrors,
918 getPageFields,
919 })
920
921 // Resolve Pro multi-page components through hook-based filters
922 const { progressIndicator: proProgressIndicator, pageNavigation: proPageNavigation } =
923 useMultiPageComponents()
924
925 type RenderEvents = Record<string, (...args: unknown[]) => void>
926 type RenderProps = Record<string, unknown>
927
928 interface RenderModel {
929 render: boolean
930 component: Component | null
931 key: string
932 wrapperClass: string
933 props: RenderProps
934 events: RenderEvents
935 }
936
937 const mergeRenderModel = (base: RenderModel, next: unknown): RenderModel => {
938 if (!next || typeof next !== 'object') {
939 return base
940 }
941
942 const partial = next as Partial<RenderModel>
943
944 return {
945 ...base,
946 ...partial,
947 props:
948 partial.props && typeof partial.props === 'object'
949 ? { ...base.props, ...partial.props }
950 : base.props,
951 events:
952 partial.events && typeof partial.events === 'object'
953 ? { ...base.events, ...partial.events }
954 : base.events,
955 }
956 }
957
958 const getIvyHooks = () => {
959 const liteHooks = window.IvyForms?.hooks
960 const apiHooks = window.IvyForms?.api?.hooks
961
962 if (liteHooks?.applyFilters) {
963 return liteHooks
964 }
965
966 if (apiHooks?.applyFilters) {
967 return apiHooks
968 }
969
970 return null
971 }
972
973 const applyRenderModelFilter = (hookName: string, base: RenderModel, context: RenderProps) => {
974 const hooks = getIvyHooks()
975 if (hooks?.applyFilters) {
976 const filtered = hooks.applyFilters(hookName, base, context)
977 return mergeRenderModel(base, filtered)
978 }
979
980 return base
981 }
982
983 const progressIndicatorRenderModel = computed<RenderModel>(() => {
984 const base: RenderModel = {
985 render:
986 !!proProgressIndicator.value &&
987 pages.value.length > 1 &&
988 progressIndicator.value.type !== 'none',
989 component: proProgressIndicator.value,
990 key: `progress-${currentPageId.value}`,
991 wrapperClass: 'ivyforms-form-render__progress-indicator-wrapper',
992 props: {
993 type: progressIndicator.value.type,
994 pages: pages.value,
995 currentPageId: currentPageId.value,
996 showPageTitles: progressIndicator.value.showPageTitles,
997 hidePageNumbers: progressIndicator.value.hidePageNumbers,
998 hideConnectingLines: progressIndicator.value.hideConnectingLines,
999 completedPages: completedPages.value,
1000 },
1001 events: {
1002 'change-page': (pageId: unknown) => {
1003 if (typeof pageId === 'string') {
1004 handleChangePage(pageId)
1005 }
1006 },
1007 },
1008 }
1009
1010 return applyRenderModelFilter(
1011 'ivyforms/form-render/filter_progress_indicator_render_model',
1012 base,
1013 {
1014 pages: pages.value,
1015 currentPageId: currentPageId.value,
1016 progressIndicator: progressIndicator.value,
1017 completedPages: completedPages.value,
1018 },
1019 )
1020 })
1021
1022 const pageNavigationRenderModel = computed<RenderModel>(() => {
1023 const navSettings = getPageNavSettings()
1024 const base: RenderModel = {
1025 render: !!proPageNavigation.value && pages.value.length > 1,
1026 component: proPageNavigation.value,
1027 key: `nav-${currentPageId.value}`,
1028 wrapperClass: 'ivyforms-form-render__navigation-wrapper',
1029 props: {
1030 pageIndex: currentPageIndex.value,
1031 totalPages: pages.value.length,
1032 previousButton: navSettings.previousButton,
1033 nextButton: navSettings.nextButton,
1034 submitButton: navSettings.submitButton,
1035 buttonPosition: navSettings.buttonPosition,
1036 isGrouped: navSettings.isGrouped,
1037 },
1038 events: {
1039 previous: () => previousPage(),
1040 next: () => nextPage(),
1041 submit: () => validateAndSubmit(),
1042 },
1043 }
1044
1045 return applyRenderModelFilter('ivyforms/form-render/filter_page_navigation_render_model', base, {
1046 pages: pages.value,
1047 currentPageId: currentPageId.value,
1048 currentPageIndex: currentPageIndex.value,
1049 pageNavigationSettings: navSettings,
1050 })
1051 })
1052
1053 const showFallbackSubmitButton = computed(
1054 () => !pageNavigationRenderModel.value.render || pages.value.length <= 1,
1055 )
1056
1057 // Page navigation settings (per page, with defaults)
1058 const pageNavigationSettings = ref<Record<string, Record<string, unknown>>>({})
1059
1060 // Get navigation settings for current page or use defaults
1061 const getPageNavSettings = () => {
1062 const currentSettings = pageNavigationSettings.value[currentPageId.value]
1063
1064 if (currentSettings) {
1065 return currentSettings
1066 }
1067
1068 // Default settings if not configured
1069 return {
1070 previousButton: {
1071 label: getLabel('previous') || 'Previous',
1072 cssClasses: '',
1073 priority: 'tertiary',
1074 type: 'border',
1075 showIcon: true,
1076 },
1077 nextButton: {
1078 label: getLabel('next') || 'Next',
1079 cssClasses: '',
1080 priority: 'tertiary',
1081 type: 'border',
1082 showIcon: true,
1083 },
1084 submitButton: {
1085 label:
1086 formData.value.formActionButtons?.submitButtonSettings?.label ||
1087 getLabel('submit') ||
1088 'Submit',
1089 cssClasses: '',
1090 priority: 'tertiary',
1091 type: 'border',
1092 showIcon: false,
1093 },
1094 buttonPosition: 'separated',
1095 isGrouped: false,
1096 }
1097 }
1098
1099 // Handle page change from progress indicator
1100 const handleChangePage = (pageId: string) => {
1101 currentPageId.value = pageId
1102 // Scroll to top of form
1103 formWrapperRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
1104 }
1105
1106 // Mark current page as completed when moving forward
1107 watch(currentPageId, async (newPageId, oldPageId) => {
1108 if (!oldPageId || newPageId === oldPageId) {
1109 return
1110 }
1111
1112 const oldIndex = pages.value.findIndex((p) => p.id === oldPageId)
1113 const newIndex = pages.value.findIndex((p) => p.id === newPageId)
1114
1115 if (oldIndex === -1 || newIndex === -1) {
1116 completedPages.value = completedPages.value.filter((id) => pages.value.some((p) => p.id === id))
1117 return
1118 }
1119
1120 // Keep completion markers only for pages that come before the active page.
1121 completedPages.value = completedPages.value.filter((id) => {
1122 const idx = pages.value.findIndex((p) => p.id === id)
1123 return idx >= 0 && idx < newIndex
1124 })
1125
1126 // On forward navigation, mark old page completed only if it validates.
1127 if (newIndex > oldIndex) {
1128 const isOldPageValid = await validatePageByRules(oldPageId, false)
1129 if (isOldPageValid && !completedPages.value.includes(oldPageId)) {
1130 completedPages.value.push(oldPageId)
1131 }
1132 }
1133 })
1134
1135 const horizontalGapCss = computed(() => {
1136 return resolveHorizontalSpacing(formData.value.styleSettings)
1137 })
1138 const formPopperScopeClass = computed(() => {
1139 return buildFormPopperScopeClass(formData.value.id || 'public-form')
1140 })
1141
1142 const styleClassList = computed(() => {
1143 const styleSettings = (formData.value.styleSettings || {}) as FormStyleSettings
1144 const parsedCustomClasses = parseCssClassList(styleSettings.customCssClass || '')
1145 const parsedAdditionalClasses = parseCssClassList(styleSettings.additionalCssClasses || '')
1146
1147 return [...parsedCustomClasses, ...parsedAdditionalClasses]
1148 })
1149
1150 const formWrapperClasses = computed(() => {
1151 const formId = formData.value.id
1152
1153 return [
1154 'ivyforms-form-wrapper',
1155 `ivyforms-form-wrapper-${formId}`,
1156 `ivyforms-form-${formId}`,
1157 ...styleClassList.value,
1158 ]
1159 })
1160 // * Form Fields Object - start with base templates then add global ones
1161 const baseTemplates = {
1162 text: markRaw(IvyTextFormField),
1163 email: markRaw(IvyEmailFormField),
1164 textarea: markRaw(IvyParagraphFormField),
1165 number: markRaw(IvyNumberFormField),
1166 slider: markRaw(SliderFormField),
1167 phone: markRaw(IvyPhoneFormField),
1168 website: markRaw(IvyWebsiteFormField),
1169 time: markRaw(TimeFormField),
1170 date: markRaw(DateFormField),
1171 recaptcha: markRaw(RecaptchaFormField),
1172 turnstile: markRaw(TurnstileFormField),
1173 hcaptcha: markRaw(HCaptchaFormField),
1174 html: markRaw(HtmlFormField),
1175 radio: markRaw(IvyRadioFormField),
1176 select: markRaw(SelectFormField),
1177 name: markRaw(NameFormField),
1178 address: markRaw(IvyAddressFormField),
1179 checkbox: markRaw(CheckboxFormField),
1180 'multi-select': markRaw(SelectFormField),
1181 rating: markRaw(RatingFormField),
1182 gdpr: markRaw(GdprFormField),
1183 }
1184
1185 // Create reactive templates and manually add global store items
1186 const formFieldsTemplates = reactive({ ...baseTemplates })
1187 // Add any existing global templates
1188 Object.entries(globalTemplatesStore).forEach(([fieldType, component]) => {
1189 formFieldsTemplates[fieldType] = component
1190 })
1191 // Force reactivity trigger
1192 const templateUpdateTrigger = ref(0)
1193 // Set up update function for late registrations
1194 if (typeof window !== 'undefined') {
1195 ;(
1196 window as Window & { _updateFormTemplates?: (fieldType: string, component: Component) => void }
1197 )._updateFormTemplates = (fieldType: string, component: Component) => {
1198 formFieldsTemplates[fieldType] = component
1199 templateUpdateTrigger.value++ // Force Vue reactivity
1200 }
1201
1202 // Listen for template registration events
1203 window.addEventListener('ivyforms:template_registered', (event: Event) => {
1204 const customEvent = event as CustomEvent<{ fieldType: string; component: Component }>
1205 const { fieldType, component } = customEvent.detail
1206 formFieldsTemplates[fieldType] = component
1207 templateUpdateTrigger.value++ // Force Vue reactivity
1208 })
1209
1210 // Also check if there are already registered templates in global store
1211 Object.entries(globalTemplatesStore).forEach(([fieldType, component]) => {
1212 formFieldsTemplates[fieldType] = component
1213 })
1214 }
1215
1216 // Initialize from global variable
1217 onMounted(async () => {
1218 const w = window as unknown as { wpIvyFormDataList?: Record<string, GlobalFormEntry> }
1219 if (typeof window !== 'undefined' && w.wpIvyFormDataList) {
1220 const counter = (formWrapperRef.value as HTMLElement)
1221 .closest('.ivyforms-frontend')!
1222 .getAttribute('data-counter') as string
1223 const formsData = w.wpIvyFormDataList[counter]
1224 // Check if formData is an object and has the required properties
1225 if (typeof formsData === 'object' && formsData !== null && formsData.id) {
1226 // Assign the form data to the reactive variable
1227 formData.value = formsData.form
1228 formNonce.value = formsData.nonce
1229 formConfirmation.value = formsData.confirmations || []
1230
1231 // Working copy of fields — do not mutate wpIvyFormDataList (formsData.fields)
1232 let fieldsToNormalize: Field[] = [...(formsData.fields || [])]
1233
1234 // Load pages and progressIndicator from form settings
1235 if (
1236 formsData.form.pages &&
1237 Array.isArray(formsData.form.pages) &&
1238 formsData.form.pages.length > 0
1239 ) {
1240 pages.value = formsData.form.pages
1241 currentPageId.value = pages.value[0].id
1242
1243 // Map pageId to fields that don't have it yet
1244 // Backend organizes fields into pages[*].fields, so we reconstruct pageId for each field
1245 const fieldPageMap = new Map<number, string>()
1246 for (const page of pages.value) {
1247 if (page.fields && Array.isArray(page.fields)) {
1248 for (const field of page.fields) {
1249 fieldPageMap.set(field.fieldIndex, page.id)
1250 }
1251 }
1252 }
1253
1254 if (fieldPageMap.size > 0) {
1255 fieldsToNormalize = fieldsToNormalize.map((field: Field) => ({
1256 ...field,
1257 pageId: field.pageId || fieldPageMap.get(field.fieldIndex) || pages.value[0].id,
1258 }))
1259 }
1260 }
1261
1262 if (formsData.form.progressIndicator) {
1263 progressIndicator.value = formsData.form.progressIndicator
1264 }
1265
1266 // Load page navigation settings from saved form (mirrors builder)
1267 if (
1268 formsData.form.formActionButtons &&
1269 typeof formsData.form.formActionButtons === 'object' &&
1270 formsData.form.formActionButtons.pageNavigationSettings &&
1271 typeof formsData.form.formActionButtons.pageNavigationSettings === 'object'
1272 ) {
1273 pageNavigationSettings.value = formsData.form.formActionButtons.pageNavigationSettings
1274 }
1275
1276 // Wait for Pro plugins to register their hooks before initializing fields
1277 // This ensures all extensions can add field-specific initialization logic
1278 await new Promise<void>((resolve) => {
1279 if (typeof window !== 'undefined') {
1280 // Check if Pro is ready
1281 const wExt = window as Window & { IvyFormsProReady?: boolean }
1282 if (wExt.IvyFormsProReady) {
1283 resolve()
1284 return
1285 }
1286
1287 // Wait for Pro ready event
1288 const handler = () => {
1289 resolve()
1290 window.removeEventListener('ivyforms:pro_ready', handler)
1291 }
1292 window.addEventListener('ivyforms:pro_ready', handler, { once: true })
1293
1294 // Fallback timeout - initialize after 100ms even if Pro doesn't signal ready
1295 setTimeout(() => {
1296 window.removeEventListener('ivyforms:pro_ready', handler)
1297 resolve()
1298 }, 100)
1299 } else {
1300 resolve()
1301 }
1302 })
1303
1304 // �
1305 NOW normalize fields AFTER Pro is ready (single normalizePreviewFields call)
1306 // This ensures visibleFields computed will apply conditional logic hooks
1307 const hasProMultiPageRuntime = !!proProgressIndicator.value || !!proPageNavigation.value
1308
1309 if (!hasProMultiPageRuntime && pages.value.length > 1) {
1310 const firstPage = pages.value[0]
1311 const firstPageId = 'page_1'
1312 const firstPageLabel =
1313 typeof firstPage?.label === 'string' && firstPage.label.trim() !== ''
1314 ? firstPage.label
1315 : 'Page 1'
1316
1317 pages.value = [{ id: firstPageId, label: firstPageLabel, closable: false }]
1318 currentPageId.value = firstPageId
1319 pageNavigationSettings.value = {}
1320
1321 fieldsToNormalize = fieldsToNormalize.map((field: Field) => ({
1322 ...field,
1323 pageId: firstPageId,
1324 settings:
1325 field.settings && typeof field.settings === 'object'
1326 ? { ...(field.settings as Record<string, unknown>), pageId: firstPageId }
1327 : field.settings,
1328 }))
1329 }
1330
1331 formFields.value = normalizePreviewFields(fieldsToNormalize)
1332
1333 // Initialize form values with defaults or empty strings
1334 formFields.value.forEach((field) => {
1335 const key = field.type + '_' + field.fieldIndex
1336
1337 // Special handling for name field type
1338 if (field.type === 'name') {
1339 const nameValue: Record<string, string> = {}
1340 const nameFieldTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
1341 // Initialize each dynamic name field
1342 nameFieldTypes.forEach((fieldType) => {
1343 nameValue[fieldType] = (field as Record<string, string>)[`${fieldType}Value`] || ''
1344 })
1345 formValues.value[key] = nameValue
1346 } else if (field.type === 'address') {
1347 // initialize all Address field subfields
1348 const addressValue: Record<string, string> = {}
1349 const addressFieldTypes = field.addressFieldTypes || [
1350 'streetAddress',
1351 'addressLine2',
1352 'city',
1353 'state',
1354 'zip',
1355 'country',
1356 ]
1357 addressFieldTypes.forEach((fieldType) => {
1358 addressValue[fieldType] = (field as Record<string, string>)[`${fieldType}Value`] || ''
1359 })
1360 formValues.value[key] = addressValue
1361 } else if (field.type === 'checkbox' && Array.isArray(field.fieldOptions)) {
1362 const defaultOpts = field.fieldOptions
1363 .filter((opt) => opt.isDefault)
1364 .map((opt) => opt.value)
1365 formValues.value[key] = defaultOpts.length ? defaultOpts : undefined
1366 } else if (
1367 (field.type === 'radio' || field.type === 'select') &&
1368 Array.isArray(field.fieldOptions)
1369 ) {
1370 const defaultOpt = field.fieldOptions.find((opt) => opt.isDefault)
1371 formValues.value[key] = defaultOpt ? defaultOpt.value : ''
1372 } else if (field.type === 'rating' && Array.isArray(field.fieldOptions)) {
1373 const defaultOpt = field.fieldOptions.find((opt) => opt.isDefault)
1374 formValues.value[key] = defaultOpt ? Number(defaultOpt.value) : undefined
1375 } else if (field.type === 'multi-select' && Array.isArray(field.fieldOptions)) {
1376 const defaultOpts = field.fieldOptions
1377 .filter((opt) => opt.isDefault)
1378 .map((opt) => opt.value)
1379 formValues.value[key] = defaultOpts.length ? defaultOpts : undefined
1380 } else if (field.type === 'website') {
1381 // Handle website fields with prefix and suffix
1382 let websiteValue = field.defaultValue || ''
1383 const prefix = field.inputPrefix || ''
1384 const suffix = field.inputSuffix || ''
1385
1386 // If there's a defaultValue, strip any existing prefix/suffix first
1387 if (websiteValue && (prefix || suffix)) {
1388 // Remove prefix if it exists
1389 if (prefix && websiteValue.startsWith(prefix)) {
1390 websiteValue = websiteValue.substring(prefix.length)
1391 }
1392 // Remove suffix if it exists
1393 if (suffix && websiteValue.endsWith(suffix)) {
1394 websiteValue = websiteValue.substring(0, websiteValue.length - suffix.length)
1395 }
1396 }
1397
1398 formValues.value[key] = websiteValue
1399 } else if (field.type === 'date') {
1400 // Sanitize date default values - filter out invalid dates with MIN_SAFE_INTEGER
1401 let dateValue = field.defaultValue || ''
1402 if (
1403 typeof dateValue === 'string' &&
1404 (dateValue.includes('-9007199254740991') || dateValue.includes('MIN_SAFE_INTEGER'))
1405 ) {
1406 dateValue = ''
1407 }
1408 formValues.value[key] = dateValue
1409 } else if (field.type === 'gdpr') {
1410 // GDPR checkbox must always be unchecked on form load
1411 formValues.value[key] = false
1412 } else {
1413 formValues.value[key] = field.defaultValue || ''
1414 }
1415
1416 if (field.type === 'name') {
1417 const nameFieldTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
1418 nameFieldTypes.forEach((nfType: string) => {
1419 const subKey = `${field.type}_${field.fieldIndex}.${nfType}`
1420 const label =
1421 (field as Record<string, string>)[`${nfType}Label`] ||
1422 getLabel(`name_field_${nfType.replace('nameField', '')}`)
1423 const rulesForSub: unknown[] = []
1424 if ((field as Record<string, unknown>)[`${nfType}Required`]) {
1425 const customMsg = (field as Record<string, unknown>)[`${nfType}RequiredMessage`]
1426 const message =
1427 typeof customMsg === 'string' && (customMsg as string).trim() !== ''
1428 ? (customMsg as string)
1429 : `${label} ${getLabel('required')}`
1430 rulesForSub.push({
1431 required: true,
1432 message,
1433 trigger: ['submit'],
1434 })
1435 }
1436 ;(formRules.value as Record<string, unknown>)[subKey] = rulesForSub
1437 })
1438 } else if (field.type === 'address') {
1439 const addressFieldTypes = field.addressFieldTypes || [
1440 'streetAddress',
1441 'addressLine2',
1442 'city',
1443 'state',
1444 'zip',
1445 'country',
1446 ]
1447 addressFieldTypes.forEach((afType: string) => {
1448 const subKey = `${field.type}_${field.fieldIndex}.${afType}`
1449 const label =
1450 (field as Record<string, string>)[`${afType}Label`] ||
1451 getLabel(`address_field_${afType.replace('addressField', '')}`)
1452 const rulesForSub: unknown[] = []
1453 if ((field as Record<string, unknown>)[`${afType}Required`]) {
1454 const customMsg = (field as Record<string, unknown>)[`${afType}RequiredMessage`]
1455 const message =
1456 typeof customMsg === 'string' && (customMsg as string).trim() !== ''
1457 ? (customMsg as string)
1458 : `${label} ${getLabel('required')}`
1459 rulesForSub.push({
1460 required: true,
1461 message,
1462 trigger: ['submit'],
1463 })
1464 }
1465 ;(formRules.value as Record<string, unknown>)[subKey] = rulesForSub
1466 })
1467 } else {
1468 const rules = []
1469
1470 // Check if this field type is registered as a Pro field
1471 // Pro fields handle their own validation via fieldInit hooks
1472 const w = window as Window & { IvyForms?: { _proFieldTypes?: Set<string> } }
1473 const isProField = w.IvyForms?._proFieldTypes?.has(field.type) || false
1474
1475 // Required rule (skip Pro fields - Pro handles them via fieldInit hooks)
1476 if ((field.required || field.type === 'gdpr') && !isProField) {
1477 // GDPR field needs custom validation to check true value
1478 if (field.type === 'gdpr') {
1479 rules.push({
1480 validator: (_rule: unknown, value: unknown, callback: (err?: Error) => void) => {
1481 if (value !== true && value !== 1 && value !== 'true' && value !== '1') {
1482 callback(new Error(getRequiredMessage(field)))
1483 } else {
1484 callback()
1485 }
1486 },
1487 trigger: ['submit'],
1488 })
1489 } else {
1490 rules.push({
1491 required: true,
1492 message: getRequiredMessage(field),
1493 trigger: ['submit'],
1494 })
1495 }
1496 }
1497
1498 // Format validations
1499 if (field.type === 'email') {
1500 rules.push({
1501 validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
1502 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
1503 if (skipValidation.value) {
1504 callback()
1505 return
1506 }
1507 if (!value) {
1508 if (field.required) {
1509 callback(new Error(getLabel('enter_email_address')))
1510 } else {
1511 callback()
1512 }
1513 return
1514 }
1515 if (!emailRegex.test(value)) {
1516 callback(new Error(getLabel('enter_email_address')))
1517 return
1518 }
1519 callback()
1520 },
1521 trigger: ['submit'],
1522 })
1523 }
1524
1525 if (field.type === 'phone') {
1526 rules.push({
1527 pattern: /^[+\d]?(?:[\d\s.-]{7,15})$/,
1528 message: getLabel('enter_phone_number'),
1529 trigger: ['submit'],
1530 })
1531 }
1532
1533 if (field.type === 'website') {
1534 rules.push({
1535 validator: (_rule: unknown, value: unknown, callback: (err?: Error) => void) => {
1536 // Check if field is required and empty
1537 if (field.required && (value === '' || value === null || value === undefined)) {
1538 return callback(new Error(getRequiredMessage(field)))
1539 }
1540 if (value === '' || value === null || value === undefined) {
1541 return callback()
1542 }
1543
1544 let urlToValidate = String(value)
1545
1546 // If field has prefix and/or suffix, construct the full URL for validation
1547 const prefix = field.inputPrefix || ''
1548 const suffix = field.inputSuffix || ''
1549
1550 if (prefix || suffix) {
1551 // User entered only the middle part, construct full URL
1552 urlToValidate = prefix + urlToValidate + suffix
1553 }
1554
1555 // Validate the complete URL
1556 const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/i
1557 if (!urlPattern.test(urlToValidate)) {
1558 return callback(new Error(getLabel('enter_valid_website')))
1559 }
1560
1561 callback()
1562 },
1563 trigger: ['submit'],
1564 })
1565 }
1566
1567 if (field.type === 'time') {
1568 rules.push({
1569 validator: (_: unknown, value: string, callback: (e?: Error) => void) => {
1570 const trimmedValue = typeof value === 'string' ? value.trim() : value
1571 const isEmpty = trimmedValue == null || trimmedValue === ''
1572 const isPartial = trimmedValue === '__PARTIAL__'
1573
1574 // Dropdown mode - check if all parts are selected
1575 if (field.timeFieldType === 'dropdown') {
1576 if (field.required && (isEmpty || isPartial)) {
1577 return callback(new Error(getRequiredMessage(field)))
1578 }
1579 if (isPartial) {
1580 return callback(new Error(getLabel('enter_valid_time')))
1581 }
1582 if (!isEmpty && !isValidTimeFormat(trimmedValue, field.timeFormat, 'dropdown')) {
1583 return callback(new Error(getLabel('enter_valid_time')))
1584 }
1585 return callback()
1586 }
1587
1588 // Required validation
1589 if (field.required && isEmpty) {
1590 return callback(new Error(getRequiredMessage(field)))
1591 }
1592
1593 // Format validation using centralized helper
1594 const hasValidFormat =
1595 isEmpty || isValidTimeFormat(trimmedValue, field.timeFormat, field.timeFieldType)
1596 if (!isEmpty && !hasValidFormat) {
1597 return callback(new Error(getLabel('enter_valid_time')))
1598 }
1599
1600 // Min/max range validation
1601 const hasMin =
1602 field.minTimeValue !== null &&
1603 field.minTimeValue !== undefined &&
1604 field.minTimeValue !== ''
1605 const hasMax =
1606 field.maxTimeValue !== null &&
1607 field.maxTimeValue !== undefined &&
1608 field.maxTimeValue !== ''
1609
1610 if ((hasMin || hasMax) && !isEmpty && hasValidFormat) {
1611 const currentTimeMinutes = parseTimeToMinutes(trimmedValue)
1612 if (currentTimeMinutes === null) {
1613 return callback(new Error(getLabel('enter_valid_time')))
1614 }
1615
1616 const minTimeMinutes = hasMin
1617 ? parseTimeToMinutes(String(field.minTimeValue))
1618 : null
1619 if (hasMin && minTimeMinutes === null) {
1620 return callback(
1621 new Error(`Malformed minimum time value: ${field.minTimeValue}`),
1622 )
1623 }
1624
1625 const maxTimeMinutes = hasMax
1626 ? parseTimeToMinutes(String(field.maxTimeValue))
1627 : null
1628 if (hasMax && maxTimeMinutes === null) {
1629 return callback(
1630 new Error(`Malformed maximum time value: ${field.maxTimeValue}`),
1631 )
1632 }
1633
1634 if (minTimeMinutes !== null && currentTimeMinutes < minTimeMinutes) {
1635 return callback(
1636 new Error(
1637 getLabel('time_must_be_after').replace('{min}', String(field.minTimeValue)),
1638 ),
1639 )
1640 }
1641
1642 if (maxTimeMinutes !== null && currentTimeMinutes > maxTimeMinutes) {
1643 return callback(
1644 new Error(
1645 getLabel('time_must_be_before').replace(
1646 '{max}',
1647 String(field.maxTimeValue),
1648 ),
1649 ),
1650 )
1651 }
1652 }
1653
1654 return callback()
1655 },
1656 trigger: ['submit'],
1657 })
1658 }
1659
1660 if (field.type === 'date') {
1661 rules.push({
1662 validator: (_: unknown, value: string, callback: (e?: Error) => void) => {
1663 const trimmedValue = typeof value === 'string' ? value.trim() : value
1664 const isEmpty = trimmedValue == null || trimmedValue === ''
1665 const isPartial = trimmedValue === '__PARTIAL__'
1666
1667 // Dropdown mode - check if all parts are selected
1668 if (field.dateFieldType === 'dropdown') {
1669 if (field.required && (isEmpty || isPartial)) {
1670 return callback(new Error(getRequiredMessage(field)))
1671 }
1672 if (isPartial) {
1673 return callback(new Error(getLabel('enter_valid_date')))
1674 }
1675 return callback()
1676 }
1677
1678 // Required validation
1679 if (field.required && isEmpty) {
1680 return callback(new Error(getRequiredMessage(field)))
1681 }
1682
1683 // Empty but not required - allow
1684 if (isEmpty) {
1685 return callback()
1686 }
1687
1688 // Format validation using centralized helper
1689 const dateFormat = (field.dateFormat as string) || 'MM/DD/YYYY'
1690 if (!isValidDateFormat(trimmedValue, dateFormat)) {
1691 return callback(new Error(getLabel('enter_date_valid_format')))
1692 }
1693
1694 // Min/max range validation using centralized helper
1695 const rangeValidation = isDateInValidRange(
1696 trimmedValue,
1697 dateFormat,
1698 field.minDateValue,
1699 field.maxDateValue,
1700 )
1701 if (!rangeValidation.isInRange) {
1702 // Map error messages to user-friendly labels
1703 if (rangeValidation.error?.includes('Malformed minimum')) {
1704 return callback(
1705 new Error(`Malformed minimum date value: ${field.minDateValue}`),
1706 )
1707 }
1708 if (rangeValidation.error?.includes('Malformed maximum')) {
1709 return callback(
1710 new Error(`Malformed maximum date value: ${field.maxDateValue}`),
1711 )
1712 }
1713 if (rangeValidation.error?.includes('at or after')) {
1714 return callback(
1715 new Error(
1716 getLabel('date_must_be_after').replace('{min}', String(field.minDateValue)),
1717 ),
1718 )
1719 }
1720 if (rangeValidation.error?.includes('at or before')) {
1721 return callback(
1722 new Error(
1723 getLabel('date_must_be_before').replace(
1724 '{max}',
1725 String(field.maxDateValue),
1726 ),
1727 ),
1728 )
1729 }
1730 return callback(new Error(rangeValidation.error || getLabel('enter_valid_date')))
1731 }
1732
1733 return callback()
1734 },
1735 trigger: ['submit'],
1736 })
1737 }
1738
1739 if (field.type === 'slider') {
1740 rules.push({
1741 validator: (_: unknown, value: unknown, callback: (e?: Error) => void) => {
1742 // Empty value is allowed (optional field)
1743 if (value === '' || value === null || value === undefined) {
1744 if (field.required) {
1745 return callback(new Error(getRequiredMessage(field)))
1746 }
1747 return callback()
1748 }
1749
1750 // Validate it's a number
1751 const num = Number(value)
1752 if (isNaN(num)) {
1753 return callback(new Error(getLabel('enter_valid_number')))
1754 }
1755
1756 // Validate min/max range
1757 const hasMin = field.minValue !== null && field.minValue !== undefined
1758 const hasMax = field.maxValue !== null && field.maxValue !== undefined
1759
1760 if (hasMin && num < field.minValue) {
1761 return callback(
1762 new Error(
1763 getLabel('number_cannot_be_smaller_than').replace(
1764 '{min}',
1765 String(field.minValue),
1766 ),
1767 ),
1768 )
1769 }
1770
1771 if (hasMax && num > field.maxValue) {
1772 return callback(
1773 new Error(
1774 getLabel('number_cannot_be_greater_than').replace(
1775 '{max}',
1776 String(field.maxValue),
1777 ),
1778 ),
1779 )
1780 }
1781
1782 return callback()
1783 },
1784 trigger: ['submit'],
1785 })
1786 }
1787
1788 if (field.type === 'number' && (field.minValue !== null || field.maxValue !== null)) {
1789 rules.push({
1790 validator: (_rule: unknown, value: unknown, callback: (err?: Error) => void) => {
1791 if (value === '' || value === null || value === undefined) {
1792 return callback()
1793 }
1794 const num = Number(value)
1795 if (isNaN(num)) {
1796 return callback(new Error(getLabel('enter_valid_number')))
1797 }
1798 const hasMin = field.minValue !== null && field.minValue !== undefined
1799 const hasMax = field.maxValue !== null && field.maxValue !== undefined
1800 if (hasMin && num < field.minValue) {
1801 if (hasMax) {
1802 const tpl = getLabel('number_out_of_range')
1803 return callback(
1804 new Error(
1805 tpl
1806 .replace('{min}', String(field.minValue))
1807 .replace('{max}', String(field.maxValue)),
1808 ),
1809 )
1810 }
1811 const tpl = getLabel('number_cannot_be_smaller_than')
1812 return callback(new Error(tpl.replace('{min}', String(field.minValue))))
1813 }
1814 if (hasMax && num > field.maxValue && field.minValue !== field.maxValue) {
1815 if (hasMin) {
1816 const tpl = getLabel('number_out_of_range')
1817 return callback(
1818 new Error(
1819 tpl
1820 .replace('{min}', String(field.minValue))
1821 .replace('{max}', String(field.maxValue)),
1822 ),
1823 )
1824 }
1825 const tpl = getLabel('number_cannot_be_greater_than')
1826 return callback(new Error(tpl.replace('{max}', String(field.maxValue))))
1827 }
1828 callback()
1829 },
1830 trigger: ['change', 'blur', 'submit'],
1831 })
1832
1833 // Real-time watcher for immediate feedback when exceeding limits
1834 watch(
1835 () => formValues.value[key],
1836 (val) => {
1837 const hasMin = field.minValue !== null && field.minValue !== undefined
1838 const hasMax = field.maxValue !== null && field.maxValue !== undefined
1839 if (val === '' || val === null || val === undefined) {
1840 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1841 return
1842 }
1843 const num = Number(val)
1844 if (isNaN(num)) {
1845 fieldErrors.value[key] = getLabel('enter_valid_number')
1846 return
1847 }
1848 if (
1849 ((hasMin && num < field.minValue) || (hasMax && num > field.maxValue)) &&
1850 field.minValue !== field.maxValue
1851 ) {
1852 if (hasMin && hasMax) {
1853 const tpl = getLabel('number_out_of_range')
1854 fieldErrors.value[key] = tpl
1855 .replace('{min}', String(field.minValue))
1856 .replace('{max}', String(field.maxValue))
1857 } else if (hasMin && num < field.minValue) {
1858 const tpl = getLabel('number_cannot_be_smaller_than')
1859 fieldErrors.value[key] = tpl.replace('{min}', String(field.minValue))
1860 } else if (hasMax && num > field.maxValue) {
1861 const tpl = getLabel('number_cannot_be_greater_than')
1862 fieldErrors.value[key] = tpl.replace('{max}', String(field.maxValue))
1863 }
1864 return
1865 }
1866 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1867 },
1868 )
1869 }
1870
1871 formRules.value[key] = rules
1872 }
1873
1874 // Real-time watcher for date min/max validation (only for input mode)
1875 if (
1876 field.type === 'date' &&
1877 field.dateFieldType === 'input' &&
1878 (field.minDateValue !== null || field.maxDateValue !== null)
1879 ) {
1880 const hasMin =
1881 field.minDateValue !== null &&
1882 field.minDateValue !== undefined &&
1883 field.minDateValue !== ''
1884 const hasMax =
1885 field.maxDateValue !== null &&
1886 field.maxDateValue !== undefined &&
1887 field.maxDateValue !== ''
1888
1889 if (hasMin || hasMax) {
1890 watch(
1891 () => formValues.value[key],
1892 (val) => {
1893 // Skip validation if empty or partial
1894 if (!val || val === '' || val === '__PARTIAL__') {
1895 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1896 return
1897 }
1898
1899 const dateFormat = (field.dateFormat as string) || 'MM/DD/YYYY'
1900
1901 const isDateComplete = (dateStr: string, format: string): boolean => {
1902 let expectedLength = format.length
1903
1904 if (format.includes('M/D') || format.includes('D/M')) {
1905 expectedLength = 8
1906 } else if (format.includes('MM/DD') || format.includes('DD/MM')) {
1907 expectedLength = 10
1908 } else if (format.includes('YYYY-MM-DD') || format.includes('YYYY-M-D')) {
1909 expectedLength = 10
1910 } else if (format.includes('DD.MM.YYYY') || format.includes('D.M.YYYY')) {
1911 expectedLength = 10
1912 }
1913
1914 if (dateStr.length < expectedLength) {
1915 return false
1916 }
1917
1918 let parts: string[] = []
1919 if (dateStr.indexOf('/') !== -1) {
1920 parts = dateStr.split('/')
1921 } else if (dateStr.indexOf('-') !== -1) {
1922 parts = dateStr.split('-')
1923 } else if (dateStr.indexOf('.') !== -1) {
1924 parts = dateStr.split('.')
1925 }
1926
1927 if (parts.length === 3) {
1928 let yearPart = ''
1929 if (format.startsWith('YYYY')) {
1930 yearPart = parts[0]
1931 } else {
1932 yearPart = parts[2]
1933 }
1934
1935 if (yearPart.length !== 4 || isNaN(Number(yearPart))) {
1936 return false
1937 }
1938 } else {
1939 return false
1940 }
1941
1942 return true
1943 }
1944
1945 if (!isDateComplete(String(val), dateFormat)) {
1946 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1947 return
1948 }
1949
1950 const currentDate = parseDateToObject(String(val), dateFormat)
1951 if (!currentDate) {
1952 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1953 return
1954 }
1955
1956 const minDate = hasMin
1957 ? parseDateToObject(String(field.minDateValue), dateFormat)
1958 : null
1959 const maxDate = hasMax
1960 ? parseDateToObject(String(field.maxDateValue), dateFormat)
1961 : null
1962
1963 // Use nextTick to ensure error is set AFTER clearFieldError is called
1964 nextTick(() => {
1965 if (minDate && currentDate < minDate) {
1966 const message = getLabel('date_must_be_after')
1967 fieldErrors.value[key] = message.replace('{min}', String(field.minDateValue))
1968 return
1969 }
1970
1971 if (maxDate && currentDate > maxDate) {
1972 const message = getLabel('date_must_be_before')
1973 fieldErrors.value[key] = message.replace('{max}', String(field.maxDateValue))
1974 return
1975 }
1976
1977 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1978 })
1979 },
1980 )
1981 }
1982 }
1983
1984 // Email confirmation extension
1985 if (field.type === 'email' && field.confirmFieldEnabled) {
1986 const confirmKey = 'emailConfirm_' + field.fieldIndex
1987 formValues.value[confirmKey] = ''
1988 const confirmRules = []
1989 confirmRules.push({
1990 validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
1991 const mainVal = formValues.value[key]
1992 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
1993 if (!mainVal) {
1994 callback()
1995 return
1996 }
1997 if (!value) {
1998 callback(new Error(getLabel('enter_email_address')))
1999 return
2000 }
2001 if (!emailRegex.test(value)) {
2002 callback(new Error(getLabel('enter_email_address')))
2003 return
2004 }
2005 if (mainVal && value !== mainVal) {
2006 callback(new Error(getLabel('emails_do_not_match')))
2007 return
2008 }
2009 callback()
2010 },
2011 trigger: ['submit'],
2012 })
2013 formRules.value[confirmKey] = confirmRules
2014 }
2015
2016 // Allow Pro or other extensions to add field-specific initialization
2017 // Hook: ivyforms:field_init
2018 if (typeof window !== 'undefined') {
2019 const w = window as Window & {
2020 IvyForms?: {
2021 hooks?: {
2022 fieldInit?: Array<
2023 (
2024 field: Field,
2025 context: {
2026 formValues: typeof formValues.value
2027 formRules: typeof formRules.value
2028 getLabel: typeof getLabel
2029 fieldErrors: typeof fieldErrors.value
2030 },
2031 ) => void
2032 >
2033 }
2034 }
2035 }
2036
2037 const hooks = w.IvyForms?.hooks?.fieldInit || []
2038 hooks.forEach((hook) => {
2039 try {
2040 hook(field, {
2041 formValues: formValues.value,
2042 formRules: formRules.value,
2043 getLabel,
2044 fieldErrors: fieldErrors.value,
2045 })
2046 } catch (error) {
2047 console.error(getLabel('error_in_field_init_hook'), error)
2048 }
2049 })
2050 }
2051 })
2052
2053 // Form is loaded, remove loader
2054 isLoading.value = false
2055
2056 // Clear any validation errors that may have been triggered during initialization
2057 // This prevents fields from showing errors on page load
2058 // Use setTimeout to ensure this runs after all DOM updates and isLoading changes
2059 setTimeout(() => {
2060 if (formRef.value) {
2061 formRef.value.clearValidate()
2062 }
2063 }, 50)
2064 // add vanila js remove class from loader
2065 const counter = (formWrapperRef.value as HTMLElement)
2066 .closest('.ivyforms-frontend')
2067 ?.getAttribute('data-counter')
2068 const loader = document.querySelector(`.ivyforms-skeleton-${counter}`)
2069 if (loader) {
2070 loader.remove()
2071 }
2072
2073 // Apply form styles if styleSettings exist
2074 if (formData.value.styleSettings) {
2075 applyFormStyles(formData.value.id as number, formData.value.styleSettings)
2076 }
2077 } else {
2078 console.error(getLabel('invalid_form_data'), formData)
2079 }
2080 } else {
2081 console.error(getLabel('form_data_not_found'))
2082 // Even with error, don't show loader anymore
2083 isLoading.value = false
2084 }
2085
2086 // After loading form data, get the outer wrapper by ID and read data attribute
2087 let counter = formData.value.counter || ''
2088 if (!counter && formWrapperRef.value) {
2089 const closest = formWrapperRef.value.closest('.ivyforms-frontend')
2090 if (closest && closest.dataset.counter) {
2091 counter = closest.dataset.counter
2092 }
2093 }
2094 if (counter) {
2095 const outerWrapper = document.getElementById('ivyforms-app-' + counter)
2096 if (outerWrapper && outerWrapper.dataset) {
2097 postId.value = outerWrapper.dataset.postId || ''
2098 refererUrl.value = outerWrapper.dataset.referer || document.referrer || ''
2099 }
2100 }
2101 })
2102
2103 function getRequiredMessage(field: Field): string {
2104 const rm = field.requiredMessage
2105 if (typeof rm === 'string' && rm.trim() !== '') {
2106 return rm
2107 }
2108
2109 // GDPR field has specific default message
2110 if (field.type === 'gdpr') {
2111 return getLabel('gdpr_required_default_message')
2112 }
2113
2114 let fieldMessage: string
2115 fieldMessage = ucFirst(field.label || field.type)
2116 fieldMessage += ` ${getLabel('required')}`
2117 return fieldMessage
2118 }
2119
2120 const ucFirst = (str: string) => (str ? str[0]?.toLocaleUpperCase() + str.slice(1) : '')
2121
2122 // Helper function to parse date string to Date object with validation
2123 const parseDateToObject = (dateStr: string, format: string): Date | null => {
2124 let day = 0,
2125 month = 0,
2126 year = 0
2127 let parts: string[] = []
2128 if (dateStr.indexOf('/') !== -1) {
2129 parts = dateStr.split('/')
2130 } else if (dateStr.indexOf('-') !== -1) {
2131 parts = dateStr.split('-')
2132 } else if (dateStr.indexOf('.') !== -1) {
2133 parts = dateStr.split('.')
2134 } else {
2135 parts = [dateStr]
2136 }
2137
2138 if (format.startsWith('MM') || format.startsWith('M')) {
2139 // MM/DD/YYYY or M/D/YYYY
2140 month = parseInt(parts[0], 10)
2141 day = parseInt(parts[1], 10)
2142 year = parseInt(parts[2], 10)
2143 } else if (format.startsWith('DD') || format.startsWith('D')) {
2144 // DD/MM/YYYY or D/M/YYYY or DD.MM.YYYY
2145 day = parseInt(parts[0], 10)
2146 month = parseInt(parts[1], 10)
2147 year = parseInt(parts[2], 10)
2148 } else if (format.startsWith('YYYY')) {
2149 // YYYY-MM-DD or YYYY-M-D
2150 year = parseInt(parts[0], 10)
2151 month = parseInt(parts[1], 10)
2152 day = parseInt(parts[2], 10)
2153 }
2154
2155 if (day && month && year) {
2156 const date = new Date(year, month - 1, day)
2157 // Validate that the date is actually valid (e.g., not Feb 31)
2158 if (date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day) {
2159 return date
2160 }
2161 }
2162 return null
2163 }
2164
2165 // Get the most common label position for the form
2166 const getFormLabelPosition = (): 'left' | 'right' | 'top' => {
2167 if (!formFields.value || formFields.value.length === 0) {
2168 return 'top'
2169 }
2170
2171 // Count label positions
2172 const positionCounts = formFields.value.reduce((counts, field) => {
2173 const position = field.labelPosition || 'top'
2174 counts[position] = (counts[position] || 0) + 1
2175 return counts
2176 }, {})
2177
2178 // Find the most common position
2179 const mostCommonPosition = Object.keys(positionCounts).reduce((a, b) =>
2180 positionCounts[a] > positionCounts[b] ? a : b,
2181 )
2182
2183 // Convert to Element Plus format
2184 switch (mostCommonPosition) {
2185 case 'left':
2186 return 'left'
2187 case 'right':
2188 return 'right'
2189 case 'top':
2190 case 'inside':
2191 default:
2192 return 'top'
2193 }
2194 }
2195
2196 // Validate form and submit
2197 const validateAndSubmit = async () => {
2198 if (!formRef.value || isSubmitting.value) return
2199 isSubmitting.value = true
2200
2201 await formRef.value.validate(
2202 async (valid: boolean, errors: Record<string, { message: string }[]>) => {
2203 if (valid) {
2204 // Handle reCAPTCHA v3 execution before form submission
2205 const hasRecaptchaField = formFields.value.some((field) => field.type === 'recaptcha')
2206
2207 if (hasRecaptchaField) {
2208 // Find reCAPTCHA field component ref
2209 const recaptchaFieldRef = formFieldsCollectorRefs.value?.find(
2210 (ref: ComponentPublicInstance): ref is RecaptchaFieldComponent =>
2211 ref &&
2212 (ref as RecaptchaFieldComponent).$props &&
2213 (ref as RecaptchaFieldComponent).$props.field &&
2214 (ref as RecaptchaFieldComponent).$props.field.type === 'recaptcha',
2215 )
2216 if (recaptchaFieldRef && recaptchaFieldRef.executeRecaptcha) {
2217 try {
2218 // For v3, this will execute the reCAPTCHA and get a token
2219 // For v2, this will return the current token
2220 const token = await recaptchaFieldRef.executeRecaptcha()
2221
2222 // Update the form value with the token
2223 const fieldKey = `recaptcha_${recaptchaFieldRef.$props.field.fieldIndex}`
2224 formValues.value[fieldKey] = token
2225 } catch (error) {
2226 console.error('reCAPTCHA execution failed:', error)
2227 isSubmitting.value = false
2228 return
2229 }
2230 }
2231 }
2232
2233 // Handle Turnstile validation before form submission
2234 const hasTurnstileField = formFields.value.some((field) => field.type === 'turnstile')
2235
2236 if (hasTurnstileField) {
2237 // Find Turnstile field component ref
2238 const turnstileFieldRef = formFieldsCollectorRefs.value?.find(
2239 (ref: ComponentPublicInstance): ref is TurnstileFieldComponent =>
2240 ref &&
2241 (ref as TurnstileFieldComponent).$props &&
2242 (ref as TurnstileFieldComponent).$props.field &&
2243 (ref as TurnstileFieldComponent).$props.field.type === 'turnstile',
2244 )
2245
2246 if (turnstileFieldRef) {
2247 const fieldKey = `turnstile_${turnstileFieldRef.$props.field.fieldIndex}`
2248 const turnstileToken = formValues.value[fieldKey]
2249
2250 // Check if Turnstile token exists
2251 if (!turnstileToken || turnstileToken.toString().trim() === '') {
2252 // Set error for the Turnstile field
2253 fieldErrors.value = {
2254 ...fieldErrors.value,
2255 [fieldKey]: getLabel('turnstile_complete_verification'),
2256 }
2257 scrollToFirstInvalidField()
2258 isSubmitting.value = false
2259 return
2260 }
2261 }
2262 }
2263
2264 // Handle hCaptcha validation before form submission
2265 const hasHCaptchaField = formFields.value.some((field) => field.type === 'hcaptcha')
2266
2267 if (hasHCaptchaField) {
2268 // Find hCaptcha field component ref
2269 const hcaptchaFieldRef = formFieldsCollectorRefs.value?.find(
2270 (ref: ComponentPublicInstance): ref is HCaptchaFieldComponent =>
2271 ref &&
2272 (ref as HCaptchaFieldComponent).$props &&
2273 (ref as HCaptchaFieldComponent).$props.field &&
2274 (ref as HCaptchaFieldComponent).$props.field.type === 'hcaptcha',
2275 )
2276
2277 if (hcaptchaFieldRef) {
2278 const fieldKey = `hcaptcha_${hcaptchaFieldRef.$props.field.fieldIndex}`
2279 const hcaptchaToken = formValues.value[fieldKey]
2280
2281 // Check if hCaptcha token exists
2282 if (!hcaptchaToken || hcaptchaToken.toString().trim() === '') {
2283 // Set error for the hCaptcha field
2284 fieldErrors.value = {
2285 ...fieldErrors.value,
2286 [fieldKey]: getLabel('hcaptcha_complete_verification'),
2287 }
2288 scrollToFirstInvalidField()
2289 isSubmitting.value = false
2290 return
2291 }
2292 }
2293 }
2294
2295 const duplicateOutcome = await validateDuplicatesBeforeProceed(undefined, {
2296 bypassCache: true,
2297 })
2298 if (duplicateOutcome !== 'ok') {
2299 handleDuplicateValidationFailure(duplicateOutcome)
2300 isSubmitting.value = false
2301 return
2302 }
2303
2304 await runBeforeFormSubmitHooks()
2305 } else {
2306 fieldErrors.value = {} // Clear previous
2307 Object.keys(errors).forEach((fieldKey) => {
2308 ;(fieldErrors.value as Record<string, string>)[fieldKey] =
2309 errors[fieldKey][0]?.message || getLabel('invalid_input')
2310 })
2311 scrollToFirstInvalidField()
2312 isSubmitting.value = false
2313 }
2314 },
2315 )
2316 }
2317
2318 const scrollToFirstInvalidField = () => {
2319 // Try to find the first field with error
2320 // Look for error classes or aria-invalid, or use the error keys
2321 nextTick(() => {
2322 // Try by error class (Element Plus/ivyforms)
2323 const invalidField = document.querySelector(
2324 '.ivyforms-form-field .is-error input, .ivyforms-form-field .is-error textarea, .ivyforms-form-field .is-error select',
2325 )
2326 if (invalidField && typeof invalidField.scrollIntoView === 'function') {
2327 invalidField.scrollIntoView({ behavior: 'smooth', block: 'center' })
2328 if (invalidField instanceof HTMLElement && typeof invalidField.focus === 'function') {
2329 invalidField.focus({ preventScroll: true })
2330 }
2331 }
2332 })
2333 }
2334
2335 const handleDuplicateValidationFailure = (outcome: 'duplicate' | 'request_failed'): void => {
2336 IvyMessage({
2337 message:
2338 outcome === 'duplicate'
2339 ? getLabel('duplicate_value_error')
2340 : getLabel('error_checking_duplicates'),
2341 type: 'error',
2342 })
2343 scrollToFirstInvalidField()
2344 }
2345
2346 // Clear error when user starts typing
2347 const clearFieldError = (fieldKey: string) => {
2348 if ((fieldErrors.value as Record<string, unknown>)[fieldKey]) {
2349 delete (fieldErrors.value as Record<string, unknown>)[fieldKey]
2350 }
2351 // Also clear parent key for name subfields like name_<index>.nameFieldX
2352 if (fieldKey.includes('.')) {
2353 const parentKey = fieldKey.split('.')[0]
2354 if ((fieldErrors.value as Record<string, unknown>)[parentKey]) {
2355 delete (fieldErrors.value as Record<string, unknown>)[parentKey]
2356 }
2357 }
2358 }
2359
2360 // Provide the clearFieldError function to child components
2361 provide('clearFieldError', clearFieldError)
2362 // Provide reactive form values so nested fields (e.g., email confirmation) can mutate the same model
2363 provide('formValues', formValues)
2364 // Provide horizontal gap for child components to use in their internal layouts
2365 provide('horizontalGap', horizontalGapCss)
2366 provide('formPopperScopeClass', formPopperScopeClass)
2367
2368 const resetForm = () => {
2369 skipValidation.value = true
2370 formValues.value = {}
2371 nextTick(() => {
2372 skipValidation.value = false
2373 })
2374 }
2375 const getFormSubmitData = (): FormSubmitData => {
2376 const processedValues = processWebsiteFieldValues(formFields.value, { ...formValues.value })
2377
2378 return {
2379 formId: formData.value.id,
2380 values: processedValues,
2381 nonce: formNonce.value,
2382 postId: postId.value,
2383 referer: refererUrl.value,
2384 }
2385 }
2386
2387 const runBeforeFormSubmitHooks = async () => {
2388 const hooks = api.hooks.beforeFormSubmit ?? []
2389 const submitData = getFormSubmitData()
2390 let submitted = false
2391
2392 const onError = (error: string) => {
2393 IvyMessage({
2394 type: 'error',
2395 message: getLabel('error_submitting_form') + error,
2396 })
2397 isSubmitting.value = false
2398 }
2399
2400 const guardedSubmit = async () => {
2401 if (submitted) {
2402 return
2403 }
2404 submitted = true
2405 await submitForm()
2406 }
2407
2408 if (hooks.length === 0) {
2409 await submitForm()
2410 return
2411 }
2412
2413 for (const hook of hooks) {
2414 try {
2415 await hook(guardedSubmit, onError, submitData)
2416 } catch (error) {
2417 console.error('[IvyForms] beforeFormSubmit hook error:', error)
2418 onError(error instanceof Error ? error.message : String(error))
2419 return
2420 }
2421 }
2422
2423 if (!submitted) {
2424 isSubmitting.value = false
2425 }
2426 }
2427
2428 // Submit form
2429 const submitForm = async () => {
2430 // Clear previous errors before each submit
2431 fieldErrors.value = {}
2432
2433 try {
2434 const { data, error, status } = await request('form/submission/', {
2435 method: 'POST',
2436 headers: {
2437 'Content-Type': 'application/json',
2438 },
2439 data: getFormSubmitData(),
2440 })
2441
2442 // Check for duplicate value error from backend (safety net after validate_before_submit)
2443 if (data?.data?.data?.is_duplicate) {
2444 applyDuplicateErrors(data?.data?.data?.duplicate_errors ?? {})
2445 handleDuplicateValidationFailure('duplicate')
2446 isSubmitting.value = false
2447 return
2448 }
2449
2450 // Handle response
2451 if (status !== 200 || error) {
2452 IvyMessage({
2453 type: 'error',
2454 message: getLabel('error_submitting_form') + (error ? error.message : data),
2455 })
2456 isSubmitting.value = false
2457 return
2458 }
2459
2460 // Show completed state briefly before hiding loader
2461 isSubmitCompleted.value = true
2462 await new Promise((resolve) => setTimeout(resolve, 600))
2463
2464 // Reset form or show success message
2465 resetForm()
2466
2467 isSubmitting.value = false
2468 isSubmitCompleted.value = false
2469
2470 const type = formConfirmation.value[0].type
2471 const enabled = formConfirmation.value[0].enabled
2472 const showForm = formConfirmation.value[0].showForm
2473
2474 if (!enabled) return
2475
2476 if (type === 'successMessage') {
2477 if (!showForm) {
2478 showSuccessMessage.value = true
2479 successMessage.value =
2480 data.data.data.confirmation || formConfirmation.value[0].message || getLabel('thank_you')
2481 return
2482 } else {
2483 showSuccessMessageReset.value = true
2484 successMessage.value =
2485 data.data.data.confirmation || formConfirmation.value[0].message || getLabel('thank_you')
2486
2487 // Hide the success message after 5 seconds
2488 setTimeout(() => {
2489 const messageElement = document.querySelector('.ivyforms-success-message')
2490 if (messageElement) {
2491 messageElement.classList.add('ivyforms-fade-out')
2492 }
2493 setTimeout(() => {
2494 showSuccessMessageReset.value = false
2495 }, 500) // Match the CSS transition duration
2496 }, 5000)
2497 }
2498 } else if (type === 'redirectToPage' && formConfirmation.value[0].pageUrl) {
2499 // Use pageUrl that was generated on the backend
2500 window.location.href = formConfirmation.value[0].pageUrl
2501 } else if (type === 'redirectToCustomUrl' && formConfirmation.value[0].url) {
2502 window.location.href = formConfirmation.value[0].url
2503 }
2504 } catch (error) {
2505 console.error(getLabel('error_submitting_form'), error)
2506 isSubmitting.value = false
2507 // Show error message
2508 }
2509 }
2510 </script>
2511
2512 <style lang="scss" scoped>
2513 .ivyforms-form-wrapper {
2514 width: 100%;
2515 max-width: 720px;
2516 margin: 0 auto;
2517 padding: 0 16px;
2518 box-sizing: border-box;
2519
2520 @media (max-width: 768px) {
2521 padding: 0 12px;
2522 max-width: 100%;
2523 }
2524
2525 .ivyforms-form-title {
2526 font-size: 24px;
2527 font-weight: 600;
2528 margin-bottom: 12px;
2529 color: var(--map-base-text-0);
2530 }
2531
2532 .ivyforms-form-description {
2533 font-size: 16px;
2534 margin-bottom: 24px;
2535 color: var(--map-base-text-1);
2536 }
2537
2538 // Progress indicator wrapper
2539 .ivyforms-form-render__progress-indicator-wrapper {
2540 margin-bottom: 24px;
2541
2542 // Hide settings button in public form (only needed in builder)
2543 :deep(.ivyforms-form-progress-indicator__settings) {
2544 display: none !important;
2545 }
2546 }
2547
2548 // Navigation wrapper - full width, margin top
2549 .ivyforms-form-render__navigation-wrapper {
2550 margin-top: 24px;
2551 width: 100%;
2552 }
2553
2554 // Fallback submit button wrapper
2555 .ivyforms-form-render__submit-wrapper {
2556 margin-top: 24px;
2557 display: flex;
2558 }
2559
2560 .ivyforms-form-row {
2561 max-width: 100%;
2562 box-sizing: border-box;
2563
2564 @media (max-width: 768px) {
2565 flex-direction: column;
2566 gap: 8px;
2567
2568 .ivyforms-form-field {
2569 width: 100% !important;
2570 max-width: 100% !important;
2571 flex: 1 1 100% !important;
2572 }
2573 }
2574 }
2575
2576 .ivyforms-form-field {
2577 min-width: 0;
2578 max-width: 100%;
2579 box-sizing: border-box;
2580
2581 :deep(.el-input),
2582 :deep(.el-select),
2583 :deep(.el-textarea),
2584 :deep(.el-date-picker),
2585 :deep(.ivyforms-input) {
2586 max-width: 100%;
2587 box-sizing: border-box;
2588 }
2589
2590 :deep(.el-input__wrapper) {
2591 max-width: 100%;
2592 box-sizing: border-box;
2593 }
2594 }
2595
2596 .ivyforms-field {
2597 padding: 12px 0;
2598 }
2599
2600 .ivyforms-form-submit {
2601 margin-top: 12px;
2602
2603 // Position modifiers
2604 &--default,
2605 &--left {
2606 justify-content: flex-start;
2607 }
2608
2609 &--center {
2610 justify-content: center;
2611 }
2612
2613 &--right {
2614 justify-content: flex-end;
2615 }
2616 }
2617
2618 ::v-deep(.ivyforms-success-message) {
2619 font-size: 20px;
2620 padding: 20px;
2621 border: 1px solid var(--map-base-brand-symbol-0);
2622 text-align: center;
2623 transition: opacity 0.5s ease-in-out;
2624 opacity: 1;
2625
2626 &.ivyforms-fade-out {
2627 opacity: 0;
2628 }
2629
2630 .ivyforms-safe-html {
2631 display: flex;
2632 flex-direction: column;
2633 align-items: center;
2634 justify-content: center;
2635
2636 img {
2637 max-width: 100%;
2638 height: auto;
2639 margin: 10px 0;
2640 }
2641 }
2642 }
2643 }
2644 </style>
2645