create-cartbase 0.1.3 → 0.1.5
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 +20 -10
- package/template/app/docs/checkout.md +46 -0
- package/template/app/docs/components.md +5 -3
- package/template/app/docs/products.md +6 -0
- package/template/app/docs/search.md +15 -6
- package/template/app/package.json +3 -3
- package/template/app/postcss.config.cjs +11 -6
- package/template/app/src/app/gallery/[slug]/page.tsx +172 -0
- package/template/app/src/app/gallery/_components/specimens.tsx +254 -0
- package/template/app/src/app/gallery/_components/status.tsx +47 -0
- package/template/app/src/app/gallery/_lib/catalog.ts +59 -0
- package/template/app/src/app/gallery/_lib/registry.ts +401 -0
- package/template/app/src/app/gallery/design-system/page.tsx +353 -0
- package/template/app/src/app/gallery/layout.tsx +83 -0
- package/template/app/src/app/gallery/page.tsx +81 -0
- package/template/app/src/app/globals.css +22 -38
- package/template/app/src/app/layout.tsx +12 -0
- package/template/app/tsconfig.tsbuildinfo +1 -1
- package/template/app/tailwind.config.cjs +0 -9
package/package.json
CHANGED
|
@@ -36,16 +36,26 @@ cd my-store && bun add @cartbase/storefront
|
|
|
36
36
|
- The package is **source-shipped TypeScript** — add
|
|
37
37
|
`transpilePackages: ["@cartbase/storefront"]` to `next.config` or nothing
|
|
38
38
|
from it will compile.
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
- **Styling is two imports and nothing else.** Tailwind 4 is CSS-first, so
|
|
40
|
+
there is no config file and no preset to register. In your `globals.css`:
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
@import "tailwindcss";
|
|
44
|
+
@import "@cartbase/storefront/theme";
|
|
45
|
+
|
|
46
|
+
/* The package ships TypeScript source, so Tailwind must scan it or the
|
|
47
|
+
components render unstyled. */
|
|
48
|
+
@source "../../node_modules/@cartbase/storefront/src";
|
|
49
|
+
|
|
50
|
+
/* Dark mode is class-based: put `.dark` on <html>. */
|
|
51
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The theme **ships filled**, so the store renders designed before you choose
|
|
55
|
+
a single value. To make it yours, override the token values (`--primary`,
|
|
56
|
+
`--background`, `--radius-base`, the font families) after the imports.
|
|
57
|
+
Never redefine the token *names*: components reference them, and the whole
|
|
58
|
+
library repaints from the values alone.
|
|
49
59
|
- Pin the package version — storefronts never float `latest`.
|
|
50
60
|
- Monorepo caveat: when the app lives in a workspace, set
|
|
51
61
|
`outputFileTracingRoot` in `next.config` — Next infers the root from
|
|
@@ -478,6 +478,52 @@ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
|
|
|
478
478
|
|
|
479
479
|
---
|
|
480
480
|
|
|
481
|
+
## POST /api/store/checkout-errors — browser-side error reporting
|
|
482
|
+
|
|
483
|
+
The browser half of checkout error capture. The server logs every money-path
|
|
484
|
+
failure into the merchant's checkout error log on its own; failures that
|
|
485
|
+
happen only in the customer's browser — a Stripe.js confirm error, a 3DS
|
|
486
|
+
return that comes back not-succeeded, a place-order rejection — are reported
|
|
487
|
+
through this endpoint. `useCheckoutOrchestration` reports them **by
|
|
488
|
+
default**; you only call this yourself if you replaced the hook's `logError`
|
|
489
|
+
and still want the platform log.
|
|
490
|
+
|
|
491
|
+
Body (`.strict()`): `error_type` (≤64 chars), `message` (≤2000),
|
|
492
|
+
optional `cart_id`, optional `context` object (redacted values only — ids,
|
|
493
|
+
codes, flags; **never card data, never addresses**; oversized context is
|
|
494
|
+
stored as `{truncated: true}`). Answers `204` always on accepted input;
|
|
495
|
+
`400` malformed; `429` past 60 reports/store/minute. Fire-and-forget: the
|
|
496
|
+
SDK's `reportCheckoutError()` swallows every failure — reporting an error
|
|
497
|
+
must never take a checkout down.
|
|
498
|
+
|
|
499
|
+
## Modifying checkout — the laws
|
|
500
|
+
|
|
501
|
+
Checkout is **locked space** (LOCK_BOUNDARIES): critical-to-function code is
|
|
502
|
+
closed; customize through props, slots and tokens, never by editing the
|
|
503
|
+
package's files. What that means in practice:
|
|
504
|
+
|
|
505
|
+
- **Fork the layout, never the logic.** `useCheckoutOrchestration` is the
|
|
506
|
+
one brain — session guard, amount sync, dead-PI recovery, 3DS return,
|
|
507
|
+
compensation-aware completion. A fork of the hook once dropped the
|
|
508
|
+
session guard and produced zombie Stripe sessions; the hook exists so
|
|
509
|
+
that class of bug is structurally impossible. Build your own screens on
|
|
510
|
+
top of the hook; do not reimplement it.
|
|
511
|
+
- **The server owns every amount.** The client never sends an amount;
|
|
512
|
+
sessions charge `total − gift_card_total` computed server-side. Any
|
|
513
|
+
checkout change that puts a number in a request body is wrong by
|
|
514
|
+
construction.
|
|
515
|
+
- **Logging is not optional.** Money failures must reach the merchant: the
|
|
516
|
+
hook's default sink does this. If you override `logError`, either call
|
|
517
|
+
`reportCheckoutError()` yourself or accept that browser failures vanish —
|
|
518
|
+
and that is a defect, not a preference.
|
|
519
|
+
- **Debug output is opt-in.** `useCheckoutOrchestration({ debug: true })`
|
|
520
|
+
turns on verbose `[buy-click]` console output for local work. It prints
|
|
521
|
+
the full prepare payload (name, phone, email, address), so it must never
|
|
522
|
+
ship enabled.
|
|
523
|
+
- **Test against the real wire.** Every request/response shape on this page
|
|
524
|
+
is executable against a store; a checkout change ships with its route
|
|
525
|
+
driven end to end, error branches included.
|
|
526
|
+
|
|
481
527
|
## Manual path — collections + sessions (step-by-step)
|
|
482
528
|
|
|
483
529
|
### POST /api/store/payment-collections
|
|
@@ -14,9 +14,11 @@ Import discipline: always import from the subpath
|
|
|
14
14
|
(`@cartbase/storefront/tracking/meta-pixel`, `@cartbase/storefront/lib/money`) —
|
|
15
15
|
tree-shaking and Next.js RSC boundary detection both work better than via
|
|
16
16
|
barrels. Styling resolves against STOREFRONT theme tokens (`bg-card`,
|
|
17
|
-
`text-foreground`, …, shadcn-standard names) —
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
`text-foreground`, …, shadcn-standard names) — `@import
|
|
18
|
+
"@cartbase/storefront/theme"` in the app's CSS supplies both the token names
|
|
19
|
+
and a filled default set of values, so components render designed out of the
|
|
20
|
+
box. No component hardcodes a colour; retheming means overriding values, never
|
|
21
|
+
renaming tokens.
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
@@ -86,6 +86,12 @@ foreign key → 400 `invalid_publishable_key`.
|
|
|
86
86
|
"thumbnail": null,
|
|
87
87
|
"allow_backorder": false,
|
|
88
88
|
"manage_inventory": true,
|
|
89
|
+
// Computed server-side by the platform's availability predicate:
|
|
90
|
+
// untracked or backorderable variants are always true; otherwise true
|
|
91
|
+
// iff available stock (kit-aware, reservations subtracted) is above
|
|
92
|
+
// zero. Read this — never re-derive stock client-side (the exact
|
|
93
|
+
// quantity is deliberately not exposed).
|
|
94
|
+
"in_stock": true,
|
|
89
95
|
"variant_rank": 0,
|
|
90
96
|
"metadata": null,
|
|
91
97
|
"options": [
|
|
@@ -171,15 +171,24 @@ curl -s "$BASE/api/store/products/search" -H "x-client-id: $CLIENT_ID" \
|
|
|
171
171
|
|
|
172
172
|
## GET /api/store/products/:idOrHandle/related
|
|
173
173
|
|
|
174
|
-
- **Purpose** — the PDP's
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
174
|
+
- **Purpose** — the PDP's product-to-product rails, BOTH of them,
|
|
175
|
+
discriminated by `kind`:
|
|
176
|
+
- `kind=related` (default) — the rail below the product. Manual admin picks
|
|
177
|
+
first (position order), then a DETERMINISTIC fallback fills to `limit`:
|
|
178
|
+
same primary collection (newest first), then most-shared-tags.
|
|
179
|
+
`auto_filled: true` when any fallback item is present.
|
|
180
|
+
- `kind=complementary` — the "goes with this" slot above the product. The
|
|
181
|
+
merchant's picks in their order and NOTHING else; `auto_filled` is always
|
|
182
|
+
false. There is no fallback by design: similarity can be computed,
|
|
183
|
+
complementarity is a merchant judgement, and a guessed upsell beside the
|
|
184
|
+
buy button is worse than an empty slot. An empty response means render
|
|
185
|
+
nothing.
|
|
178
186
|
- **Auth** — anon: `x-client-id`; optional publishable key — the anchor
|
|
179
187
|
product must be visible to the key, and scoped-away products never appear
|
|
180
188
|
as related items; optional Bearer JWT (group pricing).
|
|
181
|
-
- **Request** — query `{
|
|
182
|
-
region_id? }`. Accepts a product
|
|
189
|
+
- **Request** — query `{ kind? (related|complementary, default related),
|
|
190
|
+
limit? (1–24, default 12), currency_code?, region_id? }`. Accepts a product
|
|
191
|
+
id (`prod_…`) or handle.
|
|
183
192
|
- **Response**
|
|
184
193
|
|
|
185
194
|
```jsonc
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"typecheck": "tsc --noEmit"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@cartbase/storefront": "^0.
|
|
11
|
+
"@cartbase/storefront": "^0.6.0",
|
|
12
12
|
"next": "16.2.4",
|
|
13
13
|
"react": "19.2.4",
|
|
14
14
|
"react-dom": "19.2.4"
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"@types/node": "^20",
|
|
18
18
|
"@types/react": "^19",
|
|
19
19
|
"@types/react-dom": "^19",
|
|
20
|
-
"
|
|
20
|
+
"@tailwindcss/postcss": "^4",
|
|
21
21
|
"postcss": "^8.4.49",
|
|
22
|
-
"tailwindcss": "^
|
|
22
|
+
"tailwindcss": "^4",
|
|
23
23
|
"typescript": "^5"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind 4 ships its own PostCSS plugin and handles vendor prefixing
|
|
3
|
+
* internally, so the v3 pairing of `tailwindcss` + `autoprefixer` is gone.
|
|
4
|
+
* There is no tailwind.config file either: the theme is defined in CSS
|
|
5
|
+
* (see src/app/globals.css and the theme shipped by @cartbase/storefront).
|
|
6
|
+
*/
|
|
7
|
+
module.exports = {
|
|
8
|
+
plugins: {
|
|
9
|
+
"@tailwindcss/postcss": {},
|
|
10
|
+
},
|
|
11
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One component, at its own permanent address.
|
|
3
|
+
*
|
|
4
|
+
* This is the page we sit on together: you say "let's do the hero", we open
|
|
5
|
+
* /gallery/hero, and everything about that one thing is here.
|
|
6
|
+
*
|
|
7
|
+
* Variants and states are shown as two separate blocks on purpose. They look
|
|
8
|
+
* similar and they are not: a variant is a permanent option in the
|
|
9
|
+
* component's API with an id a theme writes, a state is data the component
|
|
10
|
+
* has to survive and ships as nothing. Putting them in one row, which this
|
|
11
|
+
* page used to do, teaches the wrong thing.
|
|
12
|
+
*/
|
|
13
|
+
import Link from "next/link"
|
|
14
|
+
import { notFound } from "next/navigation"
|
|
15
|
+
import { findEntry, GROUPS } from "../_lib/registry"
|
|
16
|
+
import { loadCatalog } from "../_lib/catalog"
|
|
17
|
+
import { SPECIMENS, type StateCase, type Variant } from "../_components/specimens"
|
|
18
|
+
import { StatusLabel } from "../_components/status"
|
|
19
|
+
|
|
20
|
+
export const dynamic = "force-dynamic"
|
|
21
|
+
|
|
22
|
+
export function generateStaticParams() {
|
|
23
|
+
return GROUPS.flatMap((g) => g.entries.map((e) => ({ slug: e.slug })))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function ComponentPage({
|
|
27
|
+
params,
|
|
28
|
+
}: {
|
|
29
|
+
params: Promise<{ slug: string }>
|
|
30
|
+
}) {
|
|
31
|
+
const { slug } = await params
|
|
32
|
+
const entry = findEntry(slug)
|
|
33
|
+
if (!entry) notFound()
|
|
34
|
+
|
|
35
|
+
const build = SPECIMENS[slug]
|
|
36
|
+
const specimen = build ? build({ catalog: await loadCatalog() }) : null
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<article>
|
|
40
|
+
<header className="mb-10">
|
|
41
|
+
<Link
|
|
42
|
+
href="/gallery"
|
|
43
|
+
className="text-xs uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
|
44
|
+
>
|
|
45
|
+
{entry.group.name}
|
|
46
|
+
</Link>
|
|
47
|
+
<div className="flex flex-wrap items-baseline gap-4 mt-2">
|
|
48
|
+
<h1 className="text-3xl font-semibold">{entry.name}</h1>
|
|
49
|
+
<StatusLabel status={entry.status} />
|
|
50
|
+
</div>
|
|
51
|
+
<p className="text-muted-foreground mt-3 max-w-2xl">{entry.summary}</p>
|
|
52
|
+
</header>
|
|
53
|
+
|
|
54
|
+
{specimen ? (
|
|
55
|
+
<div className="space-y-14">
|
|
56
|
+
{specimen.variants?.length ? (
|
|
57
|
+
<Block
|
|
58
|
+
title="Variants"
|
|
59
|
+
blurb="Deliberate options. Each id below ships in the component's API, so a theme can select it and we can never rename it without breaking every storefront using it."
|
|
60
|
+
api={specimen.api}
|
|
61
|
+
>
|
|
62
|
+
{specimen.variants.map((v) => (
|
|
63
|
+
<Cell key={v.id} label={v.label} note={v.note} wide={v.wide} id={v.id}>
|
|
64
|
+
{v.render}
|
|
65
|
+
</Cell>
|
|
66
|
+
))}
|
|
67
|
+
</Block>
|
|
68
|
+
) : (
|
|
69
|
+
<Block
|
|
70
|
+
title="Variants"
|
|
71
|
+
blurb="This component has none. It renders one way, and everything below is data it has to survive."
|
|
72
|
+
>
|
|
73
|
+
<p className="col-span-full text-sm text-muted-foreground">
|
|
74
|
+
No options to choose from.
|
|
75
|
+
</p>
|
|
76
|
+
</Block>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{specimen.states?.length ? (
|
|
80
|
+
<Block
|
|
81
|
+
title="States"
|
|
82
|
+
blurb="Not choices. The same component receiving different data, which the catalogue produces whether we plan for it or not. These ship as nothing; they exist so we can see what breaks."
|
|
83
|
+
>
|
|
84
|
+
{specimen.states.map((s: StateCase) => (
|
|
85
|
+
<Cell key={s.label} label={s.label} note={s.note} wide={s.wide}>
|
|
86
|
+
{s.render}
|
|
87
|
+
</Cell>
|
|
88
|
+
))}
|
|
89
|
+
</Block>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
) : entry.status === "missing" ? (
|
|
93
|
+
<Empty
|
|
94
|
+
title={`${entry.name} does not exist yet`}
|
|
95
|
+
body="Nothing in the library renders this. Say the word and we design it here first, then it gets built into the package."
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<Empty
|
|
99
|
+
title={`${entry.name} exists but has never been reviewed`}
|
|
100
|
+
body="The code renders in the shop today. It has no specimen here yet, which means we have never put its variants and states side by side and decided whether it is right."
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
</article>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function Block({
|
|
108
|
+
title,
|
|
109
|
+
blurb,
|
|
110
|
+
api,
|
|
111
|
+
children,
|
|
112
|
+
}: {
|
|
113
|
+
title: string
|
|
114
|
+
blurb: string
|
|
115
|
+
api?: string
|
|
116
|
+
children: React.ReactNode
|
|
117
|
+
}) {
|
|
118
|
+
return (
|
|
119
|
+
<section>
|
|
120
|
+
<div className="mb-6">
|
|
121
|
+
<h2 className="text-lg font-semibold">{title}</h2>
|
|
122
|
+
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{blurb}</p>
|
|
123
|
+
{api ? (
|
|
124
|
+
<code className="inline-block mt-3 text-xs bg-muted px-2 py-1 rounded font-mono">
|
|
125
|
+
{api}
|
|
126
|
+
</code>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
<div className="grid gap-8 md:grid-cols-2">{children}</div>
|
|
130
|
+
</section>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function Cell({
|
|
135
|
+
label,
|
|
136
|
+
note,
|
|
137
|
+
id,
|
|
138
|
+
wide,
|
|
139
|
+
children,
|
|
140
|
+
}: {
|
|
141
|
+
label: string
|
|
142
|
+
note?: string
|
|
143
|
+
id?: string
|
|
144
|
+
wide?: boolean
|
|
145
|
+
children: React.ReactNode
|
|
146
|
+
}) {
|
|
147
|
+
return (
|
|
148
|
+
<div className={wide ? "col-span-full min-w-0" : "min-w-0"}>
|
|
149
|
+
<div className="mb-3 flex items-baseline gap-2 flex-wrap">
|
|
150
|
+
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
151
|
+
{label}
|
|
152
|
+
</span>
|
|
153
|
+
{id ? (
|
|
154
|
+
<code className="text-[11px] font-mono text-muted-foreground/70">{id}</code>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
{note ? <p className="text-xs text-muted-foreground/80 -mt-2 mb-3">{note}</p> : null}
|
|
158
|
+
<div className="rounded-lg border border-border p-4 bg-background">{children}</div>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Empty({ title, body }: { title: string; body: string }) {
|
|
164
|
+
return (
|
|
165
|
+
<div className="rounded-lg border border-dashed border-border p-10 text-center">
|
|
166
|
+
<p className="font-medium">{title}</p>
|
|
167
|
+
<p className="text-sm text-muted-foreground mt-2 max-w-md mx-auto">{body}</p>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type { Variant }
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each component looks like — split into the two things that are NOT
|
|
3
|
+
* the same, and that this file previously collapsed into one row.
|
|
4
|
+
*
|
|
5
|
+
* **VARIANTS are chosen.** They are deliberate design options that ship as
|
|
6
|
+
* part of the component's public surface. `variant="outline"` is a string a
|
|
7
|
+
* theme writes and a merchant's agent reads, so each one needs a stable id
|
|
8
|
+
* that we can never rename without breaking every storefront using it. A
|
|
9
|
+
* variant is a promise.
|
|
10
|
+
*
|
|
11
|
+
* **STATES are received.** The same component, the same options, different
|
|
12
|
+
* data: a long title, a missing image, a sold-out product, a reduced price.
|
|
13
|
+
* Nobody picks these — the catalogue produces them. They ship as nothing at
|
|
14
|
+
* all. They exist here because a component that only survives tidy data is
|
|
15
|
+
* not finished, and the only way to know is to see the untidy data next to
|
|
16
|
+
* the tidy data.
|
|
17
|
+
*
|
|
18
|
+
* The rule that follows: **adding a variant is an API decision, adding a
|
|
19
|
+
* state is a test.** One is expensive and permanent, the other is free.
|
|
20
|
+
*/
|
|
21
|
+
import type { ReactNode } from "react"
|
|
22
|
+
import { Thumbnail } from "@cartbase/storefront/products/thumbnail"
|
|
23
|
+
import { ProductPreview } from "@cartbase/storefront/products/product-preview"
|
|
24
|
+
import { PreviewPrice } from "@cartbase/storefront/products/preview-price"
|
|
25
|
+
import { ImageGallery } from "@cartbase/storefront/products/image-gallery"
|
|
26
|
+
import { getProductPrice } from "@cartbase/storefront/lib/get-product-price"
|
|
27
|
+
import { Button } from "@cartbase/storefront/primitives/ui/button"
|
|
28
|
+
import { Input } from "@cartbase/storefront/primitives/ui/input"
|
|
29
|
+
import { Label } from "@cartbase/storefront/primitives/ui/label"
|
|
30
|
+
import type { StoreProduct } from "@cartbase/storefront/api/products"
|
|
31
|
+
import type { Catalog } from "../_lib/catalog"
|
|
32
|
+
|
|
33
|
+
export type SpecimenContext = { catalog: Catalog }
|
|
34
|
+
|
|
35
|
+
/** A deliberate option. `id` is the value that ships in the component's API. */
|
|
36
|
+
export type Variant = {
|
|
37
|
+
id: string
|
|
38
|
+
label: string
|
|
39
|
+
note?: string
|
|
40
|
+
wide?: boolean
|
|
41
|
+
render: ReactNode
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A data condition the component must survive. Ships as nothing. */
|
|
45
|
+
export type StateCase = {
|
|
46
|
+
label: string
|
|
47
|
+
note?: string
|
|
48
|
+
wide?: boolean
|
|
49
|
+
render: ReactNode
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type Specimen = {
|
|
53
|
+
/** How a merchant or theme selects a variant, e.g. `variant="outline"`. */
|
|
54
|
+
api?: string
|
|
55
|
+
variants?: Variant[]
|
|
56
|
+
states?: StateCase[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type SpecimenFn = (ctx: SpecimenContext) => Specimen
|
|
60
|
+
|
|
61
|
+
function Absent({ what }: { what: string }) {
|
|
62
|
+
return (
|
|
63
|
+
<p className="text-xs text-muted-foreground italic">
|
|
64
|
+
This store has no product for the “{what}” case.
|
|
65
|
+
</p>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Price({ product }: { product: StoreProduct }) {
|
|
70
|
+
const { cheapestPrice } = getProductPrice({ product })
|
|
71
|
+
if (!cheapestPrice) return <span className="text-xs text-muted-foreground">No price</span>
|
|
72
|
+
return <PreviewPrice price={cheapestPrice} />
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const SPECIMENS: Record<string, SpecimenFn | undefined> = {
|
|
76
|
+
"product-card": ({ catalog: { specimens } }) => ({
|
|
77
|
+
api: 'isFeatured',
|
|
78
|
+
variants: [
|
|
79
|
+
{
|
|
80
|
+
id: "default",
|
|
81
|
+
label: "Default",
|
|
82
|
+
note: "What every grid renders unless told otherwise.",
|
|
83
|
+
render: specimens.typical ? <ProductPreview product={specimens.typical} /> : <Absent what="typical" />,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "featured",
|
|
87
|
+
label: "Featured",
|
|
88
|
+
note: "A wider thumbnail ratio, for a hand-picked position.",
|
|
89
|
+
render: specimens.typical ? (
|
|
90
|
+
<ProductPreview product={specimens.typical} isFeatured />
|
|
91
|
+
) : (
|
|
92
|
+
<Absent what="typical" />
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
states: [
|
|
97
|
+
{
|
|
98
|
+
label: "Long title",
|
|
99
|
+
note: "78 characters. Where card layouts usually break.",
|
|
100
|
+
render: specimens.longTitle ? (
|
|
101
|
+
<ProductPreview product={specimens.longTitle} />
|
|
102
|
+
) : (
|
|
103
|
+
<Absent what="long title" />
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
label: "No image",
|
|
108
|
+
note: "The fallback has to look deliberate, not broken.",
|
|
109
|
+
render: specimens.noImage ? <ProductPreview product={specimens.noImage} /> : <Absent what="no image" />,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
label: "Reduced price",
|
|
113
|
+
note: "Coming from a real sale price list, not a decorative field.",
|
|
114
|
+
render: specimens.onSale ? <ProductPreview product={specimens.onSale} /> : <Absent what="reduced" />,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: "Sold out",
|
|
118
|
+
note: "Nothing in the card says so today. That is a finding, not a state.",
|
|
119
|
+
render: specimens.soldOut ? <ProductPreview product={specimens.soldOut} /> : <Absent what="sold out" />,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
}),
|
|
123
|
+
|
|
124
|
+
"product-grid": ({ catalog: { all } }) => ({
|
|
125
|
+
states: [
|
|
126
|
+
{
|
|
127
|
+
label: "Eight products together",
|
|
128
|
+
note: "The real test of the card is its neighbours: different title lengths and image ratios in one row.",
|
|
129
|
+
wide: true,
|
|
130
|
+
render: (
|
|
131
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
132
|
+
{all.slice(0, 8).map((p) => (
|
|
133
|
+
<ProductPreview key={p.id} product={p} />
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
thumbnail: ({ catalog: { specimens } }) => {
|
|
142
|
+
const p = specimens.typical
|
|
143
|
+
return {
|
|
144
|
+
api: 'size="square" | "full" | "small"',
|
|
145
|
+
variants: [
|
|
146
|
+
{ id: "square", label: "Square", render: <Thumbnail thumbnail={p?.thumbnail ?? null} images={p?.images} size="square" /> },
|
|
147
|
+
{ id: "full", label: "Full", render: <Thumbnail thumbnail={p?.thumbnail ?? null} images={p?.images} size="full" /> },
|
|
148
|
+
{ id: "small", label: "Small", render: <Thumbnail thumbnail={p?.thumbnail ?? null} images={p?.images} size="small" /> },
|
|
149
|
+
],
|
|
150
|
+
states: [
|
|
151
|
+
{
|
|
152
|
+
label: "No image at all",
|
|
153
|
+
note: "Falls through to the placeholder.",
|
|
154
|
+
render: <Thumbnail thumbnail={null} images={[]} size="square" />,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
"preview-price": ({ catalog: { specimens } }) => ({
|
|
161
|
+
states: [
|
|
162
|
+
{
|
|
163
|
+
label: "Plain",
|
|
164
|
+
render: specimens.typical ? <Price product={specimens.typical} /> : <Absent what="typical" />,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
label: "Reduced",
|
|
168
|
+
note: "Server computed the sale price below the original.",
|
|
169
|
+
render: specimens.onSale ? <Price product={specimens.onSale} /> : <Absent what="reduced" />,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
"image-gallery": ({ catalog: { specimens } }) => ({
|
|
175
|
+
states: [
|
|
176
|
+
{
|
|
177
|
+
label: "Four images",
|
|
178
|
+
wide: true,
|
|
179
|
+
render: specimens.gallery ? (
|
|
180
|
+
<ImageGallery images={specimens.gallery.images ?? []} />
|
|
181
|
+
) : (
|
|
182
|
+
<Absent what="deep gallery" />
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
label: "One image",
|
|
187
|
+
wide: true,
|
|
188
|
+
render: specimens.typical ? (
|
|
189
|
+
<ImageGallery images={(specimens.typical.images ?? []).slice(0, 1)} />
|
|
190
|
+
) : (
|
|
191
|
+
<Absent what="typical" />
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
{ label: "No images", wide: true, render: <ImageGallery images={[]} /> },
|
|
195
|
+
],
|
|
196
|
+
}),
|
|
197
|
+
|
|
198
|
+
button: () => ({
|
|
199
|
+
api: 'variant="…" size="…"',
|
|
200
|
+
variants: [
|
|
201
|
+
{ id: "default", label: "Default", note: "Add to cart is this one.", render: <Button>Add to cart</Button> },
|
|
202
|
+
{ id: "secondary", label: "Secondary", render: <Button variant="secondary">Secondary</Button> },
|
|
203
|
+
{ id: "outline", label: "Outline", render: <Button variant="outline">Outline</Button> },
|
|
204
|
+
{ id: "ghost", label: "Ghost", render: <Button variant="ghost">Ghost</Button> },
|
|
205
|
+
{ id: "destructive", label: "Destructive", render: <Button variant="destructive">Remove</Button> },
|
|
206
|
+
{ id: "sm", label: "Small", render: <Button size="sm">Small</Button> },
|
|
207
|
+
{ id: "lg", label: "Large", render: <Button size="lg">Large</Button> },
|
|
208
|
+
],
|
|
209
|
+
states: [
|
|
210
|
+
{
|
|
211
|
+
label: "Disabled",
|
|
212
|
+
note: "What a sold-out add-to-cart looks like.",
|
|
213
|
+
render: <Button disabled>Add to cart</Button>,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
label: "Full width",
|
|
217
|
+
note: "How it sits on a product page.",
|
|
218
|
+
render: <Button className="w-full">Add to cart</Button>,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
}),
|
|
222
|
+
|
|
223
|
+
input: () => ({
|
|
224
|
+
states: [
|
|
225
|
+
{
|
|
226
|
+
label: "Empty",
|
|
227
|
+
render: (
|
|
228
|
+
<div className="space-y-2">
|
|
229
|
+
<Label htmlFor="g-email">Email</Label>
|
|
230
|
+
<Input id="g-email" placeholder="you@example.com" />
|
|
231
|
+
</div>
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
label: "Filled",
|
|
236
|
+
render: (
|
|
237
|
+
<div className="space-y-2">
|
|
238
|
+
<Label htmlFor="g-name">Full name</Label>
|
|
239
|
+
<Input id="g-name" defaultValue="Maria Petrova" />
|
|
240
|
+
</div>
|
|
241
|
+
),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
label: "Disabled",
|
|
245
|
+
render: (
|
|
246
|
+
<div className="space-y-2">
|
|
247
|
+
<Label htmlFor="g-locked">Country</Label>
|
|
248
|
+
<Input id="g-locked" defaultValue="Bulgaria" disabled />
|
|
249
|
+
</div>
|
|
250
|
+
),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}),
|
|
254
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status, shown the same way everywhere: a dot in the nav, a word on the page.
|
|
3
|
+
*
|
|
4
|
+
* Three states and no fourth, so the board can never be ambiguous about what
|
|
5
|
+
* is finished. `missing` is deliberately loud — an empty slot with a name is
|
|
6
|
+
* the roadmap, and hiding it would make the library look more complete than
|
|
7
|
+
* it is.
|
|
8
|
+
*/
|
|
9
|
+
import type { Status } from "../_lib/registry"
|
|
10
|
+
|
|
11
|
+
const COLOUR: Record<Status, string> = {
|
|
12
|
+
done: "bg-emerald-500",
|
|
13
|
+
built: "bg-amber-400",
|
|
14
|
+
missing: "bg-border",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const WORD: Record<Status, string> = {
|
|
18
|
+
done: "Agreed",
|
|
19
|
+
built: "Not reviewed",
|
|
20
|
+
missing: "Not built",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const EXPLAIN: Record<Status, string> = {
|
|
24
|
+
done: "We looked at it together, agreed it, and its styling values are tokens.",
|
|
25
|
+
built: "The code exists and renders, but we have never reviewed it.",
|
|
26
|
+
missing: "It does not exist. Building it is the work.",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function StatusDot({ status }: { status: Status }) {
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${COLOUR[status]}`}
|
|
33
|
+
title={WORD[status]}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function StatusLabel({ status }: { status: Status }) {
|
|
39
|
+
return (
|
|
40
|
+
<span className="inline-flex items-center gap-2" title={EXPLAIN[status]}>
|
|
41
|
+
<StatusDot status={status} />
|
|
42
|
+
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
43
|
+
{WORD[status]}
|
|
44
|
+
</span>
|
|
45
|
+
</span>
|
|
46
|
+
)
|
|
47
|
+
}
|