@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.
@@ -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;