@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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -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 +84 -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 +401 -0
  13. package/scripts/patch-lru-cache-tla.js +164 -0
  14. package/scripts/validate-extensions.mjs +392 -0
  15. package/scripts/validate-themes.mjs +64 -0
  16. package/src/App.vue +3 -0
  17. package/src/components/About.vue +481 -0
  18. package/src/components/AboutValue.vue +361 -0
  19. package/src/components/BackToTop.vue +42 -0
  20. package/src/components/ComingSoon.vue +411 -0
  21. package/src/components/ComingSoonModal.vue +230 -0
  22. package/src/components/Contact.vue +518 -0
  23. package/src/components/Footer.vue +65 -0
  24. package/src/components/FooterMinimal.vue +153 -0
  25. package/src/components/Header.vue +583 -0
  26. package/src/components/Hero.vue +327 -0
  27. package/src/components/Home.vue +144 -0
  28. package/src/components/Intro.vue +130 -0
  29. package/src/components/IntroGate.vue +444 -0
  30. package/src/components/Plan.vue +116 -0
  31. package/src/components/Portfolio.vue +459 -0
  32. package/src/components/Preloader.vue +20 -0
  33. package/src/components/Principles.vue +67 -0
  34. package/src/components/Spacer15.vue +9 -0
  35. package/src/components/Spacer30.vue +9 -0
  36. package/src/components/Spacer40.vue +9 -0
  37. package/src/components/Spacer60.vue +9 -0
  38. package/src/components/StickyCTA.vue +263 -0
  39. package/src/components/Team.vue +432 -0
  40. package/src/components/icons/IconLinkedIn.vue +22 -0
  41. package/src/components/icons/IconX.vue +22 -0
  42. package/src/components/ui/SbCard.vue +52 -0
  43. package/src/components/ui/SkeletonPulse.vue +117 -0
  44. package/src/components/ui/UnitChip.vue +69 -0
  45. package/src/composables/useComingSoonConfig.js +120 -0
  46. package/src/composables/useComingSoonInterstitial.js +27 -0
  47. package/src/composables/useComponentResolver.js +196 -0
  48. package/src/composables/useEngagementTracking.js +187 -0
  49. package/src/composables/useIntroGate.js +46 -0
  50. package/src/composables/useLazyImage.js +77 -0
  51. package/src/composables/usePageConfig.js +184 -0
  52. package/src/composables/usePageMeta.js +76 -0
  53. package/src/composables/usePromoBackgroundStyles.js +67 -0
  54. package/src/constants/locales.js +20 -0
  55. package/src/extensions/extensionLoader.js +512 -0
  56. package/src/main.js +175 -0
  57. package/src/router/index.js +112 -0
  58. package/src/styles/base.css +896 -0
  59. package/src/styles/layout.css +342 -0
  60. package/src/styles/theme-base.css +84 -0
  61. package/src/themes/themeLoader.js +124 -0
  62. package/src/themes/themeManager.js +257 -0
  63. package/src/themes/themeValidator.js +380 -0
  64. package/src/utils/analytics.js +100 -0
  65. package/src/utils/appInfo.js +9 -0
  66. package/src/utils/assetResolver.js +162 -0
  67. package/src/utils/componentRegistry.js +46 -0
  68. package/src/utils/contentRequirements.js +67 -0
  69. package/src/utils/cookieConsent.js +281 -0
  70. package/src/utils/ctaCopy.js +58 -0
  71. package/src/utils/formatNumber.js +115 -0
  72. package/src/utils/imageSources.js +179 -0
  73. package/src/utils/inflateFlatConfig.js +30 -0
  74. package/src/utils/loadConfig.js +271 -0
  75. package/src/utils/semver.js +49 -0
  76. package/src/utils/siteStyles.js +40 -0
  77. package/src/utils/themeColors.js +65 -0
  78. package/src/utils/trackingContext.js +142 -0
  79. package/src/utils/unwrapDefault.js +14 -0
  80. package/src/utils/useScrollReveal.js +48 -0
  81. package/templates/index.html +36 -0
  82. package/themes/base/README.md +23 -0
  83. package/themes/base/theme.config.js +214 -0
  84. 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
+ }