@base44/app-plugin-commerce 0.4.1 → 0.5.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 +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +13 -11
- package/skills/commerce/install/02-storefront.md +89 -99
- package/skills/commerce/references/catalog-rendering.md +2 -2
- package/skills/commerce/references/storefront-custom.md +1 -1
- package/skills/commerce/references/storefront-parts.md +184 -0
- package/src/commerce/storefront/cartUI.jsx +63 -17
- package/src/commerce/storefront/index.js +5 -3
- package/src/commerce/storefront/parts/cart.jsx +64 -40
- package/src/commerce/storefront/parts/checkout.jsx +91 -55
- package/src/commerce/storefront/parts/drawer.jsx +35 -67
- package/src/commerce/storefront/parts/orderReceived.jsx +24 -12
- package/src/commerce/storefront/parts/parts.css +12 -29
- package/src/commerce/storefront/parts/shared.jsx +31 -21
- package/src/commerce/storefront/parts/visibility.js +36 -0
- package/src/commerce/storefront/useCheckout.jsx +27 -0
- package/src/commerce/utils/index.js +4 -1
- package/src/commerce/utils/specs.js +95 -10
- package/skills/commerce/references/storefront-styling.md +0 -99
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
* - `ribbons.js` — `productRibbons`: ribbons normalized to `{id, name}` — they
|
|
29
29
|
* are objects, and the field is absent on a listing page that carries none.
|
|
30
30
|
* - `specs.js` — `productSpecs`: `meta_data` → descriptive rows (`key`, `label`,
|
|
31
|
-
* `titleLabel`, `value`)
|
|
31
|
+
* `titleLabel`, `value`) each carrying an inferred render `type` (numeric,
|
|
32
|
+
* duration, location, list, text), so a weight can read as a figure and a
|
|
33
|
+
* composition as bars instead of every modifier as one grey table row. Match
|
|
34
|
+
* rows by `key` via `findSpec`, never by `label`.
|
|
32
35
|
* - `types.js` — types only: `StorefrontProduct` and the rest of the catalog
|
|
33
36
|
* shapes as JSDoc typedefs, so what a field holds is readable from the
|
|
34
37
|
* frontend instead of from the backend function's source.
|
|
@@ -6,6 +6,32 @@
|
|
|
6
6
|
* **not** attributes and not ribbons: they describe the product, they don't
|
|
7
7
|
* select a variant. Hidden keys (leading `_`) and empty values are skipped.
|
|
8
8
|
*
|
|
9
|
+
* Each row carries a `type` — inferred from the value, and for `"location"`
|
|
10
|
+
* from the key — so the rendering decision is already made for you. **A
|
|
11
|
+
* `.map()` into one uniform label/value table is the fallback, not the
|
|
12
|
+
* target:** the types exist because a carat weight and a care instruction are
|
|
13
|
+
* not the same kind of fact and should not look alike.
|
|
14
|
+
*
|
|
15
|
+
* ```jsx
|
|
16
|
+
* // ❌ every product in every store, identical: one grey table
|
|
17
|
+
* <dl>{productSpecs(product).map((s) => (
|
|
18
|
+
* <div key={s.key}><dt>{s.titleLabel}</dt><dd>{s.value}</dd></div>))}</dl>
|
|
19
|
+
*
|
|
20
|
+
* // ✅ branch on type — the figures read as figures, the rest stays a row
|
|
21
|
+
* {productSpecs(product).map((s) =>
|
|
22
|
+
* s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
|
|
23
|
+
* : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
|
|
24
|
+
* : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
|
|
25
|
+
* : s.type === "duration" ? <Lead key={s.key} label={s.label} value={s.value} />
|
|
26
|
+
* : <Row key={s.key} label={s.titleLabel} value={s.value} />)}
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Design the two or three that carry *this* product's meaning (a weight set in
|
|
30
|
+
* the display face, a provenance beside a map, a composition as bars) and let
|
|
31
|
+
* the remainder fall through to the plain row. The rows need not sit in one
|
|
32
|
+
* block either: a spec can go under the gallery, beside the price, or inside
|
|
33
|
+
* the description.
|
|
34
|
+
*
|
|
9
35
|
* ```jsx
|
|
10
36
|
* const specs = productSpecs(product);
|
|
11
37
|
* const care = findSpec(specs, "care"); // not specs.find(s => s.label === "Care")
|
|
@@ -18,17 +44,15 @@
|
|
|
18
44
|
* fallback forever (observed in a live store). `titleLabel` is the display-cased
|
|
19
45
|
* form, for when you do want to print the key as a heading.
|
|
20
46
|
*
|
|
21
|
-
* A `.map()` into one uniform label/value table is the fallback, not the
|
|
22
|
-
* target: a carat weight and a care instruction are not the same kind of fact
|
|
23
|
-
* and need not look alike. Which two or three of *this* catalog's modifiers
|
|
24
|
-
* carry meaning — and how each is rendered — is a design decision about this
|
|
25
|
-
* store, made from its own data. The rows need not sit in one block either: a
|
|
26
|
-
* spec can go under the gallery, beside the price, or inside the description.
|
|
27
|
-
*
|
|
28
47
|
* @param {object} product
|
|
29
|
-
* @returns {Array<{key: string, label: string, titleLabel: string, value: string
|
|
48
|
+
* @returns {Array<{key: string, label: string, titleLabel: string, value: string,
|
|
49
|
+
* type: "numeric"|"duration"|"location"|"list"|"text",
|
|
50
|
+
* number: number|null, unit: string|null, items: string[]}>}
|
|
30
51
|
* `[]` when the product has no visible meta_data — render nothing, not an
|
|
31
|
-
* empty section. `
|
|
52
|
+
* empty section. `number`/`unit` are set for `numeric` and `duration`
|
|
53
|
+
* (`unit` is `""` for a bare number), `items` for `list`, and are
|
|
54
|
+
* `null`/`[]` otherwise. `value` is always the store's own text, unchanged —
|
|
55
|
+
* the extra fields are there to render *with*, never a replacement for it.
|
|
32
56
|
*/
|
|
33
57
|
export function productSpecs(product) {
|
|
34
58
|
return (product?.meta_data ?? [])
|
|
@@ -40,7 +64,7 @@ export function productSpecs(product) {
|
|
|
40
64
|
// `label` is the key with underscores opened up, in whatever case it was
|
|
41
65
|
// typed; `titleLabel` is the display-cased form, for printing as a <dt>.
|
|
42
66
|
const titleLabel = label.replace(/(^|\s)\p{Ll}/gu, (c) => c.toUpperCase());
|
|
43
|
-
return { key, label, titleLabel, value };
|
|
67
|
+
return { key, label, titleLabel, value, ...classify(key, value) };
|
|
44
68
|
});
|
|
45
69
|
}
|
|
46
70
|
|
|
@@ -67,3 +91,64 @@ const normalizeSpecKey = (k) =>
|
|
|
67
91
|
.toLowerCase()
|
|
68
92
|
.replace(/[\s_-]+/g, "")
|
|
69
93
|
.trim();
|
|
94
|
+
|
|
95
|
+
const LOCATION_KEY =
|
|
96
|
+
/(origin|provenance|made[\s_-]?in|country|region|sourced|source|location|city|terroir|appellation|distillery|winery|atelier|workshop)/i;
|
|
97
|
+
|
|
98
|
+
const DURATION_UNIT =
|
|
99
|
+
/^(sec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days|week|weeks|month|months|year|years|yr|yrs)$/i;
|
|
100
|
+
|
|
101
|
+
/** Infer the render-relevant shape of one spec value. Never throws. */
|
|
102
|
+
function classify(key, raw) {
|
|
103
|
+
const value = raw.trim();
|
|
104
|
+
const plain = { type: "text", number: null, unit: null, items: [] };
|
|
105
|
+
|
|
106
|
+
if (LOCATION_KEY.test(key)) return { ...plain, type: "location" };
|
|
107
|
+
|
|
108
|
+
const qty = parseQuantity(value);
|
|
109
|
+
if (qty) {
|
|
110
|
+
const type = DURATION_UNIT.test(qty.unit) ? "duration" : "numeric";
|
|
111
|
+
return { ...plain, type, number: qty.number, unit: qty.unit };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const items = parseList(value);
|
|
115
|
+
if (items) return { ...plain, type: "list", items };
|
|
116
|
+
|
|
117
|
+
return plain;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** "0.75 ct" → {number: 0.75, unit: "ct"}; "18" → {number: 18, unit: ""}. */
|
|
121
|
+
function parseQuantity(value) {
|
|
122
|
+
const m = /^([-+]?[\d.,]+)\s*(.*)$/.exec(value);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
const number = toNumber(m[1]);
|
|
125
|
+
if (number === null) return null;
|
|
126
|
+
const unit = m[2].trim();
|
|
127
|
+
// A unit is a word or two of symbols/letters. Anything longer is prose that
|
|
128
|
+
// happens to start with a number ("2 pieces, hand-cut in the studio").
|
|
129
|
+
if (unit && (!/^[\p{L}%°µ"'/²³.\- ]{1,12}$/u.test(unit) || unit.split(/\s+/).length > 2)) return null;
|
|
130
|
+
return { number, unit };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Grouped thousands are separators; a lone comma between digits is a decimal. */
|
|
134
|
+
function toNumber(raw) {
|
|
135
|
+
let s = raw.replace(/\s/g, "");
|
|
136
|
+
if (/^[-+]?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
|
|
137
|
+
else if (/^[-+]?\d+,\d+$/.test(s)) s = s.replace(",", ".");
|
|
138
|
+
else if (s.includes(",")) return null;
|
|
139
|
+
const n = Number(s);
|
|
140
|
+
return Number.isFinite(n) ? n : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** "70% wool / 30% cashmere" → ["70% wool", "30% cashmere"]. */
|
|
144
|
+
function parseList(value) {
|
|
145
|
+
const parts = value
|
|
146
|
+
.split(/\s*[,;|·•/]\s*/)
|
|
147
|
+
.map((p) => p.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
if (parts.length < 2) return null;
|
|
150
|
+
// Short fragments with words in them — not a sentence that happens to have commas.
|
|
151
|
+
if (parts.some((p) => p.length > 24 || p.split(/\s+/).length > 3 || /[.!?]/.test(p))) return null;
|
|
152
|
+
if (!parts.some((p) => /\p{L}/u.test(p))) return null;
|
|
153
|
+
return parts;
|
|
154
|
+
}
|
|
@@ -1,99 +0,0 @@
|
|
|
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).
|