create-cartbase 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cartbase",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Scaffold a Cartbase storefront: npm create cartbase my-store",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- - The Tailwind preset is **Tailwind 3 format** (`tailwind-preset.cjs`).
40
- `create-next-app --tailwind` scaffolds Tailwind 4 (CSS-first, no config
41
- file) — either install `tailwindcss@3.4` + a `tailwind.config.cjs` with
42
- `presets: [require("@cartbase/storefront/tailwind-preset")]` (include the
43
- package source in `content`:
44
- `"./node_modules/@cartbase/storefront/src/**/*.{ts,tsx}"`), or translate
45
- the preset's tokens into Tailwind 4 `@theme` yourself. Then define the
46
- shadcn-standard token variables (`--background`, `--primary`, as raw
47
- oklch channels) in your CSS — the working set is
48
- `examples/storefront/src/app/globals.css`.
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
@@ -57,17 +57,23 @@ changes the total; `refreshPaymentIfTerminal()` from Stripe Elements
57
57
  {
58
58
  "shipping_options": [{
59
59
  "id": "so_…", "name": "Standard",
60
- "provider_id": "fp_manual", "service_zone_id": "sz_…",
61
- "shipping_option_type_id": "sotype_…", "shipping_profile_id": "sp_…",
60
+ "provider_id": null, // the carrier, when the merchant bound one
61
+ "service_zone_id": "sz_…", "shipping_profile_id": "sp_…",
62
62
  "data": null,
63
- "type": { "": "shipping_option_types row (label/code/description)" },
64
- "amount": 5, // flat price in the cart currency; null without cart_id
63
+ "type": { "label": "Express" }, // the option's display label, or null
64
+ "amount": 5, // the price the cart pays; null without cart_id
65
65
  "price_type": "flat" // calculated-rate carriers not wired yet
66
66
  }],
67
67
  "count": 1, "offset": 0, "limit": 1
68
68
  }
69
69
  ```
70
70
 
71
+ - **Eligibility** — four gates run server-side before an option lists:
72
+ the option's shipping profile must be among the cart products' profiles;
73
+ its zone must cover the shipping address; its visibility rules must
74
+ pass; and a price must exist for the cart. Conditional rates (tiers by
75
+ order amount, cart weight, or cart volume) resolve here — `amount` is
76
+ always the tier the cart actually satisfies.
71
77
  - **Errors** — 404 `cart_not_found` (bad `cart_id`).
72
78
  - **SDK** — `checkout.listShippingOptions(client, {cart_id})`.
73
79
  - **Components** — shipping picker, carrier/locker pickers (carrier
@@ -472,6 +478,52 @@ curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
472
478
 
473
479
  ---
474
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
+
475
527
  ## Manual path — collections + sessions (step-by-step)
476
528
 
477
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) — mount the Tailwind preset
18
- (`@cartbase/storefront/tailwind-preset`) and define the token variables in the
19
- app's CSS; no component hardcodes a color.
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
 
@@ -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 related-products rail. Manual admin picks first
175
- (position order), then a DETERMINISTIC fallback fills to `limit`: same
176
- primary collection (newest first), then most-shared-tags.
177
- `auto_filled: true` when any fallback item is present.
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 `{ limit? (1–24, default 12), currency_code?,
182
- region_id? }`. Accepts a product id (`prod_…`) or handle.
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.3.0",
11
+ "@cartbase/storefront": "^0.5.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
- "autoprefixer": "^10.4.20",
20
+ "@tailwindcss/postcss": "^4",
21
21
  "postcss": "^8.4.49",
22
- "tailwindcss": "^3.4.17",
22
+ "tailwindcss": "^4",
23
23
  "typescript": "^5"
24
24
  }
25
25
  }
@@ -1,6 +1,11 @@
1
- module.exports = {
2
- plugins: {
3
- tailwindcss: {},
4
- autoprefixer: {},
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
+ }