@forgecart/cli 2.202608300703.0 → 2.202609190800.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 (76) hide show
  1. package/dist/src/commands/init.d.ts +21 -2
  2. package/dist/src/commands/init.js +18 -2
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +42 -4
  6. package/templates/storefront/next.config.js +29 -7
  7. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  8. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  9. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  10. package/templates/storefront/src/app/cart/page.tsx +14 -2
  11. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  12. package/templates/storefront/src/app/layout.tsx +85 -22
  13. package/templates/storefront/src/app/page.tsx +63 -20
  14. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  15. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  16. package/templates/storefront/src/app/ping/route.ts +1 -1
  17. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  18. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  19. package/templates/storefront/src/app/products/page.tsx +41 -6
  20. package/templates/storefront/src/app/register/page.tsx +54 -0
  21. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  22. package/templates/storefront/src/app/robots.ts +69 -0
  23. package/templates/storefront/src/app/sitemap.ts +106 -0
  24. package/templates/storefront/src/app/verify/page.tsx +155 -0
  25. package/templates/storefront/src/components/CartView.tsx +26 -7
  26. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  27. package/templates/storefront/src/components/Header.tsx +30 -10
  28. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  29. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  30. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  31. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  32. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  33. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  34. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  35. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  36. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  37. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  38. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  39. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  40. package/templates/storefront/src/lib/account-actions.ts +76 -0
  41. package/templates/storefront/src/lib/account-session.ts +47 -0
  42. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  43. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  44. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  45. package/templates/storefront/src/lib/experiments.ts +1 -1
  46. package/templates/storefront/src/lib/forgecart.ts +300 -27
  47. package/templates/storefront/src/lib/format.ts +12 -14
  48. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  49. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  50. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  51. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  52. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  53. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  54. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  55. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  56. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  57. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  58. package/templates/storefront/src/lib/money.ts +50 -0
  59. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  60. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  61. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  62. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  63. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  64. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  65. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  66. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  67. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  68. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  69. package/templates/storefront/src/lib/session-actions.ts +15 -8
  70. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  71. package/templates/storefront/src/lib/shop-config.ts +9 -2
  72. package/templates/storefront/src/lib/shop-session.ts +20 -1
  73. package/templates/storefront/src/lib/track-forward.ts +43 -14
  74. package/templates/storefront/src/middleware.ts +150 -8
  75. package/templates/storefront/src/seo/redirects.ts +44 -0
  76. package/templates/storefront/src/server/runner.ts +1 -2
@@ -1,11 +1,15 @@
1
1
  import 'server-only';
2
2
 
3
- import { ForgeCartShopClient } from '@forgecart/sdk/shop';
3
+ import { ForgeCartShopClient, extractError } from '@forgecart/sdk/shop';
4
4
  import type {
5
5
  ShopProductFieldFragment as Product,
6
6
  ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
7
7
  } from '@forgecart/sdk/shop';
8
8
 
9
+ import type { AccountCallOutcome } from './account/account-link';
10
+ import { UNREACHABLE_ERROR } from './action-result';
11
+ import { getRequestLocale } from './locale/request-binding';
12
+
9
13
  /**
10
14
  * Server-side ForgeCart shop client.
11
15
  *
@@ -35,17 +39,25 @@ import type {
35
39
  const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
36
40
  const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
37
41
 
38
- let client: ForgeCartShopClient | null = null;
39
-
40
42
  /**
41
- * Lazily construct and memoize the channel-scoped shop client.
43
+ * One client per language (#1346, W1-8 / S1).
44
+ *
45
+ * The SDK's language is a per-CONNECTION header fixed at socket handshake, so
46
+ * a single instance structurally cannot serve two locales — and mutating a
47
+ * shared one with `setLanguageCode()` is worse than useless here: it DISPOSES
48
+ * the websocket, tearing down whatever concurrent request was mid-flight on
49
+ * it. Hence one client, one language, memoized for the process.
42
50
  *
43
- * All reads here are anonymous (channel scope only), so one client instance
44
- * serves the whole server process. Per-shopper session state lives in
45
- * `cart-actions.ts`, which constructs a per-request client around the session
46
- * cookie instead.
51
+ * The map is bounded by the channel's language count, not by traffic: it is
52
+ * keyed on the locale the layout RESOLVED, and the layout only resolves a
53
+ * language the channel offers `/zz/`, `/qq/` 404 before they ever reach a
54
+ * data read. Entries are never evicted: eviction would dispose a live socket
55
+ * and force a re-handshake, converting bounded memory into unbounded backend
56
+ * churn. Each idle socket is reaped by the SDK's own `lazyCloseTimeout`.
47
57
  */
