@cartbase/storefront 0.14.0 → 0.15.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 (36) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -1
  3. package/src/api/customers.ts +4 -7
  4. package/src/api/products.ts +4 -0
  5. package/src/cart-drawer/context.tsx +11 -1
  6. package/src/cart-drawer/item/variant.tsx +8 -3
  7. package/src/checkout/checkout-client.tsx +10 -1
  8. package/src/checkout/line-item-card.tsx +5 -2
  9. package/src/checkout/order-summary.tsx +9 -5
  10. package/src/common/country-select.tsx +11 -65
  11. package/src/common/index.ts +2 -0
  12. package/src/common/market-select.tsx +57 -0
  13. package/src/lib/variant-caption.ts +32 -0
  14. package/src/locales/context.ts +37 -0
  15. package/src/locales/provider.tsx +17 -21
  16. package/src/locales/types.ts +24 -21
  17. package/src/order/order-completed-template.tsx +10 -4
  18. package/src/order/order-item.tsx +4 -4
  19. package/src/products/product-template.tsx +11 -9
  20. package/src/products/related-products.tsx +9 -9
  21. package/src/reviews-ui/photo-upload.tsx +2 -1
  22. package/src/reviews-ui/review-list.tsx +4 -2
  23. package/src/reviews-ui/review-widget.tsx +4 -1
  24. package/src/reviews-ui/review-wizard.tsx +3 -2
  25. package/src/store/category-template.tsx +8 -1
  26. package/src/store/collection-template.tsx +8 -1
  27. package/src/store/search-template.tsx +10 -4
  28. package/src/store/store-template.tsx +10 -4
  29. package/src/tracking/chatgpt-pixel.tsx +17 -10
  30. package/src/tracking/consent-init.tsx +62 -44
  31. package/src/tracking/consent.ts +58 -7
  32. package/src/tracking/index.ts +2 -0
  33. package/src/tracking/meta-pixel.tsx +27 -15
  34. package/src/tracking/storefront-tags.tsx +14 -7
  35. package/src/tracking/tiktok-pixel.tsx +15 -7
  36. package/src/tracking/types.ts +5 -2
package/README.md CHANGED
@@ -42,6 +42,7 @@ NEXT_PUBLIC_CARTBASE_URL= # optional: only to point at a local or s
42
42
  | `@cartbase/storefront/store` | Listing pages: pagination, sorting, collection/category/search templates |
43
43
  | `@cartbase/storefront/order` | Order confirmation surfaces |
44
44
  | `@cartbase/storefront/tracking` | Consent banner and Consent Mode v2, every marketing tag the store configured mounted from its own config (Meta Pixel, TikTok, Google Analytics 4 and Google Ads on one tag, Google Tag Manager), and one call per commerce moment that fires all of them |
45
+ | `@cartbase/storefront/locales` | English built in; Spanish and Bulgarian as typed packs. One provider mounts the language for every client component; the server-rendered templates take their area of the pack as a required prop |
45
46
  | `@cartbase/storefront/theme` | The design system: token names plus a filled default set of values (Tailwind 4, CSS-first) |
46
47
 
47
48
  ### Tracking, in three lines
@@ -50,11 +51,16 @@ The merchant configures their pixels in the admin; the storefront mounts
50
51
  what they configured. Nothing per vendor:
51
52
 
52
53
  ```tsx
53
- <ConsentInit /> // first child of <body>, sets the consent defaults
54
- <StorefrontTags client={client} /> // every configured tag, gated by the above
55
- <TrackInit /> // captures UTMs + ad-click ids on the landing page
54
+ <ConsentInit required={consent.enabled} /> // first child of <body>, sets the consent defaults
55
+ <StorefrontTags client={client} /> // every configured tag, gated by the above
56
+ <TrackInit /> // captures UTMs + ad-click ids on the landing page
56
57
  ```
57
58
 
