@akinon/pz-theme 2.0.37-beta.0 → 2.0.37

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,358 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState } from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { readToggle } from './product-block-utils';
6
+ import { listingPlaceholderStyle } from './listing-context';
7
+ import ThemeBlock, { Block } from '../theme-block';
8
+ import { findItemTemplate, ItemTemplateList } from './item-template';
9
+ import { BlockRendererProps } from './block-renderer-registry';
10
+
11
+ /**
12
+ * Functional content block: a question/answer accordion whose items are
13
+ * authored in the editor as child blocks. Each child block is one entry — its
14
+ * label is the question, its rendered content is the answer — so the answer can
15
+ * be any composition (text, links, images) instead of a plain string property.
16
+ * Properties: searchable, searchPlaceholder, searchAriaLabel, noResultsLabel,
17
+ * emptyLabel, questionFallbackLabel, iconStyle, iconOpen, iconClosed,
18
+ * iconPosition, allowMultiple, openFirst.
19
+ * Parts: list, search, item, question, label, icon, answer, empty.
20
+ */
21
+ const FaqAccordionBlock = ({
22
+ block,
23
+ currentBreakpoint = 'desktop',
24
+ isDesigner = false,
25
+ placeholderId,
26
+ sectionId,
27
+ selectedBlockId
28
+ }: BlockRendererProps) => {
29
+ const prop = (key: string, fallback: string) =>
30
+ String(
31
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
32
+ );
33
+
34
+ const searchable = readToggle(
35
+ block.properties?.searchable,
36
+ currentBreakpoint,
37
+ false
38
+ );
39
+ const searchPlaceholder = prop('searchPlaceholder', 'Search questions');
40
+ const searchAriaLabel = prop('searchAriaLabel', '');
41
+ const noResultsLabel = prop('noResultsLabel', 'No matching questions.');
42
+ const emptyLabel = prop(
43
+ 'emptyLabel',
44
+ 'FAQ accordion (add a block per question)'
45
+ );
46
+ const questionFallbackLabel = prop('questionFallbackLabel', 'Question');
47
+ const iconStyle = prop('iconStyle', 'chevron');
48
+ const iconOpenGlyph = prop('iconOpen', '−');
49
+ const iconClosedGlyph = prop('iconClosed', '+');
50
+ const iconPosition = prop('iconPosition', 'end') === 'start' ? 'start' : 'end';
51
+ const allowMultiple = readToggle(
52
+ block.properties?.allowMultiple,
53
+ currentBreakpoint,
54
+ false
55
+ );
56
+ const openFirst = readToggle(
57
+ block.properties?.openFirst,
58
+ currentBreakpoint,
59
+ false
60
+ );
61
+
62
+ const [query, setQuery] = useState('');
63
+ // null means "the shopper has not touched the accordion yet", which is what
64
+ // lets `openFirst` decide the initial state without a second effect.
65
+ const [openIds, setOpenIds] = useState<string[] | null>(null);
66
+
67
+ const template = findItemTemplate(block, currentBreakpoint);
68
+
69
+ const items = useMemo(
70
+ () =>
71
+ (block.blocks || [])
72
+ // The row-layout child is a template, not a question.
73
+ .filter((child) => !template || child.id !== template.id)
74
+ // Hidden children stay visible to the author so they can be selected
75
+ // and unhidden, exactly as the other container blocks behave.
76
+ .filter((child) => (isDesigner ? true : !child.hidden))
77
+ .sort((a, b) => (a.order || 0) - (b.order || 0)),
78
+ [block.blocks, isDesigner, template]
79
+ );
80
+
81
+ const visibleItems = useMemo(() => {
82
+ const trimmed = query.trim().toLowerCase();
83
+ if (!trimmed) return items;
84
+
85
+ return items.filter((child) =>
86
+ String(child.label || '')
87
+ .toLowerCase()
88
+ .includes(trimmed)
89
+ );
90
+ }, [items, query]);
91
+
92
+ if (items.length === 0) {
93
+ if (!isDesigner) return null;
94
+
95
+ return (
96
+ <div
97
+ data-faq-accordion=""
98
+ data-faq-accordion-state="empty"
99
+ {...partAttrs('empty')}
100
+ style={listingPlaceholderStyle}
101
+ >
102
+ {emptyLabel}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ const firstItemId = items[0]?.id || 'faq-item-0';
108
+ const resolvedOpenIds =
109
+ openIds ?? (openFirst && firstItemId ? [firstItemId] : []);
110
+
111
+ const toggleItem = (itemId: string) =>
112
+ setOpenIds((current) => {
113
+ const open = current ?? (openFirst && firstItemId ? [firstItemId] : []);
114
+
115
+ if (open.includes(itemId)) {
116
+ return open.filter((id) => id !== itemId);
117
+ }
118
+
119
+ // Single-open is the shipped behaviour: opening one entry closes the
120
+ // previous unless the author asked for a multi-open accordion.
121
+ return allowMultiple ? [...open, itemId] : [itemId];
122
+ });
123
+
124
+ const iconGlyph = (isOpen: boolean): string => {
125
+ if (iconStyle === 'plus') return isOpen ? '−' : '+';
126
+ if (iconStyle === 'custom') return isOpen ? iconOpenGlyph : iconClosedGlyph;
127
+ return '⌄';
128
+ };
129
+
130
+ // Only the chevron flips: a plus/minus pair already encodes the state, and
131
+ // rotating it would read as a rotating cross.
132
+ const iconRotates = iconStyle === 'chevron';
133
+
134
+ /**
135
+ * The template lives under this block alongside the questions, so each entry
136
+ * gets its own copy with a distinct id — otherwise every repeated row would
137
+ * share one clone id and selecting a header in the designer would highlight
138
+ * all of them. `styleSourceId` still points at the authored template so the
139
+ * generated stylesheet keeps matching.
140
+ */
141
+ const templateHostFor = (entryIndex: number): Block =>
142
+ ({
143
+ ...block,
144
+ id: `${block.id}-faq-${entryIndex}`,
145
+ blocks: template
146
+ ? [
147
+ {
148
+ ...template,
149
+ id: `${template.id}-${entryIndex}`,
150
+ styleSourceId: template.styleSourceId || template.id
151
+ }
152
+ ]
153
+ : []
154
+ } as Block);
155
+
156
+ return (
157
+ <div
158
+ data-faq-accordion=""
159
+ {...partAttrs('list')}
160
+ style={{
161
+ display: 'flex',
162
+ flexDirection: 'column',
163
+ gap: 'var(--faq-accordion-gap, 8px)'
164
+ }}
165
+ >
166
+ {searchable && (
167
+ <input
168
+ type="search"
169
+ data-faq-accordion-search=""
170
+ {...partAttrs('search')}
171
+ value={query}
172
+ placeholder={searchPlaceholder}
173
+ aria-label={searchAriaLabel || undefined}
174
+ // Filtering is local state only, so it is safe to leave live in the
175
+ // designer — the author can preview the filtered result.
176
+ onChange={(event) => setQuery(event.target.value)}
177
+ style={{
178
+ width: '100%',
179
+ border: '1px solid var(--faq-accordion-border, #e5e7eb)',
180
+ background: 'transparent',
181
+ color: 'inherit',
182
+ padding: '10px 12px',
183
+ outline: 'none'
184
+ }}
185
+ />
186
+ )}
187
+
188
+ {visibleItems.length === 0 ? (
189
+ <div
190
+ data-faq-accordion-empty=""
191
+ {...partAttrs('empty')}
192
+ role="status"
193
+ style={{ opacity: 0.7 }}
194
+ >
195
+ {noResultsLabel}
196
+ </div>
197
+ ) : (
198
+ visibleItems.map((child, index) => {
199
+ const itemId = child.id || `faq-item-${index}`;
200
+ const isOpen = resolvedOpenIds.includes(itemId);
201
+ const question =
202
+ child.label || `${questionFallbackLabel} ${index + 1}`;
203
+
204
+ // Without these the child renders no designer toolbar at all, and a
205
+ // question becomes uneditable: the question text IS `child.label`, so
206
+ // rename is the only way to change it.
207
+ const createActionHandler = (actionType: string) => () => {
208
+ if (window.parent) {
209
+ window.parent.postMessage(
210
+ {
211
+ type: actionType,
212
+ data: { placeholderId, sectionId, blockId: child.id }
213
+ },
214
+ '*'
215
+ );
216
+ }
217
+ };
218
+
219
+ const handleRename = (newLabel: string) => {
220
+ if (window.parent) {
221
+ window.parent.postMessage(
222
+ {
223
+ type: 'RENAME_BLOCK',
224
+ data: {
225
+ placeholderId,
226
+ sectionId,
227
+ blockId: child.id,
228
+ label: newLabel
229
+ }
230
+ },
231
+ '*'
232
+ );
233
+ }
234
+ };
235
+
236
+ const icon = iconStyle !== 'none' && (
237
+ <span
238
+ aria-hidden="true"
239
+ {...partAttrs('icon', { open: isOpen })}
240
+ style={{
241
+ transform: iconRotates
242
+ ? isOpen
243
+ ? 'rotate(180deg)'
244
+ : 'rotate(0deg)'
245
+ : undefined,
246
+ transition: iconRotates ? 'transform 200ms ease' : undefined
247
+ }}
248
+ >
249
+ {iconGlyph(isOpen)}
250
+ </span>
251
+ );
252
+
253
+ // The designer's row layout fills the header while the header itself
254
+ // keeps owning the toggle, so a custom question row does not cost the
255
+ // block the accordion it exists for.
256
+ const questionContent = template ? (
257
+ <ItemTemplateList
258
+ block={templateHostFor(index)}
259
+ items={[
260
+ {
261
+ question,
262
+ label: question,
263
+ name: question,
264
+ number: index + 1,
265
+ index,
266
+ open: isOpen
267
+ }
268
+ ]}
269
+ placeholderId={placeholderId}
270
+ sectionId={sectionId}
271
+ isDesigner={isDesigner}
272
+ selectedBlockId={selectedBlockId}
273
+ currentBreakpoint={currentBreakpoint}
274
+ />
275
+ ) : (
276
+ <span {...partAttrs('label')}>{question}</span>
277
+ );
278
+
279
+ return (
280
+ <div
281
+ key={itemId}
282
+ data-faq-accordion-item=""
283
+ data-faq-accordion-open={isOpen ? 'true' : 'false'}
284
+ {...partAttrs('item', { open: isOpen })}
285
+ style={{
286
+ borderBottom: '1px solid var(--faq-accordion-border, #e5e7eb)'
287
+ }}
288
+ >
289
+ <button
290
+ type="button"
291
+ data-faq-accordion-question=""
292
+ {...partAttrs('question', { open: isOpen })}
293
+ aria-expanded={isOpen}
294
+ onClick={() => toggleItem(itemId)}
295
+ style={{
296
+ display: 'flex',
297
+ alignItems: 'center',
298
+ justifyContent: 'space-between',
299
+ gap: '12px',
300
+ width: '100%',
301
+ padding: '12px 0',
302
+ border: 'none',
303
+ background: 'transparent',
304
+ color: 'inherit',
305
+ font: 'inherit',
306
+ textAlign: 'left',
307
+ cursor: 'pointer'
308
+ }}
309
+ >
310
+ {iconPosition === 'start' && icon}
311
+ {questionContent}
312
+ {iconPosition === 'end' && icon}
313
+ </button>
314
+
315
+ <div
316
+ data-faq-accordion-answer=""
317
+ // Height is animated through grid rows so the answer keeps its
318
+ // natural height instead of needing a measured pixel value.
319
+ style={{
320
+ display: 'grid',
321
+ gridTemplateRows: isOpen ? '1fr' : '0fr',
322
+ transition: 'grid-template-rows 200ms ease'
323
+ }}
324
+ >
325
+ <div style={{ overflow: 'hidden' }}>
326
+ <div
327
+ {...partAttrs('answer', { open: isOpen })}
328
+ style={{ paddingBottom: '12px' }}
329
+ >
330
+ <ThemeBlock
331
+ block={child}
332
+ placeholderId={placeholderId}
333
+ sectionId={sectionId}
334
+ isDesigner={isDesigner}
335
+ isSelected={selectedBlockId === child.id}
336
+ selectedBlockId={selectedBlockId}
337
+ currentBreakpoint={currentBreakpoint}
338
+ onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
339
+ onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
340
+ onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
341
+ onToggleVisibility={createActionHandler(
342
+ 'TOGGLE_BLOCK_VISIBILITY'
343
+ )}
344
+ onDelete={createActionHandler('DELETE_BLOCK')}
345
+ onRename={handleRename}
346
+ />
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ );
352
+ })
353
+ )}
354
+ </div>
355
+ );
356
+ };
357
+
358
+ export default FaqAccordionBlock;
@@ -0,0 +1,252 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useSession } from 'next-auth/react';
6
+ import {
7
+ useAddFavoriteMutation,
8
+ useGetFavoritesQuery,
9
+ useRemoveFavoriteMutation
10
+ } from '@akinon/next/data/client/wishlist';
11
+ import { getResponsiveValue, partAttrs } from '../utils';
12
+ import { BlockRendererProps } from './block-renderer-registry';
13
+ import {
14
+ DesignerPlaceholder,
15
+ pickProductField,
16
+ readToggle,
17
+ useThemeProduct
18
+ } from './product-block-utils';
19
+
20
+ const FAVORITES_PAGE_SIZE = 100;
21
+
22
+ const ALLOWED_ICONS = ['heart', 'star', 'bookmark'];
23
+ const ALLOWED_LABEL_POSITIONS = ['before', 'after'];
24
+
25
+ const ICON_PATHS: Record<string, string> = {
26
+ heart: 'M12 20s-7-4.35-7-9a4 4 0 0 1 7-2.65A4 4 0 0 1 19 11c0 4.65-7 9-7 9z',
27
+ star: 'M12 4l2.5 5.2 5.5.8-4 3.9 1 5.6-5-2.7-5 2.7 1-5.6-4-3.9 5.5-.8L12 4z',
28
+ bookmark: 'M7 4h10a1 1 0 0 1 1 1v15l-6-3.5L6 20V5a1 1 0 0 1 1-1z'
29
+ };
30
+
31
+ /**
32
+ * Extra props land on the <svg> itself rather than on a wrapper: the icon is
33
+ * the element a brand styles, and wrapping it would change the markup every
34
+ * published composition already renders.
35
+ */
36
+ const FavoriteIcon = ({
37
+ filled,
38
+ name,
39
+ size,
40
+ ...rest
41
+ }: {
42
+ filled: boolean;
43
+ name: string;
44
+ size: number;
45
+ } & React.SVGProps<SVGSVGElement>) => (
46
+ <svg
47
+ width={size}
48
+ height={size}
49
+ viewBox="0 0 24 24"
50
+ fill={filled ? 'currentColor' : 'none'}
51
+ stroke="currentColor"
52
+ strokeWidth="1.5"
53
+ aria-hidden="true"
54
+ {...rest}
55
+ >
56
+ <path d={ICON_PATHS[name] || ICON_PATHS.heart} />
57
+ </svg>
58
+ );
59
+
60
+ /**
61
+ * Functional commerce block: favourite (wishlist) toggle for the ambient
62
+ * product — the product on the page on a PDP, and the row's own product when
63
+ * the block sits inside a repeated product card. Favourites are user-scoped,
64
+ * so guests get a link to the login page instead of a mutation they cannot
65
+ * complete. The editor owns labels, the icon, placement and — through named
66
+ * parts — the styling in both states.
67
+ *
68
+ * Both mutations are addressed by id: adding needs the PRODUCT's pk, removing
69
+ * needs the favourite ENTRY's pk, which is looked up in the favourites list.
70
+ * The item scope rewrites a row's `pk` to the product's, so a card carrying a
71
+ * favourite entry id cannot add the wrong thing.
72
+ *
73
+ * Properties: loginHref, showLabel, addLabel, removeLabel, activeColor, icon,
74
+ * iconSize, labelPosition.
75
+ */
76
+ const FavoriteButtonBlock = ({
77
+ block,
78
+ currentBreakpoint = 'desktop',
79
+ isDesigner = false
80
+ }: BlockRendererProps) => {
81
+ const product = useThemeProduct();
82
+ const { status } = useSession();
83
+ const isAuthenticated = status === 'authenticated';
84
+
85
+ const productPk = Number(pickProductField(product, 'pk'));
86
+ const hasProduct = Number.isFinite(productPk) && productPk > 0;
87
+
88
+ // The v1 core has no "is this product favourited" endpoint, so the current
89
+ // state is derived from the favourites list. It is skipped for guests (the
90
+ // endpoint 401s) and in the designer (no shopper session to reflect).
91
+ const { data: favorites } = useGetFavoritesQuery(
92
+ { limit: FAVORITES_PAGE_SIZE },
93
+ { skip: !isAuthenticated || !hasProduct || isDesigner }
94
+ );
95
+ const [addFavorite, addState] = useAddFavoriteMutation();
96
+ const [removeFavorite, removeState] = useRemoveFavoriteMutation();
97
+
98
+ const prop = (key: string, fallback: string) =>
99
+ String(
100
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
101
+ );
102
+
103
+ const loginHref = prop('loginHref', '/auth');
104
+ const addLabel = prop('addLabel', 'Add to Favourites');
105
+ const removeLabel = prop('removeLabel', 'Remove from Favourites');
106
+ const activeColor = prop('activeColor', '');
107
+ const showLabel = readToggle(
108
+ block.properties?.showLabel,
109
+ currentBreakpoint,
110
+ false
111
+ );
112
+ const iconValue = prop('icon', 'heart');
113
+ const icon = ALLOWED_ICONS.includes(iconValue) ? iconValue : 'heart';
114
+ const iconSizeValue = Number(
115
+ getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 24)
116
+ );
117
+ const iconSize =
118
+ Number.isFinite(iconSizeValue) && iconSizeValue > 0 ? iconSizeValue : 24;
119
+ const labelPositionValue = prop('labelPosition', 'after');
120
+ const labelPosition = ALLOWED_LABEL_POSITIONS.includes(labelPositionValue)
121
+ ? labelPositionValue
122
+ : 'after';
123
+
124
+ if (!hasProduct) {
125
+ return isDesigner ? (
126
+ <DesignerPlaceholder
127
+ label="Favourite button (no product)"
128
+ attribute="data-favorite-button"
129
+ />
130
+ ) : null;
131
+ }
132
+
133
+ const baseStyle: React.CSSProperties = {
134
+ display: 'inline-flex',
135
+ alignItems: 'center',
136
+ gap: '6px',
137
+ padding: 0,
138
+ margin: 0,
139
+ border: 'none',
140
+ background: 'transparent',
141
+ color: 'inherit',
142
+ font: 'inherit',
143
+ textDecoration: 'none'
144
+ };
145
+
146
+ const renderContent = (filled: boolean, text: string) => {
147
+ const iconNode = (
148
+ <FavoriteIcon
149
+ key="icon"
150
+ filled={filled}
151
+ name={icon}
152
+ size={iconSize}
153
+ data-favorite-icon={filled ? 'active' : 'default'}
154
+ {...partAttrs('icon', { selected: filled })}
155
+ />
156
+ );
157
+
158
+ if (!showLabel) return iconNode;
159
+
160
+ const labelNode = (
161
+ <span
162
+ key="label"
163
+ data-favorite-label=""
164
+ {...partAttrs('label', { selected: filled })}
165
+ >
166
+ {text}
167
+ </span>
168
+ );
169
+
170
+ return labelPosition === 'before'
171
+ ? [labelNode, iconNode]
172
+ : [iconNode, labelNode];
173
+ };
174
+
175
+ if (!isAuthenticated) {
176
+ return (
177
+ <NextLink
178
+ href={loginHref}
179
+ data-favorite-button=""
180
+ data-favorite-state="guest"
181
+ {...partAttrs('button')}
182
+ aria-label={addLabel}
183
+ onClick={(event) => {
184
+ if (isDesigner) event.preventDefault();
185
+ }}
186
+ style={{ ...baseStyle, cursor: 'pointer' }}
187
+ >
188
+ {renderContent(false, addLabel)}
189
+ </NextLink>
190
+ );
191
+ }
192
+
193
+ const favorite = (favorites?.results || []).find(
194
+ (item) => Number(item?.product?.pk) === productPk
195
+ );
196
+ const isFavorite = Boolean(favorite);
197
+ const busy = addState.isLoading || removeState.isLoading;
198
+
199
+ const handleToggle = async (event: React.MouseEvent<HTMLButtonElement>) => {
200
+ // A product card is usually one big link, and the heart sits inside it.
201
+ // Cancelling the click stops the card navigating away mid-mutation; on a
202
+ // PDP, where nothing wraps the button, this is a no-op.
203
+ event.preventDefault();
204
+
205
+ // Only stop the bubble when there IS a link to stop. Stopping it always
206
+ // would also swallow a brand's delegated card handler — the analytics
207
+ // click, the quick-view opener — which has nothing to do with navigation.
208
+ if (
209
+ event.currentTarget instanceof Element &&
210
+ event.currentTarget.closest('a')
211
+ ) {
212
+ event.stopPropagation();
213
+ }
214
+
215
+ if (isDesigner || busy) return;
216
+
217
+ try {
218
+ if (favorite) {
219
+ await removeFavorite(favorite.pk).unwrap();
220
+ } else {
221
+ await addFavorite(productPk).unwrap();
222
+ }
223
+ } catch {
224
+ // Commerce rejected the toggle; the list query stays authoritative so
225
+ // the icon simply keeps its previous state.
226
+ }
227
+ };
228
+
229
+ return (
230
+ <button
231
+ type="button"
232
+ data-favorite-button=""
233
+ data-favorite-state={isFavorite ? 'active' : 'default'}
234
+ {...partAttrs('button', { selected: isFavorite, loading: busy })}
235
+ aria-pressed={isFavorite}
236
+ aria-label={isFavorite ? removeLabel : addLabel}
237
+ aria-busy={busy}
238
+ disabled={busy}
239
+ onClick={handleToggle}
240
+ style={{
241
+ ...baseStyle,
242
+ cursor: busy ? 'progress' : 'pointer',
243
+ color: isFavorite && activeColor ? activeColor : 'inherit',
244
+ opacity: busy ? 0.6 : 1
245
+ }}
246
+ >
247
+ {renderContent(isFavorite, isFavorite ? removeLabel : addLabel)}
248
+ </button>
249
+ );
250
+ };
251
+
252
+ export default FavoriteButtonBlock;