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

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