@akinon/pz-theme 2.0.66-rc.0 → 2.0.67-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.
package/CHANGELOG.md CHANGED
@@ -1,36 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.66-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.66-rc.0
33
- - @akinon/theme-contract@2.0.66-rc.0
3
+ ## 2.0.67-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd75]
8
+ - @akinon/next@2.0.67-beta.0
9
+ - @akinon/theme-contract@2.0.67-beta.0
10
+
11
+ ## 2.0.66
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [7891fab]
16
+ - @akinon/theme-contract@2.0.66
17
+ - @akinon/next@2.0.66
34
18
 
35
19
  ## 2.0.65
36
20
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.66-rc.0",
4
+ "version": "2.0.67-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.66-rc.0",
11
+ "@akinon/next": "2.0.67-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.66-rc.0",
16
+ "@akinon/theme-contract": "2.0.67-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.66-rc.0",
23
+ "@akinon/next": "2.0.67-beta.0",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -7,6 +7,7 @@ import { twMerge } from 'tailwind-merge';
7
7
  import ThemeBlock from '../theme-block';
8
8
  import { useThemeSettingsContext } from '../theme-settings-context';
9
9
  import { safeDeepClone } from '../utils/safe-clone';
10
+ import { getPriceWithCurrency, isPriceBinding } from '../utils/iterator-utils';
10
11
  import {
11
12
  applyFirstSlideImageLoading,
12
13
  getCSSStyles,
@@ -64,6 +65,23 @@ const cloneBlock = (block: any): any => ({
64
65
  : undefined
65
66
  });
66
67
 
68
+ const parseProductPrice = (value: unknown): number => {
69
+ const parsed = Number(value);
70
+ return Number.isFinite(parsed) ? parsed : 0;
71
+ };
72
+
73
+ const readProductPricePair = (productData: Record<string, unknown>) => {
74
+ const activePrice = (productData as any)?.active_price as
75
+ | Record<string, unknown>
76
+ | undefined;
77
+ return {
78
+ current: parseProductPrice(activePrice?.price ?? (productData as any)?.price),
79
+ retail: parseProductPrice(
80
+ activePrice?.retail_price ?? (productData as any)?.retail_price
81
+ )
82
+ };
83
+ };
84
+
67
85
  const replaceBlockValues = (
68
86
  blockToReplace: any,
69
87
  productData: Record<string, unknown>,
@@ -78,6 +96,65 @@ const replaceBlockValues = (
78
96
  'item.',
79
97
  ''
80
98
  );
99
+
100
+ // No payload carries a discount percentage, but every one carries the two
101
+ // prices it derives from — so `item.discount_percentage` is computed here.
102
+ // A non-discounted product hides the block instead of printing "-%0".
103
+ if (bindingPath === 'discount_percentage') {
104
+ const { current, retail } = readProductPricePair(productData);
105
+ if (retail > 0 && current > 0 && retail > current) {
106
+ newBlock.value = `-%${Math.round((1 - current / retail) * 100)}`;
107
+ newBlock.hidden = false;
108
+ } else {
109
+ newBlock.hidden = true;
110
+ }
111
+ if (newBlock.blocks && newBlock.blocks.length > 0) {
112
+ newBlock.blocks = newBlock.blocks.map((childBlock: any) =>
113
+ replaceBlockValues(childBlock, productData, productIndex)
114
+ );
115
+ }
116
+ return newBlock;
117
+ }
118
+
119
+ // Prices resolve through the pair reader rather than the literal path:
120
+ // v2 collection items publish them under `active_price`, PDP-shaped
121
+ // payloads at the top level, and a composition bound against the one the
122
+ // brand's endpoint doesn't ship would otherwise render an empty block
123
+ // (imports blank a bound block's placeholder value on purpose).
124
+ if (isPriceBinding(String(newBlock.properties.dataBinding))) {
125
+ const { current, retail } = readProductPricePair(productData);
126
+ const isRetail =
127
+ bindingPath === 'retail_price' ||
128
+ bindingPath === 'active_price.retail_price';
129
+ const activePrice = (productData as any)?.active_price as
130
+ | Record<string, unknown>
131
+ | undefined;
132
+ const currency =
133
+ activePrice?.currency_type ??
134
+ (productData as any)?.currency_type ??
135
+ (productData as any)?.currency;
136
+
137
+ if (isRetail) {
138
+ // A struck-through retail price only means something when it is
139
+ // higher than what the shopper pays; the same figure twice reads as
140
+ // a rendering bug, so non-discounted products hide it.
141
+ if (retail > 0 && current > 0 && retail > current) {
142
+ newBlock.value = getPriceWithCurrency(retail, currency);
143
+ } else {
144
+ newBlock.hidden = true;
145
+ }
146
+ } else if (current > 0) {
147
+ newBlock.value = getPriceWithCurrency(current, currency);
148
+ }
149
+
150
+ if (newBlock.blocks && newBlock.blocks.length > 0) {
151
+ newBlock.blocks = newBlock.blocks.map((childBlock: any) =>
152
+ replaceBlockValues(childBlock, productData, productIndex)
153
+ );
154
+ }
155
+ return newBlock;
156
+ }
157
+
81
158
  const resolvedValue = getNestedValue(productData, bindingPath);
82
159
 
83
160
  if (resolvedValue !== undefined) {
@@ -92,6 +169,11 @@ const replaceBlockValues = (
92
169
  ? String(resolvedValue)
93
170
  : resolvedValue;
94
171
  }
172
+ } else if (newBlock.type === 'image') {
173
+ // Real data arrived but this binding has no value on it (e.g. a swatch
174
+ // field the collection endpoint doesn't ship). A broken/placeholder
175
+ // image is worse than no image, so the clone hides it.
176
+ newBlock.hidden = true;
95
177
  }
96
178
  } else if (isPlaceholderMode && newBlock.type === 'image') {
97
179
  const placeholderImages = [
@@ -383,6 +465,49 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
383
465
  const useInfinite = infinite && hasMultipleSlides;
384
466
  const useAutoPlay = autoPlay && hasMultipleSlides;
385
467
 
468
+ // 'overlay' keeps the library's centred side arrows; 'top-right' moves
469
+ // navigation into a header row above the track (with an optional inline
470
+ // title), the pattern fashion sliders use. Composition-driven via section
471
+ // properties, so a brand opts in per section without a renderer fork.
472
+ const arrowPosition = getResponsiveValue(
473
+ getConfigEntry('arrow-position'),
474
+ currentBreakpoint,
475
+ 'overlay'
476
+ );
477
+ const sliderTitle =
478
+ typeof section.properties?.['slider-title'] === 'string'
479
+ ? section.properties['slider-title']
480
+ : '';
481
+ const useTopNav = showArrows && arrowPosition === 'top-right';
482
+ const carouselRef = React.useRef<Carousel | null>(null);
483
+
484
+ const topNavChevron = (direction: 'left' | 'right') => (
485
+ <svg
486
+ width="8"
487
+ height="12"
488
+ viewBox="0 0 8 12"
489
+ fill="none"
490
+ xmlns="http://www.w3.org/2000/svg"
491
+ style={direction === 'right' ? { transform: 'scaleX(-1)' } : undefined}
492
+ >
493
+ <path d="M6.5 1L1.5 6l5 5" stroke="currentColor" strokeWidth="1.2" />
494
+ </svg>
495
+ );
496
+
497
+ const topNavButtonStyle: React.CSSProperties = {
498
+ width: '32px',
499
+ height: '32px',
500
+ display: 'flex',
501
+ alignItems: 'center',
502
+ justifyContent: 'center',
503
+ background: 'transparent',
504
+ border: 'none',
505
+ borderRadius: '2px',
506
+ color: '#000000',
507
+ cursor: 'pointer',
508
+ padding: 0
509
+ };
510
+
386
511
  const postBlockAction = (
387
512
  type: PreviewToEditorMessageType,
388
513
  blockId: string,
@@ -418,13 +543,57 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
418
543
  )}
419
544
  style={sectionStyles}
420
545
  >
546
+ {useTopNav && (
547
+ <div
548
+ style={{
549
+ display: 'flex',
550
+ alignItems: 'center',
551
+ justifyContent: sliderTitle ? 'space-between' : 'flex-end',
552
+ padding: '16px 14px 16px 24px'
553
+ }}
554
+ >
555
+ {sliderTitle && (
556
+ <p
557
+ style={{
558
+ margin: 0,
559
+ fontSize: '14px',
560
+ fontWeight: 400,
561
+ lineHeight: 1.4,
562
+ color: '#000000',
563
+ textTransform: 'uppercase'
564
+ }}
565
+ >
566
+ {sliderTitle}
567
+ </p>
568
+ )}
569
+ <div style={{ display: 'flex', alignItems: 'center' }}>
570
+ <button
571
+ type="button"
572
+ aria-label="Previous"
573
+ style={topNavButtonStyle}
574
+ onClick={() => carouselRef.current?.previous(currentSlidesToSlide)}
575
+ >
576
+ {topNavChevron('left')}
577
+ </button>
578
+ <button
579
+ type="button"
580
+ aria-label="Next"
581
+ style={topNavButtonStyle}
582
+ onClick={() => carouselRef.current?.next(currentSlidesToSlide)}
583
+ >
584
+ {topNavChevron('right')}
585
+ </button>
586
+ </div>
587
+ </div>
588
+ )}
421
589
  <Carousel
422
590
  key={`${desktopItems}-${tabletItems}-${mobileItems}-${desktopSlides}-${tabletSlides}-${mobileSlides}-${visibleCount}-${currentBreakpoint}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`}
591
+ ref={carouselRef}
423
592
  responsive={carouselResponsive}
424
593
  {...(isDesigner ? { slidesToSlide: currentSlidesToSlide } : {})}
425
594
  swipeable={true}
426
595
  draggable={true}
427
- arrows={showArrows}
596
+ arrows={showArrows && !useTopNav}
428
597
  showDots={showDots}
429
598
  renderDotsOutside={showDots}
430
599
  infinite={useInfinite}
@@ -457,7 +457,10 @@ const normalizeCurrencyLabel = (rawCurrency: unknown): string => {
457
457
  return normalized;
458
458
  };
459
459
 
460
- const getPriceWithCurrency = (value: unknown, currency: unknown): string => {
460
+ export const getPriceWithCurrency = (
461
+ value: unknown,
462
+ currency: unknown
463
+ ): string => {
461
464
  const amount = formatDisplayPrice(value);
462
465
  const currencyLabel = normalizeCurrencyLabel(currency);
463
466
 
@@ -499,10 +502,15 @@ const buildProductPriceHtml = (
499
502
  return currentText;
500
503
  };
501
504
 
502
- const isPriceBinding = (dataBinding: string): boolean => {
505
+ export const isPriceBinding = (dataBinding: string): boolean => {
503
506
  const path = dataBinding.replace('item.', '');
504
507
  return (
505
- path === 'active_price.price' || path === 'price' || path === 'retail_price'
508
+ path === 'active_price.price' ||
509
+ path === 'price' ||
510
+ path === 'retail_price' ||
511
+ // Retail price travels under active_price on v2 payloads; leaving it out
512
+ // printed "3999.00" beside a formatted "2.999 TL" in the same row.
513
+ path === 'active_price.retail_price'
506
514
  );
507
515
  };
508
516