@akinon/pz-theme 2.0.103-rc.0 → 2.0.103
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/CHANGELOG.md +8 -33
- package/package.json +4 -4
- package/src/__tests__/collection-row-scope.test.tsx +187 -0
- package/src/__tests__/group-row-styles.test.tsx +110 -0
- package/src/__tests__/theme-typography-vars.test.tsx +125 -0
- package/src/blocks/__tests__/listing-row-swatches.test.ts +111 -0
- package/src/blocks/group-block.tsx +35 -1
- package/src/blocks/item-template-utils.ts +34 -0
- package/src/blocks/item-template.tsx +6 -1
- package/src/blocks/listing-context.ts +17 -622
- package/src/blocks/listing-row.ts +692 -0
- package/src/components/with-designer-features.tsx +6 -0
- package/src/sections/__tests__/posts-slider-row-visibility.test.tsx +147 -0
- package/src/sections/posts-slider-section.tsx +23 -2
- package/src/theme-block.tsx +63 -2
- package/src/theme-placeholder-client.tsx +4 -3
- package/src/theme-placeholder-wrapper.tsx +61 -5
- package/src/utils/__tests__/property-switches.test.tsx +250 -0
- package/src/utils/index.ts +55 -3
- package/src/utils/iterator-price-shapes.test.ts +465 -0
- package/src/utils/iterator-utils.ts +371 -35
- package/src/utils/property-switches.ts +230 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listing ROW model, and how a commerce product becomes one.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no React, no hooks, no page context. `listing-context.ts` (the hook
|
|
5
|
+
* that reads the listing off the page) re-exports everything here, so every
|
|
6
|
+
* existing import keeps working — and the iterator that repeats a custom
|
|
7
|
+
* section over a COLLECTION data source can hand its rows through the same
|
|
8
|
+
* normaliser, so a card designed against a product-grid row (`item.url`,
|
|
9
|
+
* `item.retailPrice`, `item.hasDiscount`, `item.colors`, `item.attributes`)
|
|
10
|
+
* binds identically over a collection row (ZERO-4829). That import is the
|
|
11
|
+
* reason the model lives in its own module: `listing-context.ts` is a client
|
|
12
|
+
* module and the iterator is walked on both sides of hydration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { formatMoney, MoneyStyleOptions, parseAmount } from './commerce-format';
|
|
16
|
+
|
|
17
|
+
export type { MoneyStyleOptions };
|
|
18
|
+
|
|
19
|
+
type UnknownRecord = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
const isEmptyValue = (value: unknown) =>
|
|
22
|
+
value === undefined || value === null || value === '';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The same formatting `formatPrice` in product-block-utils applies, restated
|
|
26
|
+
* here rather than imported: that module is a client module that reaches for
|
|
27
|
+
* the utils barrel, and importing it would close a cycle through the iterator.
|
|
28
|
+
*/
|
|
29
|
+
const formatRowPrice = (
|
|
30
|
+
value: unknown,
|
|
31
|
+
currency: unknown,
|
|
32
|
+
locale?: string,
|
|
33
|
+
money?: MoneyStyleOptions
|
|
34
|
+
): string | null => {
|
|
35
|
+
if (isEmptyValue(value)) return null;
|
|
36
|
+
|
|
37
|
+
const formatted = formatMoney(value, {
|
|
38
|
+
...money,
|
|
39
|
+
locale,
|
|
40
|
+
currency: String(currency || ''),
|
|
41
|
+
showCurrency: true
|
|
42
|
+
});
|
|
43
|
+
if (formatted) return formatted;
|
|
44
|
+
|
|
45
|
+
// Unparseable but non-empty values ("Call for price") stay visible as-is.
|
|
46
|
+
const text = String(value).trim();
|
|
47
|
+
return text || null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const parsePriceValue = (value: unknown): number | null => parseAmount(value);
|
|
51
|
+
|
|
52
|
+
/** One buyable colour of a product, as a card swatch needs it. */
|
|
53
|
+
export interface ListingProductColor {
|
|
54
|
+
label: string;
|
|
55
|
+
value: string;
|
|
56
|
+
/** Swatch/preview image of that colour's variant, when commerce sends one. */
|
|
57
|
+
image?: string;
|
|
58
|
+
url?: string;
|
|
59
|
+
isSelected: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Hex a circular swatch can be filled with. Commerce sends the colour as a
|
|
62
|
+
* bare value ("kirmizi") and no colour at all, so this is only present when
|
|
63
|
+
* the author mapped that value in `lookups.swatch`.
|
|
64
|
+
*/
|
|
65
|
+
color?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a value resolves INTO once an author has mapped it.
|
|
70
|
+
*
|
|
71
|
+
* Every part is optional because the three brands' designs disagree: one draws
|
|
72
|
+
* a badge as an image, one as text on a colour, one as both.
|
|
73
|
+
*/
|
|
74
|
+
export interface ListingLookupEntry {
|
|
75
|
+
label?: string;
|
|
76
|
+
image?: string;
|
|
77
|
+
color?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A designer-authored value -> presentation table.
|
|
82
|
+
*
|
|
83
|
+
* Commerce sends a badge as a bare key ("new_season") and carries NO resolved
|
|
84
|
+
* form of it — no label, no image, no colour. Nothing in the payload can be
|
|
85
|
+
* bound to draw the chip both brands draw, so the resolution has to be
|
|
86
|
+
* authored. This is an editor-side table standing in for a commerce field that
|
|
87
|
+
* does not exist, and it is deliberately not a fallback: a value the table does
|
|
88
|
+
* not list resolves to nothing rather than leaking the raw key onto a card.
|
|
89
|
+
*/
|
|
90
|
+
export interface ListingBadgeLookup {
|
|
91
|
+
/** Which keyed attribute the raw value is read from (`attributes.<key>`). */
|
|
92
|
+
attribute: string;
|
|
93
|
+
entries: Record<string, ListingLookupEntry>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ListingValueLookups {
|
|
97
|
+
badge?: ListingBadgeLookup;
|
|
98
|
+
/** Colour value -> hex, for the swatch circles on a card. */
|
|
99
|
+
swatch?: Record<string, string>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The plain-text form of the tables above, as the editor panel offers them.
|
|
104
|
+
*
|
|
105
|
+
* One mapping per line, fields separated by `|` — the same shape the payment
|
|
106
|
+
* badges block already asks a designer for, so the panel does not invent a
|
|
107
|
+
* second syntax for the same job. JSON was the alternative and it is not one a
|
|
108
|
+
* merchandiser can be handed: a missing brace silently empties the table.
|
|
109
|
+
*
|
|
110
|
+
* Returns `undefined` rather than an empty table when nothing usable was
|
|
111
|
+
* authored: the caller memoises on this value, and every lookup-fed field on
|
|
112
|
+
* the row stays absent either way.
|
|
113
|
+
*/
|
|
114
|
+
export const parseListingLookups = (
|
|
115
|
+
badgeAttribute: string,
|
|
116
|
+
badgeTable: string,
|
|
117
|
+
swatchTable: string
|
|
118
|
+
): ListingValueLookups | undefined => {
|
|
119
|
+
const rows = (raw: string): string[][] =>
|
|
120
|
+
raw
|
|
121
|
+
.split(/\r?\n/)
|
|
122
|
+
.map((line) => line.split('|').map((field) => field.trim()))
|
|
123
|
+
.filter((fields) => fields[0]);
|
|
124
|
+
|
|
125
|
+
// Prototype-less, because the keys are authored: a line beginning
|
|
126
|
+
// `__proto__|` would otherwise rewrite the table's prototype instead of
|
|
127
|
+
// adding a mapping, and the row it described would silently do nothing.
|
|
128
|
+
const entries: Record<string, ListingLookupEntry> = Object.create(null);
|
|
129
|
+
for (const [key, label, image, color] of rows(badgeTable)) {
|
|
130
|
+
if (!label && !image && !color) continue;
|
|
131
|
+
entries[key] = {
|
|
132
|
+
...(label ? { label } : null),
|
|
133
|
+
...(image ? { image } : null),
|
|
134
|
+
...(color ? { color } : null)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const swatch: Record<string, string> = Object.create(null);
|
|
139
|
+
for (const [value, color] of rows(swatchTable)) {
|
|
140
|
+
if (color) swatch[value] = color;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Without the attribute name there is nothing to read the raw value from, so
|
|
144
|
+
// a half-filled badge table maps nothing rather than guessing a key.
|
|
145
|
+
const badge =
|
|
146
|
+
badgeAttribute && Object.keys(entries).length > 0
|
|
147
|
+
? { attribute: badgeAttribute, entries }
|
|
148
|
+
: undefined;
|
|
149
|
+
const hasSwatch = Object.keys(swatch).length > 0;
|
|
150
|
+
|
|
151
|
+
if (!badge && !hasSwatch) return undefined;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
...(badge ? { badge } : null),
|
|
155
|
+
...(hasSwatch ? { swatch } : null)
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** A campaign line commerce attaches to the product ("2 for 3", …). */
|
|
160
|
+
export interface ListingBasketOffer {
|
|
161
|
+
label?: string;
|
|
162
|
+
/** `listing_kwargs.discounted_total_price`, already formatted. */
|
|
163
|
+
price?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ListingProduct {
|
|
167
|
+
key: string;
|
|
168
|
+
/**
|
|
169
|
+
* The commerce product id. `key` is a React key with an index fallback, so
|
|
170
|
+
* it cannot be used to call an endpoint (wishlist, add-to-cart) — this can.
|
|
171
|
+
* Absent when the payload carried no usable pk.
|
|
172
|
+
*/
|
|
173
|
+
pk?: number;
|
|
174
|
+
name: string;
|
|
175
|
+
url: string;
|
|
176
|
+
image: string;
|
|
177
|
+
/**
|
|
178
|
+
* Every image commerce sent, in order. `image` stays the first one; the
|
|
179
|
+
* second is what a hover-swap card needs and there was no way to reach it.
|
|
180
|
+
*/
|
|
181
|
+
images?: string[];
|
|
182
|
+
price: string;
|
|
183
|
+
retailPrice: string;
|
|
184
|
+
/**
|
|
185
|
+
* The same two prices as plain numbers, for consumers that compute rather
|
|
186
|
+
* than display — analytics items, sort keys. Absent when unparseable.
|
|
187
|
+
*/
|
|
188
|
+
priceAmount?: number;
|
|
189
|
+
retailPriceAmount?: number;
|
|
190
|
+
/**
|
|
191
|
+
* `price_extra_field.previous_price` — the price-history line several
|
|
192
|
+
* markets require by law. Absent when commerce did not send one.
|
|
193
|
+
*/
|
|
194
|
+
previousPrice?: string;
|
|
195
|
+
hasDiscount: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Rounded saving off the retail price; absent when there is no discount.
|
|
198
|
+
* Kept under its original, basis-less name so every published card that
|
|
199
|
+
* binds it keeps the number it has always shown.
|
|
200
|
+
*/
|
|
201
|
+
discountPercent?: number;
|
|
202
|
+
/**
|
|
203
|
+
* The same number under a name that states its basis. A card that strikes
|
|
204
|
+
* the retail price binds this one.
|
|
205
|
+
*/
|
|
206
|
+
discountPercentFromRetail?: number;
|
|
207
|
+
/**
|
|
208
|
+
* Saving off `previousPrice` instead. A brand whose card strikes
|
|
209
|
+
* `price_extra_field.previous_price` was showing a chip computed off a price
|
|
210
|
+
* it never displayed; this is the number that matches what it struck.
|
|
211
|
+
*
|
|
212
|
+
* Both bases are published rather than one being switched between, because
|
|
213
|
+
* only the card's own design says which price it struck — guessing that from
|
|
214
|
+
* the payload would move published cards.
|
|
215
|
+
*/
|
|
216
|
+
discountPercentFromPrevious?: number;
|
|
217
|
+
/**
|
|
218
|
+
* A raw commerce value resolved through the author's lookup table (see
|
|
219
|
+
* `ListingBadgeLookup`). Absent when no table was authored, when the product
|
|
220
|
+
* carries no value for it, or when the value is not in the table.
|
|
221
|
+
*/
|
|
222
|
+
badge?: ListingLookupEntry;
|
|
223
|
+
/** Absent when the payload says nothing about stock. */
|
|
224
|
+
inStock?: boolean;
|
|
225
|
+
/** Keyed, so a card can bind one named attribute (`attributes.badge_text`). */
|
|
226
|
+
attributes?: Record<string, string>;
|
|
227
|
+
basketOffers?: ListingBasketOffer[];
|
|
228
|
+
colors?: ListingProductColor[];
|
|
229
|
+
colorCount?: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Locale/currency resolved once at the hook boundary and passed down. */
|
|
233
|
+
export interface ListingLocalizationOptions {
|
|
234
|
+
locale?: string;
|
|
235
|
+
currency?: string;
|
|
236
|
+
/**
|
|
237
|
+
* The brand's money format, when the block that asked for the listing has
|
|
238
|
+
* one. Every price on the row is formatted with it, so a designer-authored
|
|
239
|
+
* card reads the same as the built-in one. Absent = the format the theme
|
|
240
|
+
* always used.
|
|
241
|
+
*/
|
|
242
|
+
money?: MoneyStyleOptions;
|
|
243
|
+
/**
|
|
244
|
+
* Author-supplied resolution tables. Absent = every lookup-fed field on the
|
|
245
|
+
* row stays absent, which is the shape the row has always had.
|
|
246
|
+
*/
|
|
247
|
+
lookups?: ListingValueLookups;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
export const toRecord = (value: unknown): UnknownRecord =>
|
|
252
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
253
|
+
? (value as UnknownRecord)
|
|
254
|
+
: {};
|
|
255
|
+
|
|
256
|
+
export const toArray = (value: unknown): unknown[] =>
|
|
257
|
+
Array.isArray(value) ? value : [];
|
|
258
|
+
|
|
259
|
+
export const toText = (value: unknown, fallback = ''): string => {
|
|
260
|
+
if (typeof value === 'string') return value;
|
|
261
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
262
|
+
return fallback;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export const toBoolean = (value: unknown): boolean => value === true || value === 'true';
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Grid prices go through the same formatter as the PDP price block, so the
|
|
269
|
+
* same product reads identically in both places. The locale is threaded in
|
|
270
|
+
* from `useLocalization()` rather than left implicit: these blocks render on
|
|
271
|
+
* the server and hydrate on the client, and an implicit Intl locale resolves
|
|
272
|
+
* differently in the two runtimes.
|
|
273
|
+
*/
|
|
274
|
+
export const formatListingPrice = (
|
|
275
|
+
value: unknown,
|
|
276
|
+
currency: string,
|
|
277
|
+
locale?: string,
|
|
278
|
+
money?: MoneyStyleOptions
|
|
279
|
+
): string => formatRowPrice(value, currency, locale, money) ?? '';
|
|
280
|
+
|
|
281
|
+
const isMissing = (value: unknown): boolean =>
|
|
282
|
+
value === null || value === undefined || value === '';
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Percentage off ONE named basis, rounded the way the built-in badge rounds.
|
|
286
|
+
*
|
|
287
|
+
* Undefined unless the basis is a real amount above the price: a "0% off" chip
|
|
288
|
+
* and a chip computed off a basis commerce did not send are both worse than no
|
|
289
|
+
* chip at all.
|
|
290
|
+
*/
|
|
291
|
+
const percentOff = (
|
|
292
|
+
basis: number | null,
|
|
293
|
+
price: number | null
|
|
294
|
+
): number | undefined =>
|
|
295
|
+
basis !== null && price !== null && basis > 0 && basis > price
|
|
296
|
+
? Math.round(((basis - price) / basis) * 100)
|
|
297
|
+
: undefined;
|
|
298
|
+
|
|
299
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript|data):/;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A lookup image ends up in an `<img src>` the moment a row binds
|
|
303
|
+
* `item.badge.image`, so the authored value is judged before it reaches the
|
|
304
|
+
* row rather than at every binding that might read it.
|
|
305
|
+
*
|
|
306
|
+
* Browsers strip whitespace and control characters while parsing a url, so a
|
|
307
|
+
* tab wedged into "javascript:" still resolves to a live handler; the value is
|
|
308
|
+
* normalised the same way before it is judged. `data:` goes with them because
|
|
309
|
+
* an SVG document served from one is same-origin with the page that framed it.
|
|
310
|
+
*/
|
|
311
|
+
const safeLookupImage = (value: string): string =>
|
|
312
|
+
SCRIPT_SCHEME.test(value.replace(/[\u0000-\u0020]/g, '').toLowerCase())
|
|
313
|
+
? ''
|
|
314
|
+
: value;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A lookup colour is bindable into a style value, and style values are emitted
|
|
318
|
+
* into the theme's `<style>` element — so a value carrying `;` or `}` would
|
|
319
|
+
* close the rule it landed in and write its own. Only the shapes a colour can
|
|
320
|
+
* legitimately take are accepted; anything else publishes no colour, which is
|
|
321
|
+
* the same thing an unmapped value does.
|
|
322
|
+
*/
|
|
323
|
+
const SAFE_CSS_COLOR =
|
|
324
|
+
/^(#[0-9a-f]{3,8}|[a-z]+|(?:rgb|rgba|hsl|hsla)\([0-9a-z%.,\s/+-]*\)|var\(--[a-z0-9_-]+\))$/i;
|
|
325
|
+
|
|
326
|
+
const safeLookupColor = (value: string): string =>
|
|
327
|
+
SAFE_CSS_COLOR.test(value.trim()) ? value.trim() : '';
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Table lookup for a raw commerce value.
|
|
331
|
+
*
|
|
332
|
+
* Matched case-insensitively as a second pass: commerce values arrive in
|
|
333
|
+
* whatever case the brand's integration wrote them ("New_Season" and
|
|
334
|
+
* "new_season" are the same badge), and an author cannot be asked to enumerate
|
|
335
|
+
* both. An entry with nothing in it counts as no match, so an empty row in the
|
|
336
|
+
* table does not publish an empty badge.
|
|
337
|
+
*/
|
|
338
|
+
const resolveLookupEntry = (
|
|
339
|
+
entries: Record<string, ListingLookupEntry> | undefined,
|
|
340
|
+
value: string
|
|
341
|
+
): ListingLookupEntry | undefined => {
|
|
342
|
+
if (!entries || !value) return undefined;
|
|
343
|
+
|
|
344
|
+
// Own keys only. A table is indexed by a value that came from the catalogue,
|
|
345
|
+
// and `entries['toString']` on a plain object answers with a function the
|
|
346
|
+
// rest of this walk would treat as an entry.
|
|
347
|
+
const direct = Object.prototype.hasOwnProperty.call(entries, value)
|
|
348
|
+
? entries[value]
|
|
349
|
+
: undefined;
|
|
350
|
+
const normalized = value.trim().toLowerCase();
|
|
351
|
+
const entry =
|
|
352
|
+
direct ??
|
|
353
|
+
Object.entries(entries).find(
|
|
354
|
+
([key]) => key.trim().toLowerCase() === normalized
|
|
355
|
+
)?.[1];
|
|
356
|
+
|
|
357
|
+
if (!entry) return undefined;
|
|
358
|
+
|
|
359
|
+
const label = toText(entry.label);
|
|
360
|
+
// Both of these leave the row for a url and for a stylesheet, so the
|
|
361
|
+
// authored value is judged here rather than at every binding that reads it.
|
|
362
|
+
const image = safeLookupImage(toText(entry.image));
|
|
363
|
+
const color = safeLookupColor(toText(entry.color));
|
|
364
|
+
if (!label && !image && !color) return undefined;
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
...(label ? { label } : null),
|
|
368
|
+
...(image ? { image } : null),
|
|
369
|
+
...(color ? { color } : null)
|
|
370
|
+
};
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const resolveBadge = (
|
|
374
|
+
attributes: Record<string, string> | undefined,
|
|
375
|
+
lookup: ListingBadgeLookup | undefined
|
|
376
|
+
): ListingLookupEntry | undefined => {
|
|
377
|
+
if (!lookup || !attributes) return undefined;
|
|
378
|
+
|
|
379
|
+
// The attribute name is free text a merchandiser types into the panel, and
|
|
380
|
+
// it indexes an object. `constructor`, `toString` and every other
|
|
381
|
+
// Object.prototype member answer with a function rather than an attribute
|
|
382
|
+
// value, and the table lookup below would then read `.trim()` off it and
|
|
383
|
+
// take the whole category page down. Own keys only, and read as text.
|
|
384
|
+
const raw = Object.prototype.hasOwnProperty.call(
|
|
385
|
+
attributes,
|
|
386
|
+
lookup.attribute
|
|
387
|
+
)
|
|
388
|
+
? attributes[lookup.attribute]
|
|
389
|
+
: '';
|
|
390
|
+
|
|
391
|
+
return resolveLookupEntry(lookup.entries, toText(raw));
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Attributes are republished KEYED rather than as the positional
|
|
396
|
+
* `{ label, value }` list the page context builds: a card design targets a
|
|
397
|
+
* *named* attribute (`attributes.badge_text`), and a positional list cannot be
|
|
398
|
+
* addressed by name — position moves as the attribute set changes.
|
|
399
|
+
*
|
|
400
|
+
* `attributes_kwargs` is the keyed source commerce always sends. The raw
|
|
401
|
+
* `attributes` map wins where it survived, because that is the value the
|
|
402
|
+
* hand-coded brand cards read.
|
|
403
|
+
*/
|
|
404
|
+
const normalizeProductAttributes = (
|
|
405
|
+
product: UnknownRecord
|
|
406
|
+
): Record<string, string> | undefined => {
|
|
407
|
+
const keyed: Record<string, string> = {};
|
|
408
|
+
|
|
409
|
+
for (const [key, entry] of Object.entries(
|
|
410
|
+
toRecord(product.attributes_kwargs)
|
|
411
|
+
)) {
|
|
412
|
+
const value = toText(toRecord(entry).value);
|
|
413
|
+
if (value) keyed[key] = value;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// `attributes` only reaches here as a keyed map when the payload skipped the
|
|
417
|
+
// page-context normaliser, which replaces it with the positional list…
|
|
418
|
+
const raw = product.attributes;
|
|
419
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
420
|
+
for (const [key, value] of Object.entries(raw as UnknownRecord)) {
|
|
421
|
+
const text = toText(value);
|
|
422
|
+
if (text) keyed[key] = text;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// …and publishes the keyed form beside it as `attributesKeyed` (chrome/
|
|
427
|
+
// page-context.ts). Read here too, or a card bound to
|
|
428
|
+
// `item.attributes.badge2` finds the attribute over a collection row (raw
|
|
429
|
+
// payload) and nothing over the listing's own rows (page context).
|
|
430
|
+
const preKeyed = product.attributesKeyed;
|
|
431
|
+
if (preKeyed && typeof preKeyed === 'object' && !Array.isArray(preKeyed)) {
|
|
432
|
+
for (const [key, value] of Object.entries(preKeyed as UnknownRecord)) {
|
|
433
|
+
const text = toText(value);
|
|
434
|
+
if (text && keyed[key] === undefined) keyed[key] = text;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return Object.keys(keyed).length > 0 ? keyed : undefined;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const normalizeProductImages = (
|
|
442
|
+
product: UnknownRecord
|
|
443
|
+
): string[] | undefined => {
|
|
444
|
+
// `images` is what the page context normalised; `productimage_set` is the
|
|
445
|
+
// raw commerce field, for payloads handed straight to the resolver.
|
|
446
|
+
const source = toArray(product.images).length
|
|
447
|
+
? toArray(product.images)
|
|
448
|
+
: toArray(product.productimage_set);
|
|
449
|
+
|
|
450
|
+
const urls = source
|
|
451
|
+
.map((entry) => {
|
|
452
|
+
if (typeof entry === 'string') return entry;
|
|
453
|
+
const image = toRecord(entry);
|
|
454
|
+
return toText(image.url) || toText(image.image);
|
|
455
|
+
})
|
|
456
|
+
.filter((url) => url.length > 0);
|
|
457
|
+
|
|
458
|
+
return urls.length > 0 ? urls : undefined;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const normalizeBasketOffers = (
|
|
462
|
+
product: UnknownRecord,
|
|
463
|
+
currency: string,
|
|
464
|
+
locale?: string,
|
|
465
|
+
money?: MoneyStyleOptions
|
|
466
|
+
): ListingBasketOffer[] | undefined => {
|
|
467
|
+
const offers = toArray(product.basket_offers)
|
|
468
|
+
.map((entry): ListingBasketOffer | null => {
|
|
469
|
+
const offer = toRecord(entry);
|
|
470
|
+
const label = toText(offer.label);
|
|
471
|
+
const rawPrice = toRecord(offer.listing_kwargs).discounted_total_price;
|
|
472
|
+
const price = isMissing(rawPrice)
|
|
473
|
+
? ''
|
|
474
|
+
: formatListingPrice(rawPrice, currency, locale, money);
|
|
475
|
+
|
|
476
|
+
if (!label && !price) return null;
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
...(label ? { label } : null),
|
|
480
|
+
...(price ? { price } : null)
|
|
481
|
+
};
|
|
482
|
+
})
|
|
483
|
+
.filter((offer): offer is ListingBasketOffer => offer !== null);
|
|
484
|
+
|
|
485
|
+
return offers.length > 0 ? offers : undefined;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Commerce has no single colour attribute: every integration names its own
|
|
490
|
+
* (`mp_color`, `integration_renk`, …), which is why hand-coded brand cards
|
|
491
|
+
* carry a hardcoded list of keys. The known names are matched first, then any
|
|
492
|
+
* key ending in colour/renk, so a brand on a fourth spelling still gets
|
|
493
|
+
* swatches. Only selectable options are published — the rest cannot be bought.
|
|
494
|
+
*/
|
|
495
|
+
const COLOR_ATTRIBUTE_KEYS = [
|
|
496
|
+
'mp_color',
|
|
497
|
+
'integration_color',
|
|
498
|
+
'integration_renk'
|
|
499
|
+
];
|
|
500
|
+
const COLOR_ATTRIBUTE_PATTERN = /(^|_)(color|colour|renk)$/;
|
|
501
|
+
|
|
502
|
+
const isColorAttributeKey = (key: string): boolean => {
|
|
503
|
+
// Commerce keys arrive in whatever case the brand configured
|
|
504
|
+
// (`integration_Renk` is real), so the comparison is lowercased.
|
|
505
|
+
const normalized = key.trim().toLowerCase();
|
|
506
|
+
return (
|
|
507
|
+
COLOR_ATTRIBUTE_KEYS.includes(normalized) ||
|
|
508
|
+
COLOR_ATTRIBUTE_PATTERN.test(normalized)
|
|
509
|
+
);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const normalizeProductColors = (
|
|
513
|
+
product: UnknownRecord,
|
|
514
|
+
swatch?: Record<string, string>
|
|
515
|
+
): ListingProductColor[] | undefined => {
|
|
516
|
+
const groups = toArray(toRecord(product.extra_data).variants);
|
|
517
|
+
// A known key wins over the loose suffix match: a payload carrying both
|
|
518
|
+
// `integration_color` and, say, `secondary_colour` must pick the real one.
|
|
519
|
+
const group =
|
|
520
|
+
groups.find((entry) =>
|
|
521
|
+
COLOR_ATTRIBUTE_KEYS.includes(
|
|
522
|
+
toText(toRecord(entry).attribute_key).trim().toLowerCase()
|
|
523
|
+
)
|
|
524
|
+
) ??
|
|
525
|
+
groups.find((entry) =>
|
|
526
|
+
isColorAttributeKey(toText(toRecord(entry).attribute_key))
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
if (!group) return undefined;
|
|
530
|
+
|
|
531
|
+
// Reshaped once rather than per option, so this table gets the same
|
|
532
|
+
// case-insensitive matching the badge table gets.
|
|
533
|
+
const swatchEntries = swatch
|
|
534
|
+
? Object.fromEntries(
|
|
535
|
+
Object.entries(swatch).map(([key, color]) => [key, { color }])
|
|
536
|
+
)
|
|
537
|
+
: undefined;
|
|
538
|
+
|
|
539
|
+
const colors = toArray(toRecord(group).options)
|
|
540
|
+
.map((entry): ListingProductColor | null => {
|
|
541
|
+
const option = toRecord(entry);
|
|
542
|
+
if (!toBoolean(option.is_selectable)) return null;
|
|
543
|
+
|
|
544
|
+
const value = toText(option.value);
|
|
545
|
+
const label = toText(option.label) || value;
|
|
546
|
+
if (!value && !label) return null;
|
|
547
|
+
|
|
548
|
+
const variant = toRecord(option.product);
|
|
549
|
+
const image =
|
|
550
|
+
toText(variant.image) ||
|
|
551
|
+
toText(toRecord(toArray(variant.productimage_set)[0]).image);
|
|
552
|
+
const url = toText(variant.absolute_url);
|
|
553
|
+
// The swatch table is keyed by the colour VALUE, which is the stable
|
|
554
|
+
// integration key; the label is the shopper-facing text and is
|
|
555
|
+
// translated, so it cannot key a table a brand authors once.
|
|
556
|
+
// Keyed by the colour VALUE, the stable integration key. Some payloads
|
|
557
|
+
// (GANT's `integration_renk` group) carry the code as the option's LABEL
|
|
558
|
+
// and no value at all, so the label is the key there — the same string
|
|
559
|
+
// the swatch table is authored against.
|
|
560
|
+
const hex = resolveLookupEntry(swatchEntries, value || label)?.color;
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
label,
|
|
564
|
+
value,
|
|
565
|
+
...(image ? { image } : null),
|
|
566
|
+
...(url ? { url } : null),
|
|
567
|
+
// `is_selected` is what a PDP variant group says; a listing payload
|
|
568
|
+
// marks nothing, so the colour whose product IS this row is selected.
|
|
569
|
+
isSelected:
|
|
570
|
+
toBoolean(option.is_selected) ||
|
|
571
|
+
(toText(variant.pk) !== '' && toText(variant.pk) === toText(product.pk)),
|
|
572
|
+
...(hex ? { color: hex } : null)
|
|
573
|
+
};
|
|
574
|
+
})
|
|
575
|
+
.filter((color): color is ListingProductColor => color !== null);
|
|
576
|
+
|
|
577
|
+
return colors.length > 0 ? colors : undefined;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const readInStock = (product: UnknownRecord): boolean | undefined => {
|
|
581
|
+
if (typeof product.in_stock === 'boolean') return product.in_stock;
|
|
582
|
+
|
|
583
|
+
const stock = Number(product.stock);
|
|
584
|
+
if (!isMissing(product.stock) && Number.isFinite(stock)) return stock > 0;
|
|
585
|
+
|
|
586
|
+
// Unknown stock is published as unknown rather than guessed: a card that
|
|
587
|
+
// binds it should show nothing, not "out of stock".
|
|
588
|
+
return undefined;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const normalizeProduct = (
|
|
592
|
+
entry: unknown,
|
|
593
|
+
index: number,
|
|
594
|
+
options: ListingLocalizationOptions
|
|
595
|
+
): ListingProduct | null => {
|
|
596
|
+
const product = toRecord(entry);
|
|
597
|
+
const name = toText(product.title) || toText(product.name);
|
|
598
|
+
const url = toText(product.url) || toText(product.absolute_url);
|
|
599
|
+
|
|
600
|
+
if (!name && !url) return null;
|
|
601
|
+
|
|
602
|
+
// Commerce sends the currency per product; the active currency is only the
|
|
603
|
+
// fallback for payloads that omit it.
|
|
604
|
+
const currency =
|
|
605
|
+
toText(product.currency) ||
|
|
606
|
+
toText(product.currency_type) ||
|
|
607
|
+
(options.currency || '');
|
|
608
|
+
const price = formatListingPrice(
|
|
609
|
+
product.price,
|
|
610
|
+
currency,
|
|
611
|
+
options.locale,
|
|
612
|
+
options.money
|
|
613
|
+
);
|
|
614
|
+
const retailPrice = formatListingPrice(
|
|
615
|
+
product.retailPrice ?? product.retail_price,
|
|
616
|
+
currency,
|
|
617
|
+
options.locale,
|
|
618
|
+
options.money
|
|
619
|
+
);
|
|
620
|
+
// Parsed with the shared parser, not Number(): "1.299,90" is NaN to Number()
|
|
621
|
+
// and would silently drop the discount flag the PDP block still shows.
|
|
622
|
+
const numericPrice = parsePriceValue(product.price);
|
|
623
|
+
const numericRetail = parsePriceValue(
|
|
624
|
+
product.retailPrice ?? product.retail_price
|
|
625
|
+
);
|
|
626
|
+
const hasDiscount =
|
|
627
|
+
numericPrice !== null &&
|
|
628
|
+
numericRetail !== null &&
|
|
629
|
+
numericRetail > numericPrice;
|
|
630
|
+
|
|
631
|
+
const rawPreviousPrice = toRecord(product.price_extra_field).previous_price;
|
|
632
|
+
const numericPrevious = parsePriceValue(rawPreviousPrice);
|
|
633
|
+
const numericPk = Number(product.pk);
|
|
634
|
+
const colors = normalizeProductColors(product, options.lookups?.swatch);
|
|
635
|
+
const attributes = normalizeProductAttributes(product);
|
|
636
|
+
const percentFromRetail = percentOff(numericRetail, numericPrice);
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
key: toText(product.pk, '') || `product-${index}`,
|
|
640
|
+
pk:
|
|
641
|
+
!isMissing(product.pk) && Number.isFinite(numericPk)
|
|
642
|
+
? numericPk
|
|
643
|
+
: undefined,
|
|
644
|
+
name,
|
|
645
|
+
url: url || '#',
|
|
646
|
+
image: toText(product.image),
|
|
647
|
+
images: normalizeProductImages(product),
|
|
648
|
+
price,
|
|
649
|
+
retailPrice,
|
|
650
|
+
previousPrice: isMissing(rawPreviousPrice)
|
|
651
|
+
? undefined
|
|
652
|
+
: formatListingPrice(
|
|
653
|
+
rawPreviousPrice,
|
|
654
|
+
currency,
|
|
655
|
+
options.locale,
|
|
656
|
+
options.money
|
|
657
|
+
) || undefined,
|
|
658
|
+
hasDiscount,
|
|
659
|
+
...(numericPrice !== null ? { priceAmount: numericPrice } : null),
|
|
660
|
+
...(numericRetail !== null ? { retailPriceAmount: numericRetail } : null),
|
|
661
|
+
// The grid already derived this to draw its badge; publishing it means a
|
|
662
|
+
// designer-authored card does not have to re-derive it from two strings.
|
|
663
|
+
discountPercent: percentFromRetail,
|
|
664
|
+
discountPercentFromRetail: percentFromRetail,
|
|
665
|
+
discountPercentFromPrevious: percentOff(numericPrevious, numericPrice),
|
|
666
|
+
inStock: readInStock(product),
|
|
667
|
+
attributes,
|
|
668
|
+
badge: resolveBadge(attributes, options.lookups?.badge),
|
|
669
|
+
basketOffers: normalizeBasketOffers(
|
|
670
|
+
product,
|
|
671
|
+
currency,
|
|
672
|
+
options.locale,
|
|
673
|
+
options.money
|
|
674
|
+
),
|
|
675
|
+
colors,
|
|
676
|
+
colorCount: colors ? colors.length : undefined
|
|
677
|
+
};
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The row model above, for lists that are not listings. The favourites grid
|
|
682
|
+
* publishes its wishlist entries through this so a card designed against a
|
|
683
|
+
* product-grid row (`attributes.*`, `discountPercent`, `basketOffers`,
|
|
684
|
+
* `colors`) binds identically over a favourites row — one card design, both
|
|
685
|
+
* grids.
|
|
686
|
+
*/
|
|
687
|
+
export const normalizeListingProduct = (
|
|
688
|
+
entry: unknown,
|
|
689
|
+
index: number,
|
|
690
|
+
options: ListingLocalizationOptions
|
|
691
|
+
): ListingProduct | null => normalizeProduct(entry, index, options);
|
|
692
|
+
|