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

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