@forgecart/cli 2.202607161748.0 → 2.202607202320.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/dist/src/commands/init.d.ts +2 -2
- package/dist/src/commands/init.js +5 -5
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/.env.example +1 -1
- package/templates/storefront/README.md +2 -2
- package/templates/storefront/next.config.js +18 -10
- package/templates/storefront/package.json +3 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +168 -0
- package/templates/storefront/src/app/layout.tsx +2 -2
- package/templates/storefront/src/app/page.tsx +15 -3
- package/templates/storefront/src/components/CartView.tsx +2 -0
- package/templates/storefront/src/components/ForgeTracker.tsx +267 -0
- package/templates/storefront/src/components/Header.tsx +1 -1
- package/templates/storefront/src/components/ProductCard.tsx +1 -0
- package/templates/storefront/src/components/ProductPurchase.tsx +1 -0
- package/templates/storefront/src/lib/cart-actions.ts +1 -1
- package/templates/storefront/src/lib/experiments.ts +335 -0
- package/templates/storefront/src/lib/forgecart.ts +3 -3
- package/templates/storefront/src/lib/track-forward.ts +158 -0
- package/templates/storefront/src/middleware.ts +199 -0
- package/templates/storefront/src/components/ForgeAnalytics.tsx +0 -128
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared marketing-event upstream forwarder (design doc S7 + F-B).
|
|
3
|
+
*
|
|
4
|
+
* ONE implementation of "send one trackEvent to the ForgeCart shop API over
|
|
5
|
+
* plain HTTP GraphQL", used by both server-side emitters:
|
|
6
|
+
*
|
|
7
|
+
* - the `/__fc/track` relay route (client batches — page_view, cta_click,
|
|
8
|
+
* heartbeat, …), which loops this per item;
|
|
9
|
+
* - `experiments.ts` (F-B), which emits `experiment_exposure` inside
|
|
10
|
+
* `next/server` `after()` — the exposure MUST NOT self-call the relay
|
|
11
|
+
* over HTTP (an extra localhost round-trip that breaks the moment the
|
|
12
|
+
* server is bound behind a proxy) and MUST NOT use the SDK's WebSocket
|
|
13
|
+
* singleton (its connection pins one identity; design doc F-B blocker).
|
|
14
|
+
*
|
|
15
|
+
* Why raw HTTP GraphQL: the generated SDK transports operations over one
|
|
16
|
+
* WebSocket whose connection pins ONE identity — the relay/exposure path
|
|
17
|
+
* instead sends each request with the CURRENT shopper session as
|
|
18
|
+
* `Authorization: Bearer`, so attribution follows the shopper. The generated
|
|
19
|
+
* SDK also has no plain-HTTP query path, hence the hand-rolled POST (the shop
|
|
20
|
+
* API serves HTTP POST at the same `/shop-api` URL).
|
|
21
|
+
*
|
|
22
|
+
* Config is env-backed (`forgecart init` writes `.env`); before that
|
|
23
|
+
* exists (the workspace-pod image prewarm) `getUpstreamConfig()` answers
|
|
24
|
+
* `null` and every caller stays inert — the storefront never crashes over
|
|
25
|
+
* missing analytics config.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
29
|
+
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Conservative bot gate shared by every server-side emitter: only UA
|
|
33
|
+
* substrings that are unambiguously non-human (crawlers and CLI fetchers).
|
|
34
|
+
* Real browsers never match; an absent UA does NOT match (absence is not
|
|
35
|
+
* obvious, and losing real events costs more than relaying a stray
|
|
36
|
+
* programmatic call the backend's reputation flags catch).
|
|
37
|
+
*/
|
|
38
|
+
const BOT_UA_SUBSTRINGS = ['bot', 'spider', 'crawl', 'curl', 'wget'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The SDK's own `ShopTrackEvent` document, inlined: the generated client is
|
|
42
|
+
* WebSocket-only, so this module speaks plain HTTP GraphQL with the same shape.
|
|
43
|
+
*/
|
|
44
|
+
const TRACK_EVENT_MUTATION = `mutation ShopTrackEvent($input: TrackEventInput!) {
|
|
45
|
+
trackEvent(input: $input) {
|
|
46
|
+
eventId
|
|
47
|
+
accepted
|
|
48
|
+
}
|
|
49
|
+
}`;
|
|
50
|
+
|
|
51
|
+
/** The channel-scoped upstream the storefront server talks to. */
|
|
52
|
+
export interface UpstreamConfig {
|
|
53
|
+
shopApiUrl: string;
|
|
54
|
+
channelToken: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The shop API's `TrackEventInput` — validated allowlist, never raw client JSON. */
|
|
58
|
+
export interface TrackEventInput {
|
|
59
|
+
eventType: string;
|
|
60
|
+
eventId?: string;
|
|
61
|
+
occurredAt?: string;
|
|
62
|
+
properties?: Record<string, unknown>;
|
|
63
|
+
utmSource?: string;
|
|
64
|
+
utmMedium?: string;
|
|
65
|
+
utmCampaign?: string;
|
|
66
|
+
utmTerm?: string;
|
|
67
|
+
utmContent?: string;
|
|
68
|
+
currencyCode?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Per-request identity/enrichment headers forwarded upstream with one event. */
|
|
72
|
+
export interface ForwardHeaders {
|
|
73
|
+
/** Current shopper session riding as `Authorization: Bearer`, if any. */
|
|
74
|
+
sessionToken: string | null;
|
|
75
|
+
/** The browser's own User-Agent, forwarded for device enrichment. */
|
|
76
|
+
userAgent: string;
|
|
77
|
+
/**
|
|
78
|
+
* Client IP as `x-forwarded-for` (the proxy-standard contract the shop API
|
|
79
|
+
* trusts) — NEVER ForgeCart's edge-secret-gated client-IP override header
|
|
80
|
+
* (design doc S7): unsigned use would collapse per-IP rate limits onto the
|
|
81
|
+
* storefront server's IP and geo-seed wrong market/currency defaults.
|
|
82
|
+
*/
|
|
83
|
+
forwardedFor: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface UpstreamOutcome {
|
|
87
|
+
accepted: boolean;
|
|
88
|
+
eventId: string | null;
|
|
89
|
+
/** Session token surfaced in the response extensions, if any. */
|
|
90
|
+
sessionToken: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface GraphQLTrackResponse {
|
|
94
|
+
data?: { trackEvent?: { eventId?: string; accepted?: boolean } | null } | null;
|
|
95
|
+
errors?: unknown[];
|
|
96
|
+
extensions?: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The configured upstream, or `null` before `forgecart init` writes
|
|
101
|
+
* `.env` (the image-prewarm contract — callers must stay inert).
|
|
102
|
+
*/
|
|
103
|
+
export function getUpstreamConfig(): UpstreamConfig | null {
|
|
104
|
+
if (!SHOP_API_URL || !CHANNEL_TOKEN) return null;
|
|
105
|
+
return { shopApiUrl: SHOP_API_URL, channelToken: CHANNEL_TOKEN };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True when the UA is unambiguously a crawler/CLI fetcher — skip emission. */
|
|
109
|
+
export function isObviousBot(userAgent: string): boolean {
|
|
110
|
+
const normalized = userAgent.toLowerCase();
|
|
111
|
+
return BOT_UA_SUBSTRINGS.some((substring) => normalized.includes(substring));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Forward one event as a raw HTTP GraphQL POST. Any failure — missing config,
|
|
116
|
+
* network, HTTP status, GraphQL errors — resolves to `accepted:false`; the
|
|
117
|
+
* forwarder is best-effort by design (analytics must never break the
|
|
118
|
+
* storefront) and `accepted:false` is terminal (callers never retry).
|
|
119
|
+
*/
|
|
120
|
+
export async function forwardTrackEvent(
|
|
121
|
+
input: TrackEventInput,
|
|
122
|
+
{ sessionToken, userAgent, forwardedFor }: ForwardHeaders,
|
|
123
|
+
): Promise<UpstreamOutcome> {
|
|
124
|
+
const upstream = getUpstreamConfig();
|
|
125
|
+
if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
|
|
126
|
+
|
|
127
|
+
const headers: Record<string, string> = {
|
|
128
|
+
'content-type': 'application/json',
|
|
129
|
+
'forgecart-token': upstream.channelToken,
|
|
130
|
+
};
|
|
131
|
+
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
|
132
|
+
if (userAgent) headers['user-agent'] = userAgent;
|
|
133
|
+
if (forwardedFor) headers['x-forwarded-for'] = forwardedFor;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(upstream.shopApiUrl, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
|
|
142
|
+
|
|
143
|
+
const payload = (await response.json()) as GraphQLTrackResponse;
|
|
144
|
+
const minted = payload.extensions?.['forgecart-auth-token'];
|
|
145
|
+
const capturedToken = typeof minted === 'string' && minted.length > 0 ? minted : null;
|
|
146
|
+
const trackEvent = payload.data?.trackEvent;
|
|
147
|
+
if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
|
|
148
|
+
return {
|
|
149
|
+
accepted: trackEvent.accepted === true,
|
|
150
|
+
eventId: trackEvent.eventId ?? null,
|
|
151
|
+
sessionToken: capturedToken,
|
|
152
|
+
};
|
|
153
|
+
} catch {
|
|
154
|
+
// Best-effort by design: an unreachable/booting backend must never break
|
|
155
|
+
// the storefront, and `accepted:false` is terminal — no caller retries.
|
|
156
|
+
return { accepted: false, eventId: null, sessionToken: null };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
import { uuidv7 } from 'uuidv7';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Visitor-identity + forced-experiment-entry middleware (design doc S6 + F-B).
|
|
7
|
+
*
|
|
8
|
+
* S6 — visitor identity. Mints the durable anonymous visitor key — the
|
|
9
|
+
* `forgecart-visitor` cookie (UUIDv7, so ids sort by first-touch time) —
|
|
10
|
+
* exactly once per browser. It is the A/B-test subject key and a
|
|
11
|
+
* marketing-event property; it is deliberately NOT a session and NOT a
|
|
12
|
+
* marketing identity.
|
|
13
|
+
*
|
|
14
|
+
* Contract:
|
|
15
|
+
* - Mint ONLY when the cookie is absent. An identified visitor's response
|
|
16
|
+
* carries NO `Set-Cookie` at all — re-sending it on every response would
|
|
17
|
+
* make every page uncacheable for shared caches.
|
|
18
|
+
* - Zero fetches, zero environment reads: the workspace-pod prewarm boots
|
|
19
|
+
* this dev server with no `.env`, and the middleware must be inert
|
|
20
|
+
* to that (it never crashes a request, env or not).
|
|
21
|
+
* - Harmless on `<Link>` prefetches: they carry cookies like any navigation,
|
|
22
|
+
* so an identified visitor's prefetch passes straight through; page-view
|
|
23
|
+
* EMISSION stays in the client tracker precisely because prefetches reach
|
|
24
|
+
* middleware (design doc §H.1) — this file only persists the id.
|
|
25
|
+
* - Infra routes are excluded via the matcher: `/ping` (the pod readiness
|
|
26
|
+
* probe polls it forever — it must stay zero-overhead and byte-stable) and
|
|
27
|
+
* `/__forge_beacon` (error beacon; a browser that reaches it already has
|
|
28
|
+
* the cookie from the page), plus Next static internals.
|
|
29
|
+
*
|
|
30
|
+
* F-B — forced campaign entry. Campaign URLs carry
|
|
31
|
+
* `?fc-exp=<experimentKey>:<variantKey>[,<experimentKey>:<variantKey>…]`
|
|
32
|
+
* (at most {@link FORCED_URL_ENTRY_CAP} entries; both sides must match the
|
|
33
|
+
* backend's slug grammar). Valid entries MERGE into the `forgecart-exp-force`
|
|
34
|
+
* cookie — a JSON map `{ experimentKey: variantKey }`, httpOnly (server-read
|
|
35
|
+
* only), 30 days — and the request continues WITHOUT a redirect, so the
|
|
36
|
+
* campaign link stays shareable (the leak is the point). Whether a forced key
|
|
37
|
+
* actually overrides the hash is decided downstream by the assignment engine
|
|
38
|
+
* (`allowForcedEntry` + variant existence) — the middleware only persists.
|
|
39
|
+
*
|
|
40
|
+
* The merged map is ALSO written back onto this request's own `cookie` header
|
|
41
|
+
* (`NextResponse.next({ request })`), so the landing render itself already
|
|
42
|
+
* sees the forced variant — Set-Cookie alone would only reach the NEXT
|
|
43
|
+
* request. `getVariant()` still never relies on middleware→RSC cookie
|
|
44
|
+
* propagation for the VISITOR id (it reads-else-mints within a
|
|
45
|
+
* request-scoped cache); the forced map is different: it either rode in on
|
|
46
|
+
* the request cookie or is deterministically rewritten here.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const VISITOR_COOKIE_NAME = 'forgecart-visitor';
|
|
50
|
+
|
|
51
|
+
/** 400 days — Chrome's upper bound on cookie lifetime (RFC 6265bis). */
|
|
52
|
+
const VISITOR_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
|
|
53
|
+
|
|
54
|
+
/** Forced-entry URL param + cookie (design doc F-B). */
|
|
55
|
+
const FORCED_ENTRY_PARAM = 'fc-exp';
|
|
56
|
+
const FORCED_ENTRY_COOKIE_NAME = 'forgecart-exp-force';
|
|
57
|
+
|
|
58
|
+
/** 30 days — the campaign-entry window the design doc fixes. */
|
|
59
|
+
const FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
60
|
+
|
|
61
|
+
/** At most this many `exp:variant` entries are honored per URL (design doc). */
|
|
62
|
+
const FORCED_URL_ENTRY_CAP = 3;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* At most this many experiments are retained in the merged cookie (oldest
|
|
66
|
+
* evicted first). At the slug grammar's 64-char ceiling per side that keeps
|
|
67
|
+
* the serialized JSON well under the 4096-byte browser cookie cap, so a
|
|
68
|
+
* hostile chain of campaign URLs can never grow the header unboundedly.
|
|
69
|
+
*/
|
|
70
|
+
const FORCED_COOKIE_ENTRY_CAP = 12;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The backend's experiment/variant slug grammar
|
|
74
|
+
* (`ExperimentService`'s SLUG_REGEX) — both sides of every `exp:variant`
|
|
75
|
+
* entry must match it or the entry is dropped.
|
|
76
|
+
*/
|
|
77
|
+
const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse the raw `?fc-exp=` value into at most {@link FORCED_URL_ENTRY_CAP}
|
|
81
|
+
* slug-valid `experimentKey → variantKey` entries (later duplicates of the
|
|
82
|
+
* same experiment win; invalid entries are dropped silently — a mangled
|
|
83
|
+
* campaign URL must still serve the page). `null` when nothing valid remains.
|
|
84
|
+
*/
|
|
85
|
+
function parseForcedEntryParam(raw: string | null): Map<string, string> | null {
|
|
86
|
+
if (!raw) return null;
|
|
87
|
+
const entries = new Map<string, string>();
|
|
88
|
+
for (const item of raw.split(',')) {
|
|
89
|
+
const separatorAt = item.indexOf(':');
|
|
90
|
+
if (separatorAt === -1) continue;
|
|
91
|
+
const experimentKey = item.slice(0, separatorAt);
|
|
92
|
+
const variantKey = item.slice(separatorAt + 1);
|
|
93
|
+
if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
|
|
94
|
+
if (!entries.has(experimentKey) && entries.size >= FORCED_URL_ENTRY_CAP) continue;
|
|
95
|
+
entries.set(experimentKey, variantKey);
|
|
96
|
+
}
|
|
97
|
+
return entries.size > 0 ? entries : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read the existing `forgecart-exp-force` cookie into slug-valid entries.
|
|
102
|
+
* Malformed JSON / shapes are discarded wholesale — the cookie is
|
|
103
|
+
* client-tamperable, and the next valid campaign URL rewrites it anyway.
|
|
104
|
+
*/
|
|
105
|
+
function readForcedEntryCookie(request: NextRequest): Map<string, string> {
|
|
106
|
+
const entries = new Map<string, string>();
|
|
107
|
+
const raw = request.cookies.get(FORCED_ENTRY_COOKIE_NAME)?.value;
|
|
108
|
+
if (!raw) return entries;
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
// `request.cookies` already percent-decodes — `raw` is the JSON text.
|
|
112
|
+
parsed = JSON.parse(raw);
|
|
113
|
+
} catch {
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return entries;
|
|
117
|
+
for (const [experimentKey, variantKey] of Object.entries(parsed)) {
|
|
118
|
+
if (typeof variantKey !== 'string') continue;
|
|
119
|
+
if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
|
|
120
|
+
entries.set(experimentKey, variantKey);
|
|
121
|
+
}
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Merge URL entries over the stored map (URL wins; re-set keys move to the
|
|
127
|
+
* newest position) and evict oldest entries past {@link FORCED_COOKIE_ENTRY_CAP}.
|
|
128
|
+
* Returns the cookie-ready serialized value, or `null` when the URL carried
|
|
129
|
+
* no valid entry (the common case — zero cookie work on normal traffic).
|
|
130
|
+
*/
|
|
131
|
+
function mergeForcedEntries(request: NextRequest): string | null {
|
|
132
|
+
const fromUrl = parseForcedEntryParam(request.nextUrl.searchParams.get(FORCED_ENTRY_PARAM));
|
|
133
|
+
if (!fromUrl) return null;
|
|
134
|
+
const merged = readForcedEntryCookie(request);
|
|
135
|
+
for (const [experimentKey, variantKey] of fromUrl) {
|
|
136
|
+
merged.delete(experimentKey);
|
|
137
|
+
merged.set(experimentKey, variantKey);
|
|
138
|
+
}
|
|
139
|
+
while (merged.size > FORCED_COOKIE_ENTRY_CAP) {
|
|
140
|
+
const oldest = merged.keys().next().value;
|
|
141
|
+
if (oldest === undefined) break;
|
|
142
|
+
merged.delete(oldest);
|
|
143
|
+
}
|
|
144
|
+
// RAW JSON on purpose: Next's cookie APIs percent-encode on serialize and
|
|
145
|
+
// decode on parse — symmetrically for the response Set-Cookie AND the
|
|
146
|
+
// request-header rewrite below — so pre-encoding here would double-encode.
|
|
147
|
+
return JSON.stringify(Object.fromEntries(merged));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function middleware(request: NextRequest): NextResponse {
|
|
151
|
+
const forcedEntryValue = mergeForcedEntries(request);
|
|
152
|
+
const mintVisitor = !request.cookies.has(VISITOR_COOKIE_NAME);
|
|
153
|
+
if (!forcedEntryValue && !mintVisitor) {
|
|
154
|
+
return NextResponse.next();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let response: NextResponse;
|
|
158
|
+
if (forcedEntryValue) {
|
|
159
|
+
// Rewrite this request's own cookie header so the landing render already
|
|
160
|
+
// resolves the forced variant (Set-Cookie alone reaches only the NEXT
|
|
161
|
+
// request), then persist for subsequent navigations. No redirect — the
|
|
162
|
+
// campaign URL stays shareable.
|
|
163
|
+
request.cookies.set(FORCED_ENTRY_COOKIE_NAME, forcedEntryValue);
|
|
164
|
+
response = NextResponse.next({ request: { headers: request.headers } });
|
|
165
|
+
response.cookies.set({
|
|
166
|
+
name: FORCED_ENTRY_COOKIE_NAME,
|
|
167
|
+
value: forcedEntryValue,
|
|
168
|
+
httpOnly: true,
|
|
169
|
+
secure: true,
|
|
170
|
+
sameSite: 'lax',
|
|
171
|
+
maxAge: FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS,
|
|
172
|
+
path: '/',
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
response = NextResponse.next();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (mintVisitor) {
|
|
179
|
+
// `secure` is honored on https (deployed storefronts) and on localhost;
|
|
180
|
+
// browsers may drop it on other plain-http dev hosts — acceptable, since a
|
|
181
|
+
// stable visitor identity is a production concern, not a preview one.
|
|
182
|
+
response.cookies.set({
|
|
183
|
+
name: VISITOR_COOKIE_NAME,
|
|
184
|
+
value: uuidv7(),
|
|
185
|
+
httpOnly: true,
|
|
186
|
+
secure: true,
|
|
187
|
+
sameSite: 'lax',
|
|
188
|
+
maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
|
|
189
|
+
path: '/',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return response;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const config = {
|
|
196
|
+
// Every page and route EXCEPT Next internals/static assets and the infra
|
|
197
|
+
// routes documented above.
|
|
198
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|ping|__forge_beacon).*)'],
|
|
199
|
+
};
|
|
@@ -1,128 +0,0 @@
|
|
|
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
|
-
}
|