@forgecart/cli 2.202610052310.0 → 2.202610071755.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,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
+ });