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

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,38 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.81-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.81-rc.0
35
- - @akinon/theme-contract@2.0.81-rc.0
3
+ ## 2.0.81
4
+
5
+ ### Patch Changes
6
+
7
+ - ab131f37: ZERO-4835: add loyalty slug, favourites source and slider peek
8
+ - @akinon/next@2.0.81
9
+ - @akinon/theme-contract@2.0.81
36
10
 
37
11
  ## 2.0.80
38
12
 
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.81-rc.0",
4
+ "version": "2.0.81",
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.81-rc.0",
11
+ "@akinon/next": "2.0.81",
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.81-rc.0",
16
+ "@akinon/theme-contract": "2.0.81",
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.81-rc.0",
23
+ "@akinon/next": "2.0.81",
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",
@@ -42,7 +42,8 @@ const EXPECTED_MOUNTS: Record<string, string> = {
42
42
  // that is the cue to mount the slot and shrink this list.
43
43
  const UNMOUNTED_ACCOUNT_KEYS: Record<string, string> = {
44
44
  accountNotifications: 'notification-settings',
45
- accountStockAlerts: 'stock-waiting-products'
45
+ accountStockAlerts: 'stock-waiting-products',
46
+ accountLoyalty: 'loyalty'
46
47
  };
47
48
 
48
49
  const getMountableAccountKeys = () =>
@@ -17,6 +17,7 @@ let mockWidget: Record<string, unknown> | undefined;
17
17
  let mockDetail: Record<string, unknown> | undefined;
18
18
  /** Every payload that reached the basket endpoint, in order. */
19
19
  const mockAdds: Record<string, unknown>[] = [];
20
+ let mockFavourites: Record<string, unknown> | undefined;
20
21
 
21
22
  /**
22
23
  * Every `@akinon/next/data/client/*` subpath is folded onto one mock module by
@@ -44,7 +45,12 @@ function mockDataClient() {
44
45
  return { unwrap: () => Promise.resolve({}) };
45
46
  },
46
47
  { isLoading: false }
47
- ]
48
+ ],
49
+ useGetFavoritesQuery: (_args: unknown, options?: { skip?: boolean }) => ({
50
+ data: options?.skip ? undefined : mockFavourites,
51
+ isLoading: false,
52
+ isFetching: false
53
+ })
48
54
  };
49
55
  }
50
56
 
@@ -869,3 +875,83 @@ describe('section source', () => {
869
875
  expect(container.textContent).toContain('Source panel');
870
876
  });
871
877
  });
878
+
879
+ describe('struck list price and discount badge', () => {
880
+ const DISCOUNTED = {
881
+ pk: 91,
882
+ name: 'Polo',
883
+ absolute_url: '/polo',
884
+ price: '2999',
885
+ retail_price: '3999',
886
+ discount_ratio: 25.01,
887
+ currency_type: 'TRY'
888
+ };
889
+
890
+ afterEach(() => {
891
+ mockWidget = undefined;
892
+ });
893
+
894
+ it('keeps the single price span while both toggles are off', () => {
895
+ mockWidget = { products: [DISCOUNTED] };
896
+ const { container } = renderBlock(
897
+ <ProductRecommendationsBlock {...props({ collectionId: 'c1' })} />
898
+ );
899
+
900
+ expect(container.querySelector('[data-product-card-price]')).toBeTruthy();
901
+ expect(container.querySelector('[data-product-card-price-row]')).toBeNull();
902
+ expect(
903
+ container.querySelector('[data-product-card-retail-price]')
904
+ ).toBeNull();
905
+ expect(container.querySelector('[data-product-card-badge]')).toBeNull();
906
+ });
907
+
908
+ it('draws the struck price, the discounted state and the -%N chip', () => {
909
+ mockWidget = { products: [DISCOUNTED] };
910
+ const { container } = renderBlock(
911
+ <ProductRecommendationsBlock
912
+ {...props({
913
+ collectionId: 'c1',
914
+ showRetailPrice: true,
915
+ showDiscountBadge: true
916
+ })}
917
+ />
918
+ );
919
+
920
+ const retail = container.querySelector(
921
+ '[data-product-card-retail-price]'
922
+ ) as HTMLElement;
923
+ const price = container.querySelector(
924
+ '[data-product-card-price]'
925
+ ) as HTMLElement;
926
+ const badge = container.querySelector(
927
+ '[data-product-card-badge]'
928
+ ) as HTMLElement;
929
+
930
+ expect(retail.tagName).toBe('S');
931
+ expect(retail.textContent).toContain('3,999');
932
+ expect(price.getAttribute('data-state')).toContain('discounted');
933
+ expect(badge.textContent).toBe('-%25');
934
+ });
935
+
936
+ it('hides the struck price and the chip for a product without a discount', () => {
937
+ mockWidget = { products: [CARD] };
938
+ const { container } = renderBlock(
939
+ <ProductRecommendationsBlock
940
+ {...props({
941
+ collectionId: 'c1',
942
+ showRetailPrice: true,
943
+ showDiscountBadge: true
944
+ })}
945
+ />
946
+ );
947
+
948
+ expect(
949
+ container.querySelector('[data-product-card-retail-price]')
950
+ ).toBeNull();
951
+ expect(container.querySelector('[data-product-card-badge]')).toBeNull();
952
+ const price = container.querySelector(
953
+ '[data-product-card-price]'
954
+ ) as HTMLElement;
955
+ expect(price.getAttribute('data-state') || '').not.toContain('discounted');
956
+ });
957
+ });
@@ -2,7 +2,9 @@
2
2
 
3
3
  import React, { useRef } from 'react';
4
4
  import NextLink from 'next/link';
5
+ import { useSession } from 'next-auth/react';
5
6
  import { useGetWidgetQuery } from '@akinon/next/data/client/misc';
7
+ import { useGetFavoritesQuery } from '@akinon/next/data/client/wishlist';
6
8
  import {
7
9
  useAddProductMutation,
8
10
  useGetProductByPkQuery
@@ -21,6 +23,7 @@ import { DESIGNER_SAMPLE_PRODUCT } from './item-template-utils';
21
23
  import {
22
24
  formatListingPrice,
23
25
  listingPlaceholderStyle,
26
+ normalizeListingProduct,
24
27
  readToggle
25
28
  } from './listing-context';
26
29
  import {
@@ -60,7 +63,7 @@ const ALLOWED_DECIMALS = ['always', 'auto', 'never'];
60
63
  const ALLOWED_CURRENCY_DISPLAYS = ['', 'symbol', 'code', 'custom', 'none'];
61
64
  const ALLOWED_CURRENCY_POSITIONS = ['', 'before', 'after'];
62
65
  /** `collection` is the source the block has always had, so it is the default. */
63
- const ALLOWED_SOURCES = ['collection', 'recently-viewed', 'section'];
66
+ const ALLOWED_SOURCES = ['collection', 'recently-viewed', 'section', 'favourites'];
64
67
  const ALLOWED_QUICK_VIEW_ICONS = ['eye', 'plus', 'none'];
65
68
 
66
69
  const DEFAULT_QUICK_VIEW_FEEDBACK = 2000;
@@ -207,6 +210,9 @@ interface RecommendationCard {
207
210
  url: string;
208
211
  image: string;
209
212
  price: string;
213
+ retailPrice?: string;
214
+ hasDiscount?: boolean;
215
+ discountPercent?: number;
210
216
  }
211
217
 
212
218
  /**
@@ -312,6 +318,7 @@ const ProductRecommendationsBlock = ({
312
318
  // collection picked in the editor's Source panel), so the strip needs no
313
319
  // collection id of its own.
314
320
  const isSectionSource = source === 'section';
321
+ const isFavourites = source === 'favourites';
315
322
  const sectionData = useThemeSectionData();
316
323
 
317
324
  const title = String(
@@ -362,6 +369,16 @@ const ProductRecommendationsBlock = ({
362
369
  currentBreakpoint,
363
370
  true
364
371
  );
372
+ const showRetailPrice = readToggle(
373
+ block.properties?.showRetailPrice,
374
+ currentBreakpoint,
375
+ false
376
+ );
377
+ const showDiscountBadge = readToggle(
378
+ block.properties?.showDiscountBadge,
379
+ currentBreakpoint,
380
+ false
381
+ );
365
382
  const imageRatioValue = prop('imageRatio', '');
366
383
  const imageRatio = ALLOWED_IMAGE_RATIOS.includes(imageRatioValue)
367
384
  ? imageRatioValue
@@ -514,9 +531,15 @@ const ProductRecommendationsBlock = ({
514
531
  // is what prevents a request before the author has named a collection — and
515
532
  // now also a request for a collection the recently-viewed source never uses.
516
533
  const { data, isFetching } = useGetWidgetQuery(collectionId, {
517
- skip: !collectionId || isRecentlyViewed || isSectionSource
534
+ skip: !collectionId || isRecentlyViewed || isSectionSource || isFavourites
518
535
  });
519
536
 
537
+ const { status: sessionStatus } = useSession();
538
+ const { data: favouritesData } = useGetFavoritesQuery(
539
+ { limit: 100, page: 1 },
540
+ { skip: !isFavourites || sessionStatus !== 'authenticated' }
541
+ );
542
+
520
543
  // The arrows report where the track stands: the previous arrow is disabled
521
544
  // at the start and the next one at the end, re-read on scroll and resize.
522
545
  // In the designer both stay enabled so the author can style them.
@@ -539,7 +562,7 @@ const ProductRecommendationsBlock = ({
539
562
  track.removeEventListener('scroll', update);
540
563
  window.removeEventListener('resize', update);
541
564
  };
542
- }, [showArrows, isDesigner, data, recentEntries, sectionData]);
565
+ }, [showArrows, isDesigner, data, recentEntries, sectionData, favouritesData]);
543
566
 
544
567
  /**
545
568
  * The overlay's own request. A row carries a summary only, so the variant
@@ -674,6 +697,35 @@ const ProductRecommendationsBlock = ({
674
697
  []
675
698
  );
676
699
 
700
+ const discountFields = (
701
+ priceRaw: unknown,
702
+ retailRaw: unknown,
703
+ ratioRaw: unknown,
704
+ currency: string
705
+ ): Pick<
706
+ RecommendationCard,
707
+ 'hasDiscount' | 'retailPrice' | 'discountPercent'
708
+ > => {
709
+ const priceValue = parsePriceValue(priceRaw);
710
+ const retailValue = parsePriceValue(retailRaw);
711
+ const hasDiscount =
712
+ priceValue !== null && retailValue !== null && retailValue > priceValue;
713
+
714
+ if (!hasDiscount) return { hasDiscount: false };
715
+
716
+ const ratio = Number(ratioRaw);
717
+ const discountPercent =
718
+ Number.isFinite(ratio) && ratio > 0
719
+ ? Math.round(ratio)
720
+ : Math.round((1 - (priceValue as number) / (retailValue as number)) * 100);
721
+
722
+ return {
723
+ hasDiscount: true,
724
+ retailPrice: formatListingPrice(retailRaw, currency, locale, money),
725
+ discountPercent
726
+ };
727
+ };
728
+
677
729
  const collectionProducts: RecommendationCard[] = (
678
730
  (isSectionSource
679
731
  ? sectionData?.items || []
@@ -714,6 +766,12 @@ const ProductRecommendationsBlock = ({
714
766
  currency,
715
767
  locale,
716
768
  money
769
+ ),
770
+ ...discountFields(
771
+ entry.price ?? activePrice.price,
772
+ entry.retail_price ?? activePrice.retail_price,
773
+ entry.discount_ratio,
774
+ currency
717
775
  )
718
776
  };
719
777
  })
@@ -744,8 +802,64 @@ const ProductRecommendationsBlock = ({
744
802
  )
745
803
  }));
746
804
 
805
+ const favouriteCards: RecommendationCard[] = (
806
+ (favouritesData as
807
+ | {
808
+ results?: Array<{
809
+ pk?: number;
810
+ product?: Record<string, unknown> | null;
811
+ }>;
812
+ }
813
+ | undefined)?.results ?? []
814
+ )
815
+ .filter((favourite) => Boolean(favourite.product))
816
+ .slice(0, limit)
817
+ .map((favourite, index) => {
818
+ const product = favourite.product as Record<string, unknown>;
819
+ const normalized = normalizeListingProduct(product, index, {
820
+ locale,
821
+ currency: localeCurrency
822
+ });
823
+ const activePrice = (product.active_price ?? {}) as Record<
824
+ string,
825
+ unknown
826
+ >;
827
+ const currency =
828
+ toText(product.currency_type) ||
829
+ toText(activePrice.currency_type) ||
830
+ localeCurrency ||
831
+ '';
832
+ const url =
833
+ normalized?.url && normalized.url !== '#'
834
+ ? normalized.url
835
+ : toText(product.absolute_url);
836
+
837
+ return {
838
+ ...(normalized ?? {}),
839
+ key: toText(favourite.pk ?? product.pk) || `favourite-${index}`,
840
+ pk: toProductPk(product.pk),
841
+ name: normalized?.name || toText(product.name),
842
+ url: url || '#',
843
+ image: normalized?.image || normalized?.images?.[0] || '',
844
+ price: formatListingPrice(
845
+ product.price ?? activePrice.price,
846
+ currency,
847
+ locale,
848
+ money
849
+ ),
850
+ ...discountFields(
851
+ product.price ?? activePrice.price,
852
+ product.retail_price ?? activePrice.retail_price,
853
+ product.discount_ratio,
854
+ currency
855
+ )
856
+ };
857
+ });
858
+
747
859
  const products = isRecentlyViewed
748
860
  ? recentlyViewedProducts
861
+ : isFavourites
862
+ ? favouriteCards
749
863
  : collectionProducts;
750
864
 
751
865
  /**
@@ -757,7 +871,7 @@ const ProductRecommendationsBlock = ({
757
871
  */
758
872
  const designerRows: RecommendationCard[] | null =
759
873
  isDesigner &&
760
- isRecentlyViewed &&
874
+ (isRecentlyViewed || isFavourites) &&
761
875
  products.length === 0 &&
762
876
  // An authored card layout already draws its own placeholder rows
763
877
  // (`designerPlaceholderCount`), so adding built-in ones would double them.
@@ -765,8 +879,8 @@ const ProductRecommendationsBlock = ({
765
879
  ? Array.from(
766
880
  { length: Math.min(limit, columnsDesktop + 1) },
767
881
  (_, index) => ({
768
- key: `recently-viewed-placeholder-${index}`,
769
- name: recentlyViewedEmptyLabel,
882
+ key: `${source}-placeholder-${index}`,
883
+ name: isFavourites ? 'Favourites' : recentlyViewedEmptyLabel,
770
884
  url: '#',
771
885
  image: '',
772
886
  price: ''
@@ -1226,11 +1340,57 @@ const ProductRecommendationsBlock = ({
1226
1340
  </span>
1227
1341
  )}
1228
1342
 
1229
- {showPrice && product.price && (
1230
- <span data-product-card-price="" {...partAttrs('card-price')}>
1231
- {product.price}
1232
- </span>
1233
- )}
1343
+ {showPrice &&
1344
+ product.price &&
1345
+ !(showRetailPrice || showDiscountBadge) && (
1346
+ <span data-product-card-price="" {...partAttrs('card-price')}>
1347
+ {product.price}
1348
+ </span>
1349
+ )}
1350
+
1351
+ {showPrice &&
1352
+ product.price &&
1353
+ (showRetailPrice || showDiscountBadge) && (
1354
+ <span
1355
+ data-product-card-price-row=""
1356
+ {...partAttrs('card-price-row')}
1357
+ style={{
1358
+ display: 'flex',
1359
+ alignItems: 'center',
1360
+ columnGap: '9px',
1361
+ flexWrap: 'wrap'
1362
+ }}
1363
+ >
1364
+ {showRetailPrice &&
1365
+ product.hasDiscount &&
1366
+ product.retailPrice && (
1367
+ <s
1368
+ data-product-card-retail-price=""
1369
+ {...partAttrs('card-retail-price')}
1370
+ >
1371
+ {product.retailPrice}
1372
+ </s>
1373
+ )}
1374
+ <span
1375
+ data-product-card-price=""
1376
+ {...partAttrs('card-price', {
1377
+ discounted: Boolean(product.hasDiscount)
1378
+ })}
1379
+ >
1380
+ {product.price}
1381
+ </span>
1382
+ {showDiscountBadge &&
1383
+ product.hasDiscount &&
1384
+ product.discountPercent !== undefined && (
1385
+ <span
1386
+ data-product-card-badge=""
1387
+ {...partAttrs('card-badge')}
1388
+ >
1389
+ {`-%${product.discountPercent}`}
1390
+ </span>
1391
+ )}
1392
+ </span>
1393
+ )}
1234
1394
  </NextLink>
1235
1395
  );
1236
1396
 
@@ -107,14 +107,24 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
107
107
  const desktopSlides = clampNumber(slidesConfig.desktop, 1, 8);
108
108
  const mobileSlides = clampNumber(slidesConfig.mobile, 1, 8);
109
109
 
110
+ const slidePeek = getConfigEntry('slide-peek');
111
+ const desktopPeek = parseNumber(
112
+ getResponsiveValue(slidePeek, 'desktop', 0),
113
+ 0
114
+ );
115
+ const mobilePeek = parseNumber(getResponsiveValue(slidePeek, 'mobile', 0), 0);
116
+ const partialVisible = desktopPeek > 0 || mobilePeek > 0;
117
+
110
118
  const carouselResponsive = {
111
119
  desktop: {
112
120
  breakpoint: { max: 4000, min: 768 },
113
- items: desktopItems
121
+ items: desktopItems,
122
+ ...(desktopPeek > 0 ? { partialVisibilityGutter: desktopPeek } : {})
114
123
  },
115
124
  mobile: {
116
125
  breakpoint: { max: 768, min: 0 },
117
- items: mobileItems
126
+ items: mobileItems,
127
+ ...(mobilePeek > 0 ? { partialVisibilityGutter: mobilePeek } : {})
118
128
  }
119
129
  };
120
130
 
@@ -473,6 +483,12 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
473
483
  : undefined
474
484
  );
475
485
 
486
+ const arrowsOutside =
487
+ leftArrowButtonStyle.top !== undefined ||
488
+ leftArrowButtonStyle.bottom !== undefined ||
489
+ rightArrowButtonStyle.top !== undefined ||
490
+ rightArrowButtonStyle.bottom !== undefined;
491
+
476
492
  const handleLeftArrowClick = (e: React.MouseEvent) => {
477
493
  e.preventDefault();
478
494
  e.stopPropagation();
@@ -590,10 +606,11 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
590
606
  ref={carouselRef}
591
607
  key={`${desktopItems}-${mobileItems}-${desktopSlides}-${mobileSlides}-${visibleCount}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`}
592
608
  responsive={carouselResponsive}
609
+ partialVisible={partialVisible}
593
610
  slidesToSlide={currentSlidesToSlide}
594
611
  swipeable={true}
595
612
  draggable={true}
596
- arrows={isDesigner ? false : Boolean(showArrows)}
613
+ arrows={isDesigner ? false : Boolean(showArrows) && !arrowsOutside}
597
614
  showDots={Boolean(showDots)}
598
615
  renderDotsOutside={Boolean(showDots)}
599
616
  infinite={Boolean(useInfinite)}
@@ -604,10 +621,14 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
604
621
  containerClass="slider-carousel"
605
622
  dotListClass="slider-dots"
606
623
  customLeftArrow={
607
- !isDesigner && showArrows ? <CustomLeftArrow /> : undefined
624
+ !isDesigner && showArrows && !arrowsOutside ? (
625
+ <CustomLeftArrow />
626
+ ) : undefined
608
627
  }
609
628
  customRightArrow={
610
- !isDesigner && showArrows ? <CustomRightArrow /> : undefined
629
+ !isDesigner && showArrows && !arrowsOutside ? (
630
+ <CustomRightArrow />
631
+ ) : undefined
611
632
  }
612
633
  >
613
634
  {sortedBlocks.map((childBlock, index) => {
@@ -652,7 +673,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
652
673
  })}
653
674
  </Carousel>
654
675
 
655
- {isDesigner &&
676
+ {(isDesigner || arrowsOutside) &&
656
677
  showArrows &&
657
678
  sortedBlocks.length > 0 &&
658
679
  leftArrowIconBlock &&
@@ -34,6 +34,7 @@ export const PLACEHOLDER_SLUGS = {
34
34
  accountChangePassword: 'account-change-password-body',
35
35
  accountChangeEmail: 'account-change-email-body',
36
36
  accountCoupons: 'account-coupons-body',
37
+ accountLoyalty: 'account-loyalty-body',
37
38
  accountFavourites: 'account-favourites-body',
38
39
  accountFaq: 'account-faq-content',
39
40
  accountContact: 'account-contact-body',
@@ -35,6 +35,7 @@ const DEFAULT_RULES: RouteSlugRule[] = [
35
35
  [/^\/account\/change-password(\/|$)/, PLACEHOLDER_SLUGS.accountChangePassword],
36
36
  [/^\/account\/change-email(\/|$)/, PLACEHOLDER_SLUGS.accountChangeEmail],
37
37
  [/^\/account\/coupons(\/|$)/, PLACEHOLDER_SLUGS.accountCoupons],
38
+ [/^\/account\/loyalty(\/|$)/, PLACEHOLDER_SLUGS.accountLoyalty],
38
39
  [
39
40
  /^\/account\/(favourite-products|favorite-products|favourites|favorites|wishlist)(\/|$)/,
40
41
  PLACEHOLDER_SLUGS.accountFavourites
@@ -127,6 +128,7 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
127
128
  PLACEHOLDER_SLUGS.accountChangePassword,
128
129
  PLACEHOLDER_SLUGS.accountChangeEmail,
129
130
  PLACEHOLDER_SLUGS.accountCoupons,
131
+ PLACEHOLDER_SLUGS.accountLoyalty,
130
132
  PLACEHOLDER_SLUGS.accountFavourites,
131
133
  PLACEHOLDER_SLUGS.accountStockAlerts,
132
134
  PLACEHOLDER_SLUGS.accountNotifications,