@akinon/pz-theme 2.0.143-rc.0 → 2.0.143

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.
@@ -19,6 +19,7 @@ import {
19
19
  useValueLocale
20
20
  } from '../utils/localized-value';
21
21
  import { BlockRendererProps } from './block-renderer-registry';
22
+ import { CardZoom } from '../components/card-zoom';
22
23
 
23
24
  /** "No breakpoint answered", which '' cannot express (see `pickImageSource`). */
24
25
  const NO_RESPONSIVE_VALUE = Symbol('no-responsive-value');
@@ -364,6 +365,29 @@ const ImageBlock = ({
364
365
  />
365
366
  );
366
367
 
368
+ /*
369
+ * The card's picture zooming in on hover (ZERO-5173): the property-switch
370
+ * pass wrote `cardZoom` because the card or the list around it asked for
371
+ * it. The frame takes the box the picture had and clips the enlarged one,
372
+ * so the card around it does not move; without the setting the element is
373
+ * the one this block always emitted.
374
+ */
375
+ const withZoom = (picture: React.ReactElement): React.ReactElement =>
376
+ block.cardZoom && src ? (
377
+ <CardZoom
378
+ style={{
379
+ width: boxWidth,
380
+ height: boxHeight,
381
+ borderRadius: borderRadius ?? '0px',
382
+ aspectRatio: boxAspectRatio
383
+ }}
384
+ >
385
+ {picture}
386
+ </CardZoom>
387
+ ) : (
388
+ picture
389
+ );
390
+
367
391
  /*
368
392
  * No override, or the canvas: emit exactly what this block emitted before
369
393
  * this feature existed. Every block published to date takes this path, so its
@@ -372,7 +396,7 @@ const ImageBlock = ({
372
396
  * snapshot and every brand's CSS that targets `[data-block-id] > img`.
373
397
  */
374
398
  if (!src || isDesigner || pictureSources.length === 0) {
375
- return content;
399
+ return withZoom(content);
376
400
  }
377
401
 
378
402
  /*
@@ -387,7 +411,7 @@ const ImageBlock = ({
387
411
  * keeping the element, which is exactly what is wanted: the <source> list is
388
412
  * markup, not layout.
389
413
  */
390
- return (
414
+ return withZoom(
391
415
  <picture style={{ display: 'contents' }}>
392
416
  {pictureSources.map((source) => (
393
417
  <source
@@ -167,8 +167,11 @@ export const resolveListingContext = (
167
167
  const source = findListingSource(context);
168
168
  if (!source) return null;
169
169
 
170
- const products = toArray(source.products)
171
- .map((entry, index) => normalizeListingProduct(entry, index, options))
170
+ const rows = toArray(source.products);
171
+ const products = rows
172
+ .map((entry, index) =>
173
+ normalizeListingProduct(entry, index, { ...options, rows })
174
+ )
172
175
  .filter((product): product is ListingProduct => product !== null);
173
176
 
174
177
  const pagination = toRecord(source.pagination);
@@ -12,6 +12,7 @@
12
12
  * module and the iterator is walked on both sides of hydration.
13
13
  */
14
14
 
15
+ import { resolveSwatchColor } from '../utils/swatch-colors';
15
16
  import { formatMoney, MoneyStyleOptions, parseAmount } from './commerce-format';
16
17
 
17
18
  export type { MoneyStyleOptions };
@@ -60,7 +61,8 @@ export interface ListingProductColor {
60
61
  /**
61
62
  * Hex a circular swatch can be filled with. Commerce sends the colour as a
62
63
  * bare value ("kirmizi") and no colour at all, so this is only present when
63
- * the author mapped that value in `lookups.swatch`.
64
+ * the author mapped that value in `lookups.swatch`, or the brand resolved it
65
+ * through its registered swatch colours (utils/swatch-colors).
64
66
  */
65
67
  color?: string;
66
68
  }
@@ -163,6 +165,8 @@ export interface ListingBasketOffer {
163
165
  price?: string;
164
166
  }
165
167
 
168
+ export type ListingPriceNote = 'lowest' | 'advantageous';
169
+
166
170
  export interface ListingProduct {
167
171
  key: string;
168
172
  /**
@@ -214,6 +218,16 @@ export interface ListingProduct {
214
218
  * the payload would move published cards.
215
219
  */
216
220
  discountPercentFromPrevious?: number;
221
+ /**
222
+ * Which price-history caption a discounted row may carry (EU Omnibus; the
223
+ * listing's "Son 10 günün en düşük fiyatı"). `lowest`: the selling price is
224
+ * the lowest of the period — no `previousPrice`, or one at or above it.
225
+ * `advantageous`: commerce's price-history figure is BELOW the selling
226
+ * price, so a "lowest price" claim would be false and the listing says
227
+ * "Avantajlı Fiyat" instead. Absent on a row with no discount. Which words
228
+ * are printed is the card's: it binds a text to each value.
229
+ */
230
+ priceNote?: ListingPriceNote;
217
231
  /**
218
232
  * A raw commerce value resolved through the author's lookup table (see
219
233
  * `ListingBadgeLookup`). Absent when no table was authored, when the product
@@ -245,6 +259,12 @@ export interface ListingLocalizationOptions {
245
259
  * row stays absent, which is the shape the row has always had.
246
260
  */
247
261
  lookups?: ListingValueLookups;
262
+ /**
263
+ * The list's rows as commerce sent them, for a brand's swatch colours that
264
+ * read a colour off the row's neighbours (utils/swatch-colors). Absent = the
265
+ * row alone.
266
+ */
267
+ rows?: unknown[];
248
268
  }
249
269
 
250
270
 
@@ -511,7 +531,8 @@ const isColorAttributeKey = (key: string): boolean => {
511
531
 
512
532
  const normalizeProductColors = (
513
533
  product: UnknownRecord,
514
- swatch?: Record<string, string>
534
+ swatch?: Record<string, string>,
535
+ listRows?: unknown[]
515
536
  ): ListingProductColor[] | undefined => {
516
537
  const groups = toArray(toRecord(product.extra_data).variants);
517
538
  // A known key wins over the loose suffix match: a payload carrying both
@@ -528,6 +549,13 @@ const normalizeProductColors = (
528
549
 
529
550
  if (!group) return undefined;
530
551
 
552
+ const rows = listRows
553
+ ? listRows.filter(
554
+ (row): row is UnknownRecord =>
555
+ Boolean(row) && typeof row === 'object' && !Array.isArray(row)
556
+ )
557
+ : [product];
558
+
531
559
  // Reshaped once rather than per option, so this table gets the same
532
560
  // case-insensitive matching the badge table gets.
533
561
  const swatchEntries = swatch
@@ -557,7 +585,17 @@ const normalizeProductColors = (
557
585
  // (GANT's `integration_renk` group) carry the code as the option's LABEL
558
586
  // and no value at all, so the label is the key there — the same string
559
587
  // the swatch table is authored against.
560
- const hex = resolveLookupEntry(swatchEntries, value || label)?.color;
588
+ const hex =
589
+ resolveLookupEntry(swatchEntries, value || label)?.color ??
590
+ // Nothing authored for this colour: the brand's own resolution, the
591
+ // one its listing paints with, when it registered one.
592
+ resolveSwatchColor({
593
+ key: value || label,
594
+ label,
595
+ product,
596
+ variant,
597
+ rows
598
+ });
561
599
 
562
600
  return {
563
601
  label,
@@ -588,6 +626,23 @@ const readInStock = (product: UnknownRecord): boolean | undefined => {
588
626
  return undefined;
589
627
  };
590
628
 
629
+ /**
630
+ * The listing's price rule (the old storefront's SHOP-88144, shared by its
631
+ * product item, PDP and basket): only a discounted product carries a caption,
632
+ * and it may only claim the lowest price when commerce's price-history figure
633
+ * is not below the selling price.
634
+ */
635
+ const readPriceNote = (
636
+ hasDiscount: boolean,
637
+ price: number | null,
638
+ previous: number | null
639
+ ): ListingPriceNote | undefined => {
640
+ if (!hasDiscount) return undefined;
641
+ return price !== null && previous !== null && price > previous
642
+ ? 'advantageous'
643
+ : 'lowest';
644
+ };
645
+
591
646
  const normalizeProduct = (
592
647
  entry: unknown,
593
648
  index: number,
@@ -631,7 +686,11 @@ const normalizeProduct = (
631
686
  const rawPreviousPrice = toRecord(product.price_extra_field).previous_price;
632
687
  const numericPrevious = parsePriceValue(rawPreviousPrice);
633
688
  const numericPk = Number(product.pk);
634
- const colors = normalizeProductColors(product, options.lookups?.swatch);
689
+ const colors = normalizeProductColors(
690
+ product,
691
+ options.lookups?.swatch,
692
+ options.rows
693
+ );
635
694
  const attributes = normalizeProductAttributes(product);
636
695
  const percentFromRetail = percentOff(numericRetail, numericPrice);
637
696
 
@@ -663,6 +722,7 @@ const normalizeProduct = (
663
722
  discountPercent: percentFromRetail,
664
723
  discountPercentFromRetail: percentFromRetail,
665
724
  discountPercentFromPrevious: percentOff(numericPrevious, numericPrice),
725
+ priceNote: readPriceNote(hasDiscount, numericPrice, numericPrevious),
666
726
  inStock: readInStock(product),
667
727
  attributes,
668
728
  badge: resolveBadge(attributes, options.lookups?.badge),
@@ -12,6 +12,13 @@ import {
12
12
  TABLET_MEDIA_QUERY_COMPACT
13
13
  } from '../utils/breakpoints';
14
14
  import { neutralizeStyleTagBreakout } from '../utils/css-safety';
15
+ import {
16
+ resolveOwnCardsSlider,
17
+ resolveOwnCardZoom
18
+ } from '../utils/property-switches';
19
+ import { CardListSlider, readListGap } from '../components/card-list-slider';
20
+ import { postBlockSelection } from '../hooks/use-designer-features';
21
+ import { CardZoom } from '../components/card-zoom';
15
22
  import ThemeBlock, { Block } from '../theme-block';
16
23
  import { hasItemTemplate, ItemTemplateList } from './item-template';
17
24
  import { BlockRendererProps } from './block-renderer-registry';
@@ -284,13 +291,18 @@ const readOption = <T extends string>(
284
291
  * so a published grid renders exactly what it rendered before.
285
292
  *
286
293
  * Parts: list, card, image, name, price, sale-price, list-price, badge,
287
- * density, density-label, density-option, density-icon, insert, empty.
294
+ * density, density-label, density-option, density-icon, insert, empty,
295
+ * slider, slider-header, slider-arrow.
288
296
  * Properties: columnsDesktop, columnsMobile, limit, showName, nameLines,
289
297
  * imageRatio, imageFit, showPrice, showRetailPrice, showDiscountBadge,
290
298
  * discountBadgeTemplate, badgePosition, decimals, currencyDisplay,
291
299
  * currencyText, currencyPosition, showDensitySwitcher, densityOptions,
292
300
  * densityStyle, densityPosition, densityLabel, densityAriaLabel,
293
- * previewEmptyState, badgeAttribute, badgeMap, swatchMap.
301
+ * previewEmptyState, badgeAttribute, badgeMap, swatchMap, cardsSlider,
302
+ * cardsSliderShowTitle, cardsSliderTitle, cardsSliderTitleColor,
303
+ * cardsSliderTitleSize, cardsSliderTitleWeight, cardsSliderTitleFont,
304
+ * cardsSliderTitleTransform, cardsPerView, cardsPerViewMobile,
305
+ * cardsSliderArrows, cardImageHoverZoom.
294
306
  * Child properties (on a `grid-insertion` child): insertPosition,
295
307
  * insertColumnSpan, insertRowSpan, insertPages.
296
308
  */
@@ -436,6 +448,15 @@ const ProductGridBlock = ({
436
448
  const badgePosition = String(
437
449
  getResponsiveValue(block.properties?.badgePosition, currentBreakpoint, 'inline')
438
450
  );
451
+ // The grid's "Cards:" slider and zoom (ZERO-5173). The slider is the grid's
452
+ // own layout, so it is read here; the zoom reaches a designer-authored card
453
+ // through the property-switch pass, and the built-in card, drawn here,
454
+ // reads it off the grid directly.
455
+ const cardsSlider = resolveOwnCardsSlider(
456
+ block.properties,
457
+ currentBreakpoint
458
+ );
459
+ const builtInZoom = resolveOwnCardZoom(block.properties, currentBreakpoint);
439
460
 
440
461
  const showDensitySwitcher = readToggle(
441
462
  block.properties?.showDensitySwitcher,
@@ -741,7 +762,29 @@ const ProductGridBlock = ({
741
762
  : null)
742
763
  }}
743
764
  >
744
- {product.image ? (
765
+ {product.image && builtInZoom ? (
766
+ <CardZoom
767
+ attributes={{
768
+ 'data-product-card-image': '',
769
+ ...partAttrs('image')
770
+ }}
771
+ style={
772
+ imageRatio !== 'auto' ? { aspectRatio: imageRatio } : undefined
773
+ }
774
+ >
775
+ <img
776
+ src={product.image}
777
+ alt={product.name}
778
+ loading="lazy"
779
+ style={{
780
+ width: '100%',
781
+ height: imageRatio !== 'auto' ? '100%' : 'auto',
782
+ display: 'block',
783
+ objectFit: imageFit as React.CSSProperties['objectFit']
784
+ }}
785
+ />
786
+ </CardZoom>
787
+ ) : product.image ? (
745
788
  <img
746
789
  src={product.image}
747
790
  alt={product.name}
@@ -839,6 +882,43 @@ const ProductGridBlock = ({
839
882
  interleaveCells(builtInCards, insertionTiles, placedInsertions)
840
883
  );
841
884
 
885
+ // ZERO-5173: with the slider on, the same cards — built-in or authored, and
886
+ // any merchandising tile among them — go into one sliding row instead of
887
+ // the grid. The column template, the tiles' cell pins and the density
888
+ // switcher belong to the grid; the row counts its own cards in view per side
889
+ // of the 1024px line, keeps the gap the grid was authored with, and draws
890
+ // the grid's slider title beside its arrows.
891
+ if (cardsSlider) {
892
+ return (
893
+ <div
894
+ data-product-grid=""
895
+ data-product-grid-id={gridId}
896
+ data-product-grid-slider=""
897
+ >
898
+ <CardListSlider
899
+ sliderId={`grid-${gridId}`}
900
+ settings={cardsSlider}
901
+ gap={readListGap(block.styles?.['list/gap'])}
902
+ defaultGap="var(--product-grid-gap, 16px)"
903
+ isDesigner={isDesigner}
904
+ currentBreakpoint={currentBreakpoint}
905
+ trackAttributes={partAttrs('list')}
906
+ onTitleSelect={() =>
907
+ postBlockSelection({
908
+ placeholderId,
909
+ sectionId,
910
+ blockId: block.id,
911
+ blockType: block.type,
912
+ blockLabel: block.label
913
+ })
914
+ }
915
+ >
916
+ {gridChildren}
917
+ </CardListSlider>
918
+ </div>
919
+ );
920
+ }
921
+
842
922
  const grid = (
843
923
  <div
844
924
  data-product-grid=""