create-cartbase 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -3
  3. package/dist/index.js +94 -0
  4. package/package.json +18 -4
  5. package/template/app/CLAUDE.md +18 -0
  6. package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
  7. package/template/app/docs/README.md +76 -0
  8. package/template/app/docs/auth.md +105 -0
  9. package/template/app/docs/carts.md +376 -0
  10. package/template/app/docs/categories.md +194 -0
  11. package/template/app/docs/checkout.md +611 -0
  12. package/template/app/docs/collections.md +167 -0
  13. package/template/app/docs/components.md +1089 -0
  14. package/template/app/docs/consent.md +81 -0
  15. package/template/app/docs/content.md +126 -0
  16. package/template/app/docs/customers.md +269 -0
  17. package/template/app/docs/deploy.md +192 -0
  18. package/template/app/docs/gift-cards.md +153 -0
  19. package/template/app/docs/integrations.md +137 -0
  20. package/template/app/docs/menus.md +73 -0
  21. package/template/app/docs/metaobjects.md +126 -0
  22. package/template/app/docs/orders.md +221 -0
  23. package/template/app/docs/products.md +300 -0
  24. package/template/app/docs/redirects.md +50 -0
  25. package/template/app/docs/regions.md +207 -0
  26. package/template/app/docs/reviews.md +223 -0
  27. package/template/app/docs/search.md +218 -0
  28. package/template/app/docs/subscriptions.md +148 -0
  29. package/template/app/next.config.ts +34 -0
  30. package/template/app/package.json +25 -0
  31. package/template/app/postcss.config.cjs +6 -0
  32. package/template/app/smoke.mjs +158 -0
  33. package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
  34. package/template/app/src/app/checkout/page.tsx +49 -0
  35. package/template/app/src/app/globals.css +42 -0
  36. package/template/app/src/app/layout.tsx +105 -0
  37. package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
  38. package/template/app/src/app/page.tsx +25 -0
  39. package/template/app/src/app/products/[handle]/page.tsx +58 -0
  40. package/template/app/src/app/providers.tsx +54 -0
  41. package/template/app/src/app/search/page.tsx +20 -0
  42. package/template/app/src/lib/browser-client.ts +35 -0
  43. package/template/app/src/lib/cart-actions.ts +43 -0
  44. package/template/app/src/lib/config.ts +16 -0
  45. package/template/app/src/lib/server-client.ts +25 -0
  46. package/template/app/tailwind.config.cjs +9 -0
  47. package/template/app/tsconfig.json +41 -0
  48. package/template/app/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,148 @@
