@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.
@@ -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`). Match rows by `key`, never by `label`.
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. `value` is always the store's own text, unchanged.
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).