create-cartbase 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +9 -3
- package/dist/index.js +94 -0
- package/package.json +18 -4
- package/template/app/CLAUDE.md +18 -0
- package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
- package/template/app/docs/README.md +77 -0
- package/template/app/docs/auth.md +105 -0
- package/template/app/docs/carts.md +376 -0
- package/template/app/docs/categories.md +194 -0
- package/template/app/docs/checkout.md +611 -0
- package/template/app/docs/collections.md +167 -0
- package/template/app/docs/components.md +1090 -0
- package/template/app/docs/consent.md +81 -0
- package/template/app/docs/content.md +126 -0
- package/template/app/docs/customers.md +269 -0
- package/template/app/docs/deploy.md +192 -0
- package/template/app/docs/gift-cards.md +153 -0
- package/template/app/docs/integrations.md +137 -0
- package/template/app/docs/menus.md +73 -0
- package/template/app/docs/metaobjects.md +126 -0
- package/template/app/docs/orders.md +221 -0
- package/template/app/docs/platform.md +126 -0
- package/template/app/docs/products.md +300 -0
- package/template/app/docs/redirects.md +50 -0
- package/template/app/docs/regions.md +206 -0
- package/template/app/docs/reviews.md +223 -0
- package/template/app/docs/search.md +218 -0
- package/template/app/docs/subscriptions.md +148 -0
- package/template/app/next.config.ts +34 -0
- package/template/app/package.json +25 -0
- package/template/app/postcss.config.cjs +6 -0
- package/template/app/smoke.mjs +158 -0
- package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
- package/template/app/src/app/checkout/page.tsx +51 -0
- package/template/app/src/app/globals.css +42 -0
- package/template/app/src/app/layout.tsx +113 -0
- package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
- package/template/app/src/app/page.tsx +25 -0
- package/template/app/src/app/products/[handle]/page.tsx +58 -0
- package/template/app/src/app/providers.tsx +54 -0
- package/template/app/src/app/search/page.tsx +20 -0
- package/template/app/src/lib/browser-client.ts +35 -0
- package/template/app/src/lib/cart-actions.ts +47 -0
- package/template/app/src/lib/config.ts +21 -0
- package/template/app/src/lib/server-client.ts +25 -0
- package/template/app/tailwind.config.cjs +9 -0
- package/template/app/tsconfig.json +41 -0
- package/template/app/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
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 { readCartCookie } 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
|
+
// Back-compat: prefer `_cartbase_cart`, fall back to the legacy
|
|
23
|
+
// `_barter_cart_id` name (platform-fingerprints card).
|
|
24
|
+
const cartId = readCartCookie((name) => jar.get(name)?.value)
|
|
25
|
+
if (!cartId) redirect("/")
|
|
26
|
+
|
|
27
|
+
const client = await getServerClient()
|
|
28
|
+
const cart = await retrieveCart(client, cartId)
|
|
29
|
+
.then((res) => res.cart)
|
|
30
|
+
.catch(() => null)
|
|
31
|
+
if (!cart || (cart.items ?? []).length === 0) redirect("/")
|
|
32
|
+
if (cart.completed_at) redirect("/")
|
|
33
|
+
|
|
34
|
+
const [shippingOptions, paymentProviders, integrations] = await Promise.all([
|
|
35
|
+
listShippingOptions(client, { cart_id: cart.id }),
|
|
36
|
+
listPaymentProviders(client, {
|
|
37
|
+
cart_id: cart.id,
|
|
38
|
+
region_id: cart.region_id ?? undefined,
|
|
39
|
+
}),
|
|
40
|
+
getIntegrationsConfig(client),
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<CheckoutPageClient
|
|
45
|
+
cart={cart}
|
|
46
|
+
shippingOptions={shippingOptions.shipping_options}
|
|
47
|
+
paymentProviders={paymentProviders.payment_providers}
|
|
48
|
+
codConfig={integrations.cod}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -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,113 @@
|
|
|
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 { createStorefrontMetadata, PlatformInit } from "@cartbase/storefront/platform"
|
|
10
|
+
import { BARTER_CLIENT_ID, readCartCookie } from "@/lib/config"
|
|
11
|
+
import { getServerClient } from "@/lib/server-client"
|
|
12
|
+
import { Providers } from "./providers"
|
|
13
|
+
import "./globals.css"
|
|
14
|
+
|
|
15
|
+
// createStorefrontMetadata (platform-fingerprints card) stamps
|
|
16
|
+
// `<meta name="generator" content="Cartbase" />` on every page — never
|
|
17
|
+
// hand-write `generator` here.
|
|
18
|
+
export const metadata: Metadata = createStorefrontMetadata({
|
|
19
|
+
title: "Barter Example Store",
|
|
20
|
+
description: "Reference storefront built on @cartbase/storefront",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runbook steps 3–4: bootstrap store config at layout level, then mount
|
|
25
|
+
* order inside <body>: ConsentInit FIRST (static, synchronous), then the
|
|
26
|
+
* consent-gated UI, then navigation. A store with no menus 404s every
|
|
27
|
+
* handle — render no nav, never crash (menus.md).
|
|
28
|
+
*/
|
|
29
|
+
async function fetchMenu(handle: string): Promise<Menu | null> {
|
|
30
|
+
const client = await getServerClient()
|
|
31
|
+
try {
|
|
32
|
+
const { menu } = await getMenu(client, handle)
|
|
33
|
+
return menu
|
|
34
|
+
} catch {
|
|
35
|
+
return null // unknown/deleted handle (404) → graceful "no nav"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function MenuNav({ menu }: { menu: Menu | null }) {
|
|
40
|
+
if (!menu || menu.items.length === 0) return null
|
|
41
|
+
return (
|
|
42
|
+
<nav className="flex items-center gap-4">
|
|
43
|
+
{menu.items.map((item) => (
|
|
44
|
+
<Link
|
|
45
|
+
key={`${item.title}-${item.url}`}
|
|
46
|
+
href={item.url}
|
|
47
|
+
className="text-sm text-muted-foreground hover:text-foreground"
|
|
48
|
+
>
|
|
49
|
+
{item.title}
|
|
50
|
+
</Link>
|
|
51
|
+
))}
|
|
52
|
+
</nav>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default async function RootLayout({
|
|
57
|
+
children,
|
|
58
|
+
}: {
|
|
59
|
+
children: React.ReactNode
|
|
60
|
+
}) {
|
|
61
|
+
const client = await getServerClient()
|
|
62
|
+
const jar = await cookies()
|
|
63
|
+
// Back-compat: prefer the new `_cartbase_cart` cookie, fall back to the
|
|
64
|
+
// legacy `_barter_cart_id` name so an existing visitor's cart survives
|
|
65
|
+
// the rename (platform-fingerprints card).
|
|
66
|
+
const cartId = readCartCookie((name) => jar.get(name)?.value) ?? null
|
|
67
|
+
|
|
68
|
+
const [consentRes, mainMenu, footerMenu, cart] = await Promise.all([
|
|
69
|
+
getConsent(client),
|
|
70
|
+
fetchMenu("main-menu"),
|
|
71
|
+
fetchMenu("footer"),
|
|
72
|
+
cartId
|
|
73
|
+
? retrieveCart(client, cartId)
|
|
74
|
+
.then((res): Cart | null => res.cart)
|
|
75
|
+
.catch(() => null) // stale cookie → empty cart state
|
|
76
|
+
: Promise.resolve(null),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<html lang="en">
|
|
81
|
+
<body>
|
|
82
|
+
<ConsentInit />
|
|
83
|
+
<PlatformInit storeId={BARTER_CLIENT_ID} />
|
|
84
|
+
<Providers cart={cart} consent={consentRes.consent}>
|
|
85
|
+
<header className="border-b border-border">
|
|
86
|
+
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between gap-6">
|
|
87
|
+
<Link href="/" className="font-semibold text-lg">
|
|
88
|
+
Barter Example Store
|
|
89
|
+
</Link>
|
|
90
|
+
<div className="flex items-center gap-6">
|
|
91
|
+
<MenuNav menu={mainMenu} />
|
|
92
|
+
<Link
|
|
93
|
+
href="/search"
|
|
94
|
+
className="text-sm text-muted-foreground hover:text-foreground"
|
|
95
|
+
>
|
|
96
|
+
Search
|
|
97
|
+
</Link>
|
|
98
|
+
<CartButtonClient cart={cart} />
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</header>
|
|
102
|
+
<main>{children}</main>
|
|
103
|
+
<footer className="border-t border-border mt-12">
|
|
104
|
+
<div className="max-w-7xl mx-auto px-4 py-8 text-sm text-muted-foreground flex items-center justify-between">
|
|
105
|
+
<span>Barter Example Store</span>
|
|
106
|
+
<MenuNav menu={footerMenu} />
|
|
107
|
+
</div>
|
|
108
|
+
</footer>
|
|
109
|
+
</Providers>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import Link from "next/link"
|
|
5
|
+
import type { CompletedOrder, CartLineItem } from "@cartbase/storefront/api/carts"
|
|
6
|
+
import {
|
|
7
|
+
OrderCompletedTemplate,
|
|
8
|
+
} from "@cartbase/storefront/order/order-completed-template"
|
|
9
|
+
import {
|
|
10
|
+
displayItemFromCartLine,
|
|
11
|
+
} from "@cartbase/storefront/order/order-item"
|
|
12
|
+
import {
|
|
13
|
+
orderTotalsFromSummary,
|
|
14
|
+
} from "@cartbase/storefront/order/order-totals"
|
|
15
|
+
import { LAST_ORDER_STORAGE_KEY } from "../../../checkout/checkout-page-client"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Order confirmation (runbook step 8.6 + orders.md): guests have NO
|
|
19
|
+
* anonymous order read — the `completeCart()` response is the only order
|
|
20
|
+
* handle, so the checkout page stashes it in sessionStorage and this page
|
|
21
|
+
* renders it through the order family. Totals come from the order's
|
|
22
|
+
* summary snapshot (`orderTotalsFromSummary`); items keep the decorated
|
|
23
|
+
* cart lines' server-computed totals (`displayItemFromCartLine`).
|
|
24
|
+
*/
|
|
25
|
+
type Stash = { order: CompletedOrder; cartItems: CartLineItem[] }
|
|
26
|
+
|
|
27
|
+
export default function OrderConfirmedPage() {
|
|
28
|
+
const [stash, setStash] = useState<Stash | null | "missing">(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
try {
|
|
32
|
+
const raw = sessionStorage.getItem(LAST_ORDER_STORAGE_KEY)
|
|
33
|
+
setStash(raw ? (JSON.parse(raw) as Stash) : "missing")
|
|
34
|
+
} catch {
|
|
35
|
+
setStash("missing")
|
|
36
|
+
}
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
if (stash === null) return null // first paint before sessionStorage read
|
|
40
|
+
|
|
41
|
+
if (stash === "missing") {
|
|
42
|
+
return (
|
|
43
|
+
<div className="max-w-xl mx-auto px-4 py-16 text-center">
|
|
44
|
+
<h1 className="text-xl font-semibold mb-2">Order placed</h1>
|
|
45
|
+
<p className="text-muted-foreground mb-6">
|
|
46
|
+
Your order was placed successfully. A confirmation email is on its
|
|
47
|
+
way.
|
|
48
|
+
</p>
|
|
49
|
+
<Link href="/" className="underline">
|
|
50
|
+
Continue shopping
|
|
51
|
+
</Link>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { order, cartItems } = stash
|
|
57
|
+
const totals = orderTotalsFromSummary(order.summary) ?? {
|
|
58
|
+
total: null,
|
|
59
|
+
}
|
|
60
|
+
// The completeCart() order carries FLATTENED items (checkout.md), not the
|
|
61
|
+
// order-detail pivot shape the template's default converter expects —
|
|
62
|
+
// pass the normalized cart-line items instead and drop `order.items`.
|
|
63
|
+
const orderForTemplate = { ...order, items: undefined }
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<OrderCompletedTemplate
|
|
67
|
+
order={orderForTemplate}
|
|
68
|
+
totals={totals}
|
|
69
|
+
items={cartItems.map(displayItemFromCartLine)}
|
|
70
|
+
// This reference store enables exactly one provider (pp_manual); a
|
|
71
|
+
// multi-provider store should stash the chosen provider id from its
|
|
72
|
+
// checkout state alongside the order.
|
|
73
|
+
paymentProviderId={"pp_manual"}
|
|
74
|
+
storeHref="/"
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { StoreTemplate } from "@cartbase/storefront/store/store-template"
|
|
2
|
+
import type { SortOptions } from "@cartbase/storefront/lib/sort-products"
|
|
3
|
+
import { PRICING_CONTEXT } 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
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { addToCartAction } from "@/lib/cart-actions"
|
|
6
|
+
import { PRICING_CONTEXT } from "@/lib/config"
|
|
7
|
+
import { getServerClient } from "@/lib/server-client"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PDP (runbook step 5): fetch by handle WITH the pricing context, render
|
|
11
|
+
* the full product template. `addToCart` is the app-owned seam — a server
|
|
12
|
+
* action that owns the cart cookie (products family contract).
|
|
13
|
+
*/
|
|
14
|
+
export default async function ProductPage({
|
|
15
|
+
params,
|
|
16
|
+
}: {
|
|
17
|
+
params: Promise<{ handle: string }>
|
|
18
|
+
}) {
|
|
19
|
+
const { handle } = await params
|
|
20
|
+
const client = await getServerClient()
|
|
21
|
+
|
|
22
|
+
let product
|
|
23
|
+
try {
|
|
24
|
+
const res = await retrieveProduct(client, handle, PRICING_CONTEXT)
|
|
25
|
+
product = res.product
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof StoreApiError && e.status === 404) notFound()
|
|
28
|
+
throw e
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<ProductTemplate
|
|
33
|
+
client={client}
|
|
34
|
+
product={product}
|
|
35
|
+
pricingContext={PRICING_CONTEXT}
|
|
36
|
+
addToCart={addToCartAction}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function generateMetadata({
|
|
42
|
+
params,
|
|
43
|
+
}: {
|
|
44
|
+
params: Promise<{ handle: string }>
|
|
45
|
+
}) {
|
|
46
|
+
const { handle } = await params
|
|
47
|
+
const client = await getServerClient()
|
|
48
|
+
try {
|
|
49
|
+
const { product } = await retrieveProduct(client, handle)
|
|
50
|
+
// SEO fields with title/description fallbacks (runbook step 5).
|
|
51
|
+
return {
|
|
52
|
+
title: product.seo_title ?? product.title,
|
|
53
|
+
description: product.seo_description ?? product.description ?? undefined,
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { Cart } from "@cartbase/storefront/api/carts"
|
|
4
|
+
import { CartDrawerProvider } from "@cartbase/storefront/cart-drawer/context"
|
|
5
|
+
import { CartDrawerTemplate } from "@cartbase/storefront/cart-drawer/template"
|
|
6
|
+
import { ConsentBanner } from "@cartbase/storefront/tracking/consent-banner"
|
|
7
|
+
import {
|
|
8
|
+
pickConsentCopy,
|
|
9
|
+
shouldRenderBanner,
|
|
10
|
+
type ConsentSettings,
|
|
11
|
+
} from "@cartbase/storefront/tracking/consent"
|
|
12
|
+
import { CART_COOKIE, CART_COOKIE_MAX_AGE } from "@/lib/config"
|
|
13
|
+
import { browserClient } from "@/lib/browser-client"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Client-side shell: ONE CartDrawerProvider per app (cart-drawer family
|
|
17
|
+
* mount rule) + the consent banner (consent.md: render the builtin banner
|
|
18
|
+
* only when `enabled && mode === "builtin"`; ConsentInit stays in the
|
|
19
|
+
* server layout as the first child of <body>).
|
|
20
|
+
*/
|
|
21
|
+
export function Providers({
|
|
22
|
+
cart,
|
|
23
|
+
consent,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
cart: Cart | null
|
|
27
|
+
consent: ConsentSettings
|
|
28
|
+
children: React.ReactNode
|
|
29
|
+
}) {
|
|
30
|
+
const copy = pickConsentCopy(consent.copy, "en")
|
|
31
|
+
return (
|
|
32
|
+
<CartDrawerProvider
|
|
33
|
+
cart={cart}
|
|
34
|
+
client={browserClient}
|
|
35
|
+
onCartChange={(next) => {
|
|
36
|
+
// Persist the cart id (carts.md: the app owns the cart cookie).
|
|
37
|
+
document.cookie = `${CART_COOKIE}=${encodeURIComponent(
|
|
38
|
+
next.id
|
|
39
|
+
)};path=/;max-age=${CART_COOKIE_MAX_AGE};samesite=lax`
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
<CartDrawerTemplate />
|
|
44
|
+
{shouldRenderBanner(consent) && copy && (
|
|
45
|
+
<ConsentBanner
|
|
46
|
+
copy={copy}
|
|
47
|
+
layout={consent.layout}
|
|
48
|
+
privacyHref={consent.privacy_href}
|
|
49
|
+
rejectOnFirstLayer={consent.reject_on_first_layer}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
</CartDrawerProvider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SearchTemplate } from "@cartbase/storefront/store/search-template"
|
|
2
|
+
import { PRICING_CONTEXT } 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
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { StorefrontClient } from "@cartbase/storefront/api/http"
|
|
4
|
+
import {
|
|
5
|
+
BARTER_CLIENT_ID,
|
|
6
|
+
BARTER_PUBLISHABLE_KEY,
|
|
7
|
+
BARTER_URL,
|
|
8
|
+
LOCALE_COOKIE,
|
|
9
|
+
} from "./config"
|
|
10
|
+
|
|
11
|
+
function readCookie(name: string): string | null {
|
|
12
|
+
if (typeof document === "undefined") return null
|
|
13
|
+
const match = document.cookie
|
|
14
|
+
.split("; ")
|
|
15
|
+
.find((row) => row.startsWith(`${name}=`))
|
|
16
|
+
return match ? decodeURIComponent(match.slice(name.length + 1)) : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Browser-scope StorefrontClient (runbook step 2): constructed once per
|
|
21
|
+
* app. Guest-only reference app — no auth token store.
|
|
22
|
+
*
|
|
23
|
+
* baseUrl is the APP origin, not the API origin: the store API ships no
|
|
24
|
+
* CORS headers, so browser calls ride the same-origin `/api/store/*`
|
|
25
|
+
* rewrite (next.config.ts). During SSR of client components no fetches
|
|
26
|
+
* run — the API origin stands in only to satisfy the constructor.
|
|
27
|
+
*/
|
|
28
|
+
export const browserClient = new StorefrontClient({
|
|
29
|
+
baseUrl:
|
|
30
|
+
typeof window !== "undefined" ? window.location.origin : BARTER_URL,
|
|
31
|
+
clientId: BARTER_CLIENT_ID,
|
|
32
|
+
publishableKey: BARTER_PUBLISHABLE_KEY,
|
|
33
|
+
getAuthToken: () => null,
|
|
34
|
+
getLocale: () => readCookie(LOCALE_COOKIE),
|
|
35
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use server"
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache"
|
|
4
|
+
import { cookies } from "next/headers"
|
|
5
|
+
import { addLineItem, createCart } from "@cartbase/storefront/api/carts"
|
|
6
|
+
import { CART_COOKIE, CART_COOKIE_MAX_AGE, readCartCookie } from "./config"
|
|
7
|
+
import { getServerClient } from "./server-client"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PDP add-to-cart server action (carts.md: create the cart lazily on the
|
|
11
|
+
* first add — region falls back to the store default; persist `cart.id`
|
|
12
|
+
* in a cookie). Passed into `ProductTemplate`'s `addToCart` seam.
|
|
13
|
+
*
|
|
14
|
+
* Reads via `readCartCookie` (new `_cartbase_cart` name, falls back to the
|
|
15
|
+
* legacy `_barter_cart_id` — platform-fingerprints card); every WRITE uses
|
|
16
|
+
* the new name only, so a visitor's cart survives the rename.
|
|
17
|
+
*/
|
|
18
|
+
export async function addToCartAction(input: {
|
|
19
|
+
variantId: string
|
|
20
|
+
quantity: number
|
|
21
|
+
}): Promise<void> {
|
|
22
|
+
const client = await getServerClient()
|
|
23
|
+
const jar = await cookies()
|
|
24
|
+
const cartId = readCartCookie((name) => jar.get(name)?.value)
|
|
25
|
+
|
|
26
|
+
if (cartId) {
|
|
27
|
+
await addLineItem(client, cartId, {
|
|
28
|
+
variant_id: input.variantId,
|
|
29
|
+
quantity: input.quantity,
|
|
30
|
+
})
|
|
31
|
+
} else {
|
|
32
|
+
const { cart } = await createCart(client, {
|
|
33
|
+
currency_code: "eur",
|
|
34
|
+
items: [{ variant_id: input.variantId, quantity: input.quantity }],
|
|
35
|
+
})
|
|
36
|
+
jar.set(CART_COOKIE, cart.id, {
|
|
37
|
+
path: "/",
|
|
38
|
+
sameSite: "lax",
|
|
39
|
+
httpOnly: false, // the browser-side drawer persists the same cookie
|
|
40
|
+
maxAge: CART_COOKIE_MAX_AGE,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Layout refetches the cart snapshot → CartDrawerProvider sees the new
|
|
45
|
+
// product-line count and auto-opens the drawer.
|
|
46
|
+
revalidatePath("/", "layout")
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Shared storefront configuration (BUILD-A-STOREFRONT.md — inputs table). */
|
|
2
|
+
|
|
3
|
+
export const BARTER_URL = process.env.NEXT_PUBLIC_CARTBASE_URL ?? ""
|
|
4
|
+
export const BARTER_CLIENT_ID = process.env.NEXT_PUBLIC_CARTBASE_CLIENT_ID ?? ""
|
|
5
|
+
export const BARTER_PUBLISHABLE_KEY =
|
|
6
|
+
process.env.NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY || undefined
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cart-id cookie — owned by the app (the SDK never persists the cart);
|
|
10
|
+
* the NAME is owned by the library so every consumer emits the same wire
|
|
11
|
+
* fingerprint (platform-fingerprints card). `readCartCookie` is the
|
|
12
|
+
* backward-compat reader for the legacy `_barter_cart_id` name.
|
|
13
|
+
*/
|
|
14
|
+
export { CART_COOKIE, readCartCookie } from "@cartbase/storefront/lib/cookie-names"
|
|
15
|
+
export const CART_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
|
|
16
|
+
|
|
17
|
+
/** Locale cookie read by the clients' `getLocale`. */
|
|
18
|
+
export const LOCALE_COOKIE = "_barter_locale"
|
|
19
|
+
|
|
20
|
+
/** Pricing context for every catalog surface (runbook step 5). EUR only. */
|
|
21
|
+
export const PRICING_CONTEXT = { currency_code: "eur" } as const
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cookies } from "next/headers"
|
|
2
|
+
import { StorefrontClient } from "@cartbase/storefront/api/http"
|
|
3
|
+
import {
|
|
4
|
+
BARTER_CLIENT_ID,
|
|
5
|
+
BARTER_PUBLISHABLE_KEY,
|
|
6
|
+
BARTER_URL,
|
|
7
|
+
LOCALE_COOKIE,
|
|
8
|
+
} from "./config"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Server-scope StorefrontClient (runbook step 2): construct per request;
|
|
12
|
+
* `getLocale` reads the locale cookie. No customer accounts in this
|
|
13
|
+
* reference app, so `getAuthToken` always answers null (guest).
|
|
14
|
+
*/
|
|
15
|
+
export async function getServerClient(): Promise<StorefrontClient> {
|
|
16
|
+
const jar = await cookies()
|
|
17
|
+
const locale = jar.get(LOCALE_COOKIE)?.value ?? null
|
|
18
|
+
return new StorefrontClient({
|
|
19
|
+
baseUrl: BARTER_URL,
|
|
20
|
+
clientId: BARTER_CLIENT_ID,
|
|
21
|
+
publishableKey: BARTER_PUBLISHABLE_KEY,
|
|
22
|
+
getAuthToken: () => null,
|
|
23
|
+
getLocale: () => locale,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
presets: [require("@cartbase/storefront/tailwind-preset")],
|
|
4
|
+
content: [
|
|
5
|
+
"./src/**/*.{ts,tsx}",
|
|
6
|
+
// The component library ships source — scan it so its classes exist.
|
|
7
|
+
"../../packages/storefront/src/**/*.{ts,tsx}",
|
|
8
|
+
],
|
|
9
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"module": "esnext",
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"incremental": true,
|
|
20
|
+
"plugins": [
|
|
21
|
+
{
|
|
22
|
+
"name": "next"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": [
|
|
27
|
+
"./src/*"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": [
|
|
32
|
+
"next-env.d.ts",
|
|
33
|
+
"**/*.ts",
|
|
34
|
+
"**/*.tsx",
|
|
35
|
+
".next/types/**/*.ts",
|
|
36
|
+
".next/dev/types/**/*.ts"
|
|
37
|
+
],
|
|
38
|
+
"exclude": [
|
|
39
|
+
"node_modules"
|
|
40
|
+
]
|
|
41
|
+
}
|