1
+ # Subscriptions — the customer portal
2
+
3
+ The "My subscriptions" surface (subscriptions-portal card): list, detail,
4
+ schedule control, contract edits, cancel/reactivate and payment-method
5
+ recovery. Every endpoint requires a **customer session**
6
+ (`authorization: Bearer <supabase jwt>` — see [auth.md](auth.md)) **plus**
7
+ `x-client-id`. Missing/invalid JWT → `401 {code: "unauthenticated"}`;
8
+ a subscription that isn't the caller's own → **`404 not_found`** (never
9
+ 403 — existence is not confirmed across accounts).
10
+
11
+ The API contract (all routes, shapes, error codes):
12
+ `docs/contracts/store-api.md` § Subscriptions portal. This doc is the
13
+ component-facing guide.
14
+
15
+ > The executable blocks prove the **auth boundary** — the docs harness is
16
+ > anonymous; happy paths are pinned by
17
+ > `tests/store/subscriptions-portal.test.ts` and
18
+ > `tests/store/subscription-payment-update.test.ts` with real sessions.
19
+
20
+ ## Render actions from `permissions` — never hardcode
21
+
22
+ Every detail payload carries the merchant's live portal policy:
23
+
24
+ ```jsonc
25
+ "permissions": {
26
+ "allow_skip": true, // merchant toggles (Settings → Subscriptions)
27
+ "allow_reschedule": true,
28
+ "allow_pause": true,
29
+ "allow_frequency_change": true,
30
+ "allow_line_edits": true,
31
+ "allow_address_change": true,
32
+ "allow_cancel": true, // constant — cancel is a customer right
33
+ "can_update_payment": false // true only for card (Stripe) contracts
34
+ }
35
+ ```
36
+
37
+ Components MUST render conditionally from this object — a toggled-off
38
+ action answers `403 {code: "portal_action_disabled"}`, so hiding the button
39
+ is UX, the server is the enforcement. Cancel is ALWAYS shown (the cancel
40
+ law: retention offers may render alongside, never instead). Payment update
41
+ renders only when `can_update_payment` — COD/offline contracts have no card.
42
+
43
+ ## GET /api/store/subscriptions — my contracts
44
+
45
+ `{ subscriptions: [...], count }` — newest first. `payment_method` is the
46
+ merchant-facing display name from the payments registry (never `pp_*`).
47
+
48
+ ```bash
49
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/subscriptions" \
50
+ -H "x-client-id: $CLIENT_ID")
51
+ test "$STATUS" = 401
52
+ ```
53
+
54
+ ## GET /api/store/subscriptions/:id — the receipt view
55
+
56
+ Sanitized detail: `plan`, `lines` (titles + contracted `unit_price`),
57
+ `cycles` (index, status, date, linked order display id — no internal error
58
+ strings), `upcoming` (next 3 PROJECTED charge dates; empty unless active)
59
+ and `permissions`. Render the cycle list as order history; a `failed` cycle
60
+ plus `can_update_payment` is the cue to surface the payment-update flow
61
+ prominently (that pairing IS dunning recovery).
62
+
63
+ ```bash
64
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/subscriptions/sub_doesnotexist" \
65
+ -H "x-client-id: $CLIENT_ID")
66
+ test "$STATUS" = 401
67
+ ```
68
+
69
+ ## Actions
70
+
71
+ All POST, all answer `{ subscription }` (the fresh detail — re-render from
72
+ it, no refetch needed):
73
+
74
+ | Route | Body | Gate |
75
+ |---|---|---|
76
+ | `/:id/skip` | — | `allow_skip` |
77
+ | `/:id/charge-date` | `{ next_charge_at }` | `allow_reschedule` |
78
+ | `/:id/pause` | `{ until? }` | `allow_pause` |
79
+ | `/:id/resume` | — | right |
80
+ | `/:id/cancel` | `{ reason? }` | right |
81
+ | `/:id/reactivate` | `{ next_charge_at? }` | right |
82
+ | `/:id/address` | `{ shipping_address }` | `allow_address_change` |
83
+ | `/:id/lines/:lineId` | `{ quantity?, variant_id? }` | `allow_line_edits` |
84
+ | `/:id/plan` | `{ selling_plan_id }` | `allow_frequency_change` |
85
+
86
+ Component notes:
87
+
88
+ - **Skip** — confirm-dialog copy should show the NEW next date (current
89
+ next date + one plan interval). A cycle mid-payment-retry cannot skip
90
+ (400) — hide skip while the latest cycle is `failed`.
91
+ - **Pause** — offer preset durations (1/2/3 months → `until`); an `until`
92
+ pause auto-resumes server-side, no customer action needed. Indefinite
93
+ pause (no body) needs an explicit Resume.
94
+ - **Reactivate** — render on canceled contracts; default schedule is
95
+ now + interval and it NEVER charges immediately — say so in the copy.
96
+ - **Swap / frequency** — variant options come from the product's variants
97
+ (same product only); frequency options are the product's other selling
98
+ plans (`GET /api/store/products/:id/selling-plans`). Both re-price
99
+ server-side through the one price engine — display the returned
100
+ `unit_price`, never compute prices client-side.
101
+
102
+ ```bash
103
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/subscriptions/sub_doesnotexist/cancel" \
104
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
105
+ test "$STATUS" = 401
106
+ ```
107
+
108
+ ## Payment-method update (dunning recovery)
109
+
110
+ Two steps, card contracts only:
111
+
112
+ 1. `POST /:id/payment-method/session` → `{ session: { setup_intent_id,
113
+ client_secret, publishable_key } }`.
114
+ 2. Confirm client-side with Stripe.js — card fields never touch Cartbase:
115
+
116
+ ```jsonc
117
+ // stripe = Stripe(session.publishable_key)
118
+ // elements = stripe.elements({ clientSecret: session.client_secret })
119
+ // mount PaymentElement, then:
120
+ // await stripe.confirmSetup({ elements, redirect: "if_required" })
121
+ ```
122
+
123
+ 3. `POST /:id/payment-method` with `{ setup_intent_id }` → verified +
124
+ stamped; the response is the fresh detail. The next renewal charge (the
125
+ automatic retry ladder or the merchant's "Retry now") uses the new card.
126
+
127
+ Entry points to build: the payment-failed email links here; the detail view
128
+ surfaces it on `failed` cycles; the account shell may badge past-due
129
+ subscriptions.
130
+
131
+ ```bash
132
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/subscriptions/sub_doesnotexist/payment-method/session" \
133
+ -H "x-client-id: $CLIENT_ID" -d '')
134
+ test "$STATUS" = 401
135
+ ```
136
+
137
+ ## Checkout + confirmation touchpoints
138
+
139
+ - **Consent line at checkout**: subscription carts save the card for future
140
+ charges (`setup_future_usage: off_session`) — the checkout MUST show the
141
+ mandate text next to the pay button. [checkout.md](checkout.md) documents
142
+ the duty; the component ships with the portal family.
143
+ - **Order confirmation**: a completed subscription checkout returns
144
+ contracts (cycle 1 = that order) — show "subscription started, next
145
+ charge on <date>" from the order's subscription metadata.
146
+ - **PDP purchase options**: `GET /api/store/products/:id/selling-plans`
147
+ (see [products.md](products.md)) — the plan chosen at PDP rides the cart
148
+ line as `selling_plan_id`.
@@ -0,0 +1,34 @@
1
+ import path from "node:path"
2
+ import type { NextConfig } from "next"
3
+
4
+ const nextConfig: NextConfig = {
5
+ // Monorepo: pin the workspace root (Next otherwise infers it from a
6
+ // stray lockfile OUTSIDE the repo and mis-roots the dev module graph).
7
+ outputFileTracingRoot: path.join(__dirname, "../.."),
8
+ // The smoke driver browses via 127.0.0.1; allow dev-resource access.
9
+ allowedDevOrigins: ["127.0.0.1"],
10
+ // @cartbase/storefront is source-shipped TypeScript (workspace package) —
11
+ // the app's Next build transpiles it.
12
+ transpilePackages: ["@cartbase/storefront"],
13
+ images: {
14
+ // Reference app: seeded product images live on arbitrary demo hosts.
15
+ // A real store should allowlist its media domain via remotePatterns.
16
+ unoptimized: true,
17
+ },
18
+ // The barter store API ships NO CORS headers — browser-side SDK calls
19
+ // must be same-origin. Proxy them through the app origin (the browser
20
+ // client's baseUrl is window.location.origin); server-side SDK calls go
21
+ // straight to NEXT_PUBLIC_BARTER_URL and are unaffected.
22
+ async rewrites() {
23
+ const barterUrl = process.env.NEXT_PUBLIC_BARTER_URL
24
+ if (!barterUrl) return []
25
+ return [
26
+ {
27
+ source: "/api/store/:path*",
28
+ destination: `${barterUrl}/api/store/:path*`,
29
+ },
30
+ ]
31
+ },
32
+ }
33
+
34
+ export default nextConfig
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "cartbase-storefront",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Reference storefront built from docs/storefront/BUILD-A-STOREFRONT.md alone — the adoption proof for @cartbase/storefront.",
6
+ "scripts": {
7
+ "dev": "next dev --webpack -p 4778",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "dependencies": {
11
+ "@cartbase/storefront": "^0.1.0",
12
+ "next": "16.2.4",
13
+ "react": "19.2.4",
14
+ "react-dom": "19.2.4"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^20",
18
+ "@types/react": "^19",
19
+ "@types/react-dom": "^19",
20
+ "autoprefixer": "^10.4.20",
21
+ "postcss": "^8.4.49",
22
+ "tailwindcss": "^3.4.17",
23
+ "typescript": "^5"
24
+ }
25
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Adoption-proof smoke: drives the REAL example-storefront UI from home to
3
+ * a completed pp_manual checkout against the barter backend.
4
+ *
5
+ * Prereqs: backend on :4777, example app on :4778 (see examples/storefront
6
+ * package.json). Run: `node examples/storefront/smoke.mjs` from the repo
7
+ * root (Playwright is a root devDependency). Exits 0 only when the
8
+ * confirmation page shows an order number and totals.
9
+ */
10
+ import { chromium } from "@playwright/test"
11
+
12
+ const BASE = process.env.SMOKE_BASE_URL ?? "http://127.0.0.1:4778"
13
+ const trail = []
14
+ const step = (msg) => {
15
+ trail.push(msg)
16
+ console.log(`[smoke] ${msg}`)
17
+ }
18
+
19
+ const browser = await chromium.launch()
20
+ const page = await browser.newPage()
21
+ page.setDefaultTimeout(30_000)
22
+
23
+ try {
24
+ // ── Home ────────────────────────────────────────────────────────────
25
+ await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" })
26
+ step("home loaded")
27
+
28
+ // Consent banner (builtin modal is the server default) — accept.
29
+ const acceptButton = page.getByRole("button", { name: "Accept", exact: true })
30
+ try {
31
+ await acceptButton.waitFor({ state: "visible", timeout: 10_000 })
32
+ await acceptButton.click()
33
+ step("consent banner accepted")
34
+ } catch {
35
+ step("no consent banner rendered (disabled or external mode)")
36
+ }
37
+
38
+ await page.locator('a[href^="/products/"]').first().waitFor()
39
+ step("home renders product cards")
40
+
41
+ // ── PDP (seeded handle) ─────────────────────────────────────────────
42
+ await page.goto(`${BASE}/products/linen-shirt`, {
43
+ waitUntil: "domcontentloaded",
44
+ })
45
+ await page.getByTestId("product-container").waitFor()
46
+ step("PDP linen-shirt rendered")
47
+
48
+ // Pick the REAL Size option's "M" (the shared dev tenant accretes junk
49
+ // options; variant match needs exactly the Size choice). Dev-mode
50
+ // hydration can lag the SSR paint — retry until the selection sticks.
51
+ const sizeRow = page
52
+ .locator('div.flex.flex-col:has(> span:text-is("Select Size"))')
53
+ .first()
54
+ const mButton = sizeRow.getByRole("button", { name: "M", exact: true })
55
+ let selected = false
56
+ for (let i = 0; i < 30 && !selected; i++) {
57
+ await mButton.click()
58
+ await page.waitForTimeout(700)
59
+ selected = ((await mButton.getAttribute("class")) ?? "").includes(
60
+ "border-primary"
61
+ )
62
+ }
63
+ if (!selected) throw new Error("Size M never became selected")
64
+ step("selected Size M")
65
+
66
+ const addButton = page.getByTestId("add-product-button").first()
67
+ await addButton.click()
68
+ step("clicked Add to cart")
69
+
70
+ // ── Cart drawer → checkout ──────────────────────────────────────────
71
+ // The drawer auto-opens only on a rise from a nonzero base (first add
72
+ // never auto-opens — components.md); open it via the header cart
73
+ // button once the badge confirms the server-side add landed.
74
+ await page
75
+ .getByTestId("nav-cart-link")
76
+ .locator("span")
77
+ .filter({ hasText: /^\d+$/ })
78
+ .waitFor({ timeout: 20_000 })
79
+ step("header cart badge shows the added item")
80
+ await page.getByTestId("nav-cart-link").click()
81
+ const checkoutLink = page.getByRole("link", { name: "Checkout" })
82
+ await checkoutLink.click()
83
+ step("cart drawer opened via header cart button")
84
+ await page.waitForURL("**/checkout")
85
+ step("navigated to /checkout")
86
+
87
+ // ── Address form ────────────────────────────────────────────────────
88
+ const fill = async (name, value) => {
89
+ await page.locator(`[name="${name}"]`).fill(value)
90
+ }
91
+ await fill("email", "smoke-adoption@example.test")
92
+ await fill("shipping_address.first_name", "Ivan")
93
+ await fill("shipping_address.last_name", "Petrov")
94
+ await fill("shipping_address.address_1", "ul. Vitosha 15")
95
+ await fill("shipping_address.postal_code", "1000")
96
+ await fill("shipping_address.city", "Sofia")
97
+ await fill("shipping_address.phone", "+359888123456")
98
+ await page.locator('[name="shipping_address.phone"]').blur()
99
+ step("address form filled (BG address, EUR cart)")
100
+
101
+ // ── Shipping method ─────────────────────────────────────────────────
102
+ await page.getByText("Flat Rate (Bulgaria)", { exact: false }).first().click()
103
+ step('selected shipping "Flat Rate (Bulgaria)"')
104
+
105
+ // ── Payment: manual/offline tab (no Stripe env) ─────────────────────
106
+ const codTab = page.getByRole("button", { name: "Cash on delivery" })
107
+ await codTab.waitFor({ state: "visible" })
108
+ await codTab.click()
109
+ step("selected manual payment (pp_manual offline tab)")
110
+
111
+ // ── Place order ─────────────────────────────────────────────────────
112
+ const submit = page.getByTestId("submit-order-button")
113
+ await submit.waitFor({ state: "visible" })
114
+ // The Buy button enables once the debounced address autosave lands.
115
+ await page.waitForFunction(
116
+ () =>
117
+ !document.querySelector('[data-testid="submit-order-button"]')?.disabled,
118
+ undefined,
119
+ { timeout: 20_000 }
120
+ )
121
+ await submit.click()
122
+ step("clicked Place order")
123
+
124
+ // ── Confirmation ────────────────────────────────────────────────────
125
+ await page.waitForURL("**/order/**/confirmed", { timeout: 60_000 })
126
+ step(`confirmation URL: ${new URL(page.url()).pathname}`)
127
+
128
+ const orderNumberText = await page
129
+ .getByText(/#\d+/)
130
+ .first()
131
+ .innerText({ timeout: 30_000 })
132
+ const displayNumber = orderNumberText.match(/#(\d+)/)?.[1]
133
+ if (!displayNumber) throw new Error("no order display number rendered")
134
+ step(`order number rendered: #${displayNumber}`)
135
+
136
+ // Totals: the order family renders a Total row with a EUR amount.
137
+ const totalRow = page
138
+ .locator("div,li")
139
+ .filter({ hasText: /^Total/ })
140
+ .filter({ hasText: "€" })
141
+ .first()
142
+ const totalText = (await totalRow.innerText()).replace(/\s+/g, " ").trim()
143
+ if (!/€\s?\d/.test(totalText)) throw new Error("no EUR total rendered")
144
+ step(`totals rendered: ${totalText}`)
145
+
146
+ console.log("\n[smoke] PASS — completed checkout, order #" + displayNumber)
147
+ await browser.close()
148
+ process.exit(0)
149
+ } catch (err) {
150
+ console.error("\n[smoke] FAIL after steps:\n - " + trail.join("\n - "))
151
+ console.error(err)
152
+ try {
153
+ await page.screenshot({ path: "examples/storefront/smoke-failure.png" })
154
+ console.error("[smoke] screenshot: examples/storefront/smoke-failure.png")
155
+ } catch {}
156
+ await browser.close()
157
+ process.exit(1)
158
+ }
@@ -0,0 +1,66 @@
1
+ "use client"
2
+
3
+ import { useRouter } from "next/navigation"
4
+ import type { Cart } from "@cartbase/storefront/api/carts"
5
+ import type { StoreShippingOption } from "@cartbase/storefront/api/checkout"
6
+ import type { StorePaymentProvider } from "@cartbase/storefront/api/checkout"
7
+ import type { PublicCodConfig } from "@cartbase/storefront/api/integrations"
8
+ import { CheckoutProvider } from "@cartbase/storefront/checkout/context"
9
+ import { CheckoutClient } from "@cartbase/storefront/checkout/checkout-client"
10
+ import { browserClient } from "@/lib/browser-client"
11
+
12
+ /** sessionStorage key the confirmation page reads (guests have no order
13
+ * read endpoint — the completeCart() response is the only order handle,
14
+ * orders.md). */
15
+ export const LAST_ORDER_STORAGE_KEY = "barter:last-order"
16
+
17
+ export function CheckoutPageClient({
18
+ cart,
19
+ shippingOptions,
20
+ paymentProviders,
21
+ codConfig,
22
+ }: {
23
+ cart: Cart
24
+ shippingOptions: StoreShippingOption[]
25
+ paymentProviders: StorePaymentProvider[]
26
+ codConfig: PublicCodConfig | null
27
+ }) {
28
+ const router = useRouter()
29
+
30
+ return (
31
+ <CheckoutProvider orderConfirmedPath="/order/{id}/confirmed">
32
+ <CheckoutClient
33
+ client={browserClient}
34
+ cart={cart}
35
+ customer={null}
36
+ availableShippingMethods={shippingOptions}
37
+ availablePaymentMethods={paymentProviders}
38
+ countryCode="bg"
39
+ countries={[{ iso_2: "bg", display_name: "Bulgaria" }]}
40
+ // Per-store rule (the documented paymentMethodFilter seam): this
41
+ // reference store checks out offline via pp_manual only — without
42
+ // the filter the hook prefers a tenant-enabled pp_cod for the
43
+ // offline tab.
44
+ paymentMethodFilter={(methods) =>
45
+ methods?.filter((m) => m.id === "pp_manual") ?? null
46
+ }
47
+ codConfig={codConfig}
48
+ onOrderPlaced={(order) => {
49
+ // Guest order handle = the completeCart() response (orders.md).
50
+ // Stash it (+ the decorated cart lines for the items list) for
51
+ // the confirmation page, then navigate.
52
+ try {
53
+ sessionStorage.setItem(
54
+ LAST_ORDER_STORAGE_KEY,
55
+ JSON.stringify({ order, cartItems: cart.items ?? [] })
56
+ )
57
+ } catch {
58
+ // best-effort — the confirmation page has a fallback state
59
+ }
60
+ router.push(`/order/${order.id}/confirmed`)
61
+ }}
62
+ onCartChange={() => router.refresh()}
63
+ />
64
+ </CheckoutProvider>
65
+ )
66
+ }
@@ -0,0 +1,49 @@
1
+ import { cookies } from "next/headers"
2
+ import { redirect } from "next/navigation"
3
+ import { retrieveCart } from "@cartbase/storefront/api/carts"
4
+ import {
5
+ listPaymentProviders,
6
+ listShippingOptions,
7
+ } from "@cartbase/storefront/api/checkout"
8
+ import { getIntegrationsConfig } from "@cartbase/storefront/api/integrations"
9
+ import { CART_COOKIE } from "@/lib/config"
10
+ import { getServerClient } from "@/lib/server-client"
11
+ import { CheckoutPageClient } from "./checkout-page-client"
12
+
13
+ /**
14
+ * Checkout (runbook step 8 / checkout.md): list shipping options and
15
+ * payment providers WITH `cart_id` (server-side rule filtering), fetch the
16
+ * integrations `cod` block, and hand everything to the orchestrated
17
+ * client layout. Redirect completed/missing carts server-side
18
+ * (checkout-client mount rule).
19
+ */
20
+ export default async function CheckoutPage() {
21
+ const jar = await cookies()
22
+ const cartId = jar.get(CART_COOKIE)?.value
23
+ if (!cartId) redirect("/")
24
+
25
+ const client = await getServerClient()
26
+ const cart = await retrieveCart(client, cartId)
27
+ .then((res) => res.cart)
28
+ .catch(() => null)
29
+ if (!cart || (cart.items ?? []).length === 0) redirect("/")
30
+ if (cart.completed_at) redirect("/")
31
+
32
+ const [shippingOptions, paymentProviders, integrations] = await Promise.all([
33
+ listShippingOptions(client, { cart_id: cart.id }),
34
+ listPaymentProviders(client, {
35
+ cart_id: cart.id,
36
+ region_id: cart.region_id ?? undefined,
37
+ }),
38
+ getIntegrationsConfig(client),
39
+ ])
40
+
41
+ return (
42
+ <CheckoutPageClient
43
+ cart={cart}
44
+ shippingOptions={shippingOptions.shipping_options}
45
+ paymentProviders={paymentProviders.payment_providers}
46
+ codConfig={integrations.cod}
47
+ />
48
+ )
49
+ }
@@ -0,0 +1,42 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /*
6
+ * Storefront theme tokens — shadcn-standard names, raw oklch channels
7
+ * (the @cartbase/storefront tailwind-preset wraps them in oklch(...)).
8
+ * Neutral light theme for the reference app.
9
+ */
10
+ :root {
11
+ --background: 1 0 0;
12
+ --foreground: 0.145 0 0;
13
+ --card: 1 0 0;
14
+ --card-foreground: 0.145 0 0;
15
+ --popover: 1 0 0;
16
+ --popover-foreground: 0.145 0 0;
17
+ --primary: 0.205 0 0;
18
+ --primary-foreground: 0.985 0 0;
19
+ --secondary: 0.97 0 0;
20
+ --secondary-foreground: 0.205 0 0;
21
+ --muted: 0.97 0 0;
22
+ --muted-foreground: 0.556 0 0;
23
+ --accent: 0.97 0 0;
24
+ --accent-foreground: 0.205 0 0;
25
+ --destructive: 0.577 0.245 27.325;
26
+ --destructive-foreground: 0.985 0 0;
27
+ --border: 0.922 0 0;
28
+ --input: 0.922 0 0;
29
+ --ring: 0.708 0 0;
30
+ --success: 0.648 0.15 160;
31
+ --success-foreground: 0.985 0 0;
32
+ --warning: 0.828 0.189 84.429;
33
+ --warning-foreground: 0.279 0.077 45.635;
34
+ --radius: 0.5rem;
35
+ --font-display: ui-serif;
36
+ --font-body: system-ui;
37
+ --font-mono: ui-monospace;
38
+ }
39
+
40
+ body {
41
+ @apply bg-background text-foreground font-body;
42
+ }
@@ -0,0 +1,105 @@
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 { CartButtonClient } from "@cartbase/storefront/common/cart-button-client"
9
+ import { CART_COOKIE } from "@/lib/config"
10
+ import { getServerClient } from "@/lib/server-client"
11
+ import { Providers } from "./providers"
12
+ import "./globals.css"
13
+
14
+ export const metadata: Metadata = {
15
+ title: "Barter Example Store",
16
+ description: "Reference storefront built on @cartbase/storefront",
17
+ }
18
+
19
+ /**
20
+ * Runbook steps 3–4: bootstrap store config at layout level, then mount
21
+ * order inside <body>: ConsentInit FIRST (static, synchronous), then the
22
+ * consent-gated UI, then navigation. A store with no menus 404s every
23
+ * handle — render no nav, never crash (menus.md).
24
+ */
25
+ async function fetchMenu(handle: string): Promise<Menu | null> {
26
+ const client = await getServerClient()
27
+ try {
28
+ const { menu } = await getMenu(client, handle)
29
+ return menu
30
+ } catch {
31
+ return null // unknown/deleted handle (404) → graceful "no nav"
32
+ }
33
+ }
34
+
35
+ function MenuNav({ menu }: { menu: Menu | null }) {
36
+ if (!menu || menu.items.length === 0) return null
37
+ return (
38
+ <nav className="flex items-center gap-4">
39
+ {menu.items.map((item) => (
40
+ <Link
41
+ key={`${item.title}-${item.url}`}
42
+ href={item.url}
43
+ className="text-sm text-muted-foreground hover:text-foreground"
44
+ >
45
+ {item.title}
46
+ </Link>
47
+ ))}
48
+ </nav>
49
+ )
50
+ }
51
+
52
+ export default async function RootLayout({
53
+ children,
54
+ }: {
55
+ children: React.ReactNode
56
+ }) {
57
+ const client = await getServerClient()
58
+ const jar = await cookies()
59
+ const cartId = jar.get(CART_COOKIE)?.value ?? null
60
+
61
+ const [consentRes, mainMenu, footerMenu, cart] = await Promise.all([
62
+ getConsent(client),
63
+ fetchMenu("main-menu"),
64
+ fetchMenu("footer"),
65
+ cartId
66
+ ? retrieveCart(client, cartId)
67
+ .then((res): Cart | null => res.cart)
68
+ .catch(() => null) // stale cookie → empty cart state
69
+ : Promise.resolve(null),
70
+ ])
71
+
72
+ return (
73
+ <html lang="en">
74
+ <body>
75
+ <ConsentInit />
76
+ <Providers cart={cart} consent={consentRes.consent}>
77
+ <header className="border-b border-border">
78
+ <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between gap-6">
79
+ <Link href="/" className="font-semibold text-lg">
80
+ Barter Example Store
81
+ </Link>
82
+ <div className="flex items-center gap-6">
83
+ <MenuNav menu={mainMenu} />
84
+ <Link
85
+ href="/search"
86
+ className="text-sm text-muted-foreground hover:text-foreground"
87
+ >
88
+ Search
89
+ </Link>
90
+ <CartButtonClient cart={cart} />
91
+ </div>
92
+ </div>
93
+ </header>
94
+ <main>{children}</main>
95
+ <footer className="border-t border-border mt-12">
96
+ <div className="max-w-7xl mx-auto px-4 py-8 text-sm text-muted-foreground flex items-center justify-between">
97
+ <span>Barter Example Store</span>
98
+ <MenuNav menu={footerMenu} />
99
+ </div>
100
+ </footer>
101
+ </Providers>
102
+ </body>
103
+ </html>
104
+ )
105
+ }