create-cartbase 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/dist/index.js +28 -20
- package/package.json +3 -3
- package/template/app/AGENTS.md +29 -0
- package/template/app/CLAUDE.md +19 -8
- package/template/app/docs/BUILD-A-STOREFRONT.md +233 -226
- package/template/app/docs/README.md +1 -0
- package/template/app/docs/components.md +1134 -1090
- package/template/app/docs/deploy.md +15 -10
- package/template/app/docs/integrations.md +15 -3
- package/template/app/docs/platform.md +1 -1
- package/template/app/docs/store.md +47 -0
- package/template/app/docs/variables.md +3 -3
- package/template/app/next.config.ts +10 -14
- package/template/app/package.json +4 -3
- package/template/app/src/app/checkout/checkout-page-client.tsx +0 -20
- package/template/app/src/app/globals.css +1 -1
- package/template/app/src/app/layout.tsx +73 -18
- package/template/app/src/app/order/[id]/confirmed/page.tsx +92 -78
- package/template/app/src/lib/config.ts +30 -21
- package/template/app/next-env.d.ts +0 -6
- package/template/app/smoke.mjs +0 -158
- package/template/app/src/app/checkout/mypos-demo-tab.tsx +0 -101
- package/template/app/src/app/gallery/[slug]/page.tsx +0 -172
- package/template/app/src/app/gallery/_components/specimens.tsx +0 -254
- package/template/app/src/app/gallery/_components/status.tsx +0 -47
- package/template/app/src/app/gallery/_lib/catalog.ts +0 -59
- package/template/app/src/app/gallery/_lib/registry.ts +0 -401
- package/template/app/src/app/gallery/design-system/page.tsx +0 -353
- package/template/app/src/app/gallery/layout.tsx +0 -83
- package/template/app/src/app/gallery/page.tsx +0 -81
- package/template/app/tsconfig.tsbuildinfo +0 -1
|
@@ -5,7 +5,10 @@ Cartbase builds and hosts your storefront. You send the app's source files
|
|
|
5
5
|
running site: first on a **permanent preview URL**, then, when you press
|
|
6
6
|
**Publish** (or call the publish endpoint), on the store's live domain.
|
|
7
7
|
You never touch build servers, DNS, or hosting configuration; the platform
|
|
8
|
-
provisions all of it
|
|
8
|
+
provisions all of it the moment the store is created, and makes the first
|
|
9
|
+
deploy itself: the store's starting point, a complete storefront on your
|
|
10
|
+
own (still empty) catalog, already running on the preview link. Pull it
|
|
11
|
+
with the CLI, change anything, deploy it back.
|
|
9
12
|
|
|
10
13
|
Two URLs exist per store, both created automatically:
|
|
11
14
|
|
|
@@ -56,24 +59,25 @@ Error codes you can hit: `empty_bundle`, `bundle_too_large`,
|
|
|
56
59
|
|
|
57
60
|
## Environment — provided, not configured
|
|
58
61
|
|
|
59
|
-
Hosted storefronts receive
|
|
60
|
-
|
|
62
|
+
Hosted storefronts receive their store's values automatically at build
|
|
63
|
+
time; do **not** put them in the bundle (`.env*` files are blocked
|
|
61
64
|
anyway):
|
|
62
65
|
|
|
63
66
|
| Variable | Value |
|
|
64
67
|
|---|---|
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
+
| `NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY` | The store's publishable key, the one input a storefront needs |
|
|
69
|
+
| `NEXT_PUBLIC_CARTBASE_URL` | The platform origin (a constant; injected so a hosted build never guesses) |
|
|
70
|
+
| `NEXT_PUBLIC_CARTBASE_CLIENT_ID` | The store's id, the platform's own door; a storefront never needs to copy it |
|
|
68
71
|
|
|
69
72
|
Only these public values ever reach a storefront build — secret keys are
|
|
70
73
|
never injected, so code that expects one is a design error.
|
|
71
74
|
|
|
72
75
|
## Deploy to preview
|
|
73
76
|
|
|
74
|
-
`POST /api/admin/storefront/deploys` — the one ingestion door.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
`POST /api/admin/storefront/deploys` — the one ingestion door. Hosting is
|
|
78
|
+
provisioned when the store is born, so every call is just a deploy; on a
|
|
79
|
+
store whose hosting setup failed, the first call resumes it (a few extra
|
|
80
|
+
seconds).
|
|
77
81
|
|
|
78
82
|
```bash
|
|
79
83
|
# doc-noexec — admin-session auth; run from an authenticated context.
|
|
@@ -153,7 +157,8 @@ curl -s "$BASE/api/admin/storefront"
|
|
|
153
157
|
}
|
|
154
158
|
```
|
|
155
159
|
|
|
156
|
-
`storefront` is
|
|
160
|
+
`storefront` is present from the store's birth (`null` only on a store
|
|
161
|
+
created before hosting-at-birth that has never deployed). `status:
|
|
157
162
|
"provisioning"` / `"failed"` (with `last_error`) describe hosting setup,
|
|
158
163
|
not builds; a failed provisioning resumes automatically on the next
|
|
159
164
|
deploy attempt.
|
|
@@ -56,6 +56,10 @@ key-by-key.
|
|
|
56
56
|
"ga4": { "measurementId": "G-…" },
|
|
57
57
|
"klaviyo": { "publicKey": "…" },
|
|
58
58
|
"googleAds": { "conversionId": "AW-…", "conversionLabel": "…" },
|
|
59
|
+
// Pixel id only. The TikTok Events API token stays on the platform
|
|
60
|
+
// and is used by the order.placed forwarder, exactly like Meta's
|
|
61
|
+
// CAPI token, which has never appeared on this endpoint.
|
|
62
|
+
"tiktok": { "pixelId": "C…" },
|
|
59
63
|
"consent_required": true // always present — mirrors the consent CMP's enabled
|
|
60
64
|
}
|
|
61
65
|
}
|
|
@@ -64,7 +68,10 @@ key-by-key.
|
|
|
64
68
|
- **Errors**: `400 missing_client_id` · `400 invalid_publishable_key`.
|
|
65
69
|
- **SDK**: `integrations.getIntegrationsConfig(client)`
|
|
66
70
|
- **Components**: carrier/locker pickers, COD fee row in checkout, tracking
|
|
67
|
-
mounts (`<MetaPixel>/<
|
|
71
|
+
mounts (`<MetaPixel>/<GoogleTag>/<TikTokPixel>/<Gtm>` behind the consent
|
|
72
|
+
gate). `<GoogleTag measurementId adsConversionId>` takes BOTH Google
|
|
73
|
+
destinations: one loader, two `config` calls, which is Google's own
|
|
74
|
+
instruction and is what lets Consent Mode gate the Ads tag for free.
|
|
68
75
|
- **Settings**: admin → Settings → Integrations (per-provider enable +
|
|
69
76
|
config); consent settings drive `tracking.consent_required`; Rybbit is
|
|
70
77
|
deliberately ABSENT (platform analytics, not a tenant integration).
|
|
@@ -72,8 +79,13 @@ key-by-key.
|
|
|
72
79
|
Tracking wiring contract: mount tags only through the consent gate when
|
|
73
80
|
`consent_required` (see [consent.md](consent.md)); Purchase events MUST use
|
|
74
81
|
`eventID = "purchase_" + order.display_id` so Meta dedupes browser Pixel vs
|
|
75
|
-
server CAPI
|
|
76
|
-
(consent-gated) so
|
|
82
|
+
server CAPI, and `event_id = "tt_purchase_" + order.display_id` for TikTok;
|
|
83
|
+
write TrackingAttribution keys into `cart.metadata` (consent-gated) so
|
|
84
|
+
server events inherit fbp/fbc/anon-id/ga signals AND the ad-click ids
|
|
85
|
+
(`tt_ttclid`, `tt_ttp`, `google_gclid`, `google_gbraid`, `google_wbraid`)
|
|
86
|
+
that let TikTok and Google tie a conversion back to the click that caused
|
|
87
|
+
it. Capture those with `captureClickIdsFromUrl()` beside
|
|
88
|
+
`captureUtmsFromUrl()`.
|
|
77
89
|
|
|
78
90
|
```bash
|
|
79
91
|
BODY=$(curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID")
|
|
@@ -88,7 +88,7 @@ window.Cartbase").
|
|
|
88
88
|
```tsx
|
|
89
89
|
// app/layout.tsx, inside <body>, first children:
|
|
90
90
|
<ConsentInit />
|
|
91
|
-
<PlatformInit storeId={
|
|
91
|
+
<PlatformInit storeId={store.id} /> // from GET /api/store/store (store.md)
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Also build-time/render-only — no store-API curl to demonstrate; the exact
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Store
|
|
2
|
+
|
|
3
|
+
The store's own identity: its name, its slug and its brand (what the
|
|
4
|
+
merchant set under Settings → Brand). One anonymous read, usually at
|
|
5
|
+
layout level, so a storefront titles, heads and foots itself with the
|
|
6
|
+
merchant's name and never hardcodes one. Logo URLs are public. Colors are
|
|
7
|
+
`#rrggbb`. Every field except `name` and `slug` may be null.
|
|
8
|
+
|
|
9
|
+
SDK module: `@cartbase/storefront/api/store` (from 0.8.0; on 0.7.0 call
|
|
10
|
+
`client.request("/api/store/store")` with the same shape).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## GET /api/store/store
|
|
15
|
+
|
|
16
|
+
- **Purpose** — read the store's id, name, slug and brand.
|
|
17
|
+
- **Auth** — anon: the store's publishable key (`x-publishable-api-key`).
|
|
18
|
+
- **Request** — `GET /api/store/store`
|
|
19
|
+
- **Response 200**
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
{
|
|
23
|
+
"store": {
|
|
24
|
+
"id": "1e7a4c02-9b31-4f7e-8d2a-5c6f90ab12cd", // the store's public id (what PlatformInit mounts)
|
|
25
|
+
"name": "Demo Store",
|
|
26
|
+
"slug": "demo-store",
|
|
27
|
+
"brand": {
|
|
28
|
+
"logo_url": "https://…/brand/…/logo.png", // or null
|
|
29
|
+
"logo_square_url": null,
|
|
30
|
+
"color_primary": "#111111", // or null
|
|
31
|
+
"color_secondary": null,
|
|
32
|
+
"slogan": null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **Errors** — `404 store_not_found` when the key names no live store.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { getStore } from "@cartbase/storefront/api/store"
|
|
42
|
+
|
|
43
|
+
export async function generateMetadata() {
|
|
44
|
+
const { store } = await getStore(await getServerClient())
|
|
45
|
+
return createStorefrontMetadata({ title: store.name, description: store.brand.slogan ?? undefined })
|
|
46
|
+
}
|
|
47
|
+
```
|
|
@@ -58,7 +58,7 @@ Every customer notification carries it. Money is formatted in the order's own cu
|
|
|
58
58
|
| `{{ order.email }}` | The address the order was placed with. | email | |
|
|
59
59
|
| `{{ order.date }}` | When the order was placed. | date | |
|
|
60
60
|
| `{{ order.status }}` | Lifecycle state: pending, completed, canceled or archived. | text | |
|
|
61
|
-
| `{{ order.payment_status }}` | Paid,
|
|
61
|
+
| `{{ order.payment_status }}` | Paid, Unpaid, Partially paid, Refunded and so on, in the same words the admin uses. | text | |
|
|
62
62
|
| `{{ order.fulfillment_status }}` | Unfulfilled, Shipped, Delivered and so on, in the same words the admin uses. | text | |
|
|
63
63
|
| `{{ order.locale }}` | Language the customer shopped in, when the storefront sends one. | text | |
|
|
64
64
|
| `{{ order.cancelled_at }}` | When the order was cancelled, if it was. | datetime | |
|
|
@@ -70,7 +70,7 @@ Every customer notification carries it. Money is formatted in the order's own cu
|
|
|
70
70
|
| `{{ order.shipping }}` | What delivery cost the customer. | money | |
|
|
71
71
|
| `{{ order.discount }}` | Total discount applied to the order. | money | |
|
|
72
72
|
| `{{ order.payment_method_fee }}` | The fee of the payment method the customer chose (a COD courier fee, a handling fee on a manual method). | money | |
|
|
73
|
-
| `{{ order.paid }}` | How much has actually been
|
|
73
|
+
| `{{ order.paid }}` | How much has actually been received so far. | money | |
|
|
74
74
|
| `{{ order.refunded }}` | How much has been refunded. | money | |
|
|
75
75
|
| `{{ order.balance_due }}` | What is still outstanding on the order. | money | |
|
|
76
76
|
|
|
@@ -271,7 +271,7 @@ Staff operational alerts only.
|
|
|
271
271
|
| Order Shipped | Order, Customer, Shipping address, Billing address, Delivery, Store, Brand |
|
|
272
272
|
| Order Delivered | Order, Customer, Shipping address, Billing address, Delivery, Store, Brand |
|
|
273
273
|
| Order Refunded | Order, Customer, Shipping address, Billing address, Delivery, Store, Brand |
|
|
274
|
-
| Order
|
|
274
|
+
| Order Canceled | Order, Customer, Shipping address, Billing address, Delivery, Store, Brand |
|
|
275
275
|
| Admin: New Order | Order, Customer, Shipping address, Billing address, Delivery, Store, Brand |
|
|
276
276
|
| Welcome Email | Customer, Store, Brand |
|
|
277
277
|
| Password Reset | Customer, Store, Brand |
|
|
@@ -1,31 +1,27 @@
|
|
|
1
|
-
import path from "node:path"
|
|
2
1
|
import type { NextConfig } from "next"
|
|
3
2
|
|
|
4
3
|
const nextConfig: NextConfig = {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
4
|
+
// @cartbase/storefront is source-shipped TypeScript — the app's Next
|
|
5
|
+
// build transpiles it.
|
|
12
6
|
transpilePackages: ["@cartbase/storefront"],
|
|
13
7
|
images: {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
8
|
+
// Product images come from the Cartbase media CDN and, until you
|
|
9
|
+
// allowlist your own hosts under remotePatterns, from anywhere the
|
|
10
|
+
// catalog points. Tighten this when the media domain is known.
|
|
16
11
|
unoptimized: true,
|
|
17
12
|
},
|
|
18
13
|
// The Cartbase store API ships NO CORS headers — browser-side SDK calls
|
|
19
14
|
// must be same-origin. Proxy them through the app origin (the browser
|
|
20
15
|
// client's baseUrl is window.location.origin); server-side SDK calls go
|
|
21
|
-
// straight to
|
|
16
|
+
// straight to the platform origin and are unaffected. The origin is the
|
|
17
|
+
// platform's constant; NEXT_PUBLIC_CARTBASE_URL only overrides it for a
|
|
18
|
+
// local or staging platform.
|
|
22
19
|
async rewrites() {
|
|
23
|
-
const
|
|
24
|
-
if (!barterUrl) return []
|
|
20
|
+
const cartbaseUrl = process.env.NEXT_PUBLIC_CARTBASE_URL || "https://admin.cartbase.ai"
|
|
25
21
|
return [
|
|
26
22
|
{
|
|
27
23
|
source: "/api/store/:path*",
|
|
28
|
-
destination: `${
|
|
24
|
+
destination: `${cartbaseUrl}/api/store/:path*`,
|
|
29
25
|
},
|
|
30
26
|
]
|
|
31
27
|
},
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
"name": "cartbase-storefront",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "Reference storefront built from docs/storefront/BUILD-A-STOREFRONT.md alone — the adoption proof for @cartbase/storefront.",
|
|
6
5
|
"scripts": {
|
|
7
|
-
"
|
|
6
|
+
"build": "next build",
|
|
7
|
+
"start": "next start",
|
|
8
|
+
"dev": "next dev",
|
|
8
9
|
"typecheck": "tsc --noEmit"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
|
-
"@cartbase/storefront": "^0.
|
|
12
|
+
"@cartbase/storefront": "^0.8.0",
|
|
12
13
|
"next": "16.2.4",
|
|
13
14
|
"react": "19.2.4",
|
|
14
15
|
"react-dom": "19.2.4"
|
|
@@ -7,7 +7,6 @@ import type { StorePaymentEntry } from "@cartbase/storefront/api/checkout"
|
|
|
7
7
|
import { CheckoutProvider } from "@cartbase/storefront/checkout/context"
|
|
8
8
|
import { CheckoutClient } from "@cartbase/storefront/checkout/checkout-client"
|
|
9
9
|
import { browserClient } from "@/lib/browser-client"
|
|
10
|
-
import { MyposDemoTab } from "./mypos-demo-tab"
|
|
11
10
|
|
|
12
11
|
/** sessionStorage key the confirmation page reads (guests have no order
|
|
13
12
|
* read endpoint — the completeCart() response is the only order handle,
|
|
@@ -42,25 +41,6 @@ export function CheckoutPageClient({
|
|
|
42
41
|
paymentMethodFilter={(methods) =>
|
|
43
42
|
methods?.filter((m) => "payment_method_id" in m) ?? null
|
|
44
43
|
}
|
|
45
|
-
// Provider-research demo (2026-08-04): the myPOS sandbox as a third
|
|
46
|
-
// radio card, so the embedded-provider seam can be judged inside
|
|
47
|
-
// the real checkout. Off unless NEXT_PUBLIC_MYPOS_DEMO=1.
|
|
48
|
-
extraPaymentTabs={
|
|
49
|
-
process.env.NEXT_PUBLIC_MYPOS_DEMO === "1"
|
|
50
|
-
? [
|
|
51
|
-
{
|
|
52
|
-
id: "mypos-demo",
|
|
53
|
-
label: "Card via myPOS (demo)",
|
|
54
|
-
content: (
|
|
55
|
-
<MyposDemoTab
|
|
56
|
-
amount={Number(cart.total) || 23.45}
|
|
57
|
-
currency={cart.currency_code}
|
|
58
|
-
/>
|
|
59
|
-
),
|
|
60
|
-
},
|
|
61
|
-
]
|
|
62
|
-
: undefined
|
|
63
|
-
}
|
|
64
44
|
onOrderPlaced={(order) => {
|
|
65
45
|
// Guest order handle = the completeCart() response (orders.md).
|
|
66
46
|
// Stash it (+ the decorated cart lines for the items list) for
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* to scan it to know which classes exist. Without this the components render
|
|
18
18
|
* with no styles at all — the classes are in the markup but never generated.
|
|
19
19
|
*/
|
|
20
|
-
@source "
|
|
20
|
+
@source "../../node_modules/@cartbase/storefront/src";
|
|
21
21
|
|
|
22
22
|
/*
|
|
23
23
|
* Dark mode is class-based: put `.dark` on <html> and every token swaps.
|
|
@@ -5,6 +5,8 @@ import { retrieveCart, type Cart } from "@cartbase/storefront/api/carts"
|
|
|
5
5
|
import { getConsent } from "@cartbase/storefront/api/consent"
|
|
6
6
|
import { getMenu, type Menu } from "@cartbase/storefront/api/menus"
|
|
7
7
|
import { ConsentInit } from "@cartbase/storefront/tracking/consent-init"
|
|
8
|
+
import { StorefrontTags } from "@cartbase/storefront/tracking/storefront-tags"
|
|
9
|
+
import { TrackInit } from "@cartbase/storefront/tracking/track-init"
|
|
8
10
|
import { CartButtonClient } from "@cartbase/storefront/common/cart-button-client"
|
|
9
11
|
import { createStorefrontMetadata, PlatformInit } from "@cartbase/storefront/platform"
|
|
10
12
|
import { BARTER_CLIENT_ID, readCartCookie } from "@/lib/config"
|
|
@@ -15,10 +17,56 @@ import "./globals.css"
|
|
|
15
17
|
// createStorefrontMetadata (platform-fingerprints card) stamps
|
|
16
18
|
// `<meta name="generator" content="Cartbase" />` on every page — never
|
|
17
19
|
// hand-write `generator` here.
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
21
|
+
const store = await fetchStore()
|
|
22
|
+
return createStorefrontMetadata({
|
|
23
|
+
title: store.name,
|
|
24
|
+
description: store.brand.slogan ?? undefined,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The store's own identity (store.md): name, slug, brand. Read once per
|
|
30
|
+
* request; a storefront never hardcodes its name. Until @cartbase/storefront
|
|
31
|
+
* 0.8.0 ships `api/store`, the call goes through the client's low-level
|
|
32
|
+
* request with the documented shape.
|
|
33
|
+
*/
|
|
34
|
+
type StoreIdentity = {
|
|
35
|
+
/** The store's public id: what PlatformInit mounts (one key: never an env input). */
|
|
36
|
+
id: string
|
|
37
|
+
name: string
|
|
38
|
+
slug: string
|
|
39
|
+
brand: {
|
|
40
|
+
logo_url: string | null
|
|
41
|
+
logo_square_url: string | null
|
|
42
|
+
color_primary: string | null
|
|
43
|
+
color_secondary: string | null
|
|
44
|
+
slogan: string | null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchStore(): Promise<StoreIdentity> {
|
|
49
|
+
const client = await getServerClient()
|
|
50
|
+
try {
|
|
51
|
+
const { store } = await client.request<{ store: StoreIdentity }>("/api/store/store")
|
|
52
|
+
return store
|
|
53
|
+
} catch {
|
|
54
|
+
// Never crash the layout on a transient API error; the name is
|
|
55
|
+
// decoration, the store still renders.
|
|
56
|
+
return {
|
|
57
|
+
id: BARTER_CLIENT_ID ?? "",
|
|
58
|
+
name: "Store",
|
|
59
|
+
slug: "",
|
|
60
|
+
brand: {
|
|
61
|
+
logo_url: null,
|
|
62
|
+
logo_square_url: null,
|
|
63
|
+
color_primary: null,
|
|
64
|
+
color_secondary: null,
|
|
65
|
+
slogan: null,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
22
70
|
|
|
23
71
|
/**
|
|
24
72
|
* Runbook steps 3–4: bootstrap store config at layout level, then mount
|
|
@@ -65,7 +113,8 @@ export default async function RootLayout({
|
|
|
65
113
|
// the rename (platform-fingerprints card).
|
|
66
114
|
const cartId = readCartCookie((name) => jar.get(name)?.value) ?? null
|
|
67
115
|
|
|
68
|
-
const [consentRes, mainMenu, footerMenu, cart] = await Promise.all([
|
|
116
|
+
const [store, consentRes, mainMenu, footerMenu, cart] = await Promise.all([
|
|
117
|
+
fetchStore(),
|
|
69
118
|
getConsent(client),
|
|
70
119
|
fetchMenu("main-menu"),
|
|
71
120
|
fetchMenu("footer"),
|
|
@@ -80,12 +129,24 @@ export default async function RootLayout({
|
|
|
80
129
|
<html lang="en">
|
|
81
130
|
<body>
|
|
82
131
|
<ConsentInit />
|
|
83
|
-
|
|
132
|
+
{/* Every marketing tag the store configured in the admin, mounted
|
|
133
|
+
from its own config: Meta, TikTok, Google (GA4 + Ads on one
|
|
134
|
+
tag), GTM. Nothing to wire per vendor — saving the ids in
|
|
135
|
+
Settings → Integrations is the whole merchant-side act. Order
|
|
136
|
+
matters: ConsentInit sets the Consent Mode defaults
|
|
137
|
+
synchronously ABOVE this, so every tag below inherits the
|
|
138
|
+
gate. */}
|
|
139
|
+
<StorefrontTags client={client} />
|
|
140
|
+
{/* Captures UTMs and the ad-click ids (ttclid / gclid / gbraid /
|
|
141
|
+
wbraid) that exist ONLY on an ad's landing URL — miss them
|
|
142
|
+
here and no platform can attribute the order to the click. */}
|
|
143
|
+
<TrackInit />
|
|
144
|
+
<PlatformInit storeId={store.id} />
|
|
84
145
|
<Providers cart={cart} consent={consentRes.consent}>
|
|
85
146
|
<header className="border-b border-border">
|
|
86
147
|
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between gap-6">
|
|
87
148
|
<Link href="/" className="font-semibold text-lg">
|
|
88
|
-
|
|
149
|
+
{store.name}
|
|
89
150
|
</Link>
|
|
90
151
|
<div className="flex items-center gap-6">
|
|
91
152
|
<MenuNav menu={mainMenu} />
|
|
@@ -96,17 +157,11 @@ export default async function RootLayout({
|
|
|
96
157
|
Search
|
|
97
158
|
</Link>
|
|
98
159
|
{/*
|
|
99
|
-
The workbench
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
The component workbench lives at /gallery in THIS app only
|
|
161
|
+
(the seed excludes it, src/lib/storefront/seed.ts), so the
|
|
162
|
+
shop's own nav never links to it: a merchant's store must
|
|
163
|
+
not carry a dead link.
|
|
103
164
|
*/}
|
|
104
|
-
<Link
|
|
105
|
-
href="/gallery"
|
|
106
|
-
className="text-sm text-muted-foreground hover:text-foreground"
|
|
107
|
-
>
|
|
108
|
-
Library
|
|
109
|
-
</Link>
|
|
110
165
|
<CartButtonClient cart={cart} />
|
|
111
166
|
</div>
|
|
112
167
|
</div>
|
|
@@ -114,7 +169,7 @@ export default async function RootLayout({
|
|
|
114
169
|
<main>{children}</main>
|
|
115
170
|
<footer className="border-t border-border mt-12">
|
|
116
171
|
<div className="max-w-7xl mx-auto px-4 py-8 text-sm text-muted-foreground flex items-center justify-between">
|
|
117
|
-
<span>
|
|
172
|
+
<span>{store.name}</span>
|
|
118
173
|
<MenuNav menu={footerMenu} />
|
|
119
174
|
</div>
|
|
120
175
|
</footer>
|
|
@@ -1,78 +1,92 @@
|
|
|
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 {
|
|
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
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
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 { TrackOrderPurchase } from "@cartbase/storefront/tracking/track-order-purchase"
|
|
16
|
+
import { useTrackingConfig } from "@cartbase/storefront/tracking/use-tracking-config"
|
|
17
|
+
import { browserClient } from "@/lib/browser-client"
|
|
18
|
+
import { LAST_ORDER_STORAGE_KEY } from "../../../checkout/checkout-page-client"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Order confirmation (runbook step 8.6 + orders.md): guests have NO
|
|
22
|
+
* anonymous order read — the `completeCart()` response is the only order
|
|
23
|
+
* handle, so the checkout page stashes it in sessionStorage and this page
|
|
24
|
+
* renders it through the order family. Totals come from the order's
|
|
25
|
+
* summary snapshot (`orderTotalsFromSummary`); items keep the decorated
|
|
26
|
+
* cart lines' server-computed totals (`displayItemFromCartLine`).
|
|
27
|
+
*/
|
|
28
|
+
type Stash = { order: CompletedOrder; cartItems: CartLineItem[] }
|
|
29
|
+
|
|
30
|
+
export default function OrderConfirmedPage() {
|
|
31
|
+
const [stash, setStash] = useState<Stash | null | "missing">(null)
|
|
32
|
+
// The store's public tag config — Google Ads needs the account id
|
|
33
|
+
// and the purchase label from it, and null means the conversion is
|
|
34
|
+
// skipped rather than fired malformed.
|
|
35
|
+
const tracking = useTrackingConfig(browserClient)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
try {
|
|
39
|
+
const raw = sessionStorage.getItem(LAST_ORDER_STORAGE_KEY)
|
|
40
|
+
setStash(raw ? (JSON.parse(raw) as Stash) : "missing")
|
|
41
|
+
} catch {
|
|
42
|
+
setStash("missing")
|
|
43
|
+
}
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
if (stash === null) return null // first paint before sessionStorage read
|
|
47
|
+
|
|
48
|
+
if (stash === "missing") {
|
|
49
|
+
return (
|
|
50
|
+
<div className="max-w-xl mx-auto px-4 py-16 text-center">
|
|
51
|
+
<h1 className="text-xl font-semibold mb-2">Order placed</h1>
|
|
52
|
+
<p className="text-muted-foreground mb-6">
|
|
53
|
+
Your order was placed successfully. A confirmation email is on its
|
|
54
|
+
way.
|
|
55
|
+
</p>
|
|
56
|
+
<Link href="/" className="underline">
|
|
57
|
+
Continue shopping
|
|
58
|
+
</Link>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { order, cartItems } = stash
|
|
64
|
+
const totals = orderTotalsFromSummary(order.summary) ?? {
|
|
65
|
+
total: null,
|
|
66
|
+
}
|
|
67
|
+
// The completeCart() order carries FLATTENED items (checkout.md), not the
|
|
68
|
+
// order-detail pivot shape the template's default converter expects —
|
|
69
|
+
// pass the normalized cart-line items instead and drop `order.items`.
|
|
70
|
+
const orderForTemplate = { ...order, items: undefined }
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
{/* The purchase event, every configured vendor at once, fired once
|
|
75
|
+
per order per browser. Dedup keys match the platform's
|
|
76
|
+
server-side Purchase exactly, so the browser event and the
|
|
77
|
+
server event collapse into ONE conversion. */}
|
|
78
|
+
<TrackOrderPurchase order={order} items={cartItems} config={tracking} />
|
|
79
|
+
<OrderCompletedTemplate
|
|
80
|
+
order={orderForTemplate}
|
|
81
|
+
totals={totals}
|
|
82
|
+
items={cartItems.map(displayItemFromCartLine)}
|
|
83
|
+
// This reference store checks out via one merchant method (pp_* kill:
|
|
84
|
+
// methods are merchant-named, provider-less); a multi-tender store
|
|
85
|
+
// should stash the chosen tender from its checkout state alongside
|
|
86
|
+
// the order.
|
|
87
|
+
paymentMethodName={"Cash on delivery"}
|
|
88
|
+
storeHref="/"
|
|
89
|
+
/>
|
|
90
|
+
</>
|
|
91
|
+
)
|
|
92
|
+
}
|