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 +1 -1
- package/template/app/docs/BUILD-A-STOREFRONT.md +60 -10
- package/template/app/docs/components.md +49 -15
- package/template/app/docs/store.md +9 -1
- package/template/app/next.config.ts +11 -0
- package/template/app/package.json +1 -1
- package/template/app/src/app/checkout/page.tsx +12 -1
- package/template/app/src/app/layout.tsx +126 -184
- package/template/app/src/app/page.tsx +37 -28
- package/template/app/src/app/products/[handle]/page.tsx +89 -87
- package/template/app/src/app/providers.tsx +15 -12
- package/template/app/src/app/search/page.tsx +33 -23
- package/template/app/src/lib/catalog.ts +120 -0
- package/template/app/src/lib/config.ts +5 -5
- package/template/app/src/lib/store-client.ts +16 -0
package/package.json
CHANGED
|
@@ -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** (
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
fed by the integrations tracking block
|
|
128
|
-
the same switch
|
|
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
|
|
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
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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** —
|
|
1221
|
-
(the
|
|
1222
|
-
|
|
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
|
|
1307
|
-
`<RatingDistribution>` (`star-badge`),
|
|
1308
|
-
`<
|
|
1309
|
-
`reviewDisplayName` (first name
|
|
1310
|
-
app JSON-LD so UI and structured data
|
|
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
|
|
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
|
|
@@ -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
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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 {
|
|
14
|
-
|
|
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
|
-
|
|
49
|
+
cartId={cartId}
|
|
44
50
|
client={browserClient}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
searchParams={
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
19
|
-
*
|
|
20
|
-
* fingerprint (platform-fingerprints
|
|
21
|
-
* backward-compat reader for the legacy
|
|
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
|
+
})
|