@koehler8/cms 1.0.0-beta.5
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 +202 -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 +84 -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 +401 -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 +144 -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 +196 -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 +175 -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/themeLoader.js +124 -0
- package/src/themes/themeManager.js +257 -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 +46 -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 +271 -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 +637 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Google Analytics helper that respects consent settings.
|
|
3
|
+
*/
|
|
4
|
+
import { shouldEnableAnalytics } from './cookieConsent.js';
|
|
5
|
+
import { getAnalyticsContext } from './trackingContext.js';
|
|
6
|
+
|
|
7
|
+
const SENSITIVE_KEYS = new Set(['wallet_address', 'account_address', 'private_key', 'seed_phrase']);
|
|
8
|
+
|
|
9
|
+
function sanitizeParams(params = {}) {
|
|
10
|
+
if (!params || typeof params !== 'object') return {};
|
|
11
|
+
return Object.entries(params).reduce((acc, [key, value]) => {
|
|
12
|
+
const lowered = key.toLowerCase();
|
|
13
|
+
if (SENSITIVE_KEYS.has(lowered)) {
|
|
14
|
+
return acc;
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
17
|
+
acc[key] = sanitizeParams(value);
|
|
18
|
+
return acc;
|
|
19
|
+
}
|
|
20
|
+
acc[key] = value;
|
|
21
|
+
return acc;
|
|
22
|
+
}, {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function canTrack() {
|
|
26
|
+
if (typeof window === 'undefined') return false;
|
|
27
|
+
if (!shouldEnableAnalytics()) return false;
|
|
28
|
+
if (typeof window.gtag !== 'function') return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Send a GA4 event if analytics is ready.
|
|
34
|
+
* @param {string} name - GA4 event name.
|
|
35
|
+
* @param {Record<string, any>} [params] - Additional event parameters.
|
|
36
|
+
* @returns {boolean} True when the call was sent to gtag.
|
|
37
|
+
*/
|
|
38
|
+
export function trackEvent(name, params = {}) {
|
|
39
|
+
if (!name || typeof name !== 'string') {
|
|
40
|
+
if (import.meta.env.DEV) {
|
|
41
|
+
console.warn('[analytics] trackEvent requires a string name.');
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!canTrack()) return false;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const payload = sanitizeParams(params);
|
|
50
|
+
window.gtag('event', name, payload);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (import.meta.env.DEV) {
|
|
54
|
+
console.warn('[analytics] Failed to send event', name, error);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set GA4 user properties.
|
|
62
|
+
* @param {Record<string, any>} properties
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function setUserProperties(properties = {}) {
|
|
66
|
+
if (!properties || typeof properties !== 'object') return false;
|
|
67
|
+
if (!canTrack()) return false;
|
|
68
|
+
try {
|
|
69
|
+
window.gtag('set', 'user_properties', properties);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (import.meta.env.DEV) {
|
|
73
|
+
console.warn('[analytics] Failed to set user_properties', error);
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Public helper to surface readiness in components if needed.
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isAnalyticsReady() {
|
|
84
|
+
return canTrack();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Alias kept for ergonomic imports in components.
|
|
89
|
+
*/
|
|
90
|
+
export function trackEventIfReady(name, params) {
|
|
91
|
+
return trackEvent(name, params);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function trackFunnelEvent(name, params = {}) {
|
|
95
|
+
const payload = {
|
|
96
|
+
...getAnalyticsContext(),
|
|
97
|
+
...sanitizeParams(params),
|
|
98
|
+
};
|
|
99
|
+
return trackEvent(name, payload);
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import packageJsonRaw from '../../package.json?raw';
|
|
2
|
+
|
|
3
|
+
const packageJson = JSON.parse(packageJsonRaw);
|
|
4
|
+
|
|
5
|
+
const envVersion = import.meta?.env?.VITE_APP_VERSION;
|
|
6
|
+
|
|
7
|
+
export const APP_VERSION = (typeof envVersion === 'string' && envVersion.trim())
|
|
8
|
+
? envVersion.trim()
|
|
9
|
+
: packageJson?.version || '0.0.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,46 @@
|
|
|
1
|
+
const rawModules = import.meta.glob(['../components/**/*.vue', '!../components/Home.vue'], { eager: true });
|
|
2
|
+
|
|
3
|
+
function normalizeComponentEntry(entry) {
|
|
4
|
+
return entry && typeof entry === 'object' && 'default' in entry ? entry.default : entry;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function resolveComponentName(filePath) {
|
|
8
|
+
const normalized = filePath.replace(/^\.{2}\//, '').replace(/\.vue$/i, '');
|
|
9
|
+
const parts = normalized.split('/');
|
|
10
|
+
return parts.length ? parts[parts.length - 1] : normalized;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createRegistry(modules) {
|
|
14
|
+
const registry = Object.entries(modules).reduce((acc, [file, module]) => {
|
|
15
|
+
const component = normalizeComponentEntry(module);
|
|
16
|
+
if (!component) return acc;
|
|
17
|
+
|
|
18
|
+
const componentName = resolveComponentName(file);
|
|
19
|
+
|
|
20
|
+
if (!acc[componentName]) {
|
|
21
|
+
acc[componentName] = component;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (componentName.startsWith('Spacer') && componentName.length > 'Spacer'.length) {
|
|
25
|
+
const suffix = componentName.slice('Spacer'.length);
|
|
26
|
+
const parsed = Number.parseInt(suffix, 10);
|
|
27
|
+
const score = Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
28
|
+
|
|
29
|
+
const existing = acc.__spacerFallback;
|
|
30
|
+
if (!existing || score < existing.score) {
|
|
31
|
+
acc.__spacerFallback = { component, score };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return acc;
|
|
36
|
+
}, {});
|
|
37
|
+
|
|
38
|
+
if (!registry.Spacer && registry.__spacerFallback && registry.__spacerFallback.component) {
|
|
39
|
+
registry.Spacer = registry.__spacerFallback.component;
|
|
40
|
+
}
|
|
41
|
+
delete registry.__spacerFallback;
|
|
42
|
+
|
|
43
|
+
return registry;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const registry = createRegistry(rawModules);
|
|
@@ -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
|
+
}
|