@forgecart/cli 2.202608121449.0 → 2.202608200713.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 (63) hide show
  1. package/dist/src/commands/init.d.ts +16 -0
  2. package/dist/src/commands/init.js +18 -7
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +145 -61
  6. package/templates/storefront/next.config.js +20 -0
  7. package/templates/storefront/package.json +10 -2
  8. package/templates/storefront/postcss.config.js +1 -2
  9. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
  10. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
  11. package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  12. package/templates/storefront/src/app/cart/page.tsx +33 -6
  13. package/templates/storefront/src/app/checkout/page.tsx +40 -0
  14. package/templates/storefront/src/app/error.tsx +21 -0
  15. package/templates/storefront/src/app/global-error.tsx +23 -0
  16. package/templates/storefront/src/app/globals.css +105 -8
  17. package/templates/storefront/src/app/layout.tsx +45 -17
  18. package/templates/storefront/src/app/page.tsx +153 -43
  19. package/templates/storefront/src/app/ping/route.ts +1 -2
  20. package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
  21. package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
  22. package/templates/storefront/src/app/products/page.tsx +52 -9
  23. package/templates/storefront/src/components/CartView.tsx +244 -117
  24. package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
  25. package/templates/storefront/src/components/Header.tsx +8 -10
  26. package/templates/storefront/src/components/ProductCard.tsx +25 -13
  27. package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
  28. package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
  29. package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
  30. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
  31. package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
  32. package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  33. package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
  34. package/templates/storefront/src/instrumentation.ts +34 -0
  35. package/templates/storefront/src/lib/action-result.ts +30 -0
  36. package/templates/storefront/src/lib/backend-actions.ts +20 -0
  37. package/templates/storefront/src/lib/backend-client.ts +47 -0
  38. package/templates/storefront/src/lib/cart-context.tsx +157 -22
  39. package/templates/storefront/src/lib/checkout-session.ts +185 -0
  40. package/templates/storefront/src/lib/error-messages.ts +24 -0
  41. package/templates/storefront/src/lib/experiments.ts +42 -44
  42. package/templates/storefront/src/lib/forgecart.ts +61 -78
  43. package/templates/storefront/src/lib/format.ts +91 -0
  44. package/templates/storefront/src/lib/session-actions.ts +54 -0
  45. package/templates/storefront/src/lib/shop-config.ts +44 -0
  46. package/templates/storefront/src/lib/shop-session.ts +114 -0
  47. package/templates/storefront/src/lib/track-forward.ts +14 -1
  48. package/templates/storefront/src/lib/uuid.ts +19 -0
  49. package/templates/storefront/src/server/app.module.ts +18 -0
  50. package/templates/storefront/src/server/backend-api.ts +26 -0
  51. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  52. package/templates/storefront/src/server/bootstrap.ts +122 -0
  53. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  54. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  55. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  56. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  57. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  58. package/templates/storefront/src/server/runner.ts +91 -0
  59. package/templates/storefront/src/server/types.ts +36 -0
  60. package/templates/storefront/tsconfig.json +2 -0
  61. package/templates/storefront/.env.example +0 -12
  62. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  63. package/templates/storefront/tailwind.config.js +0 -8
@@ -1,10 +1,15 @@
1
1
  'use client';
2
2
 
3
+ import type { ExtractedError } from '@forgecart/sdk/shop';
4
+ import type { ShopAddItemToOrderInput } from '@forgecart/sdk/shop';
5
+
6
+ import type { ActionResult } from './action-result';
3
7
  import type { Order } from './forgecart';
4
8
  import {
5
9
  createContext,
6
10
  useCallback,
7
11
  useContext,
12
+ useEffect,
8
13
  useMemo,
9
14
  useState,
10
15
  useTransition,
@@ -12,80 +17,210 @@ import {
12
17
  } from 'react';
13
18
 
14
19
  import {
15
- addToCart,
16
- getCart,
17
- removeLine as removeLineAction,
18
- setCartSellingPlan,
19
- updateLine,
20
- } from './cart-actions';
20
+ initShopSession,
21
+ isSessionReady,
22
+ readMirrorSession,
23
+ runSessionOp,
24
+ type ShopSessionConfig,
25
+ } from './shop-session';
21
26
 
