@base44/app-plugin-commerce 0.5.0 → 0.6.0

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.
@@ -0,0 +1,150 @@
1
+ import React, { useId, useState } from "react";
2
+ import { addressFieldSpec } from "@/commerce/utils";
3
+ import { useCheckoutContext } from "./useCheckout";
4
+ import { useCountries } from "./StorefrontProvider";
5
+ import { REQUIRED_BILLING_FIELDS } from "./address";
6
+
7
+ /**
8
+ * The checkout address form, headless — the one storefront section that ships
9
+ * as a component rather than as wiring reference, because hand-rolling it is
10
+ * where checkouts break: the state/province field must appear with the right
11
+ * options once a country is picked (shipping and taxes match on country *plus*
12
+ * state, so a form without it silently mis-prices US/CA/AU orders),
13
+ * `autoComplete` tokens are what make browser autofill work, and the server's
14
+ * "we don't ship there" belongs on the country field.
15
+ *
16
+ * It renders semantic fields and nothing else — no CSS ships with it. Every
17
+ * element carries `data-part` (`field`, `label`, `control`, `required`,
18
+ * `error`) plus `data-key`/`data-span`/`data-invalid`, so the store's own
19
+ * classes attach via `className`/`classes` props or `[data-part]` selectors.
20
+ * Field labels come from `addressFieldSpec` (plain conventions — rename any of
21
+ * them via `labels`). Required marks arm on first blur, never on load.
22
+ *
23
+ * Works inside `<CheckoutProvider>` (it reads `useCheckoutContext()`).
24
+ * `which="shipping"` renders null until `shipToDifferent` is on — the
25
+ * deliver-elsewhere checkbox itself stays yours, wired to
26
+ * `checkout.shipToDifferent` / `checkout.setShipToDifferent`.
27
+ *
28
+ * @param {object} props
29
+ * @param {"billing"|"shipping"} [props.which] which address this form edits.
30
+ * @param {boolean} [props.includeCompany] add the company field (default false).
31
+ * @param {boolean} [props.includePhone] keep the phone field (default true).
32
+ * @param {string[]} [props.omit] field keys to drop entirely.
33
+ * @param {Record<string, string>} [props.labels] label text per field key,
34
+ * merged over the spec's defaults (e.g. `{ postcode: "ZIP code" }`).
35
+ * @param {string} [props.selectPlaceholder] the selects' empty-option text.
36
+ * @param {Function} [props.inputRender] `({ field, options, invalid, ...dom })`
37
+ * — swaps the control only; spread `dom` onto your input.
38
+ * @param {Function} [props.fieldRender] `({ field, label, invalid, options, ...dom })`
39
+ * — replaces the whole labeled block.
40
+ * @param {string} [props.className] class for the wrapping element.
41
+ * @param {{field?: string, label?: string, control?: string, error?: string}} [props.classes]
42
+ */
43
+ export function AddressFields({
44
+ which = "billing",
45
+ includeCompany = false,
46
+ includePhone = true,
47
+ omit,
48
+ labels,
49
+ selectPlaceholder = "Select…",
50
+ inputRender: InputRender,
51
+ fieldRender,
52
+ className,
53
+ classes,
54
+ }) {
55
+ const checkout = useCheckoutContext();
56
+ const { countries } = useCountries();
57
+ const idBase = useId();
58
+ const [touched, setTouched] = useState({});
59
+
60
+ const isBilling = which === "billing";
61
+ if (!isBilling && !checkout.shipToDifferent) return null;
62
+
63
+ const values = isBilling ? checkout.billing : checkout.shipping;
64
+ const set = isBilling ? checkout.updateBilling : checkout.updateShipping;
65
+ const omitted = new Set(omit ?? []);
66
+ const fields = addressFieldSpec({
67
+ countries,
68
+ country: values.country,
69
+ required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
70
+ includeEmail: isBilling, // one email per order, on billing
71
+ includeCompany,
72
+ includePhone,
73
+ }).filter((f) => !omitted.has(f.key));
74
+
75
+ return (
76
+ <div data-part="address-fields" data-which={which} className={className}>
77
+ {fields.map((f) => {
78
+ const id = `${idBase}-${which}-${f.key}`;
79
+ const value = values[f.key] ?? "";
80
+ const invalid = Boolean(touched[f.key] && f.required && !String(value).trim());
81
+ const dom = {
82
+ id,
83
+ value,
84
+ autoComplete: f.autoComplete,
85
+ onChange: (e) => set({ [f.key]: e && e.target ? e.target.value : e }),
86
+ onBlur: () => setTouched((t) => (t[f.key] ? t : { ...t, [f.key]: true })),
87
+ };
88
+ const labelText = labels?.[f.key] ?? f.label;
89
+ if (fieldRender) {
90
+ return (
91
+ <React.Fragment key={f.key}>
92
+ {fieldRender({ field: f, label: labelText, invalid, options: f.options, ...dom })}
93
+ </React.Fragment>
94
+ );
95
+ }
96
+ return (
97
+ <div
98
+ key={f.key}
99
+ data-part="field"
100
+ data-key={f.key}
101
+ data-span={f.colSpan}
102
+ data-invalid={invalid || undefined}
103
+ className={classes?.field}
104
+ >
105
+ <label data-part="label" className={classes?.label} htmlFor={id}>
106
+ {labelText}
107
+ {f.required && (
108
+ <span data-part="required" aria-hidden="true">
109
+ *
110
+ </span>
111
+ )}
112
+ </label>
113
+ {InputRender ? (
114
+ <InputRender field={f} options={f.options} invalid={invalid} {...dom} />
115
+ ) : f.type === "select" ? (
116
+ <select
117
+ data-part="control"
118
+ className={classes?.control}
119
+ aria-invalid={invalid || undefined}
120
+ aria-required={f.required || undefined}
121
+ {...dom}
122
+ >
123
+ <option value="">{selectPlaceholder}</option>
124
+ {f.options.map((o) => (
125
+ <option key={o.value} value={o.value}>
126
+ {o.label}
127
+ </option>
128
+ ))}
129
+ </select>
130
+ ) : (
131
+ <input
132
+ data-part="control"
133
+ className={classes?.control}
134
+ type={f.type}
135
+ aria-invalid={invalid || undefined}
136
+ aria-required={f.required || undefined}
137
+ {...dom}
138
+ />
139
+ )}
140
+ {f.key === "country" && checkout.addressError && (
141
+ <p data-part="error" role="alert" className={classes?.error}>
142
+ {checkout.addressError.message}
143
+ </p>
144
+ )}
145
+ </div>
146
+ );
147
+ })}
148
+ </div>
149
+ );
150
+ }
@@ -12,7 +12,6 @@ import {
12
12
  storefrontErrorCode,
13
13
  storefrontErrorMessage,
14
14
  } from "@/commerce/utils";
