@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,348 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useSession } from 'next-auth/react';
|
|
5
|
+
import {
|
|
6
|
+
useGetAddressesQuery,
|
|
7
|
+
useRemoveAddressMutation,
|
|
8
|
+
useSetDefaultAddressMutation
|
|
9
|
+
} from '@akinon/next/data/client/address';
|
|
10
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
11
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
12
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
13
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
14
|
+
|
|
15
|
+
interface AddressLike {
|
|
16
|
+
pk?: number;
|
|
17
|
+
title?: string;
|
|
18
|
+
first_name?: string;
|
|
19
|
+
last_name?: string;
|
|
20
|
+
line?: string;
|
|
21
|
+
phone_number?: string;
|
|
22
|
+
postcode?: string;
|
|
23
|
+
primary?: boolean;
|
|
24
|
+
city?: { name?: string } | null;
|
|
25
|
+
township?: { name?: string } | null;
|
|
26
|
+
district?: { name?: string } | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Joins the administrative parts commerce returns as nested objects. Every
|
|
31
|
+
* level is optional per country, so empty segments are dropped instead of
|
|
32
|
+
* leaving stray separators in the rendered line.
|
|
33
|
+
*/
|
|
34
|
+
const formatLocality = (address: AddressLike, separator: string): string =>
|
|
35
|
+
[address.district?.name, address.township?.name, address.city?.name]
|
|
36
|
+
.filter((part) => Boolean(part && String(part).trim()))
|
|
37
|
+
.join(separator);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Functional account block: the shopper's saved address book. Address CONTENT
|
|
41
|
+
* stays commerce-driven (address service); the editor owns whether the row
|
|
42
|
+
* actions are exposed, every caption, the card columns and the styling.
|
|
43
|
+
*
|
|
44
|
+
* Editing an address is deliberately NOT offered here: the edit payload needs
|
|
45
|
+
* the full country -> city -> township -> district cascade, which is four more
|
|
46
|
+
* queries and a validated form — far past what a single block should own. The
|
|
47
|
+
* two mutations that are safe with the data already on screen (make default,
|
|
48
|
+
* delete) are the ones wired up.
|
|
49
|
+
* Properties: showActions, showName, showPhone, showDefaultBadge, columns,
|
|
50
|
+
* emptyLabel, defaultBadgeLabel, setDefaultLabel, deleteLabel,
|
|
51
|
+
* localitySeparator, lineSeparator.
|
|
52
|
+
* Parts: list, item (selected on the default address), title, badge, name,
|
|
53
|
+
* line, phone, actions, action, empty.
|
|
54
|
+
*/
|
|
55
|
+
const AddressListBlock = ({
|
|
56
|
+
block,
|
|
57
|
+
placeholderId,
|
|
58
|
+
sectionId,
|
|
59
|
+
selectedBlockId = null,
|
|
60
|
+
currentBreakpoint = 'desktop',
|
|
61
|
+
isDesigner = false
|
|
62
|
+
}: BlockRendererProps) => {
|
|
63
|
+
const showActions = readToggle(
|
|
64
|
+
block.properties?.showActions,
|
|
65
|
+
currentBreakpoint,
|
|
66
|
+
true
|
|
67
|
+
);
|
|
68
|
+
const showName = readToggle(
|
|
69
|
+
block.properties?.showName,
|
|
70
|
+
currentBreakpoint,
|
|
71
|
+
true
|
|
72
|
+
);
|
|
73
|
+
const showPhone = readToggle(
|
|
74
|
+
block.properties?.showPhone,
|
|
75
|
+
currentBreakpoint,
|
|
76
|
+
true
|
|
77
|
+
);
|
|
78
|
+
const showDefaultBadge = readToggle(
|
|
79
|
+
block.properties?.showDefaultBadge,
|
|
80
|
+
currentBreakpoint,
|
|
81
|
+
true
|
|
82
|
+
);
|
|
83
|
+
const text = (key: string, fallback: string) =>
|
|
84
|
+
String(
|
|
85
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
86
|
+
);
|
|
87
|
+
const emptyLabel = text('emptyLabel', 'You have no saved addresses yet.');
|
|
88
|
+
const defaultBadgeLabel = text('defaultBadgeLabel', 'Default');
|
|
89
|
+
const setDefaultLabel = text('setDefaultLabel', 'Make default');
|
|
90
|
+
const deleteLabel = text('deleteLabel', 'Delete');
|
|
91
|
+
const localitySeparator = text('localitySeparator', ' / ');
|
|
92
|
+
const lineSeparator = text('lineSeparator', ', ');
|
|
93
|
+
const rawColumns = Math.round(
|
|
94
|
+
Number(getResponsiveValue(block.properties?.columns, currentBreakpoint, 1))
|
|
95
|
+
);
|
|
96
|
+
const columns =
|
|
97
|
+
Number.isFinite(rawColumns) && rawColumns > 0 ? Math.min(rawColumns, 4) : 1;
|
|
98
|
+
|
|
99
|
+
const { status } = useSession();
|
|
100
|
+
const isAuthenticated = status === 'authenticated';
|
|
101
|
+
|
|
102
|
+
// Guests have no address book; skipping avoids a guaranteed 403 on every
|
|
103
|
+
// page that hosts this block.
|
|
104
|
+
const { data, isLoading } = useGetAddressesQuery(undefined, {
|
|
105
|
+
skip: !isAuthenticated
|
|
106
|
+
});
|
|
107
|
+
const [removeAddress, { isLoading: isRemoving }] = useRemoveAddressMutation();
|
|
108
|
+
const [setDefaultAddress, { isLoading: isSettingDefault }] =
|
|
109
|
+
useSetDefaultAddressMutation();
|
|
110
|
+
|
|
111
|
+
const addresses: AddressLike[] =
|
|
112
|
+
(data as { results?: AddressLike[] } | undefined)?.results ?? [];
|
|
113
|
+
const isBusy = isRemoving || isSettingDefault;
|
|
114
|
+
|
|
115
|
+
const makeDefault = (pk?: number) => {
|
|
116
|
+
// A click inside the designer must never touch the shopper's real data.
|
|
117
|
+
if (isDesigner || isBusy || pk === undefined) return;
|
|
118
|
+
|
|
119
|
+
setDefaultAddress({ pk, primary: true });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const deleteAddress = (pk?: number) => {
|
|
123
|
+
if (isDesigner || isBusy || pk === undefined) return;
|
|
124
|
+
|
|
125
|
+
removeAddress(pk);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const rows = addresses.map((address, index) => {
|
|
129
|
+
const locality = formatLocality(address, localitySeparator);
|
|
130
|
+
const fullName = [address.first_name, address.last_name]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(' ');
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
key: String(address.pk ?? index),
|
|
136
|
+
pk: address.pk ?? '',
|
|
137
|
+
title: address.title || '',
|
|
138
|
+
name: fullName,
|
|
139
|
+
firstName: address.first_name || '',
|
|
140
|
+
lastName: address.last_name || '',
|
|
141
|
+
line: [address.line, locality, address.postcode]
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join(lineSeparator),
|
|
144
|
+
street: address.line || '',
|
|
145
|
+
locality,
|
|
146
|
+
city: address.city?.name || '',
|
|
147
|
+
township: address.township?.name || '',
|
|
148
|
+
district: address.district?.name || '',
|
|
149
|
+
postcode: address.postcode || '',
|
|
150
|
+
phone: address.phone_number || '',
|
|
151
|
+
isDefault: address.primary === true
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// A designer who marked a child as the card layout gets that layout repeated
|
|
156
|
+
// per address; otherwise the built-in card renders unchanged.
|
|
157
|
+
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
158
|
+
<ItemTemplateList
|
|
159
|
+
block={block}
|
|
160
|
+
items={rows}
|
|
161
|
+
placeholderId={placeholderId}
|
|
162
|
+
sectionId={sectionId}
|
|
163
|
+
isDesigner={isDesigner}
|
|
164
|
+
selectedBlockId={selectedBlockId}
|
|
165
|
+
currentBreakpoint={currentBreakpoint}
|
|
166
|
+
/>
|
|
167
|
+
) : null;
|
|
168
|
+
|
|
169
|
+
if (addresses.length === 0 && !customCards) {
|
|
170
|
+
if (isDesigner) {
|
|
171
|
+
return (
|
|
172
|
+
<DesignerPlaceholder
|
|
173
|
+
label={
|
|
174
|
+
isLoading ? 'Address list (loading)' : 'Address list (no addresses)'
|
|
175
|
+
}
|
|
176
|
+
attribute="data-address-list"
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The shopper-facing empty copy is only honest once the account query has
|
|
182
|
+
// actually resolved; before that (or for guests) the block stays absent.
|
|
183
|
+
if (!isAuthenticated || isLoading || !emptyLabel) return null;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
data-address-list=""
|
|
188
|
+
data-address-list-state="empty"
|
|
189
|
+
{...partAttrs('empty', { empty: true })}
|
|
190
|
+
>
|
|
191
|
+
{emptyLabel}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const actionStyle: React.CSSProperties = {
|
|
197
|
+
background: 'none',
|
|
198
|
+
border: 'none',
|
|
199
|
+
padding: 0,
|
|
200
|
+
color: 'inherit',
|
|
201
|
+
font: 'inherit',
|
|
202
|
+
textDecoration: 'underline',
|
|
203
|
+
cursor: isBusy ? 'not-allowed' : 'pointer'
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// One column keeps the flex column the block has always rendered; a grid is
|
|
207
|
+
// only introduced once the author actually asks for more than one.
|
|
208
|
+
const listLayout: React.CSSProperties =
|
|
209
|
+
columns > 1
|
|
210
|
+
? {
|
|
211
|
+
display: 'grid',
|
|
212
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`
|
|
213
|
+
}
|
|
214
|
+
: { display: 'flex', flexDirection: 'column' };
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<ul
|
|
218
|
+
data-address-list=""
|
|
219
|
+
data-address-list-columns={columns}
|
|
220
|
+
aria-busy={isBusy}
|
|
221
|
+
{...partAttrs('list')}
|
|
222
|
+
style={{
|
|
223
|
+
...listLayout,
|
|
224
|
+
gap: 'var(--address-list-gap, 16px)',
|
|
225
|
+
listStyle: 'none',
|
|
226
|
+
margin: 0,
|
|
227
|
+
padding: 0
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{customCards ??
|
|
231
|
+
addresses.map((address, index) => {
|
|
232
|
+
const locality = formatLocality(address, localitySeparator);
|
|
233
|
+
const fullName = [address.first_name, address.last_name]
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.join(' ');
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<li
|
|
239
|
+
key={address.pk ?? index}
|
|
240
|
+
data-address-item=""
|
|
241
|
+
data-address-item-default={address.primary ? 'true' : 'false'}
|
|
242
|
+
{...partAttrs('item', { selected: address.primary === true })}
|
|
243
|
+
style={{
|
|
244
|
+
display: 'flex',
|
|
245
|
+
flexDirection: 'column',
|
|
246
|
+
gap: '6px',
|
|
247
|
+
padding: '16px',
|
|
248
|
+
border: '1px solid var(--address-list-border, #e5e7eb)'
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<div
|
|
252
|
+
style={{
|
|
253
|
+
display: 'flex',
|
|
254
|
+
alignItems: 'center',
|
|
255
|
+
justifyContent: 'space-between',
|
|
256
|
+
gap: '8px'
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
<span
|
|
260
|
+
data-address-title=""
|
|
261
|
+
{...partAttrs('title')}
|
|
262
|
+
style={{ fontWeight: 600 }}
|
|
263
|
+
>
|
|
264
|
+
{address.title}
|
|
265
|
+
</span>
|
|
266
|
+
|
|
267
|
+
{showDefaultBadge && address.primary && (
|
|
268
|
+
<span
|
|
269
|
+
data-address-default-badge=""
|
|
270
|
+
{...partAttrs('badge')}
|
|
271
|
+
style={{
|
|
272
|
+
padding: '2px 8px',
|
|
273
|
+
border: '1px solid currentColor',
|
|
274
|
+
borderRadius: '999px',
|
|
275
|
+
fontSize: '0.85em',
|
|
276
|
+
opacity: 0.8
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{defaultBadgeLabel}
|
|
280
|
+
</span>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
{showName && fullName && (
|
|
285
|
+
<span data-address-name="" {...partAttrs('name')}>
|
|
286
|
+
{fullName}
|
|
287
|
+
</span>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
<span
|
|
291
|
+
data-address-line=""
|
|
292
|
+
{...partAttrs('line')}
|
|
293
|
+
style={{ opacity: 0.8 }}
|
|
294
|
+
>
|
|
295
|
+
{[address.line, locality, address.postcode]
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.join(lineSeparator)}
|
|
298
|
+
</span>
|
|
299
|
+
|
|
300
|
+
{showPhone && address.phone_number && (
|
|
301
|
+
<span
|
|
302
|
+
data-address-phone=""
|
|
303
|
+
{...partAttrs('phone')}
|
|
304
|
+
style={{ opacity: 0.8 }}
|
|
305
|
+
>
|
|
306
|
+
{address.phone_number}
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
{showActions && (
|
|
311
|
+
<div
|
|
312
|
+
data-address-actions=""
|
|
313
|
+
{...partAttrs('actions')}
|
|
314
|
+
style={{ display: 'flex', gap: '16px', marginTop: '6px' }}
|
|
315
|
+
>
|
|
316
|
+
{!address.primary && (
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
data-address-set-default=""
|
|
320
|
+
disabled={isBusy}
|
|
321
|
+
onClick={() => makeDefault(address.pk)}
|
|
322
|
+
{...partAttrs('action', { loading: isBusy })}
|
|
323
|
+
style={actionStyle}
|
|
324
|
+
>
|
|
325
|
+
{setDefaultLabel}
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
data-address-delete=""
|
|
332
|
+
disabled={isBusy}
|
|
333
|
+
onClick={() => deleteAddress(address.pk)}
|
|
334
|
+
{...partAttrs('action', { loading: isBusy })}
|
|
335
|
+
style={actionStyle}
|
|
336
|
+
>
|
|
337
|
+
{deleteLabel}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</li>
|
|
342
|
+
);
|
|
343
|
+
})}
|
|
344
|
+
</ul>
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export default AddressListBlock;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
7
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
|
+
import {
|
|
9
|
+
buildListingHref,
|
|
10
|
+
listingPlaceholderStyle,
|
|
11
|
+
readToggle,
|
|
12
|
+
useListingContext
|
|
13
|
+
} from './listing-context';
|
|
14
|
+
|
|
15
|
+
interface AppliedFilterChip {
|
|
16
|
+
key: string;
|
|
17
|
+
searchKey: string;
|
|
18
|
+
value: string;
|
|
19
|
+
label: string;
|
|
20
|
+
facetName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fillTemplate = (
|
|
24
|
+
template: string,
|
|
25
|
+
facet: string,
|
|
26
|
+
value: string
|
|
27
|
+
): string => template.split('{facet}').join(facet).split('{value}').join(value);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Functional listing block: chips for the filters currently applied. The
|
|
31
|
+
* facet labels are commerce-driven and the applied state is read from the
|
|
32
|
+
* querystring, using the same `search_key` keys `facet-filters-block` writes,
|
|
33
|
+
* so a chip disappears the moment its filter is removed there and vice versa.
|
|
34
|
+
*
|
|
35
|
+
* Parts: list, chip, label, remove, clear-all.
|
|
36
|
+
* Properties: showClearAll, clearAllLabel, clearAllPosition, chipTemplate,
|
|
37
|
+
* showRemoveIcon, removeAriaTemplate, maxChips, pageQueryParam.
|
|
38
|
+
*/
|
|
39
|
+
const AppliedFiltersBlock = ({
|
|
40
|
+
block,
|
|
41
|
+
placeholderId,
|
|
42
|
+
sectionId,
|
|
43
|
+
selectedBlockId = null,
|
|
44
|
+
currentBreakpoint = 'desktop',
|
|
45
|
+
isDesigner = false
|
|
46
|
+
}: BlockRendererProps) => {
|
|
47
|
+
const listing = useListingContext();
|
|
48
|
+
const router = useRouter();
|
|
49
|
+
const pathname = usePathname();
|
|
50
|
+
const searchParams = useSearchParams();
|
|
51
|
+
|
|
52
|
+
const showClearAll = readToggle(
|
|
53
|
+
block.properties?.showClearAll,
|
|
54
|
+
currentBreakpoint,
|
|
55
|
+
true
|
|
56
|
+
);
|
|
57
|
+
const clearAllLabel = String(
|
|
58
|
+
getResponsiveValue(
|
|
59
|
+
block.properties?.clearAllLabel,
|
|
60
|
+
currentBreakpoint,
|
|
61
|
+
'Clear all'
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
const clearAllPosition = String(
|
|
65
|
+
getResponsiveValue(
|
|
66
|
+
block.properties?.clearAllPosition,
|
|
67
|
+
currentBreakpoint,
|
|
68
|
+
'end'
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
// `{facet}` is the group name ("Color"), `{value}` the chosen label ("Red").
|
|
72
|
+
const chipTemplate = String(
|
|
73
|
+
getResponsiveValue(block.properties?.chipTemplate, currentBreakpoint, '{value}')
|
|
74
|
+
);
|
|
75
|
+
const showRemoveIcon = readToggle(
|
|
76
|
+
block.properties?.showRemoveIcon,
|
|
77
|
+
currentBreakpoint,
|
|
78
|
+
true
|
|
79
|
+
);
|
|
80
|
+
const removeAriaTemplate = String(
|
|
81
|
+
getResponsiveValue(
|
|
82
|
+
block.properties?.removeAriaTemplate,
|
|
83
|
+
currentBreakpoint,
|
|
84
|
+
'Remove {facet}: {value}'
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
const maxChipsRaw = Math.round(
|
|
88
|
+
Number(getResponsiveValue(block.properties?.maxChips, currentBreakpoint, 0))
|
|
89
|
+
);
|
|
90
|
+
// 0 shows every applied filter, which is what published compositions render.
|
|
91
|
+
const maxChips =
|
|
92
|
+
Number.isFinite(maxChipsRaw) && maxChipsRaw > 0 ? maxChipsRaw : 0;
|
|
93
|
+
const pageQueryParam = String(
|
|
94
|
+
getResponsiveValue(block.properties?.pageQueryParam, currentBreakpoint, 'page')
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const facets = listing?.facets || [];
|
|
98
|
+
|
|
99
|
+
const allChips: AppliedFilterChip[] = [];
|
|
100
|
+
facets.forEach((facet) => {
|
|
101
|
+
// The querystring is the live source: after a client-side push it is
|
|
102
|
+
// already updated while the server-rendered `is_selected` flags still
|
|
103
|
+
// describe the previous request.
|
|
104
|
+
const applied = searchParams ? searchParams.getAll(facet.searchKey) : [];
|
|
105
|
+
|
|
106
|
+
facet.choices.forEach((choice) => {
|
|
107
|
+
if (!choice.isSelected && !applied.includes(choice.value)) return;
|
|
108
|
+
|
|
109
|
+
allChips.push({
|
|
110
|
+
key: `${facet.key}-${choice.value}`,
|
|
111
|
+
searchKey: facet.searchKey,
|
|
112
|
+
value: choice.value,
|
|
113
|
+
label: choice.label,
|
|
114
|
+
facetName: facet.name
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const chips = maxChips > 0 ? allChips.slice(0, maxChips) : allChips;
|
|
120
|
+
|
|
121
|
+
const hrefForParams = (params: URLSearchParams) => {
|
|
122
|
+
// Any filter change invalidates the current page cursor.
|
|
123
|
+
params.delete(pageQueryParam);
|
|
124
|
+
return buildListingHref(pathname || listing?.path || '', params);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The URL that drops one chip. Split out from the click handler so a
|
|
129
|
+
* designer-authored chip can bind a link to `item.url` and still remove the
|
|
130
|
+
* filter — a custom row has no way to reach the handler.
|
|
131
|
+
*/
|
|
132
|
+
const hrefForRemove = (chip: AppliedFilterChip): string => {
|
|
133
|
+
const params = new URLSearchParams(
|
|
134
|
+
searchParams ? searchParams.toString() : ''
|
|
135
|
+
);
|
|
136
|
+
const remaining = params
|
|
137
|
+
.getAll(chip.searchKey)
|
|
138
|
+
.filter((value) => value !== chip.value);
|
|
139
|
+
|
|
140
|
+
params.delete(chip.searchKey);
|
|
141
|
+
remaining.forEach((value) => params.append(chip.searchKey, value));
|
|
142
|
+
|
|
143
|
+
return hrefForParams(params);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleRemove = (chip: AppliedFilterChip) => {
|
|
147
|
+
// Navigating inside the designer iframe would drop the author out of the
|
|
148
|
+
// page they are editing, so chips are inert there.
|
|
149
|
+
if (isDesigner) return;
|
|
150
|
+
|
|
151
|
+
router.push(hrefForRemove(chip));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleClearAll = () => {
|
|
155
|
+
if (isDesigner) return;
|
|
156
|
+
|
|
157
|
+
const params = new URLSearchParams(
|
|
158
|
+
searchParams ? searchParams.toString() : ''
|
|
159
|
+
);
|
|
160
|
+
// Only facet keys are dropped: the sorter and the search text are not
|
|
161
|
+
// filters and must survive a "clear all".
|
|
162
|
+
facets.forEach((facet) => params.delete(facet.searchKey));
|
|
163
|
+
|
|
164
|
+
router.push(hrefForParams(params));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// A designer who marked a child as the chip layout gets that layout
|
|
168
|
+
// repeated per applied filter; otherwise the built-in chips render.
|
|
169
|
+
const chipItems = chips.map((chip) => ({
|
|
170
|
+
key: chip.key,
|
|
171
|
+
name: chip.label,
|
|
172
|
+
label: chip.label,
|
|
173
|
+
value: chip.value,
|
|
174
|
+
facet: chip.facetName,
|
|
175
|
+
searchKey: chip.searchKey,
|
|
176
|
+
url: hrefForRemove(chip)
|
|
177
|
+
}));
|
|
178
|
+
const customChips = hasItemTemplate(block, currentBreakpoint) ? (
|
|
179
|
+
<ItemTemplateList
|
|
180
|
+
block={block}
|
|
181
|
+
items={chipItems as unknown as Record<string, unknown>[]}
|
|
182
|
+
placeholderId={placeholderId}
|
|
183
|
+
sectionId={sectionId}
|
|
184
|
+
isDesigner={isDesigner}
|
|
185
|
+
selectedBlockId={selectedBlockId}
|
|
186
|
+
currentBreakpoint={currentBreakpoint}
|
|
187
|
+
/>
|
|
188
|
+
) : null;
|
|
189
|
+
|
|
190
|
+
// With a custom chip the designer still needs something to arrange, so the
|
|
191
|
+
// template draws its own placeholder rows. On the storefront "no filters
|
|
192
|
+
// applied" still means the whole block disappears — a bare "Clear all" with
|
|
193
|
+
// nothing to clear would be worse than nothing.
|
|
194
|
+
if (chips.length === 0 && !(isDesigner && customChips)) {
|
|
195
|
+
return isDesigner ? (
|
|
196
|
+
<div data-applied-filters="" style={listingPlaceholderStyle}>
|
|
197
|
+
Applied filters (nothing selected)
|
|
198
|
+
</div>
|
|
199
|
+
) : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const clearAllNode = showClearAll && (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
data-applied-filters-clear=""
|
|
206
|
+
{...partAttrs('clear-all')}
|
|
207
|
+
onClick={handleClearAll}
|
|
208
|
+
style={{
|
|
209
|
+
padding: '4px 6px',
|
|
210
|
+
border: 'none',
|
|
211
|
+
background: 'transparent',
|
|
212
|
+
color: 'inherit',
|
|
213
|
+
font: 'inherit',
|
|
214
|
+
textDecoration: 'underline',
|
|
215
|
+
cursor: 'pointer'
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{clearAllLabel}
|
|
219
|
+
</button>
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
data-applied-filters=""
|
|
225
|
+
{...partAttrs('list')}
|
|
226
|
+
style={{
|
|
227
|
+
display: 'flex',
|
|
228
|
+
flexWrap: 'wrap',
|
|
229
|
+
alignItems: 'center',
|
|
230
|
+
gap: 'var(--applied-filters-gap, 8px)'
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{clearAllPosition === 'start' && clearAllNode}
|
|
234
|
+
|
|
235
|
+
{customChips ??
|
|
236
|
+
chips.map((chip) => (
|
|
237
|
+
<button
|
|
238
|
+
key={chip.key}
|
|
239
|
+
type="button"
|
|
240
|
+
data-applied-filter={chip.value}
|
|
241
|
+
data-applied-filter-key={chip.searchKey}
|
|
242
|
+
aria-label={fillTemplate(
|
|
243
|
+
removeAriaTemplate,
|
|
244
|
+
chip.facetName,
|
|
245
|
+
chip.label
|
|
246
|
+
)}
|
|
247
|
+
{...partAttrs('chip')}
|
|
248
|
+
onClick={() => handleRemove(chip)}
|
|
249
|
+
style={{
|
|
250
|
+
display: 'inline-flex',
|
|
251
|
+
alignItems: 'center',
|
|
252
|
+
gap: '6px',
|
|
253
|
+
padding: '4px 10px',
|
|
254
|
+
border: '1px solid currentColor',
|
|
255
|
+
borderRadius: '999px',
|
|
256
|
+
background: 'transparent',
|
|
257
|
+
color: 'inherit',
|
|
258
|
+
font: 'inherit',
|
|
259
|
+
cursor: 'pointer'
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<span {...partAttrs('label')}>
|
|
263
|
+
{fillTemplate(chipTemplate, chip.facetName, chip.label)}
|
|
264
|
+
</span>
|
|
265
|
+
{showRemoveIcon && (
|
|
266
|
+
<svg
|
|
267
|
+
width="12"
|
|
268
|
+
height="12"
|
|
269
|
+
viewBox="0 0 24 24"
|
|
270
|
+
fill="none"
|
|
271
|
+
stroke="currentColor"
|
|
272
|
+
strokeWidth="2"
|
|
273
|
+
strokeLinecap="round"
|
|
274
|
+
aria-hidden="true"
|
|
275
|
+
{...partAttrs('remove')}
|
|
276
|
+
>
|
|
277
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
278
|
+
</svg>
|
|
279
|
+
)}
|
|
280
|
+
</button>
|
|
281
|
+
))}
|
|
282
|
+
|
|
283
|
+
{clearAllPosition !== 'start' && clearAllNode}
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export default AppliedFiltersBlock;
|