@decocms/apps 1.11.0 → 1.11.1
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.1",
|
|
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",
|
|
@@ -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;
|