22
27
  /**
23
- * Cart state backed by the ForgeCart shop order API (see `cart-actions.ts`).
28
+ * Cart state on the shopper's OWN shop websocket (`shop-session.ts`) — the
29
+ * client half of the progressive stack.
30
+ *
31
+ * PPR-compatible boot: the server shell renders with NO cart (reading the
32
+ * session cookie would dynamize the whole layout), so the provider hydrates
33
+ * client-side. On mount it arms the session singleton with the serialized
34
+ * channel config; when the mirror cookie says a session exists, it reads the
35
+ * active order over the socket (`activeOrder` is nullable and NEVER mints —
36
+ * carts start with the first added item). `hydrated` stays false until that
37
+ * first read settles, so cart surfaces can render a skeleton instead of a
38
+ * false "empty cart" flash.
24
39
  *
25
- * `cart` is the live `Order` (the SDK type). The provider is seeded with the
26
- * server-fetched active order (`initialCart`) so the first paint is correct,
27
- * then every mutation calls a Server Action and replaces it with the order the
28
- * API returns. `pending` is true while a cart mutation is in flight.
40
+ * Every mutation runs on the same socket and replaces `cart` with the order
41
+ * from the typed `ActionResult` envelope; a failure keeps the last known
42
+ * cart AND surfaces the typed error on `error`. `pending` is true while a
43
+ * cart mutation is in flight. The first `add` on a fresh visitor mints the
44
+ * session client-side; `shop-session.ts` syncs the captured token back into
45
+ * the cookies so the next SSR pass sees this cart.
29
46
  */
30
47
  interface CartContextValue {
31
48
  cart: Order | null;
32
49
  itemCount: number;
33
50
  subtotal: number;
51
+ /** False until the mount-time cart read settles — render skeletons, not "empty". */
52
+ hydrated: boolean;
34
53
  pending: boolean;
54
+ error: ExtractedError | null;
35
55
  add: (variantId: string, quantity?: number, sellingPlanId?: string) => void;
36
56
  setQuantity: (lineId: string, quantity: number) => void;
37
57
  remove: (lineId: string) => void;
38
58
  setSellingPlan: (sellingPlanId: string | null) => void;
59
+ applyCoupon: (code: string) => void;
60
+ removeCoupon: (code: string) => void;
61
+ clearError: () => void;
39
62
  refresh: () => void;
40
63
  }
41
64
 
42
65
  const CartContext = createContext<CartContextValue | null>(null);
43
66
 
