@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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/cms-generate-public-assets.js +27 -0
  4. package/bin/cms-validate-extensions.js +18 -0
  5. package/bin/cms-validate-themes.js +7 -0
  6. package/extensions/manifest.schema.json +125 -0
  7. package/package.json +88 -0
  8. package/public/img/preloaders/preloader-black.svg +1 -0
  9. package/public/img/preloaders/preloader-white.svg +1 -0
  10. package/public/robots.txt +5 -0
  11. package/scripts/check-overflow.mjs +33 -0
  12. package/scripts/generate-public-assets.js +412 -0
  13. package/scripts/migrate-to-content-dirs.mjs +223 -0
  14. package/scripts/patch-lru-cache-tla.js +164 -0
  15. package/scripts/validate-extensions.mjs +392 -0
  16. package/scripts/validate-themes.mjs +64 -0
  17. package/src/App.vue +3 -0
  18. package/src/components/About.vue +481 -0
  19. package/src/components/AboutValue.vue +361 -0
  20. package/src/components/BackToTop.vue +42 -0
  21. package/src/components/ComingSoon.vue +411 -0
  22. package/src/components/ComingSoonModal.vue +230 -0
  23. package/src/components/Contact.vue +518 -0
  24. package/src/components/Footer.vue +65 -0
  25. package/src/components/FooterMinimal.vue +153 -0
  26. package/src/components/Header.vue +583 -0
  27. package/src/components/Hero.vue +327 -0
  28. package/src/components/Home.vue +161 -0
  29. package/src/components/Intro.vue +130 -0
  30. package/src/components/IntroGate.vue +444 -0
  31. package/src/components/Plan.vue +116 -0
  32. package/src/components/Portfolio.vue +459 -0
  33. package/src/components/Preloader.vue +20 -0
  34. package/src/components/Principles.vue +67 -0
  35. package/src/components/Spacer15.vue +9 -0
  36. package/src/components/Spacer30.vue +9 -0
  37. package/src/components/Spacer40.vue +9 -0
  38. package/src/components/Spacer60.vue +9 -0
  39. package/src/components/StickyCTA.vue +263 -0
  40. package/src/components/Team.vue +432 -0
  41. package/src/components/icons/IconLinkedIn.vue +22 -0
  42. package/src/components/icons/IconX.vue +22 -0
  43. package/src/components/ui/SbCard.vue +52 -0
  44. package/src/components/ui/SkeletonPulse.vue +117 -0
  45. package/src/components/ui/UnitChip.vue +69 -0
  46. package/src/composables/useComingSoonConfig.js +120 -0
  47. package/src/composables/useComingSoonInterstitial.js +27 -0
  48. package/src/composables/useComponentResolver.js +235 -0
  49. package/src/composables/useEngagementTracking.js +187 -0
  50. package/src/composables/useIntroGate.js +46 -0
  51. package/src/composables/useLazyImage.js +77 -0
  52. package/src/composables/usePageConfig.js +184 -0
  53. package/src/composables/usePageMeta.js +76 -0
  54. package/src/composables/usePromoBackgroundStyles.js +67 -0
  55. package/src/constants/locales.js +20 -0
  56. package/src/extensions/extensionLoader.js +512 -0
  57. package/src/main.js +187 -0
  58. package/src/router/index.js +112 -0
  59. package/src/styles/base.css +896 -0
  60. package/src/styles/layout.css +342 -0
  61. package/src/styles/theme-base.css +84 -0
  62. package/src/themes/buildCssVarMap.js +246 -0
  63. package/src/themes/themeLoader.js +156 -0
  64. package/src/themes/themeManager.js +34 -0
  65. package/src/themes/themeValidator.js +380 -0
  66. package/src/utils/analytics.js +100 -0
  67. package/src/utils/appInfo.js +9 -0
  68. package/src/utils/assetResolver.js +162 -0
  69. package/src/utils/componentRegistry.js +146 -0
  70. package/src/utils/contentRequirements.js +67 -0
  71. package/src/utils/cookieConsent.js +281 -0
  72. package/src/utils/ctaCopy.js +58 -0
  73. package/src/utils/formatNumber.js +115 -0
  74. package/src/utils/imageSources.js +179 -0
  75. package/src/utils/inflateFlatConfig.js +30 -0
  76. package/src/utils/loadConfig.js +319 -0
  77. package/src/utils/semver.js +49 -0
  78. package/src/utils/siteStyles.js +40 -0
  79. package/src/utils/themeColors.js +65 -0
  80. package/src/utils/trackingContext.js +142 -0
  81. package/src/utils/unwrapDefault.js +14 -0
  82. package/src/utils/useScrollReveal.js +48 -0
  83. package/templates/index.html +36 -0
  84. package/themes/base/README.md +23 -0
  85. package/themes/base/theme.config.js +214 -0
  86. 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">&gt;</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>