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

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

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