@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a module key from import.meta.glob into a lookup-friendly format.
|
|
3
|
+
*
|
|
4
|
+
* Strips everything up to and including the 'assets/' segment, producing a
|
|
5
|
+
* relative path like 'img/foo.png'.
|
|
6
|
+
*/
|
|
7
|
+
function normalizeAssetKey(key = '') {
|
|
8
|
+
const assetsIdx = key.indexOf('assets/');
|
|
9
|
+
if (assetsIdx !== -1) {
|
|
10
|
+
return key.slice(assetsIdx + 'assets/'.length);
|
|
11
|
+
}
|
|
12
|
+
// Fallback for prefixed patterns like @cms-assets/
|
|
13
|
+
const lastSlash = key.lastIndexOf('/');
|
|
14
|
+
return lastSlash >= 0 ? key.slice(lastSlash + 1) : key;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createAssetResolver(sharedAssetModules, siteAssetModules) {
|
|
18
|
+
const assetUrlMap = {};
|
|
19
|
+
|
|
20
|
+
// Register shared (framework) assets
|
|
21
|
+
for (const [key, url] of Object.entries(sharedAssetModules)) {
|
|
22
|
+
const normalizedKey = normalizeAssetKey(key);
|
|
23
|
+
if (normalizedKey) {
|
|
24
|
+
assetUrlMap[normalizedKey] = url;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Register site assets
|
|
29
|
+
for (const [key, url] of Object.entries(siteAssetModules)) {
|
|
30
|
+
const normalizedKey = normalizeAssetKey(key);
|
|
31
|
+
if (normalizedKey) {
|
|
32
|
+
assetUrlMap[normalizedKey] = url;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveAssetUrl(path = '') {
|
|
37
|
+
if (!path || typeof path !== 'string') {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
41
|
+
return assetUrlMap[normalizedPath] || '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveAsset(relativePath = '', options = {}) {
|
|
45
|
+
if (!relativePath || typeof relativePath !== 'string') {
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalizedPath = relativePath.replace(/^\/+/, '');
|
|
50
|
+
const extraCandidates = Array.isArray(options.fallbacks) ? options.fallbacks : [];
|
|
51
|
+
const candidates = new Set();
|
|
52
|
+
|
|
53
|
+
const addCandidate = (value) => {
|
|
54
|
+
if (!value || typeof value !== 'string') return;
|
|
55
|
+
const candidate = value.replace(/^\/+/, '');
|
|
56
|
+
if (candidate) {
|
|
57
|
+
candidates.add(candidate);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const withoutLeadingImg = normalizedPath.startsWith('img/')
|
|
62
|
+
? normalizedPath.slice(4)
|
|
63
|
+
: normalizedPath;
|
|
64
|
+
|
|
65
|
+
if (!normalizedPath.startsWith('img/')) {
|
|
66
|
+
addCandidate(`img/${normalizedPath}`);
|
|
67
|
+
}
|
|
68
|
+
if (withoutLeadingImg !== normalizedPath) {
|
|
69
|
+
addCandidate(`img/${withoutLeadingImg}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
addCandidate(normalizedPath);
|
|
73
|
+
if (withoutLeadingImg !== normalizedPath) {
|
|
74
|
+
addCandidate(withoutLeadingImg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
extraCandidates.forEach((candidate) => {
|
|
78
|
+
addCandidate(candidate);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
if (assetUrlMap[candidate]) {
|
|
83
|
+
return assetUrlMap[candidate];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveMedia(src = '') {
|
|
91
|
+
const value = typeof src === 'string' ? src.trim() : '';
|
|
92
|
+
if (!value) return '';
|
|
93
|
+
if (/^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('/')) {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const normalized = value.replace(/^\/+/, '');
|
|
98
|
+
const segments = normalized.split('/');
|
|
99
|
+
|
|
100
|
+
const mediaCandidates = [normalized];
|
|
101
|
+
if (segments.length > 1) {
|
|
102
|
+
const [, ...restSegments] = segments;
|
|
103
|
+
if (restSegments.length) {
|
|
104
|
+
mediaCandidates.push(restSegments.join('/'));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
mediaCandidates.push(`img/${normalized}`);
|
|
108
|
+
mediaCandidates.push(`logos/${normalized}`);
|
|
109
|
+
|
|
110
|
+
const uniqueCandidates = Array.from(new Set(mediaCandidates));
|
|
111
|
+
for (const candidate of uniqueCandidates) {
|
|
112
|
+
const resolved = resolveAsset(candidate);
|
|
113
|
+
if (resolved) {
|
|
114
|
+
return resolved;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { resolveAssetUrl, resolveAsset, resolveMedia, assetUrlMap };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Runtime singleton ----
|
|
125
|
+
// Components import resolver functions directly from this module. The Vite
|
|
126
|
+
// plugin's generated entry file calls `setAssetResolver()` with the configured
|
|
127
|
+
// instance (built from the virtual module's globs) at startup, before any
|
|
128
|
+
// component code runs.
|
|
129
|
+
|
|
130
|
+
const emptyWarning = (name) => () => {
|
|
131
|
+
if (typeof console !== 'undefined') {
|
|
132
|
+
console.warn(`[@koehler8/cms] ${name}() called before asset resolver was initialized`);
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
let _resolveAssetUrl = emptyWarning('resolveAssetUrl');
|
|
138
|
+
let _resolveAsset = emptyWarning('resolveAsset');
|
|
139
|
+
let _resolveMedia = emptyWarning('resolveMedia');
|
|
140
|
+
let _assetUrlMap = {};
|
|
141
|
+
|
|
142
|
+
export function setAssetResolver(instance) {
|
|
143
|
+
if (!instance) return;
|
|
144
|
+
_resolveAssetUrl = instance.resolveAssetUrl;
|
|
145
|
+
_resolveAsset = instance.resolveAsset;
|
|
146
|
+
_resolveMedia = instance.resolveMedia;
|
|
147
|
+
_assetUrlMap = instance.assetUrlMap || {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const resolveAssetUrl = (...args) => _resolveAssetUrl(...args);
|
|
151
|
+
export const resolveAsset = (...args) => _resolveAsset(...args);
|
|
152
|
+
export const resolveMedia = (src) => _resolveMedia(src);
|
|
153
|
+
export const assetUrlMap = new Proxy({}, {
|
|
154
|
+
get: (_, key) => _assetUrlMap[key],
|
|
155
|
+
has: (_, key) => key in _assetUrlMap,
|
|
156
|
+
ownKeys: () => Object.keys(_assetUrlMap),
|
|
157
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
158
|
+
if (key in _assetUrlMap) {
|
|
159
|
+
return { value: _assetUrlMap[key], enumerable: true, configurable: true };
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Bundled CMS components — discovered at module-eval time via the
|
|
2
|
+
// framework's own glob. Site-local components from `site/components/`
|
|
3
|
+
// are added separately at runtime via `setSiteComponents()` (called by
|
|
4
|
+
// the generated entry from a Vite virtual module — see vite-plugin.js
|
|
5
|
+
// VIRTUAL_SITE_COMPONENTS). Site components are checked BEFORE bundled
|
|
6
|
+
// components during resolution (most-specific wins), with a console
|
|
7
|
+
// warning when a site component shadows a bundled component.
|
|
8
|
+
|
|
9
|
+
const rawModules = import.meta.glob(['../components/**/*.vue', '!../components/Home.vue'], { eager: true });
|
|
10
|
+
|
|
11
|
+
function normalizeComponentEntry(entry) {
|
|
12
|
+
return entry && typeof entry === 'object' && 'default' in entry ? entry.default : entry;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveComponentName(filePath) {
|
|
16
|
+
const normalized = filePath.replace(/^\.{2}\//, '').replace(/\.vue$/i, '');
|
|
17
|
+
const parts = normalized.split('/');
|
|
18
|
+
return parts.length ? parts[parts.length - 1] : normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createRegistry(modules) {
|
|
22
|
+
const registry = Object.entries(modules).reduce((acc, [file, module]) => {
|
|
23
|
+
const component = normalizeComponentEntry(module);
|
|
24
|
+
if (!component) return acc;
|
|
25
|
+
|
|
26
|
+
const componentName = resolveComponentName(file);
|
|
27
|
+
|
|
28
|
+
if (!acc[componentName]) {
|
|
29
|
+
acc[componentName] = component;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (componentName.startsWith('Spacer') && componentName.length > 'Spacer'.length) {
|
|
33
|
+
const suffix = componentName.slice('Spacer'.length);
|
|
34
|
+
const parsed = Number.parseInt(suffix, 10);
|
|
35
|
+
const score = Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
36
|
+
|
|
37
|
+
const existing = acc.__spacerFallback;
|
|
38
|
+
if (!existing || score < existing.score) {
|
|
39
|
+
acc.__spacerFallback = { component, score };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return acc;
|
|
44
|
+
}, {});
|
|
45
|
+
|
|
46
|
+
if (!registry.Spacer && registry.__spacerFallback && registry.__spacerFallback.component) {
|
|
47
|
+
registry.Spacer = registry.__spacerFallback.component;
|
|
48
|
+
}
|
|
49
|
+
delete registry.__spacerFallback;
|
|
50
|
+
|
|
51
|
+
return registry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const registry = createRegistry(rawModules);
|
|
55
|
+
|
|
56
|
+
// ----------------------------------------------------------------------------
|
|
57
|
+
// Site-local component registry
|
|
58
|
+
//
|
|
59
|
+
// Sites can drop Vue files into `site/components/**/*.vue` and reference them
|
|
60
|
+
// from `pages/{pageId}.json` `components[]` by name — same as bundled or
|
|
61
|
+
// extension components. The vite plugin globs these eagerly into a virtual
|
|
62
|
+
// module that the generated entry imports and hands to `setSiteComponents`.
|
|
63
|
+
//
|
|
64
|
+
// Resolution priority (in useComponentResolver): site → extension → bundled.
|
|
65
|
+
// Source-qualified `site:Name` always resolves to the site-local component;
|
|
66
|
+
// `slug:Name` continues to target a specific extension.
|
|
67
|
+
|
|
68
|
+
const siteRegistry = {};
|
|
69
|
+
const siteShadows = {};
|
|
70
|
+
|
|
71
|
+
function buildSiteRegistry(modules) {
|
|
72
|
+
const next = {};
|
|
73
|
+
const collisions = {};
|
|
74
|
+
for (const [file, module] of Object.entries(modules || {})) {
|
|
75
|
+
const component = normalizeComponentEntry(module);
|
|
76
|
+
if (!component) continue;
|
|
77
|
+
const name = resolveComponentName(file);
|
|
78
|
+
if (!name) continue;
|
|
79
|
+
if (next[name]) {
|
|
80
|
+
// Same basename in two different files under site/components/ — keep
|
|
81
|
+
// the first and warn so the user can rename.
|
|
82
|
+
(collisions[name] = collisions[name] || []).push(file);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
next[name] = component;
|
|
86
|
+
}
|
|
87
|
+
return { entries: next, duplicates: collisions };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Register components found under `site/components/`. Called once during app
|
|
92
|
+
* initialization by the generated entry. Also accepts the result of an
|
|
93
|
+
* `import.meta.glob('@cms-site/components/**\/*.vue', { eager: true })` call.
|
|
94
|
+
*/
|
|
95
|
+
export function setSiteComponents(modules) {
|
|
96
|
+
// Reset prior state so HMR re-registrations don't accumulate stale entries.
|
|
97
|
+
for (const key of Object.keys(siteRegistry)) delete siteRegistry[key];
|
|
98
|
+
for (const key of Object.keys(siteShadows)) delete siteShadows[key];
|
|
99
|
+
|
|
100
|
+
const source = modules && typeof modules === 'object'
|
|
101
|
+
? (modules.default && typeof modules.default === 'object' ? modules.default : modules)
|
|
102
|
+
: {};
|
|
103
|
+
|
|
104
|
+
const { entries, duplicates } = buildSiteRegistry(source);
|
|
105
|
+
Object.assign(siteRegistry, entries);
|
|
106
|
+
|
|
107
|
+
// Emit a single concise warning per site-component name that:
|
|
108
|
+
// - shadows a bundled CMS component (overrides are powerful and easy
|
|
109
|
+
// to do by accident — flag them so they're explicit), or
|
|
110
|
+
// - has duplicate basenames across subdirectories (only the first
|
|
111
|
+
// wins; rename or restructure to fix).
|
|
112
|
+
if (typeof console !== 'undefined' && import.meta.env?.DEV) {
|
|
113
|
+
for (const name of Object.keys(entries)) {
|
|
114
|
+
if (registry[name] && registry[name] !== entries[name]) {
|
|
115
|
+
siteShadows[name] = true;
|
|
116
|
+
console.warn(
|
|
117
|
+
`[site-components] "${name}" overrides a bundled CMS component. ` +
|
|
118
|
+
`Use "site:${name}" in pages/*.json to make this explicit, or rename if accidental.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [name, files] of Object.entries(duplicates)) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`[site-components] Multiple files named "${name}.vue" in site/components/; ` +
|
|
125
|
+
`keeping the first registration. Duplicates: ${files.join(', ')}.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Lookup helper used by useComponentResolver. Returns the site-local component
|
|
133
|
+
* for `name` or `undefined`.
|
|
134
|
+
*/
|
|
135
|
+
export function getSiteComponent(name) {
|
|
136
|
+
if (!name) return undefined;
|
|
137
|
+
return siteRegistry[name];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read-only snapshot of the registered site components. Mainly for tests and
|
|
142
|
+
* tooling; runtime resolution should use getSiteComponent.
|
|
143
|
+
*/
|
|
144
|
+
export function getSiteRegistry() {
|
|
145
|
+
return { ...siteRegistry };
|
|
146
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function hasUsableValue(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
if (typeof value === 'string') {
|
|
6
|
+
return value.trim().length > 0;
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value.length > 0;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === 'object') {
|
|
12
|
+
return Object.keys(value).length > 0;
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function satisfiesSegments(target, segments) {
|
|
18
|
+
if (!segments.length) {
|
|
19
|
+
return hasUsableValue(target);
|
|
20
|
+
}
|
|
21
|
+
if (target === null || target === undefined) return false;
|
|
22
|
+
|
|
23
|
+
const [rawSegment, ...rest] = segments;
|
|
24
|
+
const isArraySegment = rawSegment.endsWith('[]');
|
|
25
|
+
const key = isArraySegment ? rawSegment.slice(0, -2) : rawSegment;
|
|
26
|
+
const nextValue = key ? target?.[key] : target;
|
|
27
|
+
|
|
28
|
+
if (isArraySegment) {
|
|
29
|
+
if (!Array.isArray(nextValue) || nextValue.length === 0) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (!rest.length) {
|
|
33
|
+
return nextValue.some((item) => hasUsableValue(item));
|
|
34
|
+
}
|
|
35
|
+
return nextValue.some((item) => satisfiesSegments(item, rest));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!rest.length) {
|
|
39
|
+
return hasUsableValue(nextValue);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return satisfiesSegments(nextValue, rest);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateRequiredContentPaths(content, requiredPaths = []) {
|
|
46
|
+
if (!Array.isArray(requiredPaths) || requiredPaths.length === 0) {
|
|
47
|
+
return { isValid: true, missing: [] };
|
|
48
|
+
}
|
|
49
|
+
const missing = [];
|
|
50
|
+
|
|
51
|
+
for (const rawPath of requiredPaths) {
|
|
52
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const segments = rawPath.split('.').map((segment) => segment.trim()).filter(Boolean);
|
|
56
|
+
if (!segments.length) continue;
|
|
57
|
+
const satisfied = satisfiesSegments(content, segments);
|
|
58
|
+
if (!satisfied) {
|
|
59
|
+
missing.push(rawPath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
isValid: missing.length === 0,
|
|
65
|
+
missing,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie Consent Management Utility
|
|
3
|
+
* Handles storing and retrieving user consent for analytics tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CONSENT_KEY = 'cookie_consent';
|
|
7
|
+
const CONSENT_TIMESTAMP_KEY = 'cookie_consent_timestamp';
|
|
8
|
+
const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
9
|
+
|
|
10
|
+
export const ConsentStatus = {
|
|
11
|
+
ACCEPTED: 'accepted',
|
|
12
|
+
DECLINED: 'declined',
|
|
13
|
+
PENDING: 'pending'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function parseEnvFlag(rawValue) {
|
|
17
|
+
if (rawValue === undefined || rawValue === null) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof rawValue === 'boolean') {
|
|
22
|
+
return rawValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const normalized = String(rawValue).trim().toLowerCase();
|
|
26
|
+
if (normalized === '') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return TRUTHY_ENV_VALUES.has(normalized);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isCookieBannerEnabled() {
|
|
34
|
+
return parseEnvFlag(import.meta.env.VITE_SHOW_COOKIE_BANNER);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the current consent status
|
|
39
|
+
* @returns {string} One of ConsentStatus values
|
|
40
|
+
*/
|
|
41
|
+
export function getConsentStatus() {
|
|
42
|
+
try {
|
|
43
|
+
const consent = localStorage.getItem(CONSENT_KEY);
|
|
44
|
+
if (consent === ConsentStatus.ACCEPTED || consent === ConsentStatus.DECLINED) {
|
|
45
|
+
return consent;
|
|
46
|
+
}
|
|
47
|
+
return ConsentStatus.PENDING;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn('Unable to access localStorage for consent:', error);
|
|
50
|
+
return ConsentStatus.PENDING;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set the user's consent status
|
|
56
|
+
* @param {string} status - One of ConsentStatus values
|
|
57
|
+
*/
|
|
58
|
+
export function setConsentStatus(status) {
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(CONSENT_KEY, status);
|
|
61
|
+
localStorage.setItem(CONSENT_TIMESTAMP_KEY, new Date().toISOString());
|
|
62
|
+
|
|
63
|
+
// Trigger custom event for other parts of the app to react
|
|
64
|
+
window.dispatchEvent(new CustomEvent('consentChanged', { detail: { status } }));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('Unable to save consent status:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Accept cookies and analytics tracking
|
|
72
|
+
*/
|
|
73
|
+
export function acceptConsent() {
|
|
74
|
+
setConsentStatus(ConsentStatus.ACCEPTED);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decline cookies and analytics tracking
|
|
79
|
+
*/
|
|
80
|
+
export function declineConsent() {
|
|
81
|
+
setConsentStatus(ConsentStatus.DECLINED);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Revoke previously given consent (records as declined)
|
|
86
|
+
*/
|
|
87
|
+
export function revokeConsent() {
|
|
88
|
+
declineConsent();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if user has accepted consent
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export function hasAcceptedConsent() {
|
|
96
|
+
return getConsentStatus() === ConsentStatus.ACCEPTED;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if user has declined consent
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
export function hasDeclinedConsent() {
|
|
104
|
+
return getConsentStatus() === ConsentStatus.DECLINED;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if consent is still pending
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function isConsentPending() {
|
|
112
|
+
return getConsentStatus() === ConsentStatus.PENDING;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Determine if analytics should be active.
|
|
117
|
+
* Pending consent defaults to enabled so tracking starts immediately.
|
|
118
|
+
*
|
|
119
|
+
* NOTE: In EU/EEA jurisdictions, GDPR and the ePrivacy Directive require
|
|
120
|
+
* affirmative consent before setting non-essential cookies or loading
|
|
121
|
+
* tracking scripts. If the site targets EU users, consider changing the
|
|
122
|
+
* PENDING case to return false so analytics remain disabled until the
|
|
123
|
+
* user explicitly accepts.
|
|
124
|
+
*
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function shouldEnableAnalytics() {
|
|
128
|
+
const status = getConsentStatus();
|
|
129
|
+
return status === ConsentStatus.ACCEPTED || status === ConsentStatus.PENDING;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let analyticsLoadPromise;
|
|
133
|
+
|
|
134
|
+
function appendAnalyticsScript(googleId) {
|
|
135
|
+
if (!googleId) return;
|
|
136
|
+
if (window.gtag) return;
|
|
137
|
+
|
|
138
|
+
const script = document.createElement('script');
|
|
139
|
+
script.async = true;
|
|
140
|
+
script.src = `https://www.googletagmanager.com/gtag/js?id=${googleId}`;
|
|
141
|
+
document.head.appendChild(script);
|
|
142
|
+
|
|
143
|
+
window.dataLayer = window.dataLayer || [];
|
|
144
|
+
function gtag() {
|
|
145
|
+
window.dataLayer.push(arguments);
|
|
146
|
+
}
|
|
147
|
+
window.gtag = gtag;
|
|
148
|
+
gtag('js', new Date());
|
|
149
|
+
gtag('config', googleId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Load Google Analytics if consent is granted
|
|
154
|
+
*/
|
|
155
|
+
export function loadGoogleAnalytics(googleId) {
|
|
156
|
+
if (!googleId || typeof window === 'undefined') {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!shouldEnableAnalytics()) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
appendAnalyticsScript(googleId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function whenHeroIsVisible(selector, callback, timeout = 4000) {
|
|
168
|
+
if (typeof window === 'undefined') {
|
|
169
|
+
callback();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const targets = Array.from(document.querySelectorAll(selector)).filter(Boolean);
|
|
174
|
+
if (!targets.length || !('IntersectionObserver' in window)) {
|
|
175
|
+
callback();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let resolved = false;
|
|
180
|
+
const resolve = () => {
|
|
181
|
+
if (resolved) return;
|
|
182
|
+
resolved = true;
|
|
183
|
+
observer.disconnect();
|
|
184
|
+
callback();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const observer = new IntersectionObserver(
|
|
188
|
+
(entries) => {
|
|
189
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
rootMargin: '0px',
|
|
195
|
+
threshold: 0.1,
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
targets.forEach((target) => observer.observe(target));
|
|
200
|
+
|
|
201
|
+
window.setTimeout(resolve, timeout);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function deferUntilIdle(callback, timeout = 2000) {
|
|
205
|
+
if (typeof window === 'undefined') {
|
|
206
|
+
callback();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const run = () => {
|
|
211
|
+
try {
|
|
212
|
+
callback();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error('Deferred analytics load failed:', error);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if ('requestIdleCallback' in window) {
|
|
219
|
+
window.requestIdleCallback(run, { timeout });
|
|
220
|
+
} else {
|
|
221
|
+
window.setTimeout(run, timeout);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Defer analytics loading until the main content is visible (or idle timeout).
|
|
227
|
+
* @param {string} googleId
|
|
228
|
+
* @param {object} [options]
|
|
229
|
+
* @param {string} [options.selector='#promo, main']
|
|
230
|
+
* @param {number} [options.visibilityTimeout=4000]
|
|
231
|
+
* @param {number} [options.idleTimeout=2000]
|
|
232
|
+
* @returns {Promise<void>}
|
|
233
|
+
*/
|
|
234
|
+
export function scheduleAnalyticsLoad(googleId, options = {}) {
|
|
235
|
+
if (!googleId || typeof window === 'undefined') {
|
|
236
|
+
return Promise.resolve();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!shouldEnableAnalytics()) {
|
|
240
|
+
return Promise.resolve();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (window.gtag) {
|
|
244
|
+
return Promise.resolve();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (analyticsLoadPromise) {
|
|
248
|
+
return analyticsLoadPromise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const {
|
|
252
|
+
selector = '#promo, main',
|
|
253
|
+
visibilityTimeout = 4000,
|
|
254
|
+
idleTimeout = 2000,
|
|
255
|
+
} = options;
|
|
256
|
+
|
|
257
|
+
analyticsLoadPromise = new Promise((resolve) => {
|
|
258
|
+
const triggerLoad = () => {
|
|
259
|
+
if (window.gtag) {
|
|
260
|
+
resolve();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
appendAnalyticsScript(googleId);
|
|
264
|
+
resolve();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const startObservation = () => {
|
|
268
|
+
whenHeroIsVisible(selector, () => {
|
|
269
|
+
deferUntilIdle(triggerLoad, idleTimeout);
|
|
270
|
+
}, visibilityTimeout);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (document.readyState === 'complete') {
|
|
274
|
+
startObservation();
|
|
275
|
+
} else {
|
|
276
|
+
window.addEventListener('load', startObservation, { once: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return analyticsLoadPromise;
|
|
281
|
+
}
|