44
67
  export function CartProvider({
45
- initialCart,
68
+ shopConfig,
46
69
  children,
47
70
  }: {
48
- initialCart: Order | null;
71
+ shopConfig: ShopSessionConfig | null;
49
72
  children: ReactNode;
50
73
  }) {
51
- const [cart, setCart] = useState<Order | null>(initialCart);
74
+ const [cart, setCart] = useState<Order | null>(null);
75
+ const [hydrated, setHydrated] = useState(false);
76
+ const [error, setError] = useState<ExtractedError | null>(null);
52
77
  const [pending, startTransition] = useTransition();
53
78
 
54
- const run = useCallback((op: () => Promise<Order | null>) => {
79
+ const run = useCallback((op: () => Promise<ActionResult<Order | null>>) => {
55
80
  startTransition(async () => {
56
- setCart(await op());
81
+ const result = await op();
82
+ if (result.ok) {
83
+ setCart(result.data);
84
+ setError(null);
85
+ return;
86
+ }
87
+ setError(result.error);
57
88
  });
58
89
  }, []);
59
90
 
91
+ // Arm the session singleton and hydrate the cart. An unconfigured scaffold
92
+ // (pre-`forgecart init`) or a visitor with no session hydrates to the
93
+ // legitimate empty state without touching the network.
94
+ useEffect(() => {
95
+ if (!shopConfig) {
96
+ setHydrated(true);
97
+ return;
98
+ }
99
+ initShopSession(shopConfig);
100
+ if (!readMirrorSession()) {
101
+ setHydrated(true);
102
+ return;
103
+ }
104
+ let cancelled = false;
105
+ void (async () => {
106
+ const result = await runSessionOp(
107
+ async (client) => (await client.order.activeOrder()).activeOrder ?? null,
108
+ );
109
+ if (cancelled) return;
110
+ if (result.ok) setCart(result.data);
111
+ setHydrated(true);
112
+ })();
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, [shopConfig]);
117
+
60
118
  const add = useCallback(
61
119
  (variantId: string, quantity = 1, sellingPlanId?: string) =>
62
- run(() => addToCart(variantId, quantity, sellingPlanId)),
120
+ run(async () => {
121
+ const input: ShopAddItemToOrderInput = { variantId, quantity };
122
+ if (sellingPlanId) {
123
+ input.sellingPlanId = sellingPlanId;
124
+ }
125
+ return runSessionOp(
126
+ async (client) => (await client.cart.addItemToOrder({ input })).addItemToOrder,
127
+ );
128
+ }),
63
129
  [run],
64
130
  );
65
131
  const setQuantity = useCallback(
66
- (lineId: string, quantity: number) => run(() => updateLine(lineId, quantity)),
132
+ (lineId: string, quantity: number) =>
133
+ run(() =>
134
+ runSessionOp(
135
+ async (client) =>
136
+ (await client.cart.adjustOrderLine({ input: { lineId, quantity } })).adjustOrderLine,
137
+ ),
138
+ ),
139
+ [run],
140
+ );
141
+ const remove = useCallback(
142
+ (lineId: string) =>
143
+ run(() =>
144
+ runSessionOp(
145
+ async (client) => (await client.cart.removeOrderLine({ lineId })).removeOrderLine,
146
+ ),
147
+ ),
67
148
  [run],
68
149
  );
69
- const remove = useCallback((lineId: string) => run(() => removeLineAction(lineId)), [run]);
70
150
  const setSellingPlan = useCallback(
71
- (sellingPlanId: string | null) => run(() => setCartSellingPlan(sellingPlanId)),
151
+ (sellingPlanId: string | null) =>
152
+ run(() =>
153
+ runSessionOp(
154
+ async (client) =>
155
+ (await client.cart.setOrderSellingPlan({ input: { sellingPlanId } }))
156
+ .setOrderSellingPlan,
157
+ ),
158
+ ),
159
+ [run],
160
+ );
161
+ const applyCoupon = useCallback(
162
+ (code: string) =>
163
+ run(() =>
164
+ runSessionOp(
165
+ async (client) => (await client.cart.applyCouponCode({ code })).applyCouponCode,
166
+ ),
167
+ ),
168
+ [run],
169
+ );
170
+ const removeCoupon = useCallback(
171
+ (code: string) =>
172
+ run(() =>
173
+ runSessionOp(
174
+ async (client) => (await client.cart.removeCouponCode({ code })).removeCouponCode,
175
+ ),
176
+ ),
177
+ [run],
178
+ );
179
+ const clearError = useCallback(() => setError(null), []);
180
+ const refresh = useCallback(
181
+ () =>
182
+ run(async () => {
183
+ if (!isSessionReady() || !readMirrorSession()) {
184
+ return { ok: true, data: null };
185
+ }
186
+ return runSessionOp(
187
+ async (client) => (await client.order.activeOrder()).activeOrder ?? null,
188
+ );
189
+ }),
72
190
  [run],
73
191
  );
74
- const refresh = useCallback(() => run(() => getCart()), [run]);
75
192
 
76
193
  const value = useMemo<CartContextValue>(
77
194
  () => ({
78
195
  cart,
79
196
  itemCount: cart?.totalQuantity ?? 0,
80
197
  subtotal: cart?.subTotalWithTax ?? 0,
198
+ hydrated,
81
199
  pending,
200
+ error,
82
201
  add,
83
202
  setQuantity,
84
203
  remove,
85
204
  setSellingPlan,
205
+ applyCoupon,
206
+ removeCoupon,
207
+ clearError,
86
208
  refresh,
87
209
  }),
88
- [cart, pending, add, setQuantity, remove, setSellingPlan, refresh],
210
+ [
211
+ cart,
212
+ hydrated,
213
+ pending,
214
+ error,
215
+ add,
216
+ setQuantity,
217
+ remove,
218
+ setSellingPlan,
219
+ applyCoupon,
220
+ removeCoupon,
221
+ clearError,
222
+ refresh,
223
+ ],
89
224
  );
90
225
 
91
226
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
@@ -0,0 +1,185 @@
1
+ 'use client';
2
+
3
+ import type { CreateAddressInput, SelectRateInput, SetCustomerInput } from '@forgecart/sdk/shop';
4
+
5
+ import type { ActionResult } from './action-result';
6
+ import type {
7
+ CheckoutOrder,
8
+ CheckoutOrderView,
9
+ ConfirmedPaymentSession,
10
+ EligiblePaymentProvider,
11
+ PaymentSession,
12
+ PaymentTemplate,
13
+ PaymentVariables,
14
+ ShippingRateGroup,
15
+ } from './forgecart';
16
+ import { runSessionOp } from './shop-session';
17
+
18
+ /**
19
+ * Checkout operations on the shopper's OWN websocket (`shop-session.ts`):
20
+ * contact → address → rates → payment → confirmation. Same names, same
21
+ * `ActionResult` envelopes as the retired Server-Action module — the flow
22
+ * components changed one import, not their contracts. Every operation here
23
+ * is session-scoped, which is exactly why it runs client-side; the only
24
+ * server-fetched checkout data is the public country list (channel read).
25
+ */
26
+
27
+ /** Attach the guest contact (email + name) to the active order. */
28
+ export async function setCustomer(
29
+ input: SetCustomerInput,
30
+ ): Promise<ActionResult<CheckoutOrderView | null>> {
31
+ return runSessionOp(
32
+ async (client) => (await client.cart.setCustomerForOrder({ input })).setCustomerForOrder,
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Persist the shipping address. `countryId` accepts the canonical Country id
38
+ * OR an ISO code (an autocomplete suggestion's `countryCode` feeds it
39
+ * directly); `phoneNumber` is required server-side
40
+ * (SHIPPING_ADDRESS_PHONE_REQUIRED arrives as a typed field error otherwise).
41
+ */
42
+ export async function setShippingAddress(
43
+ input: CreateAddressInput,
44
+ ): Promise<ActionResult<CheckoutOrderView | null>> {
45
+ return runSessionOp(
46
+ async (client) =>
47
+ (await client.checkout.setOrderShippingAddress({ input })).setOrderShippingAddress,
48
+ );
49
+ }
50
+
51
+ /** Type-ahead address suggestions ([] = no provider → manual entry). */
52
+ export async function suggestAddresses(
53
+ query: string,
54
+ sessionToken: string,
55
+ countryCode?: string,
56
+ ): Promise<ActionResult<{ id: string; description: string }[]>> {
57
+ return runSessionOp(
58
+ async (client) =>
59
+ (await client.address.addressAutocomplete({ input: { query, sessionToken, countryCode } }))
60
+ .addressAutocomplete,
61
+ );
62
+ }
63
+
64
+ /** Resolve one suggestion into a structured address for the form. */
65
+ export async function resolveAddressSuggestion(
66
+ id: string,
67
+ sessionToken: string,
68
+ ): Promise<
69
+ ActionResult<{
70
+ streetLine1: string;
71
+ streetLine2?: string | null;
72
+ city?: string | null;
73
+ province?: string | null;
74
+ postalCode?: string | null;
75
+ countryCode: string;
76
+ }>
77
+ > {
78
+ return runSessionOp(
79
+ async (client) =>
80
+ (await client.address.addressSuggestion({ input: { id, sessionToken } })).addressSuggestion,
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Explicit synchronous carrier re-quote — run on rate-step entry and after
86
+ * an address change. Per-provider failures arrive in each group's
87
+ * `lastRateErrors`; the read query never quotes (#863's read/refresh split).
88
+ */
89
+ export async function refreshShippingRates(): Promise<ActionResult<ShippingRateGroup[]>> {
90
+ return runSessionOp(
91
+ async (client) => (await client.shipping.refreshShippingRateGroups()).refreshShippingRateGroups,
92
+ );
93
+ }
94
+
95
+ /** Read the persisted rate groups (no carrier quoting). */
96
+ export async function getShippingRates(): Promise<ActionResult<ShippingRateGroup[]>> {
97
+ return runSessionOp(
98
+ async (client) => (await client.shipping.shippingRateGroups()).shippingRateGroups,
99
+ );
100
+ }
101
+
102
+ /** The CUSTOMER's rate pick — the ArrangingPayment gate requires one per group. */
103
+ export async function selectShippingRate(
104
+ input: SelectRateInput,
105
+ ): Promise<ActionResult<CheckoutOrderView | null>> {
106
+ return runSessionOp(
107
+ async (client) => (await client.shipping.selectShippingRate({ input })).selectShippingRate,
108
+ );
109
+ }
110
+
111
+ /** Advance the order to ArrangingPayment (gated: address+phone, items, selected rates). */
112
+ export async function transitionToPayment(): Promise<ActionResult<CheckoutOrderView | null>> {
113
+ return runSessionOp(
114
+ async (client) =>
115
+ (await client.checkout.transitionOrderToState({ input: { state: 'arrangingPayment' } }))
116
+ .transitionOrderToState,
117
+ );
118
+ }
119
+
120
+ /** Providers eligible for this order (eligibilityMessage is data, not an error). */
121
+ export async function getEligiblePaymentProviders(
122
+ orderId: string,
123
+ ): Promise<ActionResult<EligiblePaymentProvider[]>> {
124
+ return runSessionOp(
125
+ async (client) =>
126
+ (await client.payment.shopEligiblePaymentProviders({ orderId })).eligiblePaymentProviders,
127
+ );
128
+ }
129
+
130
+ /** Create the payment session (ONE per order — coupon edits freeze at this point). */
131
+ export async function createPaymentSession(
132
+ orderId: string,
133
+ providerCode?: string,
134
+ ): Promise<ActionResult<PaymentSession>> {
135
+ return runSessionOp(
136
+ async (client) =>
137
+ (await client.payment.createPaymentSession({ input: { orderId, providerCode } }))
138
+ .createPaymentSession,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Raw variables for the in-page payment adapter (`PaymentElementForm`):
144
+ * providerCode, publishableKey, scriptUrl, amount, currency — straight from
145
+ * the order + the channel's provider config, no session required.
146
+ */
147
+ export async function getPaymentVariables(
148
+ orderId: string,
149
+ ): Promise<ActionResult<PaymentVariables>> {
150
+ return runSessionOp(
151
+ async (client) => (await client.payment.getSessionVariables({ orderId })).getSessionVariables,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The provider's hosted form for `PaymentFormEmbed`. `origin` is the
157
+ * embedding storefront's own origin — the template pins every postMessage of
158
+ * the #865 handshake (iframe-ready / payment-success / payment-error) to it.
159
+ */
160
+ export async function getPaymentFormTemplate(
161
+ orderId: string,
162
+ origin: string,
163
+ ): Promise<ActionResult<PaymentTemplate>> {
164
+ return runSessionOp(
165
+ async (client) =>
166
+ (await client.payment.getSessionTemplate({ input: { orderId, origin } })).getSessionTemplate,
167
+ );
168
+ }
169
+
170
+ /** Confirm the session where the provider flow requires it (token/redirect models). */
171
+ export async function confirmPaymentSession(
172
+ sessionId: string,
173
+ ): Promise<ActionResult<ConfirmedPaymentSession>> {
174
+ return runSessionOp(
175
+ async (client) =>
176
+ (await client.payment.confirmPaymentSession({ sessionId })).confirmPaymentSession,
177
+ );
178
+ }
179
+
180
+ /** The settled order for the confirmation step (by the code shown to the shopper). */
181
+ export async function getOrderByCode(code: string): Promise<ActionResult<CheckoutOrder | null>> {
182
+ return runSessionOp(
183
+ async (client) => (await client.order.shopOrderByCode({ code })).orderByCode ?? null,
184
+ );
185
+ }
@@ -0,0 +1,24 @@
1
+ import type { ExtractedError } from '@forgecart/sdk/shop';
2
+
3
+ /**
4
+ * Shopper-facing messages for the typed checkout error codes.
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
+ const SHOPPER_MESSAGES: Record<string, string> = {
13
+ SHIPPING_ADDRESS_PHONE_REQUIRED: 'A phone number is required for delivery updates.',
14
+ SHIPPING_ADDRESS_COUNTRY_REQUIRED: 'Please select a country.',
15
+ SHIPPING_ADDRESS_COUNTRY_INVALID: 'We can’t ship to the selected country yet.',
16
+ };
17
+
18
+ const FALLBACK_MESSAGE = 'Something went wrong. Please check your details and try again.';
19
+
20
+ /** Human copy for a typed error — never the raw enum, never undefined. */
21
+ export function shopperMessage(error: Pick<ExtractedError, 'code'> | null | undefined): string {
22
+ if (!error?.code) return FALLBACK_MESSAGE;
23
+ return SHOPPER_MESSAGES[error.code] ?? FALLBACK_MESSAGE;
24
+ }
@@ -283,53 +283,51 @@ async function scheduleExposure(
283
283
  * whole render consistent — layout and page asking for the same key get one
284
284
  * resolution and at most ONE exposure emission per request.
285
285
  */
286
- export const getVariant = cache(
287
- async (experimentKey: string): Promise<ExperimentVariantResult> => {
288
- const inert: ExperimentVariantResult = {
289
- variantKey: null,
290
- assignmentSource: null,
291
- payload: null,
292
- };
293
- if (!SLUG_REGEX.test(experimentKey)) return inert;
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;
294
293
 
295
- const experiments = await getActiveExperiments();
296
- const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
297
- if (!experiment) return inert;
294
+ const experiments = await getActiveExperiments();
295
+ const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
296
+ if (!experiment) return inert;
298
297
 
299
- if (experiment.subjectType === 'customer') {
300
- // The storefront server has no verified customer id (auth lives behind
301
- // the shop API session) — a customer-scoped experiment renders as
302
- // non-participant control here, with no exposure. Customer-subject
303
- // resolution belongs to clients that KNOW the customer id.
304
- const control = experiment.variants[0];
305
- if (!control) return inert;
306
- return { variantKey: control.key, assignmentSource: null, payload: control.payload ?? null };
307
- }
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
+ }
308
307
 
309
- const visitorId = await getRequestVisitorId();
310
- const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
308
+ const visitorId = await getRequestVisitorId();
309
+ const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
311
310
 
312
- let result: AssignmentResult;
313
- try {
314
- result = resolveExperimentAssignment(toAssignmentConfig(experiment), {
315
- subjectKey: visitorId,
316
- forcedVariantKey,
317
- });
318
- } catch {
319
- // A malformed delivery (out-of-bounds weight, empty variants) must
320
- // never break rendering — the caller falls back to its default branch.
321
- return inert;
322
- }
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
+ }
323
322
 
324
- if (result.exposed && result.variantKey !== null) {
325
- await scheduleExposure(experiment, result, visitorId);
326
- }
323
+ if (result.exposed && result.variantKey !== null) {
324
+ await scheduleExposure(experiment, result, visitorId);
325
+ }
327
326
 
328
- const variant = experiment.variants.find((entry) => entry.key === result.variantKey);
329
- return {
330
- variantKey: result.variantKey,
331
- assignmentSource: result.assignmentSource,
332
- payload: variant?.payload ?? null,
333
- };
334
- },
335
- );
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
+ });