59
+ `consent.enabled` is the store's own switch (`GET /api/store/consent`): a
60
+ store that collects consent starts every visitor denied until they choose
61
+ on the banner; a store with the banner off starts them granted and the
62
+ pixels fire. The prop is required, so a layout cannot leave it out.
63
+
58
64
  Then one call per moment (`trackProductView`, `trackCartAdd`,
59
65
  `trackCheckoutStart`) and `<TrackOrderPurchase>` on the confirmation
60
66
  page. Purchase events dedupe against the platform's own server-side
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -194,6 +194,7 @@
194
194
  "./common/cart-button-client": "./src/common/cart-button-client.tsx",
195
195
  "./common/delete-button": "./src/common/delete-button.tsx",
196
196
  "./common/country-flag": "./src/common/country-flag.tsx",
197
+ "./common/market-select": "./src/common/market-select.tsx",
197
198
  "./common/country-select": "./src/common/country-select.tsx",
198
199
  "./common/language-select": "./src/common/language-select.tsx",
199
200
  "./common/skeleton": "./src/common/skeleton.tsx",
@@ -18,10 +18,9 @@
18
18
  import type { StorefrontClient } from "./http"
19
19
  import type { IsoDateString, PaginationQuery } from "./types"
20
20
 
21
- /** One row of `customer.addresses` (barter_commerce.customer_addresses). */
21
+ /** One row of `customer.addresses` (barter_commerce.customer_addresses), as the store serves it. */
22
22
  export interface CustomerAddress {
23
23
  id: string
24
- client_id: string
25
24
  customer_id: string
26
25
  address_name: string | null
27
26
  first_name: string | null
@@ -39,18 +38,18 @@ export interface CustomerAddress {
39
38
  metadata: Record<string, unknown> | null
40
39
  created_at: IsoDateString
41
40
  updated_at: IsoDateString
42
- deleted_at: IsoDateString | null
43
41
  }
44
42
 
45
43
  /**
46
- * The customer payload (full barter_commerce.customers row + addresses).
44
+ * The customer payload: the customer's own row as the store serves it, plus
45
+ * addresses. Named columns since 2026-09-14: no tenant id, no soft-delete
46
+ * stamp, no staff identity, no provider account id.
47
47
  * `account_status` (`pending` | `approved`) gates B2B storefront content
48
48
  * when the store's approval policy is on (b2b-v1). `tags` are admin-only
49
49
  * labels — readable here, never writable from the store surface.
50
50
  */
51
51
  export interface StoreCustomer {
52
52
  id: string
53
- client_id: string
54
53
  email: string | null
55
54
  first_name: string | null
56
55
  last_name: string | null
@@ -64,10 +63,8 @@ export interface StoreCustomer {
64
63
  account_status: string
65
64
  tags: string[]
66
65
  metadata: Record<string, unknown> | null
67
- created_by: string | null
68
66
  created_at: IsoDateString
69
67
  updated_at: IsoDateString
70
- deleted_at: IsoDateString | null
71
68
  addresses: CustomerAddress[]
72
69
  }
73
70
 
@@ -67,6 +67,8 @@ export interface StoreProductOption {
67
67
  id: string
68
68
  title: string
69
69
  product_id: string
70
+ /** The merchant's order of the options on the product page. */
71
+ position: number
70
72
  metadata: Record<string, unknown> | null
71
73
  created_at: IsoDateString
72
74
  updated_at: IsoDateString
@@ -138,6 +140,8 @@ export interface StoreProduct {
138
140
  collection_id: string | null
139
141
  type_id: string | null
140
142
  external_id: string | null
143
+ /** The brand, as plain text on the product (product-vendor card). */
144
+ vendor: string | null
141
145
  weight: number | null
142
146
  length: number | null
143
147
  height: number | null
@@ -24,6 +24,7 @@ import {
24
24
  import type { StorefrontClient } from "../api/http"
25
25
  import { productItemCount } from "../lib/cart-helpers"
26
26
  import { defaultCartDrawerLabels, type CartDrawerLabels } from "./labels"
27
+ import { useLocaleArea } from "../locales/context"
27
28
 
28
29
  /**
29
30
  * Cart drawer context — open state + cart snapshot + labels + link hrefs.
@@ -313,6 +314,11 @@ export function CartDrawerProvider({
313
314
  onCartChange?: (cart: Cart) => void
314
315
  /** Ops funnel for every failed optimistic mutation (see module JSDoc). */
315
316
  onOptimisticError?: (failure: OptimisticCartError) => void
317
+ /**
318
+ * Overrides. The default is the language mounted by
319
+ * `StorefrontLocaleProvider`, English when none is mounted, so a store
320
+ * that mounts its pack has nothing to hand over here (2026-09-14).
321
+ */
316
322
  labels?: Partial<CartDrawerLabels>
317
323
  hrefs?: Partial<CartDrawerHrefs>
318
324
  children: ReactNode
@@ -536,8 +542,12 @@ export function CartDrawerProvider({
536
542
  }
537
543
  }, [client, serverCart?.id, confirm])
538
544
 
545
+ // The mounted language is the default (2026-09-14): a store that mounts
546
+ // StorefrontLocaleProvider has nothing to hand over here, and a `labels`
547
+ // prop overrides only the keys it names.
548
+ const inherited = useLocaleArea("cartDrawer", defaultCartDrawerLabels)
539
549
  const labels: CartDrawerLabels = {
540
- ...defaultCartDrawerLabels,
550
+ ...inherited,
541
551
  ...labelOverrides,
542
552
  }
543
553
  const hrefs: CartDrawerHrefs = {
@@ -1,8 +1,12 @@
1
1
  "use client"
2
2
 
3
+ import { variantCaption } from "../../lib/variant-caption"
4
+
3
5
  /**
4
6
  * CartItemVariant — renders the variant description (e.g. "Size M / Red")
5
- * under the product title in a cart line.
7
+ * under the product title in a cart line. A single-variant placeholder
8
+ * ("Default", "Default Title", "Default variant") renders nothing
9
+ * (`lib/variant-caption`).
6
10
  *
7
11
  * Ported from `@1click/ui/src/cart-drawer/item/variant.tsx` (v2.3.1).
8
12
  * Cartbase data seam: the source rendered `variant.options[].value` from an
@@ -36,11 +40,12 @@ export function CartItemVariant({ variantTitle, options }: CartItemVariantProps)
36
40
  )
37
41
  }
38
42
 
39
- if (!variantTitle) return null
43
+ const caption = variantCaption(variantTitle)
44
+ if (!caption) return null
40
45
 
41
46
  return (
42
47
  <div className="flex items-center gap-2 flex-wrap">
43
- <span className="text-xs text-muted-foreground">{variantTitle}</span>
48
+ <span className="text-xs text-muted-foreground">{caption}</span>
44
49
  </div>
45
50
  )
46
51
  }
@@ -53,7 +53,16 @@ type CheckoutClientProps = {
53
53
  customer: StoreCustomer | null
54
54
  availableShippingMethods: StoreShippingOption[] | null
55
55
  availablePaymentMethods: PaymentProviderLike[] | null
56
- countryCode: string
56
+ /**
57
+ * The country to PRESELECT when the cart has no shipping address yet.
58
+ * Optional, like the hook's own: the offer comes from the store
59
+ * (`GET /api/store/countries`) and a store with no preselection lets the
60
+ * shopper pick. This stayed `string` when the hook went optional on
61
+ * 2026-09-13, so the reference storefront, which omits it on purpose, did
62
+ * not typecheck, and `next build` refuses a store with a type error
63
+ * (fixed 2026-09-14).
64
+ */
65
+ countryCode?: string
57
66
  /** Countries for the address form (Cartbase regions embed none). */
58
67
  countries?: Array<{ iso_2: string; display_name: string }>
59
68
  paymentMethodFilter?: (
@@ -6,6 +6,7 @@ import type { StorefrontClient } from "../api/http"
6
6
  import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
7
7
  import { Price } from "../lib/price"
8
8
  import { cn } from "../lib/utils"
9
+ import { variantCaption } from "../lib/variant-caption"
9
10
  import { useCheckoutLabels } from "./context"
10
11
 
11
12
  /**
@@ -106,8 +107,10 @@ export function LineItemCard({
106
107
  <p className="text-sm font-semibold text-foreground leading-tight truncate">
107
108
  {item.product_title}
108
109
  </p>
109
- {item.variant_title && item.variant_title !== "Default" && (
110
- <p className="text-xs text-muted-foreground mt-1">{item.variant_title}</p>
110
+ {variantCaption(item.variant_title) && (
111
+ <p className="text-xs text-muted-foreground mt-1">
112
+ {variantCaption(item.variant_title)}
113
+ </p>
111
114
  )}
112
115
 
113
116
  <div className="flex items-center gap-2 mt-2.5">
@@ -7,6 +7,7 @@ import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
7
7
  import { Price } from "../lib/price"
8
8
  import { isProductLine } from "../lib/cart-helpers"
9
9
  import { cn } from "../lib/utils"
10
+ import { variantCaption } from "../lib/variant-caption"
10
11
  import { useCheckoutLabels } from "./context"
11
12
  import { DiscountSection } from "./discount-section"
12
13
  import { GiftCardSection } from "./gift-card-section"
@@ -87,8 +88,12 @@ export function CheckoutLineItem({
87
88
  }
88
89
  }
89
90
 
91
+ // `self-start`: in the desktop column the pill is a flex item, and a flex
92
+ // item stretches to the column's width unless told otherwise. Without it
93
+ // the border ran the full width of the row with the controls huddled at
94
+ // the left (evoo checkout, 2026-09-14).
90
95
  const qtyPill = (
91
- <div className="inline-flex items-center rounded-[2px] border border-border bg-card">
96
+ <div className="inline-flex self-start items-center rounded-[2px] border border-border bg-card">
92
97
  <button
93
98
  type="button"
94
99
  onClick={() => handleQty(item.quantity - 1)}
@@ -133,15 +138,14 @@ export function CheckoutLineItem({
133
138
  </div>
134
139
  )
135
140
 
141
+ const caption = variantCaption(item.variant_title)
136
142
  const titleBlock = (
137
143
  <div className="min-w-0">
138
144
  <p className="text-[15px] font-semibold text-foreground leading-snug break-words">
139
145
  {item.product_title}
140
146
  </p>
141
- {item.variant_title && item.variant_title !== "Default" && (
142
- <p className="text-xs text-muted-foreground mt-1 truncate">
143
- {item.variant_title}
144
- </p>
147
+ {caption && (
148
+ <p className="text-xs text-muted-foreground mt-1 truncate">{caption}</p>
145
149
  )}
146
150
  </div>
147
151
  )
@@ -1,65 +1,11 @@
1
- "use client"
2
-
3
- import { useMemo } from "react"
4
- import type { StoreRegion } from "../api/regions"
5
-
6
- /**
7
- * Region picker for the storefront footer/header. Ported from
8
- * `@1click/ui/src/common/country-select.tsx` (v2.3.1) with a REAL model
9
- * change forced by the SDK (SDK wins over the source):
10
- *
11
- * - Medusa regions embedded a `countries[]` array and the select listed
12
- * countries; Cartbase regions carry NO countries on the store surface
13
- * (divergence documented in `api/regions` — tax scoping is by
14
- * `tax_regions` server-side). The select therefore lists the REGIONS
15
- * themselves (`regions.listRegions`), valued by region id.
16
- * - The source called the `updateRegion(countryCode, path)` server
17
- * action bound to URL `[countryCode]` routing; Cartbase region
18
- * persistence is app-owned (usually `carts.updateCart(client, cartId,
19
- * {region_id})` + a cookie) — so the change surfaces via `onChange`.
20
- */
21
- type CountrySelectProps = {
22
- regions: StoreRegion[]
23
- /** Currently active region id. */
24
- value?: string | null
25
- /** Fired with the selected region; persist it app-side. */
26
- onChange: (region: StoreRegion) => void
27
- className?: string
28
- }
29
-
30
- export function CountrySelect({
31
- regions,
32
- value,
33
- onChange,
34
- className,
35
- }: CountrySelectProps) {
36
- const options = useMemo(
37
- () =>
38
- [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
39
- [regions]
40
- )
41
-
42
- const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
43
- const selected = options.find((r) => r.id === e.target.value)
44
- if (selected) onChange(selected)
45
- }
46
-
47
- return (
48
- <select
49
- value={value || ""}
50
- onChange={handleChange}
51
- className={
52
- className ??
53
- "text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
54
- }
55
- >
56
- {options.map((r) => (
57
- <option key={r.id} value={r.id}>
58
- {r.name}
59
- </option>
60
- ))}
61
- </select>
62
- )
63
- }
64
-
65
- export { type CountrySelectProps }
1
+ /**
2
+ * `CountrySelect` is now `MarketSelect` (`common/market-select`), renamed on
3
+ * 2026-09-14: it lists the store's MARKETS, and a component is named for what
4
+ * it does (store-package card, finding 6; doctrine 14b). This path and these
5
+ * names are kept for ONE release so an existing import keeps compiling, and
6
+ * go with the release after it.
7
+ *
8
+ * @deprecated import `MarketSelect` from `@cartbase/storefront/common/market-select`.
9
+ */
10
+ export { MarketSelect as CountrySelect } from "./market-select"
11
+ export type { MarketSelectProps as CountrySelectProps } from "./market-select"
@@ -9,6 +9,8 @@ export { CartButton } from "./cart-button"
9
9
  export { CartButtonClient } from "./cart-button-client"
10
10
  export { DeleteButton, type DeleteButtonProps } from "./delete-button"
11
11
  export { CountryFlag } from "./country-flag"
12
+ export { MarketSelect, type MarketSelectProps } from "./market-select"
13
+ /** @deprecated the old name of `MarketSelect`, kept for one release (2026-09-14). */
12
14
  export { CountrySelect, type CountrySelectProps } from "./country-select"
13
15
  export {
14
16
  LanguageSelect,
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import type { StoreRegion } from "../api/regions"
5
+
6
+ /**
7
+ * THE MARKET PICKER for a header or footer: the store's regions, by name.
8
+ *
9
+ * Ported from `@1click/ui/src/common/country-select.tsx` (v2.3.1) and
10
+ * renamed on 2026-09-14 (store-package card, finding 6). The port carried the
11
+ * name of a component that listed a region's countries; Cartbase regions
12
+ * carry no countries on the store surface, so it listed the REGIONS
13
+ * themselves and was named for something it did not do. A market is what a
14
+ * shopper picks here; a country is picked in the checkout, from
15
+ * `listCountries`, with `CountryFlag` beside it.
16
+ *
17
+ * Persistence is the app's: `onChange` hands over the chosen region and the
18
+ * app writes it where it keeps it (usually `carts.updateCart(client, cartId,
19
+ * { region_id })` plus a cookie).
20
+ */
21
+ export type MarketSelectProps = {
22
+ regions: StoreRegion[]
23
+ /** The active region's id. */
24
+ value?: string | null
25
+ /** Fired with the chosen region; persist it app-side. */
26
+ onChange: (region: StoreRegion) => void
27
+ className?: string
28
+ }
29
+
30
+ export function MarketSelect({ regions, value, onChange, className }: MarketSelectProps) {
31
+ const options = useMemo(
32
+ () => [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
33
+ [regions]
34
+ )
35
+
36
+ const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
37
+ const selected = options.find((r) => r.id === e.target.value)
38
+ if (selected) onChange(selected)
39
+ }
40
+
41
+ return (
42
+ <select
43
+ value={value || ""}
44
+ onChange={handleChange}
45
+ className={
46
+ className ??
47
+ "text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
48
+ }
49
+ >
50
+ {options.map((r) => (
51
+ <option key={r.id} value={r.id}>
52
+ {r.name}
53
+ </option>
54
+ ))}
55
+ </select>
56
+ )
57
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The variant caption a shopper sees under a product title.
3
+ *
4
+ * A product with one variant has no real variant, only a placeholder name
5
+ * for the row that holds its price: the platform names it "Default", a
6
+ * Shopify import carries "Default Title", Medusa "Default variant". None of
7
+ * them is a fact about the product, so none is shown. Every surface that
8
+ * prints a variant (cart drawer, checkout summary, order page) goes through
9
+ * this one function; a real variant title ("Size M / Red") passes through.
10
+ *
11
+ * Found 2026-09-14 on evoo's checkout summary, where every Shopify-imported
12
+ * product read "Default Title" under its name while three surfaces each
13
+ * tested a different spelling of the placeholder.
14
+ */
15
+
16
+ const PLACEHOLDER_TITLES = new Set(["default", "default title", "default variant"])
17
+
18
+ /** True for an empty title or any spelling of the single-variant placeholder. */
19
+ export function isPlaceholderVariantTitle(
20
+ title: string | null | undefined
21
+ ): boolean {
22
+ if (!title) return true
23
+ return PLACEHOLDER_TITLES.has(title.trim().toLowerCase())
24
+ }
25
+
26
+ /** The caption to render, or null when there is nothing worth showing. */
27
+ export function variantCaption(title: string | null | undefined): string | null {
28
+ if (!title) return null
29
+ const trimmed = title.trim()
30
+ if (!trimmed || isPlaceholderVariantTitle(trimmed)) return null
31
+ return trimmed
32
+ }
@@ -0,0 +1,37 @@
1
+ "use client"
2
+
3
+ import { createContext, useContext } from "react"
4
+ import type { StorefrontLocale } from "./types"
5
+
6
+ /**
7
+ * THE MOUNTED LANGUAGE, as a context. Null outside `StorefrontLocaleProvider`.
8
+ *
9
+ * Its default is null and not `en` on purpose: `en` gathers every area's
10
+ * English, and a client component that needs one area (the cart drawer, a
11
+ * review card) must not bundle the other five to read it. Each reader hands
12
+ * over its own area's English instead, through `useLocaleArea`, so a
13
+ * component rendered with no language mounted behaves exactly as before.
14
+ */
15
+ export const StorefrontLocaleContext = createContext<StorefrontLocale | null>(null)
16
+
17
+ /**
18
+ * One area of the mounted language, for a CLIENT component that renders it.
19
+ *
20
+ * const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
21
+ *
22
+ * The pack the store mounted wins over the area's English; a `labels` prop
23
+ * still wins over both, so a store can override one word without a pack.
24
+ * Since 2026-09-14 this is how the cart drawer and the reviews family are
25
+ * localized: mount the provider once and there is nothing left to forget.
26
+ *
27
+ * A SERVER component cannot call this, because a server component has no
28
+ * context. That is why the store, product and order templates take their
29
+ * pack as a REQUIRED prop instead (`PropLabelAreas` in ./types).
30
+ */
31
+ export function useLocaleArea<K extends keyof StorefrontLocale>(
32
+ area: K,
33
+ english: StorefrontLocale[K]
34
+ ): StorefrontLocale[K] {
35
+ const locale = useContext(StorefrontLocaleContext)
36
+ return locale ? locale[area] : english
37
+ }
@@ -1,35 +1,31 @@
1
1
  "use client"
2
2
 
3
- import { createContext, useContext, type ReactNode } from "react"
3
+ import { useContext, type ReactNode } from "react"
4
4
  import { CheckoutProvider } from "../checkout/context"
5
5
  import { OrderLabelsProvider } from "../order/context"
6
6
  import { ProductLabelsProvider } from "../products/context"
7
+ import { StorefrontLocaleContext } from "./context"
7
8
  import type { StorefrontLocale } from "./types"
8
9
  import { en } from "./en"
9
10
 
10
- const StorefrontLocaleContext = createContext<StorefrontLocale>(en)
11
-
12
11
  /**
13
12
  * ONE PROVIDER FOR THE STORE'S LANGUAGE.
14
13
  *
15
14
  * import { es } from "@cartbase/storefront/locales/es"
16
15
  * <StorefrontLocaleProvider locale={es}>{children}</StorefrontLocaleProvider>
17
16
  *
18
- * It mounts the three pure label contexts (products, checkout, order) so
19
- * every component inside them is localized with no further wiring.
20
- *
21
- * IT DOES NOT MOUNT THE CART DRAWER, and that is deliberate rather than an
22
- * omission: `CartDrawerProvider` carries the cart's state, its client and
23
- * its persistence callbacks, so it belongs where the app owns that state,
24
- * not inside a language wrapper. Hand it the pack instead:
25
- *
26
- * const locale = useStorefrontLocale()
27
- * <CartDrawerProvider cart={cart} labels={locale.cartDrawer}>
28
- *
29
- * Same for the two areas that read labels from a prop:
17
+ * Every CLIENT component inside it speaks the pack, and nothing else has to
18
+ * be wired: it mounts the three label contexts (products, checkout, order),
19
+ * and the cart drawer and the reviews family read the language from it
20
+ * directly (`useLocaleArea`, since 2026-09-14). It does not MOUNT the cart
21
+ * drawer, because that provider carries the cart's state, its client and its
22
+ * persistence callbacks, which belong where the app owns that state; the
23
+ * drawer only reads the language from here.
30
24
  *
31
- * <StoreTemplate labels={locale.store} />
32
- * <ReviewWidget labels={locale.reviews} />
25
+ * What it cannot reach is a SERVER component, because a server component has
26
+ * no context. The store, product and order templates render on the server,
27
+ * so each page hands them their area of the pack as a REQUIRED prop:
28
+ * `PropLabelAreas` in ./types names them, with the mounts written out.
33
29
  *
34
30
  * DEFAULT IS ENGLISH. A component rendered outside this provider reads the
35
31
  * library's own English defaults, so nothing renders blank or crashes for
@@ -54,10 +50,10 @@ export function StorefrontLocaleProvider({
54
50
  }
55
51
 
56
52
  /**
57
- * The whole locale, for the packs this provider cannot mount (the cart
58
- * drawer's, the store template's and the reviews widget's) and for the
59
- * code that needs the language itself, e.g. `<html lang={locale.code}>`.
53
+ * The whole mounted locale, English when none is mounted: for the code that
54
+ * needs the language itself, e.g. `<html lang={locale.code}>`, and for a
55
+ * client shell that hands a server template its pack.
60
56
  */
61
57
  export function useStorefrontLocale(): StorefrontLocale {
62
- return useContext(StorefrontLocaleContext)
58
+ return useContext(StorefrontLocaleContext) ?? en
63
59
  }
@@ -40,35 +40,38 @@ export interface StorefrontLocale {
40
40
  }
41
41
 
42
42
  /**
43
- * THE AREAS WHOSE PACK THE APP MUST HAND OVER AS A PROP.
43
+ * THE AREAS WHOSE PACK THE APP MUST HAND OVER AS A PROP, and the prop is
44
+ * REQUIRED.
44
45
  *
45
46
  * Not a style choice and not a gap in this layer: a React SERVER component
46
47
  * cannot read a client context, and these areas are rendered on the server.
47
48
  * `StorefrontLocaleProvider` therefore reaches the client components and
48
- * nothing else, and a server-rendered heading stays English however many
49
- * providers are mounted above it.
49
+ * nothing else, and a server-rendered heading would stay English however
50
+ * many providers were mounted above it.
50
51
  *
51
- * Corrected 2026-09-13: this used to list `store` and `reviews` only, and
52
- * Alexander found the consequence on evoo, a Bulgarian store whose product
53
- * page ended in the English "Related products / You might also want to
54
- * check out these products". `products` and `order` belong here too, and
55
- * `ProductTemplate` was not even passing the pack down to its own
56
- * related-products strip, which made that strip untranslatable without
57
- * ejecting a file.
52
+ * Since 2026-09-14 the page-level templates of these areas REQUIRE the pack
53
+ * (`labels: StoreLabels`, never `labels?`), so a page that forgets it fails
54
+ * to compile instead of shipping silent English. That was the defect: on
55
+ * 2026-09-13 evoo, a Bulgarian store, ended a product page in the English
56
+ * "Related products / You might also want to check out these products", and
57
+ * its search page and order confirmation stayed English, with no error, no
58
+ * warning and no failing test. English is not a special case: an English
59
+ * store passes `en.store`. The scaffold declares `STORE_LOCALE` once in its
60
+ * config and every page reads its area from it.
58
61
  *
59
62
  * Hand the pack to each page-level template once and everything inside it
60
63
  * follows:
61
64
  *
62
- * <ProductTemplate labels={locale.products} /> // + the related strip
63
- * <StoreTemplate labels={locale.store} />
64
- * <SearchTemplate labels={locale.store} />
65
- * <OrderCompletedTemplate labels={locale.order} />
66
- * <ReviewWidget labels={locale.reviews} />
65
+ * <StoreTemplate labels={STORE_LOCALE.store} />
66
+ * <SearchTemplate labels={STORE_LOCALE.store} />
67
+ * <CollectionTemplate labels={STORE_LOCALE.store} />
68
+ * <CategoryTemplate labels={STORE_LOCALE.store} />
69
+ * <ProductTemplate labels={STORE_LOCALE.products} /> // + the related strip
70
+ * <OrderCompletedTemplate labels={STORE_LOCALE.order} />
67
71
  *
68
- * The cart drawer is the same case for a different reason: it owns the
69
- * cart's state, so the app mounts it and hands it `locale.cartDrawer`.
72
+ * The cart drawer and the reviews family were listed here until 2026-09-14.
73
+ * They are client components, so they now read the mounted language
74
+ * themselves (`useLocaleArea` in ./context); their `labels` prop is an
75
+ * override, never a requirement.
70
76
  */
71
- export type PropLabelAreas = Pick<
72
- StorefrontLocale,
73
- "products" | "store" | "order" | "reviews" | "cartDrawer"
74
- >
77
+ export type PropLabelAreas = Pick<StorefrontLocale, "products" | "store" | "order">
@@ -1,7 +1,7 @@
1
1
  import Link from "next/link"
2
2
  import { ChevronLeft } from "lucide-react"
3
3
  import type { StoreOrderDetail, StoreOrderItem } from "../api/orders"
4
- import { defaultOrderLabels, type OrderLabels } from "./labels"
4
+ import type { OrderLabels } from "./labels"
5
5
  import {
6
6
  OrderConfirmationHeader,
7
7
  type OrderHeaderData,
@@ -67,7 +67,14 @@ type OrderCompletedTemplateProps = {
67
67
  /** The merchant method's name — wins over any provider bucket. */
68
68
  paymentMethodName?: string | null
69
69
  cardLast4?: string | null
70
- labels?: OrderLabels
70
+ /**
71
+ * The store's order pack, REQUIRED: this template renders on the server,
72
+ * where `StorefrontLocaleProvider` cannot reach, so the page passes
73
+ * `STORE_LOCALE.order` (`en.order` for an English store). Required since
74
+ * 2026-09-14 so that forgetting it fails the build instead of shipping
75
+ * silent English, which is how evoo's confirmation page stayed English.
76
+ */
77
+ labels: OrderLabels
71
78
  locale?: string
72
79
  contactHref?: string
73
80
  returnsHref?: string
@@ -90,14 +97,13 @@ export function OrderCompletedTemplate({
90
97
  paymentProviderId,
91
98
  paymentMethodName,
92
99
  cardLast4,
93
- labels,
100
+ labels: l,
94
101
  locale,
95
102
  contactHref,
96
103
  returnsHref,
97
104
  storeHref = "/store",
98
105
  methodFeeLabel,
99
106
  }: OrderCompletedTemplateProps) {
100
- const l = { ...defaultOrderLabels, ...labels }
101
107
  const displayItems =
102
108
  items ?? (order.items ?? []).map(displayItemFromOrderItem)
103
109