@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.
@@ -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
+