@asd20/ui-next 2.23.5 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,486 @@
1
+ <template>
2
+ <asd20-modal
3
+ :open="open"
4
+ :title="modalTitle"
5
+ icon="contact"
6
+ dismissable
7
+ class="asd20-public-form-modal"
8
+ @dismiss="dismiss"
9
+ >
10
+ <asd20-viewport scrollable>
11
+ <div
12
+ v-if="loadingDefinition"
13
+ class="asd20-public-form-modal__state"
14
+ role="status"
15
+ aria-live="polite"
16
+ >
17
+ <asd20-spinner size="sm" />
18
+ <span>Loading form…</span>
19
+ </div>
20
+
21
+ <div
22
+ v-else-if="definitionError"
23
+ ref="definitionError"
24
+ class="asd20-public-form-modal__feedback"
25
+ role="alert"
26
+ tabindex="-1"
27
+ >
28
+ {{ definitionError }}
29
+ </div>
30
+
31
+ <div
32
+ v-else-if="submissionSuccess"
33
+ class="asd20-public-form-modal__success"
34
+ role="status"
35
+ aria-live="polite"
36
+ >
37
+ {{ formDefinition.successMessage }}
38
+ </div>
39
+
40
+ <form
41
+ v-else-if="formDefinition"
42
+ novalidate
43
+ @submit.prevent="validateBeforeCaptcha"
44
+ >
45
+ <p
46
+ v-if="formDefinition.introduction"
47
+ class="asd20-public-form-modal__introduction"
48
+ >
49
+ {{ formDefinition.introduction }}
50
+ </p>
51
+
52
+ <div
53
+ v-if="errorEntries.length"
54
+ ref="errorSummary"
55
+ class="asd20-public-form-modal__error-summary"
56
+ role="alert"
57
+ tabindex="-1"
58
+ >
59
+ <p>Please correct the following fields:</p>
60
+ <ul>
61
+ <li
62
+ v-for="entry in errorEntries"
63
+ :key="entry.name"
64
+ >
65
+ <a
66
+ :href="`#${fieldId(entry.name)}`"
67
+ @click.prevent="focusField(entry.name)"
68
+ >{{ entry.messages[0] }}</a>
69
+ </li>
70
+ </ul>
71
+ </div>
72
+
73
+ <asd20-public-form-field
74
+ v-for="field in formDefinition.fields"
75
+ :key="field.name"
76
+ :field="field"
77
+ :input-id="fieldId(field.name)"
78
+ :model-value="values[field.name]"
79
+ :errors="validationErrors[field.name] || []"
80
+ @update:modelValue="updateValue(field.name, $event)"
81
+ />
82
+
83
+ <div
84
+ class="asd20-public-form-modal__honeypot"
85
+ aria-hidden="true"
86
+ >
87
+ <label :for="honeypotId">Website</label>
88
+ <input
89
+ :id="honeypotId"
90
+ v-model="honeypot"
91
+ type="text"
92
+ name="website"
93
+ tabindex="-1"
94
+ autocomplete="off"
95
+ />
96
+ </div>
97
+
98
+ <div
99
+ v-if="hasFallbackContact"
100
+ class="asd20-public-form-modal__fallback"
101
+ >
102
+ <p>
103
+ If you cannot submit this form, use one of these contact options:
104
+ </p>
105
+ <a
106
+ v-if="formDefinition.fallbackContact.email"
107
+ :href="`mailto:${formDefinition.fallbackContact.email}`"
108
+ >{{ formDefinition.fallbackContact.email }}</a>
109
+ <a
110
+ v-if="formDefinition.fallbackContact.phone"
111
+ :href="fallbackPhoneHref"
112
+ >{{ formDefinition.fallbackContact.phone }}</a>
113
+ </div>
114
+
115
+ <div
116
+ class="asd20-public-form-modal__submit"
117
+ aria-live="polite"
118
+ >
119
+ <template v-if="!sending">
120
+ <recaptcha
121
+ v-if="captchaEnabled"
122
+ :sitekey="captchaSitekey"
123
+ :load-recaptcha-script="loadRecaptchaScript"
124
+ size="invisible"
125
+ @verify="captchaVerified"
126
+ @error="captchaFailed"
127
+ @expired="captchaExpired"
128
+ >
129
+ <asd20-button
130
+ :label="formDefinition.submitLabel"
131
+ horizontal
132
+ centered
133
+ bordered
134
+ @click="validateBeforeCaptcha"
135
+ />
136
+ </recaptcha>
137
+ <asd20-button
138
+ v-else
139
+ :label="formDefinition.submitLabel"
140
+ horizontal
141
+ centered
142
+ bordered
143
+ @click="submitForm('')"
144
+ />
145
+ </template>
146
+ <asd20-spinner
147
+ v-else
148
+ size="sm"
149
+ />
150
+ </div>
151
+
152
+ <div
153
+ v-if="feedbackMessage"
154
+ ref="feedback"
155
+ class="asd20-public-form-modal__feedback"
156
+ :role="feedbackRole"
157
+ tabindex="-1"
158
+ >
159
+ {{ feedbackMessage }}
160
+ </div>
161
+ </form>
162
+ </asd20-viewport>
163
+ </asd20-modal>
164
+ </template>
165
+
166
+ <script>
167
+ import Asd20Button from '../../atoms/Asd20Button'
168
+ import Asd20Spinner from '../../atoms/Asd20Spinner'
169
+ import Asd20Viewport from '../../atoms/Asd20Viewport'
170
+ import Asd20Modal from '../../molecules/Asd20Modal'
171
+ import Asd20PublicFormField from '../../molecules/Asd20PublicFormField'
172
+ import Recaptcha from '../../utils/Recaptcha'
173
+ import {
174
+ createPublicFormValues,
175
+ normalizePublicFormDefinition,
176
+ validatePublicFormValues,
177
+ } from '../../../helpers/publicFormDefinition'
178
+ import {
179
+ fetchPublicFormDefinition,
180
+ normalizeFormId,
181
+ submitPublicForm,
182
+ } from '../../../helpers/publicForms'
183
+ import createComponentInstanceId from '../../../utils/createComponentInstanceId'
184
+
185
+ const DEFAULT_CAPTCHA_SITEKEY = '6LfidKoUAAAAAFqr3QEbia3jIkecsZyxBYlMvWrX'
186
+
187
+ export default {
188
+ name: 'Asd20PublicFormModal',
189
+ components: {
190
+ Asd20Button,
191
+ Asd20Modal,
192
+ Asd20PublicFormField,
193
+ Asd20Spinner,
194
+ Asd20Viewport,
195
+ Recaptcha,
196
+ },
197
+ props: {
198
+ open: { type: Boolean, default: false },
199
+ formId: { type: String, required: true },
200
+ definition: { type: Object, default: null },
201
+ endpoint: { type: String, default: '' },
202
+ functionsEndpoint: { type: String, default: '' },
203
+ captchaEnabled: { type: Boolean, default: true },
204
+ captchaSitekey: { type: String, default: DEFAULT_CAPTCHA_SITEKEY },
205
+ loadRecaptchaScript: { type: Boolean, default: true },
206
+ schemaClient: { type: Function, default: null },
207
+ submissionClient: { type: Function, default: null },
208
+ },
209
+ emits: ['dismiss', 'submitted', 'update:open'],
210
+ data() {
211
+ const instanceId = createComponentInstanceId('asd20-public-form')
212
+ return {
213
+ instanceId,
214
+ honeypotId: `${instanceId}-website`,
215
+ formDefinition: null,
216
+ values: {},
217
+ honeypot: '',
218
+ validationErrors: {},
219
+ validationAttempted: false,
220
+ loadingDefinition: false,
221
+ definitionError: '',
222
+ feedbackMessage: '',
223
+ feedbackRole: 'alert',
224
+ sending: false,
225
+ submissionSuccess: false,
226
+ loadSequence: 0,
227
+ }
228
+ },
229
+ computed: {
230
+ modalTitle() {
231
+ return this.formDefinition?.title || 'Public Form'
232
+ },
233
+ errorEntries() {
234
+ if (!this.formDefinition) return []
235
+ return this.formDefinition.fields
236
+ .filter(field => this.validationErrors[field.name]?.length)
237
+ .map(field => ({
238
+ name: field.name,
239
+ messages: this.validationErrors[field.name],
240
+ }))
241
+ },
242
+ hasFallbackContact() {
243
+ const fallback = this.formDefinition?.fallbackContact
244
+ return Boolean(fallback?.email || fallback?.phone)
245
+ },
246
+ fallbackPhoneHref() {
247
+ const phone = this.formDefinition?.fallbackContact?.phone || ''
248
+ return `tel:${phone.replace(/[^+\d]/g, '')}`
249
+ },
250
+ },
251
+ watch: {
252
+ open: {
253
+ immediate: true,
254
+ handler(value) {
255
+ if (value) this.prepareForm()
256
+ else this.loadSequence += 1
257
+ },
258
+ },
259
+ definition: {
260
+ deep: true,
261
+ handler() {
262
+ if (this.open) this.prepareForm()
263
+ },
264
+ },
265
+ formId() {
266
+ if (this.open) this.prepareForm()
267
+ },
268
+ },
269
+ methods: {
270
+ resolveRuntimeConfig() {
271
+ return (
272
+ this.$config ||
273
+ this.$?.appContext?.config?.globalProperties?.$config ||
274
+ {}
275
+ )
276
+ },
277
+ clientOptions() {
278
+ return {
279
+ endpoint: this.endpoint,
280
+ functionsEndpoint: this.functionsEndpoint,
281
+ runtimeConfig: this.resolveRuntimeConfig(),
282
+ }
283
+ },
284
+ async prepareForm() {
285
+ const sequence = ++this.loadSequence
286
+ this.loadingDefinition = true
287
+ this.definitionError = ''
288
+ this.feedbackMessage = ''
289
+ this.submissionSuccess = false
290
+ this.validationErrors = {}
291
+ this.validationAttempted = false
292
+ this.honeypot = ''
293
+
294
+ try {
295
+ const expectedId = normalizeFormId(this.formId)
296
+ const rawDefinition = this.definition
297
+ ? this.definition
298
+ : await (this.schemaClient || fetchPublicFormDefinition)(
299
+ expectedId,
300
+ this.clientOptions()
301
+ )
302
+ const normalized = normalizePublicFormDefinition(rawDefinition)
303
+ if (normalized.id !== expectedId)
304
+ throw new Error(
305
+ 'The public form definition does not match the request.'
306
+ )
307
+ if (sequence !== this.loadSequence) return
308
+
309
+ this.formDefinition = normalized
310
+ this.values = createPublicFormValues(normalized)
311
+ } catch (_error) {
312
+ if (sequence !== this.loadSequence) return
313
+ this.formDefinition = null
314
+ this.definitionError =
315
+ 'This form could not be loaded. Please try again later or use another contact option on this page.'
316
+ this.$nextTick(() => this.$refs.definitionError?.focus?.())
317
+ } finally {
318
+ if (sequence === this.loadSequence) this.loadingDefinition = false
319
+ }
320
+ },
321
+ fieldId(name) {
322
+ return `${this.instanceId}-${name}`
323
+ },
324
+ updateValue(name, value) {
325
+ this.values[name] = value
326
+ if (this.validationAttempted) this.validate(false)
327
+ },
328
+ validate(focusSummary = true) {
329
+ if (!this.formDefinition) return false
330
+ const result = validatePublicFormValues(this.formDefinition, this.values)
331
+ this.values = result.values
332
+ this.validationErrors = result.errors
333
+ this.validationAttempted = true
334
+
335
+ if (!result.valid && focusSummary) {
336
+ this.$nextTick(() => this.$refs.errorSummary?.focus?.())
337
+ }
338
+ return result.valid
339
+ },
340
+ validateBeforeCaptcha(event) {
341
+ if (this.sending || !this.validate()) {
342
+ event?.preventDefault?.()
343
+ return false
344
+ }
345
+ return true
346
+ },
347
+ focusField(name) {
348
+ document.getElementById(this.fieldId(name))?.focus?.()
349
+ },
350
+ captchaVerified(token) {
351
+ if (token) this.submitForm(token)
352
+ },
353
+ captchaFailed() {
354
+ this.showFeedback(
355
+ 'The verification service could not start. Please try again or use the contact options below.'
356
+ )
357
+ },
358
+ captchaExpired() {
359
+ this.showFeedback(
360
+ 'The verification expired. Please submit the form again.'
361
+ )
362
+ },
363
+ failureMessage(error) {
364
+ const status = Number(error?.response?.status || 0)
365
+ const serverMessage = error?.response?.data?.error
366
+ if (status === 400 && typeof serverMessage === 'string')
367
+ return serverMessage
368
+ if (status === 403)
369
+ return 'The verification was not accepted. Please try again.'
370
+ if (status === 429) {
371
+ return 'Too many submissions were received recently. Please wait and try again, or use the contact options below.'
372
+ }
373
+ if (status === 502) {
374
+ return 'The form could not be delivered right now. Please try again later or use the contact options below.'
375
+ }
376
+ if (status === 503) {
377
+ return 'This form is temporarily unavailable. Please use the contact options below.'
378
+ }
379
+ return 'Something went wrong while submitting the form. Please try again later or use the contact options below.'
380
+ },
381
+ showFeedback(message, role = 'alert') {
382
+ this.feedbackMessage = message
383
+ this.feedbackRole = role
384
+ this.$nextTick(() => this.$refs.feedback?.focus?.())
385
+ },
386
+ async submitForm(captchaToken = '') {
387
+ if (this.sending || !this.validate()) return
388
+ if (this.captchaEnabled && !captchaToken) {
389
+ this.showFeedback('Verification is required before submitting this form.')
390
+ return
391
+ }
392
+
393
+ this.sending = true
394
+ this.feedbackMessage = ''
395
+ try {
396
+ const payload = {
397
+ ...this.values,
398
+ website: this.honeypot,
399
+ captchaToken,
400
+ }
401
+ await (this.submissionClient || submitPublicForm)(
402
+ this.formDefinition.id,
403
+ payload,
404
+ this.clientOptions()
405
+ )
406
+ this.submissionSuccess = true
407
+ this.$emit('submitted', {
408
+ formId: this.formDefinition.id,
409
+ definitionVersion: this.formDefinition.version,
410
+ })
411
+ } catch (error) {
412
+ this.showFeedback(this.failureMessage(error))
413
+ } finally {
414
+ this.sending = false
415
+ }
416
+ },
417
+ dismiss() {
418
+ this.$emit('dismiss')
419
+ this.$emit('update:open', false)
420
+ },
421
+ },
422
+ }
423
+ </script>
424
+
425
+ <style lang="scss" scoped>
426
+ @use '../../../design/component-stack' as *;
427
+
428
+ .asd20-public-form-modal {
429
+ & :deep(.asd20-modal__content .asd20-viewport) {
430
+ padding: space(1);
431
+ }
432
+
433
+ &__state,
434
+ &__submit {
435
+ align-items: center;
436
+ display: flex;
437
+ gap: space(0.25);
438
+ justify-content: center;
439
+ min-height: space(2);
440
+ }
441
+
442
+ &__error-summary,
443
+ &__feedback,
444
+ &__success {
445
+ font-weight: 600;
446
+ margin: space(0.5) 0;
447
+ }
448
+
449
+ &__error-summary {
450
+ border: 2px solid asd20-swatch('emergency', -1);
451
+ padding: space(0.5);
452
+
453
+ p {
454
+ margin-top: 0;
455
+ }
456
+ }
457
+
458
+ &__success {
459
+ font-size: 1.1rem;
460
+ text-align: center;
461
+ }
462
+
463
+ &__fallback {
464
+ display: flex;
465
+ flex-direction: column;
466
+ margin: space(0.5) 0;
467
+
468
+ p {
469
+ margin-bottom: space(0.125);
470
+ }
471
+ }
472
+
473
+ &__feedback {
474
+ text-align: center;
475
+ }
476
+
477
+ &__honeypot {
478
+ clip: rect(0, 0, 0, 0);
479
+ height: 1px;
480
+ margin: -1px;
481
+ overflow: hidden;
482
+ position: absolute;
483
+ width: 1px;
484
+ }
485
+ }
486
+ </style>