PluginProbe
The Innovative Form Builder – IvyForms / 0.6
The Innovative Form Builder – IvyForms v0.6
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 0.6, at frontend/src/views/public/FormRender.vue

1,784 lines 65.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 ref="formWrapperRef"
4 :class="['ivyforms-form-wrapper', 'ivyforms-form-wrapper-' + formData.id]"
5 >
6 <div v-if="showSuccessMessage" class="ivyforms-success-message">
7 <SafeHTML :html="successMessage || getLabel('thank_you')" field-type="confirmation" />
8 </div>
9 <!-- Form title-->
10 <template v-else>
11 <div v-if="showSuccessMessageReset" class="ivyforms-success-message ivyforms-mb-24">
12 <SafeHTML :html="successMessage || getLabel('thank_you')" field-type="confirmation" />
13 </div>
14
15 <h3
16 v-if="formData.showTitle"
17 :class="['ivyforms-form-title', 'ivyforms-form-title-' + formData.id]"
18 >
19 {{ formData.name }}
20 </h3>
21 <!-- Form description-->
22 <p
23 v-if="formData.showDescription"
24 :class="['ivyforms-form-description', 'ivyforms-form-description-' + formData.id]"
25 >
26 {{ formData.description }}
27 </p>
28
29 <IvyForm
30 :id="'ivyforms-form-' + formData.id"
31 ref="formRef"
32 :class="'ivyforms-form ivyforms-form-' + formData.id"
33 :data-form-id="formData.id"
34 :rules="formRules"
35 :model="formValues"
36 :label-position="getFormLabelPosition()"
37 @submit.prevent="submitForm"
38 >
39 <template v-for="field in visibleFields" :key="field.type + '_' + field.fieldIndex">
40 <component
41 :is="formFieldsTemplates[field.type]"
42 ref="formFieldsCollectorRefs"
43 v-model="formValues[field.type + '_' + field.fieldIndex]"
44 :field="field"
45 :error="fieldErrors[field.type + '_' + field.fieldIndex]"
46 :field-errors="fieldErrors"
47 :label-position="getFormLabelPosition()"
48 ></component>
49 </template>
50 <!-- Add hidden inputs fields for referer, post id ... -->
51 <div class="ivyforms-form-submit">
52 <IvyButtonAction
53 :id="'ivyforms-form-submit-button-' + formData.id"
54 priority="tertiary"
55 type="border"
56 @click.prevent.stop="validateAndSubmit"
57 >
58 {{ getLabel('submit') }}
59 </IvyButtonAction>
60 </div>
61 </IvyForm>
62 </template>
63 </div>
64 </template>
65
66 <script setup lang="ts">
67 import {
68 type Component,
69 type ComponentPublicInstance,
70 markRaw,
71 nextTick,
72 onMounted,
73 provide,
74 reactive,
75 ref,
76 watch,
77 computed,
78 } from 'vue'
79 import IvyForm from '@/views/_components/form/IvyForm.vue'
80 import IvyButtonAction from '@/views/_components/button/IvyButtonAction.vue'
81 import { useApiClient } from '@/composables/useApiClient'
82 import { useWcagColors } from '@/composables/useWcagColors'
83 // * Form Field templates
84 import IvyTextFormField from '../public/fields/TextFormField.vue'
85 import IvyParagraphFormField from '../public/fields/ParagraphFormField.vue'
86 import IvyEmailFormField from '../public/fields/EmailFormField.vue'
87 import IvyNumberFormField from '../public/fields/NumberFormField.vue'
88 import IvyPhoneFormField from '../public/fields/PhoneFormField.vue'
89 import IvyWebsiteFormField from '../public/fields/WebsiteFormField.vue'
90 import IvyRadioFormField from '../public/fields/RadioFormField.vue'
91 import NameFormField from '../public/fields/NameFormField.vue'
92 import RecaptchaFormField from '../public/fields/RecaptchaFormField.vue'
93 import IvyAddressFormField from '../public/fields/AddressFormField.vue'
94 import CheckboxFormField from '../public/fields/CheckboxFormField.vue'
95 import TimeFormField from '../public/fields/TimeFormField.vue'
96 import DateFormField from '../public/fields/DateFormField.vue'
97 import type { Field } from '@/types/field'
98 import type { Confirmation } from '@/types/confirmations'
99 import { useLabels } from '@/composables/useLabels'
100 import IvyMessage from '@/views/_components/message/ivyMessage.ts'
101 import utilRedirect from '@/utils/utilRedirects'
102 import { useRouter } from 'vue-router'
103 import SelectFormField from '@/views/public/fields/SelectFormField.vue'
104
105 // Initialize WCAG color system for public forms
106 const { startWatching } = useWcagColors()
107 startWatching()
108
109 // Global templates storage and hook setup
110 const globalTemplatesStore: Record<string, Component> = {}
111
112 // Hook to allow Pro plugin to register additional field components
113 const registerFieldTemplateGlobal = (fieldType: string, component: Component) => {
114 if (component && fieldType) {
115 const wrappedComponent = markRaw(component)
116 globalTemplatesStore[fieldType] = wrappedComponent
117
118 // Also update the reactive templates if available
119 const w = window as Window & {
120 _updateFormTemplates?: (fieldType: string, component: Component) => void
121 }
122 if (w._updateFormTemplates) {
123 w._updateFormTemplates(fieldType, wrappedComponent)
124 }
125
126 // Trigger a global event to notify any listening components
127 if (typeof window !== 'undefined') {
128 window.dispatchEvent(
129 new CustomEvent('ivyforms:template_registered', {
130 detail: { fieldType, component: wrappedComponent },
131 }),
132 )
133 }
134 }
135 }
136
137 // Set up global hook immediately
138 if (typeof window !== 'undefined') {
139 const w = window as Window & {
140 IvyForms?: {
141 api?: {
142 registerFieldTemplate?: (fieldType: string, component: Component) => void
143 fields?: {
144 registerPublic?: (fieldType: string, component: Component) => void
145 }
146 hooks?: {
147 registerFieldInit?: (
148 callback: (
149 field: Field,
150 context: {
151 formValues: Record<string, unknown>
152 formRules: Record<string, unknown>
153 getLabel: (key: string) => string
154 fieldErrors: Record<string, unknown>
155 },
156 ) => void,
157 ) => void
158 registerProFieldType?: (fieldType: string) => void
159 }
160 }
161 hooks?: {
162 fieldInit?: Array<
163 (
164 field: Field,
165 context: {
166 formValues: Record<string, unknown>
167 formRules: Record<string, unknown>
168 getLabel: (key: string) => string
169 fieldErrors: Record<string, unknown>
170 },
171 ) => void
172 >
173 }
174 _publicFieldQueue?: Array<{ fieldType: string; component: Component }>
175 _proFieldTypes?: Set<string>
176 }
177 }
178 // Initialize IvyForms globals with proper structure (types defined in global.d.ts)
179 // Note: Only minimal public API is initialized here, full API is in admin context
180 // These objects are populated dynamically at runtime, so we cast through 'unknown'
181 // which is type-safe since they will be fully initialized before use
182 if (!w.IvyForms) {
183 w.IvyForms = {
184 api: {
185 fields: { admin: {}, public: {} },
186 hooks: { actions: {}, filters: {} },
187 fieldOptions: { registry: {} },
188 },
189 hooks: {
190 actions: {},
191 filters: {},
192 fieldInit: [],
193 },
194 fields: {},
195 } as unknown as typeof w.IvyForms
196 }
197 if (!w.IvyForms.api) {
198 w.IvyForms.api = {
199 fields: { admin: {}, public: {} },
200 hooks: { actions: {}, filters: {} },
201 fieldOptions: { registry: {} },
202 } as unknown as typeof w.IvyForms.api
203 }
204 if (!w.IvyForms.api.fields) {
205 w.IvyForms.api.fields = {
206 admin: {},
207 public: {},
208 } as unknown as typeof w.IvyForms.api.fields
209 }
210
211 // Make it available globally
212 w.IvyForms.api.fields.registerPublic = registerFieldTemplateGlobal
213
214 // Process any queued registrations
215 const queue = w.IvyForms._publicFieldQueue || []
216 queue.forEach(({ fieldType, component }) => registerFieldTemplateGlobal(fieldType, component))
217
218 // Set up hooks system
219 if (!w.IvyForms.hooks) {
220 w.IvyForms.hooks = {
221 actions: {},
222 filters: {},
223 fieldInit: [],
224 } as unknown as typeof w.IvyForms.hooks
225 }
226 if (!w.IvyForms.hooks.fieldInit) {
227 w.IvyForms.hooks.fieldInit = []
228 }
229
230 // Initialize Pro field types registry
231 if (!w.IvyForms._proFieldTypes) {
232 w.IvyForms._proFieldTypes = new Set<string>()
233 }
234
235 // Add API to register field initialization hooks
236 if (!w.IvyForms.api.hooks) {
237 w.IvyForms.api.hooks = {
238 actions: {},
239 filters: {},
240 } as unknown as typeof w.IvyForms.api.hooks
241 }
242 w.IvyForms.api.hooks.registerFieldInit = (
243 callback: (
244 field: Field,
245 context: {
246 formValues: Record<string, unknown>
247 formRules: Record<string, unknown>
248 getLabel: (key: string) => string
249 fieldErrors: Record<string, unknown>
250 },
251 ) => void,
252 ) => {
253 if (typeof callback === 'function') {
254 w.IvyForms!.hooks!.fieldInit!.push(callback)
255 }
256 }
257
258 // Add API to register Pro field types (fields that skip Lite validation)
259 w.IvyForms.api.hooks.registerProFieldType = (fieldType: string) => {
260 if (fieldType && typeof fieldType === 'string') {
261 w.IvyForms!._proFieldTypes!.add(fieldType)
262 }
263 }
264 }
265
266 interface RecaptchaFieldComponent extends ComponentPublicInstance {
267 $props: {
268 field: {
269 type: string
270 fieldIndex: number
271 }
272 }
273 executeRecaptcha?: () => Promise<string>
274 }
275
276 const { getLabel } = useLabels()
277 const { request } = useApiClient()
278 const { redirectToUrl } = utilRedirect()
279 const router = useRouter()
280 const skipValidation = ref(false)
281
282 // Helper: normalize backend fields for preview (merge Name children into parent)
283 function normalizePreviewFields(rawFields: Field[]): Field[] {
284 if (!Array.isArray(rawFields) || rawFields.length === 0) return []
285
286 // Preserve original order index to stably order children without subFieldIndex
287 const apiOrder = new Map<Field, number>()
288 rawFields.forEach((f, i) => apiOrder.set(f, i))
289
290 const isChild = (f: Field) => f.parentId !== undefined && f.parentId !== null
291 const parents = rawFields.filter((f) => !isChild(f))
292
293 const result: Field[] = []
294
295 for (const parent of parents) {
296 // Collect children by matching parentId to parent.id or parent.fieldIndex
297 const children = rawFields.filter(
298 (c) =>
299 isChild(c) &&
300 (c.parentId === (parent as Field).id || c.parentId === (parent as Field).fieldIndex),
301 )
302
303 // Propagate common settings to top-level field if provided
304 const pSettings = (parent.settings || {}) as Record<string, unknown>
305 if (pSettings) {
306 if (parent.hideLabel === undefined && typeof pSettings.hideLabel !== 'undefined') {
307 ;(parent as Record<string, unknown>).hideLabel = !!pSettings.hideLabel
308 }
309 if (parent.readOnly === undefined && typeof pSettings.readOnly !== 'undefined') {
310 ;(parent as Record<string, unknown>).readOnly = !!(pSettings.readOnly as boolean)
311 }
312 if (
313 (parent as Record<string, unknown>).description === undefined &&
314 typeof pSettings.description !== 'undefined'
315 ) {
316 ;(parent as Record<string, unknown>).description = String(pSettings.description || '')
317 }
318 if (
319 (parent as Record<string, unknown>).cssClasses === undefined &&
320 typeof pSettings.cssClasses !== 'undefined'
321 ) {
322 ;(parent as Record<string, unknown>).cssClasses = String(pSettings.cssClasses || '')
323 }
324 }
325
326 // Apply Pro filters to parse field (e.g., conditional logic parser)
327 let processedParent = parent
328 if (window.IvyForms?.hooks?.applyFilters) {
329 processedParent = window.IvyForms.hooks.applyFilters(
330 'ivyforms/form-render/filter_parse_field',
331 parent,
332 ) as Field
333 }
334
335 // Use processedParent for rest of function
336 const parentField = processedParent
337
338 // Merge children for name and address fields
339 if (children.length >= 2 || parentField.type === 'name' || parentField.type === 'address') {
340 // Sort children: first those with explicit subFieldIndex, then the rest by API order
341 const ordered = [...children].sort((a, b) => {
342 const as = ((a.settings || {}) as Record<string, unknown>).subFieldIndex
343 const bs = ((b.settings || {}) as Record<string, unknown>).subFieldIndex
344 const aHas = typeof as === 'number'
345 const bHas = typeof bs === 'number'
346 if (aHas && bHas) return (as as number) - (bs as number)
347 if (aHas) return -1
348 if (bHas) return 1
349 return (apiOrder.get(a) || 0) - (apiOrder.get(b) || 0)
350 })
351
352 if (parentField.type === 'name') {
353 // Merge as name field
354 const nameFieldTypes: string[] = []
355 ordered.forEach((child, idx) => {
356 const key = `nameField${idx + 1}`
357 nameFieldTypes.push(key)
358 const cs = (child.settings || {}) as Record<string, unknown>
359 const prec = parentField as unknown as Record<string, unknown>
360 prec[`${key}Label`] = (child.label as unknown) || ''
361 prec[`${key}Placeholder`] = (child.placeholder as unknown) || ''
362 prec[`${key}Required`] = !!child.required
363 // Required message: prefer settings.requiredMessage, fallback to legacy top-level
364 if (typeof cs.requiredMessage !== 'undefined') {
365 prec[`${key}RequiredMessage`] = cs.requiredMessage
366 } else if (typeof (child as Record<string, unknown>).requiredMessage !== 'undefined') {
367 prec[`${key}RequiredMessage`] = (child as Record<string, unknown>).requiredMessage
368 }
369 // Default value
370 if (typeof child.defaultValue !== 'undefined') {
371 prec[`${key}Value`] = child.defaultValue as unknown
372 }
373 // Hide sublabel (support both settings.hideLabel and legacy child.hideLabel)
374 const childHasSettingsHide = typeof cs.hideLabel !== 'undefined'
375 const childHasTopLevelHide =
376 typeof (child as Record<string, unknown>).hideLabel !== 'undefined'
377 if (childHasSettingsHide || childHasTopLevelHide) {
378 const childHideLabel = childHasSettingsHide
379 ? !!(cs.hideLabel as boolean)
380 : !!((child as Record<string, unknown>).hideLabel as boolean)
381 prec[`${key}HideLabel`] = childHideLabel
382 }
383 // Subfield description
384 if (typeof (child as Record<string, unknown>).description !== 'undefined') {
385 prec[`${key}Description`] = String(
386 ((child as Record<string, unknown>).description as string) || '',
387 )
388 }
389 // Visibility
390 if (typeof (child as Record<string, unknown>).description !== 'undefined') {
391 prec[`${key}Visible`] = String(
392 ((child as Record<string, unknown>).visible as boolean) || true,
393 )
394 }
395 })
396 ;(parentField as Record<string, unknown>).nameFieldTypes = nameFieldTypes
397 parentField.type = 'name'
398 } else if (parentField.type === 'address') {
399 // Merge as address field
400 // Use canonical order for address fields (must match IvyAddressFormField.vue and builder)
401 const canonicalOrder = ['streetAddress', 'addressLine2', 'city', 'state', 'zip', 'country']
402 const addressFieldTypes = []
403 // If there are NO children, still populate all 6 subfields on the parent
404 if (ordered.length === 0) {
405 canonicalOrder.forEach((fieldType) => {
406 addressFieldTypes.push(fieldType)
407 const prec = parentField as unknown as Record<string, unknown>
408 prec[`${fieldType}Label`] =
409 (parentField as Record<string, unknown>)[`${fieldType}Label`] || ''
410 prec[`${fieldType}Placeholder`] =
411 (parentField as Record<string, unknown>)[`${fieldType}Placeholder`] || ''
412 prec[`${fieldType}Required`] =
413 (parentField as Record<string, unknown>)[`${fieldType}Required`] || false
414 prec[`${fieldType}RequiredMessage`] =
415 (parentField as Record<string, unknown>)[`${fieldType}RequiredMessage`] || ''
416 prec[`${fieldType}Value`] =
417 (parentField as Record<string, unknown>)[`${fieldType}Value`] || ''
418 prec[`${fieldType}HideLabel`] =
419 (parentField as Record<string, unknown>)[`${fieldType}HideLabel`] || false
420 prec[`${fieldType}Description`] =
421 (parentField as Record<string, unknown>)[`${fieldType}Description`] || ''
422 prec[`${fieldType}Visible`] =
423 (parentField as Record<string, unknown>)[`${fieldType}Visible`] || true
424 })
425 ;(parentField as Record<string, unknown>).addressFieldTypes = canonicalOrder
426 parentField.type = 'address'
427 } else {
428 canonicalOrder.forEach((fieldType, idx) => {
429 // Find child by settings.fieldType, or by label (case-insensitive, spaces removed), or fallback to ordered[idx] if exists
430 let child =
431 ordered.find((c) => {
432 const settings = parseSettings((c as unknown as Record<string, unknown>).settings)
433 return (
434 (settings && settings.fieldType === fieldType) ||
435 (c.label && c.label.toLowerCase().replace(/\s/g, '') === fieldType.toLowerCase())
436 )
437 }) || ordered.find((c) => c.type === fieldType)
438 // Only fallback to ordered[idx] if it exists and is not undefined
439 if (!child && Array.isArray(ordered) && ordered.length > idx && ordered[idx]) {
440 child = ordered[idx]
441 }
442 addressFieldTypes.push(fieldType)
443 const cs = child && child.settings ? parseSettings(child.settings) : {}
444 const prec = parentField as unknown as Record<string, unknown>
445 prec[`${fieldType}Label`] = child ? (child.label as unknown) || '' : ''
446 prec[`${fieldType}Placeholder`] = child ? (child.placeholder as unknown) || '' : ''
447 prec[`${fieldType}Required`] = child ? !!child.required : false
448 if (child && typeof cs.requiredMessage !== 'undefined') {
449 prec[`${fieldType}RequiredMessage`] = cs.requiredMessage
450 } else if (
451 child &&
452 typeof (child as Record<string, unknown>).requiredMessage !== 'undefined'
453 ) {
454 prec[`${fieldType}RequiredMessage`] = (
455 child as Record<string, unknown>
456 ).requiredMessage
457 } else {
458 prec[`${fieldType}RequiredMessage`] = ''
459 }
460 if (child && typeof child.defaultValue !== 'undefined') {
461 prec[`${fieldType}Value`] = child.defaultValue as unknown
462 } else {
463 prec[`${fieldType}Value`] = ''
464 }
465 const childHasSettingsHide = child && typeof cs.hideLabel !== 'undefined'
466 const childHasTopLevelHide =
467 child && typeof (child as Record<string, unknown>).hideLabel !== 'undefined'
468 if (childHasSettingsHide || childHasTopLevelHide) {
469 const childHideLabel = childHasSettingsHide
470 ? !!(cs.hideLabel as boolean)
471 : !!((child as Record<string, unknown>).hideLabel as boolean)
472 prec[`${fieldType}HideLabel`] = childHideLabel
473 } else {
474 prec[`${fieldType}HideLabel`] = false
475 }
476 if (child && typeof (child as Record<string, unknown>).description !== 'undefined') {
477 prec[`${fieldType}Description`] = String(
478 ((child as Record<string, unknown>).description as string) || '',
479 )
480 } else {
481 prec[`${fieldType}Description`] = ''
482 }
483 if (child && typeof (child as Record<string, unknown>).visible !== 'undefined') {
484 prec[`${fieldType}Visible`] = !!((child as Record<string, unknown>)
485 .visible as boolean)
486 } else {
487 prec[`${fieldType}Visible`] = true
488 }
489 })
490 ;(parentField as Record<string, unknown>).addressFieldTypes = canonicalOrder
491 parentField.type = 'address'
492 }
493 }
494 }
495
496 result.push(parentField)
497 }
498
499 return result
500 }
501
502 // Safely parse settings which may be stored as JSON string or as an object
503 const parseSettings = (s: unknown): Record<string, unknown> => {
504 if (!s) return {}
505 if (typeof s === 'string') {
506 try {
507 return JSON.parse(s) as Record<string, unknown>
508 } catch {
509 return {}
510 }
511 }
512 if (typeof s === 'object') {
513 return s as Record<string, unknown>
514 }
515 return {}
516 }
517
518 // Form data
519 const formFields = ref([])
520 const formValues = ref({})
521 const formRules = ref({})
522 const fieldErrors = ref({})
523 const isLoading = ref(true)
524 const formRef = ref(null)
525 const formWrapperRef = ref(null)
526 const formFieldsCollectorRefs = ref([])
527 const formNonce = ref(null)
528 const formConfirmation = ref([])
529 const showSuccessMessage = ref(false)
530 const showSuccessMessageReset = ref(false)
531 const successMessage = ref('')
532 const postId = ref('')
533 const refererUrl = ref('')
534
535 // Computed property to filter visible fields
536 // Hook: ivyforms_fields_visible can be used by Pro to add conditional logic filtering
537 const visibleFields = computed(() => {
538 if (!formFields.value || formFields.value.length === 0) {
539 return []
540 }
541
542 // Allow Pro plugin to filter fields (e.g., conditional logic)
543 let fields = [...formFields.value]
544
545 if (window.IvyForms?.hooks?.applyFilters) {
546 fields = window.IvyForms.hooks.applyFilters(
547 'ivyforms/form-render/filter_fields_visible',
548 fields,
549 formValues.value,
550 )
551 }
552
553 return fields
554 })
555
556 interface FormData {
557 id: string | number
558 name?: string
559 description?: string
560 showTitle?: boolean
561 showDescription?: boolean
562 storeEntries?: boolean
563 defaultValue?: string
564 counter?: number
565 fields?: Field[]
566 confirmations?: Confirmation[]
567 }
568
569 // Define the shape of entries provided by the global window variable
570 interface GlobalFormEntry {
571 id: string | number
572 form: FormData
573 nonce: string
574 fields: Field[]
575 confirmations: Confirmation[]
576 }
577
578 // Update your refs with proper typing
579 const formData = ref<FormData>({
580 id: '',
581 name: '',
582 description: '',
583 showTitle: true,
584 showDescription: false,
585 storeEntries: true,
586 defaultValue: '',
587 counter: 0,
588 fields: [],
589 confirmations: [],
590 })
591 // * Form Fields Object - start with base templates then add global ones
592 const baseTemplates = {
593 text: markRaw(IvyTextFormField),
594 email: markRaw(IvyEmailFormField),
595 textarea: markRaw(IvyParagraphFormField),
596 number: markRaw(IvyNumberFormField),
597 phone: markRaw(IvyPhoneFormField),
598 website: markRaw(IvyWebsiteFormField),
599 time: markRaw(TimeFormField),
600 date: markRaw(DateFormField),
601 recaptcha: markRaw(RecaptchaFormField),
602 radio: markRaw(IvyRadioFormField),
603 select: markRaw(SelectFormField),
604 name: markRaw(NameFormField),
605 address: markRaw(IvyAddressFormField),
606 checkbox: markRaw(CheckboxFormField),
607 'multi-select': markRaw(SelectFormField),
608 }
609
610 // Create reactive templates and manually add global store items
611 const formFieldsTemplates = reactive({ ...baseTemplates })
612 // Add any existing global templates
613 Object.entries(globalTemplatesStore).forEach(([fieldType, component]) => {
614 formFieldsTemplates[fieldType] = component
615 })
616 // Force reactivity trigger
617 const templateUpdateTrigger = ref(0)
618 // Set up update function for late registrations
619 if (typeof window !== 'undefined') {
620 ;(
621 window as Window & { _updateFormTemplates?: (fieldType: string, component: Component) => void }
622 )._updateFormTemplates = (fieldType: string, component: Component) => {
623 formFieldsTemplates[fieldType] = component
624 templateUpdateTrigger.value++ // Force Vue reactivity
625 }
626
627 // Listen for template registration events
628 window.addEventListener('ivyforms:template_registered', (event: Event) => {
629 const customEvent = event as CustomEvent<{ fieldType: string; component: Component }>
630 const { fieldType, component } = customEvent.detail
631 formFieldsTemplates[fieldType] = component
632 templateUpdateTrigger.value++ // Force Vue reactivity
633 })
634
635 // Also check if there are already registered templates in global store
636 Object.entries(globalTemplatesStore).forEach(([fieldType, component]) => {
637 formFieldsTemplates[fieldType] = component
638 })
639 }
640
641 // Initialize from global variable
642 onMounted(async () => {
643 const w = window as unknown as { wpIvyFormDataList?: Record<string, GlobalFormEntry> }
644 if (typeof window !== 'undefined' && w.wpIvyFormDataList) {
645 const counter = (formWrapperRef.value as HTMLElement)
646 .closest('.ivyforms-frontend')!
647 .getAttribute('data-counter') as string
648 const formsData = w.wpIvyFormDataList[counter]
649 // Check if formData is an object and has the required properties
650 if (typeof formsData === 'object' && formsData !== null && formsData.id) {
651 // Assign the form data to the reactive variable
652 formData.value = formsData.form
653 formNonce.value = formsData.nonce
654 // Normalize fields for preview
655 formFields.value = normalizePreviewFields(formsData.fields || [])
656 formConfirmation.value = formsData.confirmations || []
657
658 // Wait for Pro plugins to register their hooks before initializing fields
659 // This ensures all extensions can add field-specific initialization logic
660 await new Promise<void>((resolve) => {
661 if (typeof window !== 'undefined') {
662 // Check if Pro is ready
663 const wExt = window as Window & { IvyFormsProReady?: boolean }
664 if (wExt.IvyFormsProReady) {
665 resolve()
666 return
667 }
668
669 // Wait for Pro ready event
670 const handler = () => {
671 resolve()
672 window.removeEventListener('ivyforms:pro_ready', handler)
673 }
674 window.addEventListener('ivyforms:pro_ready', handler, { once: true })
675
676 // Fallback timeout - initialize after 100ms even if Pro doesn't signal ready
677 setTimeout(() => {
678 window.removeEventListener('ivyforms:pro_ready', handler)
679 resolve()
680 }, 100)
681 } else {
682 resolve()
683 }
684 })
685
686 // �
687 NOW initialize fields AFTER Pro is ready
688 // This ensures visibleFields computed will apply conditional logic hooks
689 formFields.value = normalizePreviewFields(formsData.fields || [])
690
691 // Initialize form values with defaults or empty strings
692 formFields.value.forEach((field) => {
693 const key = field.type + '_' + field.fieldIndex
694
695 // Special handling for name field type
696 if (field.type === 'name') {
697 const nameValue: Record<string, string> = {}
698 const nameFieldTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
699 // Initialize each dynamic name field
700 nameFieldTypes.forEach((fieldType) => {
701 nameValue[fieldType] = (field as Record<string, string>)[`${fieldType}Value`] || ''
702 })
703 formValues.value[key] = nameValue
704 } else if (field.type === 'address') {
705 // initialize all Address field subfields
706 const addressValue: Record<string, string> = {}
707 const addressFieldTypes = field.addressFieldTypes || [
708 'streetAddress',
709 'addressLine2',
710 'city',
711 'state',
712 'zip',
713 'country',
714 ]
715 addressFieldTypes.forEach((fieldType) => {
716 addressValue[fieldType] = (field as Record<string, string>)[`${fieldType}Value`] || ''
717 })
718 formValues.value[key] = addressValue
719 } else if (field.type === 'checkbox' && Array.isArray(field.fieldOptions)) {
720 const defaultOpts = field.fieldOptions
721 .filter((opt) => opt.isDefault)
722 .map((opt) => opt.value)
723 formValues.value[key] = defaultOpts.length ? defaultOpts : undefined
724 } else if (
725 (field.type === 'radio' || field.type === 'select') &&
726 Array.isArray(field.fieldOptions)
727 ) {
728 const defaultOpt = field.fieldOptions.find((opt) => opt.isDefault)
729 formValues.value[key] = defaultOpt ? defaultOpt.value : ''
730 } else if (field.type === 'multi-select' && Array.isArray(field.fieldOptions)) {
731 const defaultOpts = field.fieldOptions
732 .filter((opt) => opt.isDefault)
733 .map((opt) => opt.value)
734 formValues.value[key] = defaultOpts.length ? defaultOpts : undefined
735 } else if (field.type === 'website') {
736 // Handle website fields with prefix and suffix
737 let websiteValue = field.defaultValue || ''
738 const prefix = field.inputPrefix || ''
739 const suffix = field.inputSuffix || ''
740
741 // If there's a defaultValue, strip any existing prefix/suffix first
742 if (websiteValue && (prefix || suffix)) {
743 // Remove prefix if it exists
744 if (prefix && websiteValue.startsWith(prefix)) {
745 websiteValue = websiteValue.substring(prefix.length)
746 }
747 // Remove suffix if it exists
748 if (suffix && websiteValue.endsWith(suffix)) {
749 websiteValue = websiteValue.substring(0, websiteValue.length - suffix.length)
750 }
751 }
752
753 formValues.value[key] = websiteValue
754 } else if (field.type === 'date') {
755 // Sanitize date default values - filter out invalid dates with MIN_SAFE_INTEGER
756 let dateValue = field.defaultValue || ''
757 if (
758 typeof dateValue === 'string' &&
759 (dateValue.includes('-9007199254740991') || dateValue.includes('MIN_SAFE_INTEGER'))
760 ) {
761 dateValue = ''
762 }
763 formValues.value[key] = dateValue
764 } else {
765 formValues.value[key] = field.defaultValue || ''
766 }
767
768 if (field.type === 'name') {
769 const nameFieldTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
770 nameFieldTypes.forEach((nfType: string) => {
771 const subKey = `${field.type}_${field.fieldIndex}.${nfType}`
772 const label =
773 (field as Record<string, string>)[`${nfType}Label`] ||
774 getLabel(`name_field_${nfType.replace('nameField', '')}`)
775 const rulesForSub: unknown[] = []
776 if ((field as Record<string, unknown>)[`${nfType}Required`]) {
777 const customMsg = (field as Record<string, unknown>)[`${nfType}RequiredMessage`]
778 const message =
779 typeof customMsg === 'string' && (customMsg as string).trim() !== ''
780 ? (customMsg as string)
781 : `${label} ${getLabel('required')}`
782 rulesForSub.push({
783 required: true,
784 message,
785 trigger: ['submit'],
786 })
787 }
788 ;(formRules.value as Record<string, unknown>)[subKey] = rulesForSub
789 })
790 } else if (field.type === 'address') {
791 const addressFieldTypes = field.addressFieldTypes || [
792 'streetAddress',
793 'addressLine2',
794 'city',
795 'state',
796 'zip',
797 'country',
798 ]
799 addressFieldTypes.forEach((afType: string) => {
800 const subKey = `${field.type}_${field.fieldIndex}.${afType}`
801 const label =
802 (field as Record<string, string>)[`${afType}Label`] ||
803 getLabel(`address_field_${afType.replace('addressField', '')}`)
804 const rulesForSub: unknown[] = []
805 if ((field as Record<string, unknown>)[`${afType}Required`]) {
806 const customMsg = (field as Record<string, unknown>)[`${afType}RequiredMessage`]
807 const message =
808 typeof customMsg === 'string' && (customMsg as string).trim() !== ''
809 ? (customMsg as string)
810 : `${label} ${getLabel('required')}`
811 rulesForSub.push({
812 required: true,
813 message,
814 trigger: ['submit'],
815 })
816 }
817 ;(formRules.value as Record<string, unknown>)[subKey] = rulesForSub
818 })
819 } else {
820 const rules = []
821
822 // Check if this field type is registered as a Pro field
823 // Pro fields handle their own validation via fieldInit hooks
824 const w = window as Window & { IvyForms?: { _proFieldTypes?: Set<string> } }
825 const isProField = w.IvyForms?._proFieldTypes?.has(field.type) || false
826
827 // Required rule (skip Pro fields - Pro handles them via fieldInit hooks)
828 if (field.required && !isProField) {
829 rules.push({
830 required: true,
831 message: getRequiredMessage(field),
832 trigger: ['submit'],
833 })
834 }
835
836 // Format validations
837 if (field.type === 'email') {
838 rules.push({
839 validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
840 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
841 if (skipValidation.value) {
842 callback()
843 return
844 }
845 if (!value) {
846 if (field.required) {
847 callback(new Error(getLabel('enter_email_address')))
848 } else {
849 callback()
850 }
851 return
852 }
853 if (!emailRegex.test(value)) {
854 callback(new Error(getLabel('enter_email_address')))
855 return
856 }
857 callback()
858 },
859 trigger: ['submit'],
860 })
861 }
862
863 if (field.type === 'phone') {
864 rules.push({
865 pattern: /^[+\d]?(?:[\d\s.-]{7,15})$/,
866 message: getLabel('enter_phone_number'),
867 trigger: ['submit'],
868 })
869 }
870
871 if (field.type === 'website') {
872 rules.push({
873 validator: (_rule: unknown, value: unknown, callback: (err?: Error) => void) => {
874 // Check if field is required and empty
875 if (field.required && (value === '' || value === null || value === undefined)) {
876 return callback(new Error(getRequiredMessage(field)))
877 }
878 if (value === '' || value === null || value === undefined) {
879 return callback()
880 }
881
882 let urlToValidate = String(value)
883
884 // If field has prefix and/or suffix, construct the full URL for validation
885 const prefix = field.inputPrefix || ''
886 const suffix = field.inputSuffix || ''
887
888 if (prefix || suffix) {
889 // User entered only the middle part, construct full URL
890 urlToValidate = prefix + urlToValidate + suffix
891 }
892
893 // Validate the complete URL
894 const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/i
895 if (!urlPattern.test(urlToValidate)) {
896 return callback(new Error(getLabel('enter_valid_website')))
897 }
898
899 callback()
900 },
901 trigger: ['submit'],
902 })
903 }
904
905 if (field.type === 'time') {
906 rules.push({
907 validator: (_: unknown, value: string, callback: (e?: Error) => void) => {
908 // Trim spaces before validation
909 const trimmedValue = typeof value === 'string' ? value.trim() : value
910 const isEmpty = trimmedValue == null || trimmedValue === ''
911 const isPartial = trimmedValue === '__PARTIAL__'
912 let validPattern = false
913 let errorMsg = ''
914
915 // Special handling for dropdown mode - check if all parts are selected
916 if (field.timeFieldType === 'dropdown') {
917 if (field.required && (isEmpty || isPartial)) {
918 return callback(new Error(getRequiredMessage(field)))
919 }
920 if (isPartial) {
921 return callback(new Error(getLabel('enter_valid_time')))
922 }
923 if (!isEmpty) {
924 let hasValidFormat = false
925 if (field.timeFormat === 'ampm') {
926 hasValidFormat = /^(0?[1-9]|1[0-2]):[0-5]\d ?([AP]M|[ap]m)$/.test(
927 trimmedValue,
928 )
929 } else {
930 hasValidFormat = /^(?:[01]?\d|2[0-3]):[0-5]\d$/.test(trimmedValue)
931 }
932 if (!hasValidFormat) {
933 return callback(new Error(getLabel('enter_valid_time')))
934 }
935 }
936 return callback()
937 }
938
939 // For time-picker and input modes, use pattern validation
940 if (field.timeFieldType === 'time-picker') {
941 validPattern =
942 isEmpty ||
943 /^(?:[01]\d|2[0-3]):[0-5]\d(?:\s?(?:AM|PM|am|pm))?$/.test(trimmedValue)
944 errorMsg = getLabel('enter_valid_time')
945 } else if (field.timeFormat === 'ampm') {
946 validPattern = /^(0?[1-9]|1[0-2]):[0-5]\d (AM|PM|am|pm)$/.test(trimmedValue)
947 errorMsg = getLabel('enter_valid_time')
948 } else {
949 validPattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(trimmedValue)
950 errorMsg = getLabel('enter_valid_time')
951 }
952 if (field.required && isEmpty) {
953 return callback(new Error(getRequiredMessage(field)))
954 }
955 if (!isEmpty && !validPattern) {
956 return callback(new Error(errorMsg))
957 }
958 return callback()
959 },
960 trigger: ['submit'],
961 })
962 }
963
964 if (field.type === 'date') {
965 rules.push({
966 validator: (_: unknown, value: string, callback: (e?: Error) => void) => {
967 // Trim spaces before validation
968 const trimmedValue = typeof value === 'string' ? value.trim() : value
969 const isEmpty = trimmedValue == null || trimmedValue === ''
970 const isPartial = trimmedValue === '__PARTIAL__'
971
972 // Special handling for dropdown mode - check if all parts are selected
973 if (field.dateFieldType === 'dropdown') {
974 if (field.required && (isEmpty || isPartial)) {
975 return callback(new Error(getRequiredMessage(field)))
976 }
977 if (isPartial) {
978 return callback(new Error(getLabel('enter_valid_date')))
979 }
980 // If not empty and not partial in dropdown mode, it's valid (dropdown ensures correct format)
981 if (!isEmpty) {
982 return callback()
983 }
984 return callback()
985 }
986
987 // Check required
988 if (field.required && isEmpty) {
989 return callback(new Error(getRequiredMessage(field)))
990 }
991
992 // If empty and not required, allow it
993 if (isEmpty) {
994 return callback()
995 }
996
997 // Validate date format based on field.dateFormat (Day.js format)
998 const dateFormat = (field.dateFormat as string) || 'MM/DD/YYYY'
999 let isValidDate = false
1000
1001 if (dateFormat === 'MM/DD/YYYY' || dateFormat === 'M/D/YYYY') {
1002 isValidDate = /^(0?[1-9]|1[0-2])\/(0?[1-9]|[12]\d|3[01])\/\d{4}$/.test(
1003 trimmedValue,
1004 )
1005 } else if (dateFormat === 'DD/MM/YYYY' || dateFormat === 'D/M/YYYY') {
1006 isValidDate = /^(0?[1-9]|[12]\d|3[01])\/(0?[1-9]|1[0-2])\/\d{4}$/.test(
1007 trimmedValue,
1008 )
1009 } else if (dateFormat === 'YYYY-MM-DD' || dateFormat === 'YYYY-M-D') {
1010 isValidDate = /^\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[12]\d|3[01])$/.test(trimmedValue)
1011 } else if (dateFormat === 'DD.MM.YYYY' || dateFormat === 'D.M.YYYY') {
1012 isValidDate = /^(0?[1-9]|[12]\d|3[01])\.(0?[1-9]|1[0-2])\.\d{4}$/.test(
1013 trimmedValue,
1014 )
1015 } else {
1016 // Default to MM/DD/YYYY
1017 isValidDate = /^(0?[1-9]|1[0-2])\/(0?[1-9]|[12]\d|3[01])\/\d{4}$/.test(
1018 trimmedValue,
1019 )
1020 }
1021
1022 if (!isValidDate) {
1023 return callback(new Error(getLabel('enter_date_valid_format')))
1024 }
1025
1026 // Validate min/max date range
1027 const hasMin =
1028 field.minDateValue !== null &&
1029 field.minDateValue !== undefined &&
1030 field.minDateValue !== ''
1031 const hasMax =
1032 field.maxDateValue !== null &&
1033 field.maxDateValue !== undefined &&
1034 field.maxDateValue !== ''
1035
1036 if (hasMin || hasMax) {
1037 const currentDate = parseDateToObject(trimmedValue, dateFormat)
1038 if (!currentDate) {
1039 return callback(new Error(getLabel('enter_valid_date')))
1040 }
1041
1042 const minDate = hasMin
1043 ? parseDateToObject(String(field.minDateValue), dateFormat)
1044 : null
1045 const maxDate = hasMax
1046 ? parseDateToObject(String(field.maxDateValue), dateFormat)
1047 : null
1048
1049 // Check if date is before minimum
1050 if (minDate && currentDate < minDate) {
1051 const message = getLabel('date_must_be_after')
1052 return callback(new Error(message.replace('{min}', String(field.minDateValue))))
1053 }
1054
1055 // Check if date is after maximum
1056 if (maxDate && currentDate > maxDate) {
1057 const message = getLabel('date_must_be_before')
1058 return callback(new Error(message.replace('{max}', String(field.maxDateValue))))
1059 }
1060 }
1061
1062 return callback()
1063 },
1064 trigger: ['submit'],
1065 })
1066 }
1067
1068 if (field.type === 'number' && (field.minValue !== null || field.maxValue !== null)) {
1069 rules.push({
1070 validator: (_rule: unknown, value: unknown, callback: (err?: Error) => void) => {
1071 if (value === '' || value === null || value === undefined) {
1072 return callback()
1073 }
1074 const num = Number(value)
1075 if (isNaN(num)) {
1076 return callback(new Error(getLabel('enter_valid_number')))
1077 }
1078 const hasMin = field.minValue !== null && field.minValue !== undefined
1079 const hasMax = field.maxValue !== null && field.maxValue !== undefined
1080 if (hasMin && num < field.minValue) {
1081 if (hasMax) {
1082 const tpl = getLabel('number_out_of_range')
1083 return callback(
1084 new Error(
1085 tpl
1086 .replace('{min}', String(field.minValue))
1087 .replace('{max}', String(field.maxValue)),
1088 ),
1089 )
1090 }
1091 const tpl = getLabel('number_cannot_be_smaller_than')
1092 return callback(new Error(tpl.replace('{min}', String(field.minValue))))
1093 }
1094 if (hasMax && num > field.maxValue && field.minValue !== field.maxValue) {
1095 if (hasMin) {
1096 const tpl = getLabel('number_out_of_range')
1097 return callback(
1098 new Error(
1099 tpl
1100 .replace('{min}', String(field.minValue))
1101 .replace('{max}', String(field.maxValue)),
1102 ),
1103 )
1104 }
1105 const tpl = getLabel('number_cannot_be_greater_than')
1106 return callback(new Error(tpl.replace('{max}', String(field.maxValue))))
1107 }
1108 callback()
1109 },
1110 trigger: ['change', 'blur', 'submit'],
1111 })
1112
1113 // Real-time watcher for immediate feedback when exceeding limits
1114 watch(
1115 () => formValues.value[key],
1116 (val) => {
1117 const hasMin = field.minValue !== null && field.minValue !== undefined
1118 const hasMax = field.maxValue !== null && field.maxValue !== undefined
1119 if (val === '' || val === null || val === undefined) {
1120 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1121 return
1122 }
1123 const num = Number(val)
1124 if (isNaN(num)) {
1125 fieldErrors.value[key] = getLabel('enter_valid_number')
1126 return
1127 }
1128 if (
1129 ((hasMin && num < field.minValue) || (hasMax && num > field.maxValue)) &&
1130 field.minValue !== field.maxValue
1131 ) {
1132 if (hasMin && hasMax) {
1133 const tpl = getLabel('number_out_of_range')
1134 fieldErrors.value[key] = tpl
1135 .replace('{min}', String(field.minValue))
1136 .replace('{max}', String(field.maxValue))
1137 } else if (hasMin && num < field.minValue) {
1138 const tpl = getLabel('number_cannot_be_smaller_than')
1139 fieldErrors.value[key] = tpl.replace('{min}', String(field.minValue))
1140 } else if (hasMax && num > field.maxValue) {
1141 const tpl = getLabel('number_cannot_be_greater_than')
1142 fieldErrors.value[key] = tpl.replace('{max}', String(field.maxValue))
1143 }
1144 return
1145 }
1146 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1147 },
1148 )
1149 }
1150
1151 formRules.value[key] = rules
1152 }
1153
1154 // Real-time watcher for date min/max validation (only for input mode)
1155 if (
1156 field.type === 'date' &&
1157 field.dateFieldType === 'input' &&
1158 (field.minDateValue !== null || field.maxDateValue !== null)
1159 ) {
1160 const hasMin =
1161 field.minDateValue !== null &&
1162 field.minDateValue !== undefined &&
1163 field.minDateValue !== ''
1164 const hasMax =
1165 field.maxDateValue !== null &&
1166 field.maxDateValue !== undefined &&
1167 field.maxDateValue !== ''
1168
1169 if (hasMin || hasMax) {
1170 watch(
1171 () => formValues.value[key],
1172 (val) => {
1173 // Skip validation if empty or partial
1174 if (!val || val === '' || val === '__PARTIAL__') {
1175 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1176 return
1177 }
1178
1179 const dateFormat = (field.dateFormat as string) || 'MM/DD/YYYY'
1180
1181 const isDateComplete = (dateStr: string, format: string): boolean => {
1182 let expectedLength = format.length
1183
1184 if (format.includes('M/D') || format.includes('D/M')) {
1185 expectedLength = 8
1186 } else if (format.includes('MM/DD') || format.includes('DD/MM')) {
1187 expectedLength = 10
1188 } else if (format.includes('YYYY-MM-DD') || format.includes('YYYY-M-D')) {
1189 expectedLength = 10
1190 } else if (format.includes('DD.MM.YYYY') || format.includes('D.M.YYYY')) {
1191 expectedLength = 10
1192 }
1193
1194 if (dateStr.length < expectedLength) {
1195 return false
1196 }
1197
1198 let parts: string[] = []
1199 if (dateStr.indexOf('/') !== -1) {
1200 parts = dateStr.split('/')
1201 } else if (dateStr.indexOf('-') !== -1) {
1202 parts = dateStr.split('-')
1203 } else if (dateStr.indexOf('.') !== -1) {
1204 parts = dateStr.split('.')
1205 }
1206
1207 if (parts.length === 3) {
1208 let yearPart = ''
1209 if (format.startsWith('YYYY')) {
1210 yearPart = parts[0]
1211 } else {
1212 yearPart = parts[2]
1213 }
1214
1215 if (yearPart.length !== 4 || isNaN(Number(yearPart))) {
1216 return false
1217 }
1218 } else {
1219 return false
1220 }
1221
1222 return true
1223 }
1224
1225 if (!isDateComplete(String(val), dateFormat)) {
1226 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1227 return
1228 }
1229
1230 const currentDate = parseDateToObject(String(val), dateFormat)
1231 if (!currentDate) {
1232 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1233 return
1234 }
1235
1236 const minDate = hasMin
1237 ? parseDateToObject(String(field.minDateValue), dateFormat)
1238 : null
1239 const maxDate = hasMax
1240 ? parseDateToObject(String(field.maxDateValue), dateFormat)
1241 : null
1242
1243 // Use nextTick to ensure error is set AFTER clearFieldError is called
1244 nextTick(() => {
1245 if (minDate && currentDate < minDate) {
1246 const message = getLabel('date_must_be_after')
1247 fieldErrors.value[key] = message.replace('{min}', String(field.minDateValue))
1248 return
1249 }
1250
1251 if (maxDate && currentDate > maxDate) {
1252 const message = getLabel('date_must_be_before')
1253 fieldErrors.value[key] = message.replace('{max}', String(field.maxDateValue))
1254 return
1255 }
1256
1257 if (fieldErrors.value[key]) delete fieldErrors.value[key]
1258 })
1259 },
1260 )
1261 }
1262 }
1263
1264 // Email confirmation extension
1265 if (field.type === 'email' && field.confirmFieldEnabled) {
1266 const confirmKey = 'emailConfirm_' + field.fieldIndex
1267 formValues.value[confirmKey] = ''
1268 const confirmRules = []
1269 confirmRules.push({
1270 validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
1271 const mainVal = formValues.value[key]
1272 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
1273 if (!mainVal) {
1274 callback()
1275 return
1276 }
1277 if (!value) {
1278 callback(new Error(getLabel('enter_email_address')))
1279 return
1280 }
1281 if (!emailRegex.test(value)) {
1282 callback(new Error(getLabel('enter_email_address')))
1283 return
1284 }
1285 if (mainVal && value !== mainVal) {
1286 callback(new Error(getLabel('emails_do_not_match')))
1287 return
1288 }
1289 callback()
1290 },
1291 trigger: ['submit'],
1292 })
1293 formRules.value[confirmKey] = confirmRules
1294 }
1295
1296 // Allow Pro or other extensions to add field-specific initialization
1297 // Hook: ivyforms:field_init
1298 if (typeof window !== 'undefined') {
1299 const w = window as Window & {
1300 IvyForms?: {
1301 hooks?: {
1302 fieldInit?: Array<
1303 (
1304 field: Field,
1305 context: {
1306 formValues: typeof formValues.value
1307 formRules: typeof formRules.value
1308 getLabel: typeof getLabel
1309 fieldErrors: typeof fieldErrors.value
1310 },
1311 ) => void
1312 >
1313 }
1314 }
1315 }
1316
1317 const hooks = w.IvyForms?.hooks?.fieldInit || []
1318 hooks.forEach((hook) => {
1319 try {
1320 hook(field, {
1321 formValues: formValues.value,
1322 formRules: formRules.value,
1323 getLabel,
1324 fieldErrors: fieldErrors.value,
1325 })
1326 } catch (error) {
1327 console.error(getLabel('error_in_field_init_hook'), error)
1328 }
1329 })
1330 }
1331 })
1332
1333 // Form is loaded, remove loader
1334 isLoading.value = false
1335
1336 // Clear any validation errors that may have been triggered during initialization
1337 // This prevents fields from showing errors on page load
1338 // Use setTimeout to ensure this runs after all DOM updates and isLoading changes
1339 setTimeout(() => {
1340 if (formRef.value) {
1341 formRef.value.clearValidate()
1342 }
1343 }, 50)
1344 // add vanila js remove class from loader
1345 const counter = (formWrapperRef.value as HTMLElement)
1346 .closest('.ivyforms-frontend')
1347 ?.getAttribute('data-counter')
1348 const loader = document.querySelector(`.ivyforms-skeleton-${counter}`)
1349 if (loader) {
1350 loader.remove()
1351 }
1352 } else {
1353 console.error(getLabel('invalid_form_data'), formData)
1354 }
1355 } else {
1356 console.error(getLabel('form_data_not_found'))
1357 // Even with error, don't show loader anymore
1358 isLoading.value = false
1359 }
1360
1361 // After loading form data, get the outer wrapper by ID and read data attribute
1362 let counter = formData.value.counter || ''
1363 if (!counter && formWrapperRef.value) {
1364 const closest = formWrapperRef.value.closest('.ivyforms-frontend')
1365 if (closest && closest.dataset.counter) {
1366 counter = closest.dataset.counter
1367 }
1368 }
1369 if (counter) {
1370 const outerWrapper = document.getElementById('ivyforms-app-' + counter)
1371 if (outerWrapper && outerWrapper.dataset) {
1372 postId.value = outerWrapper.dataset.postId || ''
1373 refererUrl.value = outerWrapper.dataset.referer || document.referrer || ''
1374 }
1375 }
1376 })
1377
1378 function getRequiredMessage(field: Field): string {
1379 const rm = field.requiredMessage
1380 if (typeof rm === 'string' && rm.trim() !== '') {
1381 return rm
1382 }
1383 let fieldMessage: string
1384 fieldMessage = ucFirst(field.label || field.type)
1385 fieldMessage += ` ${getLabel('required')}`
1386 return fieldMessage
1387 }
1388
1389 const ucFirst = (str: string) => (str ? str[0]?.toLocaleUpperCase() + str.slice(1) : '')
1390
1391 // Helper function to parse date string to Date object with validation
1392 const parseDateToObject = (dateStr: string, format: string): Date | null => {
1393 let day = 0,
1394 month = 0,
1395 year = 0
1396 let parts: string[] = []
1397 if (dateStr.indexOf('/') !== -1) {
1398 parts = dateStr.split('/')
1399 } else if (dateStr.indexOf('-') !== -1) {
1400 parts = dateStr.split('-')
1401 } else if (dateStr.indexOf('.') !== -1) {
1402 parts = dateStr.split('.')
1403 } else {
1404 parts = [dateStr]
1405 }
1406
1407 if (format.startsWith('MM') || format.startsWith('M')) {
1408 // MM/DD/YYYY or M/D/YYYY
1409 month = parseInt(parts[0], 10)
1410 day = parseInt(parts[1], 10)
1411 year = parseInt(parts[2], 10)
1412 } else if (format.startsWith('DD') || format.startsWith('D')) {
1413 // DD/MM/YYYY or D/M/YYYY or DD.MM.YYYY
1414 day = parseInt(parts[0], 10)
1415 month = parseInt(parts[1], 10)
1416 year = parseInt(parts[2], 10)
1417 } else if (format.startsWith('YYYY')) {
1418 // YYYY-MM-DD or YYYY-M-D
1419 year = parseInt(parts[0], 10)
1420 month = parseInt(parts[1], 10)
1421 day = parseInt(parts[2], 10)
1422 }
1423
1424 if (day && month && year) {
1425 const date = new Date(year, month - 1, day)
1426 // Validate that the date is actually valid (e.g., not Feb 31)
1427 if (date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day) {
1428 return date
1429 }
1430 }
1431 return null
1432 }
1433
1434 // Get the most common label position for the form
1435 const getFormLabelPosition = (): 'left' | 'right' | 'top' => {
1436 if (!formFields.value || formFields.value.length === 0) {
1437 return 'top'
1438 }
1439
1440 // Count label positions
1441 const positionCounts = formFields.value.reduce((counts, field) => {
1442 const position = field.labelPosition || 'top'
1443 counts[position] = (counts[position] || 0) + 1
1444 return counts
1445 }, {})
1446
1447 // Find the most common position
1448 const mostCommonPosition = Object.keys(positionCounts).reduce((a, b) =>
1449 positionCounts[a] > positionCounts[b] ? a : b,
1450 )
1451
1452 // Convert to Element Plus format
1453 switch (mostCommonPosition) {
1454 case 'left':
1455 return 'left'
1456 case 'right':
1457 return 'right'
1458 case 'top':
1459 case 'inside':
1460 default:
1461 return 'top'
1462 }
1463 }
1464
1465 // Validate form and submit
1466 const validateAndSubmit = async () => {
1467 if (!formRef.value) return
1468
1469 await formRef.value.validate(
1470 async (valid: boolean, errors: Record<string, { message: string }[]>) => {
1471 if (valid) {
1472 // Handle reCAPTCHA v3 execution before form submission
1473 const hasRecaptchaField = formFields.value.some((field) => field.type === 'recaptcha')
1474
1475 if (hasRecaptchaField) {
1476 // Find reCAPTCHA field component ref
1477 const recaptchaFieldRef = formFieldsCollectorRefs.value?.find(
1478 (ref: ComponentPublicInstance): ref is RecaptchaFieldComponent =>
1479 ref &&
1480 (ref as RecaptchaFieldComponent).$props &&
1481 (ref as RecaptchaFieldComponent).$props.field &&
1482 (ref as RecaptchaFieldComponent).$props.field.type === 'recaptcha',
1483 )
1484 if (recaptchaFieldRef && recaptchaFieldRef.executeRecaptcha) {
1485 try {
1486 // For v3, this will execute the reCAPTCHA and get a token
1487 // For v2, this will return the current token
1488 const token = await recaptchaFieldRef.executeRecaptcha()
1489
1490 // Update the form value with the token
1491 const fieldKey = `recaptcha_${recaptchaFieldRef.$props.field.fieldIndex}`
1492 formValues.value[fieldKey] = token
1493 } catch (error) {
1494 console.error('reCAPTCHA execution failed:', error)
1495 // You might want to show an error message to the user here
1496 return
1497 }
1498 }
1499 }
1500
1501 submitForm()
1502 } else {
1503 fieldErrors.value = {} // Clear previous
1504 Object.keys(errors).forEach((fieldKey) => {
1505 const rawMsg = errors[fieldKey][0]?.message || getLabel('invalid_input')
1506 const displayMsg = rawMsg
1507 ;(fieldErrors.value as Record<string, string>)[fieldKey] = displayMsg
1508 })
1509 }
1510 },
1511 )
1512 }
1513
1514 // Clear error when user starts typing
1515 const clearFieldError = (fieldKey: string) => {
1516 if ((fieldErrors.value as Record<string, unknown>)[fieldKey]) {
1517 delete (fieldErrors.value as Record<string, unknown>)[fieldKey]
1518 }
1519 // Also clear parent key for name subfields like name_<index>.nameFieldX
1520 if (fieldKey.includes('.')) {
1521 const parentKey = fieldKey.split('.')[0]
1522 if ((fieldErrors.value as Record<string, unknown>)[parentKey]) {
1523 delete (fieldErrors.value as Record<string, unknown>)[parentKey]
1524 }
1525 }
1526 }
1527
1528 // Provide the clearFieldError function to child components
1529 provide('clearFieldError', clearFieldError)
1530 // Provide reactive form values so nested fields (e.g., email confirmation) can mutate the same model
1531 provide('formValues', formValues)
1532
1533 const resetForm = () => {
1534 skipValidation.value = true
1535 formValues.value = {}
1536 nextTick(() => {
1537 skipValidation.value = false
1538 })
1539 }
1540 // Submit form
1541 const submitForm = async () => {
1542 // Clear previous errors before each submit
1543 fieldErrors.value = {}
1544
1545 // Process form values to handle website fields with prefix/suffix
1546 const processedValues = { ...formValues.value }
1547
1548 formFields.value.forEach((field) => {
1549 if (field.type === 'website') {
1550 const key = field.type + '_' + field.fieldIndex
1551 const fullValue = processedValues[key] || ''
1552 const prefix = field.inputPrefix || ''
1553 const suffix = field.inputSuffix || ''
1554
1555 let userInput = fullValue
1556
1557 // Only add prefix/suffix if the value doesn't already have them
1558 if (fullValue && (prefix || suffix)) {
1559 // Check if prefix is already present before adding
1560 if (prefix && !userInput.startsWith(prefix)) {
1561 userInput = prefix + userInput
1562 }
1563
1564 // Check if suffix is already present before adding
1565 if (suffix && !userInput.endsWith(suffix)) {
1566 userInput = userInput + suffix
1567 }
1568 }
1569
1570 processedValues[key] = userInput
1571 }
1572 })
1573
1574 try {
1575 const { data, error, status } = await request('form/submission/', {
1576 method: 'POST',
1577 headers: {
1578 'Content-Type': 'application/json',
1579 },
1580 data: {
1581 formId: formData.value.id,
1582 values: processedValues,
1583 nonce: formNonce.value,
1584 postId: postId.value,
1585 referer: refererUrl.value,
1586 },
1587 })
1588
1589 // Check for duplicate value error from backend
1590 if (data.data.data.is_duplicate) {
1591 const duplicateFields = data.data.data.duplicate_errors
1592
1593 for (const field of formFields.value) {
1594 const fieldKey = field.type + '_' + field.fieldIndex
1595
1596 if (duplicateFields[field.id]) {
1597 const fieldValue = formValues.value[fieldKey]
1598 if (fieldValue && fieldValue.toString().trim() !== '') {
1599 ;(fieldErrors.value as Record<string, string>)[fieldKey] = getLabel(
1600 'duplicate_value_error_label',
1601 ).replace('{label}', field.label?.toLowerCase() || 'value')
1602 }
1603 }
1604 }
1605 return
1606 }
1607
1608 // Handle response
1609 if (status !== 200 || error) {
1610 IvyMessage({
1611 type: 'error',
1612 message: getLabel('error_submitting_form') + (error ? error.message : data),
1613 })
1614 return
1615 }
1616
1617 // Reset form or show success message
1618 resetForm()
1619 const type = formConfirmation.value[0].type
1620 const enabled = formConfirmation.value[0].enabled
1621 const showForm = formConfirmation.value[0].showForm
1622
1623 if (!enabled) return
1624
1625 if (type === 'successMessage') {
1626 if (!showForm) {
1627 showSuccessMessage.value = true
1628 successMessage.value =
1629 data.data.data.confirmation || formConfirmation.value[0].message || getLabel('thank_you')
1630 return
1631 } else {
1632 showSuccessMessageReset.value = true
1633 successMessage.value =
1634 data.data.data.confirmation || formConfirmation.value[0].message || getLabel('thank_you')
1635
1636 // Hide the success message after 5 seconds
1637 setTimeout(() => {
1638 const messageElement = document.querySelector('.ivyforms-success-message')
1639 if (messageElement) {
1640 messageElement.classList.add('ivyforms-fade-out')
1641 }
1642 setTimeout(() => {
1643 showSuccessMessageReset.value = false
1644 }, 500) // Match the CSS transition duration
1645 }, 5000)
1646 }
1647 } else if (type === 'redirectToPage' && formConfirmation.value[0].page) {
1648 const pageId = formConfirmation.value[0].page
1649 if (pageId) {
1650 if (router) {
1651 await router.push({ path: '/', query: { p: pageId } })
1652 } else {
1653 redirectToUrl(`/?p=${pageId}`)
1654 }
1655 }
1656 } else if (type === 'redirectToCustomUrl' && formConfirmation.value[0].url) {
1657 redirectToUrl(formConfirmation.value[0].url)
1658 }
1659 } catch (error) {
1660 console.error(getLabel('error_submitting_form'), error)
1661 // Show error message
1662 }
1663 }
1664 </script>
1665
1666 <style lang="scss" scoped>
1667 .ivyforms-form-wrapper {
1668 width: 100%;
1669 max-width: 720px;
1670 margin: 0 auto;
1671
1672 .ivyforms-form-title {
1673 font-size: 24px;
1674 font-weight: 600;
1675 margin-bottom: 12px;
1676 color: var(--map-base-text-0);
1677 }
1678
1679 .ivyforms-form-description {
1680 font-size: 16px;
1681 margin-bottom: 24px;
1682 color: var(--map-base-text-1);
1683 }
1684
1685 .ivyforms-field {
1686 padding: 12px 0;
1687 }
1688
1689 .ivyforms-form-submit {
1690 margin-top: 12px;
1691 }
1692
1693 .ivyforms-form-item {
1694 .el-form-item {
1695 // When labelPosition is 'left' - use left label class for positioning
1696 &[class*='el-form-item--label-left']*='el-form-item--label-left'] {
1697 flex-direction: row;
1698 align-items: flex-start;
1699
1700 .el-form-item__label {
1701 flex-shrink: 0;
1702 margin-right: 16px; // Add spacing between label and input
1703 margin-bottom: 0; // Remove bottom margin for side positioning
1704 min-width: 50px; // Ensure label has minimum width
1705
1706 // Style the left label when positioned on the left
1707 .ivyforms-form-item__left-label {
1708 align-items: center;
1709 display: flex;
1710 gap: 4px;
1711 }
1712 }
1713
1714 .el-form-item__content {
1715 flex: 1;
1716 }
1717 }
1718
1719 // When labelPosition is 'right' - use right label class for positioning
1720 &[class*='el-form-item--label-right']*='el-form-item--label-right'] {
1721 flex-direction: row;
1722 align-items: flex-start;
1723
1724 .el-form-item__label {
1725 flex-shrink: 0;
1726 margin-left: 16px; // Add spacing for right-positioned labels
1727 margin-right: 0;
1728 margin-bottom: 0;
1729 min-width: 50px;
1730 order: 2; // Move label to the right side
1731
1732 // Style the right label when positioned on the right
1733 .ivyforms-form-item__right-label {
1734 align-items: center;
1735 display: flex;
1736 gap: 4px;
1737 // Override the underline styling for positional right labels
1738 color: var(--map-base-text-0);
1739 text-decoration: none;
1740 margin-left: 0;
1741 }
1742 }
1743
1744 .el-form-item__content {
1745 flex: 1;
1746 order: 1; // Move content to the left side
1747 }
1748 }
1749 }
1750 }
1751
1752 ::v-deep(.ivyforms-success-message) {
1753 display: flex;
1754 flex-direction: column;
1755 align-items: center;
1756 justify-content: center;
1757 font-size: 20px;
1758 padding: 20px;
1759 border: 1px solid var(--map-base-brand-symbol-0);
1760 border-radius: 8px;
1761 text-align: center;
1762 transition: opacity 0.5s ease-in-out;
1763 opacity: 1;
1764
1765 &.ivyforms-fade-out {
1766 opacity: 0;
1767 }
1768
1769 .ivyforms-safe-html {
1770 display: flex;
1771 flex-direction: column;
1772 align-items: center;
1773 justify-content: center;
1774
1775 img {
1776 max-width: 100%;
1777 height: auto;
1778 margin: 10px 0;
1779 }
1780 }
1781 }
1782 }
1783 </style>
1784