@forgecart/cli 2.202610280649.0 → 2.202610280926.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-questions.d.ts +12 -11
- package/dist/src/commands/init-questions.js +15 -23
- package/dist/src/commands/init-questions.js.map +1 -1
- package/dist/src/commands/init.d.ts +13 -13
- package/dist/src/commands/init.js +75 -144
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +3 -3
- package/dist/src/commands/refresh.js +4 -14
- package/dist/src/commands/refresh.js.map +1 -1
- package/dist/src/commands/template-manifest.d.ts +2 -5
- package/dist/src/commands/template-manifest.js +1 -1
- package/dist/src/commands/template-manifest.js.map +1 -1
- package/dist/src/commands/type/init.input.d.ts +1 -1
- package/dist/src/commands/type/init.types.d.ts +4 -4
- package/dist/src/template/template-set.d.ts +17 -27
- package/dist/src/template/template-set.js +25 -42
- package/dist/src/template/template-set.js.map +1 -1
- package/dist/src/template/template-unavailable-error.d.ts +3 -4
- package/dist/src/template/template-unavailable-error.js +3 -4
- package/dist/src/template/template-unavailable-error.js.map +1 -1
- package/dist/src/template/type/template-set.types.d.ts +14 -17
- package/dist/src/template/type/template-set.types.js +6 -1
- package/dist/src/template/type/template-set.types.js.map +1 -1
- package/dist/src/template/util/template-identity.util.d.ts +1 -3
- package/dist/src/template/util/template-identity.util.js +0 -5
- package/dist/src/template/util/template-identity.util.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront-blank/.forgecartignore +2 -0
- package/templates/storefront-blank/Procfile +1 -0
- package/templates/storefront-blank/README.md +86 -0
- package/templates/storefront-blank/SEO-MIGRATION.md +718 -0
- package/templates/storefront-blank/_gitignore +21 -0
- package/templates/storefront-blank/next.config.js +105 -0
- package/templates/storefront-blank/package.json +35 -0
- package/templates/storefront-blank/postcss.config.js +5 -0
- package/templates/storefront-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-blank/src/app/error.tsx +21 -0
- package/templates/storefront-blank/src/app/global-error.tsx +23 -0
- package/templates/storefront-blank/src/app/globals.css +110 -0
- package/templates/storefront-blank/src/app/layout.tsx +102 -0
- package/templates/storefront-blank/src/app/page.tsx +52 -0
- package/templates/storefront-blank/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront-blank/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-blank/src/app/ping/route.ts +21 -0
- package/templates/storefront-blank/src/app/robots.ts +69 -0
- package/templates/storefront-blank/src/app/sitemap.ts +107 -0
- package/templates/storefront-blank/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-blank/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-blank/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-blank/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-blank/src/instrumentation.ts +109 -0
- package/templates/storefront-blank/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-blank/src/lib/account/account-state.ts +152 -0
- package/templates/storefront-blank/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-blank/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-blank/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-blank/src/lib/account-actions.ts +76 -0
- package/templates/storefront-blank/src/lib/account-session.ts +114 -0
- package/templates/storefront-blank/src/lib/action-result.ts +30 -0
- package/templates/storefront-blank/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-blank/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-blank/src/lib/backend-client.ts +47 -0
- package/templates/storefront-blank/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-blank/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-blank/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-blank/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-blank/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-blank/src/lib/error-messages.ts +118 -0
- package/templates/storefront-blank/src/lib/experiments.ts +333 -0
- package/templates/storefront-blank/src/lib/forgecart.ts +464 -0
- package/templates/storefront-blank/src/lib/format.ts +89 -0
- package/templates/storefront-blank/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-blank/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-blank/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-blank/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-blank/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-blank/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-blank/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-blank/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-blank/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-blank/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-blank/src/lib/money.ts +50 -0
- package/templates/storefront-blank/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-blank/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-blank/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-blank/src/lib/seo/noindex.ts +223 -0
- package/templates/storefront-blank/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-blank/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-blank/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-blank/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-blank/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-blank/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-blank/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-blank/src/lib/seo/sitemap-entries.ts +310 -0
- package/templates/storefront-blank/src/lib/session-actions.ts +61 -0
- package/templates/storefront-blank/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-blank/src/lib/shop-config.ts +51 -0
- package/templates/storefront-blank/src/lib/shop-session.ts +204 -0
- package/templates/storefront-blank/src/lib/track-forward.ts +200 -0
- package/templates/storefront-blank/src/lib/uuid.ts +19 -0
- package/templates/storefront-blank/src/middleware.ts +379 -0
- package/templates/storefront-blank/src/seo/redirects.ts +44 -0
- package/templates/storefront-blank/src/seo/static-routes.ts +12 -0
- package/templates/storefront-blank/src/server/app.module.ts +18 -0
- package/templates/storefront-blank/src/server/backend-api.ts +21 -0
- package/templates/storefront-blank/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-blank/src/server/bootstrap.ts +122 -0
- package/templates/storefront-blank/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
- package/templates/storefront-blank/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-blank/src/server/runner.ts +90 -0
- package/templates/storefront-blank/src/server/types.ts +36 -0
- package/templates/storefront-blank/tsconfig.json +25 -0
- package/templates/storefront-blank-sdk-floor.json +1174 -0
- package/templates/storefront-shadcn-blank/.forgecartignore +2 -0
- package/templates/storefront-shadcn-blank/Procfile +1 -0
- package/templates/storefront-shadcn-blank/README.md +93 -0
- package/templates/storefront-shadcn-blank/SEO-MIGRATION.md +718 -0
- package/templates/storefront-shadcn-blank/_gitignore +21 -0
- package/templates/storefront-shadcn-blank/components.json +20 -0
- package/templates/storefront-shadcn-blank/next.config.js +105 -0
- package/templates/storefront-shadcn-blank/package.json +42 -0
- package/templates/storefront-shadcn-blank/postcss.config.js +5 -0
- package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn-blank/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn-blank/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn-blank/src/app/globals.css +156 -0
- package/templates/storefront-shadcn-blank/src/app/layout.tsx +102 -0
- package/templates/storefront-shadcn-blank/src/app/page.tsx +53 -0
- package/templates/storefront-shadcn-blank/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn-blank/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn-blank/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn-blank/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn-blank/src/app/sitemap.ts +107 -0
- package/templates/storefront-shadcn-blank/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn-blank/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn-blank/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn-blank/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn-blank/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn-blank/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn-blank/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn-blank/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn-blank/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn-blank/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn-blank/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn-blank/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn-blank/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn-blank/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn-blank/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn-blank/src/lib/account/account-state.ts +152 -0
- package/templates/storefront-shadcn-blank/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn-blank/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn-blank/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn-blank/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn-blank/src/lib/account-session.ts +114 -0
- package/templates/storefront-shadcn-blank/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn-blank/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn-blank/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn-blank/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn-blank/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn-blank/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn-blank/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn-blank/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn-blank/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn-blank/src/lib/error-messages.ts +118 -0
- package/templates/storefront-shadcn-blank/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn-blank/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn-blank/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn-blank/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn-blank/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn-blank/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/noindex.ts +223 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-entries.ts +310 -0
- package/templates/storefront-shadcn-blank/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn-blank/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn-blank/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn-blank/src/lib/shop-session.ts +204 -0
- package/templates/storefront-shadcn-blank/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn-blank/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn-blank/src/middleware.ts +379 -0
- package/templates/storefront-shadcn-blank/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn-blank/src/seo/static-routes.ts +12 -0
- package/templates/storefront-shadcn-blank/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn-blank/src/server/backend-api.ts +21 -0
- package/templates/storefront-shadcn-blank/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn-blank/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn-blank/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
- package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn-blank/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn-blank/src/server/types.ts +36 -0
- package/templates/storefront-shadcn-blank/tsconfig.json +25 -0
- package/templates/storefront-shadcn-blank-sdk-floor.json +1174 -0
- package/templates/template-set.json +15 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { localizedPath } from '../lib/locale/localized-path';
|
|
5
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An internal link that stays inside the request's locale cluster (#1346,
|
|
9
|
+
* W1-8 / S2).
|
|
10
|
+
*
|
|
11
|
+
* Every internal navigation goes through this instead of `next/link` directly.
|
|
12
|
+
* A German page whose links all point at the English cluster leaves every
|
|
13
|
+
* German page but the entry point unreachable by following links, so a crawler
|
|
14
|
+
* never discovers them and the locale is effectively unindexed.
|
|
15
|
+
*
|
|
16
|
+
* The locale arrives as a PROP and is never derived here. Under the middleware
|
|
17
|
+
* rewrite the browser's URL and the rendered route differ — `/de/products`
|
|
18
|
+
* renders the `/products` route — so `usePathname()` returns the REWRITTEN
|
|
19
|
+
* path, and a component that read the locale from it would emit English links
|
|
20
|
+
* on every German page while looking correct in review. Server components take
|
|
21
|
+
* the binding from `getRequestLocale()`; client components receive it from
|
|
22
|
+
* whichever server component renders them.
|
|
23
|
+
*
|
|
24
|
+
* `href` is the UNPREFIXED route path, always — the same string you would have
|
|
25
|
+
* written before the locale layer existed. Prefixing is this component's job.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// `locale` is omitted because `next/link` HAS a prop by that name — the Pages
|
|
29
|
+
// Router i18n feature, typed `string | false`. This storefront does its own
|
|
30
|
+
// locale routing (App Router, middleware rewrite), so that prop is never the
|
|
31
|
+
// one we want, and forwarding it would collide with the binding below: the
|
|
32
|
+
// production build fails with TS2430, which `next dev` never reports.
|
|
33
|
+
type ForwardedLinkProps = Omit<ComponentProps<typeof Link>, 'href' | 'children' | 'locale'>;
|
|
34
|
+
|
|
35
|
+
export interface LocaleLinkProps extends ForwardedLinkProps {
|
|
36
|
+
/** The unprefixed route path, e.g. `/products`. */
|
|
37
|
+
href: string;
|
|
38
|
+
/** The request's locale binding, from `getRequestLocale()`. */
|
|
39
|
+
locale: LocaleBinding;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function LocaleLink({ href, locale, children, ...rest }: LocaleLinkProps): ReactNode {
|
|
44
|
+
return (
|
|
45
|
+
<Link href={localizedPath(href, locale)} {...rest}>
|
|
46
|
+
{children}
|
|
47
|
+
</Link>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from './utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shadcn/ui alert, with the status variants this variant declares.
|
|
8
|
+
*
|
|
9
|
+
* shadcn/ui ships `default` and `destructive`; the storefront also speaks
|
|
10
|
+
* success, warning and info, so those three are variants here, each filled
|
|
11
|
+
* with its own `--success` / `--warning` / `--info` token and the
|
|
12
|
+
* `-foreground` that stays legible on it (see the mapping table in
|
|
13
|
+
* `src/app/globals.css`). An alert is `role="alert"` unless the caller says
|
|
14
|
+
* otherwise — a polite outcome passes `role="status"`.
|
|
15
|
+
*/
|
|
16
|
+
const alertVariants = cva(
|
|
17
|
+
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
variant: {
|
|
21
|
+
default: 'bg-card text-card-foreground',
|
|
22
|
+
destructive:
|
|
23
|
+
'bg-card text-destructive [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
|
24
|
+
success:
|
|
25
|
+
'border-success bg-success text-success-foreground *:data-[slot=alert-description]:text-success-foreground/90',
|
|
26
|
+
warning:
|
|
27
|
+
'border-warning bg-warning text-warning-foreground *:data-[slot=alert-description]:text-warning-foreground/90',
|
|
28
|
+
info: 'border-info bg-info text-info-foreground *:data-[slot=alert-description]:text-info-foreground/90',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
variant: 'default',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function Alert({
|
|
38
|
+
className,
|
|
39
|
+
variant,
|
|
40
|
+
...props
|
|
41
|
+
}: ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
data-slot="alert"
|
|
45
|
+
role="alert"
|
|
46
|
+
className={cn(alertVariants({ variant }), className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function AlertTitle({ className, ...props }: ComponentProps<'div'>) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="alert-title"
|
|
56
|
+
className={cn('col-start-2 min-h-4 font-medium tracking-tight', className)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function AlertDescription({ className, ...props }: ComponentProps<'div'>) {
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
data-slot="alert-description"
|
|
66
|
+
className={cn(
|
|
67
|
+
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { Alert, AlertDescription, AlertTitle };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from './utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The shadcn/ui badge — the cart count, a subscription line's label, an
|
|
9
|
+
* applied coupon. As with the button, the variant shadcn/ui paints with a raw
|
|
10
|
+
* `text-white` is not vendored: every colour here is a token.
|
|
11
|
+
*/
|
|
12
|
+
const badgeVariants = cva(
|
|
13
|
+
'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3',
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
|
18
|
+
secondary:
|
|
19
|
+
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
|
20
|
+
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
variant: 'default',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function Badge({
|
|
30
|
+
className,
|
|
31
|
+
variant,
|
|
32
|
+
asChild = false,
|
|
33
|
+
...props
|
|
34
|
+
}: ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
35
|
+
const Comp = asChild ? Slot : 'span';
|
|
36
|
+
|
|
37
|
+
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { Badge, badgeVariants };
|