@decocms/apps 1.10.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.10.0",
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",
@@ -0,0 +1,283 @@
1
+ /**
2
+ * OneDollarStats — deco's lightweight in-house analytics.
3
+ *
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`:
7
+ *
8
+ * ```tsx
9
+ * <DecoRootLayout … >
10
+ * <OneDollarStats />
11
+ * </DecoRootLayout>
12
+ * ```
13
+ *
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.
36
+ *
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.
52
+ */
53
+
54
+ import { useEffect } from "react";
55
+
56
+ declare global {
57
+ interface Window {
58
+ stonks?: {
59
+ view?: (params?: Record<string, string | boolean | number>) => void;
60
+ event?: (name: string, params?: Record<string, string | boolean | number>) => void;
61
+ };
62
+ }
63
+ }
64
+
65
+ export interface Props {
66
+ /** lilstts collector URL. Defaults to {@link DEFAULT_COLLECTOR_ADDRESS}. */
67
+ collectorAddress?: string;
68
+ /** lilstts static script URL. Defaults to {@link DEFAULT_ANALYTICS_SCRIPT_URL}. */
69
+ staticScriptUrl?: string;
70
+ }
71
+
72
+ export const DEFAULT_COLLECTOR_ADDRESS = "https://d.lilstts.com/events";
73
+ export const DEFAULT_ANALYTICS_SCRIPT_URL = "https://s.lilstts.com/deco.js";
74
+
75
+ /**
76
+ * Set `ONEDOLLAR_ENABLED=false` on the Worker to disable. Default: enabled.
77
+ * Matches the Fresh-side Deno env contract.
78
+ */
79
+ const ONEDOLLAR_ENABLED = process.env.ONEDOLLAR_ENABLED !== "false";
80
+ const ONEDOLLAR_COLLECTOR = process.env.ONEDOLLAR_COLLECTOR;
81
+ const ONEDOLLAR_STATIC_SCRIPT = process.env.ONEDOLLAR_STATIC_SCRIPT;
82
+
83
+ function OneDollarStats({ collectorAddress, staticScriptUrl }: Props) {
84
+ if (!ONEDOLLAR_ENABLED) return null;
85
+
86
+ const collector = collectorAddress ?? ONEDOLLAR_COLLECTOR ?? DEFAULT_COLLECTOR_ADDRESS;
87
+ const staticScript = staticScriptUrl ?? ONEDOLLAR_STATIC_SCRIPT ?? DEFAULT_ANALYTICS_SCRIPT_URL;
88
+
89
+ return (
90
+ <>
91
+ <link rel="dns-prefetch" href={collector} />
92
+ <link rel="preconnect" href={collector} crossOrigin="anonymous" />
93
+ <script
94
+ id="onedollarstats-tracker"
95
+ data-autocollect="false"
96
+ data-hash-routing="true"
97
+ data-url={collector}
98
+ src={staticScript}
99
+ defer
100
+ />
101
+ <OneDollarStatsClient />
102
+ </>
103
+ );
104
+ }
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
+
283
+ export default OneDollarStats;