create-cartbase 0.1.18 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cartbase",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Scaffold a Cartbase storefront: npm create cartbase my-store",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -73,8 +73,15 @@ Construct ONE `StorefrontClient` per scope and pass it to every SDK call
73
73
  (all SDK functions take the client as first argument — see any domain doc's
74
74
  SDK line):
75
75
 
76
- - **Server** (RSC, server actions): construct per request; `getAuthToken`
77
- reads the customer session cookie; `getLocale` reads the locale cookie.
76
+ - **Server, catalogue** (`src/lib/store-client.ts`): ONE client with the
77
+ store's key and nothing about the visitor. It reads no cookie, so every
78
+ catalogue read made with it can be cached and every page built from
79
+ those reads is prerendered (Step 3). A store with customer prices or
80
+ several languages keys its cached reads by the customer group or the
81
+ locale, passed in as arguments; it never reads a cookie inside one.
82
+ - **Server, visitor** (`src/lib/server-client.ts`, checkout, account):
83
+ construct per request; `getAuthToken` reads the customer session cookie;
84
+ `getLocale` reads the locale cookie. Only behind a `<Suspense>` boundary.
78
85
  - **Browser**: construct once; token from your session store.
79
86
 
80
87
  Auth model (recap — full detail in [auth.md](auth.md)):
@@ -102,9 +109,35 @@ async rewrites() {
102
109
  }
103
110
  ```
104
111
 
105
- ## Step 3 — Store configuration bootstrap
106
-
107
- Fetch once at layout level, cache per the docs' cache headers:
112
+ ## Step 3 — Store configuration bootstrap, and the prerendered store
113
+
114
+ **Every page is a prerendered file on the CDN.** The scaffold runs Next.js
115
+ Cache Components (`cacheComponents: true` and the `catalog` life in
116
+ `next.config.ts`), which is what makes a storefront fast: Alenika, the
117
+ store the package came from, serves every page this way.
118
+
119
+ - Every catalogue read (the store, consent, tags, menus, products,
120
+ collections, content, reviews) is a function in `src/lib/catalog.ts`
121
+ marked `"use cache: remote"` with `cacheLife("catalog")`, made with the
122
+ catalogue client. The platform is asked once per two minutes per read,
123
+ whichever server renders; a change in the admin reaches the store within
124
+ that window. A failed read throws inside the cache, so an error is never
125
+ stored as an empty answer; the public function decides what it reads as.
126
+ - What belongs to one visitor renders behind `<Suspense>`: the query
127
+ (`searchParams`: sort, page, search, the product page's `?variant=`),
128
+ the checkout's cart, a customer's account. A page passes `searchParams`
129
+ down unread and the component inside the boundary reads it.
130
+ - Dynamic routes list their pages with `generateStaticParams` (every
131
+ product handle, every collection handle), so the build prerenders them;
132
+ a page added later renders on its first visit and is kept.
133
+ - The build refuses a request read outside a boundary ("Uncached data was
134
+ accessed outside of `<Suspense>`"). That error is the speed law doing its
135
+ job: wrap the read, never switch the setting off.
136
+ - A component never reads the query while it renders
137
+ (`useSearchParams`): read `window.location.search` when you need it, at
138
+ the click or in an effect. The package holds itself to this with a test.
139
+
140
+ Fetch once at layout level, through `src/lib/catalog.ts`:
108
141
 
109
142
  1. [regions.md](regions.md) — regions (→ region_id for pricing + payment
110
143
  providers), currencies, supported locales.
@@ -123,19 +156,34 @@ and [consent.md](consent.md)):
123
156
  otherwise); the layout resolves the consent config on the server and
124
157
  passes the switch. A store that collects consent starts visitors denied
125
158
  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`).
159
+ 2. `<StorefrontTags config={tracking}>` — every tag the store configured,
160
+ fed by the integrations tracking block (a cached read in
161
+ `src/lib/catalog.ts`), never by env vars, each gated by the same switch
162
+ (`tracking.consent_required`).
129
163
  3. Navigation from [menus.md](menus.md) — `main-menu` / `footer` handles;
130
164
  an unknown handle 404s and must render as "no nav", never crash.
131
165
 
166
+ The layout reads nothing from the request: no `cookies()`, no cart. The
167
+ cart drawer reads the stored cart id in the browser
168
+ (`readBrowserCartId()` handed to `CartDrawerProvider` as `cartId`) and the
169
+ cart after it, and `<CartButtonClient />` with no `cart` counts the
170
+ drawer's cart. A layout that reads the cart cookie makes every page of the
171
+ store render per visit.
172
+
132
173
  ## Step 5 — Catalog
133
174
 
134
175
  - [products.md](products.md) — listing + PDP. Always pass a pricing context
135
176
  (`currency_code` or `region_id`) or prices come back undecorated; render
136
177
  `variant.calculated_price`, fall back to base `prices[]`. **Never cache a
137
178
  `calculated_price` response shared when a customer JWT was present** —
138
- prices vary by customer group.
179
+ prices vary by customer group. The product page reads the product from
180
+ the cache for the page and hands `ProductTemplate` the query as a promise
181
+ (`initialVariantId={searchParams.then((q) => q.variant)}`): the buy box
182
+ re-reads the product live behind its boundary, so price and stock are
183
+ this minute's while the rest of the page is prerendered.
184
+ - A sliding row in a browser component takes cards drawn on the server,
185
+ never the products: a product is tens of kilobytes, and every prop of a
186
+ browser component is written into the page.
139
187
  - [collections.md](collections.md) — collection pages use the membership
140
188
  endpoint (`/collections/:id/products`) which honors the admin's sort.
141
189
  - [categories.md](categories.md) — category tree, tags, types.
@@ -160,7 +208,9 @@ and [consent.md](consent.md)):
160
208
  ## Step 7 — Cart
161
209
 
162
210
  [carts.md](carts.md): create the cart lazily on first add-to-cart with the
163
- region + (optionally) sales channel; persist `cart.id` in a cookie; all cart
211
+ region + (optionally) sales channel; persist `cart.id` in a cookie
212
+ (`writeCartCookie` in the drawer's `onCartChange`, `readBrowserCartId()`
213
+ for its `cartId`, `clearCartCookie` for its `onCartEnd`); all cart
164
214
  mutations return the decorated cart — totals are SERVER truth, render them
165
215
  verbatim, never compute client-side. Line items, quantity updates, deletes,
