@koehler8/cms 1.0.0-beta.9 → 1.1.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.
- package/CHANGELOG.md +1522 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +33 -17
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +365 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +82 -66
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +136 -36
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/notFound.js +52 -0
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +119 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +549 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- package/scripts/patch-lru-cache-tla.js +0 -164
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
</template>
|
|
49
49
|
|
|
50
50
|
<script setup>
|
|
51
|
-
import { computed,
|
|
51
|
+
import { computed, ref } from 'vue';
|
|
52
|
+
import { useFocusTrap } from '../composables/useFocusTrap.js';
|
|
52
53
|
|
|
53
54
|
const props = defineProps({
|
|
54
55
|
open: {
|
|
@@ -68,7 +69,6 @@ const props = defineProps({
|
|
|
68
69
|
const emit = defineEmits(['close']);
|
|
69
70
|
|
|
70
71
|
const dialogRef = ref(null);
|
|
71
|
-
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
72
72
|
const dialogTitleId = computed(() => 'coming-soon-title');
|
|
73
73
|
const dialogDescriptionId = computed(() => 'coming-soon-message');
|
|
74
74
|
|
|
@@ -76,58 +76,7 @@ const emitClose = () => {
|
|
|
76
76
|
emit('close');
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
if (!props.open) return;
|
|
81
|
-
if (typeof document === 'undefined') return;
|
|
82
|
-
if (event.key !== 'Tab') return;
|
|
83
|
-
const dialogEl = dialogRef.value;
|
|
84
|
-
if (!dialogEl) return;
|
|
85
|
-
const focusable = dialogEl.querySelectorAll(focusableSelector);
|
|
86
|
-
if (!focusable.length) {
|
|
87
|
-
event.preventDefault();
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const first = focusable[0];
|
|
91
|
-
const last = focusable[focusable.length - 1];
|
|
92
|
-
if (event.shiftKey && document.activeElement === first) {
|
|
93
|
-
event.preventDefault();
|
|
94
|
-
last.focus();
|
|
95
|
-
} else if (!event.shiftKey && document.activeElement === last) {
|
|
96
|
-
event.preventDefault();
|
|
97
|
-
first.focus();
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
watch(
|
|
102
|
-
() => props.open,
|
|
103
|
-
(isOpen) => {
|
|
104
|
-
if (typeof document === 'undefined') return;
|
|
105
|
-
if (isOpen) {
|
|
106
|
-
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
107
|
-
window.requestAnimationFrame(() => {
|
|
108
|
-
dialogRef.value?.focus();
|
|
109
|
-
});
|
|
110
|
-
} else {
|
|
111
|
-
dialogRef.value?.focus();
|
|
112
|
-
}
|
|
113
|
-
document.addEventListener('keydown', trapFocus);
|
|
114
|
-
} else {
|
|
115
|
-
document.removeEventListener('keydown', trapFocus);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
onMounted(() => {
|
|
121
|
-
if (typeof document === 'undefined') return;
|
|
122
|
-
if (props.open) {
|
|
123
|
-
document.addEventListener('keydown', trapFocus);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
onBeforeUnmount(() => {
|
|
128
|
-
if (typeof document === 'undefined') return;
|
|
129
|
-
document.removeEventListener('keydown', trapFocus);
|
|
130
|
-
});
|
|
79
|
+
useFocusTrap(dialogRef, () => props.open, { onEscape: emitClose });
|
|
131
80
|
</script>
|
|
132
81
|
|
|
133
82
|
<style scoped>
|
|
@@ -200,31 +149,54 @@ onBeforeUnmount(() => {
|
|
|
200
149
|
}
|
|
201
150
|
|
|
202
151
|
.coming-soon-cta:hover,
|
|
203
|
-
.coming-soon-cta:focus {
|
|
152
|
+
.coming-soon-cta:focus-visible {
|
|
204
153
|
opacity: 0.92;
|
|
205
154
|
transform: translateY(-1px);
|
|
206
155
|
}
|
|
207
156
|
|
|
208
|
-
.coming-soon-cta:focus {
|
|
157
|
+
.coming-soon-cta:focus-visible {
|
|
209
158
|
outline: 2px solid #111111;
|
|
210
159
|
outline-offset: 2px;
|
|
211
160
|
}
|
|
212
161
|
|
|
213
162
|
.coming-soon-close {
|
|
214
163
|
position: absolute;
|
|
215
|
-
top: 0.
|
|
216
|
-
right: 0.
|
|
164
|
+
top: 0.5rem;
|
|
165
|
+
right: 0.5rem;
|
|
217
166
|
background: transparent;
|
|
218
167
|
border: none;
|
|
219
168
|
font-size: 1.5rem;
|
|
220
169
|
line-height: 1;
|
|
221
170
|
cursor: pointer;
|
|
222
|
-
color:
|
|
223
|
-
|
|
171
|
+
/* AA-safe color (#595959 = 7:1 on white) and a 32×32 hit area for
|
|
172
|
+
2.5.8 Target Size (Minimum). */
|
|
173
|
+
color: #595959;
|
|
174
|
+
padding: 0;
|
|
175
|
+
min-width: 32px;
|
|
176
|
+
min-height: 32px;
|
|
177
|
+
display: inline-flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
border-radius: 6px;
|
|
224
181
|
}
|
|
225
182
|
|
|
226
183
|
.coming-soon-close:hover,
|
|
227
|
-
.coming-soon-close:focus {
|
|
184
|
+
.coming-soon-close:focus-visible {
|
|
228
185
|
color: #111111;
|
|
229
186
|
}
|
|
187
|
+
.coming-soon-close:focus-visible {
|
|
188
|
+
outline: 2px solid #1f2a44;
|
|
189
|
+
outline-offset: 2px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@media (prefers-reduced-motion: reduce) {
|
|
193
|
+
.coming-soon-cta {
|
|
194
|
+
transition: none;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.coming-soon-cta:hover,
|
|
198
|
+
.coming-soon-cta:focus-visible {
|
|
199
|
+
transform: none;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
230
202
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<section id="contact" class="contact-section ui-section ui-section--stacked" data-analytics-section="contact">
|
|
2
|
+
<section v-if="hasContent" id="contact" class="contact-section ui-section ui-section--stacked" data-analytics-section="contact">
|
|
3
3
|
<div class="container">
|
|
4
4
|
<header class="section-header text-center">
|
|
5
5
|
<div class="section-heading">
|
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
<p class="section-description">{{ subtitle }}</p>
|
|
10
10
|
</header>
|
|
11
11
|
<div class="contact-wrapper">
|
|
12
|
-
<form
|
|
12
|
+
<form
|
|
13
|
+
id="signup"
|
|
14
|
+
ref="formRef"
|
|
15
|
+
class="contact-form ui-card ui-card-surface"
|
|
16
|
+
:action="formAction"
|
|
17
|
+
method="POST"
|
|
18
|
+
@submit.prevent="handleSubmit"
|
|
19
|
+
>
|
|
13
20
|
<!-- Google Forms requires these hidden fields -->
|
|
14
|
-
<input type="hidden" name="entry.1210125274" id="siteToken">
|
|
15
21
|
<input type="hidden" name="fvv" :value="hiddenFields.fvv">
|
|
16
22
|
<!-- Replace the fbzx value with the real one from your form's HTML source or prefilled link -->
|
|
17
23
|
<input type="hidden" name="fbzx" :value="hiddenFields.fbzx">
|
|
@@ -21,55 +27,122 @@
|
|
|
21
27
|
|
|
22
28
|
<div class="contact-grid">
|
|
23
29
|
<div class="contact-field">
|
|
24
|
-
<
|
|
30
|
+
<label class="contact-label ui-label-sm" for="contact-name">
|
|
31
|
+
{{ placeholders.name }}<span class="contact-required" aria-hidden="true">*</span>
|
|
32
|
+
</label>
|
|
33
|
+
<input
|
|
34
|
+
id="contact-name"
|
|
35
|
+
class="ui-form-control"
|
|
36
|
+
type="text"
|
|
37
|
+
:placeholder="placeholders.name"
|
|
38
|
+
name="entry.757423084"
|
|
39
|
+
autocomplete="name"
|
|
40
|
+
required
|
|
41
|
+
aria-required="true"
|
|
42
|
+
>
|
|
25
43
|
</div>
|
|
26
44
|
<div class="contact-field">
|
|
27
|
-
<
|
|
45
|
+
<label class="contact-label ui-label-sm" for="contact-email">
|
|
46
|
+
{{ placeholders.email }}<span class="contact-required" aria-hidden="true">*</span>
|
|
47
|
+
</label>
|
|
48
|
+
<input
|
|
49
|
+
id="contact-email"
|
|
50
|
+
class="ui-form-control"
|
|
51
|
+
type="email"
|
|
52
|
+
:placeholder="placeholders.email"
|
|
53
|
+
name="entry.886201522"
|
|
54
|
+
autocomplete="email"
|
|
55
|
+
required
|
|
56
|
+
aria-required="true"
|
|
57
|
+
>
|
|
28
58
|
</div>
|
|
29
59
|
<div class="contact-field">
|
|
30
|
-
<
|
|
60
|
+
<label class="contact-label ui-label-sm" for="contact-subject">{{ placeholders.subject }}</label>
|
|
61
|
+
<input
|
|
62
|
+
id="contact-subject"
|
|
63
|
+
class="ui-form-control"
|
|
64
|
+
type="text"
|
|
65
|
+
:placeholder="placeholders.subject"
|
|
66
|
+
name="entry.270717970"
|
|
67
|
+
>
|
|
31
68
|
</div>
|
|
32
69
|
<div class="contact-field">
|
|
33
|
-
<
|
|
70
|
+
<label class="contact-label ui-label-sm" for="contact-phone">{{ placeholders.phone }}</label>
|
|
71
|
+
<input
|
|
72
|
+
id="contact-phone"
|
|
73
|
+
class="ui-form-control"
|
|
74
|
+
type="tel"
|
|
75
|
+
:placeholder="placeholders.phone"
|
|
76
|
+
name="entry.1570175907"
|
|
77
|
+
autocomplete="tel"
|
|
78
|
+
>
|
|
34
79
|
</div>
|
|
35
80
|
<div class="contact-field contact-field--full">
|
|
36
|
-
<
|
|
81
|
+
<label class="contact-label ui-label-sm" for="contact-message">
|
|
82
|
+
{{ placeholders.message }}<span class="contact-required" aria-hidden="true">*</span>
|
|
83
|
+
</label>
|
|
84
|
+
<textarea
|
|
85
|
+
id="contact-message"
|
|
86
|
+
class="ui-form-control ui-form-textarea"
|
|
87
|
+
rows="6"
|
|
88
|
+
:placeholder="placeholders.message"
|
|
89
|
+
name="entry.1743462629"
|
|
90
|
+
required
|
|
91
|
+
aria-required="true"
|
|
92
|
+
></textarea>
|
|
37
93
|
</div>
|
|
38
94
|
<div class="contact-field contact-field--full">
|
|
39
|
-
<label class="contact-label ui-label-sm"
|
|
95
|
+
<label class="contact-label ui-label-sm" for="contact-challenge">
|
|
96
|
+
{{ challenge.label }}<span class="contact-required" aria-hidden="true">*</span>
|
|
97
|
+
</label>
|
|
40
98
|
<small
|
|
41
99
|
v-if="challenge.instructions"
|
|
100
|
+
id="contact-challenge-hint"
|
|
42
101
|
class="contact-hint ui-form-hint"
|
|
43
102
|
:class="{ 'contact-hint--inline': challenge.label.length < 18 }"
|
|
44
103
|
>
|
|
45
104
|
{{ challenge.instructions }}
|
|
46
105
|
</small>
|
|
47
106
|
<div class="ui-input-group">
|
|
48
|
-
<span class="ui-input-group__addon">{{ challenge.question }}</span>
|
|
107
|
+
<span class="ui-input-group__addon" id="contact-challenge-prompt">{{ challenge.question }}</span>
|
|
49
108
|
<input
|
|
109
|
+
id="contact-challenge"
|
|
50
110
|
type="text"
|
|
51
111
|
class="ui-input-group__input"
|
|
52
112
|
:placeholder="challenge.placeholder"
|
|
53
113
|
v-model="challengeAnswer"
|
|
54
114
|
autocomplete="off"
|
|
55
115
|
required
|
|
116
|
+
aria-required="true"
|
|
117
|
+
:aria-describedby="challengeDescribedBy"
|
|
118
|
+
:aria-invalid="hasChallengeError ? 'true' : null"
|
|
56
119
|
>
|
|
57
120
|
</div>
|
|
58
121
|
</div>
|
|
59
122
|
</div>
|
|
60
123
|
|
|
124
|
+
<p class="contact-required-note">
|
|
125
|
+
<span class="contact-required" aria-hidden="true">*</span> Required fields
|
|
126
|
+
</p>
|
|
127
|
+
|
|
61
128
|
<div class="contact-actions">
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
>
|
|
68
|
-
{{ confirmationText }}
|
|
69
|
-
</div>
|
|
70
|
-
<button class="primary-button contact-submit" type="submit">{{ submitLabel }}</button>
|
|
129
|
+
<button
|
|
130
|
+
class="primary-button contact-submit"
|
|
131
|
+
type="submit"
|
|
132
|
+
:disabled="!isFormConfigured"
|
|
133
|
+
>{{ submitLabel }}</button>
|
|
71
134
|
</div>
|
|
72
|
-
<
|
|
135
|
+
<p v-if="!isFormConfigured" class="contact-feedback">
|
|
136
|
+
The contact form isn't accepting messages right now.
|
|
137
|
+
</p>
|
|
138
|
+
<p
|
|
139
|
+
id="msg"
|
|
140
|
+
class="contact-feedback"
|
|
141
|
+
:class="feedbackKind ? `contact-feedback--${feedbackKind}` : null"
|
|
142
|
+
role="status"
|
|
143
|
+
aria-live="polite"
|
|
144
|
+
aria-atomic="true"
|
|
145
|
+
>{{ feedbackText }}</p>
|
|
73
146
|
</form>
|
|
74
147
|
</div>
|
|
75
148
|
</div>
|
|
@@ -77,28 +150,32 @@
|
|
|
77
150
|
</template>
|
|
78
151
|
|
|
79
152
|
<script setup>
|
|
80
|
-
import { inject, onMounted,
|
|
153
|
+
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
|
81
154
|
import { trackEvent } from '../utils/analytics.js';
|
|
155
|
+
import { readStoredLocale } from '../utils/webStorage.js';
|
|
82
156
|
|
|
83
157
|
const title = ref('Contact Us')
|
|
84
158
|
const subtitle = ref("Send us a message and we'll keep you updated.")
|
|
85
159
|
const formAction = ref('')
|
|
86
160
|
const submitLabel = ref('Send Message')
|
|
87
161
|
const confirmationText = ref('Your message has been sent successfully.')
|
|
88
|
-
const isConfirmationVisible = ref(false)
|
|
89
162
|
const honeypotMessage = ref('Thanks!')
|
|
90
163
|
const pendingMessage = ref('Submitting…')
|
|
91
164
|
const successMessage = ref('Thanks! Your message was sent.')
|
|
92
165
|
const errorMessage = ref('Sorry—something went wrong. Please try again.')
|
|
93
166
|
|
|
94
|
-
|
|
167
|
+
// Single live feedback region (#msg): success, error, and pending states all
|
|
168
|
+
// announce through it exactly once.
|
|
169
|
+
const feedbackText = ref('')
|
|
170
|
+
const feedbackKind = ref('')
|
|
171
|
+
|
|
172
|
+
// A configured action URL is the only way a submission can go anywhere.
|
|
173
|
+
// Without one the form is inert and says so — previously it posted to the
|
|
174
|
+
// page's own URL and reported success.
|
|
175
|
+
const isFormConfigured = computed(() => /^https?:\/\//i.test(formAction.value || ''))
|
|
95
176
|
|
|
96
177
|
function getTrackedLocale() {
|
|
97
|
-
|
|
98
|
-
const stored = localStorage.getItem('locale');
|
|
99
|
-
if (stored) return stored;
|
|
100
|
-
} catch (_) {}
|
|
101
|
-
return 'default';
|
|
178
|
+
return readStoredLocale() || 'default';
|
|
102
179
|
}
|
|
103
180
|
|
|
104
181
|
const placeholders = reactive({
|
|
@@ -129,6 +206,14 @@ const challenge = reactive({
|
|
|
129
206
|
})
|
|
130
207
|
|
|
131
208
|
const challengeAnswer = ref('')
|
|
209
|
+
const hasChallengeError = ref(false)
|
|
210
|
+
|
|
211
|
+
const challengeDescribedBy = computed(() => {
|
|
212
|
+
const ids = ['contact-challenge-prompt']
|
|
213
|
+
if (challenge.instructions) ids.push('contact-challenge-hint')
|
|
214
|
+
if (hasChallengeError.value) ids.push('msg')
|
|
215
|
+
return ids.join(' ')
|
|
216
|
+
})
|
|
132
217
|
|
|
133
218
|
let expectedChallengeAnswer = ''
|
|
134
219
|
let activeChallengeType = 'arithmetic'
|
|
@@ -243,6 +328,11 @@ function normalizeChallengeType(type) {
|
|
|
243
328
|
}
|
|
244
329
|
|
|
245
330
|
function generateChallenge() {
|
|
331
|
+
// Deferred until mount: generating during setup ran on both the server and
|
|
332
|
+
// the client with different random values, so the prerendered question
|
|
333
|
+
// never matched what the client validated against — a guaranteed hydration
|
|
334
|
+
// mismatch on every page load.
|
|
335
|
+
if (!isMounted.value) return
|
|
246
336
|
const configuredTypes = Array.isArray(challenge.types) && challenge.types.length
|
|
247
337
|
? challenge.types
|
|
248
338
|
: [...DEFAULT_CHALLENGE_TYPES]
|
|
@@ -258,8 +348,6 @@ function generateChallenge() {
|
|
|
258
348
|
activeChallengeType = chosenType
|
|
259
349
|
}
|
|
260
350
|
|
|
261
|
-
generateChallenge()
|
|
262
|
-
|
|
263
351
|
function answersMatch(expected, received) {
|
|
264
352
|
const expectedValue = String(expected ?? '').trim()
|
|
265
353
|
const receivedValue = String(received ?? '').trim()
|
|
@@ -276,12 +364,16 @@ function isChallengeAnswerCorrect(answer) {
|
|
|
276
364
|
return answersMatch(expectedChallengeAnswer, answer)
|
|
277
365
|
}
|
|
278
366
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
let tokenField
|
|
367
|
+
const formRef = ref(null)
|
|
368
|
+
const isMounted = ref(false)
|
|
282
369
|
|
|
283
370
|
const pageContent = inject('pageContent', ref({}))
|
|
284
371
|
|
|
372
|
+
// components[] contract: no content.contact block → render nothing. A
|
|
373
|
+
// contact form can't work without configuration anyway (the default form
|
|
374
|
+
// action is empty), so an unconfigured Contact rendered a decoy form.
|
|
375
|
+
const hasContent = computed(() => Object.keys(pageContent.value?.contact || {}).length > 0)
|
|
376
|
+
|
|
285
377
|
const defaultTitle = title.value
|
|
286
378
|
const defaultSubtitle = subtitle.value
|
|
287
379
|
const defaultFormAction = formAction.value
|
|
@@ -314,7 +406,6 @@ watch(
|
|
|
314
406
|
formAction.value = formCopy.action ?? defaultFormAction
|
|
315
407
|
submitLabel.value = formCopy.submitLabel ?? defaultSubmitLabel
|
|
316
408
|
confirmationText.value = formCopy.confirmationText ?? defaultConfirmationText
|
|
317
|
-
isConfirmationVisible.value = false
|
|
318
409
|
honeypotMessage.value = formCopy.honeypotMessage ?? defaultHoneypot
|
|
319
410
|
pendingMessage.value = formCopy.pendingMessage ?? defaultPending
|
|
320
411
|
successMessage.value = formCopy.successMessage ?? defaultSuccess
|
|
@@ -354,103 +445,97 @@ watch(
|
|
|
354
445
|
)
|
|
355
446
|
|
|
356
447
|
onMounted(() => {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const resetTokenField = () => {
|
|
362
|
-
if (tokenField) {
|
|
363
|
-
tokenField.value = SUBMISSION_TOKEN
|
|
364
|
-
tokenField.defaultValue = SUBMISSION_TOKEN
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
resetTokenField()
|
|
448
|
+
isMounted.value = true
|
|
449
|
+
challengeAnswer.value = ''
|
|
450
|
+
generateChallenge()
|
|
451
|
+
})
|
|
369
452
|
|
|
370
|
-
|
|
453
|
+
async function handleSubmit() {
|
|
454
|
+
const formElement = formRef.value
|
|
455
|
+
if (!formElement) return
|
|
371
456
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
isConfirmationVisible.value = false
|
|
457
|
+
feedbackKind.value = ''
|
|
458
|
+
feedbackText.value = ''
|
|
375
459
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
trackEvent('contact_form_submit', params)
|
|
460
|
+
const startedAt = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
461
|
+
? performance.now()
|
|
462
|
+
: undefined
|
|
463
|
+
const baseParams = {
|
|
464
|
+
locale: getTrackedLocale()
|
|
465
|
+
}
|
|
466
|
+
const emitSubmitEvent = (status, extra = {}) => {
|
|
467
|
+
const params = { ...baseParams, status, challenge_type: activeChallengeType, ...extra }
|
|
468
|
+
if (startedAt !== undefined && typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
469
|
+
params.duration_ms = Math.round(performance.now() - startedAt)
|
|
388
470
|
}
|
|
471
|
+
trackEvent('contact_form_submit', params)
|
|
472
|
+
}
|
|
389
473
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const hp = formElement.querySelector('input[name="website"]')
|
|
393
|
-
if (hp && hp.value) {
|
|
394
|
-
msg.textContent = honeypotMessage.value
|
|
395
|
-
formElement.reset()
|
|
396
|
-
challengeAnswer.value = ''
|
|
397
|
-
generateChallenge()
|
|
398
|
-
resetTokenField()
|
|
399
|
-
trackEvent('contact_form_honeypot_triggered', {
|
|
400
|
-
...baseParams,
|
|
401
|
-
challenge_type: 'honeypot_field'
|
|
402
|
-
})
|
|
403
|
-
return
|
|
404
|
-
}
|
|
474
|
+
emitSubmitEvent('attempt')
|
|
405
475
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
476
|
+
if (!isFormConfigured.value) {
|
|
477
|
+
feedbackKind.value = 'error'
|
|
478
|
+
feedbackText.value = errorMessage.value
|
|
479
|
+
emitSubmitEvent('unconfigured')
|
|
480
|
+
return
|
|
481
|
+
}
|
|
413
482
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
msg.textContent = successMessage.value
|
|
427
|
-
formElement.reset()
|
|
428
|
-
challengeAnswer.value = ''
|
|
429
|
-
generateChallenge()
|
|
430
|
-
resetTokenField()
|
|
431
|
-
isConfirmationVisible.value = !!(confirmationText.value && confirmationText.value.trim())
|
|
432
|
-
emitSubmitEvent('success')
|
|
433
|
-
} catch (err) {
|
|
434
|
-
console.error('[Contact] Form submission failed:', err)
|
|
435
|
-
msg.textContent = errorMessage.value
|
|
436
|
-
emitSubmitEvent('error', {
|
|
437
|
-
error_name: err?.name || 'unknown',
|
|
438
|
-
error_code: err?.code || 'unknown'
|
|
439
|
-
})
|
|
440
|
-
challengeAnswer.value = ''
|
|
441
|
-
generateChallenge()
|
|
442
|
-
resetTokenField()
|
|
443
|
-
}
|
|
483
|
+
const hp = formElement.querySelector('input[name="website"]')
|
|
484
|
+
if (hp && hp.value) {
|
|
485
|
+
feedbackKind.value = 'success'
|
|
486
|
+
feedbackText.value = honeypotMessage.value
|
|
487
|
+
formElement.reset()
|
|
488
|
+
challengeAnswer.value = ''
|
|
489
|
+
generateChallenge()
|
|
490
|
+
trackEvent('contact_form_honeypot_triggered', {
|
|
491
|
+
...baseParams,
|
|
492
|
+
challenge_type: 'honeypot_field'
|
|
493
|
+
})
|
|
494
|
+
return
|
|
444
495
|
}
|
|
445
496
|
|
|
446
|
-
|
|
447
|
-
|
|
497
|
+
if (!isChallengeAnswerCorrect(challengeAnswer.value)) {
|
|
498
|
+
feedbackKind.value = 'error'
|
|
499
|
+
feedbackText.value = challenge.errorMessage
|
|
500
|
+
hasChallengeError.value = true
|
|
501
|
+
emitSubmitEvent('challenge_failed')
|
|
502
|
+
challengeAnswer.value = ''
|
|
503
|
+
generateChallenge()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
448
506
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
507
|
+
hasChallengeError.value = false
|
|
508
|
+
feedbackKind.value = 'pending'
|
|
509
|
+
feedbackText.value = pendingMessage.value
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const formData = new FormData(formElement)
|
|
513
|
+
await fetch(formAction.value, {
|
|
514
|
+
method: 'POST',
|
|
515
|
+
mode: 'no-cors',
|
|
516
|
+
body: formData
|
|
517
|
+
})
|
|
518
|
+
// no-cors responses are opaque: resolving means the request reached the
|
|
519
|
+
// endpoint, not that it was accepted — the strongest claim available.
|
|
520
|
+
feedbackKind.value = 'success'
|
|
521
|
+
feedbackText.value = (confirmationText.value || '').trim() || successMessage.value
|
|
522
|
+
formElement.reset()
|
|
523
|
+
challengeAnswer.value = ''
|
|
524
|
+
generateChallenge()
|
|
525
|
+
emitSubmitEvent('success')
|
|
526
|
+
} catch (err) {
|
|
527
|
+
// Network-level failure: report it and keep everything the visitor typed.
|
|
528
|
+
console.error('[Contact] Form submission failed:', err)
|
|
529
|
+
feedbackKind.value = 'error'
|
|
530
|
+
feedbackText.value = errorMessage.value
|
|
531
|
+
emitSubmitEvent('error', {
|
|
532
|
+
error_name: err?.name || 'unknown',
|
|
533
|
+
error_code: err?.code || 'unknown'
|
|
534
|
+
})
|
|
535
|
+
challengeAnswer.value = ''
|
|
536
|
+
generateChallenge()
|
|
452
537
|
}
|
|
453
|
-
}
|
|
538
|
+
}
|
|
454
539
|
</script>
|
|
455
540
|
|
|
456
541
|
<style scoped>
|
|
@@ -491,6 +576,17 @@ onUnmounted(() => {
|
|
|
491
576
|
color: var(--contact-label-color, var(--ui-text-primary, #1f2a44));
|
|
492
577
|
}
|
|
493
578
|
|
|
579
|
+
.contact-required {
|
|
580
|
+
margin-left: 0.2em;
|
|
581
|
+
color: var(--contact-required-color, #a83838); /* AA-safe critical text */
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.contact-required-note {
|
|
585
|
+
margin: -8px 0 0;
|
|
586
|
+
font-size: 0.8rem;
|
|
587
|
+
color: var(--contact-required-note-color, var(--ui-text-muted, #54627b));
|
|
588
|
+
}
|
|
589
|
+
|
|
494
590
|
.contact-hint--inline {
|
|
495
591
|
display: inline-block;
|
|
496
592
|
margin-left: 6px;
|