@base44/app-plugin-commerce 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -108,7 +108,7 @@ Open a file when its work starts — not while planning.
108
108
  | Topic | Open when | Size |
109
109
  |---|---|---|
110
110
  | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 5K |
111
- | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 33K |
111
+ | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 34K |
112
112
  | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
113
113
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
114
114
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
@@ -11,6 +11,7 @@ carry_forward:
11
11
  - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
12
12
  - "No copy ships: every word the parts render comes from the store's copy file (labels); a ⟨copy: …⟩ placeholder on screen is unfinished work."
13
13
  - "Identity is encoded once — design classes in index.css (parts styled via [data-part] selectors, controls always among them) plus one or two signature moments per page."
14
+ - "The product page is the richest surface and budgeted for it (~8K chars): productSpecs rows carry a type — branch on it, never .map() the list into one grey label/value table."
14
15
  ---
15
16
 
16
17
  # 02 — Storefront
@@ -56,7 +57,9 @@ import AdminApp from "@/commerce/admin";
56
57
 
57
58
  The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`). Pages then carry short class names plus a little layout. ⚑ **A utility run that appears twice becomes a class.** The words work the same way: one copy file, written once.
58
59
 
59
- **Concentrate identity; don't diffuse it.** The classes carry the look; on top of them spend bespoke markup on **one or two signature moments per page**. **The product page stays the richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and receipt are convention surfaces — parts give the structure, your classes the look. Keep components small (~2–4K chars).
60
+ **Concentrate identity; don't diffuse it.** The classes carry the look; on top of them spend bespoke markup on **one or two signature moments per page**. **The product page stays the richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and receipt are convention surfaces — parts give the structure, your classes the look.
61
+
62
+ ⚑ **Budget by surface, and spend the product page's.** Convention surfaces are ~2–4K chars each; **the product page gets ~8K and the collection ~5K**, because rendering axes and specs *by what they are* is exactly what those chars buy — a product page that came in at 3K is the generic one. Over budget means re-implemented hook logic (a quantity clamp, totals math, variant resolution), never too much design: find your version, delete it, call the hook.
60
63
 
61
64
  ## The parts — shared contract
62
65
 
@@ -91,7 +94,7 @@ Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney(
91
94
  | `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
92
95
  | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
93
96
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
94
- | `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data`; `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
97
+ | `productSpecs(product)` | `[{ key, label, titleLabel, value, type, number, unit, items }]` from `meta_data` `type` is `"numeric" \| "duration" \| "location" \| "list" \| "text"`, inferred, with `number`/`unit` split out for the first two and `items` for a list. `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
95
98
  | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
96
99
  | `cart.items[n]` | `{ item_key, product_id, variation_id, name, slug, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}` (`attributesLabel(item.attributes)` → `"Size: 42 · Color: Ivory"`); `purchasable` is a **result object** `{ok, code, error}`, not a boolean; `slug` is the line's product-page link. |
97
100
 
@@ -105,7 +108,7 @@ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPri
105
108
 
106
109
  ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
107
110
 
108
- A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no `type` flag, and `product.price` is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design: lead with the one or two fields *these* products are judged on, not the default name/price/stars trio.
111
+ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no `type` flag, and `product.price` is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
109
112
 
110
113
  ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), each linking to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key on `r.id`; the object in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it.
111
114
 
@@ -167,7 +170,17 @@ Build your layout from — all optional, **not one component style**:
167
170
 
168
171
  ⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from a ternary chain, never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer.
169
172
  - **Description** — `product.description` is HTML; render as rich text, `short_description` above.
170
- - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. **The product page's signature-moment candidate**: render the two or three keys that carry *this* catalog's meaning as what they are (a weight as a figure, a composition as bars, a provenance beside its place), the rest as plain rows in your classes. Not one uniform grey table; not a bespoke widget per row. `[]` means no section.
173
+ - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*, **the product page's signature-moment candidate**. Every row arrives pre-classified, so the branch point is already there and one uniform table is a choice, not a default:
174
+
175
+ ```jsx
176
+ {productSpecs(product).map((s) =>
177
+ s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
178
+ : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
179
+ : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // origin, provenance
180
+ : <Row key={s.key} label={s.titleLabel} value={s.value} />)}
181
+ ```
182
+
183
+ ⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section.
171
184
  - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
172
185
  - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards: an average of nothing is `0`). `p.reviews` arrives with the product; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to auto-approving stores — and refresh after. Policies: [`../references/reviews.md`](../references/reviews.md).
173
186
  - **Title** — a `<title>` and description per page type; one static title everywhere is invisible to search. No structured data ships — for rich results emit your own `Product`/`Offer` JSON-LD from `product` and `view.display`.
@@ -314,5 +327,6 @@ import { OrderReceived } from "@/commerce/storefront";
314
327
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
315
328
  - [ ] Navigation affordances are yours: the checkout link in `Cart.Ready`, `orderReceivedPath` on `Checkout.Root`, `PaymentLink`'s child — the kit rendered none of them.
316
329
  - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; the product page stays richest.
330
+ - [ ] **Specs and axes render by what they are**: the product page branches on `productSpecs` `type`/`key` for the rows that carry this catalog's meaning, and the grid has a rhythm — no page ships one uniform grey label/value table or one identical chip row per axis.
317
331
 
318
332
  Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
@@ -51,7 +51,7 @@ once. The whole list, so none of it has to be read out of the backend:
51
51
  |---|---|---|
52
52
  | `images[]` | `{src, name, alt}` — **objects**; `[]` is legitimate | `productImages(product)` |
53
53
  | `ribbons[]` | `{id, name}` — **objects**; the field is **absent**, not `[]`, when no row on the page carries one | `productRibbons(product)` |
54
- | `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` + `findSpec` |
54
+ | `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` (adds a render `type`) + `findSpec` |
55
55
  | `attributes[]` | `{attribute_id, name, position, options: string[]}` — one entry per **axis** | `variantAxes(view, pick)` |
56
56
  | `default_attributes[]` | `{attribute_id, name, option}` — the merchant's pre-selection | `defaultSelection` (automatic in `useProduct`) |
57
57
  | `dimensions` | `{length, width, height}` in the store's unit | — |
@@ -95,7 +95,7 @@ Both lists are inventories of what the data supports — **not a layout and not
95
95
 
96
96
  **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `submitReview` off `useStorefront()`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
97
97
 
98
- **Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` and nothing more. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey label/value table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
98
+ **Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` plus an inferred `type` — `"numeric"` / `"duration"` (with `number` and `unit` split out), `"location"`, `"list"` (with `items`), `"text"` — so the rendering branch is already made for you: a weight can be a figure, a composition bars, a provenance a place. ⚑ **Never `.map()` the whole list into one grey label/value table**; design the two or three rows this catalog is judged on and let the rest fall through to a plain row. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
99
99
 
100
100
  ## 4. Ribbons — in **both** views
101
101
 
@@ -43,8 +43,10 @@
43
43
  * gallery follows the variant selection without owning a second copy of it),
44
44
  * `productRibbons` (ribbons are `{id, name}` objects, not strings — rendering
45
45
  * 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),
46
+ * `productSpecs` + `findSpec` (spec rows carry an inferred render `type` so a
47
+ * weight reads as a figure and a composition as bars rather than every modifier
48
+ * as one grey table row; meta keys are free text, so featuring a particular
49
+ * spec needs a tolerant lookup, not an equality test),
48
50
  * `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
49
51
  * `addressFieldSpec` (the checkout's field list, including the state/province
50
52
  * field that silently mis-prices US/CA/AU orders when it is left out), and
@@ -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
+ }