@decocms/apps 1.11.0 → 1.12.0
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/package.json +2 -1
- package/vtex/utils/sitemap.ts +153 -4
- package/website/components/OneDollarStats.tsx +225 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
|
|
6
6
|
"exports": {
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
"@tanstack/react-query": "^5.90.21",
|
|
123
123
|
"@types/react": "^19.0.0",
|
|
124
124
|
"@vitest/coverage-v8": "^4.1.0",
|
|
125
|
+
"happy-dom": "^20.9.0",
|
|
125
126
|
"knip": "^5.86.0",
|
|
126
127
|
"react": "^19.0.0",
|
|
127
128
|
"react-dom": "^19.0.0",
|
package/vtex/utils/sitemap.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* VTEX Sitemap
|
|
2
|
+
* VTEX Sitemap utilities.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* with the CMS sitemap generator.
|
|
4
|
+
* Two flavors:
|
|
5
|
+
* - `getVtexSitemapEntries()` — flatten VTEX sub-sitemaps into a single
|
|
6
|
+
* `SitemapEntry[]` list, for composition with the CMS sitemap generator.
|
|
7
|
+
* - `createVtexSitemapProxy()` — proxy `/sitemap.xml` and `/sitemap/*`
|
|
8
|
+
* straight from VTEX's commerce-stable origin, preserving the sitemap-index
|
|
9
|
+
* shape (so crawlers stay within Google's per-file size limit). This is the
|
|
10
|
+
* right choice when the storefront has no native sitemap renderer and just
|
|
11
|
+
* needs to expose VTEX's existing crawl tree to the public hostname.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client";
|
|
@@ -131,3 +136,147 @@ function rewriteUrl(url: string, vtexSitemapHost: string, origin: string): strin
|
|
|
131
136
|
return url.replace(`https://${vtexSitemapHost}`, origin);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// VTEX sitemap proxy factory
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns true if `pathname` is one of the proxied sitemap paths
|
|
146
|
+
* (`/sitemap.xml` or any `/sitemap/*` sub-sitemap).
|
|
147
|
+
*/
|
|
148
|
+
export function isVtexSitemapPath(pathname: string): boolean {
|
|
149
|
+
return pathname === "/sitemap.xml" || pathname.startsWith("/sitemap/");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface VtexSitemapProxyConfig {
|
|
153
|
+
/**
|
|
154
|
+
* Extra `<sitemap>` entries to inject into the root sitemap index
|
|
155
|
+
* (`/sitemap.xml` only — sub-sitemaps are passed through untouched).
|
|
156
|
+
*
|
|
157
|
+
* Useful for site-managed sitemaps such as a static search-result
|
|
158
|
+
* index (`sitemap-busca.xml`) that VTEX doesn't generate.
|
|
159
|
+
*
|
|
160
|
+
* Each value is normalized to an absolute URL on the storefront
|
|
161
|
+
* origin: leading-slash paths become `${origin}${path}`, and bare
|
|
162
|
+
* names become `${origin}/${name}`. Absolute URLs are used as-is.
|
|
163
|
+
*
|
|
164
|
+
* @example ["/sitemap-busca.xml"]
|
|
165
|
+
*/
|
|
166
|
+
extraSitemaps?: string[];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* VTEX environment for the upstream sitemap fetch.
|
|
170
|
+
* @default "vtexcommercestable"
|
|
171
|
+
*/
|
|
172
|
+
environment?: "vtexcommercestable" | "vtexcommercebeta";
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `Cache-Control` header to set on proxied responses. The default
|
|
176
|
+
* favors edge caching (Cloudflare honors `s-maxage`) with a long
|
|
177
|
+
* stale-while-revalidate window so a slow VTEX origin never blocks
|
|
178
|
+
* crawlers.
|
|
179
|
+
*
|
|
180
|
+
* @default "public, s-maxage=3600, stale-while-revalidate=86400"
|
|
181
|
+
*/
|
|
182
|
+
cacheControl?: string;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Optional fetch override — primarily for tests. Defaults to the
|
|
186
|
+
* platform `fetch`.
|
|
187
|
+
*/
|
|
188
|
+
fetchImpl?: typeof fetch;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400";
|
|
192
|
+
|
|
193
|
+
function normalizeExtraSitemap(entry: string, origin: string): string {
|
|
194
|
+
if (entry.startsWith("http://") || entry.startsWith("https://")) return entry;
|
|
195
|
+
const path = entry.startsWith("/") ? entry : `/${entry}`;
|
|
196
|
+
return `${origin}${path}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Creates a sitemap proxy handler that mirrors VTEX's `/sitemap.xml`
|
|
201
|
+
* (and sub-sitemaps) onto the storefront origin.
|
|
202
|
+
*
|
|
203
|
+
* Returns a function compatible with `createDecoWorkerEntry`'s
|
|
204
|
+
* `proxyHandler`: it returns `null` for non-sitemap paths, so it
|
|
205
|
+
* composes naturally with other proxy handlers
|
|
206
|
+
* (`createVtexCheckoutProxy`, custom logic, etc.).
|
|
207
|
+
*
|
|
208
|
+
* The VTEX account is read from the `configureVtex(...)` call done at
|
|
209
|
+
* worker startup — no per-call account configuration is needed.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* import { createVtexSitemapProxy } from "@decocms/apps/vtex/utils/sitemap";
|
|
214
|
+
* import {
|
|
215
|
+
* createVtexCheckoutProxy,
|
|
216
|
+
* shouldProxyToVtex,
|
|
217
|
+
* } from "@decocms/apps/vtex/utils/proxy";
|
|
218
|
+
*
|
|
219
|
+
* const proxySitemap = createVtexSitemapProxy({
|
|
220
|
+
* extraSitemaps: ["/sitemap-busca.xml"], // optional, site-managed
|
|
221
|
+
* });
|
|
222
|
+
* const proxyCheckout = createVtexCheckoutProxy({ ... });
|
|
223
|
+
*
|
|
224
|
+
* createDecoWorkerEntry(serverEntry, {
|
|
225
|
+
* proxyHandler: async (request, url) => {
|
|
226
|
+
* const sitemap = await proxySitemap(request, url);
|
|
227
|
+
* if (sitemap) return sitemap;
|
|
228
|
+
*
|
|
229
|
+
* if (!shouldProxyToVtex(url.pathname)) return null;
|
|
230
|
+
* return proxyCheckout(request, url);
|
|
231
|
+
* },
|
|
232
|
+
* });
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export function createVtexSitemapProxy(
|
|
236
|
+
config: VtexSitemapProxyConfig = {},
|
|
237
|
+
): (request: Request, url: URL) => Promise<Response | null> {
|
|
238
|
+
const environment = config.environment ?? "vtexcommercestable";
|
|
239
|
+
const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL;
|
|
240
|
+
const extraSitemaps = config.extraSitemaps ?? [];
|
|
241
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
242
|
+
|
|
243
|
+
return async (_request: Request, url: URL): Promise<Response | null> => {
|
|
244
|
+
if (!isVtexSitemapPath(url.pathname)) return null;
|
|
245
|
+
|
|
246
|
+
// vtexHost() reads the configured account from configureVtex().
|
|
247
|
+
const vtexSitemapHost = vtexHost(environment);
|
|
248
|
+
const target = `https://${vtexSitemapHost}${url.pathname}`;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const resp = await fetchImpl(target);
|
|
252
|
+
if (!resp.ok) {
|
|
253
|
+
console.error(`[vtex-sitemap] VTEX returned ${resp.status} for ${url.pathname}`);
|
|
254
|
+
return new Response("Sitemap temporarily unavailable", { status: 502 });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let xml = await resp.text();
|
|
258
|
+
xml = xml.replaceAll(`https://${vtexSitemapHost}`, url.origin);
|
|
259
|
+
|
|
260
|
+
if (url.pathname === "/sitemap.xml" && extraSitemaps.length > 0) {
|
|
261
|
+
const extraEntries = extraSitemaps
|
|
262
|
+
.map(
|
|
263
|
+
(s) =>
|
|
264
|
+
` <sitemap>\n <loc>${normalizeExtraSitemap(s, url.origin)}</loc>\n </sitemap>`,
|
|
265
|
+
)
|
|
266
|
+
.join("\n");
|
|
267
|
+
xml = xml.replace("</sitemapindex>", `${extraEntries}\n</sitemapindex>`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return new Response(xml, {
|
|
271
|
+
status: 200,
|
|
272
|
+
headers: {
|
|
273
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
274
|
+
"Cache-Control": cacheControl,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error("[vtex-sitemap] Failed to proxy VTEX sitemap:", err);
|
|
279
|
+
return new Response("Sitemap temporarily unavailable", { status: 502 });
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OneDollarStats — deco's lightweight in-house analytics.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Mount in the site's `__root.tsx` as a child of `DecoRootLayout`:
|
|
4
|
+
* Posts pageviews (initial load + SPA navigations) and forwards DECO
|
|
5
|
+
* events to the lilstts collector. Mount once in `__root.tsx` as a child
|
|
6
|
+
* of `DecoRootLayout`:
|
|
8
7
|
*
|
|
9
8
|
* ```tsx
|
|
10
9
|
* <DecoRootLayout … >
|
|
@@ -12,25 +11,53 @@
|
|
|
12
11
|
* </DecoRootLayout>
|
|
13
12
|
* ```
|
|
14
13
|
*
|
|
15
|
-
*
|
|
14
|
+
* The component is env-gated and self-mounting — no CMS wiring needed.
|
|
15
|
+
*
|
|
16
|
+
* ## Why this design
|
|
17
|
+
*
|
|
18
|
+
* 1. **We own pageviews.** The lilstts SDK has its own auto-pageview path
|
|
19
|
+
* (driven by `history.pushState` wrapping). We disable it via
|
|
20
|
+
* `data-autocollect="false"` and call `window.stonks.view(flags)`
|
|
21
|
+
* ourselves. This is the only way to attach `deco_segment` cookie
|
|
22
|
+
* flags to pageviews — the SDK's auto-path doesn't know about them.
|
|
23
|
+
*
|
|
24
|
+
* 2. **`useEffect` for client logic.** All side-effects (initial pageview,
|
|
25
|
+
* pushState wrap, DECO event subscribe) run inside a `useEffect`,
|
|
26
|
+
* which fires after hydration. By then `<ScriptOnce>` in
|
|
27
|
+
* `DecoRootLayout` has bootstrapped `window.DECO.events`, and the SDK
|
|
28
|
+
* `<script>` (rendered as a sibling) has loaded and set
|
|
29
|
+
* `window.stonks`. No inline `dangerouslySetInnerHTML` snippet, no
|
|
30
|
+
* fragile script-execution-order dependency.
|
|
31
|
+
*
|
|
32
|
+
* 3. **Module-level guards.** `window.DECO.events.subscribe()` returns no
|
|
33
|
+
* unsubscribe handle, so we cannot clean up on unmount. We use a
|
|
34
|
+
* module-level `initialized` flag to ensure init runs exactly once
|
|
35
|
+
* per page lifetime, surviving HMR and React StrictMode double-mount.
|
|
16
36
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* 4. **Bounded readiness polling.** `window.stonks` and `window.DECO`
|
|
38
|
+
* might not be ready the instant our effect fires (race with script
|
|
39
|
+
* load). We poll every 50 ms for up to 10 s. Production: resolves
|
|
40
|
+
* within one tick.
|
|
41
|
+
*
|
|
42
|
+
* ## Behavioural parity vs Fresh `deco-cx/apps`
|
|
43
|
+
*
|
|
44
|
+
* Mirrors the Path B snippet (`analytics/loaders/OneDollarScript.ts`):
|
|
45
|
+
* unconditional first pageview with flag enrichment, SPA nav tracking,
|
|
46
|
+
* and DECO event forwarding. Diverges from the Fresh component variant
|
|
47
|
+
* (which depended on a synthesised `{ name: "deco" }` event from
|
|
48
|
+
* `Events.tsx`'s subscribe-replay — no equivalent in TanStack).
|
|
49
|
+
*
|
|
50
|
+
* `pageId` enrichment is intentionally dropped — no admin dashboard
|
|
51
|
+
* consumes it. Add later if a flag-segmented dashboard needs it.
|
|
27
52
|
*/
|
|
28
53
|
|
|
54
|
+
import { useEffect } from "react";
|
|
55
|
+
|
|
29
56
|
declare global {
|
|
30
57
|
interface Window {
|
|
31
58
|
stonks?: {
|
|
32
|
-
view?: (params
|
|
33
|
-
event?: (name: string, params
|
|
59
|
+
view?: (params?: Record<string, string | boolean | number>) => void;
|
|
60
|
+
event?: (name: string, params?: Record<string, string | boolean | number>) => void;
|
|
34
61
|
};
|
|
35
62
|
}
|
|
36
63
|
}
|
|
@@ -46,81 +73,13 @@ export const DEFAULT_COLLECTOR_ADDRESS = "https://d.lilstts.com/events";
|
|
|
46
73
|
export const DEFAULT_ANALYTICS_SCRIPT_URL = "https://s.lilstts.com/deco.js";
|
|
47
74
|
|
|
48
75
|
/**
|
|
49
|
-
*
|
|
76
|
+
* Set `ONEDOLLAR_ENABLED=false` on the Worker to disable. Default: enabled.
|
|
50
77
|
* Matches the Fresh-side Deno env contract.
|
|
51
78
|
*/
|
|
52
79
|
const ONEDOLLAR_ENABLED = process.env.ONEDOLLAR_ENABLED !== "false";
|
|
53
80
|
const ONEDOLLAR_COLLECTOR = process.env.ONEDOLLAR_COLLECTOR;
|
|
54
81
|
const ONEDOLLAR_STATIC_SCRIPT = process.env.ONEDOLLAR_STATIC_SCRIPT;
|
|
55
82
|
|
|
56
|
-
/**
|
|
57
|
-
* Inline subscriber snippet — kept as a plain string constant (not a
|
|
58
|
-
* `useScript(fn)` serialisation) because `fn.toString()` produces different
|
|
59
|
-
* output in SSR vs client Vite builds under React Compiler, causing
|
|
60
|
-
* hydration mismatches on `dangerouslySetInnerHTML`. See
|
|
61
|
-
* `@decocms/start/sdk/useScript` for the deprecation note.
|
|
62
|
-
*
|
|
63
|
-
* Mirrors the Path B (`analytics/loaders/OneDollarScript.ts`) snippet from
|
|
64
|
-
* deco-cx/apps: parse `deco_segment` cookie for flags, fire first pageview
|
|
65
|
-
* unconditionally, patch `history.pushState` + `popstate` for SPA navs,
|
|
66
|
-
* subscribe to `window.DECO.events` to forward non-`deco` events.
|
|
67
|
-
*/
|
|
68
|
-
const ONEDOLLAR_SNIPPET = `(function(){
|
|
69
|
-
function parseCookies(str){
|
|
70
|
-
var out = {};
|
|
71
|
-
str.split(";").forEach(function(c){
|
|
72
|
-
var idx = c.indexOf("=");
|
|
73
|
-
if (idx < 0) return;
|
|
74
|
-
out[c.slice(0, idx).trim()] = c.slice(idx + 1).trim();
|
|
75
|
-
});
|
|
76
|
-
return out;
|
|
77
|
-
}
|
|
78
|
-
function tryOrDefault(fn, d){ try { return fn(); } catch(e) { return d; } }
|
|
79
|
-
function getFlags(cookies){
|
|
80
|
-
var out = [];
|
|
81
|
-
var raw = cookies["deco_segment"];
|
|
82
|
-
var seg = raw ? tryOrDefault(function(){ return JSON.parse(decodeURIComponent(atob(raw))); }, {}) : {};
|
|
83
|
-
(seg.active || []).forEach(function(name){ out.push({ name: name, value: true }); });
|
|
84
|
-
(seg.inactiveDrawn || []).forEach(function(name){ out.push({ name: name, value: false }); });
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
function truncate(v){ return ("" + v).slice(0, 990); }
|
|
88
|
-
var flagList = getFlags(parseCookies(document.cookie || ""));
|
|
89
|
-
var flags = {};
|
|
90
|
-
flagList.forEach(function(f){ flags[f.name] = f.value; });
|
|
91
|
-
function trackPageview(){
|
|
92
|
-
if (window.stonks && typeof window.stonks.view === "function") {
|
|
93
|
-
window.stonks.view(flags);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
trackPageview();
|
|
97
|
-
var origPush = history.pushState;
|
|
98
|
-
if (origPush) {
|
|
99
|
-
history.pushState = function(){
|
|
100
|
-
origPush.apply(this, arguments);
|
|
101
|
-
trackPageview();
|
|
102
|
-
};
|
|
103
|
-
addEventListener("popstate", trackPageview);
|
|
104
|
-
}
|
|
105
|
-
if (window.DECO && window.DECO.events && typeof window.DECO.events.subscribe === "function") {
|
|
106
|
-
window.DECO.events.subscribe(function(event){
|
|
107
|
-
if (!event || !event.name || event.name === "deco") return;
|
|
108
|
-
var values = {};
|
|
109
|
-
for (var k in flags) values[k] = flags[k];
|
|
110
|
-
var params = event.params || {};
|
|
111
|
-
for (var key in params) {
|
|
112
|
-
var v = params[key];
|
|
113
|
-
if (v !== null && v !== undefined) {
|
|
114
|
-
values[key] = truncate(typeof v !== "object" ? v : JSON.stringify(v));
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (window.stonks && typeof window.stonks.event === "function") {
|
|
118
|
-
window.stonks.event(event.name, values);
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
})();`;
|
|
123
|
-
|
|
124
83
|
function OneDollarStats({ collectorAddress, staticScriptUrl }: Props) {
|
|
125
84
|
if (!ONEDOLLAR_ENABLED) return null;
|
|
126
85
|
|
|
@@ -132,15 +91,193 @@ function OneDollarStats({ collectorAddress, staticScriptUrl }: Props) {
|
|
|
132
91
|
<link rel="dns-prefetch" href={collector} />
|
|
133
92
|
<link rel="preconnect" href={collector} crossOrigin="anonymous" />
|
|
134
93
|
<script
|
|
135
|
-
id="tracker"
|
|
94
|
+
id="onedollarstats-tracker"
|
|
136
95
|
data-autocollect="false"
|
|
137
96
|
data-hash-routing="true"
|
|
138
97
|
data-url={collector}
|
|
139
98
|
src={staticScript}
|
|
99
|
+
defer
|
|
140
100
|
/>
|
|
141
|
-
<
|
|
101
|
+
<OneDollarStatsClient />
|
|
142
102
|
</>
|
|
143
103
|
);
|
|
144
104
|
}
|
|
145
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Client-only side-effects. Mounted as a child of {@link OneDollarStats};
|
|
108
|
+
* does not render any DOM.
|
|
109
|
+
*/
|
|
110
|
+
function OneDollarStatsClient() {
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
initOneDollarStats();
|
|
113
|
+
}, []);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Module-level state — survives StrictMode double-mount and HMR remounts.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
let initialized = false;
|
|
122
|
+
let cachedFlags: Record<string, boolean> | null = null;
|
|
123
|
+
|
|
124
|
+
interface DecoSegmentCookie {
|
|
125
|
+
active?: string[];
|
|
126
|
+
inactiveDrawn?: string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read A/B test flags from the `deco_segment` cookie. Cached after first
|
|
131
|
+
* read for the lifetime of the page — flags are baked at request time
|
|
132
|
+
* server-side and don't change mid-session.
|
|
133
|
+
*
|
|
134
|
+
* Exported for testing.
|
|
135
|
+
*/
|
|
136
|
+
export function readFlagsFromCookie(
|
|
137
|
+
cookieString: string = typeof document !== "undefined" ? document.cookie : "",
|
|
138
|
+
): Record<string, boolean> {
|
|
139
|
+
if (cachedFlags && cookieString === (typeof document !== "undefined" ? document.cookie : "")) {
|
|
140
|
+
return cachedFlags;
|
|
141
|
+
}
|
|
142
|
+
const flags: Record<string, boolean> = {};
|
|
143
|
+
try {
|
|
144
|
+
const cookies = parseCookies(cookieString);
|
|
145
|
+
const raw = cookies.deco_segment;
|
|
146
|
+
if (raw) {
|
|
147
|
+
const seg = JSON.parse(decodeURIComponent(atob(raw))) as DecoSegmentCookie;
|
|
148
|
+
for (const name of seg.active ?? []) flags[name] = true;
|
|
149
|
+
for (const name of seg.inactiveDrawn ?? []) flags[name] = false;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Malformed cookie — proceed with empty flags rather than crashing analytics.
|
|
153
|
+
}
|
|
154
|
+
cachedFlags = flags;
|
|
155
|
+
return flags;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseCookies(cookieString: string): Record<string, string> {
|
|
159
|
+
return cookieString.split(";").reduce<Record<string, string>>((acc, c) => {
|
|
160
|
+
const idx = c.indexOf("=");
|
|
161
|
+
if (idx > 0) acc[c.slice(0, idx).trim()] = c.slice(idx + 1).trim();
|
|
162
|
+
return acc;
|
|
163
|
+
}, {});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Truncate any value to the lilstts payload limit (~1 KB per field).
|
|
168
|
+
* Exported for testing.
|
|
169
|
+
*/
|
|
170
|
+
export function truncate(v: unknown): string {
|
|
171
|
+
const s = typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
172
|
+
return s.slice(0, 990);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Poll for a global to become available, then invoke `cb` exactly once.
|
|
177
|
+
* Bounded by `maxAttempts * intervalMs` (default ~10 s). On timeout, no-op.
|
|
178
|
+
*/
|
|
179
|
+
function whenReady<T>(
|
|
180
|
+
check: () => T | undefined,
|
|
181
|
+
cb: (value: T) => void,
|
|
182
|
+
{ intervalMs = 50, maxAttempts = 200 }: { intervalMs?: number; maxAttempts?: number } = {},
|
|
183
|
+
): void {
|
|
184
|
+
const initial = check();
|
|
185
|
+
if (initial !== undefined) {
|
|
186
|
+
cb(initial);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let attempts = 0;
|
|
190
|
+
const iv = setInterval(() => {
|
|
191
|
+
attempts++;
|
|
192
|
+
const v = check();
|
|
193
|
+
if (v !== undefined) {
|
|
194
|
+
clearInterval(iv);
|
|
195
|
+
cb(v);
|
|
196
|
+
} else if (attempts >= maxAttempts) {
|
|
197
|
+
clearInterval(iv);
|
|
198
|
+
}
|
|
199
|
+
}, intervalMs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Wire up the analytics integration. Idempotent — only the first call has
|
|
204
|
+
* any effect.
|
|
205
|
+
*
|
|
206
|
+
* @internal exported for tests; do not call from app code.
|
|
207
|
+
*/
|
|
208
|
+
export function initOneDollarStats(): void {
|
|
209
|
+
if (initialized) return;
|
|
210
|
+
initialized = true;
|
|
211
|
+
|
|
212
|
+
const flags = readFlagsFromCookie();
|
|
213
|
+
|
|
214
|
+
// 1) Initial pageview + SPA nav tracking, with flag enrichment.
|
|
215
|
+
whenReady(
|
|
216
|
+
() =>
|
|
217
|
+
typeof window.stonks?.view === "function"
|
|
218
|
+
? window.stonks.view.bind(window.stonks)
|
|
219
|
+
: undefined,
|
|
220
|
+
(view) => {
|
|
221
|
+
view(flags);
|
|
222
|
+
wrapHistoryPushState(() => view(flags));
|
|
223
|
+
addEventListener("popstate", () => view(flags));
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// 2) Forward DECO events to stonks.event with flag enrichment.
|
|
228
|
+
whenReady(
|
|
229
|
+
() =>
|
|
230
|
+
typeof window.DECO?.events?.subscribe === "function"
|
|
231
|
+
? window.DECO.events.subscribe.bind(window.DECO.events)
|
|
232
|
+
: undefined,
|
|
233
|
+
(subscribe) => {
|
|
234
|
+
subscribe((event: { name?: string; params?: Record<string, unknown> } | null | undefined) => {
|
|
235
|
+
if (!event || !event.name || event.name === "deco") return;
|
|
236
|
+
if (typeof window.stonks?.event !== "function") return;
|
|
237
|
+
const values: Record<string, string | boolean | number> = { ...flags };
|
|
238
|
+
for (const [k, v] of Object.entries(event.params ?? {})) {
|
|
239
|
+
if (v == null) continue;
|
|
240
|
+
values[k] = truncate(v);
|
|
241
|
+
}
|
|
242
|
+
window.stonks.event(event.name, values);
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Wrap `history.pushState` to invoke `onPush` after each call. Idempotent
|
|
250
|
+
* via a marker property on the wrapper. The lilstts SDK installs its own
|
|
251
|
+
* wrapper too — with `data-autocollect="false"` its handler is a no-op,
|
|
252
|
+
* so we don't double-fire.
|
|
253
|
+
*/
|
|
254
|
+
function wrapHistoryPushState(onPush: () => void): void {
|
|
255
|
+
const ANY_HISTORY = history as History & { __onedollarstats_wrapped?: true };
|
|
256
|
+
if (ANY_HISTORY.__onedollarstats_wrapped) return;
|
|
257
|
+
const original = history.pushState;
|
|
258
|
+
const wrapped = function (this: History, ...args: Parameters<History["pushState"]>): void {
|
|
259
|
+
original.apply(this, args);
|
|
260
|
+
try {
|
|
261
|
+
onPush();
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error("[OneDollarStats] pushState handler", err);
|
|
264
|
+
}
|
|
265
|
+
} as History["pushState"];
|
|
266
|
+
(wrapped as unknown as { __onedollarstats_wrapped: true }).__onedollarstats_wrapped = true;
|
|
267
|
+
history.pushState = wrapped;
|
|
268
|
+
ANY_HISTORY.__onedollarstats_wrapped = true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @internal — reset module state for tests. NEVER call from app code.
|
|
273
|
+
*/
|
|
274
|
+
export function __resetForTests(): void {
|
|
275
|
+
initialized = false;
|
|
276
|
+
cachedFlags = null;
|
|
277
|
+
if (typeof history !== "undefined") {
|
|
278
|
+
const h = history as History & { __onedollarstats_wrapped?: true };
|
|
279
|
+
delete h.__onedollarstats_wrapped;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
146
283
|
export default OneDollarStats;
|