@forgecart/cli 2.202610280742.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,143 @@
|
|
|
1
|
+
import { isLocaleSegment } from '../locale/grammar';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a storefront page URL resolves to (#1934, epic launch#50) — the WHOLE
|
|
5
|
+
* decision behind `/pages/<route>`, as a value.
|
|
6
|
+
*
|
|
7
|
+
* PURE BY CONTRACT, for the reason `lib/seo/resolve-path.ts` is: this is the
|
|
8
|
+
* law that decides between a rendered page and a real 404, and
|
|
9
|
+
* `tool/storefront-template-spec` imports it directly by relative path from
|
|
10
|
+
* OUTSIDE the scaffold, so nothing reachable from here may need the template's
|
|
11
|
+
* Next toolchain. The route file is left as dispatch — it turns `notFound` into
|
|
12
|
+
* Next's `notFound()` and `ok` into markup, and decides nothing itself.
|
|
13
|
+
*
|
|
14
|
+
* The inputs are declared STRUCTURALLY rather than imported from the SDK for
|
|
15
|
+
* the same reason (`ResolvableProduct` precedent): the generated query types
|
|
16
|
+
* satisfy these shapes structurally, so the scaffold's own `tsc` proves the
|
|
17
|
+
* assignment while this module stays importable with no `paths`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The subset of a page definition this decision reads, declared AS THE SHOP
|
|
22
|
+
* API'S GENERATED TYPES CARRY IT.
|
|
23
|
+
*
|
|
24
|
+
* Every nullable selection reaches a scaffold OPTIONAL as well as nullable
|
|
25
|
+
* (`route?: Maybe<string>`), so a contract spelled `string | null` would refuse
|
|
26
|
+
* the very value the query produces — and, this module having no compiler of
|
|
27
|
+
* its own in this repository, the refusal would surface in the merchant's
|
|
28
|
+
* `next build` instead of here. Admitting the shape is therefore part of the
|
|
29
|
+
* contract, not a laxity: what each absence MEANS is decided in the table
|
|
30
|
+
* below, once, rather than guessed at by each caller.
|
|
31
|
+
*/
|
|
32
|
+
export interface RoutedPageGroup {
|
|
33
|
+
/**
|
|
34
|
+
* `'page'` for a storefront page; anything else — a data definition, or a
|
|
35
|
+
* selection from a pod whose SDK does not carry the field — is not one.
|
|
36
|
+
*/
|
|
37
|
+
readonly kind?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* The route the definition is mounted at. Absent means mounted NOWHERE: the
|
|
40
|
+
* column is nullable because most definitions are data rather than pages.
|
|
41
|
+
*/
|
|
42
|
+
readonly route?: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why a page URL is a 404. Four distinct causes, kept apart because they answer
|
|
47
|
+
* different questions about a deployment — "the merchant has not published
|
|
48
|
+
* this yet" and "this URL was never a page" look identical to a visitor and are
|
|
49
|
+
* nothing alike to whoever is debugging.
|
|
50
|
+
*/
|
|
51
|
+
export type PageNotFoundReason =
|
|
52
|
+
/**
|
|
53
|
+
* The slug is a two-letter locale segment. No page may hold one (launch#54
|
|
54
|
+
* W1-7 — the server refuses to mint such a route rather than auto-suffixing
|
|
55
|
+
* it), so this URL can never carry content in any deployment. Judged first
|
|
56
|
+
* and without consulting the lookup, so the rule is stated where it is true
|
|
57
|
+
* rather than inferred from an empty result.
|
|
58
|
+
*/
|
|
59
|
+
| 'reserved-locale-segment'
|
|
60
|
+
/**
|
|
61
|
+
* No page definition of this channel is mounted at this route: the lookup
|
|
62
|
+
* answered nothing, or what it answered carries no route of its own and is
|
|
63
|
+
* therefore mounted nowhere.
|
|
64
|
+
*/
|
|
65
|
+
| 'unknown-route'
|
|
66
|
+
/** Something answered, but it is not a page definition. */
|
|
67
|
+
| 'not-a-page'
|
|
68
|
+
/**
|
|
69
|
+
* The page exists and has nothing a visitor may see: never given a record, or
|
|
70
|
+
* holding only records the publish fence hides — a DRAFT, or one SCHEDULED
|
|
71
|
+
* for a moment that has not arrived. The shop API already removed those rows
|
|
72
|
+
* (it fences every storefront read to published content), so this arm is the
|
|
73
|
+
* renderer HONOURING an empty result, never a second publish check.
|
|
74
|
+
*/
|
|
75
|
+
| 'no-live-content';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The answer for one page URL.
|
|
79
|
+
*
|
|
80
|
+
* The `ok` arm carries BOTH halves of what the route renders, narrowed to the
|
|
81
|
+
* caller's own types. That is why it is generic rather than a bare verdict: a
|
|
82
|
+
* caller that had to re-check `group !== null` after a resolution that already
|
|
83
|
+
* proved it would be asserting what the law just decided.
|
|
84
|
+
*/
|
|
85
|
+
export type PageResolution<TGroup, TEntry> =
|
|
86
|
+
| { kind: 'ok'; group: TGroup; entry: TEntry }
|
|
87
|
+
| { kind: 'notFound'; reason: PageNotFoundReason };
|
|
88
|
+
|
|
89
|
+
export interface ResolvePageInput<TGroup, TEntry> {
|
|
90
|
+
/** The slug exactly as it appeared in the URL, locale prefix already stripped. */
|
|
91
|
+
requestedRoute: string;
|
|
92
|
+
/** What `pageByRoute` answered, or null when it answered nothing. */
|
|
93
|
+
group: TGroup | null;
|
|
94
|
+
/**
|
|
95
|
+
* The page's visible records, in the order the CALLER asked for. A page is
|
|
96
|
+
* one URL, so it serves one record: the first of these. Which record that is
|
|
97
|
+
* is the caller's decision and belongs at the query (see `getPageEntries`),
|
|
98
|
+
* not here.
|
|
99
|
+
*/
|
|
100
|
+
entries: readonly TEntry[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Decide the honest answer for a page URL.
|
|
105
|
+
*
|
|
106
|
+
* TOTAL over its input and ordered so each arm may assume the ones above it did
|
|
107
|
+
* not fire. Two of the four refusals are the feature's own contract — an
|
|
108
|
+
* unpublished or not-yet-live page answers 404 — and the other two are what
|
|
109
|
+
* make the function total.
|
|
110
|
+
*/
|
|
111
|
+
export function resolvePage<TGroup extends RoutedPageGroup, TEntry>({
|
|
112
|
+
requestedRoute,
|
|
113
|
+
group,
|
|
114
|
+
entries,
|
|
115
|
+
}: ResolvePageInput<TGroup, TEntry>): PageResolution<TGroup, TEntry> {
|
|
116
|
+
if (isLocaleSegment(requestedRoute)) {
|
|
117
|
+
return { kind: 'notFound', reason: 'reserved-locale-segment' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (group === null) return { kind: 'notFound', reason: 'unknown-route' };
|
|
121
|
+
|
|
122
|
+
// A boundary check, not a redundant one: the shop query returns the general
|
|
123
|
+
// field-definition type and nothing in that TYPE says "page", so this is the
|
|
124
|
+
// template asserting that what it is about to render as a page is one — the
|
|
125
|
+
// same posture `parseScaffoldedRoute` takes toward a value another process
|
|
126
|
+
// wrote.
|
|
127
|
+
if (group.kind !== 'page') return { kind: 'notFound', reason: 'not-a-page' };
|
|
128
|
+
|
|
129
|
+
// Mounted-ness, read back off the answer itself, in the same posture as the
|
|
130
|
+
// kind check above: `pageByRoute` matched this group BY its route, so one
|
|
131
|
+
// that carries none contradicts the question it answered. Stating that as a
|
|
132
|
+
// 404 is what keeps the route from rendering a page no URL owns — and it is
|
|
133
|
+
// why the absence is resolved HERE rather than by a caller that would have
|
|
134
|
+
// to invent a meaning for it.
|
|
135
|
+
if (group.route === null || group.route === undefined) {
|
|
136
|
+
return { kind: 'notFound', reason: 'unknown-route' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry = entries[0];
|
|
140
|
+
if (entry === undefined) return { kind: 'notFound', reason: 'no-live-content' };
|
|
141
|
+
|
|
142
|
+
return { kind: 'ok', group, entry };
|
|
143
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shopper-facing copy for the typed error codes a storefront call can meet.
|
|
5
|
+
*
|
|
6
|
+
* The shop API's error contract ships CODES, never prose — `message` on an
|
|
7
|
+
* extracted error is the code string itself, and resolving human copy is the
|
|
8
|
+
* frontend's job (error-handling contract). A raw enum leaking into the UI is
|
|
9
|
+
* a defect (the address step rendered SHIPPING_ADDRESS_PHONE_REQUIRED
|
|
10
|
+
* verbatim, 2026-08-09).
|
|
11
|
+
*
|
|
12
|
+
* Two families live here. The checkout's address codes, and every refusal a
|
|
13
|
+
* customer ACCOUNT call can meet (#1093): signing in and out, registering, a
|
|
14
|
+
* verification or reset link, a password or e-mail change. The account family
|
|
15
|
+
* is swept against the shop's own `ErrorCode` enum by
|
|
16
|
+
* `tool/storefront-template-spec` — every member of its `Auth - Self-Service`
|
|
17
|
+
* and `Auth - Service` blocks, plus the sign-in refusal, the anonymous
|
|
18
|
+
* socket's refusal and the taken address — so a refusal added to the platform
|
|
19
|
+
* reddens that sweep until it has copy here. Before #1093 this map held the
|
|
20
|
+
* three address codes alone, and every refused sign-in rendered as the
|
|
21
|
+
* generic line: indistinguishable from the platform faults it was masking.
|
|
22
|
+
*
|
|
23
|
+
* `SHOP_API_UNREACHABLE` is not a platform code but this template's own
|
|
24
|
+
* (`lib/action-result.ts`): the answer when the API could not be asked at all,
|
|
25
|
+
* which is the one failure the generic line's "check your details" is wrong
|
|
26
|
+
* about.
|
|
27
|
+
*
|
|
28
|
+
* A Map rather than an object literal: a lookup keyed by an API-supplied
|
|
29
|
+
* string must not be able to land on `Object.prototype` (`constructor`,
|
|
30
|
+
* `toString`) and render a function as copy.
|
|
31
|
+
*/
|
|
32
|
+
const SHOPPER_MESSAGES: ReadonlyMap<string, string> = new Map([
|
|
33
|
+
['SHIPPING_ADDRESS_PHONE_REQUIRED', 'A phone number is required for delivery updates.'],
|
|
34
|
+
['SHIPPING_ADDRESS_COUNTRY_REQUIRED', 'Please select a country.'],
|
|
35
|
+
['SHIPPING_ADDRESS_COUNTRY_INVALID', 'We can’t ship to the selected country yet.'],
|
|
36
|
+
[
|
|
37
|
+
'INVALID_CREDENTIALS',
|
|
38
|
+
'That e-mail address and password do not match. Check both and try again.',
|
|
39
|
+
],
|
|
40
|
+
['UNAUTHORIZED', 'You are not signed in, or your session has ended. Please sign in again.'],
|
|
41
|
+
[
|
|
42
|
+
'EMAIL_ADDRESS_CONFLICT',
|
|
43
|
+
'That e-mail address already has an account. Sign in with it, or reset its password.',
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
'IDENTIFIER_ALREADY_EXISTS',
|
|
47
|
+
'That e-mail address already has an account. Sign in with it, or reset its password.',
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
'NOT_VERIFIED',
|
|
51
|
+
'This account is not confirmed yet. Open the link in the confirmation e-mail we sent you, then sign in.',
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'VERIFICATION_TOKEN_INVALID',
|
|
55
|
+
'This confirmation link is not valid — it may have been used already. Ask for a new confirmation e-mail.',
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
'VERIFICATION_TOKEN_EXPIRED',
|
|
59
|
+
'This confirmation link has expired. Ask for a new confirmation e-mail and open the newest link.',
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'PASSWORD_RESET_TOKEN_INVALID',
|
|
63
|
+
'This password reset link is not valid — it may have been used already. Ask for a new one.',
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
'PASSWORD_RESET_TOKEN_EXPIRED',
|
|
67
|
+
'This password reset link has expired. Ask for a new one and open the newest link.',
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
'PASSWORD_VALIDATION_FAILED',
|
|
71
|
+
'That password does not meet the store’s requirements. Please choose a different one.',
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'PASSWORD_CHANGE_FAILED',
|
|
75
|
+
'Your password could not be changed. Check your current password and try again.',
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
'IDENTIFIER_CHANGE_TOKEN_INVALID',
|
|
79
|
+
'This e-mail change link is not valid — it may have been used already. Ask for the change again.',
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'IDENTIFIER_CHANGE_TOKEN_EXPIRED',
|
|
83
|
+
'This e-mail change link has expired. Ask for the change again and open the newest link.',
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
'CUSTOMER_NOT_FOUND',
|
|
87
|
+
'That account does not shop at this store. Sign in with the address you registered here.',
|
|
88
|
+
],
|
|
89
|
+
['SESSION_NOT_FOUND', 'Your session has ended. Please sign in again.'],
|
|
90
|
+
[
|
|
91
|
+
'SESSION_CACHE_READ_FAILED',
|
|
92
|
+
'We could not check your session just now. Please try again in a moment.',
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'SESSION_CACHE_WRITE_FAILED',
|
|
96
|
+
'We could not save your session just now. Please try again in a moment.',
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'SHOP_API_UNREACHABLE',
|
|
100
|
+
'We could not reach the store just now. Please check your connection and try again.',
|
|
101
|
+
],
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const FALLBACK_MESSAGE = 'Something went wrong. Please check your details and try again.';
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Human copy for a typed error — never undefined.
|
|
108
|
+
*
|
|
109
|
+
* A code this map has no copy for renders the generic line WITH the code
|
|
110
|
+
* appended in parentheses: an unmodelled refusal must stay distinguishable
|
|
111
|
+
* from a platform fault, by the shopper who reports it and by the merchant —
|
|
112
|
+
* or their agent — who reads the report. Only an error carrying no code at all
|
|
113
|
+
* renders the generic line bare.
|
|
114
|
+
*/
|
|
115
|
+
export function shopperMessage(error: Pick<ExtractedError, 'code'> | null | undefined): string {
|
|
116
|
+
if (!error?.code) return FALLBACK_MESSAGE;
|
|
117
|
+
return SHOPPER_MESSAGES.get(error.code) ?? `${FALLBACK_MESSAGE} (${error.code})`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveExposureEventId,
|
|
3
|
+
resolveExperimentAssignment,
|
|
4
|
+
type AssignmentResult,
|
|
5
|
+
type AssignmentSource,
|
|
6
|
+
type ExperimentAssignmentConfig,
|
|
7
|
+
} from '@forgecart/sdk';
|
|
8
|
+
import { cookies, headers } from 'next/headers';
|
|
9
|
+
import { after } from 'next/server';
|
|
10
|
+
import { cache } from 'react';
|
|
11
|
+
import { uuidv7 } from 'uuidv7';
|
|
12
|
+
|
|
13
|
+
import { SESSION_COOKIE } from './session-cookies';
|
|
14
|
+
import { forwardTrackEvent, getUpstreamConfig, isObviousBot } from './track-forward';
|
|
15
|
+
import type { TrackEventInput, UpstreamConfig } from './track-forward';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Server-side A/B variant resolution (design doc Wave F-B).
|
|
19
|
+
*
|
|
20
|
+
* `getVariant(experimentKey)` is the ONE storefront API for experiments:
|
|
21
|
+
* Server Components call it and branch on the returned `variantKey` (variant
|
|
22
|
+
* payloads are code branches first, `payload` JSON second). Resolution is
|
|
23
|
+
* LOCAL — the SDK's deterministic assignment engine over a TTL-cached config
|
|
24
|
+
* — so variants render with zero flicker and zero added request round-trips.
|
|
25
|
+
*
|
|
26
|
+
* How the pieces fit:
|
|
27
|
+
* - Config: the public `activeExperiments` shop query, fetched over raw
|
|
28
|
+
* HTTP GraphQL with the hand-rolled `forgecart-token` header (the
|
|
29
|
+
* `track-forward.ts` pattern — the template's pinned published SDK has no
|
|
30
|
+
* plain-HTTP query path) and cached module-scope for
|
|
31
|
+
* {@link CONFIG_TTL_MS} keyed by channel token. A fetch failure resolves
|
|
32
|
+
* to "no experiments" (every caller renders the default branch) and is
|
|
33
|
+
* cached for the same TTL, so a dead backend costs one failed fetch per
|
|
34
|
+
* minute — never one per request.
|
|
35
|
+
* - Subject: the `forgecart-visitor` cookie (S6). When absent, an id is
|
|
36
|
+
* minted IN-REQUEST via a request-scoped `cache()` — never relying on
|
|
37
|
+
* middleware→RSC cookie propagation (S6 doc rule; the middleware persists
|
|
38
|
+
* the durable cookie, this module only needs a consistent value for the
|
|
39
|
+
* current render). Same UUIDv7 value shape as the middleware mint.
|
|
40
|
+
* - Forced entry: the `forgecart-exp-force` cookie the middleware merges
|
|
41
|
+
* from `?fc-exp=` campaign URLs. The engine honors it only when the
|
|
42
|
+
* experiment allows forced entry and the variant exists
|
|
43
|
+
* (`assignmentSource: 'forced'`).
|
|
44
|
+
* - Exposure: when the engine says `exposed` (hashed or forced), ONE
|
|
45
|
+
* `experiment_exposure` marketing event is emitted per request inside
|
|
46
|
+
* `next/server` `after()` — after the response, off the critical path —
|
|
47
|
+
* through the shared `forwardTrackEvent` (never an HTTP self-call to the
|
|
48
|
+
* relay, never the SDK's identity-pinning WebSocket). The event id is the
|
|
49
|
+
* SDK's deterministic UUIDv5 over `salt:subjectKey:utcDay`, so re-renders
|
|
50
|
+
* and multi-page sessions dedupe server-side to one exposure per subject
|
|
51
|
+
* per UTC day.
|
|
52
|
+
*
|
|
53
|
+
* `currencyCode` is deliberately ABSENT from the exposure input: §A wants the
|
|
54
|
+
* session market's currency on exposures, but pre-cart the template server
|
|
55
|
+
* has no side-effect-free source for it — the shop API surfaces currency only
|
|
56
|
+
* via `activeOrder` (whose read AUTO-CREATES an order for a session, see
|
|
57
|
+
* `cart-actions.ts#getCart`) and the geo-seeded market lives in a session
|
|
58
|
+
* that a first-touch page view does not yet have. Inventing a source here
|
|
59
|
+
* (e.g. the channel default off a product read) would mislabel the segment;
|
|
60
|
+
* the field stays unset until the ingest side can stamp the session market
|
|
61
|
+
* server-side.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
const VISITOR_COOKIE = 'forgecart-visitor';
|
|
65
|
+
const FORCED_ENTRY_COOKIE = 'forgecart-exp-force';
|
|
66
|
+
|
|
67
|
+
/** Delivery-config cache TTL — the design doc's 60s config-plane freshness. */
|
|
68
|
+
const CONFIG_TTL_MS = 60_000;
|
|
69
|
+
|
|
70
|
+
/** Canonical textual UUID — the only visitor-cookie shape trusted as subject. */
|
|
71
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
72
|
+
|
|
73
|
+
/** Backend slug grammar (`ExperimentService`) — gates keys read from cookies. */
|
|
74
|
+
const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The SDK's own `ShopActiveExperiments` document, inlined — same rationale as
|
|
78
|
+
* `track-forward.ts`: the generated client is WebSocket-only, so the config
|
|
79
|
+
* read speaks plain HTTP GraphQL with the identical selection.
|
|
80
|
+
*/
|
|
81
|
+
const ACTIVE_EXPERIMENTS_QUERY = `query ShopActiveExperiments {
|
|
82
|
+
activeExperiments {
|
|
83
|
+
experimentKey
|
|
84
|
+
subjectType
|
|
85
|
+
version
|
|
86
|
+
salt
|
|
87
|
+
lifecycle
|
|
88
|
+
trafficBps
|
|
89
|
+
allowForcedEntry
|
|
90
|
+
variants {
|
|
91
|
+
key
|
|
92
|
+
weight
|
|
93
|
+
payload
|
|
94
|
+
}
|
|
95
|
+
winnerVariantKey
|
|
96
|
+
}
|
|
97
|
+
}`;
|
|
98
|
+
|
|
99
|
+
/** One variant as the `activeExperiments` shop query delivers it. */
|
|
100
|
+
interface ActiveExperimentVariant {
|
|
101
|
+
key: string;
|
|
102
|
+
weight: number;
|
|
103
|
+
payload: Record<string, unknown> | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The public delivery projection of one experiment (FB2b contract). */
|
|
107
|
+
interface ActiveExperiment {
|
|
108
|
+
experimentKey: string;
|
|
109
|
+
subjectType: 'visitor' | 'customer';
|
|
110
|
+
version: number;
|
|
111
|
+
salt: string;
|
|
112
|
+
lifecycle: 'running' | 'paused' | 'completed';
|
|
113
|
+
trafficBps: number;
|
|
114
|
+
allowForcedEntry: boolean;
|
|
115
|
+
variants: ActiveExperimentVariant[];
|
|
116
|
+
winnerVariantKey: string | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a Server Component branches on. */
|
|
120
|
+
export interface ExperimentVariantResult {
|
|
121
|
+
/**
|
|
122
|
+
* The assigned variant key, or `null` when the experiment is unknown /
|
|
123
|
+
* undelivered / malformed — callers treat `null` as "render the default
|
|
124
|
+
* branch", which keeps every page correct with zero experiments configured.
|
|
125
|
+
*/
|
|
126
|
+
variantKey: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* How the variant was decided: `'hashed'` (randomized cohort), `'forced'`
|
|
129
|
+
* (campaign entry — excluded from the randomized comparison), `'lifecycle'`
|
|
130
|
+
* (paused → control / completed → winner), or `null` (non-participant
|
|
131
|
+
* control or no experiment).
|
|
132
|
+
*/
|
|
133
|
+
assignmentSource: AssignmentSource | null;
|
|
134
|
+
/** The variant's optional delivery payload (design doc Q5), if any. */
|
|
135
|
+
payload: Record<string, unknown> | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface GraphQLActiveExperimentsResponse {
|
|
139
|
+
data?: { activeExperiments?: unknown } | null;
|
|
140
|
+
errors?: unknown[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Module-scope 60s TTL cache — one config fetch per server per minute. */
|
|
144
|
+
let configCache: {
|
|
145
|
+
channelToken: string;
|
|
146
|
+
expiresAt: number;
|
|
147
|
+
load: Promise<ActiveExperiment[]>;
|
|
148
|
+
} | null = null;
|
|
149
|
+
|
|
150
|
+
async function fetchActiveExperiments(upstream: UpstreamConfig): Promise<ActiveExperiment[]> {
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(upstream.shopApiUrl, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'content-type': 'application/json',
|
|
156
|
+
'forgecart-token': upstream.channelToken,
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({ query: ACTIVE_EXPERIMENTS_QUERY }),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) return [];
|
|
161
|
+
const payload = (await response.json()) as GraphQLActiveExperimentsResponse;
|
|
162
|
+
const list = payload.data?.activeExperiments;
|
|
163
|
+
return Array.isArray(list) ? (list as ActiveExperiment[]) : [];
|
|
164
|
+
} catch {
|
|
165
|
+
// Experiments must never break the storefront: no config → every caller
|
|
166
|
+
// renders its default branch until the next TTL window retries.
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getActiveExperiments(): Promise<ActiveExperiment[]> {
|
|
172
|
+
const upstream = getUpstreamConfig();
|
|
173
|
+
// Inert before `forgecart init` writes `.env` (image-prewarm contract).
|
|
174
|
+
if (!upstream) return Promise.resolve([]);
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (
|
|
177
|
+
configCache &&
|
|
178
|
+
configCache.channelToken === upstream.channelToken &&
|
|
179
|
+
now < configCache.expiresAt
|
|
180
|
+
) {
|
|
181
|
+
return configCache.load;
|
|
182
|
+
}
|
|
183
|
+
configCache = {
|
|
184
|
+
channelToken: upstream.channelToken,
|
|
185
|
+
expiresAt: now + CONFIG_TTL_MS,
|
|
186
|
+
load: fetchActiveExperiments(upstream),
|
|
187
|
+
};
|
|
188
|
+
return configCache.load;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The assignment subject for this request: the S6 visitor cookie when it
|
|
193
|
+
* rode in, else ONE in-request UUIDv7 shared by every `getVariant` call of
|
|
194
|
+
* the render (request-scoped `cache()`). A cookie-less first page view thus
|
|
195
|
+
* gets a consistent assignment within its own render; the middleware's
|
|
196
|
+
* Set-Cookie makes the durable id authoritative from the next request on.
|
|
197
|
+
*/
|
|
198
|
+
const getRequestVisitorId = cache(async (): Promise<string> => {
|
|
199
|
+
const store = await cookies();
|
|
200
|
+
const existing = store.get(VISITOR_COOKIE)?.value;
|
|
201
|
+
if (existing && UUID_PATTERN.test(existing)) return existing;
|
|
202
|
+
return uuidv7();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** The forced-entry map from the middleware-merged cookie, slug-validated. */
|
|
206
|
+
const getForcedVariantMap = cache(async (): Promise<Record<string, string>> => {
|
|
207
|
+
const store = await cookies();
|
|
208
|
+
const raw = store.get(FORCED_ENTRY_COOKIE)?.value;
|
|
209
|
+
if (!raw) return {};
|
|
210
|
+
let parsed: unknown;
|
|
211
|
+
try {
|
|
212
|
+
// `cookies()` already percent-decodes — `raw` is the JSON text.
|
|
213
|
+
parsed = JSON.parse(raw);
|
|
214
|
+
} catch {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
|
|
218
|
+
const map: Record<string, string> = {};
|
|
219
|
+
for (const [experimentKey, variantKey] of Object.entries(parsed)) {
|
|
220
|
+
if (typeof variantKey !== 'string') continue;
|
|
221
|
+
if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
|
|
222
|
+
map[experimentKey] = variantKey;
|
|
223
|
+
}
|
|
224
|
+
return map;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
function toAssignmentConfig(experiment: ActiveExperiment): ExperimentAssignmentConfig {
|
|
228
|
+
return {
|
|
229
|
+
salt: experiment.salt,
|
|
230
|
+
lifecycle: experiment.lifecycle,
|
|
231
|
+
trafficBps: experiment.trafficBps,
|
|
232
|
+
allowForcedEntry: experiment.allowForcedEntry,
|
|
233
|
+
variants: experiment.variants.map(({ key, weight }) => ({ key, weight })),
|
|
234
|
+
winnerVariantKey: experiment.winnerVariantKey,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Emit the `experiment_exposure` marketing event inside `after()`. Request
|
|
240
|
+
* data (UA, client IP, session) is captured HERE, during render — `after()`
|
|
241
|
+
* callbacks run once the response is done and must not touch request APIs.
|
|
242
|
+
* The response is already sent when a session-less exposure reaches the shop
|
|
243
|
+
* API, so a token minted for it cannot be persisted — accepted: the relay's
|
|
244
|
+
* first client batch establishes the durable shopper session.
|
|
245
|
+
*/
|
|
246
|
+
async function scheduleExposure(
|
|
247
|
+
experiment: ActiveExperiment,
|
|
248
|
+
result: AssignmentResult,
|
|
249
|
+
visitorId: string,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const requestHeaders = await headers();
|
|
252
|
+
const userAgent = requestHeaders.get('user-agent') ?? '';
|
|
253
|
+
// Same bot policy as the relay: unambiguous crawlers emit nothing.
|
|
254
|
+
if (isObviousBot(userAgent)) return;
|
|
255
|
+
const forwardedFor = requestHeaders.get('x-forwarded-for');
|
|
256
|
+
const store = await cookies();
|
|
257
|
+
const sessionToken = store.get(SESSION_COOKIE)?.value ?? null;
|
|
258
|
+
|
|
259
|
+
const occurredAt = new Date();
|
|
260
|
+
const input: TrackEventInput = {
|
|
261
|
+
eventType: 'experiment_exposure',
|
|
262
|
+
// Deterministic UUIDv5 over salt:subjectKey:utcDay — every re-render of
|
|
263
|
+
// this subject today resubmits the SAME id and the backend dedupes.
|
|
264
|
+
eventId: deriveExposureEventId(experiment.salt, visitorId, occurredAt),
|
|
265
|
+
occurredAt: occurredAt.toISOString(),
|
|
266
|
+
properties: {
|
|
267
|
+
experimentKey: experiment.experimentKey,
|
|
268
|
+
variantKey: result.variantKey,
|
|
269
|
+
version: experiment.version,
|
|
270
|
+
subjectType: experiment.subjectType,
|
|
271
|
+
assignmentSource: result.assignmentSource,
|
|
272
|
+
visitorId,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
after(async () => {
|
|
277
|
+
await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resolve the variant for one experiment. Request-scoped `cache()` keeps the
|
|
283
|
+
* whole render consistent — layout and page asking for the same key get one
|
|
284
|
+
* resolution and at most ONE exposure emission per request.
|
|
285
|
+
*/
|
|
286
|
+
export const getVariant = cache(async (experimentKey: string): Promise<ExperimentVariantResult> => {
|
|
287
|
+
const inert: ExperimentVariantResult = {
|
|
288
|
+
variantKey: null,
|
|
289
|
+
assignmentSource: null,
|
|
290
|
+
payload: null,
|
|
291
|
+
};
|
|
292
|
+
if (!SLUG_REGEX.test(experimentKey)) return inert;
|
|
293
|
+
|
|
294
|
+
const experiments = await getActiveExperiments();
|
|
295
|
+
const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
|
|
296
|
+
if (!experiment) return inert;
|
|
297
|
+
|
|
298
|
+
if (experiment.subjectType === 'customer') {
|
|
299
|
+
// The storefront server has no verified customer id (auth lives behind
|
|
300
|
+
// the shop API session) — a customer-scoped experiment renders as
|
|
301
|
+
// non-participant control here, with no exposure. Customer-subject
|
|
302
|
+
// resolution belongs to clients that KNOW the customer id.
|
|
303
|
+
const control = experiment.variants[0];
|
|
304
|
+
if (!control) return inert;
|
|
305
|
+
return { variantKey: control.key, assignmentSource: null, payload: control.payload ?? null };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const visitorId = await getRequestVisitorId();
|
|
309
|
+
const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
|
|
310
|
+
|
|
311
|
+
let result: AssignmentResult;
|
|
312
|
+
try {
|
|
313
|
+
result = resolveExperimentAssignment(toAssignmentConfig(experiment), {
|
|
314
|
+
subjectKey: visitorId,
|
|
315
|
+
forcedVariantKey,
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
// A malformed delivery (out-of-bounds weight, empty variants) must
|
|
319
|
+
// never break rendering — the caller falls back to its default branch.
|
|
320
|
+
return inert;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (result.exposed && result.variantKey !== null) {
|
|
324
|
+
await scheduleExposure(experiment, result, visitorId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const variant = experiment.variants.find((entry) => entry.key === result.variantKey);
|
|
328
|
+
return {
|
|
329
|
+
variantKey: result.variantKey,
|
|
330
|
+
assignmentSource: result.assignmentSource,
|
|
331
|
+
payload: variant?.payload ?? null,
|
|
332
|
+
};
|
|
333
|
+
});
|