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

1,069 lines 39.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 {{ successMessage || getLabel('thank_you') }}
8 </div>
9 <!-- Form title-->
10 <template v-else>
11 <div v-if="showSuccessMessageReset" class="ivyforms-success-message ivyforms-mb-24">
12 {{ successMessage || getLabel('thank_you') }}
13 </div>
14
15 <h2
16 v-if="formData.showTitle"
17 :class="['ivyforms-form-title', 'ivyforms-form-title-' + formData.id]"
18 >
19 {{ formData.name }}
20 </h2>
21 <!-- Form description-->
22 <p
23 v-if="formData.showDescription"
24 :class="['ivyforms-form-description', 'ivyforms-form-description-' + formData.id]"
25 >
26 {{ formData.description }}
27 </p>
28
29 <IvyForm
30 :id="'ivyforms-form-' + formData.id"
31 ref="formRef"
32 :class="'ivyforms-form ivyforms-form-' + formData.id"
33 :data-form-id="formData.id"
34 :rules="formRules"
35 :model="formValues"
36 :label-position="getFormLabelPosition()"
37 @submit.prevent="submitForm"
38 >
39 <template v-for="field in formFields" :key="field.type + '_' + field.fieldIndex">
40 <component
41 :is="formFieldsTemplates[field.type]"
42 ref="formFieldsCollectorRefs"
43 v-model="formValues[field.type + '_' + field.fieldIndex]"
44 :field="field"
45 :error="fieldErrors[field.type + '_' + field.fieldIndex]"
46 :field-errors="fieldErrors"
47 :label-position="getFormLabelPosition()"
48 ></component>
49 </template>
50 <!-- Add hidden inputs fields for referer, post id ... -->
51 <div class="ivyforms-form-submit">
52 <IvyButtonAction
53 :id="'ivyforms-form-submit-button-' + formData.id"
54 priority="tertiary"
55 type="border"
56 @click.prevent.stop="validateAndSubmit"
57 >
58 {{ getLabel('submit') }}
59 </IvyButtonAction>
60 </div>
61 </IvyForm>
62 </template>
63 </div>
64 </template>
65
66 <script setup lang="ts">
67 import {
68 type ComponentPublicInstance,
69 markRaw,
70 onMounted,
71 provide,
72 reactive,
73 ref,
74 nextTick,
75 watch,
76 } from 'vue'
77 import IvyForm from '@/views/_components/form/IvyForm.vue'
78 import IvyButtonAction from '@/views/_components/button/IvyButtonAction.vue'
79 import { useApiClient } from '@/composables/useApiClient'
80 import { useWcagColors } from '@/composables/useWcagColors'
81 // * Form Field templates
82 import IvyTextFormField from '../public/fields/TextFormField.vue'
83 import IvyParagraphFormField from '../public/fields/ParagraphFormField.vue'
84 import IvyEmailFormField from '../public/fields/EmailFormField.vue'
85 import IvyNumberFormField from '../public/fields/NumberFormField.vue'
86 import IvyPhoneFormField from '../public/fields/PhoneFormField.vue'
87 import IvyWebsiteFormField from '../public/fields/WebsiteFormField.vue'
88 import IvyRadioFormField from '../public/fields/RadioFormField.vue'
89 import NameFormField from '../public/fields/NameFormField.vue'
90 import RecaptchaFormField from '../public/fields/RecaptchaFormField.vue'
91 import IvyAddressFormField from '../public/fields/AddressFormField.vue'
92 import type { Field } from '@/types/field'
93 import type { Confirmation } from '@/types/confirmations'
94 import { useLabels } from '@/composables/useLabels'
95 import IvyMessage from '@/views/_components/message/ivyMessage.ts'
96 import utilRedirect from '@/utils/utilRedirects'
97 import { useRouter } from 'vue-router'
98
99 // Initialize WCAG color system for public forms
100 useWcagColors()
101
102 interface RecaptchaFieldComponent extends ComponentPublicInstance {
103 $props: {
104 field: {
105 type: string
106 fieldIndex: number
107 }
108 }
109 executeRecaptcha?: () => Promise<string>
110 }
111
112 const { getLabel } = useLabels()
113 const { request } = useApiClient()
114 const { redirectToUrl } = utilRedirect()
115 const router = useRouter()
116 const skipValidation = ref(false)
117
118 // Helper: normalize backend fields for preview (merge Name children into parent)
119 function normalizePreviewFields(rawFields: Field[]): Field[] {
120 if (!Array.isArray(rawFields) || rawFields.length === 0) return []
121
122 // Preserve original order index to stably order children without subFieldIndex
123 const apiOrder = new Map<Field, number>()
124 rawFields.forEach((f, i) => apiOrder.set(f, i))
125
126 const isChild = (f: Field) => f.parentId !== undefined && f.parentId !== null
127 const parents = rawFields.filter((f) => !isChild(f))
128
129 const result: Field[] = []
130
131 for (const parent of parents) {
132 // Collect children by matching parentId to parent.id or parent.fieldIndex
133 const children = rawFields.filter(
134 (c) =>
135 isChild(c) &&
136 (c.parentId === (parent as Field).id || c.parentId === (parent as Field).fieldIndex),
137 )
138
139 // Propagate common settings to top-level field if provided
140 const pSettings = (parent.settings || {}) as Record<string, unknown>
141 if (pSettings) {
142 if (parent.hideLabel === undefined && typeof pSettings.hideLabel !== 'undefined') {
143 ;(parent as Record<string, unknown>).hideLabel = !!pSettings.hideLabel
144 }
145 if (parent.readOnly === undefined && typeof pSettings.readOnly !== 'undefined') {
146 ;(parent as Record<string, unknown>).readOnly = !!(pSettings.readOnly as boolean)
147 }
148 if (
149 (parent as Record<string, unknown>).description === undefined &&
150 typeof pSettings.description !== 'undefined'
151 ) {
152 ;(parent as Record<string, unknown>).description = String(pSettings.description || '')
153 }
154 if (
155 (parent as Record<string, unknown>).cssClasses === undefined &&
156 typeof pSettings.cssClasses !== 'undefined'
157 ) {
158 ;(parent as Record<string, unknown>).cssClasses = String(pSettings.cssClasses || '')
159 }
160 }
161
162 // Merge children for name and address fields
163 if (children.length >= 2 || parent.type === 'name' || parent.type === 'address') {
164 // Sort children: first those with explicit subFieldIndex, then the rest by API order
165 const ordered = [...children].sort((a, b) => {
166 const as = ((a.settings || {}) as Record<string, unknown>).subFieldIndex
167 const bs = ((b.settings || {}) as Record<string, unknown>).subFieldIndex
168 const aHas = typeof as === 'number'
169 const bHas = typeof bs === 'number'
170 if (aHas && bHas) return (as as number) - (bs as number)
171 if (aHas) return -1
172 if (bHas) return 1
173 return (apiOrder.get(a) || 0) - (apiOrder.get(b) || 0)
174 })
175
176 if (parent.type === 'name') {
177 // Merge as name field
178 const nameFieldTypes: string[] = []
179 ordered.forEach((child, idx) => {
180 const key = `nameField${idx + 1}`
181 nameFieldTypes.push(key)
182 const cs = (child.settings || {}) as Record<string, unknown>
183 const prec = parent as unknown as Record<string, unknown>
184 prec[`${key}Label`] = (child.label as unknown) || ''
185 prec[`${key}Placeholder`] = (child.placeholder as unknown) || ''
186 prec[`${key}Required`] = !!child.required
187 // Required message: prefer settings.requiredMessage, fallback to legacy top-level
188 if (typeof cs.requiredMessage !== 'undefined') {
189 prec[`${key}RequiredMessage`] = cs.requiredMessage
190 } else if (typeof (child as Record<string, unknown>).requiredMessage !== 'undefined') {
191 prec[`${key}RequiredMessage`] = (child as Record<string, unknown>).requiredMessage
192 }
193 // Default value
194 if (typeof child.defaultValue !== 'undefined') {
195 prec[`${key}Value`] = child.defaultValue as unknown
196 }
197 // Hide sublabel (support both settings.hideLabel and legacy child.hideLabel)
198 const childHasSettingsHide = typeof cs.hideLabel !== 'undefined'
199 const childHasTopLevelHide =
200 typeof (child as Record<string, unknown>).hideLabel !== 'undefined'
201 if (childHasSettingsHide || childHasTopLevelHide) {
202 const childHideLabel = childHasSettingsHide
203 ? !!(cs.hideLabel as boolean)
204 : !!((child as Record<string, unknown>).hideLabel as boolean)
205 prec[`${key}HideLabel`] = childHideLabel
206 }
207 // Subfield description
208 if (typeof (child as Record<string, unknown>).description !== 'undefined') {
209 prec[`${key}Description`] = String(
210 ((child as Record<string, unknown>).description as string) || '',
211 )
212 }
213 // Visibility
214 if (typeof (child as Record<string, unknown>).description !== 'undefined') {
215 prec[`${key}Visible`] = String(
216 ((child as Record<string, unknown>).visible as boolean) || true,
217 )
218 }
219 })
220 ;(parent as Record<string, unknown>).nameFieldTypes = nameFieldTypes
221 parent.type = 'name'
222 } else if (parent.type === 'address') {
223 // Merge as address field
224 // Use canonical order for address fields (must match IvyAddressFormField.vue and builder)
225 const canonicalOrder = ['streetAddress', 'addressLine2', 'city', 'state', 'zip', 'country']
226 const addressFieldTypes = []
227 // If there are NO children, still populate all 6 subfields on the parent
228 if (ordered.length === 0) {
229 canonicalOrder.forEach((fieldType) => {
230 addressFieldTypes.push(fieldType)
231 const prec = parent as unknown as Record<string, unknown>
232 prec[`${fieldType}Label`] =
233 (parent as Record<string, unknown>)[`${fieldType}Label`] || ''
234 prec[`${fieldType}Placeholder`] =
235 (parent as Record<string, unknown>)[`${fieldType}Placeholder`] || ''
236 prec[`${fieldType}Required`] =
237 (parent as Record<string, unknown>)[`${fieldType}Required`] || false
238 prec[`${fieldType}RequiredMessage`] =
239 (parent as Record<string, unknown>)[`${fieldType}RequiredMessage`] || ''
240 prec[`${fieldType}Value`] =
241 (parent as Record<string, unknown>)[`${fieldType}Value`] || ''
242 prec[`${fieldType}HideLabel`] =
243 (parent as Record<string, unknown>)[`${fieldType}HideLabel`] || false
244 prec[`${fieldType}Description`] =
245 (parent as Record<string, unknown>)[`${fieldType}Description`] || ''
246 prec[`${fieldType}Visible`] =
247 (parent as Record<string, unknown>)[`${fieldType}Visible`] || true
248 })
249 ;(parent as Record<string, unknown>).addressFieldTypes = canonicalOrder
250 parent.type = 'address'
251 } else {
252 canonicalOrder.forEach((fieldType, idx) => {
253 // Find child by settings.fieldType, or by label (case-insensitive, spaces removed), or fallback to ordered[idx] if exists
254 let child =
255 ordered.find((c) => {
256 const settings = parseSettings((c as unknown as Record<string, unknown>).settings)
257 return (
258 (settings && settings.fieldType === fieldType) ||
259 (c.label && c.label.toLowerCase().replace(/\s/g, '') === fieldType.toLowerCase())
260 )
261 }) || ordered.find((c) => c.type === fieldType)
262 // Only fallback to ordered[idx] if it exists and is not undefined
263 if (!child && Array.isArray(ordered) && ordered.length > idx && ordered[idx]) {
264 child = ordered[idx]
265 }
266 addressFieldTypes.push(fieldType)
267 const cs = child && child.settings ? parseSettings(child.settings) : {}
268 const prec = parent as unknown as Record<string, unknown>
269 prec[`${fieldType}Label`] = child ? (child.label as unknown) || '' : ''
270 prec[`${fieldType}Placeholder`] = child ? (child.placeholder as unknown) || '' : ''
271 prec[`${fieldType}Required`] = child ? !!child.required : false
272 if (child && typeof cs.requiredMessage !== 'undefined') {
273 prec[`${fieldType}RequiredMessage`] = cs.requiredMessage
274 } else if (
275 child &&
276 typeof (child as Record<string, unknown>).requiredMessage !== 'undefined'
277 ) {
278 prec[`${fieldType}RequiredMessage`] = (
279 child as Record<string, unknown>
280 ).requiredMessage
281 } else {
282 prec[`${fieldType}RequiredMessage`] = ''
283 }
284 if (child && typeof child.defaultValue !== 'undefined') {
285 prec[`${fieldType}Value`] = child.defaultValue as unknown
286 } else {
287 prec[`${fieldType}Value`] = ''
288 }
289 const childHasSettingsHide = child && typeof cs.hideLabel !== 'undefined'
290 const childHasTopLevelHide =
291 child && typeof (child as Record<string, unknown>).hideLabel !== 'undefined'
292 if (childHasSettingsHide || childHasTopLevelHide) {
293 const childHideLabel = childHasSettingsHide
294 ? !!(cs.hideLabel as boolean)
295 : !!((child as Record<string, unknown>).hideLabel as boolean)
296 prec[`${fieldType}HideLabel`] = childHideLabel
297 } else {
298 prec[`${fieldType}HideLabel`] = false
299 }
300 if (child && typeof (child as Record<string, unknown>).description !== 'undefined') {
301 prec[`${fieldType}Description`] = String(
302 ((child as Record<string, unknown>).description as string) || '',
303 )
304 } else {
305 prec[`${fieldType}Description`] = ''
306 }
307 if (child && typeof (child as Record<string, unknown>).visible !== 'undefined') {
308 prec[`${fieldType}Visible`] = !!((child as Record<string, unknown>)
309 .visible as boolean)
310 } else {
311 prec[`${fieldType}Visible`] = true
312 }
313 })
314 ;(parent as Record<string, unknown>).addressFieldTypes = canonicalOrder
315 parent.type = 'address'
316 }
317 }
318 }
319
320 result.push(parent)
321 }
322
323 return result
324 }
325
326 // Safely parse settings which may be stored as JSON string or as an object
327 const parseSettings = (s: unknown): Record<string, unknown> => {
328 if (!s) return {}
329 if (typeof s === 'string') {
330 try {
331 return JSON.parse(s) as Record<string, unknown>
332 } catch {
333 return {}
334 }
335 }
336 if (typeof s === 'object') {
337 return s as Record<string, unknown>
338 }
339 return {}
340 }
341
342 // Form data
343 const formFields = ref([])
344 const formValues = ref({})
345 const formRules = ref({})
346 const fieldErrors = ref({})
347 const isLoading = ref(true)
348 const formRef = ref(null)
349 const formWrapperRef = ref(null)
350 const formFieldsCollectorRefs = ref([])
351 const formNonce = ref(null)
352 const formConfirmation = ref([])
353 const showSuccessMessage = ref(false)
354 const showSuccessMessageReset = ref(false)
355 const successMessage = ref('')
356 const postId = ref('')
357 const refererUrl = ref('')
358
359 interface FormData {
360 id: string | number
361 name?: string
362 description?: string
363 showTitle?: boolean
364 showDescription?: boolean
365 storeEntries?: boolean
366 defaultValue?: string
367 counter?: number
368 fields?: Field[]
369 confirmations?: Confirmation[]
370 }
371
372 // Define the shape of entries provided by the global window variable
373 interface GlobalFormEntry {
374 id: string | number
375 form: FormData
376 nonce: string
377 fields: Field[]
378 confirmations: Confirmation[]
379 }
380
381 // Update your refs with proper typing
382 const formData = ref<FormData>({
383 id: '',
384 name: '',
385 description: '',
386 showTitle: true,
387 showDescription: false,
388 storeEntries: true,
389 defaultValue: '',
390 counter: 0,
391 fields: [],
392 confirmations: [],
393 })
394 // * Form Fields Object
395 const formFieldsTemplates = reactive({
396 text: markRaw(IvyTextFormField),
397 email: markRaw(IvyEmailFormField),
398 textarea: markRaw(IvyParagraphFormField),
399 number: markRaw(IvyNumberFormField),
400 phone: markRaw(IvyPhoneFormField),
401 website: markRaw(IvyWebsiteFormField),
402 recaptcha: markRaw(RecaptchaFormField),
403 radio: markRaw(IvyRadioFormField),
404 name: markRaw(NameFormField),
405 address: markRaw(IvyAddressFormField),
406 })
407
408 // Initialize from global variable
409
410 // Initialize from global variable
411 onMounted(async () => {
412 const w = window as unknown as { wpIvyFormDataList?: Record<string, GlobalFormEntry> }
413 if (typeof window !== 'undefined' && w.wpIvyFormDataList) {
414 const counter = (formWrapperRef.value as HTMLElement)
415 .closest('.ivyforms-frontend')!
416 .getAttribute('data-counter') as string
417 const formsData = w.wpIvyFormDataList[counter]
418 // Check if formData is an object and has the required properties
419 if (typeof formsData === 'object' && formsData !== null && formsData.id) {
420 // Assign the form data to the reactive variable
421 formData.value = formsData.form
422 formNonce.value = formsData.nonce
423 // Normalize fields for preview
424 const normalized = normalizePreviewFields(formsData.fields || [])
425 formFields.value = normalized
426 formConfirmation.value = formsData.confirmations || []
427
428 // Initialize form values with defaults or empty strings
429 formFields.value.forEach((field) => {
430 const key = field.type + '_' + field.fieldIndex
431
432 // Special handling for name field type
433 if (field.type === 'name') {
434 const nameValue: Record<string, string> = {}
435 const nameFieldTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
436 // Initialize each dynamic name field
437 nameFieldTypes.forEach((fieldType) => {
438 nameValue[fieldType] = (field as Record<string, string>)[`${fieldType}Value`] || ''
439 })
440 formValues.value[key] = nameValue
441 } else if (field.type === 'address') {
442 // initialize all Address field subfields
443 const addressValue: Record<string, string> = {}
444 const addressFieldTypes = field.addressFieldTypes || [
445 'streetAddress',
446 'addressLine2',
447 'city',
448 'state',
449 'zip',
450 'country',
451 ]
452 addressFieldTypes.forEach((fieldType) => {
453 addressValue[fieldType] = (field as Record<string, string>)[`${fieldType}Value`] || ''
454 })
455 formValues.value[key] = addressValue
456 } else if (field.type === 'radio' && Array.isArray(field.fieldOptions)) {
457 const defaultOpt = field.fieldOptions.find((opt) => opt.isDefault)
458 formValues.value[key] = defaultOpt ? defaultOpt.value : ''
459 } else {
460 formValues.value[key] = field.defaultValue || ''
461 }
462
463 if (field.type === 'name') {
464 const nameFieldTypes = field.nameFieldTypes || ['nameField1', 'nameField2']
465 nameFieldTypes.forEach((nfType: string) => {
466 const subKey = `${field.type}_${field.fieldIndex}.${nfType}`
467 const label =
468 (field as Record<string, string>)[`${nfType}Label`] ||
469 getLabel(`name_field_${nfType.replace('nameField', '')}`)
470 const rulesForSub: unknown[] = []
471 if ((field as Record<string, unknown>)[`${nfType}Required`]) {
472 const customMsg = (field as Record<string, unknown>)[`${nfType}RequiredMessage`]
473 const message =
474 typeof customMsg === 'string' && (customMsg as string).trim() !== ''
475 ? (customMsg as string)
476 : `${label} ${getLabel('required')}`
477 rulesForSub.push({
478 required: true,
479 message,
480 trigger: ['submit'],
481 })
482 }
483 ;(formRules.value as Record<string, unknown>)[subKey] = rulesForSub
484 })
485 } else if (field.type === 'address') {
486 const addressFieldTypes = field.addressFieldTypes || [
487 'streetAddress',
488 'addressLine2',
489 'city',
490 'state',
491 'zip',
492 'country',
493 ]
494 addressFieldTypes.forEach((afType: string) => {
495 const subKey = `${field.type}_${field.fieldIndex}.${afType}`
496 const label =
497 (field as Record<string, string>)[`${afType}Label`] ||
498 getLabel(`address_field_${afType.replace('addressField', '')}`)
499 const rulesForSub: unknown[] = []
500 if ((field as Record<string, unknown>)[`${afType}Required`]) {
501 const customMsg = (field as Record<string, unknown>)[`${afType}RequiredMessage`]
502 const message =
503 typeof customMsg === 'string' && (customMsg as string).trim() !== ''
504 ? (customMsg as string)
505 : `${label} ${getLabel('required')}`
506 rulesForSub.push({
507 required: true,
508 message,
509 trigger: ['submit'],
510 })
511 }
512 ;(formRules.value as Record<string, unknown>)[subKey] = rulesForSub
513 })
514 } else {
515 const rules = []
516
517 // Required rule
518 if (field.required) {
519 rules.push({
520 required: true,
521 message: getRequiredMessage(field),
522 trigger: ['submit'],
523 })
524 }
525
526 // Format validations
527 if (field.type === 'email') {
528 rules.push({
529 validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
530 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
531 if (skipValidation.value) {
532 callback()
533 return
534 }
535 if (!value) {
536 if (field.required) {
537 callback(new Error(getLabel('enter_email_address')))
538 } else {
539 callback()
540 }
541 return
542 }
543 if (!emailRegex.test(value)) {
544 callback(new Error(getLabel('enter_email_address')))
545 return
546 }
547 callback()
548 },
549 trigger: ['change', 'blur', 'submit'],
550 })
551 }
552
553 if (field.type === 'phone') {
554 rules.push({
555 pattern: /^[+\d]?(?:[\d\s.-]{7,15})$/,
556 message: getLabel('enter_phone_number'),
557 trigger: ['submit'],
558 })
559 }
560
561 if (field.type === 'website') {
562 rules.push({
563 pattern: /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/,
564 message: getLabel('enter_valid_website'),
565 trigger: ['submit'],
566 })
567 }
568
569 if (field.type === 'number' && (field.minValue !== null || field.maxValue !== null)) {
570 rules.push({
571 validator: (_rule: unknown, value: unknown, callback: (err?: Error) => void) => {
572 if (value === '' || value === null || value === undefined) {
573 return callback()
574 }
575 const num = Number(value)
576 if (isNaN(num)) {
577 return callback(new Error(getLabel('enter_valid_number')))
578 }
579 const hasMin = field.minValue !== null && field.minValue !== undefined
580 const hasMax = field.maxValue !== null && field.maxValue !== undefined
581 if (hasMin && num < field.minValue) {
582 if (hasMax) {
583 const tpl = getLabel('number_out_of_range')
584 return callback(
585 new Error(
586 tpl
587 .replace('{min}', String(field.minValue))
588 .replace('{max}', String(field.maxValue)),
589 ),
590 )
591 }
592 return callback(new Error(getLabel('range_from') + ': ' + field.minValue))
593 }
594 if (hasMax && num > field.maxValue && field.minValue !== field.maxValue) {
595 if (hasMin) {
596 const tpl = getLabel('number_out_of_range')
597 return callback(
598 new Error(
599 tpl
600 .replace('{min}', String(field.minValue))
601 .replace('{max}', String(field.maxValue)),
602 ),
603 )
604 }
605 return callback(new Error(getLabel('range_to') + ': ' + field.maxValue))
606 }
607 callback()
608 },
609 trigger: ['change', 'blur', 'submit'],
610 })
611
612 // Real-time watcher for immediate feedback when exceeding limits
613 watch(
614 () => formValues.value[key],
615 (val) => {
616 const hasMin = field.minValue !== null && field.minValue !== undefined
617 const hasMax = field.maxValue !== null && field.maxValue !== undefined
618 if (val === '' || val === null || val === undefined) {
619 if (fieldErrors.value[key]) delete fieldErrors.value[key]
620 return
621 }
622 const num = Number(val)
623 if (isNaN(num)) {
624 fieldErrors.value[key] = getLabel('enter_valid_number')
625 return
626 }
627 if (
628 ((hasMin && num < field.minValue) || (hasMax && num > field.maxValue)) &&
629 field.minValue !== field.maxValue
630 ) {
631 if (hasMin && hasMax) {
632 const tpl = getLabel('number_out_of_range')
633 fieldErrors.value[key] = tpl
634 .replace('{min}', String(field.minValue))
635 .replace('{max}', String(field.maxValue))
636 } else if (hasMin && num < field.minValue) {
637 fieldErrors.value[key] = getLabel('range_from') + ': ' + field.minValue
638 } else if (hasMax && num > field.maxValue) {
639 fieldErrors.value[key] = getLabel('range_to') + ': ' + field.maxValue
640 }
641 return
642 }
643 if (fieldErrors.value[key]) delete fieldErrors.value[key]
644 },
645 )
646 }
647
648 formRules.value[key] = rules
649 }
650 // Email confirmation extension
651 if (field.type === 'email' && field.confirmFieldEnabled) {
652 const confirmKey = 'emailConfirm_' + field.fieldIndex
653 formValues.value[confirmKey] = ''
654 const confirmRules = []
655 confirmRules.push({
656 validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
657 const mainVal = formValues.value[key]
658 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
659 if (!mainVal) {
660 callback()
661 return
662 }
663 if (!value) {
664 callback(new Error(getLabel('enter_email_address')))
665 return
666 }
667 if (!emailRegex.test(value)) {
668 callback(new Error(getLabel('enter_email_address')))
669 return
670 }
671 if (mainVal && value !== mainVal) {
672 callback(new Error(getLabel('emails_do_not_match')))
673 return
674 }
675 callback()
676 },
677 trigger: ['change', 'blur', 'submit'],
678 })
679 formRules.value[confirmKey] = confirmRules
680 }
681 })
682 // Form is loaded, remove loader
683 isLoading.value = false
684 // add vanila js remove class from loader
685 const counter = (formWrapperRef.value as HTMLElement)
686 .closest('.ivyforms-frontend')
687 ?.getAttribute('data-counter')
688 const loader = document.querySelector(`.ivyforms-skeleton-${counter}`)
689 if (loader) {
690 loader.remove()
691 }
692 } else {
693 console.error(getLabel('invalid_form_data'), formData)
694 }
695 } else {
696 console.error(getLabel('form_data_not_found'))
697 // Even with error, don't show loader anymore
698 isLoading.value = false
699 }
700
701 // After loading form data, get the outer wrapper by ID and read data attribute
702 let counter = formData.value.counter || ''
703 if (!counter && formWrapperRef.value) {
704 const closest = formWrapperRef.value.closest('.ivyforms-frontend')
705 if (closest && closest.dataset.counter) {
706 counter = closest.dataset.counter
707 }
708 }
709 if (counter) {
710 const outerWrapper = document.getElementById('ivyforms-app-' + counter)
711 if (outerWrapper && outerWrapper.dataset) {
712 postId.value = outerWrapper.dataset.postId || ''
713 refererUrl.value = outerWrapper.dataset.referer || document.referrer || ''
714 }
715 }
716 })
717
718 function getRequiredMessage(field: Field): string {
719 const rm = field.requiredMessage
720 if (typeof rm === 'string' && rm.trim() !== '') {
721 return rm
722 }
723 let fieldMessage: string
724 fieldMessage = ucFirst(field.label || field.type)
725 fieldMessage += ` ${getLabel('required')}`
726 return fieldMessage
727 }
728
729 const ucFirst = (str: string) => (str ? str[0]?.toLocaleUpperCase() + str.slice(1) : '')
730
731 // Get the most common label position for the form
732 const getFormLabelPosition = (): 'left' | 'right' | 'top' => {
733 if (!formFields.value || formFields.value.length === 0) {
734 return 'top'
735 }
736
737 // Count label positions
738 const positionCounts = formFields.value.reduce((counts, field) => {
739 const position = field.labelPosition || 'top'
740 counts[position] = (counts[position] || 0) + 1
741 return counts
742 }, {})
743
744 // Find the most common position
745 const mostCommonPosition = Object.keys(positionCounts).reduce((a, b) =>
746 positionCounts[a] > positionCounts[b] ? a : b,
747 )
748
749 // Convert to Element Plus format
750 switch (mostCommonPosition) {
751 case 'left':
752 return 'left'
753 case 'right':
754 return 'right'
755 case 'top':
756 case 'inside':
757 default:
758 return 'top'
759 }
760 }
761
762 // Validate form and submit
763 const validateAndSubmit = async () => {
764 if (!formRef.value) return
765
766 await formRef.value.validate(
767 async (valid: boolean, errors: Record<string, { message: string }[]>) => {
768 if (valid) {
769 // Handle reCAPTCHA v3 execution before form submission
770 const hasRecaptchaField = formFields.value.some((field) => field.type === 'recaptcha')
771
772 if (hasRecaptchaField) {
773 // Find reCAPTCHA field component ref
774 const recaptchaFieldRef = formFieldsCollectorRefs.value?.find(
775 (ref: ComponentPublicInstance): ref is RecaptchaFieldComponent =>
776 ref &&
777 (ref as RecaptchaFieldComponent).$props &&
778 (ref as RecaptchaFieldComponent).$props.field &&
779 (ref as RecaptchaFieldComponent).$props.field.type === 'recaptcha',
780 )
781 if (recaptchaFieldRef && recaptchaFieldRef.executeRecaptcha) {
782 try {
783 // For v3, this will execute the reCAPTCHA and get a token
784 // For v2, this will return the current token
785 const token = await recaptchaFieldRef.executeRecaptcha()
786
787 // Update the form value with the token
788 const fieldKey = `recaptcha_${recaptchaFieldRef.$props.field.fieldIndex}`
789 formValues.value[fieldKey] = token
790 } catch (error) {
791 console.error('reCAPTCHA execution failed:', error)
792 // You might want to show an error message to the user here
793 return
794 }
795 }
796 }
797
798 submitForm()
799 } else {
800 fieldErrors.value = {} // Clear previous
801 Object.keys(errors).forEach((fieldKey) => {
802 const rawMsg = errors[fieldKey][0]?.message || getLabel('invalid_input')
803 let displayMsg = rawMsg
804
805 // If this is a Name field, store error on the specific subfield key
806 if (fieldKey.startsWith('name_')) {
807 const [parentKey, subfield] = fieldKey.split('.')
808
809 if (subfield) {
810 // This is a specific subfield error (e.g., name_1.nameField1)
811 const parts = parentKey.split('_')
812 const fieldIndex = Number(parts[1])
813 const field = formFields.value.find(
814 (f) => f.type === 'name' && Number(f.fieldIndex) === fieldIndex,
815 )
816
817 if (field) {
818 // Try to get the specific required message for better error message
819 const nfType = subfield
820 if (nfType && (field as Record<string, unknown>)[`${nfType}RequiredMessage`]) {
821 displayMsg =
822 (field as Record<string, string>)[`${nfType}RequiredMessage`] || rawMsg
823 } else if (nfType && (field as Record<string, unknown>)[`${nfType}Label`]) {
824 const sublabel = (field as Record<string, string>)[`${nfType}Label`]
825 displayMsg = sublabel ? `${sublabel} ${getLabel('required')}` : rawMsg
826 }
827 }
828
829 // Store error on the specific subfield key so NameFormField can target it
830 ;(fieldErrors.value as Record<string, string>)[fieldKey] = displayMsg
831 } else {
832 // Fallback for parent-level name errors
833 ;(fieldErrors.value as Record<string, string>)[fieldKey] = displayMsg
834 }
835 } else {
836 ;(fieldErrors.value as Record<string, string>)[fieldKey] = displayMsg
837 }
838 })
839 }
840 },
841 )
842 }
843
844 // Clear error when user starts typing
845 const clearFieldError = (fieldKey: string) => {
846 if ((fieldErrors.value as Record<string, unknown>)[fieldKey]) {
847 delete (fieldErrors.value as Record<string, unknown>)[fieldKey]
848 }
849 // Also clear parent key for name subfields like name_<index>.nameFieldX
850 if (fieldKey.includes('.')) {
851 const parentKey = fieldKey.split('.')[0]
852 if ((fieldErrors.value as Record<string, unknown>)[parentKey]) {
853 delete (fieldErrors.value as Record<string, unknown>)[parentKey]
854 }
855 }
856 }
857
858 // Provide the clearFieldError function to child components
859 provide('clearFieldError', clearFieldError)
860 // Provide reactive form values so nested fields (e.g., email confirmation) can mutate the same model
861 provide('formValues', formValues)
862
863 const resetForm = () => {
864 skipValidation.value = true
865 formValues.value = {}
866 nextTick(() => {
867 skipValidation.value = false
868 })
869 }
870 // Submit form
871 const submitForm = async () => {
872 // Clear previous errors before each submit
873 fieldErrors.value = {}
874 try {
875 const { data, error, status } = await request('form/submission/', {
876 method: 'POST',
877 headers: {
878 'Content-Type': 'application/json',
879 },
880 data: {
881 formId: formData.value.id,
882 values: formValues.value,
883 nonce: formNonce.value,
884 postId: postId.value,
885 referer: refererUrl.value,
886 },
887 })
888
889 // Check for duplicate value error from backend
890 if (data.data.data.is_duplicate) {
891 const duplicateFields = data.data.data.duplicate_errors
892
893 for (const field of formFields.value) {
894 const fieldKey = field.type + '_' + field.fieldIndex
895
896 if (duplicateFields[field.id]) {
897 const fieldValue = formValues.value[fieldKey]
898 if (fieldValue && fieldValue.toString().trim() !== '') {
899 ;(fieldErrors.value as Record<string, string>)[fieldKey] = getLabel(
900 'duplicate_value_error_label',
901 ).replace('{label}', field.label?.toLowerCase() || 'value')
902 }
903 }
904 }
905 return
906 }
907
908 // Handle response
909 if (status !== 200 || error) {
910 IvyMessage({
911 type: 'error',
912 message: getLabel('error_submitting_form') + (error ? error.message : data),
913 })
914 return
915 }
916
917 // Reset form or show success message
918 resetForm()
919 const type = formConfirmation.value[0].type
920 const enabled = formConfirmation.value[0].enabled
921 const showForm = formConfirmation.value[0].showForm
922
923 if (!enabled) return
924
925 if (type === 'successMessage') {
926 if (!showForm) {
927 showSuccessMessage.value = true
928 successMessage.value =
929 data.data.data.confirmation || formConfirmation.value[0].message || getLabel('thank_you')
930 return
931 } else {
932 showSuccessMessageReset.value = true
933 successMessage.value =
934 data.data.data.confirmation || formConfirmation.value[0].message || getLabel('thank_you')
935
936 // Hide the success message after 5 seconds
937 setTimeout(() => {
938 const messageElement = document.querySelector('.ivyforms-success-message')
939 if (messageElement) {
940 messageElement.classList.add('ivyforms-fade-out')
941 }
942 setTimeout(() => {
943 showSuccessMessageReset.value = false
944 }, 500) // Match the CSS transition duration
945 }, 5000)
946 }
947 } else if (type === 'redirectToPage' && formConfirmation.value[0].page) {
948 const pageId = formConfirmation.value[0].page
949 if (pageId) {
950 if (router) {
951 await router.push({ path: '/', query: { p: pageId } })
952 } else {
953 redirectToUrl(`/?p=${pageId}`)
954 }
955 }
956 } else if (type === 'redirectToCustomUrl' && formConfirmation.value[0].url) {
957 redirectToUrl(formConfirmation.value[0].url)
958 }
959 } catch (error) {
960 console.error(getLabel('error_submitting_form'), error)
961 // Show error message
962 }
963 }
964 </script>
965
966 <style lang="scss" scoped>
967 .ivyforms-form-wrapper {
968 width: 100%;
969 max-width: 720px;
970 margin: 0 auto;
971
972 .ivyforms-form-title {
973 font-size: 24px;
974 font-weight: 600;
975 margin-bottom: 12px;
976 color: var(--map-base-text-0);
977 }
978
979 .ivyforms-form-description {
980 font-size: 16px;
981 margin-bottom: 24px;
982 color: var(--map-base-text-1);
983 }
984
985 .ivyforms-field {
986 padding: 12px 0;
987 }
988
989 .ivyforms-form-submit {
990 margin-top: 12px;
991 }
992
993 .ivyforms-form-item {
994 .el-form-item {
995 // When labelPosition is 'left' - use left label class for positioning
996 &[class*='el-form-item--label-left']*='el-form-item--label-left'] {
997 flex-direction: row;
998 align-items: flex-start;
999
1000 .el-form-item__label {
1001 flex-shrink: 0;
1002 margin-right: 16px; // Add spacing between label and input
1003 margin-bottom: 0; // Remove bottom margin for side positioning
1004 min-width: 50px; // Ensure label has minimum width
1005
1006 // Style the left label when positioned on the left
1007 .ivyforms-form-item__left-label {
1008 align-items: center;
1009 display: flex;
1010 gap: 4px;
1011 }
1012 }
1013
1014 .el-form-item__content {
1015 flex: 1;
1016 }
1017 }
1018
1019 // When labelPosition is 'right' - use right label class for positioning
1020 &[class*='el-form-item--label-right']*='el-form-item--label-right'] {
1021 flex-direction: row;
1022 align-items: flex-start;
1023
1024 .el-form-item__label {
1025 flex-shrink: 0;
1026 margin-left: 16px; // Add spacing for right-positioned labels
1027 margin-right: 0;
1028 margin-bottom: 0;
1029 min-width: 50px;
1030 order: 2; // Move label to the right side
1031
1032 // Style the right label when positioned on the right
1033 .ivyforms-form-item__right-label {
1034 align-items: center;
1035 display: flex;
1036 gap: 4px;
1037 // Override the underline styling for positional right labels
1038 color: var(--map-base-text-0);
1039 text-decoration: none;
1040 margin-left: 0;
1041 }
1042 }
1043
1044 .el-form-item__content {
1045 flex: 1;
1046 order: 1; // Move content to the left side
1047 }
1048 }
1049 }
1050 }
1051
1052 ::v-deep(.ivyforms-success-message) {
1053 font-size: 20px;
1054 color: var(--map-base-brand-symbol-0);
1055 padding: 20px;
1056 background-color: var(--map-base-brand-o05);
1057 border: 1px solid var(--map-base-brand-symbol-0);
1058 border-radius: 8px;
1059 text-align: center;
1060 transition: opacity 0.5s ease-in-out;
1061 opacity: 1;
1062
1063 &.ivyforms-fade-out {
1064 opacity: 0;
1065 }
1066 }
1067 }
1068 </style>
1069