15
- import { LabelsScope } from "./parts/labels";
16
15
 
17
16
  /**
18
17
  * StorefrontProvider — one client, one store-info cache, ONE shared cart.
@@ -27,9 +26,7 @@ import { LabelsScope } from "./parts/labels";
27
26
  * it, so the cart badge and the cart page read different carts.
28
27
  *
29
28
  * Pass `base44`, or `store={createStorefront(base44)}` when other modules need
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).
29
+ * the same client.
33
30
  *
34
31
  * What lives here, and why it must not be duplicated per page: the client (it
35
32
  * owns the cart_token lifecycle); store info, cached for the session —
@@ -41,7 +38,7 @@ import { LabelsScope } from "./parts/labels";
41
38
 
42
39
  const StorefrontContext = createContext(null);
43
40
 
44
- export function StorefrontProvider({ base44, store, labels, children }) {
41
+ export function StorefrontProvider({ base44, store, children }) {
45
42
  const client = useMemo(
46
43
  () => store ?? createStorefront(base44),
47
44
  [store, base44],
@@ -112,11 +109,7 @@ export function StorefrontProvider({ base44, store, labels, children }) {
112
109
  () => ({ client, info, infoError, cart, cartError, mutationError, runCart, clearCart }),
113
110
  [client, info, infoError, cart, cartError, mutationError, runCart, clearCart],
114
111
  );
115
- return (
116
- <StorefrontContext.Provider value={value}>
117
- <LabelsScope labels={labels}>{children}</LabelsScope>
118
- </StorefrontContext.Provider>
119
- );
112
+ return <StorefrontContext.Provider value={value}>{children}</StorefrontContext.Provider>;
120
113
  }
121
114
 
122
115
  /** Advanced escape hatch: the raw provider state. Prefer the hooks below. */
@@ -1,45 +1,26 @@
1
- import React, {
2
- createContext,
3
- useCallback,
4
- useContext,
5
- useEffect,
6
- useId,
7
- useLayoutEffect,
8
- useMemo,
9
- useRef,
10
- useState,
11
- } from "react";
1
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
12
2
  import { useLocation } from "react-router-dom";
13
3
 
