@forgecart/cli 2.202610052310.0 → 2.202610060357.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 +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- 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
|
+
}
|