@base44/app-plugin-commerce 0.3.4 → 0.4.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/README.md +2 -2
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +1 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +1 -0
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +46 -50
- package/skills/commerce/docs/api-storefront.md +1 -1
- package/skills/commerce/install/01-install.md +4 -1
- package/skills/commerce/install/02-storefront.md +169 -184
- package/skills/commerce/references/storefront-custom.md +150 -0
- package/skills/commerce/references/storefront-styling.md +99 -0
- package/skills/commerce/references/storefront-verification.md +4 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +10 -3
- package/src/commerce/storefront/index.js +29 -0
- package/src/commerce/storefront/parts/cart.jsx +167 -0
- package/src/commerce/storefront/parts/checkout.jsx +504 -0
- package/src/commerce/storefront/parts/drawer.jsx +119 -0
- package/src/commerce/storefront/parts/labels.jsx +140 -0
- package/src/commerce/storefront/parts/orderReceived.jsx +188 -0
- package/src/commerce/storefront/parts/parts.css +232 -0
- package/src/commerce/storefront/parts/shared.jsx +127 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
read_when: "A cart row, checkout section or receipt region needs structure the parts' props can't express, and you are about to build it on the raw hooks."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Custom sections on the raw hooks
|
|
6
|
+
|
|
7
|
+
The parts (`Cart.*`, `CartDrawer.*`, `Checkout.*`, `OrderReceived.*`) are thin
|
|
8
|
+
compositions over the hooks below — same providers, same shared state — so
|
|
9
|
+
**replacing one section while keeping the others is normal**: a bespoke
|
|
10
|
+
shipping selector between `<Checkout.AddressFields>` and
|
|
11
|
+
`<Checkout.PaymentMethods>` reads the same checkout the parts do.
|
|
12
|
+
|
|
13
|
+
Work down this ladder; each rung is cheaper than the next:
|
|
14
|
+
|
|
15
|
+
1. **`classes` / CSS** on `data-part` — almost every "the part looks wrong" is this rung.
|
|
16
|
+
2. **Labels** — a wording problem is a copy-file problem, never a code problem.
|
|
17
|
+
3. **`…Render` overrides** — `optionRender`, `lineRender`, `itemRender`, `inputRender`/`fieldRender`: your element, the part's wiring.
|
|
18
|
+
4. **This file** — rebuild one section on its hook.
|
|
19
|
+
5. **Edit the part's source** (`src/commerce/storefront/parts/`) — legitimate when a *user requirement* exceeds the props; the edited part is the store's code to maintain.
|
|
20
|
+
|
|
21
|
+
Never re-implement what a hook does (quantity clamps, totals math, repricing,
|
|
22
|
+
drawer state) — that is where storefront bugs cluster. And keep the parts'
|
|
23
|
+
guarantees when you replace them: every state worded, busy scoped to the row,
|
|
24
|
+
a disabled button that says why.
|
|
25
|
+
|
|
26
|
+
## What the custom-section hooks resolve to
|
|
27
|
+
|
|
28
|
+
| Call | Resolves to |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }` — optimistic quantity, clicks coalesced (~250ms), rollback on rejection. `pending` is **this row's** flag: true while its request is out, false only after the new cart view lands — the only mutation-settled signal (`useCart().status` never returns to `"loading"`). |
|
|
31
|
+
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }` — the drawer state on `<CartUIProvider>` (Esc, open-on-add, close-on-navigate included). |
|
|
32
|
+
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the choices (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`), and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. Works under `<Checkout.Root>` (or your own `<CheckoutProvider>`). |
|
|
33
|
+
| `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`; `lines` normalized (`image` is `{src, alt}\|null`, `totalLabel` pre-formatted); an order's totals are **flat** (`order.total`, no `order.totals`). |
|
|
34
|
+
| `cartTotalsLines(cart, { formatMoney, labels })` / `orderTotalsLines(order, …)` | `[{ key, label, amount, formatted, hidden, emphasis }]` — keys `subtotal` `discount` `shipping` `tax` `total`; discount/tax are produced and flagged `hidden` when zero, so a mapping renderer can't drop them. |
|
|
35
|
+
| `addressFieldSpec({ countries, country, required, includeEmail })` | `[{ key, label, type, required, autoComplete, options, colSpan }]` — the state field appears (as a select where the country carries states) once `country` is passed. |
|
|
36
|
+
| `ShippingMethodPicker` / `PaymentMethodPicker` | render-prop components the parts wrap — `children({ status, methods, chosen, mustChoose, single, syncing, hint, … })` / `children({ gateways, selected, single, hint })`; every option decorated with `selected`, `select()`, `costLabel`. |
|
|
37
|
+
|
|
38
|
+
## Reference wiring
|
|
39
|
+
|
|
40
|
+
Words below come from the same copy file the parts read (`STORE_COPY`) — a
|
|
41
|
+
custom section never hardcodes them.
|
|
42
|
+
|
|
43
|
+
**A custom cart row** — `<CartLine>` binds `useCartLine` per row without a
|
|
44
|
+
hook-in-a-loop:
|
|
45
|
+
|
|
46
|
+
```jsx
|
|
47
|
+
{cart.items.map((item) => (
|
|
48
|
+
<CartLine key={item.item_key} line={item}>
|
|
49
|
+
{(l) => (
|
|
50
|
+
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
51
|
+
{item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
|
|
52
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
53
|
+
aria-label={STORE_COPY.aria.decrease(item.name)}>−</button>
|
|
54
|
+
{l.quantity}
|
|
55
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
56
|
+
aria-label={STORE_COPY.aria.increase(item.name)}>+</button>
|
|
57
|
+
<button onClick={l.remove} disabled={l.pending}
|
|
58
|
+
aria-label={STORE_COPY.aria.remove(item.name)}>×</button>
|
|
59
|
+
{l.error && <p role="alert">{l.error.message}</p>}
|
|
60
|
+
</li>
|
|
61
|
+
)}
|
|
62
|
+
</CartLine>
|
|
63
|
+
))}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Give repeated controls unique accessible names (the item's name in the label)
|
|
67
|
+
— three identical "Remove" buttons are ambiguous to a screen reader and to a
|
|
68
|
+
script driving the page.
|
|
69
|
+
|
|
70
|
+
**A custom shipping step** — the picker encodes the branching a checkout must
|
|
71
|
+
not skip:
|
|
72
|
+
|
|
73
|
+
```jsx
|
|
74
|
+
<ShippingMethodPicker>
|
|
75
|
+
{({ hint, mustChoose, methods, chosen }) => (
|
|
76
|
+
<fieldset>{/* renders null for a virtual cart */}
|
|
77
|
+
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
78
|
+
{hint.serverMessage ?? STORE_COPY.shipping[hint.code]}</p>}
|
|
79
|
+
{mustChoose && methods.map((m) => (
|
|
80
|
+
<label key={m.id}>
|
|
81
|
+
<input type="radio" name="ship" checked={m.selected} onChange={m.select} />
|
|
82
|
+
{m.title} {m.costLabel}
|
|
83
|
+
</label>
|
|
84
|
+
))}
|
|
85
|
+
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
86
|
+
</fieldset>
|
|
87
|
+
)}
|
|
88
|
+
</ShippingMethodPicker>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Never render `cart.chosen_shipping_method` directly — it is a rate id; `chosen`
|
|
92
|
+
arrives resolved. A single option still *shows* what it is, never a picker of
|
|
93
|
+
one. `PaymentMethodPicker` is the same shape over `gateways` (titles and
|
|
94
|
+
descriptions are the admin's copy — render them, don't invent your own).
|
|
95
|
+
|
|
96
|
+
**A custom address form** — bind the spec, never a hand-typed field list:
|
|
97
|
+
|
|
98
|
+
```jsx
|
|
99
|
+
function AddressFields({ which }) {
|
|
100
|
+
const c = useCheckoutContext();
|
|
101
|
+
const { countries } = useCountries(); // [] until store info lands — never null
|
|
102
|
+
const isBilling = which === "billing";
|
|
103
|
+
const values = isBilling ? c.billing : c.shipping;
|
|
104
|
+
const set = isBilling ? c.updateBilling : c.updateShipping;
|
|
105
|
+
const fields = addressFieldSpec({
|
|
106
|
+
countries, country: values.country,
|
|
107
|
+
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
108
|
+
includeEmail: isBilling, // one email per order, on billing
|
|
109
|
+
});
|
|
110
|
+
return fields.map((f) => (
|
|
111
|
+
<div key={f.key}>
|
|
112
|
+
<label htmlFor={`${which}-${f.key}`}>{STORE_COPY.fields[f.key]}{f.required && " *"}</label>
|
|
113
|
+
{f.type === "select" ? (
|
|
114
|
+
<select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
|
|
115
|
+
onChange={(e) => set({ [f.key]: e.target.value })}>
|
|
116
|
+
<option value="">{STORE_COPY.fields.select_placeholder}</option>
|
|
117
|
+
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
118
|
+
</select>
|
|
119
|
+
) : (
|
|
120
|
+
<input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
|
|
121
|
+
autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
|
|
122
|
+
)}
|
|
123
|
+
{/* "we don't ship there" is an address-level error — it belongs on country */}
|
|
124
|
+
{f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
|
|
125
|
+
</div>
|
|
126
|
+
));
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
⚑ Passing `country` is what makes the state/province field appear — rates and
|
|
131
|
+
taxes match on country *plus* state, so a form without it silently mis-prices
|
|
132
|
+
US/CA/AU orders. ⚑ Keep every `autoComplete` token — it is what makes browser
|
|
133
|
+
autofill work.
|
|
134
|
+
|
|
135
|
+
**A custom place-order region** — the gate explains itself:
|
|
136
|
+
|
|
137
|
+
```jsx
|
|
138
|
+
<button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
|
|
139
|
+
{c.placing ? STORE_COPY.placeOrder.placing : STORE_COPY.placeOrder.label}
|
|
140
|
+
</button>
|
|
141
|
+
{c.orderError && <p role="alert">{c.orderError.message}</p>}
|
|
142
|
+
{!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{STORE_COPY.blockers[code]}</p>)}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`placeOrder` is safe as a direct `onClick` handler (an event is not read as
|
|
146
|
+
order fields) and resolves `{ ok, result }` / `{ ok: false, error }` without
|
|
147
|
+
throwing. ⚑ Keep the `stage === "submitted"` guard above any empty-cart branch
|
|
148
|
+
— placing an order clears the cart, and without the guard the page flashes
|
|
149
|
+
"your bag is empty" over a just-placed order (`Checkout.Root`'s gates encode
|
|
150
|
+
this ordering; a fully hand-built page must re-encode it).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
read_when: "You are writing the CSS for the cart, drawer, checkout or receipt — the parts' look."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Styling the parts
|
|
6
|
+
|
|
7
|
+
The parts ship **layout geometry and nothing else** (`src/commerce/storefront/parts/parts.css`,
|
|
8
|
+
loaded automatically by `@/commerce/storefront`): field grids, labels above
|
|
9
|
+
full-width controls, media as a fixed square, rows as media + content +
|
|
10
|
+
controls with the money pushed right, totals as label-left/value-right, the
|
|
11
|
+
drawer as a right-hand panel over a full-viewport overlay. Nothing in it
|
|
12
|
+
carries color, background, border, radius, shadow, font or text decoration — a
|
|
13
|
+
release check enforces that — so an unstyled store reads as *unfinished*, never
|
|
14
|
+
as broken, and the look is entirely yours.
|
|
15
|
+
|
|
16
|
+
**Every rule is wrapped in `:where()`, so its specificity is 0.** A plain
|
|
17
|
+
`[data-part="row"] { display: grid }` in your stylesheet wins — no `!important`,
|
|
18
|
+
no cascade fights, no need to know what the sheet did.
|
|
19
|
+
|
|
20
|
+
## Two ways in, one vocabulary
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
/* index.css — selectors, next to the store's design classes */
|
|
24
|
+
[data-part="control"] { … }
|
|
25
|
+
[data-part="option"][data-state="selected"] { … }
|
|
26
|
+
[data-part="row"][data-pending] { opacity: .55; }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```jsx
|
|
30
|
+
{/* or classes, keyed by the same data-part names */}
|
|
31
|
+
<Cart.Lines className="bag" classes={{ row: "bag-row", media: "bag-thumb", "line-total": "price" }} />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Tailwind reaches inner parts with arbitrary variants
|
|
35
|
+
(`className="[&_[data-part=option]]:choice-row"`) or `@apply` inside a design
|
|
36
|
+
class. Either way the names are the ones below.
|
|
37
|
+
|
|
38
|
+
## The minimum that makes a store look designed
|
|
39
|
+
|
|
40
|
+
⚑ **Style the controls.** Browser-default inputs are a white box in a system
|
|
41
|
+
font — on a dark or branded storefront that alone reads as unfinished:
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
[data-part="control"], [data-part="input"] {
|
|
45
|
+
border: …; background: …; color: inherit; font: inherit; padding: …;
|
|
46
|
+
}
|
|
47
|
+
[data-part="control"]:focus-visible { outline: …; } /* keep a visible focus ring */
|
|
48
|
+
[data-part="field"][data-invalid] [data-part="control"] { border-color: …; }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then, in the store's own values: the labels (`[data-part="label"]` — size,
|
|
52
|
+
tracking, case), the option rows (`[data-part="option"]` — padding, border,
|
|
53
|
+
and the `[data-state="selected"]` treatment, which is the one control customers
|
|
54
|
+
look for), the totals (`[data-part="value"]`, `[data-emphasis]` for the total
|
|
55
|
+
line), the buttons (`[data-part="place-order"]`, `apply`, `remove`,
|
|
56
|
+
`increase`/`decrease`), the notices (`[data-part="error"]`, `blocker`, `hint` —
|
|
57
|
+
`[data-severity="error"]` marks the loud ones), and the drawer surface
|
|
58
|
+
(`[data-part="panel"]` needs a background of its own, `[data-part="overlay"]` a
|
|
59
|
+
scrim).
|
|
60
|
+
|
|
61
|
+
**The parts add no outer margins** — space *between* sections comes from the
|
|
62
|
+
containers you wrap them in (`gap` on your checkout grid, your aside, your
|
|
63
|
+
drawer panel's column).
|
|
64
|
+
|
|
65
|
+
## Tuning the built-in geometry
|
|
66
|
+
|
|
67
|
+
Set these anywhere — `:root`, a page, one part — instead of rewriting the rules:
|
|
68
|
+
|
|
69
|
+
| Custom property | Default | Controls |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `--commerce-gap` | `1rem` | fields, rows, options, panel sections |
|
|
72
|
+
| `--commerce-gap-tight` | `0.4rem` | label→control, name→attributes, stepper |
|
|
73
|
+
| `--commerce-field-columns` | `2` | address-form columns (set `1` in a narrow aside or a media query) |
|
|
74
|
+
| `--commerce-media-size` | `4rem` | cart/summary thumbnail edge |
|
|
75
|
+
| `--commerce-drawer-width` | `28rem` | drawer panel width |
|
|
76
|
+
| `--commerce-drawer-z` | `50` | drawer stacking order (raise above a sticky header) |
|
|
77
|
+
|
|
78
|
+
## `data-part` inventory
|
|
79
|
+
|
|
80
|
+
| Part root | Inner `data-part`s | State attributes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `address-fields`; `ship-to-different` | field · label · required · control · error | `data-which`, `data-key`, `data-span`, `data-invalid` |
|
|
83
|
+
| `shipping-methods` `payment-methods` | hint · option · option-input · option-label · option-cost / option-description · chosen | `data-state="selected"`, `data-syncing`, `data-severity` |
|
|
84
|
+
| `lines` `items`; `notices` | row · media · content · name · attributes · controls · stepper · increase · decrease · quantity · remove · line-total · error; notice | `data-pending`, `data-empty`, `data-code` |
|
|
85
|
+
| `totals`; `payment-instructions` | row · label · value; description · account | `data-key`, `data-emphasis` |
|
|
86
|
+
| `coupon-field` | input · apply · error · applied · code · remove | `data-busy` |
|
|
87
|
+
| `place-order` · `order-error` · `blockers` | blocker | `data-state="placing"`, `data-code` |
|
|
88
|
+
| `trigger` · `drawer` | overlay · panel; `close` | `data-state="open\|closed"` |
|
|
89
|
+
|
|
90
|
+
`media` is an `<img>` when the line has an image and an empty `<div
|
|
91
|
+
data-part="media" data-empty>` when it doesn't — same box either way, so style
|
|
92
|
+
the placeholder (`[data-empty]`) rather than letting it render as a hole.
|
|
93
|
+
|
|
94
|
+
## When CSS isn't enough
|
|
95
|
+
|
|
96
|
+
A control that needs different markup takes the part's render override
|
|
97
|
+
(`inputRender`, `optionRender`, `lineRender`, `itemRender`, `fieldRender`) —
|
|
98
|
+
your element, the part's wiring. A whole section that needs different structure
|
|
99
|
+
drops to its hook: [`./storefront-custom.md`](./storefront-custom.md).
|
|
@@ -51,3 +51,7 @@ DOM is briefly right about the *intent* and wrong about the *state*.
|
|
|
51
51
|
(`/order-received?order_id=…&order_key=…` — the ids come back in
|
|
52
52
|
`placeOrder`'s result, and `commerce/admin-orders` `search` has the order
|
|
53
53
|
either way).
|
|
54
|
+
- **Sweep for unfinished copy.** A part whose label is missing renders a
|
|
55
|
+
visible `⟨copy: some.path⟩` placeholder. `⟨copy:` must appear on **no**
|
|
56
|
+
page the script visits — finding one means the store's copy file is
|
|
57
|
+
incomplete, a build defect on par with a broken button.
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
storefrontErrorCode,
|
|
13
13
|
storefrontErrorMessage,
|
|
14
14
|
} from "@/commerce/utils";
|
|
15
|
+
import { LabelsScope } from "./parts/labels";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
@@ -26,7 +27,9 @@ import {
|
|
|
26
27
|
* it, so the cart badge and the cart page read different carts.
|
|
27
28
|
*
|
|
28
29
|
* Pass `base44`, or `store={createStorefront(base44)}` when other modules need
|
|
29
|
-
* the same client.
|
|
30
|
+
* the same client. `labels` (optional) is the store's copy map for the parts
|
|
31
|
+
* layer — pass it once here and every part resolves its words from it (the
|
|
32
|
+
* key list and example live in the skill's install/02-storefront.md).
|
|
30
33
|
*
|
|
31
34
|
* What lives here, and why it must not be duplicated per page: the client (it
|
|
32
35
|
* owns the cart_token lifecycle); store info, cached for the session —
|
|
@@ -38,7 +41,7 @@ import {
|
|
|
38
41
|
|
|
39
42
|
const StorefrontContext = createContext(null);
|
|
40
43
|
|
|
41
|
-
export function StorefrontProvider({ base44, store, children }) {
|
|
44
|
+
export function StorefrontProvider({ base44, store, labels, children }) {
|
|
42
45
|
const client = useMemo(
|
|
43
46
|
() => store ?? createStorefront(base44),
|
|
44
47
|
[store, base44],
|
|
@@ -109,7 +112,11 @@ export function StorefrontProvider({ base44, store, children }) {
|
|
|
109
112
|
() => ({ client, info, infoError, cart, cartError, mutationError, runCart, clearCart }),
|
|
110
113
|
[client, info, infoError, cart, cartError, mutationError, runCart, clearCart],
|
|
111
114
|
);
|
|
112
|
-
return
|
|
115
|
+
return (
|
|
116
|
+
<StorefrontContext.Provider value={value}>
|
|
117
|
+
<LabelsScope labels={labels}>{children}</LabelsScope>
|
|
118
|
+
</StorefrontContext.Provider>
|
|
119
|
+
);
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
/** Advanced escape hatch: the raw provider state. Prefer the hooks below. */
|
|
@@ -60,6 +60,23 @@
|
|
|
60
60
|
*
|
|
61
61
|
* The catalog and product surfaces are where the design freedom lives: these
|
|
62
62
|
* hooks hand you resolved data, and the rendering is entirely yours.
|
|
63
|
+
*
|
|
64
|
+
* **Parts** — for the four commodity surfaces only (cart, drawer, checkout,
|
|
65
|
+
* order-received), `Checkout.*` / `Cart.*` / `CartDrawer.*` /
|
|
66
|
+
* `OrderReceived.*` render each section's correct semantic markup with zero
|
|
67
|
+
* copy and zero navigation: you place them in YOUR layout, and supply every
|
|
68
|
+
* word through `labels` (chain: part prop → page Root → `<StorefrontProvider
|
|
69
|
+
* labels={…}>`; a missing key renders a visible `⟨copy: …⟩` placeholder).
|
|
70
|
+
* They carry **layout geometry and nothing else** (`parts/parts.css`: field
|
|
71
|
+
* grids, row and thumbnail sizing, totals rows, the drawer panel — no color,
|
|
72
|
+
* type or border, every rule at zero specificity), so an unstyled store looks
|
|
73
|
+
* unfinished but never broken; the look arrives as your CSS on
|
|
74
|
+
* `data-part`/`data-state`, or via `className`/`classes`. Contract tables, the
|
|
75
|
+
* copy example and the `data-part` inventory: the commerce skill's
|
|
76
|
+
* install/02-storefront.md — use the parts from there, not from these files.
|
|
77
|
+
* They compose the hooks above, so dropping one section down to its hook is
|
|
78
|
+
* normal; they are also the app's own source, editable when a requirement
|
|
79
|
+
* outgrows their props.
|
|
63
80
|
*/
|
|
64
81
|
export {
|
|
65
82
|
StorefrontProvider,
|
|
@@ -81,6 +98,18 @@ export {
|
|
|
81
98
|
isShippingAddressComplete,
|
|
82
99
|
} from "./address";
|
|
83
100
|
|
|
101
|
+
// ── parts: guided sections for the commodity surfaces ──────────────────────
|
|
102
|
+
// The parts' layout geometry (field grids, row/media sizing, totals rows, the
|
|
103
|
+
// drawer panel) — structure only, no look, every rule at zero specificity so
|
|
104
|
+
// the store's CSS wins. See parts/parts.css for the custom properties.
|
|
105
|
+
import "./parts/parts.css";
|
|
106
|
+
|
|
107
|
+
export { Checkout } from "./parts/checkout";
|
|
108
|
+
export { Cart } from "./parts/cart";
|
|
109
|
+
export { CartDrawer } from "./parts/drawer";
|
|
110
|
+
export { OrderReceived } from "./parts/orderReceived";
|
|
111
|
+
export { REQUIRED_LABEL_KEYS } from "./parts/labels";
|
|
112
|
+
|
|
84
113
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
85
114
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
86
115
|
export { useProduct, useAddToCart } from "./useProduct";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { attributesLabel } from "@/commerce/utils";
|
|
3
|
+
import { useCart, useFormatMoney } from "../StorefrontProvider";
|
|
4
|
+
import { CartLine } from "../useCartLine";
|
|
5
|
+
import { label, nameLabel, useResolvedLabels } from "./labels";
|
|
6
|
+
import { CouponField, Totals } from "./shared";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cart parts — the bag's sections, unstyled and unworded, for a cart page or
|
|
10
|
+
* a drawer's contents alike. Contract, styling (`data-part`), labels and
|
|
11
|
+
* placement: the commerce skill's install/02-storefront.md. These read the
|
|
12
|
+
* ONE shared cart on `<StorefrontProvider>`; there is no `Cart.Root`.
|
|
13
|
+
*
|
|
14
|
+
* `Loading` / `Empty` / `Ready` are gates: children render only in the
|
|
15
|
+
* matching cart status, so branching on emptiness-while-loading cannot be
|
|
16
|
+
* written. Children may be a node or a function of `{ cart, itemCount }`.
|
|
17
|
+
* The checkout link belongs in the store's own markup inside `<Cart.Ready>` —
|
|
18
|
+
* no part renders navigation.
|
|
19
|
+
*/
|
|
20
|
+
function gate(name, match) {
|
|
21
|
+
function Gate({ children }) {
|
|
22
|
+
const { status, cart, itemCount } = useCart();
|
|
23
|
+
if (status !== match) return null;
|
|
24
|
+
return typeof children === "function" ? (children({ cart, itemCount }) ?? null) : (children ?? null);
|
|
25
|
+
}
|
|
26
|
+
Gate.displayName = `Cart.${name}`;
|
|
27
|
+
return Gate;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const Loading = gate("Loading", "loading");
|
|
31
|
+
const Empty = gate("Empty", "empty");
|
|
32
|
+
const Ready = gate("Ready", "ready");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The line rows, with the interaction premade per row (optimistic quantity,
|
|
36
|
+
* coalesced clicks, rollback, per-row `pending` — never a cart-wide busy
|
|
37
|
+
* state). `lineRender(item, controls)` replaces a row wholesale; `item.slug`
|
|
38
|
+
* is there when the store's row links to the product page.
|
|
39
|
+
*/
|
|
40
|
+
function Lines({ lineRender, className, classes, labels: partLabels }) {
|
|
41
|
+
const { status, cart } = useCart();
|
|
42
|
+
const formatMoney = useFormatMoney();
|
|
43
|
+
const L = useResolvedLabels(partLabels);
|
|
44
|
+
if (status !== "ready") return null;
|
|
45
|
+
return (
|
|
46
|
+
<ul data-part="lines" className={className}>
|
|
47
|
+
{cart.items.map((item) => (
|
|
48
|
+
<CartLine key={item.item_key} line={item}>
|
|
49
|
+
{(l) =>
|
|
50
|
+
lineRender ? (
|
|
51
|
+
lineRender(item, l)
|
|
52
|
+
) : (
|
|
53
|
+
<li
|
|
54
|
+
data-part="row"
|
|
55
|
+
data-pending={l.pending || undefined}
|
|
56
|
+
aria-busy={l.pending}
|
|
57
|
+
className={classes?.row}
|
|
58
|
+
>
|
|
59
|
+
{item.image ? (
|
|
60
|
+
<img data-part="media" className={classes?.media} src={item.image} alt="" loading="lazy" />
|
|
61
|
+
) : (
|
|
62
|
+
<div data-part="media" data-empty="" className={classes?.media} />
|
|
63
|
+
)}
|
|
64
|
+
<span data-part="content" className={classes?.content}>
|
|
65
|
+
<span data-part="name" className={classes?.name}>{item.name}</span>
|
|
66
|
+
{attributesLabel(item.attributes) ? (
|
|
67
|
+
<span data-part="attributes" className={classes?.attributes}>
|
|
68
|
+
{attributesLabel(item.attributes)}
|
|
69
|
+
</span>
|
|
70
|
+
) : null}
|
|
71
|
+
</span>
|
|
72
|
+
<span data-part="controls" className={classes?.controls}>
|
|
73
|
+
<span data-part="stepper" className={classes?.stepper}>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
data-part="decrease"
|
|
77
|
+
className={classes?.decrease}
|
|
78
|
+
onClick={l.decrease}
|
|
79
|
+
disabled={!l.canDecrease || l.pending}
|
|
80
|
+
aria-label={nameLabel(L, "aria.decrease", item.name)}
|
|
81
|
+
>
|
|
82
|
+
−
|
|
83
|
+
</button>
|
|
84
|
+
<span data-part="quantity" className={classes?.quantity}>{l.quantity}</span>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
data-part="increase"
|
|
88
|
+
className={classes?.increase}
|
|
89
|
+
onClick={l.increase}
|
|
90
|
+
disabled={!l.canIncrease || l.pending}
|
|
91
|
+
aria-label={nameLabel(L, "aria.increase", item.name)}
|
|
92
|
+
>
|
|
93
|
+
+
|
|
94
|
+
</button>
|
|
95
|
+
</span>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
data-part="remove"
|
|
99
|
+
className={classes?.remove}
|
|
100
|
+
onClick={l.remove}
|
|
101
|
+
disabled={l.pending}
|
|
102
|
+
aria-label={nameLabel(L, "aria.remove", item.name)}
|
|
103
|
+
>
|
|
104
|
+
×
|
|
105
|
+
</button>
|
|
106
|
+
</span>
|
|
107
|
+
<span data-part="line-total" className={classes?.["line-total"]}>
|
|
108
|
+
{formatMoney(item.total)}
|
|
109
|
+
</span>
|
|
110
|
+
{l.error && (
|
|
111
|
+
<p data-part="error" role="alert" className={classes?.error}>{l.error.message}</p>
|
|
112
|
+
)}
|
|
113
|
+
{item.purchasable && !item.purchasable.ok && (
|
|
114
|
+
<p data-part="error" data-code={item.purchasable.code} role="alert" className={classes?.error}>
|
|
115
|
+
{item.purchasable.error}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</li>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
</CartLine>
|
|
122
|
+
))}
|
|
123
|
+
</ul>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What auto-dropped and why, in the server's own words — expired coupons
|
|
129
|
+
* (`coupon_notices`) and vanished products (`removed_items`). Renders null
|
|
130
|
+
* when there is nothing to say; place it above the lines so a disappearing
|
|
131
|
+
* row never goes unexplained.
|
|
132
|
+
*/
|
|
133
|
+
function Notices({ className, classes }) {
|
|
134
|
+
const { cart } = useCart();
|
|
135
|
+
const notices = [
|
|
136
|
+
...(cart?.coupon_notices ?? []).map((n, i) => ({
|
|
137
|
+
key: `coupon-${n.code ?? i}`,
|
|
138
|
+
code: n.error_code,
|
|
139
|
+
text: n.error,
|
|
140
|
+
})),
|
|
141
|
+
...(cart?.removed_items ?? []).map((r, i) => ({
|
|
142
|
+
key: `removed-${r.item_key ?? i}`,
|
|
143
|
+
code: r.code,
|
|
144
|
+
text: r.reason,
|
|
145
|
+
})),
|
|
146
|
+
];
|
|
147
|
+
if (!notices.length) return null;
|
|
148
|
+
return (
|
|
149
|
+
<ul data-part="notices" role="status" className={className}>
|
|
150
|
+
{notices.map((n) => (
|
|
151
|
+
<li key={n.key} data-part="notice" data-code={n.code} className={classes?.notice}>
|
|
152
|
+
{n.text}
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const Cart = {
|
|
160
|
+
Loading,
|
|
161
|
+
Empty,
|
|
162
|
+
Ready,
|
|
163
|
+
Lines,
|
|
164
|
+
Notices,
|
|
165
|
+
CouponField,
|
|
166
|
+
Totals,
|
|
167
|
+
};
|