create-cartbase 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,3 +19,7 @@ app at a local or staging platform; without it the app talks to the
19
19
  platform. Deploy with the
20
20
  [`cartbase`](https://www.npmjs.com/package/cartbase) CLI: `cartbase login`,
21
21
  then `cartbase deploy`.
22
+
23
+ Run it in a folder of its own. Inside an existing project, Next.js takes
24
+ that project's folder as the workspace root; the scaffold notices and
25
+ prints the one line to add to `next.config.ts` if that is what you meant.
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * WHERE THE SCAFFOLD LANDS MATTERS TO NEXT.JS, so the scaffold says so.
6
+ *
7
+ * Next infers the workspace root from the lockfiles it finds walking up from
8
+ * the app (`next/dist/lib/find-root.js`, Next 16.2.4): the highest lockfile
9
+ * wins, the walk stops at the Git repository that contains the app, and a
10
+ * lockfile at or above the home directory never counts. A store scaffolded
11
+ * INSIDE another project therefore gets that project's folder as its root,
12
+ * and once the store has its own lockfile Next warns on every start that it
13
+ * "inferred your workspace root". The scaffold cannot know whether the
14
+ * merchant meant a workspace member or a stray folder, so it names the
15
+ * situation and both ways out at the moment they can still choose
16
+ * (store-package card, finding 4).
17
+ */
18
+ /** The lockfiles Next.js treats as workspace-root markers, its own list. */
19
+ export const ROOT_MARKERS = [
20
+ "pnpm-lock.yaml",
21
+ "package-lock.json",
22
+ "yarn.lock",
23
+ "bun.lock",
24
+ "bun.lockb",
25
+ ];
26
+ /** Is `dir` `of` itself, or one of its ancestors? */
27
+ function isAncestorOrSelf(dir, of) {
28
+ const rel = path.relative(dir, of);
29
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
30
+ }
31
+ /** The nearest folder, from `start` upward, that is a Git repository or worktree. */
32
+ function gitBoundary(start) {
33
+ let current = start;
34
+ for (;;) {
35
+ if (fs.existsSync(path.join(current, ".git")))
36
+ return current;
37
+ const parent = path.dirname(current);
38
+ if (parent === current)
39
+ return null;
40
+ current = parent;
41
+ }
42
+ }
43
+ /**
44
+ * The project the target folder would sit inside, by Next's own rule, or
45
+ * null when Next would take the app's own folder as the root. The target
46
+ * itself need not exist yet: the scaffold asks before it creates it.
47
+ */
48
+ export function findEnclosingProject(target, options = {}) {
49
+ const home = options.homeDir === undefined ? os.homedir() : options.homeDir;
50
+ const parent = path.dirname(path.resolve(target));
51
+ const boundary = gitBoundary(parent);
52
+ let current = parent;
53
+ for (;;) {
54
+ // Never the home directory or above: Next ignores a marker there.
55
+ if (home && isAncestorOrSelf(current, home))
56
+ return null;
57
+ // Never above the repository that contains the app.
58
+ if (boundary && current !== boundary && isAncestorOrSelf(current, boundary))
59
+ return null;
60
+ for (const marker of ROOT_MARKERS) {
61
+ if (fs.existsSync(path.join(current, marker)))
62
+ return { dir: current, marker };
63
+ }
64
+ const next = path.dirname(current);
65
+ if (next === current)
66
+ return null;
67
+ current = next;
68
+ }
69
+ }
70
+ /** What the scaffold prints when the folder sits inside another project. */
71
+ export function enclosingProjectNote(target, found) {
72
+ const app = path.basename(path.resolve(target));
73
+ return [
74
+ `Note: ${app}/ sits inside another project (${found.marker} in ${found.dir}).`,
75
+ `Next.js takes that folder as the workspace root and, once ${app}/ has its own`,
76
+ `lockfile, warns on every start that it inferred the root. Either it is a`,
77
+ `member of that workspace, so name the root in next.config.ts:`,
78
+ ` turbopack: { root: ${JSON.stringify(found.dir)} }`,
79
+ `or it is a project of its own, so create it outside that folder, or run`,
80
+ `git init inside it, which is where Next stops looking.`,
81
+ ].join("\n");
82
+ }
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { enclosingProjectNote, findEnclosingProject } from "./enclosing-project.js";
5
6
  function parseArgs(argv) {
6
7
  const flags = new Map();
7
8
  let dir;
@@ -82,6 +83,9 @@ Given as a flag it is written into .env.local; otherwise a placeholder is.
82
83
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
83
84
  const key = flag(flags, "key");
84
85
  fs.writeFileSync(path.join(target, ".env.local"), envFile({ key, url: flag(flags, "url"), clientId: flag(flags, "client-id") }));
86
+ // A store scaffolded inside another project inherits that project's
87
+ // workspace root in Next's eyes; said once, here, while it is cheap to move.
88
+ const enclosing = findEnclosingProject(target);
85
89
  console.log(`
86
90
  Created ${path.basename(target)}/
87
91
 
@@ -97,6 +101,6 @@ Next steps:
97
101
  The complete storefront reference is in docs/ — hand CLAUDE.md (or
98
102
  AGENTS.md) to your coding agent and it has everything. Deploy with the
99
103
  Cartbase CLI: cartbase login, then cartbase deploy.
100
- `);
104
+ ${enclosing ? `\n${enclosingProjectNote(target, enclosing)}\n` : ""}`);
101
105
  }
102
106
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cartbase",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Scaffold a Cartbase storefront: npm create cartbase my-store",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -118,10 +118,14 @@ Fetch once at layout level, cache per the docs' cache headers:
118
118
  Order inside `<body>` matters (contracts in [components.md](components.md)
119
119
  and [consent.md](consent.md)):
120
120
 
121
- 1. `<ConsentInit>` FIRST child of body — static and synchronous, never
122
- awaits a fetch (first-hit consent race otherwise).
123
- 2. Tracking mounts (`<MetaPixel>`, `<GA4>`, GTM) gated on the consent
124
- state and fed by the integrations tracking block, never by env vars.
121
+ 1. `<ConsentInit required={consent.enabled}>` FIRST child of body —
122
+ synchronous, never awaits a fetch in the browser (first-hit consent race
123
+ otherwise); the layout resolves the consent config on the server and
124
+ passes the switch. A store that collects consent starts visitors denied
125
+ until they choose; a store with the banner off starts them granted.
126
+ 2. `<StorefrontTags client={client}>` — every tag the store configured,
127
+ fed by the integrations tracking block, never by env vars, each gated by
128
+ the same switch (`tracking.consent_required`).
125
129
  3. Navigation from [menus.md](menus.md) — `main-menu` / `footer` handles;
126
130
  an unknown handle 404s and must render as "no nav", never crash.
127
131
 
@@ -43,17 +43,26 @@ Shipping: `locales/en`, `locales/es`, `locales/bg`. Each is typed
43
43
  pack at compile time instead of falling back to English mid-checkout.
44
44
 
45
45
  The provider mounts the three pure label contexts (products, checkout,
46
- order). It deliberately does NOT mount the cart drawer, whose provider
47
- carries cart state, and two families read labels from a prop. Hand those
48
- their pack:
46
+ order), and the cart drawer and the reviews family read the language from
47
+ it directly, so every CLIENT component inside it speaks the pack with no
48
+ further wiring. What it cannot reach is a SERVER component: the store,
49
+ product and order templates render on the server, so each page hands them
50
+ their area of the pack, and that prop is REQUIRED. A page that forgets it
51
+ does not compile; `PropLabelAreas` in `locales` names the areas.
49
52
 
50
53
  ```tsx
51
- const locale = useStorefrontLocale()
52
- <CartDrawerProvider cart={cart} labels={locale.cartDrawer}>
53
- <StoreTemplate labels={locale.store} />
54
- <ReviewWidget labels={locale.reviews} />
54
+ <StoreTemplate labels={STORE_LOCALE.store} />
55
+ <SearchTemplate labels={STORE_LOCALE.store} />
56
+ <CollectionTemplate labels={STORE_LOCALE.store} />
57
+ <CategoryTemplate labels={STORE_LOCALE.store} />
58
+ <ProductTemplate labels={STORE_LOCALE.products} />
59
+ <OrderCompletedTemplate labels={STORE_LOCALE.order} />
55
60
  ```
56
61
 
62
+ `STORE_LOCALE` is the pack the store declared once in its config (`en`, or
63
+ `bg`, or `es`); the scaffold ships it that way. English is not a special
64
+ case: an English store passes `en.store`.
65
+
57
66
  **Failure copy is copy.** What a shopper reads when an address, a payment, a
58
67
  gift card or a discount code fails lives in the checkout pack too
59
68
  (`addressErrors`, `paymentErrors`, `giftCardErrors`, `promotionErrors`).
@@ -72,17 +81,23 @@ GA4 Measurement Protocol sending is Cartbase-backend-owned** (the
72
81
  unconditionally — every component renders nothing when its id prop is absent,
73
82
  every helper no-ops outside the browser.
74
83
 
75
- ### `<ConsentInit />` — `tracking/consent-init`
76
-
77
- - **Purpose** — the synchronous Consent Mode v2 "default" snippet: reads the
78
- `_1c_consent` cookie and sets gtag's consent default (denied when no stored
79
- choice) before any Google tag loads.
80
- - **SDK calls** none. It must NEVER wait on a fetch: the per-store config
81
- drives the banner, not this default (async default = first-hit consent
82
- race).
84
+ ### `<ConsentInit required />` — `tracking/consent-init`
85
+
86
+ - **Purpose** — the synchronous Consent Mode v2 "default" snippet: sets
87
+ gtag's consent default before any Google tag loads. `required` is the
88
+ store's consent switch (`consent.enabled` from `GET /api/store/consent`).
89
+ With it true a new visitor starts DENIED until they choose on the banner;
90
+ with it false the store collects no consent and a new visitor starts
91
+ GRANTED. A choice stored in `_1c_consent` wins either way.
92
+ - **SDK calls** — none in the browser. The layout resolves the consent
93
+ config on the server (it fetches it for the banner anyway) and passes the
94
+ switch; the script must NEVER wait on a fetch (async default = first-hit
95
+ consent race).
83
96
  - **Mount rules** — FIRST child of `<body>` in the root layout, before any
84
- tag component. Plain inline `<script>` by design (not next/script).
85
- - **Settings** none (static; the shared cookie name is baked in).
97
+ tag component. Plain inline `<script>` by design (not next/script). The
98
+ prop is REQUIRED: neither default is safe, so a layout must say what the
99
+ store does, and one that does not fails to compile (2026-09-14).
100
+ - **Settings** — the consent card's `enabled` (admin → Settings → Consent).
86
101
 
87
102
  ### `<ConsentBanner copy layout privacyHref rejectOnFirstLayer />` — `tracking/consent-banner`
88
103
 
@@ -103,17 +118,22 @@ every helper no-ops outside the browser.
103
118
  - Also ships `<ConsentSettingsLink>` (footer link that re-opens the settings
104
119
  layer — "withdraw as easily as given") and the pure `<ConsentBannerCard>`.
105
120
 
106
- ### `<MetaPixel pixelId />` — `tracking/meta-pixel`
121
+ ### `<MetaPixel pixelId consentRequired />` — `tracking/meta-pixel`
107
122
 
108
- - **Purpose** — injects fbevents.js, pushes the consent state from
109
- `_1c_consent` BEFORE `fbq('init')` (pre-init revoke = Pixel queues events
110
- until grant), fires the initial PageView.
123
+ - **Purpose** — injects fbevents.js, pushes the consent state BEFORE
124
+ `fbq('init')` (pre-init revoke = Pixel queues events until grant), fires
125
+ the initial PageView. The state is the store's default overridden by the
126
+ visitor's stored `_1c_consent` choice: revoked on a store that collects
127
+ consent, granted on a store with the banner off.
111
128
  - **SDK calls** — `getTrackingConfig(client)` (`tracking/get-tracking-config`,
112
- wraps `GET /api/store/integrations` → `tracking.facebookPixel.pixelId`).
113
- - **Mount rules** — root layout, after `<ConsentInit>`. Renders nothing when
114
- `pixelId` is falsy. When `tracking.consent_required` is true the consent
115
- gate above is mandatory (it is wired in by construction the snippet
116
- always reads the cookie).
129
+ wraps `GET /api/store/integrations` → `tracking.facebookPixel.pixelId` and
130
+ `tracking.consent_required`).
131
+ - **Mount rules** root layout, after `<ConsentInit>`; `<StorefrontTags>`
132
+ mounts it with both props from the block. Renders nothing when `pixelId`
133
+ is falsy. `consentRequired` is REQUIRED for the reason `<ConsentInit>`
134
+ gives, and so it is on `<TikTokPixel>` and `<ChatGptPixel>`, the two
135
+ other tags that gate their own SDK (2026-09-14). Google needs no prop: it
136
+ reads the `<ConsentInit>` default.
117
137
  - **Settings** — admin Integrations hub `facebook_capi` row (`enabled` +
118
138
  `credentials.pixel_id`); consent card `enabled` → `consent_required`.
119
139
  - Companion: `updatePixelAdvancedMatching(visitor)` — call from checkout /
@@ -590,7 +610,8 @@ Domain doc for every call: [carts.md](carts.md); gift-card tender:
590
610
  change INCLUDING first-add cart creation — persist `cart.id` here),
591
611
  `onOptimisticError?(failure)` (the ops funnel for every failed
592
612
  optimistic mutation — wire to store logging; replaces the source's
593
- `logEvent` backend call), `labels?: Partial<CartDrawerLabels>`,
613
+ `logEvent` backend call), `labels?: Partial<CartDrawerLabels>` (overrides:
614
+ the language mounted by `StorefrontLocaleProvider` is the default),
594
615
  `hrefs?: {checkout, browse, productPrefix}`.
595
616
  - **Hook** — `useCartDrawer()` → `{isOpen, open, close, toggle, cart,
596
617
  addItem(variantId, qty?, display?), updateQuantity(lineId, qty),
@@ -601,9 +622,10 @@ Domain doc for every call: [carts.md](carts.md); gift-card tender:
601
622
  B2B price lists via attached customer, gift-card product flags
602
623
  (`is_giftcard` lines are non-discountable), automatic promotions,
603
624
  COD-fee integration (injects the fee line the drawer hides).
604
- - **Mount rules** — wrap the root layout; ONE provider per app. i18n via
605
- `labels` (`cart-drawer/labels` holds the English defaults; pass
606
- `locale.cartDrawer` from a pack in `locales/*` for another language).
625
+ - **Mount rules** — wrap the root layout; ONE provider per app. i18n: mount
626
+ `StorefrontLocaleProvider` above it and the drawer speaks the pack with
627
+ nothing handed over (since 2026-09-14); `labels` overrides single keys
628
+ (`cart-drawer/labels` holds the English defaults).
607
629
 
608
630
  ### `<CartDrawer sidebar children />` — `cart-drawer/cart-drawer`
609
631
 
@@ -831,12 +853,15 @@ with a legacy flat-row fallback. Unit-tested
831
853
  - **SDK calls** — none; expects a `StoreProduct` fetched with pricing
832
854
  context for the price line. Server-safe.
833
855
 
834
- ### `<RelatedProducts client product pricingContext? limit? labels? renderProduct? />` — `products/related-products`
856
+ ### `<RelatedProducts client product labels pricingContext? limit? renderProduct? />` — `products/related-products`
835
857
 
836
858
  - **Purpose** — the "You might also like" strip.
837
859
  - **SDK calls** — `api/search` `listRelatedProducts(product.id)` — manual
838
860
  admin picks first, deterministic fallback fills to `limit`
839
861
  (`auto_filled`); anchor never appears. Renders nothing on empty/404.
862
+ - **Labels** — `labels: ProductLabels`, REQUIRED: a server component cannot
863
+ read the provider, so pass `STORE_LOCALE.products`; `ProductTemplate`
864
+ passes its own through.
840
865
  - **Settings** — Admin → Product → Related (manual picks), price lists.
841
866
  - **Mount rules** — async server component; render inside `<Suspense>`.
842
867
 
@@ -850,7 +875,7 @@ with a legacy flat-row fallback. Unit-tested
850
875
  - **Mount rules** — async server component inside `<Suspense>` (fallback:
851
876
  disabled `<ProductActions>`).
852
877
 
853
- ### `<ProductTemplate client product pricingContext? addToCart onAddToCart? openCart? />` — `products/product-template`
878
+ ### `<ProductTemplate client product labels addToCart pricingContext? onAddToCart? openCart? promises? hideSpecs? sections? />` — `products/product-template`
854
879
 
855
880
  - **Purpose** — the full PDP: sticky info column (`ProductInfo` +
856
881
  `ProductTabs`), gallery, sticky actions column (suspended
@@ -858,8 +883,10 @@ with a legacy flat-row fallback. Unit-tested
858
883
  - **SDK calls** — via children (retrieveProduct, listRelatedProducts). The
859
884
  page fetches the product by handle (`retrieveProduct`) and passes it in.
860
885
  - **Settings** — union of children's.
861
- - **Mount rules** — server component; wrap the page in
862
- `ProductLabelsProvider` for i18n; `addToCart`/`openCart` seams as on
886
+ - **Mount rules** — server component; `labels` is REQUIRED (the pack's
887
+ `products` area) because the related strip renders on the server; the
888
+ client parts inside read `ProductLabelsProvider`, which
889
+ `StorefrontLocaleProvider` mounts; `addToCart`/`openCart` seams as on
863
890
  `ProductActions`.
864
891
 
865
892
  ## Family: store (`@cartbase/storefront/store/*`)
@@ -871,8 +898,9 @@ column) over the legacy 100-item window.
871
898
 
872
899
  **Labels / i18n** — `store/labels` (`StoreLabels` + defaults + the pure
873
900
  `sortOptionLabelKeys` map — completeness unit-tested) and `locale.store`
874
- from a pack. Templates take `labels?: Partial<StoreLabels>` props
875
- (no context in this family, matching the production original).
901
+ from a pack. Templates take `labels: StoreLabels`, REQUIRED: they are
902
+ server components and cannot read the provider, so a page hands them
903
+ `STORE_LOCALE.store` (`en.store` for an English store).
876
904
 
877
905
  ### `<Pagination page totalPages />` — `store/pagination`
878
906
 
@@ -909,14 +937,14 @@ from a pack. Templates take `labels?: Partial<StoreLabels>` props
909
937
 
910
938
  - **Purpose** — pulse skeleton for any product grid. Server-safe, no calls.
911
939
 
912
- ### `<StoreTemplate client sortBy? page? pricingContext? labels? renderProduct? />` — `store/store-template`
940
+ ### `<StoreTemplate client labels sortBy? page? pricingContext? renderProduct? />` — `store/store-template`
913
941
 
914
942
  - **Purpose** — the `/store` all-products page: sort sidebar + heading +
915
943
  suspended `PaginatedProducts`.
916
944
  - **SDK calls** — via `PaginatedProducts`. Pass the page's `sortBy`/`page`
917
945
  query params straight in.
918
946
 
919
- ### `<CollectionTemplate client collection sortBy? page? pricingContext? labels? renderProduct? />` — `store/collection-template`
947
+ ### `<CollectionTemplate client collection labels sortBy? page? pricingContext? renderProduct? />` — `store/collection-template`
920
948
 
921
949
  - **Purpose** — collection page over the MEMBERSHIP listing (multi-
922
950
  collection products appear in every collection).
@@ -930,7 +958,7 @@ from a pack. Templates take `labels?: Partial<StoreLabels>` props
930
958
  conditions, channel links (scoped-away collection 404s), price lists.
931
959
  - **Mount rules** — server component; grid suspends internally.
932
960
 
933
- ### `<CategoryTemplate client category sortBy? page? pricingContext? labels? renderProduct? />` — `store/category-template`
961
+ ### `<CategoryTemplate client category labels sortBy? page? pricingContext? renderProduct? />` — `store/category-template`
934
962
 
935
963
  - **Purpose** — category page: breadcrumbs (ancestor chain), description,
936
964
  child-category links, `PaginatedProducts` filtered by `category_id`.
@@ -940,7 +968,7 @@ from a pack. Templates take `labels?: Partial<StoreLabels>` props
940
968
  children sections don't render.
941
969
  - **Settings** — category tree (active/internal flags are server-filtered).
942
970
 
943
- ### `<SearchTemplate client searchParams basePath? pricingContext? limit? labels? renderProduct? />` — `store/search-template`
971
+ ### `<SearchTemplate client searchParams labels basePath? pricingContext? limit? renderProduct? />` — `store/search-template`
944
972
 
945
973
  - **Purpose** — the search results page in the
946
974
  store-template idiom — GET query box, facet sidebar from the response
@@ -981,9 +1009,11 @@ selector.
981
1009
  Labels: `OrderLabelsProvider`/`useOrderLabels` (`order/context`) +
982
1010
  `defaultOrderLabels` (`order/labels`, English) + `locale.order` from a
983
1011
  pack in `locales/*` for any other language. Every
984
- component also takes a `labels` prop pick.
1012
+ component also takes a `labels` prop pick, and the template's is REQUIRED
1013
+ (`labels: OrderLabels`): it renders on the server, where no provider
1014
+ reaches, so the page passes `STORE_LOCALE.order`.
985
1015
 
986
- ### `<OrderCompletedTemplate order totals items? shippingMethod? paymentProviderId? cardLast4? … />` — `order/order-completed-template`
1016
+ ### `<OrderCompletedTemplate order totals labels items? shippingMethod? paymentProviderId? cardLast4? … />` — `order/order-completed-template`
987
1017
 
988
1018
  - **Purpose** — the full confirmation page: hero header, fulfillment
989
1019
  timeline, items+totals card, contact/delivery/payment/help cards,
@@ -1109,14 +1139,17 @@ Shared storefront chrome, production-proven.
1109
1139
  `api/carts.deleteLineItem(client, cartId, id)` (idempotent) and hands
1110
1140
  the refreshed `{cart}` to `onDeleted`; spinner resets on failure.
1111
1141
 
1112
- ### `<CountrySelect regions value? onChange />` — `common/country-select`
1113
-
1114
- - **Purpose** — the market picker for a header or footer. It lists REGIONS
1115
- (`api/regions.listRegions`), valued by region id; persistence is app-owned
1116
- via `onChange` (usually `carts.updateCart(client, cartId, {region_id})` +
1117
- a cookie). The name is a leftover from the ported source and is wrong for
1118
- what it does: it selects a market, not a country. Use
1119
- `listCountries` + `CountryFlag` when you want a country.
1142
+ ### `<MarketSelect regions value? onChange />` — `common/market-select`
1143
+
1144
+ - **Purpose** — the market picker for a header or footer: the store's
1145
+ REGIONS (`api/regions.listRegions`) by name, valued by region id;
1146
+ persistence is app-owned via `onChange` (usually
1147
+ `carts.updateCart(client, cartId, {region_id})` + a cookie). A country is
1148
+ a different thing: the checkout picks one from `listCountries`, with
1149
+ `CountryFlag` beside it.
1150
+ - **Was `CountrySelect`** (`common/country-select`) until 2026-09-14, a name
1151
+ the port carried for a component that never listed countries here. The
1152
+ old path and names still import for one release and then go.
1120
1153
  - **Settings** — Admin → Settings → Markets → Regions: which rows exist.
1121
1154
 
1122
1155
  ### `<CountryFlag code title? className? />` — `common/country-flag`
@@ -1154,10 +1187,12 @@ Shared storefront chrome, production-proven.
1154
1187
  Verified-purchase review components, ported from a production storefront,
1155
1188
  over `api/reviews`. ONE barrel export seam: everything imports from
1156
1189
  `@cartbase/storefront/reviews-ui`. Endpoint truth:
1157
- [reviews.md](reviews.md). Labels: `defaultReviewsUiLabels` +
1158
- `locale.reviews` from a pack in `locales/*` (English by default,
1159
- parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots
1160
- resolve via `formatLabel`).
1190
+ [reviews.md](reviews.md). Labels: the family reads the language mounted by
1191
+ `StorefrontLocaleProvider` on its own (English with none, since
1192
+ 2026-09-14), `labels?` on any component overrides keys, and `StarBadge`,
1193
+ the one server-safe piece, takes its labels as a prop. Strings are
1194
+ parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots
1195
+ (resolve via `formatLabel`).
1161
1196
 
1162
1197
  ### `<ReviewWidget client productId initialData? />` — the PDP section
1163
1198
 
@@ -7,10 +7,18 @@ the payload is complete and renderable even for an unconfigured store
7
7
 
8
8
  ## Storefront wiring (the trap that matters)
9
9
 
10
- - Mount `<ConsentInit>` as the **first child of `<body>`** — it sets the
11
- synchronous Consent Mode v2 DEFAULT and must **never wait on this fetch**
12
- (async default = first-hit consent race). Resolve this config server-side
13
- (RSC) and inline it into the document.
10
+ - Mount `<ConsentInit required={consent.enabled}>` as the **first child of
11
+ `<body>`** — it sets the synchronous Consent Mode v2 DEFAULT and must
12
+ **never wait on this fetch in the browser** (async default = first-hit
13
+ consent race). Resolve this config server-side (RSC) and inline the
14
+ setting into the document; the prop is required, a layout without it
15
+ does not compile.
16
+ - **The switch decides the default; a stored choice wins.** `enabled: true`
17
+ is a store that collects consent: every visitor starts DENIED until they
18
+ choose on the banner. `enabled: false` is a store with no consent gate:
19
+ every visitor starts GRANTED, and every configured pixel fires. Either
20
+ way a choice already in the `_1c_consent` cookie is what counts, so a
21
+ visitor who declined keeps that decision if the banner is switched off.
14
22
  - Render the built-in banner only when `enabled && mode === "builtin"`.
15
23
  - `mode: "external"` = the merchant's CMP owns the UI and must write the
16
24
  same `_1c_consent` cookie (or call `setConsent()`) — all Cartbase-side tag
@@ -18,8 +26,9 @@ the payload is complete and renderable even for an unconfigured store
18
26
  - Choices persist 12 months in the cookie. Rybbit (platform analytics)
19
27
  stays outside consent by design.
20
28
  - Pair with [integrations.md](integrations.md): `tracking.consent_required`
21
- mirrors `enabled` here tags mount only through the consent gate when
22
- true.
29
+ mirrors `enabled` here, and `<StorefrontTags>` hands it to every pixel
30
+ that gates its own SDK (Meta, TikTok, ChatGPT). Google reads the
31
+ `<ConsentInit>` default.
23
32
 
24
33
  ## GET /api/store/consent — the CMP config
25
34
 
@@ -58,8 +67,9 @@ the payload is complete and renderable even for an unconfigured store
58
67
  succeeds (a corrupt/missing stored config degrades to defaults, never to
59
68
  a broken banner).
60
69
  - **SDK**: `consent.getConsent(client)`
61
- - **Components**: `<ConsentInit>` + `<ConsentBanner copy={copy[locale]}
62
- layout privacyHref rejectOnFirstLayer>` (consent family; reference impl
70
+ - **Components**: `<ConsentInit required={consent.enabled}>` +
71
+ `<ConsentBanner copy={copy[locale]} layout privacyHref
72
+ rejectOnFirstLayer>` (consent family; reference impl
63
73
  `src/components/storefront/consent/`).
64
74
  - **Settings**: admin → Settings → Consent (enabled/mode/layout/
65
75
  privacy_href/copy per locale).
@@ -71,7 +71,6 @@ test "$STATUS" = 401
71
71
  {
72
72
  "customer": {
73
73
  "id": "uuid",
74
- "client_id": "uuid",
75
74
  "email": "maria@example.com",
76
75
  "first_name": "Maria",
77
76
  "last_name": "Petrova",
@@ -83,15 +82,17 @@ test "$STATUS" = 401
83
82
  "account_status": "approved", // "pending" | "approved" — B2B gating
84
83
  "tags": [], // admin-only labels; read-only here
85
84
  "metadata": null,
86
- "created_by": null,
87
85
  "created_at": "ISO-8601",
88
86
  "updated_at": "ISO-8601",
89
- "deleted_at": null,
90
87
  "addresses": [ /* CustomerAddress[], created_at asc — shape below */ ]
91
88
  }
92
89
  }
93
90
  ```
94
91
 
92
+ Named columns only (2026-09-14): the tenant id, the staff member who
93
+ created the row, the payment provider's customer id and the soft-delete
94
+ stamp are not part of this payload.
95
+
95
96
  - **Errors**: `401 unauthenticated` · `400 missing_client_id`.
96
97
  - **SDK**: `customers.getMe(client)`
97
98
  - **Components**: account dashboard / header account state.
@@ -145,7 +146,6 @@ curl -s "$BASE/api/store/customers/me" -H "x-client-id: $CLIENT_ID" \
145
146
  "addresses": [
146
147
  {
147
148
  "id": "uuid",
148
- "client_id": "uuid",
149
149
  "customer_id": "uuid",
150
150
  "address_name": "Home",
151
151
  "first_name": "Maria",
@@ -162,8 +162,7 @@ curl -s "$BASE/api/store/customers/me" -H "x-client-id: $CLIENT_ID" \
162
162
  "is_default_shipping": true,
163
163
  "metadata": null,
164
164
  "created_at": "ISO-8601",
165
- "updated_at": "ISO-8601",
166
- "deleted_at": null
165
+ "updated_at": "ISO-8601"
167
166
  }
168
167
  ],
169
168
  "count": 1,
@@ -76,8 +76,10 @@ key-by-key.
76
76
  config); consent settings drive `tracking.consent_required`; Rybbit is
77
77
  deliberately ABSENT (platform analytics, not a tenant integration).
78
78
 
79
- Tracking wiring contract: mount tags only through the consent gate when
80
- `consent_required` (see [consent.md](consent.md)); Purchase events MUST use
79
+ Tracking wiring contract: `consent_required` is the store's consent switch
80
+ and decides every tag's default (see [consent.md](consent.md)): true starts
81
+ a visitor denied until they choose, false starts them granted, and a stored
82
+ choice wins either way. `<StorefrontTags>` applies it; Purchase events MUST use
81
83
  `eventID = "purchase_" + order.display_id` so Meta dedupes browser Pixel vs
82
84
  server CAPI, and `event_id = "tt_purchase_" + order.display_id` for TikTok;
83
85
  write TrackingAttribution keys into `cart.metadata` (consent-gated) so
@@ -69,6 +69,7 @@ foreign key → 400 `invalid_publishable_key`.
69
69
  "collection_id": "pcol_01tst00000000000000000001",
70
70
  "type_id": null,
71
71
  "external_id": null,
72
+ "vendor": null, // the brand, plain text
72
73
  "weight": null, "length": null, "height": null, "width": null,
73
74
  "hs_code": null, "origin_country": null, "mid_code": null, "material": null,
74
75
  "seo_title": null, // null = fall back to title
@@ -9,7 +9,7 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@cartbase/storefront": "^0.14.0",
12
+ "@cartbase/storefront": "^0.15.0",
13
13
  "next": "16.2.4",
14
14
  "react": "19.2.4",
15
15
  "react-dom": "19.2.4"
@@ -9,7 +9,7 @@ import { StorefrontTags } from "@cartbase/storefront/tracking/storefront-tags"
9
9
  import { TrackInit } from "@cartbase/storefront/tracking/track-init"
10
10
  import { CartButtonClient } from "@cartbase/storefront/common/cart-button-client"
11
11
  import { createStorefrontMetadata, PlatformInit } from "@cartbase/storefront/platform"
12
- import { BARTER_CLIENT_ID, readCartCookie } from "@/lib/config"
12
+ import { BARTER_CLIENT_ID, readCartCookie, STORE_LOCALE } from "@/lib/config"
13
13
  import { getServerClient } from "@/lib/server-client"
14
14
  import { Providers } from "./providers"
15
15
  import "./globals.css"
@@ -126,14 +126,18 @@ export default async function RootLayout({
126
126
  ])
127
127
 
128
128
  return (
129
- <html lang="en">
129
+ <html lang={STORE_LOCALE.code}>
130
130
  <body>
131
- <ConsentInit />
131
+ {/* The store's own consent switch decides the default: a store
132
+ that collects consent starts every visitor denied until they
133
+ choose, a store with the banner off starts them granted. The
134
+ prop is required, so a layout cannot leave it out. */}
135
+ <ConsentInit required={consentRes.consent.enabled} />
132
136
  {/* Every marketing tag the store configured in the admin, mounted
133
- from its own config: Meta, TikTok, Google (GA4 + Ads on one
134
- tag), GTM. Nothing to wire per vendor — saving the ids in
135
- Settings → Integrations is the whole merchant-side act. Order
136
- matters: ConsentInit sets the Consent Mode defaults
137
+ from its own config: Meta, TikTok, ChatGPT, Google (GA4 + Ads
138
+ on one tag), GTM. Nothing to wire per vendor — saving the ids
139
+ in Settings → Integrations is the whole merchant-side act.
140
+ Order matters: ConsentInit sets the Consent Mode defaults
137
141
  synchronously ABOVE this, so every tag below inherits the
138
142
  gate. */}
139
143
  <StorefrontTags client={client} />
@@ -15,6 +15,7 @@ import {
15
15
  import { TrackOrderPurchase } from "@cartbase/storefront/tracking/track-order-purchase"
16
16
  import { useTrackingConfig } from "@cartbase/storefront/tracking/use-tracking-config"
17
17
  import { browserClient } from "@/lib/browser-client"
18
+ import { STORE_LOCALE } from "@/lib/config"
18
19
  import { LAST_ORDER_STORAGE_KEY } from "../../../checkout/checkout-page-client"
19
20
 
20
21
  /**
@@ -86,6 +87,9 @@ export default function OrderConfirmedPage() {
86
87
  // the order.
87
88
  paymentMethodName={"Cash on delivery"}
88
89
  storeHref="/"
90
+ // The order pack, as a prop: this template reads its copy from props,
91
+ // not from the locale provider, and the prop is required.
92
+ labels={STORE_LOCALE.order}
89
93
  />
90
94
  </>
91
95
  )
@@ -1,6 +1,6 @@
1
1
  import { StoreTemplate } from "@cartbase/storefront/store/store-template"
2
2
  import type { SortOptions } from "@cartbase/storefront/lib/sort-products"
3
- import { PRICING_CONTEXT } from "@/lib/config"
3
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
4
4
  import { getServerClient } from "@/lib/server-client"
5
5
 
6
6
  /**
@@ -20,6 +20,9 @@ export default async function HomePage({
20
20
  sortBy={sortBy as SortOptions | undefined}
21
21
  page={page}
22
22
  pricingContext={PRICING_CONTEXT}
23
+ // Server-rendered: the locale provider cannot reach it, so the pack
24
+ // comes as a prop, and the prop is required.
25
+ labels={STORE_LOCALE.store}
23
26
  />
24
27
  )
25
28
  }
@@ -3,7 +3,7 @@ import { retrieveProduct } from "@cartbase/storefront/api/products"
3
3
  import { StoreApiError } from "@cartbase/storefront/api/types"
4
4
  import { ProductTemplate } from "@cartbase/storefront/products/product-template"
5
5
  import { addToCartAction } from "@/lib/cart-actions"
6
- import { PRICING_CONTEXT } from "@/lib/config"
6
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
7
7
  import { getServerClient } from "@/lib/server-client"
8
8
 
9
9
  /**
@@ -50,6 +50,11 @@ export default async function ProductPage({
50
50
  // default for a store that has not decided yet. The physical-facts
51
51
  // section appears on its own for products that HAVE facts, and is
52
52
  // skipped for those that do not.
53
+ //
54
+ // The pack has to be handed over here, not just mounted at the root:
55
+ // the related-products strip is a server component and cannot read
56
+ // the locale provider, so its heading would stay English. Required.
57
+ labels={STORE_LOCALE.products}
53
58
  />
54
59
  )
55
60
  }
@@ -9,14 +9,20 @@ import {
9
9
  shouldRenderBanner,
10
10
  type ConsentSettings,
11
11
  } from "@cartbase/storefront/tracking/consent"
12
- import { CART_COOKIE, CART_COOKIE_MAX_AGE } from "@/lib/config"
12
+ import { StorefrontLocaleProvider } from "@cartbase/storefront/locales"
13
+ import { CART_COOKIE, CART_COOKIE_MAX_AGE, STORE_LOCALE } from "@/lib/config"
13
14
  import { browserClient } from "@/lib/browser-client"
14
15
 
15
16
  /**
16
- * Client-side shell: ONE CartDrawerProvider per app (cart-drawer family
17
- * mount rule) + the consent banner (consent.md: render the builtin banner
18
- * only when `enabled && mode === "builtin"`; ConsentInit stays in the
19
- * server layout as the first child of <body>).
17
+ * Client-side shell: the store's language, ONE CartDrawerProvider per app
18
+ * (cart-drawer family mount rule) + the consent banner (consent.md: render
19
+ * the builtin banner only when `enabled && mode === "builtin"`; ConsentInit
20
+ * stays in the server layout as the first child of <body>).
21
+ *
22
+ * The language is mounted once, here. Every client component inside reads
23
+ * it, the cart drawer included, so nothing is handed over below. The
24
+ * server-rendered templates cannot read it, so each page hands them their
25
+ * area (`labels={STORE_LOCALE.store}`), and those props are required.
20
26
  */
21
27
  export function Providers({
22
28
  cart,
@@ -27,28 +33,32 @@ export function Providers({
27
33
  consent: ConsentSettings
28
34
  children: React.ReactNode
29
35
  }) {
30
- const copy = pickConsentCopy(consent.copy, "en")
36
+ // The store's own consent copy, in the store's language where it has it;
37
+ // pickConsentCopy falls back to English and then to whatever exists.
38
+ const copy = pickConsentCopy(consent.copy, STORE_LOCALE.code)
31
39
  return (
32
- <CartDrawerProvider
33
- cart={cart}
34
- client={browserClient}
35
- onCartChange={(next) => {
36
- // Persist the cart id (carts.md: the app owns the cart cookie).
37
- document.cookie = `${CART_COOKIE}=${encodeURIComponent(
38
- next.id
39
- )};path=/;max-age=${CART_COOKIE_MAX_AGE};samesite=lax`
40
- }}
41
- >
42
- {children}
43
- <CartDrawerTemplate />
44
- {shouldRenderBanner(consent) && copy && (
45
- <ConsentBanner
46
- copy={copy}
47
- layout={consent.layout}
48
- privacyHref={consent.privacy_href}
49
- rejectOnFirstLayer={consent.reject_on_first_layer}
50
- />
51
- )}
52
- </CartDrawerProvider>
40
+ <StorefrontLocaleProvider locale={STORE_LOCALE}>
41
+ <CartDrawerProvider
42
+ cart={cart}
43
+ client={browserClient}
44
+ onCartChange={(next) => {
45
+ // Persist the cart id (carts.md: the app owns the cart cookie).
46
+ document.cookie = `${CART_COOKIE}=${encodeURIComponent(
47
+ next.id
48
+ )};path=/;max-age=${CART_COOKIE_MAX_AGE};samesite=lax`
49
+ }}
50
+ >
51
+ {children}
52
+ <CartDrawerTemplate />
53
+ {shouldRenderBanner(consent) && copy && (
54
+ <ConsentBanner
55
+ copy={copy}
56
+ layout={consent.layout}
57
+ privacyHref={consent.privacy_href}
58
+ rejectOnFirstLayer={consent.reject_on_first_layer}
59
+ />
60
+ )}
61
+ </CartDrawerProvider>
62
+ </StorefrontLocaleProvider>
53
63
  )
54
64
  }
@@ -1,5 +1,5 @@
1
1
  import { SearchTemplate } from "@cartbase/storefront/store/search-template"
2
- import { PRICING_CONTEXT } from "@/lib/config"
2
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
3
3
  import { getServerClient } from "@/lib/server-client"
4
4
 
5
5
  /** Search page (runbook step 5) — fully URL-state driven search-template. */
@@ -15,6 +15,9 @@ export default async function SearchPage({
15
15
  client={client}
16
16
  searchParams={params}
17
17
  pricingContext={PRICING_CONTEXT}
18
+ // Server-rendered: the locale provider cannot reach it, so the pack
19
+ // comes as a prop, and the prop is required.
20
+ labels={STORE_LOCALE.store}
18
21
  />
19
22
  )
20
23
  }
@@ -26,5 +26,16 @@ export const CART_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
26
26
  /** Locale cookie read by the clients' `getLocale`. */
27
27
  export const LOCALE_COOKIE = "_barter_locale"
28
28
 
29
+ /**
30
+ * THE STORE'S LANGUAGE, declared once. The library speaks international
31
+ * English and knows no other language; a language is a typed pack the store
32
+ * imports. Swap `en` for `bg` or `es` here (`@cartbase/storefront/locales/bg`)
33
+ * and every screen follows: providers.tsx mounts it for the client
34
+ * components, and each page hands the server-rendered templates their area
35
+ * from it (`labels={STORE_LOCALE.store}`), because a server component cannot
36
+ * read a client context. Those props are required, so a page cannot forget.
37
+ */
38
+ export { en as STORE_LOCALE } from "@cartbase/storefront/locales/en"
39
+
29
40
  /** Pricing context for every catalog surface (runbook step 5). EUR only. */
30
41
  export const PRICING_CONTEXT = { currency_code: "eur" } as const