@koehler8/cms 1.0.0-beta.10
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/cms-generate-public-assets.js +27 -0
- package/bin/cms-validate-extensions.js +18 -0
- package/bin/cms-validate-themes.js +7 -0
- package/extensions/manifest.schema.json +125 -0
- package/package.json +88 -0
- package/public/img/preloaders/preloader-black.svg +1 -0
- package/public/img/preloaders/preloader-white.svg +1 -0
- package/public/robots.txt +5 -0
- package/scripts/check-overflow.mjs +33 -0
- package/scripts/generate-public-assets.js +412 -0
- package/scripts/migrate-to-content-dirs.mjs +223 -0
- package/scripts/patch-lru-cache-tla.js +164 -0
- package/scripts/validate-extensions.mjs +392 -0
- package/scripts/validate-themes.mjs +64 -0
- package/src/App.vue +3 -0
- package/src/components/About.vue +481 -0
- package/src/components/AboutValue.vue +361 -0
- package/src/components/BackToTop.vue +42 -0
- package/src/components/ComingSoon.vue +411 -0
- package/src/components/ComingSoonModal.vue +230 -0
- package/src/components/Contact.vue +518 -0
- package/src/components/Footer.vue +65 -0
- package/src/components/FooterMinimal.vue +153 -0
- package/src/components/Header.vue +583 -0
- package/src/components/Hero.vue +327 -0
- package/src/components/Home.vue +161 -0
- package/src/components/Intro.vue +130 -0
- package/src/components/IntroGate.vue +444 -0
- package/src/components/Plan.vue +116 -0
- package/src/components/Portfolio.vue +459 -0
- package/src/components/Preloader.vue +20 -0
- package/src/components/Principles.vue +67 -0
- package/src/components/Spacer15.vue +9 -0
- package/src/components/Spacer30.vue +9 -0
- package/src/components/Spacer40.vue +9 -0
- package/src/components/Spacer60.vue +9 -0
- package/src/components/StickyCTA.vue +263 -0
- package/src/components/Team.vue +432 -0
- package/src/components/icons/IconLinkedIn.vue +22 -0
- package/src/components/icons/IconX.vue +22 -0
- package/src/components/ui/SbCard.vue +52 -0
- package/src/components/ui/SkeletonPulse.vue +117 -0
- package/src/components/ui/UnitChip.vue +69 -0
- package/src/composables/useComingSoonConfig.js +120 -0
- package/src/composables/useComingSoonInterstitial.js +27 -0
- package/src/composables/useComponentResolver.js +235 -0
- package/src/composables/useEngagementTracking.js +187 -0
- package/src/composables/useIntroGate.js +46 -0
- package/src/composables/useLazyImage.js +77 -0
- package/src/composables/usePageConfig.js +184 -0
- package/src/composables/usePageMeta.js +76 -0
- package/src/composables/usePromoBackgroundStyles.js +67 -0
- package/src/constants/locales.js +20 -0
- package/src/extensions/extensionLoader.js +512 -0
- package/src/main.js +187 -0
- package/src/router/index.js +112 -0
- package/src/styles/base.css +896 -0
- package/src/styles/layout.css +342 -0
- package/src/styles/theme-base.css +84 -0
- package/src/themes/buildCssVarMap.js +246 -0
- package/src/themes/themeLoader.js +156 -0
- package/src/themes/themeManager.js +34 -0
- package/src/themes/themeValidator.js +380 -0
- package/src/utils/analytics.js +100 -0
- package/src/utils/appInfo.js +9 -0
- package/src/utils/assetResolver.js +162 -0
- package/src/utils/componentRegistry.js +146 -0
- package/src/utils/contentRequirements.js +67 -0
- package/src/utils/cookieConsent.js +281 -0
- package/src/utils/ctaCopy.js +58 -0
- package/src/utils/formatNumber.js +115 -0
- package/src/utils/imageSources.js +179 -0
- package/src/utils/inflateFlatConfig.js +30 -0
- package/src/utils/loadConfig.js +319 -0
- package/src/utils/semver.js +49 -0
- package/src/utils/siteStyles.js +40 -0
- package/src/utils/themeColors.js +65 -0
- package/src/utils/trackingContext.js +142 -0
- package/src/utils/unwrapDefault.js +14 -0
- package/src/utils/useScrollReveal.js +48 -0
- package/templates/index.html +36 -0
- package/themes/base/README.md +23 -0
- package/themes/base/theme.config.js +214 -0
- package/vite-plugin.js +726 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section id="contact" class="contact-section ui-section ui-section--stacked" data-analytics-section="contact">
|
|
3
|
+
<div class="container">
|
|
4
|
+
<header class="section-header text-center">
|
|
5
|
+
<div class="section-heading">
|
|
6
|
+
<h2 class="display-heading">{{ title }}</h2>
|
|
7
|
+
<span class="section-divider" aria-hidden="true"></span>
|
|
8
|
+
</div>
|
|
9
|
+
<p class="section-description">{{ subtitle }}</p>
|
|
10
|
+
</header>
|
|
11
|
+
<div class="contact-wrapper">
|
|
12
|
+
<form id="signup" class="contact-form ui-card ui-card-surface" :action="formAction" method="POST">
|
|
13
|
+
<!-- Google Forms requires these hidden fields -->
|
|
14
|
+
<input type="hidden" name="entry.1210125274" id="siteToken">
|
|
15
|
+
<input type="hidden" name="fvv" :value="hiddenFields.fvv">
|
|
16
|
+
<!-- Replace the fbzx value with the real one from your form's HTML source or prefilled link -->
|
|
17
|
+
<input type="hidden" name="fbzx" :value="hiddenFields.fbzx">
|
|
18
|
+
<input type="hidden" name="pageHistory" :value="hiddenFields.pageHistory">
|
|
19
|
+
<!-- Simple honeypot to deter bots -->
|
|
20
|
+
<input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
|
|
21
|
+
|
|
22
|
+
<div class="contact-grid">
|
|
23
|
+
<div class="contact-field">
|
|
24
|
+
<input class="ui-form-control" type="text" :placeholder="placeholders.name" name="entry.757423084" required>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="contact-field">
|
|
27
|
+
<input class="ui-form-control" type="email" :placeholder="placeholders.email" name="entry.886201522" required>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="contact-field">
|
|
30
|
+
<input class="ui-form-control" type="text" :placeholder="placeholders.subject" name="entry.270717970">
|
|
31
|
+
</div>
|
|
32
|
+
<div class="contact-field">
|
|
33
|
+
<input class="ui-form-control" type="tel" :placeholder="placeholders.phone" name="entry.1570175907">
|
|
34
|
+
</div>
|
|
35
|
+
<div class="contact-field contact-field--full">
|
|
36
|
+
<textarea class="ui-form-control ui-form-textarea" rows="6" :placeholder="placeholders.message" name="entry.1743462629" required></textarea>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="contact-field contact-field--full">
|
|
39
|
+
<label class="contact-label ui-label-sm">{{ challenge.label }}</label>
|
|
40
|
+
<small
|
|
41
|
+
v-if="challenge.instructions"
|
|
42
|
+
class="contact-hint ui-form-hint"
|
|
43
|
+
:class="{ 'contact-hint--inline': challenge.label.length < 18 }"
|
|
44
|
+
>
|
|
45
|
+
{{ challenge.instructions }}
|
|
46
|
+
</small>
|
|
47
|
+
<div class="ui-input-group">
|
|
48
|
+
<span class="ui-input-group__addon">{{ challenge.question }}</span>
|
|
49
|
+
<input
|
|
50
|
+
type="text"
|
|
51
|
+
class="ui-input-group__input"
|
|
52
|
+
:placeholder="challenge.placeholder"
|
|
53
|
+
v-model="challengeAnswer"
|
|
54
|
+
autocomplete="off"
|
|
55
|
+
required
|
|
56
|
+
>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="contact-actions">
|
|
62
|
+
<div
|
|
63
|
+
v-if="isConfirmationVisible"
|
|
64
|
+
class="submitted"
|
|
65
|
+
role="status"
|
|
66
|
+
aria-live="polite"
|
|
67
|
+
>
|
|
68
|
+
{{ confirmationText }}
|
|
69
|
+
</div>
|
|
70
|
+
<button class="primary-button contact-submit" type="submit">{{ submitLabel }}</button>
|
|
71
|
+
</div>
|
|
72
|
+
<small id="msg" class="contact-feedback"></small>
|
|
73
|
+
</form>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<script setup>
|
|
80
|
+
import { inject, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
81
|
+
import { trackEvent } from '../utils/analytics.js';
|
|
82
|
+
|
|
83
|
+
const title = ref('Contact Us')
|
|
84
|
+
const subtitle = ref("Send us a message and we'll keep you updated.")
|
|
85
|
+
const formAction = ref('')
|
|
86
|
+
const submitLabel = ref('Send Message')
|
|
87
|
+
const confirmationText = ref('Your message has been sent successfully.')
|
|
88
|
+
const isConfirmationVisible = ref(false)
|
|
89
|
+
const honeypotMessage = ref('Thanks!')
|
|
90
|
+
const pendingMessage = ref('Submitting…')
|
|
91
|
+
const successMessage = ref('Thanks! Your message was sent.')
|
|
92
|
+
const errorMessage = ref('Sorry—something went wrong. Please try again.')
|
|
93
|
+
|
|
94
|
+
const SUBMISSION_TOKEN = '';
|
|
95
|
+
|
|
96
|
+
function getTrackedLocale() {
|
|
97
|
+
try {
|
|
98
|
+
const stored = localStorage.getItem('locale');
|
|
99
|
+
if (stored) return stored;
|
|
100
|
+
} catch (_) {}
|
|
101
|
+
return 'default';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const placeholders = reactive({
|
|
105
|
+
name: 'Enter Your Name',
|
|
106
|
+
email: 'Enter Your E-Mail',
|
|
107
|
+
subject: 'Enter Your Subject',
|
|
108
|
+
phone: 'Enter Your Phone',
|
|
109
|
+
message: 'Enter Your Message'
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Google Forms hidden fields — configure via site config (contact.form.hidden)
|
|
113
|
+
const hiddenFields = reactive({
|
|
114
|
+
fvv: '1',
|
|
115
|
+
fbzx: '',
|
|
116
|
+
pageHistory: '0'
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const DEFAULT_CHALLENGE_TYPES = ['arithmetic', 'wordPosition', 'digitSum']
|
|
120
|
+
|
|
121
|
+
const challenge = reactive({
|
|
122
|
+
label: 'Spam Check',
|
|
123
|
+
placeholder: 'Enter answer',
|
|
124
|
+
instructions: 'Solve the verification prompt to continue.',
|
|
125
|
+
template: 'What is {a} + {b}?',
|
|
126
|
+
question: '',
|
|
127
|
+
errorMessage: 'Please answer the spam check correctly.',
|
|
128
|
+
types: [...DEFAULT_CHALLENGE_TYPES]
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const challengeAnswer = ref('')
|
|
132
|
+
|
|
133
|
+
let expectedChallengeAnswer = ''
|
|
134
|
+
let activeChallengeType = 'arithmetic'
|
|
135
|
+
|
|
136
|
+
function secureRandomInt(min, max) {
|
|
137
|
+
const lower = Math.ceil(min)
|
|
138
|
+
const upper = Math.floor(max)
|
|
139
|
+
if (upper <= lower) return lower
|
|
140
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
141
|
+
const range = upper - lower + 1
|
|
142
|
+
const maxUint32 = 0xffffffff
|
|
143
|
+
const limit = maxUint32 - (maxUint32 % range)
|
|
144
|
+
const buffer = new Uint32Array(1)
|
|
145
|
+
let randomValue = 0
|
|
146
|
+
do {
|
|
147
|
+
crypto.getRandomValues(buffer)
|
|
148
|
+
randomValue = buffer[0]
|
|
149
|
+
} while (randomValue >= limit)
|
|
150
|
+
return lower + (randomValue % range)
|
|
151
|
+
}
|
|
152
|
+
return lower + Math.floor(Math.random() * (upper - lower + 1))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pickRandom(list) {
|
|
156
|
+
if (!Array.isArray(list) || list.length === 0) return undefined
|
|
157
|
+
const index = secureRandomInt(0, list.length - 1)
|
|
158
|
+
return list[index]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ORDINAL_WORDS = [
|
|
162
|
+
'',
|
|
163
|
+
'first',
|
|
164
|
+
'second',
|
|
165
|
+
'third',
|
|
166
|
+
'fourth',
|
|
167
|
+
'fifth',
|
|
168
|
+
'sixth',
|
|
169
|
+
'seventh',
|
|
170
|
+
'eighth',
|
|
171
|
+
'ninth',
|
|
172
|
+
'tenth'
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
const challengeStrategies = {
|
|
176
|
+
arithmetic({ template }) {
|
|
177
|
+
const operator = pickRandom(['+', '-', '×']) || '+'
|
|
178
|
+
let a = secureRandomInt(3, 12)
|
|
179
|
+
let b = secureRandomInt(2, 11)
|
|
180
|
+
let result
|
|
181
|
+
if (operator === '-') {
|
|
182
|
+
if (a < b) [a, b] = [b, a]
|
|
183
|
+
result = a - b
|
|
184
|
+
} else if (operator === '×') {
|
|
185
|
+
a = secureRandomInt(2, 9)
|
|
186
|
+
b = secureRandomInt(2, 9)
|
|
187
|
+
result = a * b
|
|
188
|
+
} else {
|
|
189
|
+
result = a + b
|
|
190
|
+
}
|
|
191
|
+
const questionTemplate = typeof template === 'string' && template.trim()
|
|
192
|
+
? template
|
|
193
|
+
: 'What is {a} {op} {b}?'
|
|
194
|
+
const question = questionTemplate
|
|
195
|
+
.replace('{a}', a)
|
|
196
|
+
.replace('{b}', b)
|
|
197
|
+
.replace('{op}', operator)
|
|
198
|
+
return {
|
|
199
|
+
question,
|
|
200
|
+
expectedAnswer: String(result)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
wordPosition() {
|
|
204
|
+
const words = ['harmony', 'melody', 'studio', 'galaxy', 'token', 'future', 'signal', 'creative', 'vault', 'design']
|
|
205
|
+
const word = pickRandom(words) || 'harmony'
|
|
206
|
+
const maxIndex = Math.min(word.length, ORDINAL_WORDS.length - 1)
|
|
207
|
+
const position = secureRandomInt(1, Math.max(2, maxIndex))
|
|
208
|
+
const question = `Type the ${ORDINAL_WORDS[position]} letter of "${word.toUpperCase()}".`
|
|
209
|
+
return {
|
|
210
|
+
question,
|
|
211
|
+
expectedAnswer: word.charAt(position - 1)
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
digitSum() {
|
|
215
|
+
const value = secureRandomInt(200, 9999)
|
|
216
|
+
const digits = String(value).split('')
|
|
217
|
+
const sum = digits.reduce((acc, digit) => acc + Number(digit || 0), 0)
|
|
218
|
+
return {
|
|
219
|
+
question: `Add the digits of ${value}.`,
|
|
220
|
+
expectedAnswer: String(sum)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function normalizeChallengeType(type) {
|
|
226
|
+
if (typeof type !== 'string') return ''
|
|
227
|
+
const normalized = type.replace(/[_\-\s]/g, '').toLowerCase()
|
|
228
|
+
switch (normalized) {
|
|
229
|
+
case 'arithmetic':
|
|
230
|
+
case 'math':
|
|
231
|
+
return 'arithmetic'
|
|
232
|
+
case 'wordposition':
|
|
233
|
+
case 'letter':
|
|
234
|
+
case 'letterposition':
|
|
235
|
+
return 'wordPosition'
|
|
236
|
+
case 'digitsum':
|
|
237
|
+
case 'sumdigits':
|
|
238
|
+
case 'sumofdigits':
|
|
239
|
+
return 'digitSum'
|
|
240
|
+
default:
|
|
241
|
+
return ''
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function generateChallenge() {
|
|
246
|
+
const configuredTypes = Array.isArray(challenge.types) && challenge.types.length
|
|
247
|
+
? challenge.types
|
|
248
|
+
: [...DEFAULT_CHALLENGE_TYPES]
|
|
249
|
+
const availableTypes = configuredTypes
|
|
250
|
+
.map(normalizeChallengeType)
|
|
251
|
+
.filter((type) => type && challengeStrategies[type])
|
|
252
|
+
const activeTypes = availableTypes.length ? availableTypes : [...DEFAULT_CHALLENGE_TYPES]
|
|
253
|
+
const chosenType = pickRandom(activeTypes) || 'arithmetic'
|
|
254
|
+
const strategy = challengeStrategies[chosenType] || challengeStrategies.arithmetic
|
|
255
|
+
const { question, expectedAnswer } = strategy({ template: challenge.template })
|
|
256
|
+
challenge.question = question
|
|
257
|
+
expectedChallengeAnswer = expectedAnswer != null ? String(expectedAnswer) : ''
|
|
258
|
+
activeChallengeType = chosenType
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
generateChallenge()
|
|
262
|
+
|
|
263
|
+
function answersMatch(expected, received) {
|
|
264
|
+
const expectedValue = String(expected ?? '').trim()
|
|
265
|
+
const receivedValue = String(received ?? '').trim()
|
|
266
|
+
if (!expectedValue) return false
|
|
267
|
+
const expectedNumber = Number(expectedValue)
|
|
268
|
+
if (!Number.isNaN(expectedNumber)) {
|
|
269
|
+
const receivedNumber = Number(receivedValue)
|
|
270
|
+
return !Number.isNaN(receivedNumber) && receivedNumber === expectedNumber
|
|
271
|
+
}
|
|
272
|
+
return receivedValue.toLowerCase() === expectedValue.toLowerCase()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isChallengeAnswerCorrect(answer) {
|
|
276
|
+
return answersMatch(expectedChallengeAnswer, answer)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let formElement
|
|
280
|
+
let submitHandler
|
|
281
|
+
let tokenField
|
|
282
|
+
|
|
283
|
+
const pageContent = inject('pageContent', ref({}))
|
|
284
|
+
|
|
285
|
+
const defaultTitle = title.value
|
|
286
|
+
const defaultSubtitle = subtitle.value
|
|
287
|
+
const defaultFormAction = formAction.value
|
|
288
|
+
const defaultSubmitLabel = submitLabel.value
|
|
289
|
+
const defaultConfirmationText = confirmationText.value
|
|
290
|
+
const defaultHoneypot = honeypotMessage.value
|
|
291
|
+
const defaultPending = pendingMessage.value
|
|
292
|
+
const defaultSuccess = successMessage.value
|
|
293
|
+
const defaultError = errorMessage.value
|
|
294
|
+
const defaultPlaceholders = { ...placeholders }
|
|
295
|
+
const defaultHiddenFields = { ...hiddenFields }
|
|
296
|
+
const defaultChallenge = {
|
|
297
|
+
label: challenge.label,
|
|
298
|
+
placeholder: challenge.placeholder,
|
|
299
|
+
instructions: challenge.instructions,
|
|
300
|
+
template: challenge.template,
|
|
301
|
+
errorMessage: challenge.errorMessage,
|
|
302
|
+
types: [...challenge.types]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
watch(
|
|
306
|
+
() => pageContent.value,
|
|
307
|
+
(content) => {
|
|
308
|
+
const contact = content?.contact ?? {}
|
|
309
|
+
|
|
310
|
+
title.value = contact.title ?? defaultTitle
|
|
311
|
+
subtitle.value = contact.subtitle ?? defaultSubtitle
|
|
312
|
+
|
|
313
|
+
const formCopy = contact.form ?? {}
|
|
314
|
+
formAction.value = formCopy.action ?? defaultFormAction
|
|
315
|
+
submitLabel.value = formCopy.submitLabel ?? defaultSubmitLabel
|
|
316
|
+
confirmationText.value = formCopy.confirmationText ?? defaultConfirmationText
|
|
317
|
+
isConfirmationVisible.value = false
|
|
318
|
+
honeypotMessage.value = formCopy.honeypotMessage ?? defaultHoneypot
|
|
319
|
+
pendingMessage.value = formCopy.pendingMessage ?? defaultPending
|
|
320
|
+
successMessage.value = formCopy.successMessage ?? defaultSuccess
|
|
321
|
+
errorMessage.value = formCopy.errorMessage ?? defaultError
|
|
322
|
+
|
|
323
|
+
const fields = formCopy.fields ?? {}
|
|
324
|
+
placeholders.name = fields.name ?? defaultPlaceholders.name
|
|
325
|
+
placeholders.email = fields.email ?? defaultPlaceholders.email
|
|
326
|
+
placeholders.subject = fields.subject ?? defaultPlaceholders.subject
|
|
327
|
+
placeholders.phone = fields.phone ?? defaultPlaceholders.phone
|
|
328
|
+
placeholders.message = fields.message ?? defaultPlaceholders.message
|
|
329
|
+
|
|
330
|
+
const hidden = formCopy.hidden ?? {}
|
|
331
|
+
hiddenFields.fvv = hidden.fvv ?? defaultHiddenFields.fvv
|
|
332
|
+
hiddenFields.fbzx = hidden.fbzx ?? defaultHiddenFields.fbzx
|
|
333
|
+
hiddenFields.pageHistory = hidden.pageHistory ?? defaultHiddenFields.pageHistory
|
|
334
|
+
|
|
335
|
+
const challengeCopy = formCopy.challenge ?? {}
|
|
336
|
+
challenge.label = challengeCopy.label ?? defaultChallenge.label
|
|
337
|
+
challenge.placeholder = challengeCopy.placeholder ?? defaultChallenge.placeholder
|
|
338
|
+
|
|
339
|
+
challenge.instructions = challengeCopy.instructions ?? defaultChallenge.instructions
|
|
340
|
+
challenge.template = challengeCopy.template ?? defaultChallenge.template
|
|
341
|
+
challenge.errorMessage = challengeCopy.errorMessage ?? defaultChallenge.errorMessage
|
|
342
|
+
|
|
343
|
+
const configuredTypes = Array.isArray(challengeCopy.types) && challengeCopy.types.length
|
|
344
|
+
? challengeCopy.types
|
|
345
|
+
: defaultChallenge.types
|
|
346
|
+
const normalizedTypes = configuredTypes
|
|
347
|
+
.map(normalizeChallengeType)
|
|
348
|
+
.filter(Boolean)
|
|
349
|
+
challenge.types = normalizedTypes.length ? normalizedTypes : [...DEFAULT_CHALLENGE_TYPES]
|
|
350
|
+
challengeAnswer.value = ''
|
|
351
|
+
generateChallenge()
|
|
352
|
+
},
|
|
353
|
+
{ immediate: true }
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
onMounted(() => {
|
|
357
|
+
formElement = document.getElementById('signup')
|
|
358
|
+
const msg = document.getElementById('msg')
|
|
359
|
+
tokenField = document.getElementById('siteToken')
|
|
360
|
+
|
|
361
|
+
const resetTokenField = () => {
|
|
362
|
+
if (tokenField) {
|
|
363
|
+
tokenField.value = SUBMISSION_TOKEN
|
|
364
|
+
tokenField.defaultValue = SUBMISSION_TOKEN
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
resetTokenField()
|
|
369
|
+
|
|
370
|
+
if (!formElement || !msg) return
|
|
371
|
+
|
|
372
|
+
submitHandler = async (e) => {
|
|
373
|
+
e.preventDefault()
|
|
374
|
+
isConfirmationVisible.value = false
|
|
375
|
+
|
|
376
|
+
const startedAt = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
377
|
+
? performance.now()
|
|
378
|
+
: undefined
|
|
379
|
+
const baseParams = {
|
|
380
|
+
locale: getTrackedLocale()
|
|
381
|
+
}
|
|
382
|
+
const emitSubmitEvent = (status, extra = {}) => {
|
|
383
|
+
const params = { ...baseParams, status, challenge_type: activeChallengeType, ...extra }
|
|
384
|
+
if (startedAt !== undefined && typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
385
|
+
params.duration_ms = Math.round(performance.now() - startedAt)
|
|
386
|
+
}
|
|
387
|
+
trackEvent('contact_form_submit', params)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
emitSubmitEvent('attempt')
|
|
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
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!isChallengeAnswerCorrect(challengeAnswer.value)) {
|
|
407
|
+
msg.textContent = challenge.errorMessage
|
|
408
|
+
emitSubmitEvent('challenge_failed')
|
|
409
|
+
challengeAnswer.value = ''
|
|
410
|
+
generateChallenge()
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
msg.textContent = pendingMessage.value
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
resetTokenField()
|
|
418
|
+
const formData = new FormData(formElement)
|
|
419
|
+
formData.set('entry.1210125274', SUBMISSION_TOKEN)
|
|
420
|
+
|
|
421
|
+
await fetch(formElement.action, {
|
|
422
|
+
method: 'POST',
|
|
423
|
+
mode: 'no-cors',
|
|
424
|
+
body: formData
|
|
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
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
formElement.addEventListener('submit', submitHandler)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
onUnmounted(() => {
|
|
450
|
+
if (formElement && submitHandler) {
|
|
451
|
+
formElement.removeEventListener('submit', submitHandler)
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
</script>
|
|
455
|
+
|
|
456
|
+
<style scoped>
|
|
457
|
+
.contact-section {
|
|
458
|
+
background: var(--contact-section-bg, var(--theme-body-background, #f7f7f7));
|
|
459
|
+
--section-divider-color: var(--contact-divider-color, var(--brand-border-highlight, rgba(79, 108, 240, 0.28)));
|
|
460
|
+
--section-description-color: var(--contact-description-color, var(--ui-text-muted, rgba(31, 42, 68, 0.72)));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.contact-wrapper {
|
|
464
|
+
display: flex;
|
|
465
|
+
justify-content: center;
|
|
466
|
+
width: 100%;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.contact-form {
|
|
470
|
+
max-width: 720px;
|
|
471
|
+
width: 100%;
|
|
472
|
+
display: flex;
|
|
473
|
+
flex-direction: column;
|
|
474
|
+
gap: clamp(20px, 4vw, 32px);
|
|
475
|
+
padding: clamp(24px, 5vw, 40px);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.contact-grid {
|
|
479
|
+
display: grid;
|
|
480
|
+
gap: clamp(16px, 3vw, 28px);
|
|
481
|
+
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.contact-field--full {
|
|
485
|
+
grid-column: 1 / -1;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.contact-label {
|
|
489
|
+
display: block;
|
|
490
|
+
margin-bottom: 6px;
|
|
491
|
+
color: var(--contact-label-color, var(--ui-text-primary, #1f2a44));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.contact-hint--inline {
|
|
495
|
+
display: inline-block;
|
|
496
|
+
margin-left: 6px;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.contact-actions {
|
|
500
|
+
text-align: center;
|
|
501
|
+
display: flex;
|
|
502
|
+
flex-direction: column;
|
|
503
|
+
gap: 12px;
|
|
504
|
+
align-items: center;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.contact-submit {
|
|
508
|
+
width: min(240px, 100%);
|
|
509
|
+
justify-content: center;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.contact-feedback {
|
|
513
|
+
display: block;
|
|
514
|
+
margin-top: 8px;
|
|
515
|
+
text-align: center;
|
|
516
|
+
color: var(--contact-feedback-color, var(--ui-text-muted, rgba(31, 42, 68, 0.72)));
|
|
517
|
+
}
|
|
518
|
+
</style>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<footer class="site-footer ui-footer" data-analytics-section="footer">
|
|
3
|
+
<div class="container ui-footer__grid">
|
|
4
|
+
<div class="ui-footer__brand">
|
|
5
|
+
<a class="ui-footer__logo" href="/" aria-label="Return home">
|
|
6
|
+
<img
|
|
7
|
+
:src="logoSrc"
|
|
8
|
+
:alt="siteName"
|
|
9
|
+
class="site-footer__logo-img"
|
|
10
|
+
loading="lazy"
|
|
11
|
+
decoding="async"
|
|
12
|
+
/>
|
|
13
|
+
</a>
|
|
14
|
+
<p class="ui-footer__text">{{ footerText }}</p>
|
|
15
|
+
</div>
|
|
16
|
+
<div v-if="footerLinks.length" class="ui-footer__links">
|
|
17
|
+
<p class="ui-footer__heading ui-label-sm">Links</p>
|
|
18
|
+
<nav aria-label="Footer links">
|
|
19
|
+
<ul class="ui-footer__nav">
|
|
20
|
+
<li v-for="(link, idx) in footerLinks" :key="idx">
|
|
21
|
+
<a
|
|
22
|
+
class="ui-footer__link"
|
|
23
|
+
:href="link.href"
|
|
24
|
+
target="_blank"
|
|
25
|
+
rel="noopener noreferrer"
|
|
26
|
+
>
|
|
27
|
+
<span>{{ link.text }}</span>
|
|
28
|
+
<span class="ui-footer__link-icon" aria-hidden="true">></span>
|
|
29
|
+
</a>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</nav>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</footer>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup>
|
|
39
|
+
import { computed, inject, ref } from 'vue';
|
|
40
|
+
import { resolveAsset } from '../utils/assetResolver.js';
|
|
41
|
+
|
|
42
|
+
const injectedSiteData = inject('siteData', ref({}));
|
|
43
|
+
const pageContent = inject('pageContent', ref({}));
|
|
44
|
+
|
|
45
|
+
const logoSrc = computed(() => resolveAsset('img/logo.png'));
|
|
46
|
+
|
|
47
|
+
const siteName = computed(() => injectedSiteData.value?.site?.title || '');
|
|
48
|
+
const footerContent = computed(() => pageContent.value?.footer || {});
|
|
49
|
+
|
|
50
|
+
const footerText = computed(() => footerContent.value?.text || '');
|
|
51
|
+
const footerLinks = computed(() =>
|
|
52
|
+
Array.isArray(footerContent.value?.links) ? footerContent.value.links : []
|
|
53
|
+
);
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.site-footer__logo-img {
|
|
58
|
+
max-height: 48px;
|
|
59
|
+
width: auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.ui-footer__text {
|
|
63
|
+
margin-bottom: 0;
|
|
64
|
+
}
|
|
65
|
+
</style>
|