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

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