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

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,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.69-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.69-rc.0
33
- - @akinon/theme-contract@2.0.69-rc.0
3
+ ## 2.0.69
4
+
5
+ ### Patch Changes
6
+
7
+ - 877f938: ZERO-4837: Enhance wishlist functionality and styling across components
8
+ - Updated dependencies [877f938]
9
+ - @akinon/next@2.0.69
10
+ - @akinon/theme-contract@2.0.69
34
11
 
35
12
  ## 2.0.68
36
13
 
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.69-rc.0",
4
+ "version": "2.0.69",
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.69-rc.0",
11
+ "@akinon/next": "2.0.69",
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.69-rc.0",
16
+ "@akinon/theme-contract": "2.0.69",
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.69-rc.0",
23
+ "@akinon/next": "2.0.69",
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",
@@ -0,0 +1,78 @@
1
+ import { normalizeListingProduct } from '../listing-context';
2
+
3
+ /**
4
+ * The favourites grid publishes wishlist entries through this normalizer so a
5
+ * card designed against a product-grid row binds identically over a
6
+ * favourites row. These cases pin the fields that card actually needs:
7
+ * keyed attributes (Renk/Beden rows), the discount chip and the
8
+ * "Sepette …" basket-offer chip.
9
+ */
10
+ describe('normalizeListingProduct', () => {
11
+ const wishlistProduct = {
12
+ pk: 42,
13
+ name: 'GANT Erkek Beyaz Regular Fit Bisiklet Yaka T-Shirt',
14
+ absolute_url: '/p/42',
15
+ image: '',
16
+ productimage_set: [{ image: 'https://cdn.example/1.jpg' }],
17
+ price: '5499.00',
18
+ retail_price: '7856.00',
19
+ currency_type: 'try',
20
+ in_stock: true,
21
+ attributes: {
22
+ integration_renk: 'Beyaz',
23
+ integration_beden: 'S'
24
+ },
25
+ attributes_kwargs: {
26
+ integration_renk: { value: 'Beyaz' },
27
+ integration_beden: { value: 'S' }
28
+ },
29
+ basket_offers: [
30
+ {
31
+ pk: 7,
32
+ label: 'Sepette 2.999 TL',
33
+ kwargs: { show_benefit_products: false },
34
+ listing_kwargs: { discounted_total_price: '2999.00' }
35
+ }
36
+ ]
37
+ };
38
+
39
+ it('publishes the keyed attributes a Renk/Beden row binds', () => {
40
+ const row = normalizeListingProduct(wishlistProduct, 0, {
41
+ locale: 'tr',
42
+ currency: 'try'
43
+ });
44
+
45
+ expect(row?.attributes?.integration_renk).toBe('Beyaz');
46
+ expect(row?.attributes?.integration_beden).toBe('S');
47
+ });
48
+
49
+ it('derives the discount chip off the struck retail price', () => {
50
+ const row = normalizeListingProduct(wishlistProduct, 0, {
51
+ locale: 'tr',
52
+ currency: 'try'
53
+ });
54
+
55
+ expect(row?.hasDiscount).toBe(true);
56
+ expect(row?.discountPercent).toBe(30);
57
+ });
58
+
59
+ it('publishes the basket offer as a bindable label and price', () => {
60
+ const row = normalizeListingProduct(wishlistProduct, 0, {
61
+ locale: 'tr',
62
+ currency: 'try'
63
+ });
64
+
65
+ expect(row?.basketOffers?.[0]?.label).toBe('Sepette 2.999 TL');
66
+ expect(row?.basketOffers?.[0]?.price).toContain('2.999');
67
+ });
68
+
69
+ it('falls back to the image set when the flat image field is empty', () => {
70
+ const row = normalizeListingProduct(wishlistProduct, 0, {
71
+ locale: 'tr',
72
+ currency: 'try'
73
+ });
74
+
75
+ // The favourites card reads `image || images[0]`.
76
+ expect(row?.images?.[0]).toBe('https://cdn.example/1.jpg');
77
+ });
78
+ });
@@ -19,19 +19,20 @@ import {
19
19
 
20
20
  const DEFAULT_FEEDBACK_TIMEOUT = 2000;
21
21
 
22
- const ALLOWED_ICONS = ['cart', 'bag', 'plus'];
22
+ const ALLOWED_ICONS = ['cart', 'bag', 'plus', 'arrow'];
23
23
  const ALLOWED_ICON_POSITIONS = ['before', 'after'];
24
24
 
25
25
  const ICON_PATHS: Record<string, string> = {
26
26
  cart: 'M3 4h2l2.4 10.4a2 2 0 0 0 2 1.6h7.2a2 2 0 0 0 2-1.6L20.5 8H6M9 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm9 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
27
27
  bag: 'M6 8h12l-1 12H7L6 8Zm3 0V6a3 3 0 0 1 6 0v2',
28
- plus: 'M12 5v14M5 12h14'
28
+ plus: 'M12 5v14M5 12h14',
29
+ arrow: 'M4 12h15M13 6l6 6-6 6'
29
30
  };
30
31
 
31
- const CartIcon = ({ name }: { name: string }) => (
32
+ const CartIcon = ({ name, size = 18 }: { name: string; size?: number }) => (
32
33
  <svg
33
- width="18"
34
- height="18"
34
+ width={size}
35
+ height={size}
35
36
  viewBox="0 0 24 24"
36
37
  fill="none"
37
38
  stroke="currentColor"
@@ -80,7 +81,8 @@ const readAvailableStock = (product: ThemeProduct | null): number | null => {
80
81
  *
81
82
  * Parts: button, label, icon, row, quantity, quantity-button, quantity-input.
82
83
  * Properties: label, addingLabel, addedLabel, errorLabel, outOfStockLabel,
83
- * fullWidth, quantity, showIcon, icon, iconPosition, hideWhenOutOfStock,
84
+ * fullWidth, quantity, showIcon, icon, iconSize, iconGap, iconPosition,
85
+ * hideWhenOutOfStock,
84
86
  * feedbackDuration, showQuantitySelector, quantityMin, quantityMax,
85
87
  * quantityStep, quantityPosition, decreaseLabel, increaseLabel,
86
88
  * decreaseAriaLabel, increaseAriaLabel.
@@ -128,6 +130,16 @@ const AddToCartBlock = ({
128
130
  );
129
131
  const iconValue = prop('icon', 'cart');
130
132
  const icon = ALLOWED_ICONS.includes(iconValue) ? iconValue : 'cart';
133
+ const iconSizeValue = Number(
134
+ getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 18)
135
+ );
136
+ const iconSize =
137
+ Number.isFinite(iconSizeValue) && iconSizeValue > 0 ? iconSizeValue : 18;
138
+ const iconGapValue = Number(
139
+ getResponsiveValue(block.properties?.iconGap, currentBreakpoint, 8)
140
+ );
141
+ const iconGap =
142
+ Number.isFinite(iconGapValue) && iconGapValue >= 0 ? iconGapValue : 8;
131
143
  const iconPositionValue = prop('iconPosition', 'before');
132
144
  const iconPosition = ALLOWED_ICON_POSITIONS.includes(iconPositionValue)
133
145
  ? iconPositionValue
@@ -282,7 +294,7 @@ const AddToCartBlock = ({
282
294
  {...partAttrs('icon')}
283
295
  style={{ display: 'inline-flex', flex: '0 0 auto' }}
284
296
  >
285
- <CartIcon name={icon} />
297
+ <CartIcon name={icon} size={iconSize} />
286
298
  </span>
287
299
  ) : null;
288
300
 
@@ -301,7 +313,7 @@ const AddToCartBlock = ({
301
313
  display: 'inline-flex',
302
314
  alignItems: 'center',
303
315
  justifyContent: 'center',
304
- gap: '8px',
316
+ gap: `${iconGap}px`,
305
317
  width: fullWidth ? '100%' : 'auto',
306
318
  padding: 0,
307
319
  margin: 0,
@@ -16,7 +16,7 @@ import {
16
16
  MOBILE_MEDIA_QUERY_COMPACT,
17
17
  TABLET_MEDIA_QUERY_COMPACT
18
18
  } from '../utils/breakpoints';
19
- import { formatMoney, parseAmount } from './commerce-format';
19
+ import { normalizeListingProduct } from './listing-context';
20
20
  import { hasItemTemplate, ItemTemplateList } from './item-template';
21
21
  import { BlockRendererProps } from './block-renderer-registry';
22
22
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
@@ -156,6 +156,8 @@ const FavouritesGridBlock = ({
156
156
  const outOfStockLabel = text('outOfStockLabel', 'Out of Stock');
157
157
  const removeLabel = text('removeLabel', 'Remove from favourites');
158
158
  const emptyLabel = text('emptyLabel', '');
159
+ // Empty keeps the CSS-variable fallback every published grid renders with.
160
+ const gridGap = text('gridGap', '').trim();
159
161
  const removeIcon = REMOVE_ICONS[text('removeIcon', 'x')] || REMOVE_ICONS.x;
160
162
  const imageRatio = text('imageRatio', 'auto');
161
163
  const rawNameLines = Math.round(
@@ -218,40 +220,42 @@ const FavouritesGridBlock = ({
218
220
  }
219
221
  };
220
222
 
221
- /** Everything the card (and a designer's own layout) reads off one entry. */
223
+ /**
224
+ * Everything the card (and a designer's own layout) reads off one entry.
225
+ *
226
+ * The product side is the listing row model, so a card designed against the
227
+ * product grid binds identically here — `attributes.*` (Renk/Beden rows),
228
+ * `discountPercent`, `basketOffers` ("Sepette …" chips), `colors`. On top of
229
+ * it ride the wishlist-only fields every published favourites card already
230
+ * binds: `pk` is the wishlist ENTRY (what remove calls), `productPk` the
231
+ * product behind it, `href` the alias `url` has always had.
232
+ */
222
233
  const describe = (favourite: FavouriteLike, index: number) => {
223
234
  const product = favourite.product as FavouriteProductLike;
224
- const name = product.name || '';
225
- const href = product.absolute_url || '';
226
- const image = product.image || product.productimage_set?.[0]?.image || '';
227
- // Commerce sends the currency per product; the active currency is only
228
- // the fallback for payloads that omit it.
229
- const currency = product.currency_type || localeCurrency;
230
- const price = formatMoney(product.price, { locale, currency });
231
- const retailPrice = formatMoney(product.retail_price, {
235
+ const normalized = normalizeListingProduct(product, index, {
232
236
  locale,
233
- currency
237
+ // The normalizer prefers the product's own currency_type; the active
238
+ // currency is only the fallback for payloads that omit it.
239
+ currency: localeCurrency
234
240
  });
235
- // Parsed with the shared parser, not Number(): "1.299,90" is NaN to
236
- // Number() and would silently drop the discount flag.
237
- const numericPrice = parseAmount(product.price);
238
- const numericRetail = parseAmount(product.retail_price);
239
- const hasDiscount =
240
- numericPrice !== null &&
241
- numericRetail !== null &&
242
- numericRetail > numericPrice;
241
+ const href = normalized?.url && normalized.url !== '#'
242
+ ? normalized.url
243
+ : product.absolute_url || '';
243
244
 
244
245
  return {
246
+ ...(normalized ?? {}),
245
247
  key: String(favourite.pk ?? product.pk ?? index),
246
248
  pk: favourite.pk ?? '',
247
249
  productPk: product.pk ?? '',
248
- name,
250
+ name: normalized?.name || product.name || '',
249
251
  url: href,
250
252
  href,
251
- image,
252
- price,
253
- retailPrice,
254
- hasDiscount,
253
+ image: normalized?.image || normalized?.images?.[0] || '',
254
+ price: normalized?.price || '',
255
+ retailPrice: normalized?.retailPrice || '',
256
+ hasDiscount: normalized?.hasDiscount ?? false,
257
+ // Deliberately NOT the normalizer's tri-state stock: unknown stock must
258
+ // not disable the add-to-cart CTA — commerce still validates on add.
255
259
  inStock: isInStock(product)
256
260
  };
257
261
  };
@@ -346,7 +350,10 @@ const FavouritesGridBlock = ({
346
350
  data-favourites-grid-id={gridId}
347
351
  aria-busy={isBusy}
348
352
  {...partAttrs('list')}
349
- style={{ display: 'grid', gap: 'var(--favourites-grid-gap, 16px)' }}
353
+ style={{
354
+ display: 'grid',
355
+ gap: gridGap || 'var(--favourites-grid-gap, 16px)'
356
+ }}
350
357
  >
351
358
  <style>
352
359
  {`[data-favourites-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
@@ -671,6 +671,19 @@ const normalizeProduct = (
671
671
  };
672
672
  };
673
673
 
674
+ /**
675
+ * The row model above, for lists that are not listings. The favourites grid
676
+ * publishes its wishlist entries through this so a card designed against a
677
+ * product-grid row (`attributes.*`, `discountPercent`, `basketOffers`,
678
+ * `colors`) binds identically over a favourites row — one card design, both
679
+ * grids.
680
+ */
681
+ export const normalizeListingProduct = (
682
+ entry: unknown,
683
+ index: number,
684
+ options: ListingLocalizationOptions
685
+ ): ListingProduct | null => normalizeProduct(entry, index, options);
686
+
674
687
  const normalizeFacet = (entry: unknown, index: number): ListingFacet | null => {
675
688
  const facet = toRecord(entry);
676
689
  const data = toRecord(facet.data);
@@ -0,0 +1,155 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ import { useThemePageContext } from '../../theme-page-context';
5
+ import { evaluateVisibilityRules } from '../../utils/visibility-rules';
6
+
7
+ const useGetFavoritesQuery = jest.fn();
8
+
9
+ jest.mock('@akinon/next/data/client/wishlist', () => ({
10
+ useGetFavoritesQuery: (...args: unknown[]) => useGetFavoritesQuery(...args)
11
+ }));
12
+
13
+ const useSession = jest.fn();
14
+
15
+ jest.mock('next-auth/react', () => ({
16
+ useSession: () => useSession()
17
+ }));
18
+
19
+ // Imported after the mocks so the provider picks them up.
20
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
21
+ const {
22
+ LiveWishlistPageContext
23
+ } = require('../live-wishlist-page-context') as typeof import('../live-wishlist-page-context');
24
+
25
+ /**
26
+ * Stands in for the section filter: the same rule a designer authors on the
27
+ * "no favourites yet" sections, evaluated against what the provider publishes.
28
+ */
29
+ const EmptyStateProbe = () => {
30
+ const pageContext = useThemePageContext();
31
+ const wishlist = (pageContext as { wishlist?: unknown } | null)?.wishlist as
32
+ | { count: number; isEmpty: boolean }
33
+ | undefined;
34
+
35
+ const showsEmptyState = evaluateVisibilityRules(
36
+ {
37
+ enabled: true,
38
+ dataConditions: [
39
+ { path: 'wishlist.isEmpty', operator: 'equals', value: 'true' }
40
+ ]
41
+ },
42
+ {
43
+ authState: 'authenticated',
44
+ breakpoint: 'desktop',
45
+ locale: 'tr',
46
+ currency: 'TRY',
47
+ pathname: '/account/favourite-products',
48
+ pageContext
49
+ }
50
+ );
51
+
52
+ return (
53
+ <span data-testid="probe">
54
+ {wishlist ? `${wishlist.count}:${wishlist.isEmpty}` : 'unknown'}:
55
+ {showsEmptyState ? 'empty-state' : 'held'}
56
+ </span>
57
+ );
58
+ };
59
+
60
+ describe('LiveWishlistPageContext', () => {
61
+ beforeEach(() => {
62
+ useGetFavoritesQuery.mockReset();
63
+ useSession.mockReset();
64
+ });
65
+
66
+ it('publishes nothing while the session is still resolving', () => {
67
+ useSession.mockReturnValue({ status: 'loading' });
68
+ useGetFavoritesQuery.mockReturnValue({ data: undefined, isSuccess: false });
69
+
70
+ render(
71
+ <LiveWishlistPageContext>
72
+ <EmptyStateProbe />
73
+ </LiveWishlistPageContext>
74
+ );
75
+
76
+ // Conditions fail closed on a missing path: the empty-state sections hold
77
+ // back rather than flash before the wishlist is known.
78
+ expect(screen.getByTestId('probe')).toHaveTextContent('unknown:held');
79
+ });
80
+
81
+ it('resolves guests to an empty wishlist without touching the endpoint', () => {
82
+ useSession.mockReturnValue({ status: 'unauthenticated' });
83
+ useGetFavoritesQuery.mockReturnValue({ data: undefined, isSuccess: false });
84
+
85
+ render(
86
+ <LiveWishlistPageContext>
87
+ <EmptyStateProbe />
88
+ </LiveWishlistPageContext>
89
+ );
90
+
91
+ expect(screen.getByTestId('probe')).toHaveTextContent('0:true:empty-state');
92
+ expect(useGetFavoritesQuery).toHaveBeenCalledWith(
93
+ { limit: 1, page: 1 },
94
+ { skip: true }
95
+ );
96
+ });
97
+
98
+ it('holds back until the favourites query answers', () => {
99
+ useSession.mockReturnValue({ status: 'authenticated' });
100
+ useGetFavoritesQuery.mockReturnValue({ data: undefined, isSuccess: false });
101
+
102
+ render(
103
+ <LiveWishlistPageContext>
104
+ <EmptyStateProbe />
105
+ </LiveWishlistPageContext>
106
+ );
107
+
108
+ expect(screen.getByTestId('probe')).toHaveTextContent('unknown:held');
109
+ });
110
+
111
+ it('publishes the live count and hides the empty state when favourites exist', () => {
112
+ useSession.mockReturnValue({ status: 'authenticated' });
113
+ useGetFavoritesQuery.mockReturnValue({
114
+ data: { count: 7, results: [] },
115
+ isSuccess: true
116
+ });
117
+
118
+ render(
119
+ <LiveWishlistPageContext>
120
+ <EmptyStateProbe />
121
+ </LiveWishlistPageContext>
122
+ );
123
+
124
+ expect(screen.getByTestId('probe')).toHaveTextContent('7:false:held');
125
+ });
126
+
127
+ it('shows the empty state when the last favourite is removed', () => {
128
+ useSession.mockReturnValue({ status: 'authenticated' });
129
+ useGetFavoritesQuery.mockReturnValue({
130
+ data: { count: 1, results: [] },
131
+ isSuccess: true
132
+ });
133
+
134
+ const { rerender } = render(
135
+ <LiveWishlistPageContext>
136
+ <EmptyStateProbe />
137
+ </LiveWishlistPageContext>
138
+ );
139
+ expect(screen.getByTestId('probe')).toHaveTextContent('1:false:held');
140
+
141
+ // What the RTK cache write after the 'Favorite' tag invalidation looks
142
+ // like from here.
143
+ useGetFavoritesQuery.mockReturnValue({
144
+ data: { count: 0, results: [] },
145
+ isSuccess: true
146
+ });
147
+ rerender(
148
+ <LiveWishlistPageContext>
149
+ <EmptyStateProbe />
150
+ </LiveWishlistPageContext>
151
+ );
152
+
153
+ expect(screen.getByTestId('probe')).toHaveTextContent('0:true:empty-state');
154
+ });
155
+ });
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import { PropsWithChildren, useMemo } from 'react';
4
+ import { useSession } from 'next-auth/react';
5
+ import { useGetFavoritesQuery } from '@akinon/next/data/client/wishlist';
6
+
7
+ import { ThemePageContextProvider } from '../theme-page-context';
8
+
9
+ /**
10
+ * Publishes the shopper's wishlist summary as page context for the slot below,
11
+ * so a composed favourites screen can split into an empty and a filled state
12
+ * with visibility rules: `wishlist.isEmpty equals true` on the "no favourites
13
+ * yet" sections, `wishlist.count gt 0` on the grid section.
14
+ *
15
+ * The wishlist is session-scoped and has no server data module, so — like
16
+ * LiveBasketPageContext — this subscribes the RTK Query cache the favourites
17
+ * blocks already invalidate through the 'Favorite' tag: removing the last
18
+ * favourite refetches this entry and the empty state appears without a reload.
19
+ * Only the count is read, so the query asks for a single row.
20
+ *
21
+ * The value stays null until the answer is known. Visibility data conditions
22
+ * fail closed on a missing path, so conditional sections hold back for that
23
+ * first paint instead of flashing the wrong state. Guests resolve immediately:
24
+ * no session means no favourites — and the account layout redirects them
25
+ * anyway, so this only decides what renders during that redirect.
26
+ */
27
+ export const LiveWishlistPageContext = ({ children }: PropsWithChildren) => {
28
+ const { status } = useSession();
29
+ const isAuthenticated = status === 'authenticated';
30
+
31
+ const { data, isSuccess } = useGetFavoritesQuery(
32
+ { limit: 1, page: 1 },
33
+ { skip: !isAuthenticated }
34
+ );
35
+
36
+ const value = useMemo(() => {
37
+ if (status === 'loading') return null;
38
+
39
+ if (!isAuthenticated) {
40
+ return { wishlist: { count: 0, isEmpty: true } };
41
+ }
42
+
43
+ if (!isSuccess) return null;
44
+
45
+ const count = Number(data?.count) || 0;
46
+ return { wishlist: { count, isEmpty: count === 0 } };
47
+ }, [status, isAuthenticated, isSuccess, data]);
48
+
49
+ return (
50
+ <ThemePageContextProvider value={value}>
51
+ {children}
52
+ </ThemePageContextProvider>
53
+ );
54
+ };
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  } from './chrome/scroll-state';
39
39
  export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
40
40
  export { LiveBasketPageContext } from './chrome/live-basket-page-context';
41
+ export { LiveWishlistPageContext } from './chrome/live-wishlist-page-context';
41
42
  export {
42
43
  LiveOrderSuccessPageContext,
43
44
  LiveAccountOrderPageContext
@@ -478,6 +478,31 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
478
478
  typeof section.properties?.['slider-title'] === 'string'
479
479
  ? section.properties['slider-title']
480
480
  : '';
481
+ // Title styling is composition-driven with the values the title has always
482
+ // rendered with as defaults, so published sliders keep their exact look.
483
+ const titleColor = String(
484
+ getResponsiveValue(getConfigEntry('title-color'), currentBreakpoint, '') ||
485
+ '#000000'
486
+ );
487
+ const titleSizeRaw = Number(
488
+ getResponsiveValue(getConfigEntry('title-size'), currentBreakpoint, 14)
489
+ );
490
+ const titleSize =
491
+ Number.isFinite(titleSizeRaw) && titleSizeRaw > 0 ? titleSizeRaw : 14;
492
+ const titleWeight = String(
493
+ getResponsiveValue(getConfigEntry('title-weight'), currentBreakpoint, '') ||
494
+ '400'
495
+ );
496
+ const titleFont = String(
497
+ getResponsiveValue(getConfigEntry('title-font'), currentBreakpoint, '') || ''
498
+ ).trim();
499
+ const titleTransform = String(
500
+ getResponsiveValue(
501
+ getConfigEntry('title-transform'),
502
+ currentBreakpoint,
503
+ ''
504
+ ) || 'uppercase'
505
+ );
481
506
  const useTopNav = showArrows && arrowPosition === 'top-right';
482
507
  const carouselRef = React.useRef<Carousel | null>(null);
483
508
 
@@ -554,13 +579,16 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
554
579
  >
555
580
  {sliderTitle && (
556
581
  <p
582
+ data-slider-title=""
557
583
  style={{
558
584
  margin: 0,
559
- fontSize: '14px',
560
- fontWeight: 400,
585
+ fontSize: `${titleSize}px`,
586
+ fontWeight: titleWeight,
561
587
  lineHeight: 1.4,
562
- color: '#000000',
563
- textTransform: 'uppercase'
588
+ color: titleColor,
589
+ textTransform:
590
+ titleTransform as React.CSSProperties['textTransform'],
591
+ ...(titleFont ? { fontFamily: titleFont } : null)
564
592
  }}
565
593
  >
566
594
  {sliderTitle}