166
216
  and the customer-attach call after login are all in that doc.
@@ -832,10 +832,14 @@ Six rules every buy panel is held to, the package's own and a store's:
832
832
  forgets a choice. The value is the id's DIGITS (`pvar_1000683784068`
833
833
  travels as `1000683784068`), the platform's law for every id in a URL;
834
834
  reading accepts the whole key too.
835
- 2. **The first paint is right.** The page reads `searchParams.variant` on the
836
- server and passes it down (`initialVariantId` on `ProductTemplate`,
837
- `ProductActions` or the hook), so the server renders that variant's
838
- price, code and stock and nothing flashes.
835
+ 2. **The first paint is right.** The page hands `searchParams.variant` down
836
+ (`initialVariantId` on `ProductTemplate`, `ProductActions` or the hook),
837
+ so the server renders that variant's price, code and stock and nothing
838
+ flashes. On Cache Components the page passes it to `ProductTemplate` as a
839
+ promise (`searchParams.then((q) => q.variant)`) and the live buy box
840
+ reads it behind its boundary, so the rest of the page stays prerendered.
841
+ The hook itself never reads the query while rendering: it reads
842
+ `window.location.search` when it rewrites the address.
839
843
  3. **A switch is a browser event.** The address is rewritten with the
840
844
  browser's own history call, which Next's router picks up; no server render
