@forgecart/cli 2.202610052310.0 → 2.202610071755.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.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * DEV-ONLY storefront error beacon. Renders nothing.
7
+ *
8
+ * Registers `window.onerror` + `window.onunhandledrejection` and POSTs the failure
9
+ * detail to the same-origin Next API route `/__forge_beacon`, which forwards it
10
+ * server-side to the in-pod workspace-manager receiver. This surfaces the crash
11
+ * class the pod's dev-server stderr scan CANNOT see: a client-component runtime
12
+ * error or a hydration/SSR-boundary throw that never reaches the dev-server's
13
+ * output. The browser only ever talks SAME-ORIGIN — the API route is the only thing
14
+ * that knows the pod-internal receiver address.
15
+ *
16
+ * Production builds ship zero bytes of this: the literal `NODE_ENV` check is inlined
17
+ * by the bundler, so the effect body is dead-code-eliminated from `next build` /
18
+ * `next start` output — a deployed storefront registers no global error handlers and
19
+ * has no beacon API route reachable in prod (the route itself is dev-gated too).
20
+ *
21
+ * The beacon is best-effort and never blocks the page: the POST is fire-and-forget
22
+ * with `keepalive` so it survives an unload, and a delivery failure is swallowed —
23
+ * the page's own error boundary still renders. The full
24
+ * browser → API route → pod → supervisor-fold path is exercised at the e2e layer,
25
+ * not in this template.
26
+ *
27
+ * Every report carries the revision the PAGE WAS SERVED AT (#847), read out of the
28
+ * `forge-revision` meta tag the server wrote into this document rather than looked
29
+ * up fresh at throw time. That distinction is the entire point: a fresh lookup would
30
+ * answer whatever is live NOW, so a promote landing between serve and throw would
31
+ * make this report blame the new revision for the old one's crash — and the pod's
32
+ * same-revision fold would then mark a healthy tree DEGRADED. The tag cannot drift,
33
+ * because it was rendered with the markup that broke.
34
+ */
35
+ export function ForgeErrorBeacon() {
36
+ useEffect(() => {
37
+ if (process.env.NODE_ENV !== 'development') {
38
+ return;
39
+ }
40
+
41
+ /**
42
+ * The revision this document was SERVED at, or `undefined` when the server
43
+ * did not stamp one (production, or a pod that cannot resolve it — both
44
+ * normal). `'forge-revision'` is repeated here as a literal on purpose: the
45
+ * resolver lives in `src/server/forge/live-revision.ts`, which a client
46
+ * component must not import, so this is the same repeated-contract shape the
47
+ * `/__forge_beacon` URL already uses across this feature's producers.
48
+ *
49
+ * An absent tag, an absent attribute and an empty value all answer
50
+ * `undefined`, which `JSON.stringify` then omits from the body entirely —
51
+ * so an unstamped page sends exactly the payload it sent before, and the pod
52
+ * keeps its existing untagged behaviour instead of receiving a blank string
53
+ * it would have to interpret.
54
+ */
55
+ const readServedRevision = (): string | undefined => {
56
+ const content = document
57
+ .querySelector('meta[name="forge-revision"]')
58
+ ?.getAttribute('content');
59
+ if (!content) return undefined;
60
+ return content;
61
+ };
62
+
63
+ const send = (message: string, stack?: string) => {
64
+ const body = JSON.stringify({
65
+ message,
66
+ stack,
67
+ route: window.location.pathname,
68
+ source: 'browser',
69
+ atRevision: readServedRevision(),
70
+ });
71
+ // Same-origin POST; keepalive lets it survive a navigation/unload. A failed
72
+ // delivery is intentionally swallowed — the beacon is a backstop signal, not
73
+ // a hard dependency of the page.
74
+ void fetch('/__forge_beacon', {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body,
78
+ keepalive: true,
79
+ }).catch(() => undefined);
80
+ };
81
+
82
+ const onError = (event: ErrorEvent) => {
83
+ send(event.message || 'window.onerror', event.error?.stack);
84
+ };
85
+ const onRejection = (event: PromiseRejectionEvent) => {
86
+ const reason = event.reason;
87
+ const message =
88
+ reason instanceof Error ? reason.message : String(reason ?? 'unhandledrejection');
89
+ const stack = reason instanceof Error ? reason.stack : undefined;
90
+ send(message, stack);
91
+ };
92
+
93
+ window.addEventListener('error', onError);
94
+ window.addEventListener('unhandledrejection', onRejection);
95
+ return () => {
96
+ window.removeEventListener('error', onError);
97
+ window.removeEventListener('unhandledrejection', onRejection);
98
+ };
99
+ }, []);
100
+
101
+ return null;
102
+ }
@@ -0,0 +1,479 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import { useEffect } from 'react';
5
+
6
+ import {
7
+ type MarketingIdentifier,
8
+ parseMarketingParams,
9
+ type UtmParams,
10
+ } from '../lib/marketing-params';
11
+ import { mintUuid } from '../lib/uuid';
12
+
13
+ /**
14
+ * Storefront event tracker (design doc S7). Renders nothing.
15
+ *
16
+ * The single client-side emitter of marketing events. Everything goes through
17
+ * the same-origin `/__fc/track` relay — NEVER the SDK's WebSocket singleton,
18
+ * whose connection pins one identity and would mis-attribute every event after
19
+ * a login. The relay replays the shopper's `forgecart-session` cookie per
20
+ * request, so attribution follows the identity.
21
+ *
22
+ * What it emits:
23
+ * - `page_view` on the initial load and on every App-Router route change
24
+ * (client-effect emission is the only prefetch-safe choice — `<Link>`
25
+ * prefetches run middleware and RSC fetches but no client effects).
26
+ * `properties.referrerPath` is the module-tracked previous pathname:
27
+ * `document.referrer` is frozen across soft navigations and must not be
28
+ * read per-view; only the initial load additionally carries it (origin
29
+ * level under the default Referrer-Policy) as `properties.referrer`.
30
+ * - `cta_click` from ONE delegated capture-phase click listener for any
31
+ * `[data-fc-track]` element — the element stays dumb (a semantic slug in
32
+ * `properties.trackId`); what the click MEANS is backend automation
33
+ * config, never deployed code.
34
+ * - `heartbeat` every {@link HEARTBEAT_MS} while the tab is visible (plus
35
+ * one on refocus) — liveness plumbing only, excluded from aggregates.
36
+ *
37
+ * What a landing additionally does: a view whose URL carries marketing
38
+ * parameters reads them once (`lib/marketing-params`). The click IDs go to
39
+ * {@link IDENTIFY_ENDPOINT} and that request is AWAITED before the view is
40
+ * enqueued, so the identity the relay mints carries them instead of the two
41
+ * racing; the campaign tags are held for the rest of the visit and ride every
42
+ * later event. A URL without any of them does nothing at all — no request, no
43
+ * stored state, no change to the events below.
44
+ *
45
+ * Delivery: events buffer and flush as one batch at {@link FLUSH_MAX_EVENTS}
46
+ * events or after an age timer via `fetch(keepalive)` — the FIRST batch waits
47
+ * {@link ESTABLISHMENT_GRACE_MS}, every later batch {@link FLUSH_INTERVAL_MS}
48
+ * (see the grace rationale on the constant); `sendBeacon` is used ONLY for the
49
+ * `pagehide` flush (its unload-time queueing guarantee is the one thing
50
+ * keepalive fetch lacks). Every event carries a client-minted `eventId`
51
+ * (`crypto.randomUUID`) — the server dedupes on it, so an accidental
52
+ * double-send is harmless. Delivery is fire-and-forget: failures are
53
+ * swallowed and never retried; analytics must never break the storefront.
54
+ *
55
+ * Deliberately NO 30-second same-path dedup (the old `ForgeAnalytics`
56
+ * behavior): it was live-map-correct but funnel-lossy — A→B→A inside 30s
57
+ * dropped the return view. The short same-signal suppressors (300ms; also what
58
+ * keeps StrictMode's double-mount from double-emitting, via module scope) plus
59
+ * the server-side `eventId` dedupe replace it, so revisits are recorded.
60
+ *
61
+ * Two deliberate suppressions:
62
+ * - missing config (the pod image pre-renders the template before
63
+ * `forgecart init` writes `.env`) → the tracker is inert;
64
+ * - framed embeds (`window.parent !== window`) → the visual editor's
65
+ * artboard preview emits nothing, so funnels stay production-visitor-only.
66
+ */
67
+
68
+ const HEARTBEAT_MS = 60_000;
69
+ /** Minimum spacing between heartbeats (refocus storms, StrictMode remounts). */
70
+ const HEARTBEAT_MIN_SPACING_MS = 30_000;
71
+ /** Batch flush thresholds: whichever of size / age is hit first sends. */
72
+ const FLUSH_MAX_EVENTS = 10;
73
+ const FLUSH_INTERVAL_MS = 500;
74
+ /**
75
+ * Age timer for the FIRST batch only. On a cookie-less first touch the relay
76
+ * and the first cart Server Action race to mint the shopper session, and the
77
+ * browser keeps whichever `Set-Cookie` lands last — if the tracker's ~500ms
78
+ * page_view batch wins that race against a fast add-to-cart, the cookie flips
79
+ * away from the session holding the just-created cart. Holding the initial
80
+ * flush for this grace lets the cart's cookie land first, so the relay batch
81
+ * then CARRIES it and rides the cart's session instead of minting a rival
82
+ * (design doc S7: the first tracked interaction establishes the identity the
83
+ * cart later reuses — establishment still happens, just not mid-race).
84
+ * Events are timestamped at enqueue (`occurredAt`), so deferral loses nothing.
85
+ */
86
+ const ESTABLISHMENT_GRACE_MS = 2_000;
87
+ /** Same-signal suppression window (per click slug; per page_view path). */
88
+ const SUPPRESS_MS = 300;
89
+ /**
90
+ * Minimum VISIBLE dwell before a route's scroll_depth summary is worth a
91
+ * row (#1022 phase 2b) — filters bounce-through navigations and StrictMode's
92
+ * dev-only phantom unmount.
93
+ */
94
+ const ENGAGEMENT_MIN_DWELL_MS = 250;
95
+ const TRACK_ENDPOINT = '/__fc/track';
96
+ /** Landing-only: where a captured click ID is attached to the shopper. */
97
+ const IDENTIFY_ENDPOINT = '/__fc/identify';
98
+
99
+ /** One buffered event — the relay forwards these fields to `trackEvent`. */
100
+ interface TrackedEvent {
101
+ eventType: string;
102
+ eventId: string;
103
+ occurredAt: string;
104
+ properties?: Record<string, unknown>;
105
+ /**
106
+ * The campaign this visit arrived on, repeated on every event of the visit.
107
+ *
108
+ * Spelled out rather than reused from the parser's `UtmParams` because the
109
+ * five fields are a CONTRACT with the relay's allowlist, not a shape that
110
+ * should follow the parser if it ever grows a sixth tag. An absent tag is an
111
+ * absent KEY here, never `''`: the backend stores a present empty string as a
112
+ * real ClickHouse dimension, where it can no longer be told apart from a
113
+ * campaign that genuinely has no medium.
114
+ */
115
+ utmSource?: string;
116
+ utmMedium?: string;
117
+ utmCampaign?: string;
118
+ utmTerm?: string;
119
+ utmContent?: string;
120
+ }
121
+
122
+ // Module scope on purpose: the buffer, timers and suppressors must survive
123
+ // StrictMode's mount → unmount → mount cycle (a ref would reset) and there is
124
+ // exactly one tracker per page.
125
+ let buffer: TrackedEvent[] = [];
126
+ let flushTimer: number | null = null;
127
+ /** True once any batch has gone out — ends the establishment grace. */
128
+ let firstBatchSent = false;
129
+ const lastClickAtBySlug = new Map<string, number>();
130
+ let lastPageView: { path: string; at: number } | null = null;
131
+ let previousPathname: string | null = null;
132
+ let lastHeartbeatAt = 0;
133
+ /**
134
+ * The campaign tags of the landing that started this visit. Module scope for
135
+ * the same reason as the buffer — StrictMode remounts the component and a ref
136
+ * would reset — and because there is nowhere else to read them from: an
137
+ * internal navigation carries no query string.
138
+ */
139
+ let capturedUtm: UtmParams = {};
140
+ /**
141
+ * The click IDs already sent to {@link IDENTIFY_ENDPOINT}, as `key=value&…`.
142
+ *
143
+ * Module scope so StrictMode's mount → unmount → mount sends ONE identify for
144
+ * one landing. A signature rather than a boolean because "once per captured
145
+ * landing" is not "once per visit": a shopper who clicks a second ad back into
146
+ * the store brings a different click ID that must still be delivered, while a
147
+ * revisit of the same landing URL must not send the same one twice.
148
+ */
149
+ let identifiedClickIds: string | null = null;
150
+
151
+ /**
152
+ * Consent extension point. Tracking currently needs no opt-in, so this is a
153
+ * no-op returning `true`; the merchant cookie-banner fast-follow implements
154
+ * the real gate HERE (read the stored consent state) and every emission path
155
+ * already respects it — nothing enters the buffer without consent.
156
+ */
157
+ function hasTrackingConsent(): boolean {
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * Client-side idempotency key via the shared insecure-context-safe minter
163
+ * (lib/uuid.ts) — a thrown TypeError inside a click listener would violate
164
+ * the "analytics never breaks the storefront" invariant.
165
+ */
166
+ function mintEventId(): string {
167
+ return mintUuid();
168
+ }
169
+
170
+ /** Stamp and buffer one event; flush on size, else arm the age timer. */
171
+ function enqueue(eventType: string, properties?: Record<string, unknown>): void {
172
+ if (!hasTrackingConsent()) return;
173
+ const event: TrackedEvent = {
174
+ eventType,
175
+ eventId: mintEventId(),
176
+ occurredAt: new Date().toISOString(),
177
+ // Spreading an empty object adds no keys, so an organic visit emits exactly
178
+ // the event shape it did before any of this existed.
179
+ ...capturedUtm,
180
+ };
181
+ if (properties) event.properties = properties;
182
+ buffer.push(event);
183
+
184
+ if (buffer.length >= FLUSH_MAX_EVENTS) {
185
+ flush();
186
+ return;
187
+ }
188
+ if (flushTimer === null) {
189
+ const delay = firstBatchSent ? FLUSH_INTERVAL_MS : ESTABLISHMENT_GRACE_MS;
190
+ flushTimer = window.setTimeout(flush, delay);
191
+ }
192
+ }
193
+
194
+ /** Drain the buffer, returning the batch to send (empties module state). */
195
+ function drain(): TrackedEvent[] {
196
+ if (flushTimer !== null) {
197
+ window.clearTimeout(flushTimer);
198
+ flushTimer = null;
199
+ }
200
+ const events = buffer;
201
+ buffer = [];
202
+ // The grace ends once a batch actually goes out — from then on the age
203
+ // timer runs at the normal cadence.
204
+ if (events.length > 0) firstBatchSent = true;
205
+ return events;
206
+ }
207
+
208
+ /** Primary delivery: keepalive fetch, fire-and-forget, failures swallowed. */
209
+ function flush(): void {
210
+ const events = drain();
211
+ if (events.length === 0) return;
212
+ void fetch(TRACK_ENDPOINT, {
213
+ method: 'POST',
214
+ headers: { 'content-type': 'application/json' },
215
+ body: JSON.stringify({ events }),
216
+ keepalive: true,
217
+ }).catch(() => undefined);
218
+ }
219
+
220
+ /**
221
+ * Read this URL's marketing parameters and act on what it carries.
222
+ *
223
+ * Resolves once the click IDs have been delivered — the caller awaits it so the
224
+ * identify request and the first tracked batch cannot race. Does nothing
225
+ * whatsoever for a URL that carries none, which is the overwhelmingly common
226
+ * case and the reason the parser returns a union.
227
+ */
228
+ async function captureLanding(search: string): Promise<void> {
229
+ // Consent is checked HERE as well as in `enqueue` because the identify
230
+ // request is not a buffered event: it leaves on its own, carrying a click ID
231
+ // that identifies the visitor more directly than anything in the buffer, so
232
+ // a gate that only guards `enqueue` would let it past a visitor who declined.
233
+ if (!hasTrackingConsent()) return;
234
+ const marketing = parseMarketingParams(search);
235
+ if (marketing.kind === 'none') return;
236
+
237
+ // Merge, never clear — the same semantics `middleware.ts` uses for the
238
+ // `fc-exp` cookie. Only a URL that carries tags updates them, so an internal
239
+ // navigation keeps the campaign the visitor arrived on, and a second ad
240
+ // landing legitimately overwrites the tags it brings.
241
+ capturedUtm = { ...capturedUtm, ...marketing.utm };
242
+
243
+ if (marketing.identifiers.length === 0) return;
244
+ const signature = signClickIds(marketing.identifiers);
245
+ if (signature === identifiedClickIds) return;
246
+ // Recorded BEFORE the await: StrictMode's second mount runs while this
247
+ // request is still in flight and must find the landing already claimed.
248
+ identifiedClickIds = signature;
249
+ await fetch(IDENTIFY_ENDPOINT, {
250
+ method: 'POST',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify({ identifiers: marketing.identifiers }),
253
+ keepalive: true,
254
+ }).catch(() => undefined); // same swallow as flush(): analytics never breaks the storefront
255
+ }
256
+
257
+ /** The click IDs of one landing as a comparable string, in parser order. */
258
+ function signClickIds(identifiers: readonly MarketingIdentifier[]): string {
259
+ return identifiers.map(({ key, value }) => `${key}=${value}`).join('&');
260
+ }
261
+
262
+ /**
263
+ * Capture the landing, THEN record the view.
264
+ *
265
+ * The order is the whole point. The first request of a visit is what mints the
266
+ * shopper session, so sending the identify and the page_view in parallel would
267
+ * mint one identity per request and split the visit in two — the click ID on
268
+ * one, every tracked event on the other, and no attribution joining them.
269
+ */
270
+ async function emitPageView(properties: Record<string, unknown>): Promise<void> {
271
+ await captureLanding(window.location.search);
272
+ enqueue('page_view', properties);
273
+ }
274
+
275
+ /**
276
+ * `pagehide` delivery: `sendBeacon` — the only flush that uses it, for its
277
+ * survive-the-unload queueing guarantee. Falls back to keepalive fetch where
278
+ * the API is unavailable.
279
+ */
280
+ function flushOnPagehide(): void {
281
+ const events = drain();
282
+ if (events.length === 0) return;
283
+ const body = JSON.stringify({ events });
284
+ if (typeof navigator.sendBeacon === 'function') {
285
+ navigator.sendBeacon(TRACK_ENDPOINT, new Blob([body], { type: 'application/json' }));
286
+ return;
287
+ }
288
+ void fetch(TRACK_ENDPOINT, {
289
+ method: 'POST',
290
+ headers: { 'content-type': 'application/json' },
291
+ body,
292
+ keepalive: true,
293
+ }).catch(() => undefined);
294
+ }
295
+
296
+ /**
297
+ * `enabled` is computed SERVER-side (env presence) and passed as a boolean —
298
+ * the tracker only needs to know whether the relay is configured; the channel
299
+ * token itself must never serialize into the RSC payload.
300
+ */
301
+ export function ForgeTracker({ enabled }: { enabled: boolean }) {
302
+ const pathname = usePathname();
303
+
304
+ // page_view — initial load + every route change.
305
+ useEffect(() => {
306
+ if (!enabled) return;
307
+ if (window.parent !== window) return;
308
+ const now = Date.now();
309
+ if (lastPageView && lastPageView.path === pathname && now - lastPageView.at < SUPPRESS_MS) {
310
+ return;
311
+ }
312
+ lastPageView = { path: pathname, at: now };
313
+ const properties: Record<string, unknown> = { path: pathname };
314
+ if (previousPathname !== null) {
315
+ properties.referrerPath = previousPathname;
316
+ } else if (document.referrer) {
317
+ properties.referrer = document.referrer;
318
+ }
319
+ previousPathname = pathname;
320
+ // The suppressor above is already stamped, so the double mount StrictMode
321
+ // performs while this is in flight still emits exactly one view.
322
+ void emitPageView(properties);
323
+ }, [enabled, pathname]);
324
+
325
+ // cta_click — one delegated capture-phase listener — and the pagehide flush.
326
+ useEffect(() => {
327
+ if (!enabled) return;
328
+ if (window.parent !== window) return;
329
+
330
+ const onClick = (event: Event): void => {
331
+ if (!(event.target instanceof Element)) return;
332
+ const tracked = event.target.closest('[data-fc-track]');
333
+ if (!tracked) return;
334
+ const trackId = tracked.getAttribute('data-fc-track');
335
+ if (!trackId) return;
336
+ const now = Date.now();
337
+ const lastAt = lastClickAtBySlug.get(trackId);
338
+ if (lastAt !== undefined && now - lastAt < SUPPRESS_MS) return;
339
+ lastClickAtBySlug.set(trackId, now);
340
+ enqueue('cta_click', { trackId, path: window.location.pathname });
341
+ };
342
+ const onPagehide = (): void => flushOnPagehide();
343
+
344
+ // Capture phase so the event is seen even when a handler below stops
345
+ // propagation (e.g. a framework's synthetic-event stopPropagation).
346
+ document.addEventListener('click', onClick, true);
347
+ window.addEventListener('pagehide', onPagehide);
348
+ return () => {
349
+ document.removeEventListener('click', onClick, true);
350
+ window.removeEventListener('pagehide', onPagehide);
351
+ };
352
+ }, [enabled]);
353
+
354
+ // heartbeat — while visible, plus one on refocus; paused while hidden.
355
+ useEffect(() => {
356
+ if (!enabled) return;
357
+ if (window.parent !== window) return;
358
+
359
+ let interval: ReturnType<typeof setInterval> | null = null;
360
+
361
+ const beat = (): void => {
362
+ const now = Date.now();
363
+ if (now - lastHeartbeatAt < HEARTBEAT_MIN_SPACING_MS) return;
364
+ lastHeartbeatAt = now;
365
+ enqueue('heartbeat');
366
+ };
367
+ const start = (): void => {
368
+ if (interval !== null) return;
369
+ interval = setInterval(beat, HEARTBEAT_MS);
370
+ };
371
+ const stop = (): void => {
372
+ if (interval === null) return;
373
+ clearInterval(interval);
374
+ interval = null;
375
+ };
376
+ const onVisibility = (): void => {
377
+ if (document.visibilityState === 'visible') {
378
+ beat();
379
+ start();
380
+ } else {
381
+ stop();
382
+ }
383
+ };
384
+
385
+ document.addEventListener('visibilitychange', onVisibility);
386
+ if (document.visibilityState === 'visible') start();
387
+ return () => {
388
+ document.removeEventListener('visibilitychange', onVisibility);
389
+ stop();
390
+ };
391
+ }, [enabled]);
392
+
393
+ // scroll_depth — ONE engagement summary per route (#1022 phase 2b): the
394
+ // max scroll depth reached (percent, 0-100) and the VISIBLE dwell time,
395
+ // emitted when the route unmounts (client-side navigation) or the page
396
+ // hides. Rides the dormant `scroll_depth` event type already in the
397
+ // default shop allowlist; the server hoists both properties into the
398
+ // typed `scrollDepth`/`durationMs` ClickHouse columns (CH 005).
399
+ useEffect(() => {
400
+ if (!enabled) return;
401
+ if (window.parent !== window) return;
402
+
403
+ let maxDepthPct = 0;
404
+ let visibleMs = 0;
405
+ let visibleSince: number | null =
406
+ document.visibilityState === 'visible' ? performance.now() : null;
407
+ let ticking = false;
408
+ let emitted = false;
409
+
410
+ const measure = (): void => {
411
+ ticking = false;
412
+ const doc = document.documentElement;
413
+ // A page shorter than the viewport is fully seen — 100 by definition;
414
+ // otherwise percent of total document height brought into view.
415
+ const pct =
416
+ doc.scrollHeight <= window.innerHeight
417
+ ? 100
418
+ : Math.min(
419
+ 100,
420
+ Math.round(((window.scrollY + window.innerHeight) / doc.scrollHeight) * 100),
421
+ );
422
+ if (pct > maxDepthPct) maxDepthPct = pct;
423
+ };
424
+ const onScroll = (): void => {
425
+ if (ticking) return;
426
+ ticking = true;
427
+ requestAnimationFrame(measure);
428
+ };
429
+ const onVisibility = (): void => {
430
+ const now = performance.now();
431
+ if (document.visibilityState === 'visible') {
432
+ visibleSince ??= now;
433
+ return;
434
+ }
435
+ if (visibleSince !== null) {
436
+ visibleMs += now - visibleSince;
437
+ visibleSince = null;
438
+ }
439
+ };
440
+ const emit = (): void => {
441
+ if (emitted) return;
442
+ if (visibleSince !== null) {
443
+ visibleMs += performance.now() - visibleSince;
444
+ visibleSince = null;
445
+ }
446
+ // Dwell floor: a route that never accumulated meaningful visible time
447
+ // has no engagement story — this also swallows StrictMode's dev-only
448
+ // mount/unmount phantom, which would otherwise emit a zero-dwell row.
449
+ if (visibleMs < ENGAGEMENT_MIN_DWELL_MS) return;
450
+ emitted = true;
451
+ enqueue('scroll_depth', {
452
+ path: window.location.pathname,
453
+ scrollDepth: maxDepthPct,
454
+ durationMs: Math.round(visibleMs),
455
+ });
456
+ };
457
+ const onPagehide = (): void => {
458
+ // Emit BEFORE the flush so the summary rides the pagehide beacon —
459
+ // flushOnPagehide drains-and-no-ops on empty, so the sibling pagehide
460
+ // listener's own call stays harmless.
461
+ emit();
462
+ flushOnPagehide();
463
+ };
464
+
465
+ measure(); // above-the-fold baseline before any scroll fires
466
+
467
+ window.addEventListener('scroll', onScroll, { passive: true });
468
+ document.addEventListener('visibilitychange', onVisibility);
469
+ window.addEventListener('pagehide', onPagehide);
470
+ return () => {
471
+ window.removeEventListener('scroll', onScroll);
472
+ document.removeEventListener('visibilitychange', onVisibility);
473
+ window.removeEventListener('pagehide', onPagehide);
474
+ emit(); // client-side route change: summarize the route being left
475
+ };
476
+ }, [enabled, pathname]);
477
+
478
+ return null;
479
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Mounts the ForgeCart visual-editor guest runtime. Renders nothing.
7
+ *
8
+ * The runtime stays dormant until activated: it registers a single `message`
9
+ * listener and posts a READY beacon, then waits for the dashboard host to
10
+ * drive a nonce-gated handshake over `postMessage` (the activation nonce
11
+ * arrives via the `#fcd=` URL fragment — there is no env switch). On a page
12
+ * that is not embedded in an iframe it never even loads.
13
+ *
14
+ * Production builds ship zero bytes of this: the literal `NODE_ENV` check is
15
+ * inlined by the bundler, so the effect body — including the dynamic import
16
+ * and its chunk — is dead-code-eliminated from `next build` output.
17
+ */
18
+ export function ForgecartDesigner() {
19
+ useEffect(() => {
20
+ if (process.env.NODE_ENV !== 'development') {
21
+ return;
22
+ }
23
+ // Top-level pages have no designer host; skip loading the chunk entirely.
24
+ if (window.parent === window) {
25
+ return;
26
+ }
27
+ let cancelled = false;
28
+ let dispose: (() => void) | undefined;
29
+ const install = async () => {
30
+ const mod = await import('@forgecart/designer-runtime');
31
+ if (cancelled) {
32
+ return;
33
+ }
34
+ dispose = mod.installDesignerRuntime();
35
+ };
36
+ void install();
37
+ return () => {
38
+ cancelled = true;
39
+ dispose?.();
40
+ };
41
+ }, []);
42
+ return null;
43
+ }