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