@akinon/pz-theme 2.0.38-beta.0 → 2.0.38
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__/chrome-takeover-gate.test.tsx +252 -0
- package/src/__tests__/registry-parity.test.ts +14 -1
- package/src/__tests__/route-slug.test.ts +116 -0
- package/src/blocks/__tests__/overlay-sweep.test.tsx +592 -0
- package/src/blocks/account-button-block.tsx +133 -0
- package/src/blocks/announcement-bar-block.tsx +407 -0
- package/src/blocks/block-renderer-registry.tsx +26 -0
- package/src/blocks/currency-selector-block.tsx +177 -0
- package/src/blocks/hamburger-menu-block.tsx +461 -0
- package/src/blocks/language-selector-block.tsx +172 -0
- package/src/blocks/mega-menu-block.tsx +712 -0
- package/src/blocks/mini-basket-block.tsx +173 -0
- package/src/blocks/nav-menu-block.tsx +309 -0
- package/src/blocks/newsletter-form-block.tsx +184 -0
- package/src/blocks/payment-badges-block.tsx +393 -0
- package/src/blocks/search-box-block.tsx +277 -0
- package/src/blocks/social-links-block.tsx +391 -0
- package/src/blocks/wishlist-button-block.tsx +115 -0
- package/src/chrome/chrome-fallback.tsx +147 -0
- package/src/chrome/chrome-slot.tsx +68 -0
- package/src/chrome/composition-state.ts +92 -0
- package/src/chrome/page-context.ts +387 -0
- package/src/chrome/placeholder-slugs.ts +86 -0
- package/src/chrome/route-slug.ts +159 -0
- package/src/index.ts +20 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useMemo } from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import ThemeBlock, { Block } from '../theme-block';
|
|
6
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
7
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
9
|
+
import {
|
|
10
|
+
findItemTemplate,
|
|
11
|
+
hasItemTemplate,
|
|
12
|
+
ItemTemplateList
|
|
13
|
+
} from './item-template';
|
|
14
|
+
|
|
15
|
+
interface PaymentBadge {
|
|
16
|
+
label: string;
|
|
17
|
+
image: string;
|
|
18
|
+
href: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const looksLikeUrl = (value: string) =>
|
|
22
|
+
/^(https?:)?\/\//i.test(value) ||
|
|
23
|
+
value.startsWith('/') ||
|
|
24
|
+
value.startsWith('data:image');
|
|
25
|
+
|
|
26
|
+
const labelFromUrl = (url: string) => {
|
|
27
|
+
const file = url.split('?')[0].split('/').pop() || '';
|
|
28
|
+
return file.replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ').trim();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const escapeRegExp = (value: string) =>
|
|
32
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One badge per line (or per the entry separator, `;` by default), fields
|
|
36
|
+
* separated by the field separator (`|` by default):
|
|
37
|
+
* `Visa|https://cdn/visa.svg|https://brand/payment`. A lone URL becomes an
|
|
38
|
+
* image badge, a lone word becomes a text badge — brands ship both.
|
|
39
|
+
*
|
|
40
|
+
* Comma is deliberately NOT the default entry separator: badge images are
|
|
41
|
+
* served from transform CDNs whose paths contain commas (`/w_120,h_40,c_fit/`),
|
|
42
|
+
* and splitting on them tore a single URL into two malformed badges.
|
|
43
|
+
*/
|
|
44
|
+
const parseBadges = (
|
|
45
|
+
raw: string,
|
|
46
|
+
entrySeparator: string,
|
|
47
|
+
fieldSeparator: string
|
|
48
|
+
): PaymentBadge[] =>
|
|
49
|
+
raw
|
|
50
|
+
// A newline always ends an entry: the field is a textarea, and a brand
|
|
51
|
+
// that changed the separator still types one badge per line.
|
|
52
|
+
.split(new RegExp(`\\r?\\n|${escapeRegExp(entrySeparator)}`))
|
|
53
|
+
.map((entry) => entry.trim())
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((entry) => {
|
|
56
|
+
const [first = '', second = '', third = ''] = entry
|
|
57
|
+
.split(fieldSeparator)
|
|
58
|
+
.map((part) => part.trim());
|
|
59
|
+
|
|
60
|
+
if (!second && looksLikeUrl(first)) {
|
|
61
|
+
return { label: labelFromUrl(first), image: first, href: '' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
label: first,
|
|
66
|
+
image: looksLikeUrl(second) ? second : '',
|
|
67
|
+
href: third
|
|
68
|
+
};
|
|
69
|
+
})
|
|
70
|
+
.filter((badge) => badge.label || badge.image);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Functional chrome block: the row of payment and trust logos in the footer.
|
|
74
|
+
* Badges are authored either as child image blocks (the richer path, which wins
|
|
75
|
+
* when present) or through the `badges` property list; a group marked as the
|
|
76
|
+
* row layout repeats the designer's own badge instead. The block owns the row
|
|
77
|
+
* layout only.
|
|
78
|
+
* Properties: badges, entrySeparator, fieldSeparator, gap, badgeHeight,
|
|
79
|
+
* grayscale, showLabels, fallbackLabel, maxBadges, openInNewTab.
|
|
80
|
+
* Parts: list, badge, image, label.
|
|
81
|
+
*/
|
|
82
|
+
const PaymentBadgesBlock = ({
|
|
83
|
+
block,
|
|
84
|
+
placeholderId,
|
|
85
|
+
sectionId,
|
|
86
|
+
selectedBlockId = null,
|
|
87
|
+
currentBreakpoint = 'desktop',
|
|
88
|
+
isDesigner = false
|
|
89
|
+
}: BlockRendererProps) => {
|
|
90
|
+
const readText = (key: string, fallback: string): string => {
|
|
91
|
+
const value = getResponsiveValue(
|
|
92
|
+
block.properties?.[key],
|
|
93
|
+
currentBreakpoint,
|
|
94
|
+
fallback
|
|
95
|
+
);
|
|
96
|
+
return value === undefined || value === null ? fallback : String(value);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const clamp = (key: string, fallback: number, min: number, max: number) => {
|
|
100
|
+
const numeric = Math.round(
|
|
101
|
+
Number(getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback))
|
|
102
|
+
);
|
|
103
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
104
|
+
return Math.min(max, Math.max(min, numeric));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const gap = clamp('gap', 12, 0, 64);
|
|
108
|
+
const badgeHeight = clamp('badgeHeight', 24, 8, 120);
|
|
109
|
+
const maxBadges = clamp('maxBadges', 0, 0, 40);
|
|
110
|
+
const grayscale = readToggle(
|
|
111
|
+
block.properties?.grayscale,
|
|
112
|
+
currentBreakpoint,
|
|
113
|
+
false
|
|
114
|
+
);
|
|
115
|
+
const showLabels = readToggle(
|
|
116
|
+
block.properties?.showLabels,
|
|
117
|
+
currentBreakpoint,
|
|
118
|
+
false
|
|
119
|
+
);
|
|
120
|
+
const openInNewTab = readToggle(
|
|
121
|
+
block.properties?.openInNewTab,
|
|
122
|
+
currentBreakpoint,
|
|
123
|
+
false
|
|
124
|
+
);
|
|
125
|
+
const fallbackLabel = readText('fallbackLabel', 'Payment method');
|
|
126
|
+
// An empty separator would explode the text character by character, so an
|
|
127
|
+
// empty value falls back to the one the block has always used.
|
|
128
|
+
const entrySeparatorValue = readText('entrySeparator', ';');
|
|
129
|
+
const entrySeparator = entrySeparatorValue.length > 0
|
|
130
|
+
? entrySeparatorValue
|
|
131
|
+
: ';';
|
|
132
|
+
const fieldSeparatorValue = readText('fieldSeparator', '|');
|
|
133
|
+
const fieldSeparator = fieldSeparatorValue.length > 0
|
|
134
|
+
? fieldSeparatorValue
|
|
135
|
+
: '|';
|
|
136
|
+
|
|
137
|
+
const badgesText = String(
|
|
138
|
+
getResponsiveValue(block.properties?.badges, currentBreakpoint, '')
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// The row layout lives among the children, so it must not also be drawn as
|
|
142
|
+
// one of the badges.
|
|
143
|
+
const template = findItemTemplate(block, currentBreakpoint);
|
|
144
|
+
const templateId = template?.id;
|
|
145
|
+
|
|
146
|
+
const childBlocks: Block[] = useMemo(
|
|
147
|
+
() =>
|
|
148
|
+
(block.blocks || [])
|
|
149
|
+
.filter((child) => (isDesigner ? true : !child.hidden))
|
|
150
|
+
.filter((child) => !templateId || child.id !== templateId)
|
|
151
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0)),
|
|
152
|
+
[block.blocks, isDesigner, templateId]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const badges = useMemo(() => {
|
|
156
|
+
const parsed = parseBadges(badgesText, entrySeparator, fieldSeparator);
|
|
157
|
+
return maxBadges > 0 ? parsed.slice(0, maxBadges) : parsed;
|
|
158
|
+
}, [badgesText, entrySeparator, fieldSeparator, maxBadges]);
|
|
159
|
+
|
|
160
|
+
// Resolved once so a designer-authored badge binds the same fields the
|
|
161
|
+
// built-in one draws — `item.image`, `item.label`, `item.url`.
|
|
162
|
+
const badgeItems = useMemo(
|
|
163
|
+
() =>
|
|
164
|
+
badges.map((badge, index) => ({
|
|
165
|
+
label: badge.label,
|
|
166
|
+
name: badge.label,
|
|
167
|
+
image: badge.image,
|
|
168
|
+
url: badge.href,
|
|
169
|
+
href: badge.href,
|
|
170
|
+
index: index + 1
|
|
171
|
+
})),
|
|
172
|
+
[badges]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const rowStyle: React.CSSProperties = {
|
|
176
|
+
display: 'flex',
|
|
177
|
+
alignItems: 'center',
|
|
178
|
+
flexWrap: 'wrap',
|
|
179
|
+
gap: `${gap}px`
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const customRows =
|
|
183
|
+
hasItemTemplate(block, currentBreakpoint) &&
|
|
184
|
+
(badgeItems.length > 0 || isDesigner) ? (
|
|
185
|
+
<ItemTemplateList
|
|
186
|
+
block={block}
|
|
187
|
+
items={badgeItems}
|
|
188
|
+
placeholderId={placeholderId}
|
|
189
|
+
sectionId={sectionId}
|
|
190
|
+
isDesigner={isDesigner}
|
|
191
|
+
selectedBlockId={selectedBlockId}
|
|
192
|
+
currentBreakpoint={currentBreakpoint}
|
|
193
|
+
designerPlaceholderCount={4}
|
|
194
|
+
/>
|
|
195
|
+
) : null;
|
|
196
|
+
|
|
197
|
+
if (!customRows && childBlocks.length === 0 && badges.length === 0) {
|
|
198
|
+
return isDesigner ? (
|
|
199
|
+
<DesignerPlaceholder
|
|
200
|
+
label="Payment badges (no logos)"
|
|
201
|
+
attribute="data-payment-badges"
|
|
202
|
+
/>
|
|
203
|
+
) : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (customRows) {
|
|
207
|
+
return (
|
|
208
|
+
<div
|
|
209
|
+
data-payment-badges=""
|
|
210
|
+
data-payment-badges-source="template"
|
|
211
|
+
{...partAttrs('list')}
|
|
212
|
+
style={rowStyle}
|
|
213
|
+
>
|
|
214
|
+
{customRows}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (childBlocks.length > 0) {
|
|
220
|
+
const createActionHandler = (actionType: string, blockId?: string) => () => {
|
|
221
|
+
if (window.parent) {
|
|
222
|
+
window.parent.postMessage(
|
|
223
|
+
{ type: actionType, data: { placeholderId, sectionId, blockId } },
|
|
224
|
+
'*'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Rename is a separate message because it carries the new label; without it
|
|
230
|
+
// the toolbar exposes no way to retitle a badge block.
|
|
231
|
+
const createRenameHandler =
|
|
232
|
+
(blockId?: string) => (newLabel: string) => {
|
|
233
|
+
if (window.parent) {
|
|
234
|
+
window.parent.postMessage(
|
|
235
|
+
{
|
|
236
|
+
type: 'RENAME_BLOCK',
|
|
237
|
+
data: { placeholderId, sectionId, blockId, label: newLabel }
|
|
238
|
+
},
|
|
239
|
+
'*'
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
data-payment-badges=""
|
|
247
|
+
data-payment-badges-source="blocks"
|
|
248
|
+
{...partAttrs('list')}
|
|
249
|
+
style={rowStyle}
|
|
250
|
+
>
|
|
251
|
+
{childBlocks.map((child, index) => (
|
|
252
|
+
<div
|
|
253
|
+
key={child.id || `badge-${index}`}
|
|
254
|
+
data-payment-badge=""
|
|
255
|
+
{...partAttrs('badge')}
|
|
256
|
+
>
|
|
257
|
+
<ThemeBlock
|
|
258
|
+
block={child}
|
|
259
|
+
placeholderId={placeholderId}
|
|
260
|
+
sectionId={sectionId}
|
|
261
|
+
isDesigner={isDesigner}
|
|
262
|
+
isSelected={selectedBlockId === child.id}
|
|
263
|
+
selectedBlockId={selectedBlockId}
|
|
264
|
+
currentBreakpoint={currentBreakpoint}
|
|
265
|
+
onMoveUp={createActionHandler('MOVE_BLOCK_UP', child.id)}
|
|
266
|
+
onMoveDown={createActionHandler('MOVE_BLOCK_DOWN', child.id)}
|
|
267
|
+
onDuplicate={createActionHandler('DUPLICATE_BLOCK', child.id)}
|
|
268
|
+
onToggleVisibility={createActionHandler(
|
|
269
|
+
'TOGGLE_BLOCK_VISIBILITY',
|
|
270
|
+
child.id
|
|
271
|
+
)}
|
|
272
|
+
onDelete={createActionHandler('DELETE_BLOCK', child.id)}
|
|
273
|
+
onRename={createRenameHandler(child.id)}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div
|
|
283
|
+
data-payment-badges=""
|
|
284
|
+
data-payment-badges-source="property"
|
|
285
|
+
{...partAttrs('list')}
|
|
286
|
+
style={rowStyle}
|
|
287
|
+
>
|
|
288
|
+
{badges.map((badge, index) => {
|
|
289
|
+
const image = badge.image ? (
|
|
290
|
+
// Plain img: payment marks are served from the brand's own CDN, which
|
|
291
|
+
// the next.config image allowlist does not necessarily cover.
|
|
292
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
293
|
+
<img
|
|
294
|
+
src={badge.image}
|
|
295
|
+
alt={badge.label || fallbackLabel}
|
|
296
|
+
loading="lazy"
|
|
297
|
+
data-payment-badge-image=""
|
|
298
|
+
{...partAttrs('image')}
|
|
299
|
+
style={{
|
|
300
|
+
display: 'block',
|
|
301
|
+
// The CSS variable keeps precedence: a brand already setting it
|
|
302
|
+
// globally must not be overridden by the block's own height.
|
|
303
|
+
height: `var(--payment-badges-height, ${badgeHeight}px)`,
|
|
304
|
+
width: 'auto',
|
|
305
|
+
...(grayscale ? { filter: 'grayscale(1)' } : null)
|
|
306
|
+
}}
|
|
307
|
+
/>
|
|
308
|
+
) : null;
|
|
309
|
+
|
|
310
|
+
const caption =
|
|
311
|
+
badge.label && (!badge.image || showLabels) ? (
|
|
312
|
+
<span
|
|
313
|
+
data-payment-badge-label=""
|
|
314
|
+
{...partAttrs('label')}
|
|
315
|
+
style={
|
|
316
|
+
badge.image
|
|
317
|
+
? {
|
|
318
|
+
display: 'inline-block',
|
|
319
|
+
fontSize: '0.85em',
|
|
320
|
+
lineHeight: 1.4
|
|
321
|
+
}
|
|
322
|
+
: {
|
|
323
|
+
display: 'inline-block',
|
|
324
|
+
padding: '4px 8px',
|
|
325
|
+
border: '1px solid currentColor',
|
|
326
|
+
borderRadius: '4px',
|
|
327
|
+
fontSize: '0.85em',
|
|
328
|
+
lineHeight: 1.4
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
>
|
|
332
|
+
{badge.label}
|
|
333
|
+
</span>
|
|
334
|
+
) : null;
|
|
335
|
+
|
|
336
|
+
const content = (
|
|
337
|
+
<>
|
|
338
|
+
{image}
|
|
339
|
+
{caption}
|
|
340
|
+
</>
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const badgeStyle: React.CSSProperties = {
|
|
344
|
+
display: 'inline-flex',
|
|
345
|
+
alignItems: 'center',
|
|
346
|
+
...(image && caption ? { gap: '8px' } : null)
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
if (!badge.href) {
|
|
350
|
+
return (
|
|
351
|
+
<span
|
|
352
|
+
key={`${badge.label}-${index}`}
|
|
353
|
+
data-payment-badge=""
|
|
354
|
+
{...partAttrs('badge')}
|
|
355
|
+
style={badgeStyle}
|
|
356
|
+
>
|
|
357
|
+
{content}
|
|
358
|
+
</span>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<NextLink
|
|
364
|
+
key={`${badge.label}-${index}`}
|
|
365
|
+
data-payment-badge=""
|
|
366
|
+
{...partAttrs('badge')}
|
|
367
|
+
// Navigating inside the designer iframe would drop the author out of
|
|
368
|
+
// the page they are editing, so the badges are inert there.
|
|
369
|
+
// NextLink warns when href resolves to undefined, so the value is
|
|
370
|
+
// resolved here rather than relying on the caller's guard alone.
|
|
371
|
+
href={isDesigner ? '#' : badge.href || '#'}
|
|
372
|
+
onClick={(event) => {
|
|
373
|
+
if (isDesigner) event.preventDefault();
|
|
374
|
+
}}
|
|
375
|
+
aria-label={badge.label || fallbackLabel}
|
|
376
|
+
{...(isDesigner || !openInNewTab
|
|
377
|
+
? {}
|
|
378
|
+
: { target: '_blank', rel: 'noopener noreferrer' })}
|
|
379
|
+
style={{
|
|
380
|
+
...badgeStyle,
|
|
381
|
+
color: 'inherit',
|
|
382
|
+
textDecoration: 'none'
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
{content}
|
|
386
|
+
</NextLink>
|
|
387
|
+
);
|
|
388
|
+
})}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export default PaymentBadgesBlock;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import { readToggle } from './product-block-utils';
|
|
8
|
+
|
|
9
|
+
const clampNumber = (
|
|
10
|
+
value: unknown,
|
|
11
|
+
fallback: number,
|
|
12
|
+
min: number,
|
|
13
|
+
max: number
|
|
14
|
+
) => {
|
|
15
|
+
const numeric = Math.round(Number(value));
|
|
16
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
17
|
+
return Math.min(max, Math.max(min, numeric));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Functional chrome block: site search. Renders either as an icon that
|
|
22
|
+
* expands into an overlay input (mode: 'icon', the common brand pattern)
|
|
23
|
+
* or as an always-visible inline input (mode: 'inline'). The editor owns
|
|
24
|
+
* placement, mode, every label and the styling of each piece.
|
|
25
|
+
* Properties: mode, placeholder, searchUrl, queryParam, showIcon, iconSize,
|
|
26
|
+
* showSubmit, submitLabel, submitStyle, closeLabel, triggerLabel,
|
|
27
|
+
* formMaxWidth, autoFocus, clearOnSubmit.
|
|
28
|
+
* Parts: trigger, icon, form, input, submit, panel, close.
|
|
29
|
+
*/
|
|
30
|
+
const SearchBoxBlock = ({
|
|
31
|
+
block,
|
|
32
|
+
currentBreakpoint = 'desktop',
|
|
33
|
+
isDesigner = false
|
|
34
|
+
}: BlockRendererProps) => {
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [term, setTerm] = useState('');
|
|
38
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
|
|
40
|
+
const prop = (key: string, fallback: string) =>
|
|
41
|
+
String(
|
|
42
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const mode = prop('mode', 'icon');
|
|
46
|
+
const placeholder = prop('placeholder', 'Search');
|
|
47
|
+
const searchUrl = prop('searchUrl', '/list');
|
|
48
|
+
const queryParam = prop('queryParam', 'search_text');
|
|
49
|
+
const closeLabel = prop('closeLabel', 'Close search');
|
|
50
|
+
const submitLabel = prop('submitLabel', 'Search');
|
|
51
|
+
const submitStyle = prop('submitStyle', 'label');
|
|
52
|
+
// Empty on purpose: the trigger has always announced itself with the
|
|
53
|
+
// placeholder, so an unset label must keep doing exactly that.
|
|
54
|
+
const triggerLabel = prop('triggerLabel', '');
|
|
55
|
+
const showIcon = readToggle(
|
|
56
|
+
block.properties?.showIcon,
|
|
57
|
+
currentBreakpoint,
|
|
58
|
+
true
|
|
59
|
+
);
|
|
60
|
+
const showSubmit = readToggle(
|
|
61
|
+
block.properties?.showSubmit,
|
|
62
|
+
currentBreakpoint,
|
|
63
|
+
false
|
|
64
|
+
);
|
|
65
|
+
const autoFocus = readToggle(
|
|
66
|
+
block.properties?.autoFocus,
|
|
67
|
+
currentBreakpoint,
|
|
68
|
+
true
|
|
69
|
+
);
|
|
70
|
+
const clearOnSubmit = readToggle(
|
|
71
|
+
block.properties?.clearOnSubmit,
|
|
72
|
+
currentBreakpoint,
|
|
73
|
+
false
|
|
74
|
+
);
|
|
75
|
+
const iconSize = clampNumber(
|
|
76
|
+
getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 22),
|
|
77
|
+
22,
|
|
78
|
+
12,
|
|
79
|
+
64
|
|
80
|
+
);
|
|
81
|
+
const formMaxWidth = clampNumber(
|
|
82
|
+
getResponsiveValue(block.properties?.formMaxWidth, currentBreakpoint, 720),
|
|
83
|
+
720,
|
|
84
|
+
240,
|
|
85
|
+
1600
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (open && autoFocus) inputRef.current?.focus();
|
|
90
|
+
}, [open, autoFocus]);
|
|
91
|
+
|
|
92
|
+
const submit = (event: React.FormEvent) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
const value = term.trim();
|
|
95
|
+
if (!value || isDesigner) return;
|
|
96
|
+
const separator = searchUrl.includes('?') ? '&' : '?';
|
|
97
|
+
router.push(
|
|
98
|
+
`${searchUrl}${separator}${queryParam}=${encodeURIComponent(value)}`
|
|
99
|
+
);
|
|
100
|
+
if (clearOnSubmit) setTerm('');
|
|
101
|
+
setOpen(false);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const icon = (
|
|
105
|
+
<svg
|
|
106
|
+
width={iconSize}
|
|
107
|
+
height={iconSize}
|
|
108
|
+
viewBox="0 0 24 24"
|
|
109
|
+
fill="none"
|
|
110
|
+
stroke="currentColor"
|
|
111
|
+
strokeWidth="1.6"
|
|
112
|
+
strokeLinecap="round"
|
|
113
|
+
data-search-icon=""
|
|
114
|
+
{...partAttrs('icon')}
|
|
115
|
+
>
|
|
116
|
+
<circle cx="11" cy="11" r="7" />
|
|
117
|
+
<path d="M20 20l-3.5-3.5" />
|
|
118
|
+
</svg>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const submitButton = showSubmit ? (
|
|
122
|
+
<button
|
|
123
|
+
type="submit"
|
|
124
|
+
data-search-submit=""
|
|
125
|
+
{...partAttrs('submit')}
|
|
126
|
+
aria-label={submitLabel}
|
|
127
|
+
style={{
|
|
128
|
+
display: 'inline-flex',
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
gap: '6px',
|
|
131
|
+
background: 'none',
|
|
132
|
+
border: 'none',
|
|
133
|
+
color: 'inherit',
|
|
134
|
+
font: 'inherit',
|
|
135
|
+
cursor: 'pointer',
|
|
136
|
+
padding: '0 8px'
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{submitStyle === 'icon' ? icon : submitLabel}
|
|
140
|
+
</button>
|
|
141
|
+
) : null;
|
|
142
|
+
|
|
143
|
+
if (mode === 'inline') {
|
|
144
|
+
return (
|
|
145
|
+
<form
|
|
146
|
+
data-search-box=""
|
|
147
|
+
{...partAttrs('form')}
|
|
148
|
+
onSubmit={submit}
|
|
149
|
+
style={{ display: 'flex' }}
|
|
150
|
+
>
|
|
151
|
+
<input
|
|
152
|
+
type="search"
|
|
153
|
+
value={term}
|
|
154
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
155
|
+
placeholder={placeholder}
|
|
156
|
+
aria-label={placeholder}
|
|
157
|
+
data-search-input=""
|
|
158
|
+
{...partAttrs('input')}
|
|
159
|
+
style={{
|
|
160
|
+
width: '100%',
|
|
161
|
+
border: '1px solid currentColor',
|
|
162
|
+
borderRadius: 'inherit',
|
|
163
|
+
background: 'transparent',
|
|
164
|
+
color: 'inherit',
|
|
165
|
+
padding: '8px 12px',
|
|
166
|
+
outline: 'none'
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
{submitButton}
|
|
170
|
+
</form>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
data-search-box=""
|
|
179
|
+
{...partAttrs('trigger', { open })}
|
|
180
|
+
aria-label={triggerLabel || placeholder}
|
|
181
|
+
aria-expanded={open}
|
|
182
|
+
onClick={() => setOpen((value) => !value)}
|
|
183
|
+
style={{
|
|
184
|
+
display: 'inline-flex',
|
|
185
|
+
alignItems: 'center',
|
|
186
|
+
background: 'none',
|
|
187
|
+
border: 'none',
|
|
188
|
+
padding: 0,
|
|
189
|
+
cursor: 'pointer',
|
|
190
|
+
color: 'inherit'
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{icon}
|
|
194
|
+
</button>
|
|
195
|
+
|
|
196
|
+
{open && (
|
|
197
|
+
<div
|
|
198
|
+
data-search-overlay=""
|
|
199
|
+
{...partAttrs('panel', { open: true })}
|
|
200
|
+
style={{
|
|
201
|
+
position: 'fixed',
|
|
202
|
+
top: 0,
|
|
203
|
+
left: 0,
|
|
204
|
+
right: 0,
|
|
205
|
+
zIndex: 1000,
|
|
206
|
+
background: '#ffffff',
|
|
207
|
+
color: '#111827',
|
|
208
|
+
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
|
209
|
+
padding: '20px 24px'
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<form
|
|
213
|
+
onSubmit={submit}
|
|
214
|
+
data-search-form=""
|
|
215
|
+
{...partAttrs('form')}
|
|
216
|
+
style={{
|
|
217
|
+
display: 'flex',
|
|
218
|
+
alignItems: 'center',
|
|
219
|
+
gap: '12px',
|
|
220
|
+
maxWidth: `${formMaxWidth}px`,
|
|
221
|
+
margin: '0 auto'
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
{showIcon && icon}
|
|
225
|
+
<input
|
|
226
|
+
ref={inputRef}
|
|
227
|
+
type="search"
|
|
228
|
+
value={term}
|
|
229
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
230
|
+
placeholder={placeholder}
|
|
231
|
+
aria-label={placeholder}
|
|
232
|
+
data-search-input=""
|
|
233
|
+
{...partAttrs('input')}
|
|
234
|
+
style={{
|
|
235
|
+
flex: 1,
|
|
236
|
+
border: 'none',
|
|
237
|
+
borderBottom: '1px solid #d1d5db',
|
|
238
|
+
background: 'transparent',
|
|
239
|
+
fontSize: '18px',
|
|
240
|
+
padding: '8px 0',
|
|
241
|
+
outline: 'none'
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
{submitButton}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
aria-label={closeLabel}
|
|
248
|
+
data-search-close=""
|
|
249
|
+
{...partAttrs('close')}
|
|
250
|
+
onClick={() => setOpen(false)}
|
|
251
|
+
style={{
|
|
252
|
+
background: 'none',
|
|
253
|
+
border: 'none',
|
|
254
|
+
cursor: 'pointer',
|
|
255
|
+
padding: '4px'
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<svg
|
|
259
|
+
width="20"
|
|
260
|
+
height="20"
|
|
261
|
+
viewBox="0 0 24 24"
|
|
262
|
+
fill="none"
|
|
263
|
+
stroke="currentColor"
|
|
264
|
+
strokeWidth="1.8"
|
|
265
|
+
strokeLinecap="round"
|
|
266
|
+
>
|
|
267
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
268
|
+
</svg>
|
|
269
|
+
</button>
|
|
270
|
+
</form>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</>
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export default SearchBoxBlock;
|