@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.
- package/CHANGELOG.md +4 -4
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- 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;
|