@forgecart/cli 2.202607091048.0 → 2.202607120419.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.202607091048.0",
3
+ "version": "2.202607120419.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import type { Metadata } from 'next';
2
2
  import type { ReactNode } from 'react';
3
3
 
4
+ import { ForgeAnalytics } from '../components/ForgeAnalytics';
4
5
  import { ForgeErrorBeacon } from '../components/ForgeErrorBeacon';
5
6
  import { ForgecartDesigner } from '../components/ForgecartDesigner';
6
7
  import { Header } from '../components/Header';
@@ -30,6 +31,10 @@ export default async function RootLayout({ children }: { children: ReactNode })
30
31
  </CartProvider>
31
32
  <ForgecartDesigner />
32
33
  <ForgeErrorBeacon />
34
+ <ForgeAnalytics
35
+ shopApiUrl={process.env.FORGECART_SHOP_API_URL ?? ''}
36
+ channelToken={process.env.FORGECART_CHANNEL_TOKEN ?? ''}
37
+ />
33
38
  </body>
34
39
  </html>
35
40
  );
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+
3
+ import { ForgeCartShopClient } from '@forgecart/sdk';
4
+ import { usePathname } from 'next/navigation';
5
+ import { useEffect } from 'react';
6
+
7
+ /**
8
+ * Storefront analytics tracker. Renders nothing.
9
+ *
10
+ * Emits the marketing events that drive the dashboard's live-visitor count and
11
+ * realtime map:
12
+ *
13
+ * - `page_view` on the initial load and on every App-Router route change;
14
+ * - `heartbeat` every {@link HEARTBEAT_MS} while the tab is VISIBLE (plus one
15
+ * immediately on refocus), so an idle-but-open tab stays inside the live
16
+ * window. Heartbeats are liveness plumbing only — the backend excludes
17
+ * them from every event-counting aggregate.
18
+ *
19
+ * Identity needs no code here: the SDK's WebSocket connect mints an anonymous
20
+ * shop session server-side, captures the returned token, and persists it in
21
+ * localStorage — so every event from this browser rides one identity, and the
22
+ * server enriches geo/device from the connection's own IP/UA.
23
+ *
24
+ * Two deliberate suppressions:
25
+ * - missing config (the pod image pre-renders the template before
26
+ * `forgecart init` writes `.env.local`) → the tracker is inert;
27
+ * - framed embeds (`window.parent !== window`) → the visual editor's
28
+ * artboard preview never counts its own admin as a live visitor. A normal
29
+ * top-level tab on the same URL still counts.
30
+ *
31
+ * Unlike `ForgeErrorBeacon` there is NO `NODE_ENV` gate — tracking is a
32
+ * production feature. Every send is fire-and-forget and swallows failures:
33
+ * analytics must never break the storefront.
34
+ */
35
+
36
+ const HEARTBEAT_MS = 60_000;
37
+ /** Minimum spacing between sends of the same signal (StrictMode remounts, rapid refocus). */
38
+ const DEDUP_MS = 30_000;
39
+
40
+ let client: ForgeCartShopClient | null = null;
41
+ let lastPageView: { path: string; at: number } | null = null;
42
+ let lastHeartbeatAt = 0;
43
+
44
+ function getClient(shopApiUrl: string, channelToken: string): ForgeCartShopClient {
45
+ if (!client) {
46
+ client = new ForgeCartShopClient({ endpoint: shopApiUrl, channelToken });
47
+ }
48
+ return client;
49
+ }
50
+
51
+ async function send(
52
+ shopApiUrl: string,
53
+ channelToken: string,
54
+ eventType: string,
55
+ properties?: Record<string, unknown>,
56
+ ): Promise<void> {
57
+ try {
58
+ await getClient(shopApiUrl, channelToken).marketingEvent.shopTrackEvent({
59
+ input: { eventType, properties },
60
+ });
61
+ } catch {
62
+ // Best-effort by design: a failed send (offline, rate-limited, booting
63
+ // backend) must never surface in the storefront.
64
+ }
65
+ }
66
+
67
+ export function ForgeAnalytics({
68
+ shopApiUrl,
69
+ channelToken,
70
+ }: {
71
+ shopApiUrl: string;
72
+ channelToken: string;
73
+ }) {
74
+ const pathname = usePathname();
75
+
76
+ // page_view — initial load + every route change.
77
+ useEffect(() => {
78
+ if (!shopApiUrl || !channelToken) return;
79
+ if (window.parent !== window) return;
80
+ const now = Date.now();
81
+ if (lastPageView && lastPageView.path === pathname && now - lastPageView.at < DEDUP_MS) {
82
+ return;
83
+ }
84
+ lastPageView = { path: pathname, at: now };
85
+ void send(shopApiUrl, channelToken, 'page_view', { path: pathname });
86
+ }, [shopApiUrl, channelToken, pathname]);
87
+
88
+ // heartbeat — while visible, plus one on refocus; paused while hidden.
89
+ useEffect(() => {
90
+ if (!shopApiUrl || !channelToken) return;
91
+ if (window.parent !== window) return;
92
+
93
+ let interval: ReturnType<typeof setInterval> | null = null;
94
+
95
+ const beat = () => {
96
+ const now = Date.now();
97
+ if (now - lastHeartbeatAt < DEDUP_MS) return;
98
+ lastHeartbeatAt = now;
99
+ void send(shopApiUrl, channelToken, 'heartbeat');
100
+ };
101
+ const start = () => {
102
+ if (interval !== null) return;
103
+ interval = setInterval(beat, HEARTBEAT_MS);
104
+ };
105
+ const stop = () => {
106
+ if (interval === null) return;
107
+ clearInterval(interval);
108
+ interval = null;
109
+ };
110
+ const onVisibility = () => {
111
+ if (document.visibilityState === 'visible') {
112
+ beat();
113
+ start();
114
+ } else {
115
+ stop();
116
+ }
117
+ };
118
+
119
+ document.addEventListener('visibilitychange', onVisibility);
120
+ if (document.visibilityState === 'visible') start();
121
+ return () => {
122
+ document.removeEventListener('visibilitychange', onVisibility);
123
+ stop();
124
+ };
125
+ }, [shopApiUrl, channelToken]);
126
+
127
+ return null;
128
+ }