14
4
  /**
15
- * Cart drawer state and behavior, headless **the drawer's surface is yours**:
16
- * you render the overlay and the panel, and own their position, size, padding,
17
- * animation and every class on them. What lives here is the part that is the
18
- * same in every store and easy to get wrong:
19
- *
20
- * - `open` + `openCart`/`closeCart`/`toggleCart`.
21
- * - Esc closes; the route changing closes (`closeOnNavigate`); an item landing
22
- * in the cart opens (`openOnAdd`, wired through `useAddToCart` — pass false
23
- * for a navigate-to-bag flow).
24
- * - **Focus.** Attach `panelRef` to your panel element: focus moves into it
25
- * when the drawer opens and returns to whatever had it when the drawer
26
- * closes (give the panel `tabIndex={-1}` so it can receive it).
27
- * - **A closed-but-mounted panel is made `inert`** while `panelRef` is
28
- * attached — the classic drawer bug is a panel translated off-screen whose
29
- * buttons stay clickable, tab-able and readable to screen readers. Animate
30
- * the slide freely; the closed panel stops being interactive by itself.
31
- * - `panelId` — put it on your panel as `id`; the kit's `<CartDrawer.Trigger>`
32
- * already points `aria-controls` at it.
5
+ * Cart drawer/panel state, headless: `open` plus the handlers to change it,
6
+ * and the two behaviors every drawer needs but a hand-written one forgets —
7
+ * it closes when the route changes (`closeOnNavigate`) and it opens when an
8
+ * item lands in the cart (`openOnAdd`, wired through `useAddToCart`; pass false
9
+ * for a navigate-to-bag flow). Esc closes it. You own every element, class and
10
+ * attribute.
33
11
  *
34
12
  * Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
35
- * layout; the layout renders the drawer off `open`.
13
+ * layout; the layout then renders the panel off `open`.
36
14
  *
37
- * ⚑ Your panel is the dialog: `role="dialog" aria-modal="true"` plus a name
38
- * (`aria-label`/`aria-labelledby`). The overlay is a click-away surface
39
- * (`aria-hidden`, no tab stop) it is *not* the close control; a named close
40
- * button inside the panel is (`<CartDrawer.Close>`).
15
+ * ⚑ **Render the drawer conditionally `{ui.open && …}`.** The classic drawer
16
+ * bug is a panel translated off-screen but still mounted: its buttons stay
17
+ * clickable, tab-able and visible to screen readers. Unmounting when closed is
18
+ * the trivial fix; if you keep it mounted to animate the slide, that concern is
19
+ * yours again — set the `inert` attribute while closed. The overlay is a
20
+ * click-away surface (`aria-hidden`, no tab stop) — it is not the close control;
21
+ * a named close button inside the panel is.
41
22
  *
42
- * `useCartUI()` → `{ open, openCart, closeCart, toggleCart, panelRef, panelId }`.
23
+ * `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
43
24
  * `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
44
25
  * integrates without requiring the provider).
45
26
  *
@@ -48,9 +29,6 @@ import { useLocation } from "react-router-dom";
48
29
 
49
30
  const CartUIContext = createContext(null);
50
31
 
51
- /** Layout effect where there is a DOM; plain effect on the server (no warning). */
52
- const useDrawerEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
53
-
54
32
  /** Internal: closes the drawer whenever the route changes (needs a Router above). */
