@akinon/pz-theme 2.0.37-rc.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 +7 -31
  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,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;