48
- export function getShopClient(): ForgeCartShopClient {
58
+ const clientsByLocale = new Map<string, ForgeCartShopClient>();
59
+
60
+ function assertShopConfigured(): void {
49
61
  if (!SHOP_API_URL) {
50
62
  throw new Error(
51
63
  'FORGECART_SHOP_API_URL is not set. Add it to .env (forgecart init writes it for you).',
@@ -56,17 +68,43 @@ export function getShopClient(): ForgeCartShopClient {
56
68
  'FORGECART_CHANNEL_TOKEN is not set. Add it to .env (forgecart init writes it for you).',
57
69
  );
58
70
  }
59
- if (!client) {
60
- client = new ForgeCartShopClient({
61
- endpoint: SHOP_API_URL,
62
- channelToken: CHANNEL_TOKEN,
63
- });
64
- }
65
- return client;
66
71
  }
67
72
 
68
- /** Convenience singleton for direct use in Server Components. */
69
- export const shopClient = (): ForgeCartShopClient => getShopClient();
73
+ /**
74
+ * The client for an explicit language.
75
+ *
76
+ * `locale` must be one the channel offers — the caller has already resolved it
77
+ * through `getRequestLocale()`, whose decision function 404s anything else. It
78
+ * is not re-validated here, and it deliberately does NOT fall back to the
79
+ * default on a surprise: silently answering in another language than the
80
+ * document just declared in `<html lang>` is precisely the bug this slice
81
+ * exists to close, and it would look correct in every review.
82
+ */
83
+ export function getShopClientForLocale(locale: string): ForgeCartShopClient {
84
+ assertShopConfigured();
85
+ const existing = clientsByLocale.get(locale);
86
+ if (existing) return existing;
87
+
88
+ const created = new ForgeCartShopClient({
89
+ endpoint: SHOP_API_URL,
90
+ channelToken: CHANNEL_TOKEN,
91
+ languageCode: locale,
92
+ });
93
+ clientsByLocale.set(locale, created);
94
+ return created;
95
+ }
96
+
97
+ /**
98
+ * The client for THIS request's language.
99
+ *
100
+ * Every read below goes through it, so a German page's product names, facets
101
+ * and selling plans come back in German — the half of the locale contract that
102
+ * `<html lang>` alone would only claim.
103
+ */
104
+ export async function getShopClient(): Promise<ForgeCartShopClient> {
105
+ const { binding } = await getRequestLocale();
106
+ return getShopClientForLocale(binding.locale);
107
+ }
70
108
 
71
109
  // The SDK's operation-shaped fragment types, re-exported under their domain
72
110
  // names so components import them from one place. These are exactly what the
@@ -93,10 +131,43 @@ import type {
93
131
  GetSessionTemplateQuery,
94
132
  GetSessionVariablesQuery,
95
133
  RefreshShippingRateGroupsMutation,
134
+ SeoEntriesQuery,
96
135
  ShopEligiblePaymentProvidersQuery,
136
+ ShopEntriesQuery,
97
137
  ShopOrderByCodeQuery,
138
+ ShopPageByRouteQuery,
139
+ ShopProductQuery,
98
140
  } from '@forgecart/sdk/shop';
99
141
 
142
+ /**
143
+ * A single product AS SELECTED by the detail query — the display fields plus
144
+ * the per-language SEO sidecar (#1341) and `resolvedLanguageCode` (#1339).
145
+ *
146
+ * Distinct from `Product` (the list-row fragment) on purpose: only this shape
147
+ * carries what the slug law needs, and taking it by indexed access means the
148
+ * template cannot drift from what the operation actually asks for.
149
+ */
150
+ export type ProductWithSeo = NonNullable<ShopProductQuery['product']>;
151
+
152
+ /** One page of the XML sitemap's catalog feed (#1339). */
153
+ export type SeoEntryFeedPage = SeoEntriesQuery['seoEntries'];
154
+
155
+ /**
156
+ * An ACF page definition as `pageByRoute` selects it (#1934) — the merchant's
157
+ * own field definitions, in the order the editor arranged them.
158
+ *
159
+ * Taken by indexed access off the generated query rather than re-declared, like
160
+ * every other shape here: what the route renders and what the operation asks
161
+ * for cannot drift apart.
162
+ */
163
+ export type PageGroup = NonNullable<ShopPageByRouteQuery['pageByRoute']>;
164
+ /** One field definition of a page — the label and the identity of a value. */
165
+ export type PageFieldDefinition = PageGroup['fieldDefinitions'][number];
166
+ /** One record of a page, with its values and repeater rows. */
167
+ export type PageEntry = ShopEntriesQuery['entries']['items'][number];
168
+ /** One stored value, discriminated by `__typename` over the ACF field types. */
169
+ export type PageFieldValue = PageEntry['fields'][number];
170
+
100
171
  export type Country = AvailableCountriesQuery['availableCountries'][number];
101
172
  export type ShippingRateGroup =
102
173
  RefreshShippingRateGroupsMutation['refreshShippingRateGroups'][number];
@@ -134,19 +205,34 @@ export async function getProducts(
134
205
  options: { take?: number; skip?: number } = {},
135
206
  ): Promise<{ items: Product[]; totalItems: number }> {
136
207
  const { take = 24, skip = 0 } = options;
137
- const { products } = await getShopClient().product.shopProducts({ options: { take, skip } });
208
+ const { products } = await (
209
+ await getShopClient()
210
+ ).product.shopProducts({ options: { take, skip } });
138
211
  return { items: products.items, totalItems: products.totalItems };
139
212
  }
140
213
 
141
- /** Fetch a single product by its URL slug. Returns `null` if not found. */
142
- export async function getProductBySlug(slug: string): Promise<Product | null> {
143
- const { product } = await getShopClient().product.shopProduct({ slug });
214
+ /**
215
+ * Fetch a single product by its URL slug. Returns `null` if not found.
216
+ *
217
+ * Typed as {@link ProductWithSeo}, not `Product`: the single-product query
218
+ * selects the per-language SEO sidecar (#1341) alongside the display fields,
219
+ * and the slug law in `lib/seo/resolve-path.ts` reads it to decide whether this
220
+ * URL is the locale's own address, a derived copy, or a redirect. The list
221
+ * queries deliberately do NOT select it — resolving the sidecar per row would
222
+ * pay for it once per card.
223
+ *
224
+ * The lookup resolves across languages (#1339): a slug that belongs to another
225
+ * language, or to this product's PREVIOUS slug, still finds it, and the
226
+ * returned `slug` is this locale's current one.
227
+ */
228
+ export async function getProductBySlug(slug: string): Promise<ProductWithSeo | null> {
229
+ const { product } = await (await getShopClient()).product.shopProduct({ slug });
144
230
  return product ?? null;
145
231
  }
146
232
 
147
233
  /** Fetch a single product by id. Returns `null` if not found. */
148
234
  export async function getProductById(id: string): Promise<Product | null> {
149
- const { product } = await getShopClient().product.shopProduct({ id });
235
+ const { product } = await (await getShopClient()).product.shopProduct({ id });
150
236
  return product ?? null;
151
237
  }
152
238
 
@@ -162,23 +248,210 @@ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
162
248
  export async function getSellingPlanGroupsForVariant(
163
249
  variantId: string,
164
250
  ): Promise<SellingPlanGroup[]> {
165
- const { sellingPlanGroupsForVariant } =
166
- await getShopClient().sellingPlan.sellingPlanGroupsForVariant({ variantId });
251
+ const { sellingPlanGroupsForVariant } = await (
252
+ await getShopClient()
253
+ ).sellingPlan.sellingPlanGroupsForVariant({ variantId });
167
254
  return sellingPlanGroupsForVariant;
168
255
  }
169
256
 
170
257
  /** Fetch the channel-wide subscription groups (whole-cart subscribe box). */
171
258
  export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]> {
172
- const { channelSellingPlanGroups } = await getShopClient().sellingPlan.channelSellingPlanGroups();
259
+ const { channelSellingPlanGroups } = await (
260
+ await getShopClient()
261
+ ).sellingPlan.channelSellingPlanGroups();
173
262
  return channelSellingPlanGroups;
174
263
  }
175
264
 
265
+ /**
266
+ * One page of the catalog entries the XML sitemap enumerates (#1339).
267
+ *
268
+ * The connection language is passed in rather than taken from the request,
269
+ * because this feed has no request locale to inherit: it returns EVERY
270
+ * language's path for every entry (languages without a translation row are
271
+ * omitted, never synthesized), so the answer is the same whichever language
272
+ * asks. `/sitemap.xml` is an unprefixed route by grammar — there is no locale
273
+ * in its URL to resolve — and the caller names the channel default so that the
274
+ * choice is visible instead of inherited from a header that is not there.
275
+ */
276
+ export async function getProductSeoEntries(
277
+ languageCode: string,
278
+ options: { take: number; skip: number },
279
+ ): Promise<SeoEntryFeedPage> {
280
+ const { seoEntries } = await getShopClientForLocale(languageCode).seo.seoEntries({
281
+ input: { kind: 'PRODUCT', take: options.take, skip: options.skip },
282
+ });
283
+ return seoEntries;
284
+ }
285
+
286
+ /**
287
+ * The page definition mounted at a storefront route (#1934). `null` is "no page
288
+ * here" — an unknown route, a data definition's code, anything outside the
289
+ * route grammar — and the caller turns that into a real 404.
290
+ *
291
+ * METADATA only. Whether the page has content to show is the entries read
292
+ * below; keeping the two apart is what lets `resolvePage` state the whole 404
293
+ * decision in one place instead of splitting it across a query and a render.
294
+ */
295
+ export async function getPageByRoute(route: string): Promise<PageGroup | null> {
296
+ const { pageByRoute } = await (await getShopClient()).acf.shopPageByRoute({ route });
297
+ return pageByRoute ?? null;
298
+ }
299
+
300
+ /**
301
+ * The record a page URL serves, as a list of at most one (#1934).
302
+ *
303
+ * `take: 1` because a route is ONE address; `createdAt ASC` because that makes
304
+ * it the page's ORIGINAL record. The shop API's own default is `createdAt
305
+ * DESC`, and inheriting it would mean that adding a second record to a live
306
+ * page silently REPLACES what the URL has been serving — a content change
307
+ * nobody asked for, made by a create. Ascending is a stable answer: the page a
308
+ * merchant published stays the page at that URL.
309
+ *
310
+ * The list may come back EMPTY, and that is the feature's whole 404 arm: every
311
+ * storefront read is fenced to published content, so a page whose only records
312
+ * are DRAFT or SCHEDULED-not-yet-live looks exactly like a page with no records
313
+ * at all. `resolvePage` decides; this function only asks.
314
+ */
315
+ export async function getPageEntries(definitionCode: string): Promise<PageEntry[]> {
316
+ const { entries } = await (
317
+ await getShopClient()
318
+ ).acf.shopEntries({
319
+ definitionCode,
320
+ options: { take: 1, sort: [{ field: 'createdAt', direction: 'ASC' }] },
321
+ });
322
+ return entries.items;
323
+ }
324
+
325
+ /**
326
+ * Every ACF page route the shop currently SERVES (#1934) — the sitemap's page
327
+ * feed.
328
+ *
329
+ * A stricter question than `getPageByRoute`'s, deliberately: this list must
330
+ * contain no URL that would answer 404, because a sitemap entry that does costs
331
+ * crawl budget and lands in Search Console as an error. The API applies that
332
+ * gate; unpublishing a page takes it out of this list, and out of the document
333
+ * on the next window.
334
+ *
335
+ * The connection language is named by the caller for the same reason
336
+ * `getProductSeoEntries` names it: `/sitemap.xml` is an unprefixed route with
337
+ * no request locale to inherit, and a route exists in every language alike.
338
+ */
339
+ export async function getServedPageRoutes(languageCode: string): Promise<string[]> {
340
+ const { pageRoutes } = await getShopClientForLocale(languageCode).acf.shopPageRoutes();
341
+ return pageRoutes;
342
+ }
343
+
176
344
  /**
177
345
  * Countries the channel ships to — a PUBLIC channel read (no session), so it
178
346
  * belongs on this server singleton: the checkout page prefetches it and hands
179
347
  * the list to the client flow.
180
348
  */
181
349
  export async function getAvailableCountries(): Promise<Country[]> {
182
- const { availableCountries } = await getShopClient().country.availableCountries();
350
+ const { availableCountries } = await (await getShopClient()).country.availableCountries();
183
351
  return availableCountries;
184
352
  }
353
+
354
+ /**
355
+ * ── The four CUSTOMER-ACCOUNT operations (#1472, #1471) ─────────────────────
356
+ *
357
+ * The platform mails a shopper a verification link and a password-reset link
358
+ * pointing at THIS storefront's origin (`/verify` and `/reset-password`), and
359
+ * three of these are the calls those two pages make. The fourth asks for the
360
+ * verification mail to be sent AGAIN — the prompt `/register` leaves a shopper
361
+ * on, for the one whose first mail never arrived (#1471). They are the only
362
+ * WRITES in this module, and the only functions in it that do not throw.
363
+ *
364
+ * Not throwing is the point. Every read above serves a page whose failure IS a
365
+ * failure — an unreachable catalog is a 500, and the route error boundary is
366
+ * the right answer. For these four, refusal is the ORDINARY case: a
367
+ * one-shot token is used exactly once, so the second click on the same link is
368
+ * an expected outcome to RENDER, not an incident to report. A thrown error
369
+ * would put the shopper on an error page for doing something entirely normal.
370
+ *
371
+ * They answer with {@link AccountCallOutcome} rather than the `ActionResult`
372
+ * envelope the checkout operations use, because there is nothing to carry: the
373
+ * API's answer to a one-shot credential is whether it was accepted and, if
374
+ * not, which `ErrorCode` it refused with. That is a pure, serializable value —
375
+ * it crosses the Server Action boundary unchanged — and `lib/account/*`
376
+ * interprets it in ONE place for both pages. The unreachable/unparseable arm
377
+ * borrows `UNREACHABLE_ERROR`'s code so the whole template spells that
378
+ * condition one way.
379
+ */
380
+
381
+ /** Verify a customer's e-mail address with the token from their mail. */
382
+ export async function verifyCustomerAccount(token: string): Promise<AccountCallOutcome> {
383
+ return runAccountOperation(async (client) => {
384
+ await client.customer.verifyCustomerAccount({ token });
385
+ });
386
+ }
387
+
388
+ /** Set a new password from the token in a password-reset mail. */
389
+ export async function resetPassword(input: {
390
+ token: string;
391
+ password: string;
392
+ }): Promise<AccountCallOutcome> {
393
+ return runAccountOperation(async (client) => {
394
+ await client.customer.resetPassword({ input });
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Ask the platform to mail a password-reset link.
400
+ *
401
+ * The API's boolean answer (whether an account matched) is deliberately
402
+ * DISCARDED here rather than returned: surfacing it would let a visitor learn
403
+ * whether an address shops here, and a value this module returns is a value a
404
+ * page can render by accident. The neutral answer is decided once, in
405
+ * `lib/account/reset-password-state.ts`.
406
+ */
407
+ export async function requestPasswordReset(emailAddress: string): Promise<AccountCallOutcome> {
408
+ return runAccountOperation(async (client) => {
409
+ await client.customer.requestPasswordReset({ emailAddress });
410
+ });
411
+ }
412
+
413
+ /**
414
+ * Ask the platform to send the verification mail again (#1471).
415
+ *
416
+ * The API's boolean is deliberately DISCARDED, exactly as the password-reset
417
+ * request's is: a value this module returns is a value a page can render by
418
+ * accident, and rendering whether an address is registered here is an
419
+ * enumeration oracle. The neutral answer is decided once, in
420
+ * `lib/account/register-state.ts`.
421
+ *
422
+ * Unauthenticated by necessity rather than by convenience. A shopper who never
423
+ * confirmed their address cannot log in at all, so a resend sitting behind a
424
+ * login would be unreachable by precisely the people who need it.
425
+ */
426
+ export async function requestCustomerVerification(
427
+ emailAddress: string,
428
+ ): Promise<AccountCallOutcome> {
429
+ return runAccountOperation(async (client) => {
430
+ await client.customer.requestCustomerVerification({ emailAddress });
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Run one account operation and report its outcome.
436
+ *
437
+ * The `try` covers `getShopClient()` as well as the call, and that is
438
+ * deliberate: an unconfigured scaffold throws there (`assertShopConfigured`),
439
+ * and the prewarm contract says a storefront with no `.env` must SERVE rather
440
+ * than crash — so that failure has to arrive as a rendered state like any
441
+ * other, not as an unhandled throw inside a render.
442
+ *
443
+ * This is the template's one sanctioned use of `catch`: wrapping an external
444
+ * SDK's errors at the call site, exactly as `shop-session.ts#runSessionOp` and
445
+ * `server/runner.ts` already do. Nothing is swallowed — every failure leaves
446
+ * here as a code the page renders.
447
+ */
448
+ async function runAccountOperation(
449
+ operation: (client: ForgeCartShopClient) => Promise<void>,
450
+ ): Promise<AccountCallOutcome> {
451
+ try {
452
+ await operation(await getShopClient());
453
+ return { kind: 'ok' };
454
+ } catch (error) {
455
+ return { kind: 'error', code: extractError(error)?.code ?? UNREACHABLE_ERROR.code };
456
+ }
457
+ }
@@ -3,6 +3,8 @@ import type {
3
3
  ShopSellingPlanFieldFragment as SellingPlan,
4
4
  } from '@forgecart/sdk/shop';
5
5
 
6
+ import { toMajorUnits } from './money';
7
+
6
8
  /**
7
9
  * Pure display helpers over SDK types — safe to import from ANY component.
8
10
  *
@@ -18,22 +20,18 @@ import type {
18
20
  * Format a minor-unit price as a currency string. Defaults to USD; pass a
19
21
  * different ISO currency code as needed.
20
22
  *
21
- * ForgeCart prices are integers in the currency's smallest denomination, so
22
- * the divisor depends on the currency's ISO-4217 exponent: 2500 is $25.00
23
- * (two-decimal cents) but ¥2,500 (zero-decimal yen) and BHD 2.500
24
- * (three-decimal fils). The exponent is derived from the same
25
- * `Intl.NumberFormat` instance that renders the price — its
26
- * `resolvedOptions().maximumFractionDigits` carries the CLDR fraction digits
27
- * for the currency — so the divisor always agrees with the digits shown,
28
- * with a defensive fallback of 2 should the formatter resolve none.
23
+ * The minor-unit divisor comes from {@link toMajorUnits}, which derives the
24
+ * currency's ISO-4217 exponent from CLDR — never a hardcoded hundred, which is
25
+ * wrong for zero-decimal yen and three-decimal fils. That derivation moved to
26
+ * `lib/money.ts` when the JSON-LD layer acquired the second consumer: the
27
+ * shopper-facing string and the machine-readable `Offer.price` disagreeing
28
+ * about what 2500 means would publish one price to people and another to
29
+ * search engines.
29
30
  */
30
31
  export function formatPrice(minorUnits: number, currency = 'USD'): string {
31
- const formatter = new Intl.NumberFormat('en-US', {
32
- style: 'currency',
33
- currency,
34
- });
35
- const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
36
- return formatter.format(minorUnits / 10 ** exponent);
32
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(
33
+ toMajorUnits(minorUnits, currency),
34
+ );
37
35
  }
38
36
 
39
37
  /** Lowest variant price for a product, in minor units, or `null` if none. */
@@ -0,0 +1,152 @@
1
+ import {
2
+ buildUpstreamHeaders,
3
+ getUpstreamConfig,
4
+ readMintedSessionToken,
5
+ type ForwardHeaders,
6
+ } from './track-forward';
7
+
8
+ /**
9
+ * Marketing-identity upstream forwarder (#1596).
10
+ *
11
+ * ONE implementation of "tell the ForgeCart shop API which ad click this
12
+ * shopper arrived from". The click IDs a landing URL carries (`gclid`,
13
+ * `fbclid`, `ttclid`) are read on the client by `lib/marketing-params.ts`,
14
+ * posted to the same-origin `/__fc/identify` relay, and land here as a single
15
+ * `setMarketingIdentifiers` mutation.
16
+ *
17
+ * Why a module of its own rather than a branch inside `track-forward.ts`: a
18
+ * click ID is not an event. It names the IDENTITY the events belong to, it is
19
+ * sent once per landing instead of once per batch, and the ad platforms read it
20
+ * back out of that identity months later when a conversion is reported. The two
21
+ * forwarders share their TRANSPORT and nothing else — `getUpstreamConfig`,
22
+ * `buildUpstreamHeaders` and `readMintedSessionToken` live next door and are
23
+ * imported rather than restated, so the session a request rides can never
24
+ * differ between them; the shapes stay apart because they describe different
25
+ * things.
26
+ *
27
+ * Same discipline as the event forwarder, for the same reasons: raw HTTP
28
+ * GraphQL (the generated SDK transports over one WebSocket whose connection
29
+ * pins ONE identity, and has no plain-HTTP query path), inert while
30
+ * `forgecart init` has not written `.env` yet, and EVERY failure mode —
31
+ * missing config, network, HTTP status, GraphQL errors — resolving to a typed
32
+ * negative outcome instead of a throw. Attribution must never break the page
33
+ * the shopper came for.
34
+ */
35
+
36
+ /**
37
+ * The SDK's own `ShopSetMarketingIdentifiers` document, inlined verbatim.
38
+ *
39
+ * Kept character-identical to the shipped operation
40
+ * (`app/client-sdk-generators/src/operations/shop/marketing-identity.graphql`)
41
+ * so checking the two for drift is a diff rather than a reading. `identityId`
42
+ * belongs to that document and is deliberately not read below: the storefront
43
+ * has no use for the backend's internal id, and the client it answers has even
44
+ * less.
45
+ */
46
+ const SET_MARKETING_IDENTIFIERS_MUTATION = `mutation ShopSetMarketingIdentifiers($input: SetMarketingIdentifiersInput!) {
47
+ setMarketingIdentifiers(input: $input) {
48
+ identityId
49
+ accepted
50
+ rejected
51
+ }
52
+ }`;
53
+
54
+ /**
55
+ * One click ID, shaped for the API's `MarketingIdentifierInput`.
56
+ *
57
+ * The client parser's `MarketingIdentifier` is the same pair by design — this
58
+ * declaration exists because the SERVER side of the hop mirrors the SDL (the
59
+ * sibling's `TrackEventInput` does the same), and the relay route rebuilds the
60
+ * pair from untrusted JSON rather than trusting anything the client typed.
61
+ */
62
+ export interface MarketingIdentifierInput {
63
+ /** The registered identifier key the value is stored under. */
64
+ key: string;
65
+ /** The click ID exactly as the ad platform wrote it into the URL. */
66
+ value: string;
67
+ }
68
+
69
+ /**
70
+ * What the upstream made of the identifiers.
71
+ *
72
+ * `rejected` is NOT an error and must never be turned into one. The backend
73
+ * partitions the submitted keys against its identifier registry, stores the
74
+ * ones it knows, drops the ones it does not and REPORTS both — no throw, no
75
+ * rollback, no partial failure (`MarketingIdentityService.setIdentifiersForShop`).
76
+ * A key landing in `rejected` wrote no row and broke nothing; it means only
77
+ * that this storefront and the backend registry disagree about a spelling,
78
+ * which is a deployment fact worth being able to read and not a runtime
79
+ * condition to handle. Nothing downstream may treat a non-empty `rejected` as a
80
+ * reason to retry, to fail the request, or to hold up the page.
81
+ */
82
+ export interface IdentifyOutcome {
83
+ /** Identifier keys the backend recognised and stored. */
84
+ accepted: readonly string[];
85
+ /** Keys it does not know — dropped upstream; see the note above. */
86
+ rejected: readonly string[];
87
+ /** Session token surfaced in the response extensions, if any. */
88
+ sessionToken: string | null;
89
+ }
90
+
91
+ interface GraphQLIdentifyResponse {
92
+ data?: {
93
+ setMarketingIdentifiers?: { accepted?: string[]; rejected?: string[] } | null;
94
+ } | null;
95
+ errors?: unknown[];
96
+ extensions?: Record<string, unknown>;
97
+ }
98
+
99
+ /** The outcome every failure mode answers with: nothing stored, nothing minted. */
100
+ const NO_IDENTIFIERS_STORED: IdentifyOutcome = { accepted: [], rejected: [], sessionToken: null };
101
+
102
+ /**
103
+ * Forward the captured click IDs as one raw HTTP GraphQL POST.
104
+ *
105
+ * One call for the whole list — the mutation takes an array and the backend
106
+ * writes them against a single identity, so there is no per-item sequencing to
107
+ * get wrong here (the event relay loops only because `trackEvent` takes one
108
+ * event at a time).
109
+ *
110
+ * Any failure — missing config, network, HTTP status, GraphQL errors —
111
+ * resolves to an empty outcome; the forwarder is best-effort by design and the
112
+ * outcome is terminal (callers never retry).
113
+ */
114
+ export async function forwardMarketingIdentifiers(
115
+ identifiers: readonly MarketingIdentifierInput[],
116
+ forward: ForwardHeaders,
117
+ ): Promise<IdentifyOutcome> {
118
+ const upstream = getUpstreamConfig();
119
+ if (!upstream) return NO_IDENTIFIERS_STORED;
120
+
121
+ try {
122
+ const response = await fetch(upstream.shopApiUrl, {
123
+ method: 'POST',
124
+ headers: buildUpstreamHeaders(upstream.channelToken, forward),
125
+ body: JSON.stringify({
126
+ query: SET_MARKETING_IDENTIFIERS_MUTATION,
127
+ variables: { input: { identifiers } },
128
+ }),
129
+ });
130
+ if (!response.ok) return NO_IDENTIFIERS_STORED;
131
+
132
+ const payload = (await response.json()) as GraphQLIdentifyResponse;
133
+ // Read the mint even when the mutation itself did not answer: a cookie-less
134
+ // request mints the session in middleware, BEFORE the resolver runs, so the
135
+ // token is real regardless of what happened to the identifiers — and the
136
+ // relay must persist it or the next request mints a second identity.
137
+ const capturedToken = readMintedSessionToken(payload.extensions);
138
+ const result = payload.data?.setMarketingIdentifiers;
139
+ if (!result) return { accepted: [], rejected: [], sessionToken: capturedToken };
140
+ return {
141
+ accepted: result.accepted ?? [],
142
+ rejected: result.rejected ?? [],
143
+ sessionToken: capturedToken,
144
+ };
145
+ } catch {
146
+ // Best-effort by design: an unreachable or still-booting backend must never
147
+ // break the landing page. The click ID is lost with the page view it came
148
+ // with, which is the accepted cost of never letting analytics hold up a
149
+ // shopper — no caller retries.
150
+ return NO_IDENTIFIERS_STORED;
151
+ }
152
+ }