@akinon/pz-theme 2.0.69-beta.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 +5 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/normalize-listing-product.test.ts +78 -0
- package/src/blocks/add-to-cart-block.tsx +20 -8
- package/src/blocks/favourites-grid-block.tsx +32 -25
- package/src/blocks/listing-context.ts +13 -0
- package/src/chrome/__tests__/live-wishlist-page-context.test.tsx +155 -0
- package/src/chrome/live-wishlist-page-context.tsx +54 -0
- package/src/index.ts +1 -0
- package/src/sections/posts-slider-section.tsx +32 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.69
|
|
3
|
+
## 2.0.69
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
- @akinon/
|
|
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
|
|
10
11
|
|
|
11
12
|
## 2.0.68
|
|
12
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
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
34
|
-
height=
|
|
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,
|
|
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:
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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={{
|
|
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:
|
|
560
|
-
fontWeight:
|
|
585
|
+
fontSize: `${titleSize}px`,
|
|
586
|
+
fontWeight: titleWeight,
|
|
561
587
|
lineHeight: 1.4,
|
|
562
|
-
color:
|
|
563
|
-
textTransform:
|
|
588
|
+
color: titleColor,
|
|
589
|
+
textTransform:
|
|
590
|
+
titleTransform as React.CSSProperties['textTransform'],
|
|
591
|
+
...(titleFont ? { fontFamily: titleFont } : null)
|
|
564
592
|
}}
|
|
565
593
|
>
|
|
566
594
|
{sliderTitle}
|