841
845
  runs. (Going through the router made every click a navigation: the address
@@ -969,7 +973,9 @@ with a legacy flat-row fallback. Unit-tested
969
973
  - **Purpose** — the full PDP: sticky info column (`ProductInfo` +
970
974
  `ProductTabs`), gallery, sticky actions column (suspended
971
975
  `ProductActionsWrapper`), related strip. `initialVariantId` is the
972
- address's variant (`searchParams.variant`, rule 2 of the contract);
976
+ address's variant (`searchParams.variant`, rule 2 of the contract), a
977
+ string or a promise of one; a promise is read inside the live buy box's
978
+ boundary, and the fallback box renders the merchant's default;
973
979
  `renderProduct` is the store's card for the related strip, so a store
974
980
  with its own card no longer draws the library's preview on one page.
975
981
  - **SDK calls** — via children (retrieveProduct, listRelatedProducts). The
@@ -1214,12 +1220,15 @@ Shared storefront chrome, production-proven.
1214
1220
  has one (`[countryCode]` param by default, `paramName` overridable);
1215
1221
  plain link on cookie-locale apps (the Cartbase default). Client.
1216
1222
 
1217
- ### `<CartButton client cartId? />` + `<CartButtonClient cart />` — `common/cart-button`, `common/cart-button-client`
1223
+ ### `<CartButton client cartId? />` + `<CartButtonClient cart? />` — `common/cart-button`, `common/cart-button-client`
1218
1224
 
1219
1225
  - **Purpose** — header cart button: badge count + opens the cart drawer.
1220
- - **SDK calls** — server wrapper: `api/carts.retrieveCart(client, cartId)`
1221
- (the app owns the cart-id cookie); fetch failure degrades to an empty
1222
- button. Client half takes the decorated `Cart`; badge count =
1226
+ - **SDK calls** — `<CartButtonClient />` with no `cart` counts the drawer's
1227
+ own cart (the one the provider read in the browser): the default, and
1228
+ the only shape that keeps a store's pages prerendered. The server wrapper
1229
+ `CartButton` reads `api/carts.retrieveCart(client, cartId)` from the
1230
+ cookie, so it renders per visit and belongs behind a `<Suspense>`; fetch
1231
+ failure degrades to an empty button. Badge count =
1223
1232
  `productItemCount(cart.items)` (fee-line-aware, consistent with the
1224
1233
  drawer).
1225
1234
  - **Mount rules** — `CartButtonClient` must sit inside the cart-drawer
@@ -1303,12 +1312,37 @@ parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots
1303
1312
  - **Settings** — Settings → Reviews display options: `widget_layout`
1304
1313
  (masonry|list), `widget_page_size`, `widget_photo_first` (all arrive
1305
1314
  via `getWidget().options`); moderation decides visibility.
1306
- - Pieces exported for custom layouts: `<StarRow>`, `<StarBadge>`,
1307
- `<RatingDistribution>` (`star-badge`), `<ReviewList>` +
1308
- `<ReviewLightbox>` (presentational cards + overlay),
1309
- `reviewDisplayName` (first name + surname initial — shared with any
1310
- app JSON-LD so UI and structured data can't drift, production fix),
1311
- `formatReviewDate`.
1315
+ - Pieces exported for custom layouts: `<StarRow>` (`size="lg"` is the
1316
+ lightbox's row), `<StarBadge>`, `<RatingDistribution>` (`star-badge`),
1317
+ `<ReviewList>` (the presentational cards; a click on a card opens it in
1318
+ `<ReviewLightbox>`), `reviewDisplayName` (first name and surname
1319
+ initial, shared with any app JSON-LD so UI and structured data cannot
1320
+ drift, a production fix), `formatReviewDate`.
1321
+ - **Theme**: the stars fill in the `rating` colour (`--rating` in
1322
+ tokens.css, the warning yellow unless the store sets its own).
1323
+
1324
+ ### `<ReviewLightbox reviews position onPositionChange labels? />`: one review, whole
1325
+
1326
+ - **Purpose**: the review opened from ANY widget that shows reviews: the
1327
+ photo or video on the left, and on the right the reviewer, the verified
1328
+ badge with a button that says what it means, the stars, the date, the
1329
+ text and the store's reply. On a phone the photo sits on top and the
1330
+ review scrolls under it. The arrows and the arrow keys walk the open
1331
+ review's photos, then on to the next review; the dots jump between one
1332
+ review's photos; Escape, the close button and a click outside close it.
1333
+ - **Mount rules**: client component, built on the Dialog primitive. The
1334
+ opening widget owns the state and hands over its own list, so the
1335
+ lightbox walks the reviews in the widget's order:
1336
+
1337
+ ```tsx
1338
+ const [open, setOpen] = useState<LightboxPosition | null>(null)
1339
+ // on a card: onClick={() => setOpen({ review: index, media: 0 })}
1340
+ <ReviewLightbox reviews={reviews} position={open} onPositionChange={setOpen} />
1341
+ ```
1342
+
1343
+ `media` indexes `lightboxMedia(review)`, the same filtered list a card
1344
+ counts its photos from. `stepLightbox` is the walk itself, for a widget
1345
+ that steps on its own.
1312
1346
 
1313
1347
  ### `<ReviewWizard client token validation rewardPct? supportEmail? … />` — the token page
1314
1348
 
@@ -38,10 +38,18 @@ SDK module: `@cartbase/storefront/api/store` (from 0.8.0; on 0.7.0 call
38
38
  - **Errors** — `404 store_not_found` when the key names no live store.
39
39
 
40
40
  ```ts
41
+ import { cacheLife } from "next/cache"
41
42
  import { getStore } from "@cartbase/storefront/api/store"
42
43
 
44
+ // A cached read with the catalogue client (BUILD-A-STOREFRONT.md step 3).
45
+ async function readStore() {
46
+ "use cache: remote"
47
+ cacheLife("catalog")
48
+ return (await getStore(storeClient)).store
49
+ }
50
+
43
51
  export async function generateMetadata() {
44
- const { store } = await getStore(await getServerClient())
52
+ const store = await readStore()
45
53
  return createStorefrontMetadata({ title: store.name, description: store.brand.slogan ?? undefined })
46
54
  }
47
55
  ```
@@ -4,6 +4,17 @@ const nextConfig: NextConfig = {
4
4
  // @cartbase/storefront is source-shipped TypeScript — the app's Next
5
5
  // build transpiles it.
6
6
  transpilePackages: ["@cartbase/storefront"],
7
+ // Cache Components: every page is a prerendered shell served from the
8
+ // CDN. The catalogue reads are cached (src/lib/catalog.ts) and send no
9
+ // cookies; what belongs to one visitor (the cart, the query, the live buy
10
+ // box, the checkout) streams in behind <Suspense>. A request read left
11
+ // outside a boundary fails the build, so a slow page cannot ship.
12
+ cacheComponents: true,
13
+ cacheLife: {
14
+ // The catalogue: products, menus, the store, consent, tags. A change in
15
+ // the admin reaches the store within two minutes.
16
+ catalog: { stale: 60, revalidate: 120, expire: 3600 },
17
+ },
7
18
  images: {
8
19
  // Product pictures live on the Cartbase media CDN and Next resizes them
9
20
  // on this project, per device, in the modern formats. A picture from
@@ -9,7 +9,7 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@cartbase/storefront": "^0.18.1",
12
+ "@cartbase/storefront": "^0.19.0",
13
13
  "next": "16.2.4",
14
14
  "react": "19.2.4",
15
15
  "react-dom": "19.2.4"
@@ -1,3 +1,4 @@
1
+ import { Suspense } from "react"
1
2
  import { cookies } from "next/headers"
2
3
  import { redirect } from "next/navigation"
3
4
  import { retrieveCart } from "@cartbase/storefront/api/carts"
@@ -16,7 +17,17 @@ import { CheckoutPageClient } from "./checkout-page-client"
16
17
  * client layout. Redirect completed/missing carts server-side
17
18
  * (checkout-client mount rule).
18
19
  */
19
- export default async function CheckoutPage() {
20
+ export default function CheckoutPage() {
21
+ // The cart is this visitor's: the checkout renders at request time,
22
+ // behind its boundary (Cache Components).
23
+ return (
24
+ <Suspense fallback={null}>
25
+ <Checkout />
26
+ </Suspense>
27
+ )
28
+ }
29
+
30
+ async function Checkout() {
20
31
  const jar = await cookies()
21
32
  // Back-compat: prefer `_cartbase_cart`, fall back to the legacy
22
33
  // `_barter_cart_id` name (platform-fingerprints card).
@@ -1,184 +1,126 @@
1
- import type { Metadata } from "next"
2
- import Link from "next/link"
3
- import { cookies } from "next/headers"
4
- import { retrieveCart, type Cart } from "@cartbase/storefront/api/carts"
5
- import { getConsent } from "@cartbase/storefront/api/consent"
6
- import { getMenu, type Menu } from "@cartbase/storefront/api/menus"
7
- import { ConsentInit } from "@cartbase/storefront/tracking/consent-init"
8
- import { StorefrontTags } from "@cartbase/storefront/tracking/storefront-tags"
9
- import { TrackInit } from "@cartbase/storefront/tracking/track-init"
10
- import { CartButtonClient } from "@cartbase/storefront/common/cart-button-client"
11
- import { createStorefrontMetadata, PlatformInit } from "@cartbase/storefront/platform"
12
- import { BARTER_CLIENT_ID, readCartCookie, STORE_LOCALE } from "@/lib/config"
13
- import { getServerClient } from "@/lib/server-client"
14
- import { Providers } from "./providers"
15
- import "./globals.css"
16
-
17
- // createStorefrontMetadata (platform-fingerprints card) stamps
18
- // `<meta name="generator" content="Cartbase" />` on every page — never
19
- // hand-write `generator` here.
20
- export async function generateMetadata(): Promise<Metadata> {
21
- const store = await fetchStore()
22
- return createStorefrontMetadata({
23
- title: store.name,
24
- description: store.brand.slogan ?? undefined,
25
- })
26
- }
27
-
28
- /**
29
- * The store's own identity (store.md): name, slug, brand. Read once per
30
- * request; a storefront never hardcodes its name. Until @cartbase/storefront
31
- * 0.8.0 ships `api/store`, the call goes through the client's low-level
32
- * request with the documented shape.
33
- */
34
- type StoreIdentity = {
35
- /** The store's public id: what PlatformInit mounts (one key: never an env input). */
36
- id: string
37
- name: string
38
- slug: string
39
- brand: {
40
- logo_url: string | null
41
- logo_square_url: string | null
42
- color_primary: string | null
43
- color_secondary: string | null
44
- slogan: string | null
45
- }
46
- }
47
-
48
- async function fetchStore(): Promise<StoreIdentity> {
49
- const client = await getServerClient()
50
- try {
51
- const { store } = await client.request<{ store: StoreIdentity }>("/api/store/store")
52
- return store
53
- } catch {
54
- // Never crash the layout on a transient API error; the name is
55
- // decoration, the store still renders.
56
- return {
57
- id: BARTER_CLIENT_ID ?? "",
58
- name: "Store",
59
- slug: "",
60
- brand: {
61
- logo_url: null,
62
- logo_square_url: null,
63
- color_primary: null,
64
- color_secondary: null,
65
- slogan: null,
66
- },
67
- }
68
- }
69
- }
70
-
71
- /**
72
- * Runbook steps 3–4: bootstrap store config at layout level, then mount
73
- * order inside <body>: ConsentInit FIRST (static, synchronous), then the
74
- * consent-gated UI, then navigation. A store with no menus 404s every
75
- * handle — render no nav, never crash (menus.md).
76
- */
77
- async function fetchMenu(handle: string): Promise<Menu | null> {
78
- const client = await getServerClient()
79
- try {
80
- const { menu } = await getMenu(client, handle)
81
- return menu
82
- } catch {
83
- return null // unknown/deleted handle (404) graceful "no nav"
84
- }
85
- }
86
-
87
- function MenuNav({ menu }: { menu: Menu | null }) {
88
- if (!menu || menu.items.length === 0) return null
89
- return (
90
- <nav className="flex items-center gap-4">
91
- {menu.items.map((item) => (
92
- <Link
93
- key={`${item.title}-${item.url}`}
94
- href={item.url}
95
- className="text-sm text-muted-foreground hover:text-foreground"
96
- >
97
- {item.title}
98
- </Link>
99
- ))}
100
- </nav>
101
- )
102
- }
103
-
104
- export default async function RootLayout({
105
- children,
106
- }: {
107
- children: React.ReactNode
108
- }) {
109
- const client = await getServerClient()
110
- const jar = await cookies()
111
- // Back-compat: prefer the new `_cartbase_cart` cookie, fall back to the
112
- // legacy `_barter_cart_id` name so an existing visitor's cart survives
113
- // the rename (platform-fingerprints card).
114
- const cartId = readCartCookie((name) => jar.get(name)?.value) ?? null
115
-
116
- const [store, consentRes, mainMenu, footerMenu, cart] = await Promise.all([
117
- fetchStore(),
118
- getConsent(client),
119
- fetchMenu("main-menu"),
120
- fetchMenu("footer"),
121
- cartId
122
- ? retrieveCart(client, cartId)
123
- .then((res): Cart | null => res.cart)
124
- .catch(() => null) // stale cookie → empty cart state
125
- : Promise.resolve(null),
126
- ])
127
-
128
- return (
129
- <html lang={STORE_LOCALE.code}>
130
- <body>
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} />
136
- {/* Every marketing tag the store configured in the admin, mounted
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
141
- synchronously ABOVE this, so every tag below inherits the
142
- gate. */}
143
- <StorefrontTags client={client} />
144
- {/* Captures UTMs and the ad-click ids (ttclid / gclid / gbraid /
145
- wbraid) that exist ONLY on an ad's landing URL — miss them
146
- here and no platform can attribute the order to the click. */}
147
- <TrackInit />
148
- <PlatformInit storeId={store.id} />
149
- <Providers cart={cart} consent={consentRes.consent}>
150
- <header className="border-b border-border">
151
- <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between gap-6">
152
- <Link href="/" className="font-semibold text-lg">
153
- {store.name}
154
- </Link>
155
- <div className="flex items-center gap-6">
156
- <MenuNav menu={mainMenu} />
157
- <Link
158
- href="/search"
159
- className="text-sm text-muted-foreground hover:text-foreground"
160
- >
161
- Search
162
- </Link>
163
- {/*
164
- The component workbench lives at /gallery in THIS app only
165
- (the seed excludes it, src/lib/storefront/seed.ts), so the
166
- shop's own nav never links to it: a merchant's store must
167
- not carry a dead link.
168
- */}
169
- <CartButtonClient cart={cart} />
170
- </div>
171
- </div>
172
- </header>
173
- <main>{children}</main>
174
- <footer className="border-t border-border mt-12">
175
- <div className="max-w-7xl mx-auto px-4 py-8 text-sm text-muted-foreground flex items-center justify-between">
176
- <span>{store.name}</span>
177
- <MenuNav menu={footerMenu} />
178
- </div>
179
- </footer>
180
- </Providers>
181
- </body>
182
- </html>
183
- )
184
- }
1
+ import type { Metadata } from "next"
2
+ import Link from "next/link"
3
+ import type { Menu } from "@cartbase/storefront/api/menus"
4
+ import { ConsentInit } from "@cartbase/storefront/tracking/consent-init"
5
+ import { StorefrontTags } from "@cartbase/storefront/tracking/storefront-tags"
6
+ import { TrackInit } from "@cartbase/storefront/tracking/track-init"
7
+ import { CartButtonClient } from "@cartbase/storefront/common/cart-button-client"
8
+ import { createStorefrontMetadata, PlatformInit } from "@cartbase/storefront/platform"
9
+ import { STORE_LOCALE } from "@/lib/config"
10
+ import { getConsentSettings, getMenuSafe, getStoreIdentity, getTracking } from "@/lib/catalog"
11
+ import { Providers } from "./providers"
12
+ import "./globals.css"
13
+
14
+ // createStorefrontMetadata (platform-fingerprints card) stamps
15
+ // `<meta name="generator" content="Cartbase" />` on every page — never
16
+ // hand-write `generator` here.
17
+ export async function generateMetadata(): Promise<Metadata> {
18
+ const store = await getStoreIdentity()
19
+ return createStorefrontMetadata({
20
+ title: store.name,
21
+ description: store.brand.slogan ?? undefined,
22
+ })
23
+ }
24
+
25
+ function MenuNav({ menu }: { menu: Menu | null }) {
26
+ if (!menu || menu.items.length === 0) return null
27
+ return (
28
+ <nav className="flex items-center gap-4">
29
+ {menu.items.map((item) => (
30
+ <Link
31
+ key={`${item.title}-${item.url}`}
32
+ href={item.url}
33
+ className="text-sm text-muted-foreground hover:text-foreground"
34
+ >
35
+ {item.title}
36
+ </Link>
37
+ ))}
38
+ </nav>
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Runbook steps 3–4: bootstrap store config at layout level, then mount
44
+ * order inside <body>: ConsentInit FIRST (static, synchronous), then the
45
+ * consent-gated UI, then navigation. A store with no menus 404s every
46
+ * handle — render no nav, never crash (menus.md).
47
+ *
48
+ * Nothing here reads the request. The store, the consent settings, the tags
49
+ * and the menus are cached reads (lib/catalog.ts), so the shell is
50
+ * prerendered and every page is served from the CDN (Cache Components). The
51
+ * cart is the visitor's: the drawer reads its id from the cookie in the
52
+ * browser and the cart after it (providers.tsx), and the cart button counts
53
+ * the drawer's cart, so no page waits for the cart.
54
+ */
55
+ export default async function RootLayout({
56
+ children,
57
+ }: {
58
+ children: React.ReactNode
59
+ }) {
60
+ const [store, consent, tracking, mainMenu, footerMenu] = await Promise.all([
61
+ getStoreIdentity(),
62
+ getConsentSettings(),
63
+ getTracking(),
64
+ getMenuSafe("main-menu"),
65
+ getMenuSafe("footer"),
66
+ ])
67
+
68
+ return (
69
+ <html lang={STORE_LOCALE.code}>
70
+ <body>
71
+ {/* The store's own consent switch decides the default: a store
72
+ that collects consent starts every visitor denied until they
73
+ choose, a store with the banner off starts them granted. The
74
+ prop is required, so a layout cannot leave it out. */}
75
+ <ConsentInit required={consent.enabled} />
76
+ {/* Every marketing tag the store configured in the admin, mounted
77
+ from its own config: Meta, TikTok, ChatGPT, Google (GA4 + Ads
78
+ on one tag), GTM. Nothing to wire per vendor: saving the ids
79
+ under Settings, Integrations is the whole merchant-side act.
80
+ Order matters: ConsentInit sets the Consent Mode defaults
81
+ synchronously ABOVE this, so every tag below inherits the
82
+ gate. The config is a cached read handed over, so the tags
83
+ cost the page nothing at request time. */}
84
+ {tracking ? <StorefrontTags config={tracking} /> : null}
85
+ {/* Captures UTMs and the ad-click ids (ttclid / gclid / gbraid /
86
+ wbraid) that exist ONLY on an ad's landing URL — miss them
87
+ here and no platform can attribute the order to the click. */}
88
+ <TrackInit />
89
+ <PlatformInit storeId={store.id} />
90
+ <Providers consent={consent}>
91
+ <header className="border-b border-border">
92
+ <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between gap-6">
93
+ <Link href="/" className="font-semibold text-lg">
94
+ {store.name}
95
+ </Link>
96
+ <div className="flex items-center gap-6">
97
+ <MenuNav menu={mainMenu} />
98
+ <Link
99
+ href="/search"
100
+ className="text-sm text-muted-foreground hover:text-foreground"
101
+ >
102
+ Search
103
+ </Link>
104
+ {/*
105
+ The component workbench lives at /gallery in THIS app only
106
+ (the seed excludes it, src/lib/storefront/seed.ts), so the
107
+ shop's own nav never links to it: a merchant's store must
108
+ not carry a dead link.
109
+ */}
110
+ {/* No `cart` prop: the badge counts the drawer's own cart. */}
111
+ <CartButtonClient />
112
+ </div>
113
+ </div>
114
+ </header>
115
+ <main>{children}</main>
116
+ <footer className="border-t border-border mt-12">
117
+ <div className="max-w-7xl mx-auto px-4 py-8 text-sm text-muted-foreground flex items-center justify-between">
118
+ <span>{store.name}</span>
119
+ <MenuNav menu={footerMenu} />
120
+ </div>
121
+ </footer>
122
+ </Providers>
123
+ </body>
124
+ </html>
125
+ )
126
+ }
@@ -1,28 +1,37 @@
1
- import { StoreTemplate } from "@cartbase/storefront/store/store-template"
2
- import type { SortOptions } from "@cartbase/storefront/lib/sort-products"
3
- import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
4
- import { getServerClient } from "@/lib/server-client"
5
-
6
- /**
7
- * Home = the all-products listing (runbook step 5: always pass a pricing
8
- * context or prices come back undecorated).
9
- */
10
- export default async function HomePage({
11
- searchParams,
12
- }: {
13
- searchParams: Promise<{ sortBy?: string; page?: string }>
14
- }) {
15
- const { sortBy, page } = await searchParams
16
- const client = await getServerClient()
17
- return (
18
- <StoreTemplate
19
- client={client}
20
- sortBy={sortBy as SortOptions | undefined}
21
- page={page}
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}
26
- />
27
- )
28
- }
1
+ import { Suspense } from "react"
2
+ import { StoreTemplate } from "@cartbase/storefront/store/store-template"
3
+ import { SkeletonProductGrid } from "@cartbase/storefront/store/skeleton-product-grid"
4
+ import type { SortOptions } from "@cartbase/storefront/lib/sort-products"
5
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
6
+ import { storeClient } from "@/lib/store-client"
7
+
8
+ type ListingQuery = { sortBy?: string; page?: string }
9
+
10
+ /**
11
+ * Home = the all-products listing (runbook step 5: always pass a pricing
12
+ * context or prices come back undecorated). The sort and the page number
13
+ * are request data, so the listing renders behind a boundary and the shell
14
+ * around it stays prerendered.
15
+ */
16
+ export default function HomePage({ searchParams }: { searchParams: Promise<ListingQuery> }) {
17
+ return (
18
+ <Suspense fallback={<SkeletonProductGrid />}>
19
+ <Listing searchParams={searchParams} />
20
+ </Suspense>
21
+ )
22
+ }
23
+
24
+ async function Listing({ searchParams }: { searchParams: Promise<ListingQuery> }) {
25
+ const { sortBy, page } = await searchParams
26
+ return (
27
+ <StoreTemplate
28
+ client={storeClient}
29
+ sortBy={sortBy as SortOptions | undefined}
30
+ page={page}
31
+ pricingContext={PRICING_CONTEXT}
32
+ // Server-rendered: the locale provider cannot reach it, so the pack
33
+ // comes as a prop, and the prop is required.
34
+ labels={STORE_LOCALE.store}
35
+ />
36
+ )
37
+ }
@@ -1,87 +1,89 @@
1
- import { notFound } from "next/navigation"
2
- import { retrieveProduct } from "@cartbase/storefront/api/products"
3
- import { StoreApiError } from "@cartbase/storefront/api/types"
4
- import { ProductTemplate } from "@cartbase/storefront/products/product-template"
5
- import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
6
- import { getServerClient } from "@/lib/server-client"
7
-
8
- /**
9
- * PDP (runbook step 5): fetch by handle WITH the pricing context, render
10
- * the full product template. No `addToCart` is passed: the add goes
11
- * through the cart drawer mounted in `providers.tsx`, so the drawer opens
12
- * at the click with the product in it and no page render runs (the product
13
- * page contract, rule 6). The drawer's `onCartChange` owns the cart cookie.
14
- */
15
- export default async function ProductPage({
16
- params,
17
- searchParams,
18
- }: {
19
- params: Promise<{ handle: string }>
20
- searchParams: Promise<{ variant?: string }>
21
- }) {
22
- const [{ handle }, { variant }] = await Promise.all([params, searchParams])
23
- const client = await getServerClient()
24
-
25
- let product
26
- try {
27
- const res = await retrieveProduct(client, handle, PRICING_CONTEXT)
28
- product = res.product
29
- } catch (e) {
30
- if (e instanceof StoreApiError && e.status === 404) notFound()
31
- throw e
32
- }
33
-
34
- return (
35
- <ProductTemplate
36
- client={client}
37
- product={product}
38
- pricingContext={PRICING_CONTEXT}
39
- // The product page contract: the address names the variant
40
- // (`?variant=<digits>`, Shopify's parameter), and the page hands it to
41
- // the template so the FIRST paint is that variant's price, code and
42
- // stock. Switching a variant rewrites the address in the browser
43
- // without asking the server.
44
- initialVariantId={variant}
45
- // NO `promises` here, deliberately. Until 0.13.0 the library carried
46
- // delivery, exchange and return promises as label DEFAULTS, so every
47
- // store scaffolded from this file told shoppers "your package will
48
- // arrive in 3-5 business days" and "we'll refund your money" without
49
- // anyone having decided that. Those are YOUR commitments, so you write
50
- // them:
51
- //
52
- // promises={[
53
- // { icon: "delivery", title: "Fast delivery",
54
- // body: "Your order arrives in 2 working days." },
55
- // ]}
56
- //
57
- // Pass nothing and the section does not exist, which is the right
58
- // default for a store that has not decided yet. The physical-facts
59
- // section appears on its own for products that HAVE facts, and is
60
- // skipped for those that do not.
61
- //
62
- // The pack has to be handed over here, not just mounted at the root:
63
- // the related-products strip is a server component and cannot read
64
- // the locale provider, so its heading would stay English. Required.
65
- labels={STORE_LOCALE.products}
66
- />
67
- )
68
- }
69
-
70
- export async function generateMetadata({
71
- params,
72
- }: {
73
- params: Promise<{ handle: string }>
74
- }) {
75
- const { handle } = await params
76
- const client = await getServerClient()
77
- try {
78
- const { product } = await retrieveProduct(client, handle)
79
- // SEO fields with title/description fallbacks (runbook step 5).
80
- return {
81
- title: product.seo_title ?? product.title,
82
- description: product.seo_description ?? product.description ?? undefined,
83
- }
84
- } catch {
85
- return {}
86
- }
87
- }
1
+ import { notFound } from "next/navigation"
2
+ import { ProductTemplate } from "@cartbase/storefront/products/product-template"
3
+ import { getProduct, listProductHandles } from "@/lib/catalog"
4
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
5
+ import { storeClient } from "@/lib/store-client"
6
+
7
+ /**
8
+ * Every product's page is prerendered at build, so it is a file on the CDN.
9
+ * A product added after the last deploy renders on its first visit and is
10
+ * kept from then on. Without this list the handle would be request data and
11
+ * every product page would render per visit (Cache Components).
12
+ */
13
+ export async function generateStaticParams() {
14
+ const handles = await listProductHandles()
15
+ // Cache Components refuses an empty list; a store with no products yet
16
+ // still builds, and the placeholder answers 404.
17
+ return handles.length ? handles.map((handle) => ({ handle })) : [{ handle: "__none__" }]
18
+ }
19
+
20
+ /**
21
+ * PDP (runbook step 5): the product is a cached read by handle WITH the
22
+ * pricing context (lib/catalog.ts), and the template renders it. The buy
23
+ * box re-reads the product live behind its own boundary, so price and stock
24
+ * are this minute's while the rest of the page is prerendered.
25
+ *
26
+ * No `addToCart` is passed: the add goes through the cart drawer mounted in
27
+ * `providers.tsx`, so the drawer opens at the click with the product in it
28
+ * and no page render runs (the product page contract, rule 6). The drawer's
29
+ * `onCartChange` owns the cart cookie.
30
+ */
31
+ export default async function ProductPage({
32
+ params,
33
+ searchParams,
34
+ }: {
35
+ params: Promise<{ handle: string }>
36
+ searchParams: Promise<{ variant?: string }>
37
+ }) {
38
+ const { handle } = await params
39
+ const product = await getProduct(handle)
40
+ if (!product) notFound()
41
+
42
+ return (
43
+ <ProductTemplate
44
+ client={storeClient}
45
+ product={product}
46
+ pricingContext={PRICING_CONTEXT}
47
+ // The product page contract: the address names the variant
48
+ // (`?variant=<digits>`, Shopify's parameter), and the FIRST paint of
49
+ // the buy box is that variant's price, code and stock. The query is
50
+ // request data, so it goes in as a promise and the live buy box reads
51
+ // it behind its boundary. Switching a variant rewrites the address in
52
+ // the browser without asking the server.
53
+ initialVariantId={searchParams.then((query) => query.variant)}
54
+ // NO `promises` here, deliberately. Until 0.13.0 the library carried
55
+ // delivery, exchange and return promises as label DEFAULTS, so every
56
+ // store scaffolded from this file told shoppers "your package will
57
+ // arrive in 3-5 business days" and "we'll refund your money" without
58
+ // anyone having decided that. Those are YOUR commitments, so you write
59
+ // them:
60
+ //
61
+ // promises={[
62
+ // { icon: "delivery", title: "Fast delivery",
63
+ // body: "Your order arrives in 2 working days." },
64
+ // ]}
65
+ //
66
+ // Pass nothing and the section does not exist, which is the right
67
+ // default for a store that has not decided yet. The physical-facts
68
+ // section appears on its own for products that HAVE facts, and is
69
+ // skipped for those that do not.
70
+ //
71
+ // The pack has to be handed over here, not just mounted at the root:
72
+ // the related-products strip is a server component and cannot read
73
+ // the locale provider, so its heading would stay English. Required.
74
+ labels={STORE_LOCALE.products}
75
+ />
76
+ )
77
+ }
78
+
79
+ export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }) {
80
+ const { handle } = await params
81
+ // The page's own read: one cache entry answers both.
82
+ const product = await getProduct(handle).catch(() => null)
83
+ if (!product) return {}
84
+ // SEO fields with title/description fallbacks (runbook step 5).
85
+ return {
86
+ title: product.seo_title ?? product.title,
87
+ description: product.seo_description ?? product.description ?? undefined,
88
+ }
89
+ }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import type { Cart } from "@cartbase/storefront/api/carts"
3
+ import { useState } from "react"
4
4
  import { CartDrawerProvider } from "@cartbase/storefront/cart-drawer/context"
5
5
  import { CartDrawerTemplate } from "@cartbase/storefront/cart-drawer/template"
6
6
  import { ConsentBanner } from "@cartbase/storefront/tracking/consent-banner"
@@ -10,8 +10,12 @@ import {
10
10
  type ConsentSettings,
11
11
  } from "@cartbase/storefront/tracking/consent"
12
12
  import { StorefrontLocaleProvider } from "@cartbase/storefront/locales"
13
- import { clearCartCookie } from "@cartbase/storefront/lib/cookie-names"
14
- import { CART_COOKIE, CART_COOKIE_MAX_AGE, STORE_LOCALE } from "@/lib/config"
13
+ import {
14
+ clearCartCookie,
15
+ readBrowserCartId,
16
+ writeCartCookie,
17
+ } from "@cartbase/storefront/lib/cookie-names"
18
+ import { STORE_LOCALE } from "@/lib/config"
15
19
  import { browserClient } from "@/lib/browser-client"
16
20
 
17
21
  /**
@@ -26,28 +30,27 @@ import { browserClient } from "@/lib/browser-client"
26
30
  * area (`labels={STORE_LOCALE.store}`), and those props are required.
27
31
  */
28
32
  export function Providers({
29
- cart,
30
33
  consent,
31
34
  children,
32
35
  }: {
33
- cart: Cart | null
34
36
  consent: ConsentSettings
35
37
  children: React.ReactNode
36
38
  }) {
39
+ // The stored cart id, read once in the browser: the prerendered shell
40
+ // knows no visitor, so the server renders without one, and the drawer
41
+ // reads the cart as soon as it has the id.
42
+ const [cartId] = useState(readBrowserCartId)
37
43
  // The store's own consent copy, in the store's language where it has it;
38
44
  // pickConsentCopy falls back to English and then to whatever exists.
39
45
  const copy = pickConsentCopy(consent.copy, STORE_LOCALE.code)
40
46
  return (
41
47
  <StorefrontLocaleProvider locale={STORE_LOCALE}>
42
48
  <CartDrawerProvider
43
- cart={cart}
49
+ cartId={cartId}
44
50
  client={browserClient}
45
- onCartChange={(next) => {
46
- // Persist the cart id (carts.md: the app owns the cart cookie).
47
- document.cookie = `${CART_COOKIE}=${encodeURIComponent(
48
- next.id
49
- )};path=/;max-age=${CART_COOKIE_MAX_AGE};samesite=lax`
50
- }}
51
+ // Persist the cart id (carts.md: the app decides when the cart
52
+ // cookie is written; the package owns its name and shape).
53
+ onCartChange={(next) => writeCartCookie(next.id)}
51
54
  // The cart is over (ordered, or gone): forget the stored id, so the
52
55
  // next add starts a new cart.
53
56
  onCartEnd={clearCartCookie}
@@ -1,23 +1,33 @@
1
- import { SearchTemplate } from "@cartbase/storefront/store/search-template"
2
- import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
3
- import { getServerClient } from "@/lib/server-client"
4
-
5
- /** Search page (runbook step 5) — fully URL-state driven search-template. */
6
- export default async function SearchPage({
7
- searchParams,
8
- }: {
9
- searchParams: Promise<Record<string, string | string[] | undefined>>
10
- }) {
11
- const params = await searchParams
12
- const client = await getServerClient()
13
- return (
14
- <SearchTemplate
15
- client={client}
16
- searchParams={params}
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}
21
- />
22
- )
23
- }
1
+ import { Suspense } from "react"
2
+ import { SearchTemplate } from "@cartbase/storefront/store/search-template"
3
+ import { SkeletonProductGrid } from "@cartbase/storefront/store/skeleton-product-grid"
4
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
5
+ import { storeClient } from "@/lib/store-client"
6
+
7
+ type Query = Record<string, string | string[] | undefined>
8
+
9
+ /**
10
+ * Search page (runbook step 5) — fully URL-state driven search-template. The
11
+ * query is request data, so the results render behind a boundary.
12
+ */
13
+ export default function SearchPage({ searchParams }: { searchParams: Promise<Query> }) {
14
+ return (
15
+ <Suspense fallback={<SkeletonProductGrid />}>
16
+ <Results searchParams={searchParams} />
17
+ </Suspense>
18
+ )
19
+ }
20
+
21
+ async function Results({ searchParams }: { searchParams: Promise<Query> }) {
22
+ const params = await searchParams
23
+ return (
24
+ <SearchTemplate
25
+ client={storeClient}
26
+ searchParams={params}
27
+ pricingContext={PRICING_CONTEXT}
28
+ // Server-rendered: the locale provider cannot reach it, so the pack
29
+ // comes as a prop, and the prop is required.
30
+ labels={STORE_LOCALE.store}
31
+ />
32
+ )
33
+ }
@@ -0,0 +1,120 @@
1
+ import { cacheLife, cacheTag } from "next/cache"
2
+ import { getConsent, type ConsentConfig } from "@cartbase/storefront/api/consent"
3
+ import { getMenu, type Menu } from "@cartbase/storefront/api/menus"
4
+ import { listProducts, retrieveProduct, type StoreProduct } from "@cartbase/storefront/api/products"
5
+ import { getStore, type StoreIdentity } from "@cartbase/storefront/api/store"
6
+ import { getTrackingConfig } from "@cartbase/storefront/tracking/get-tracking-config"
7
+ import type { TrackingConfig } from "@cartbase/storefront/tracking/types"
8
+ import { BARTER_CLIENT_ID, PRICING_CONTEXT } from "./config"
9
+ import { storeClient } from "./store-client"
10
+
11
+ /**
12
+ * The store's cached reads (runbook step 3, Cache Components). Each is a
13
+ * `use cache: remote` function on the `catalog` life (next.config.ts): the
14
+ * platform is asked once per window, whichever instance renders, and every
15
+ * page built from these reads is a prerendered file on the CDN.
16
+ *
17
+ * Failure contract: inside the cache a failed read THROWS, so a platform
18
+ * blip is never stored as an empty answer for the whole window; the public
19
+ * function decides what an error reads as. An unknown handle (404) is an
20
+ * answer, null, and is kept.
21
+ */
22
+
23
+ function isNotFound(error: unknown): boolean {
24
+ return (error as { status?: number } | null)?.status === 404
25
+ }
26
+
27
+ const FALLBACK_STORE: StoreIdentity = {
28
+ id: BARTER_CLIENT_ID ?? "",
29
+ name: "Store",
30
+ slug: "",
31
+ brand: {
32
+ logo_url: null,
33
+ logo_square_url: null,
34
+ color_primary: null,
35
+ color_secondary: null,
36
+ slogan: null,
37
+ },
38
+ }
39
+
40
+ async function readStore(): Promise<StoreIdentity> {
41
+ "use cache: remote"
42
+ cacheLife("catalog")
43
+ cacheTag("catalog")
44
+ const { store } = await getStore(storeClient)
45
+ return store
46
+ }
47
+
48
+ /**
49
+ * The store's own identity (store.md): name, slug, brand. A storefront never
50
+ * hardcodes its name, and a transient API error never takes the layout down.
51
+ */
52
+ export async function getStoreIdentity(): Promise<StoreIdentity> {
53
+ return readStore().catch(() => FALLBACK_STORE)
54
+ }
55
+
56
+ /** The store's consent configuration; the platform always answers a complete one. */
57
+ export async function getConsentSettings(): Promise<ConsentConfig> {
58
+ "use cache: remote"
59
+ cacheLife("catalog")
60
+ cacheTag("catalog")
61
+ const { consent } = await getConsent(storeClient)
62
+ return consent
63
+ }
64
+
65
+ async function readTracking(): Promise<TrackingConfig> {
66
+ "use cache: remote"
67
+ cacheLife("catalog")
68
+ cacheTag("catalog")
69
+ return getTrackingConfig(storeClient)
70
+ }
71
+
72
+ /** The store's marketing tags; null when the platform does not answer, so no tag mounts. */
73
+ export async function getTracking(): Promise<TrackingConfig | null> {
74
+ return readTracking().catch(() => null)
75
+ }
76
+
77
+ async function readMenu(handle: string): Promise<Menu | null> {
78
+ "use cache: remote"
79
+ cacheLife("catalog")
80
+ cacheTag("catalog")
81
+ try {
82
+ const { menu } = await getMenu(storeClient, handle)
83
+ return menu
84
+ } catch (error) {
85
+ if (isNotFound(error)) return null
86
+ throw error
87
+ }
88
+ }
89
+
90
+ /** One menu by handle, or null: a store with no menus 404s every handle (menus.md). */
91
+ export async function getMenuSafe(handle: string): Promise<Menu | null> {
92
+ return readMenu(handle).catch(() => null)
93
+ }
94
+
95
+ /** One product by handle, priced; null for a handle the store does not have. */
96
+ export async function getProduct(handle: string): Promise<StoreProduct | null> {
97
+ "use cache: remote"
98
+ cacheLife("catalog")
99
+ cacheTag("catalog")
100
+ try {
101
+ const { product } = await retrieveProduct(storeClient, handle, PRICING_CONTEXT)
102
+ return product
103
+ } catch (error) {
104
+ if (isNotFound(error)) return null
105
+ throw error
106
+ }
107
+ }
108
+
109
+ /** The platform's largest page of products. */
110
+ const PAGE = 200
111
+
112
+ /** Every product handle, for the product pages the build prerenders. */
113
+ export async function listProductHandles(): Promise<string[]> {
114
+ const handles: string[] = []
115
+ for (let offset = 0; ; offset += PAGE) {
116
+ const { products, count } = await listProducts(storeClient, { limit: PAGE, offset })
117
+ handles.push(...products.map((product) => product.handle).filter(Boolean))
118
+ if (!products.length || offset + PAGE >= count) return handles
119
+ }
120
+ }
@@ -15,13 +15,13 @@ export const BARTER_URL = cartbaseApiOrigin(process.env.NEXT_PUBLIC_CARTBASE_URL
15
15
  export const BARTER_CLIENT_ID = process.env.NEXT_PUBLIC_CARTBASE_CLIENT_ID || undefined
16
16
 
17
17
  /**
18
- * Cart-id cookie — owned by the app (the SDK never persists the cart);
19
- * the NAME is owned by the library so every consumer emits the same wire
20
- * fingerprint (platform-fingerprints card). `readCartCookie` is the
21
- * backward-compat reader for the legacy `_barter_cart_id` name.
18
+ * Cart-id cookie: the app decides when it is written (providers.tsx hands
19
+ * `writeCartCookie` to the drawer); the NAME and shape are the library's so
20
+ * every consumer emits the same wire fingerprint (platform-fingerprints
21
+ * card). `readCartCookie` is the backward-compat reader for the legacy
22
+ * `_barter_cart_id` name, which the checkout uses on the server.
22
23
  */
23
24
  export { CART_COOKIE, readCartCookie } from "@cartbase/storefront/lib/cookie-names"
24
- export const CART_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
25
25
 
26
26
  /** Locale cookie read by the clients' `getLocale`. */
27
27
  export const LOCALE_COOKIE = "_barter_locale"
@@ -0,0 +1,16 @@
1
+ import { StorefrontClient } from "@cartbase/storefront/api/http"
2
+ import { BARTER_CLIENT_ID, BARTER_PUBLISHABLE_KEY, BARTER_URL } from "./config"
3
+
4
+ /**
5
+ * The catalogue's client on the server (runbook step 2): the store's key and
6
+ * nothing about the visitor. It reads no cookie, so every read made with it
7
+ * can be cached (`lib/catalog.ts`) and every page built from those reads is
8
+ * prerendered. A store with one language and no customer prices has nothing
9
+ * in its catalogue that depends on who asks. Reads that belong to one
10
+ * visitor (the checkout's cart) use `getServerClient` instead.
11
+ */
12
+ export const storeClient = new StorefrontClient({
13
+ baseUrl: BARTER_URL,
14
+ clientId: BARTER_CLIENT_ID,
15
+ publishableKey: BARTER_PUBLISHABLE_KEY,
16
+ })