@decocms/apps-website 7.2.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 ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@decocms/apps-website",
3
+ "version": "7.2.0",
4
+ "type": "module",
5
+ "description": "Deco generic-site app: SEO, analytics, theme, and content utilities shared across every commerce backend",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/decocms/blocks.git",
9
+ "directory": "packages/apps-website"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./mod": "./src/mod.ts",
15
+ "./client": "./src/client.ts",
16
+ "./types": "./src/types.ts",
17
+ "./components/*": "./src/components/*.tsx",
18
+ "./loaders/*": "./src/loaders/*.ts",
19
+ "./loaders/fonts/*": "./src/loaders/fonts/*.ts",
20
+ "./utils/*": "./src/utils/*.ts",
21
+ "./sections/*": "./src/sections/*.tsx",
22
+ "./sections/Seo/*": "./src/sections/Seo/*.tsx"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "test": "vitest run --root ../.. packages/apps-website/",
27
+ "typecheck": "tsc --noEmit",
28
+ "lint:unused": "knip"
29
+ },
30
+ "dependencies": {
31
+ "@decocms/blocks": "7.2.0",
32
+ "@decocms/apps-commerce": "7.2.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.0.0",
40
+ "@types/react-dom": "^19.0.0",
41
+ "knip": "^5.86.0",
42
+ "typescript": "^5.9.0"
43
+ },
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org",
46
+ "access": "public"
47
+ }
48
+ }
package/src/client.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Website app singleton configuration.
3
+ *
4
+ * Uses globalThis to survive Vite module duplication (optimized deps
5
+ * vs raw source imports can create separate module instances).
6
+ */
7
+
8
+ import type { WebsiteConfig } from "./types";
9
+
10
+ const G = globalThis as unknown as { __decoWebsiteConfig?: WebsiteConfig };
11
+
12
+ export function configureWebsite(config: WebsiteConfig): void {
13
+ G.__decoWebsiteConfig = config;
14
+ }
15
+
16
+ export function getWebsiteConfig(): WebsiteConfig {
17
+ if (!G.__decoWebsiteConfig) {
18
+ throw new Error("Website app not configured. Call configureWebsite() first.");
19
+ }
20
+ return G.__decoWebsiteConfig;
21
+ }
@@ -0,0 +1,146 @@
1
+ declare global {
2
+ interface Window {
3
+ dataLayer: unknown[];
4
+ DECO: { events: { subscribe: (fn: (event: any) => void) => void } };
5
+ }
6
+ }
7
+
8
+ export const getGTMIdFromSrc = (src: string | undefined) => {
9
+ if (!src) return undefined;
10
+ try {
11
+ return new URL(src).searchParams.get("id") ?? undefined;
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ };
16
+
17
+ interface TagManagerProps {
18
+ trackingId: string;
19
+ src?: string;
20
+ }
21
+
22
+ export function GoogleTagManager(props: TagManagerProps) {
23
+ const _isOnPremises = !!props.src;
24
+ const hasTrackingId = "trackingId" in props;
25
+ const id = _isOnPremises ? props.src : props.trackingId;
26
+ const hostname = _isOnPremises ? props.src : "https://www.googletagmanager.com";
27
+ const src = new URL(`/gtm.js?id=${hasTrackingId ? props.trackingId : ""}`, hostname);
28
+ const noscript = new URL(`/ns.html?id=${hasTrackingId ? props.trackingId : ""}`, hostname);
29
+
30
+ return (
31
+ <>
32
+ <script
33
+ id={`gtm-script-${id}`}
34
+ dangerouslySetInnerHTML={{
35
+ __html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
36
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
37
+ j=d.createElement(s);j.async=true;j.src=i;f.parentNode.insertBefore(j,f);
38
+ })(window,document,'script','dataLayer', '${src.href}');`,
39
+ }}
40
+ />
41
+ <noscript>
42
+ <iframe
43
+ title="Google Tag Manager"
44
+ src={noscript.href}
45
+ height="0"
46
+ width="0"
47
+ style={{ display: "none", visibility: "hidden" }}
48
+ />
49
+ </noscript>
50
+ </>
51
+ );
52
+ }
53
+
54
+ export function GTAG({ trackingId }: Pick<TagManagerProps, "trackingId">) {
55
+ const safeId = trackingId.replace(/[^a-zA-Z0-9_-]/g, "");
56
+ return (
57
+ <>
58
+ <script async src={`https://www.googletagmanager.com/gtag/js?id=${safeId}`} />
59
+ <script
60
+ dangerouslySetInnerHTML={{
61
+ __html: `window.dataLayer = window.dataLayer || [];
62
+ function gtag() {
63
+ dataLayer.push(arguments);
64
+ }
65
+ gtag("js", new Date());
66
+ gtag("config", '${safeId}');`,
67
+ }}
68
+ />
69
+ </>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * DataLayer event forwarding snippet.
75
+ * Subscribes to DECO events and pushes them to the GTM dataLayer.
76
+ */
77
+ const snippetCode = `
78
+ (function() {
79
+ if (typeof globalThis.window !== "undefined" && globalThis.window.DECO && globalThis.window.DECO.events) {
80
+ globalThis.window.DECO.events.subscribe(function(event) {
81
+ globalThis.window.dataLayer = globalThis.window.dataLayer || [];
82
+ if (!event || !globalThis.window.dataLayer || typeof globalThis.window.dataLayer.push !== "function") {
83
+ return;
84
+ }
85
+ if (event.name === "deco") {
86
+ globalThis.window.dataLayer.push({ event: event.name, deco: event.params });
87
+ return;
88
+ }
89
+ globalThis.window.dataLayer.push({ ecommerce: null });
90
+ globalThis.window.dataLayer.push({ event: event.name, ecommerce: event.params });
91
+ });
92
+ }
93
+ })();
94
+ `;
95
+
96
+ export interface Props {
97
+ /**
98
+ * @description google tag manager container id. For more info: https://developers.google.com/tag-platform/tag-manager/web#standard_web_page_installation .
99
+ */
100
+ trackingIds?: string[];
101
+ /**
102
+ * @title GA Measurement Ids
103
+ * @label measurement id
104
+ * @description the google analytics property measurement id. For more info: https://support.google.com/analytics/answer/9539598
105
+ */
106
+ googleAnalyticsIds?: string[];
107
+ /**
108
+ * @description custom url for serving google tag manager.
109
+ */
110
+ src?: string;
111
+ /**
112
+ * @description Disable forwarding events into dataLayer
113
+ */
114
+ disableAutomaticEventPush?: boolean;
115
+ }
116
+
117
+ export default function Analytics({
118
+ trackingIds,
119
+ src,
120
+ googleAnalyticsIds,
121
+ disableAutomaticEventPush,
122
+ }: Props) {
123
+ const isDeploy = process.env.NODE_ENV === "production";
124
+ // Backwards compat: extract GTM ID from src URL
125
+ const trackingId = getGTMIdFromSrc(src) ?? "";
126
+
127
+ return (
128
+ <>
129
+ {isDeploy && (
130
+ <>
131
+ {trackingIds?.map((id) => (
132
+ <GoogleTagManager key={id} src={src} trackingId={id.trim()} />
133
+ ))}
134
+ {googleAnalyticsIds?.map((id) => (
135
+ <GTAG key={id} trackingId={id.trim()} />
136
+ ))}
137
+ {src && !trackingIds?.length && <GoogleTagManager src={src} trackingId={trackingId} />}
138
+ </>
139
+ )}
140
+
141
+ {disableAutomaticEventPush !== true && (
142
+ <script defer id="analytics-script" dangerouslySetInnerHTML={{ __html: snippetCode }} />
143
+ )}
144
+ </>
145
+ );
146
+ }
@@ -0,0 +1,218 @@
1
+ // Runs under the workspace default jsdom environment (see root vitest.config.ts) —
2
+ // apps-start used happy-dom for this file specifically, but this repo standardizes
3
+ // on jsdom for all packages/*/src, and the DOM APIs this test exercises (document,
4
+ // window, history, cookies) are supported by both.
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ __resetForTests,
8
+ initOneDollarStats,
9
+ readFlagsFromCookie,
10
+ truncate,
11
+ } from "./OneDollarStats";
12
+
13
+ const ORIGINAL_PUSH_STATE = history.pushState.bind(history);
14
+
15
+ function clearAllCookies() {
16
+ const names = document.cookie
17
+ .split(";")
18
+ .map((c) => c.split("=")[0]?.trim())
19
+ .filter(Boolean);
20
+ for (const n of names) {
21
+ document.cookie = `${n}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
22
+ document.cookie = `${n}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
23
+ }
24
+ }
25
+
26
+ beforeEach(() => {
27
+ // Use fake timers so leftover poll intervals from a prior init can't fire
28
+ // into the next test's window — and so we can advance time deterministically.
29
+ vi.useFakeTimers();
30
+ __resetForTests();
31
+ clearAllCookies();
32
+ history.pushState = ORIGINAL_PUSH_STATE;
33
+ (globalThis as { stonks?: unknown }).stonks = undefined;
34
+ (globalThis as { DECO?: unknown }).DECO = undefined;
35
+ });
36
+
37
+ afterEach(() => {
38
+ // Drain any leftover timers before tearing down so they don't bleed across tests.
39
+ vi.clearAllTimers();
40
+ vi.useRealTimers();
41
+ });
42
+
43
+ describe("truncate", () => {
44
+ it("returns the string unchanged when under the limit", () => {
45
+ expect(truncate("hello")).toBe("hello");
46
+ });
47
+
48
+ it("caps long strings at 990 chars", () => {
49
+ const big = "a".repeat(2000);
50
+ expect(truncate(big)).toHaveLength(990);
51
+ });
52
+
53
+ it("stringifies numbers and booleans", () => {
54
+ expect(truncate(42)).toBe("42");
55
+ expect(truncate(true)).toBe("true");
56
+ });
57
+
58
+ it("JSON-stringifies objects and arrays", () => {
59
+ expect(truncate({ a: 1 })).toBe('{"a":1}');
60
+ expect(truncate([1, 2])).toBe("[1,2]");
61
+ });
62
+ });
63
+
64
+ describe("readFlagsFromCookie", () => {
65
+ function encodeSegment(payload: object): string {
66
+ return btoa(encodeURIComponent(JSON.stringify(payload)));
67
+ }
68
+
69
+ it("returns an empty object when no deco_segment cookie is present", () => {
70
+ expect(readFlagsFromCookie("")).toEqual({});
71
+ expect(readFlagsFromCookie("foo=bar; baz=qux")).toEqual({});
72
+ });
73
+
74
+ it("decodes active flags as true and inactiveDrawn as false", () => {
75
+ const cookie = `deco_segment=${encodeSegment({
76
+ active: ["new_home", "promo_v2"],
77
+ inactiveDrawn: ["old_promo"],
78
+ })}`;
79
+ expect(readFlagsFromCookie(cookie)).toEqual({
80
+ new_home: true,
81
+ promo_v2: true,
82
+ old_promo: false,
83
+ });
84
+ });
85
+
86
+ it("ignores other cookies", () => {
87
+ const cookie = `csrf=abc; deco_segment=${encodeSegment({ active: ["x"] })}; theme=dark`;
88
+ expect(readFlagsFromCookie(cookie)).toEqual({ x: true });
89
+ });
90
+
91
+ it("returns an empty object on malformed cookie", () => {
92
+ expect(readFlagsFromCookie("deco_segment=not-base64!")).toEqual({});
93
+ expect(readFlagsFromCookie("deco_segment=YWJj")).toEqual({}); // valid b64, invalid json
94
+ });
95
+
96
+ it("handles empty active/inactiveDrawn arrays", () => {
97
+ const cookie = `deco_segment=${encodeSegment({ active: [], inactiveDrawn: [] })}`;
98
+ expect(readFlagsFromCookie(cookie)).toEqual({});
99
+ });
100
+ });
101
+
102
+ describe("initOneDollarStats", () => {
103
+ function setStonks(view = vi.fn(), event = vi.fn()) {
104
+ (window as Window & { stonks?: unknown }).stonks = { view, event };
105
+ return { view, event };
106
+ }
107
+
108
+ function setDeco() {
109
+ const subscribers: Array<(e: unknown) => void> = [];
110
+ (window as Window & { DECO?: unknown }).DECO = {
111
+ events: {
112
+ subscribe: (fn: (e: unknown) => void) => {
113
+ subscribers.push(fn);
114
+ },
115
+ },
116
+ };
117
+ return subscribers;
118
+ }
119
+
120
+ it("fires the initial pageview with cookie flags once stonks is ready", () => {
121
+ const { view } = setStonks();
122
+ document.cookie = `deco_segment=${btoa(encodeURIComponent(JSON.stringify({ active: ["abtest_a"] })))}`;
123
+
124
+ initOneDollarStats();
125
+ // stonks was ready immediately → first call fires synchronously, no polling.
126
+ expect(view).toHaveBeenCalledTimes(1);
127
+ expect(view).toHaveBeenCalledWith({ abtest_a: true });
128
+ });
129
+
130
+ it("waits for stonks via polling when SDK loads late", () => {
131
+ const view = vi.fn();
132
+ // Stonks NOT set yet.
133
+ initOneDollarStats();
134
+ expect(view).not.toHaveBeenCalled();
135
+
136
+ // SDK arrives a few ticks later.
137
+ (window as Window & { stonks?: unknown }).stonks = { view };
138
+ vi.advanceTimersByTime(100);
139
+
140
+ expect(view).toHaveBeenCalledTimes(1);
141
+ expect(view).toHaveBeenCalledWith({});
142
+ });
143
+
144
+ it("fires a pageview on history.pushState (SPA nav)", () => {
145
+ const { view } = setStonks();
146
+ initOneDollarStats();
147
+ view.mockClear();
148
+
149
+ history.pushState({}, "", "/somewhere");
150
+ expect(view).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it("fires a pageview on popstate", () => {
154
+ const { view } = setStonks();
155
+ initOneDollarStats();
156
+ view.mockClear();
157
+
158
+ dispatchEvent(new Event("popstate"));
159
+ expect(view).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it("is idempotent — second call does nothing, wrapper installed once", () => {
163
+ const { view } = setStonks();
164
+ initOneDollarStats();
165
+ initOneDollarStats();
166
+ initOneDollarStats();
167
+
168
+ expect(view).toHaveBeenCalledTimes(1);
169
+
170
+ view.mockClear();
171
+ history.pushState({}, "", "/x");
172
+ expect(view).toHaveBeenCalledTimes(1);
173
+ });
174
+
175
+ it("forwards DECO events to stonks.event with flag enrichment, skipping 'deco' events", () => {
176
+ const { event } = setStonks();
177
+ const subscribers = setDeco();
178
+ document.cookie = `deco_segment=${btoa(encodeURIComponent(JSON.stringify({ active: ["v2"] })))}`;
179
+
180
+ initOneDollarStats();
181
+
182
+ expect(subscribers).toHaveLength(1);
183
+ const dispatch = subscribers[0]!;
184
+
185
+ dispatch({ name: "deco", params: { foo: 1 } });
186
+ expect(event).not.toHaveBeenCalled();
187
+
188
+ dispatch({ name: "add_to_cart", params: { sku: "ABC", price: 99 } });
189
+ expect(event).toHaveBeenCalledTimes(1);
190
+ expect(event).toHaveBeenCalledWith("add_to_cart", {
191
+ v2: true,
192
+ sku: "ABC",
193
+ price: "99",
194
+ });
195
+ });
196
+
197
+ it("ignores subscribed events that lack a name", () => {
198
+ const { event } = setStonks();
199
+ const subscribers = setDeco();
200
+
201
+ initOneDollarStats();
202
+
203
+ subscribers[0]!(null);
204
+ subscribers[0]!(undefined);
205
+ subscribers[0]!({});
206
+ expect(event).not.toHaveBeenCalled();
207
+ });
208
+
209
+ it("drops null/undefined params, retains other values, and ignores deco_segment when not set", () => {
210
+ const { event } = setStonks();
211
+ const subscribers = setDeco();
212
+
213
+ initOneDollarStats();
214
+
215
+ subscribers[0]!({ name: "ev", params: { keep: "x", drop: null, also_drop: undefined } });
216
+ expect(event).toHaveBeenCalledWith("ev", { keep: "x" });
217
+ });
218
+ });