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 +4 -0
- package/dist/enclosing-project.js +82 -0
- package/dist/index.js +5 -1
- package/package.json +1 -1
- package/template/app/docs/BUILD-A-STOREFRONT.md +8 -4
- package/template/app/docs/components.md +89 -54
- package/template/app/docs/consent.md +18 -8
- package/template/app/docs/customers.md +5 -6
- package/template/app/docs/integrations.md +4 -2
- package/template/app/docs/products.md +1 -0
- package/template/app/package.json +1 -1
- package/template/app/src/app/layout.tsx +11 -7
- package/template/app/src/app/order/[id]/confirmed/page.tsx +4 -0
- package/template/app/src/app/page.tsx +4 -1
- package/template/app/src/app/products/[handle]/page.tsx +6 -1
- package/template/app/src/app/providers.tsx +37 -27
- package/template/app/src/app/search/page.tsx +4 -1
- package/template/app/src/lib/config.ts +11 -0
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
|
@@ -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 —
|
|
122
|
-
awaits a fetch (first-hit consent race
|
|
123
|
-
|
|
124
|
-
|
|
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)
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
<
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
`
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
605
|
-
`
|
|
606
|
-
|
|
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?
|
|
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?
|
|
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;
|
|
862
|
-
`
|
|
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
|
|
875
|
-
|
|
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?
|
|
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?
|
|
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?
|
|
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?
|
|
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
|
-
### `<
|
|
1113
|
-
|
|
1114
|
-
- **Purpose** — the market picker for a header or footer
|
|
1115
|
-
(`api/regions.listRegions`), valued by region id;
|
|
1116
|
-
via `onChange` (usually
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
`
|
|
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:
|
|
1158
|
-
`
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
|
11
|
-
synchronous Consent Mode v2 DEFAULT and must
|
|
12
|
-
(async default = first-hit
|
|
13
|
-
(RSC) and inline
|
|
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
|
|
22
|
-
|
|
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
|
|
62
|
-
layout privacyHref
|
|
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:
|
|
80
|
-
|
|
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 @@ 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=
|
|
129
|
+
<html lang={STORE_LOCALE.code}>
|
|
130
130
|
<body>
|
|
131
|
-
|
|
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
|
|
134
|
-
tag), GTM. Nothing to wire per vendor — saving the ids
|
|
135
|
-
Settings → Integrations is the whole merchant-side act.
|
|
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 {
|
|
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
|
|
17
|
-
* mount rule) + the consent banner (consent.md: render
|
|
18
|
-
* only when `enabled && mode === "builtin"`; ConsentInit
|
|
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
|
-
|
|
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
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|