55
33
  function CloseOnNavigate({ close }) {
56
34
  const { pathname } = useLocation();
@@ -74,9 +52,6 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
74
52
  const openCart = useCallback(() => setOpen(true), []);
75
53
  const closeCart = useCallback(() => setOpen(false), []);
76
54
  const toggleCart = useCallback(() => setOpen((o) => !o), []);
77
- const panelId = useId();
78
- const panelRef = useRef(null);
79
- const restoreRef = useRef(null);
80
55
 
81
56
  // Esc closes — the keyboard's way out is Esc and the named close button.
82
57
  useEffect(() => {
@@ -88,36 +63,15 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
88
63
  return () => window.removeEventListener("keydown", onKey);
89
64
  }, [open]);
90
65
 
91
- // Focus into the store's panel on open, back where it came from on close, and
92
- // a closed-but-mounted panel is inert — all on whatever element the store
93
- // attached `panelRef` to, so the drawer's markup and animation stay theirs.
94
- // Layout effect: before paint, so an animating panel is never briefly live.
95
- useDrawerEffect(() => {
96
- const node = panelRef.current;
97
- if (open) {
98
- restoreRef.current = typeof document !== "undefined" ? document.activeElement : null;
99
- if (node) {
100
- node.inert = false;
101
- node.focus?.({ preventScroll: true });
102
- }
103
- } else {
104
- if (node) node.inert = true;
105
- restoreRef.current?.focus?.();
106
- restoreRef.current = null;
107
- }
108
- }, [open]);
109
-
110
66
  const value = useMemo(
111
67
  () => ({
112
68
  open,
113
69
  openCart,
114
70
  closeCart,
115
71
  toggleCart,
116
- panelRef,
117
- panelId,
118
72
  onItemAdded: openOnAdd ? openCart : null,
119
73
  }),
120
- [open, openCart, closeCart, toggleCart, panelId, openOnAdd],
74
+ [open, openCart, closeCart, toggleCart, openOnAdd],
121
75
  );
122
76
 
123
77
  return (
@@ -35,6 +35,10 @@
35
35
  * - `useOrderReturn` — the mandatory `/order-received` page in one hook.
36
36
  * - `ShippingMethodPicker` / `PaymentMethodPicker` — render-prop components for
37
37
  * the two checkout choices that are store data, never hardcoded.
38
+ * - `AddressFields` — the ONE rendered component in the layer: the checkout
39
+ * address form (state/province appears per country, `autoComplete` kept,
40
+ * required marks arm on blur). Unstyled; style it via `data-part`
41
+ * selectors or its `className`/`classes` props.
38
42
  *
39
43
  * Re-exported from `@/commerce/utils` so one import line covers a page:
40
44
  * `variantAxes` (axes → options with selected/disabled/stock derived),
@@ -43,8 +47,10 @@
43
47
  * gallery follows the variant selection without owning a second copy of it),
44
48
  * `productRibbons` (ribbons are `{id, name}` objects, not strings — rendering
45
49
  * one straight into JSX is React's "Objects are not valid as a React child"),
46
- * `productSpecs` + `findSpec` (meta keys are free text, so featuring a
47
- * particular spec needs a tolerant lookup, not an equality test),
50
+ * `productSpecs` + `findSpec` (spec rows carry an inferred render `type` so a
51
+ * weight reads as a figure and a composition as bars rather than every modifier
52
+ * as one grey table row; meta keys are free text, so featuring a particular
53
+ * spec needs a tolerant lookup, not an equality test),
48
54
  * `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
49
55
  * `addressFieldSpec` (the checkout's field list, including the state/province
50
56
  * field that silently mis-prices US/CA/AU orders when it is left out), and
@@ -60,23 +66,6 @@
60
66
  *
61
67
  * The catalog and product surfaces are where the design freedom lives: these
62
68
  * 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.
80
69
  */
81
70
  export {
82
71
  StorefrontProvider,
@@ -87,9 +76,10 @@ export {
87
76
  useCountries,
88
77
  useCart,
89
78
  } from "./StorefrontProvider";
90
- export { useCheckout, CheckoutProvider, useCheckoutContext, useInCheckout } from "./useCheckout";
79
+ export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
91
80
  export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
92
81
  export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
82
+ export { AddressFields } from "./AddressFields";
93
83
  export { CartUIProvider, useCartUI } from "./cartUI";
94
84
  export {
95
85
  REQUIRED_BILLING_FIELDS,
@@ -98,18 +88,6 @@ export {
98
88
  isShippingAddressComplete,
99
89
  } from "./address";
100
90
 
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
-
113
91
  // ── catalog ────────────────────────────────────────────────────────────────
114
92
  export { useProductList, useCategories, useRibbons } from "./useProductList";
115
93
  export { useProduct, useAddToCart } from "./useProduct";
@@ -381,30 +381,3 @@ export function useCheckoutContext() {
381
381
  export function useCheckoutContextOptional() {
382
382
  return useContext(CheckoutContext);
383
383
  }
384
-
385
- /**
386
- * Is this component rendering inside a checkout (`Checkout.Root`, or your own
387
- * `<CheckoutProvider>`)? For the one component a store writes once and shows in
388
- * three places — the drawer, the cart page and the checkout's order summary —
389
- * where a merchandising block belongs in the first two and is a distraction (or
390
- * a way out of the funnel) in the third:
391
- *
392
- * function CartContents() {
393
- * const inCheckout = useInCheckout();
394
- * return (
395
- * <>
396
- * <Cart.Lines />
397
- * {!inCheckout && <UpsellRail />} // your own upsell / related rail
398
- * <Cart.Totals />
399
- * </>
400
- * );
401
- * }
402
- *
403
- * A boolean, not a mode: nothing in the kit changes behavior from it. Prefer an
404
- * explicit prop (`<CartContents showUpsell={false} />`) where the caller knows
405
- * best; this is for the case where it doesn't — a shared component several
406
- * pages deep.
407
- */
408
- export function useInCheckout() {
409
- return useContext(CheckoutContext) != null;
410
- }
@@ -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
+ }