@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,177 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
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: currency switcher, the mirror of the language
22
+ * selector. Currencies come from the storefront settings through
23
+ * `useLocalization()`; the editor owns placement, wording, separator and
24
+ * styling.
25
+ * Properties: mode, separator, showSeparator, uppercase, labelSource, gap,
26
+ * ariaLabel, emptyText.
27
+ * Parts: option-list, option (selected/hover), separator, dropdown.
28
+ */
29
+ const CurrencySelectorBlock = ({
30
+ block,
31
+ currentBreakpoint = 'desktop',
32
+ isDesigner = false
33
+ }: BlockRendererProps) => {
34
+ const { currency, currencies, setCurrency } = useLocalization();
35
+
36
+ const mode = String(
37
+ getResponsiveValue(block.properties?.mode, currentBreakpoint, 'inline')
38
+ );
39
+ const separator = String(
40
+ getResponsiveValue(block.properties?.separator, currentBreakpoint, '|')
41
+ );
42
+ const labelSource = String(
43
+ getResponsiveValue(
44
+ block.properties?.labelSource,
45
+ currentBreakpoint,
46
+ 'label'
47
+ )
48
+ );
49
+ const ariaLabel = String(
50
+ getResponsiveValue(
51
+ block.properties?.ariaLabel,
52
+ currentBreakpoint,
53
+ 'Currency'
54
+ )
55
+ );
56
+ // Empty on purpose: the editor placeholder has always been built from the
57
+ // separator, so an unset override must keep doing exactly that.
58
+ const emptyText = String(
59
+ getResponsiveValue(block.properties?.emptyText, currentBreakpoint, '')
60
+ );
61
+ const gap = clampNumber(
62
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 6),
63
+ 6,
64
+ 0,
65
+ 32
66
+ );
67
+ const showSeparator = readToggle(
68
+ block.properties?.showSeparator,
69
+ currentBreakpoint,
70
+ true
71
+ );
72
+ const uppercase = readToggle(
73
+ block.properties?.uppercase,
74
+ currentBreakpoint,
75
+ false
76
+ );
77
+
78
+ const items = Array.isArray(currencies) ? currencies : [];
79
+
80
+ const format = (value: string) =>
81
+ uppercase ? String(value).toUpperCase() : String(value);
82
+
83
+ const readLabel = (item: { label?: string; code?: string }): string =>
84
+ labelSource === 'code'
85
+ ? String(item.code ?? '')
86
+ : String(item.label || item.code || '');
87
+
88
+ const change = (value: string) => {
89
+ // setCurrency drops a cookie and reloads the page, which would tear the
90
+ // designer iframe out from under the author.
91
+ if (isDesigner || !value || value === currency) return;
92
+ setCurrency?.(value);
93
+ };
94
+
95
+ if (items.length === 0) {
96
+ return isDesigner ? (
97
+ <span
98
+ data-currency-selector=""
99
+ {...partAttrs('option-list', { empty: true })}
100
+ >
101
+ {emptyText || `TRY ${separator} USD`}
102
+ </span>
103
+ ) : null;
104
+ }
105
+
106
+ if (mode === 'select') {
107
+ return (
108
+ <select
109
+ data-currency-selector=""
110
+ {...partAttrs('dropdown')}
111
+ value={currency || ''}
112
+ onChange={(event) => change(event.target.value)}
113
+ aria-label={ariaLabel}
114
+ style={{
115
+ background: 'transparent',
116
+ border: 'none',
117
+ color: 'inherit',
118
+ cursor: 'pointer',
119
+ outline: 'none'
120
+ }}
121
+ >
122
+ {items.map((item) => (
123
+ <option key={item.code} value={item.code}>
124
+ {format(readLabel(item))}
125
+ </option>
126
+ ))}
127
+ </select>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <span
133
+ data-currency-selector=""
134
+ {...partAttrs('option-list')}
135
+ style={{
136
+ display: 'inline-flex',
137
+ alignItems: 'center',
138
+ gap: `${gap}px`
139
+ }}
140
+ >
141
+ {items.map((item, index) => {
142
+ const active = item.code === currency;
143
+
144
+ return (
145
+ <React.Fragment key={item.code}>
146
+ {index > 0 && showSeparator && (
147
+ <span aria-hidden="true" {...partAttrs('separator')}>
148
+ {separator}
149
+ </span>
150
+ )}
151
+ <button
152
+ type="button"
153
+ onClick={() => change(item.code)}
154
+ aria-current={active}
155
+ data-currency-option={active ? 'selected' : 'default'}
156
+ {...partAttrs('option', { selected: active })}
157
+ style={{
158
+ background: 'none',
159
+ border: 'none',
160
+ padding: 0,
161
+ cursor: 'pointer',
162
+ color: 'inherit',
163
+ font: 'inherit',
164
+ opacity: active ? 1 : 0.6,
165
+ fontWeight: active ? 600 : 400
166
+ }}
167
+ >
168
+ {format(readLabel(item))}
169
+ </button>
170
+ </React.Fragment>
171
+ );
172
+ })}
173
+ </span>
174
+ );
175
+ };
176
+
177
+ export default CurrencySelectorBlock;
@@ -0,0 +1,461 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useGetMenuQuery } from '@akinon/next/data/client/misc';
6
+ import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { useOverlay } from '../hooks/use-overlay';
8
+ import { BlockRendererProps } from './block-renderer-registry';
9
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
10
+ import { readToggle } from './product-block-utils';
11
+
12
+ interface MenuItemLike {
13
+ pk?: number | string;
14
+ label?: string;
15
+ url?: string;
16
+ children?: MenuItemLike[];
17
+ }
18
+
19
+ const clampNumber = (
20
+ value: unknown,
21
+ fallback: number,
22
+ min: number,
23
+ max: number
24
+ ) => {
25
+ const numeric = Math.round(Number(value));
26
+ if (!Number.isFinite(numeric)) return fallback;
27
+ return Math.min(max, Math.max(min, numeric));
28
+ };
29
+
30
+ /**
31
+ * Functional chrome block: hamburger button opening a slide-in drawer
32
+ * with the commerce menu tree — the standard mobile navigation. Menu
33
+ * CONTENT stays commerce-driven; the editor owns placement/visibility
34
+ * (typically visible on mobile only), the drawer geometry, every label and
35
+ * the styling of each piece.
36
+ * Properties: depth, parent, side, drawerWidth, drawerMaxWidth, iconSize,
37
+ * showTriggerLabel, triggerLabel, ariaLabel, closeLabel, toggleLabel,
38
+ * showOverlay, showClose, lockScroll, closeOnNavigate, expandMode, maxItems,
39
+ * emptyText.
40
+ * Parts: trigger, icon, overlay, drawer, header, close, menu, item-row, item,
41
+ * toggle, submenu, submenu-item, empty.
42
+ */
43
+ const HamburgerMenuBlock = ({
44
+ block,
45
+ placeholderId,
46
+ sectionId,
47
+ selectedBlockId = null,
48
+ currentBreakpoint = 'desktop',
49
+ isDesigner = false
50
+ }: BlockRendererProps) => {
51
+ const [open, setOpen] = useState(false);
52
+ const [expanded, setExpanded] = useState<string[]>([]);
53
+
54
+ const depth = Number(
55
+ getResponsiveValue(block.properties?.depth, currentBreakpoint, 2)
56
+ );
57
+ const parent = block.properties?.parent
58
+ ? String(getResponsiveValue(block.properties.parent, currentBreakpoint, ''))
59
+ : undefined;
60
+ const side = String(
61
+ getResponsiveValue(block.properties?.side, currentBreakpoint, 'left')
62
+ );
63
+ const drawerWidth = clampNumber(
64
+ getResponsiveValue(block.properties?.drawerWidth, currentBreakpoint, 320),
65
+ 320,
66
+ 200,
67
+ 720
68
+ );
69
+ const drawerMaxWidth = clampNumber(
70
+ getResponsiveValue(block.properties?.drawerMaxWidth, currentBreakpoint, 85),
71
+ 85,
72
+ 40,
73
+ 100
74
+ );
75
+ const iconSize = clampNumber(
76
+ getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 24),
77
+ 24,
78
+ 12,
79
+ 64
80
+ );
81
+ const ariaLabel = String(
82
+ getResponsiveValue(block.properties?.ariaLabel, currentBreakpoint, 'Menu')
83
+ );
84
+ const triggerLabel = String(
85
+ getResponsiveValue(
86
+ block.properties?.triggerLabel,
87
+ currentBreakpoint,
88
+ 'Menu'
89
+ )
90
+ );
91
+ const showTriggerLabel = readToggle(
92
+ block.properties?.showTriggerLabel,
93
+ currentBreakpoint,
94
+ false
95
+ );
96
+ const closeLabel = String(
97
+ getResponsiveValue(
98
+ block.properties?.closeLabel,
99
+ currentBreakpoint,
100
+ 'Close menu'
101
+ )
102
+ );
103
+ const toggleLabel = String(
104
+ getResponsiveValue(
105
+ block.properties?.toggleLabel,
106
+ currentBreakpoint,
107
+ 'Toggle submenu'
108
+ )
109
+ );
110
+ const emptyText = String(
111
+ getResponsiveValue(
112
+ block.properties?.emptyText,
113
+ currentBreakpoint,
114
+ 'Menu (no items)'
115
+ )
116
+ );
117
+ const showOverlay = readToggle(
118
+ block.properties?.showOverlay,
119
+ currentBreakpoint,
120
+ true
121
+ );
122
+ const showClose = readToggle(
123
+ block.properties?.showClose,
124
+ currentBreakpoint,
125
+ true
126
+ );
127
+ const lockScroll = readToggle(
128
+ block.properties?.lockScroll,
129
+ currentBreakpoint,
130
+ true
131
+ );
132
+ const closeOnNavigate = readToggle(
133
+ block.properties?.closeOnNavigate,
134
+ currentBreakpoint,
135
+ true
136
+ );
137
+ const expandMode = String(
138
+ getResponsiveValue(
139
+ block.properties?.expandMode,
140
+ currentBreakpoint,
141
+ 'single'
142
+ )
143
+ );
144
+ const maxItems = clampNumber(
145
+ getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
146
+ 0,
147
+ 0,
148
+ 60
149
+ );
150
+
151
+ const { data: menu } = useGetMenuQuery(
152
+ {
153
+ depth: Number.isFinite(depth) && depth > 0 ? depth : 2,
154
+ ...(parent ? { parent } : {})
155
+ } as Parameters<typeof useGetMenuQuery>[0],
156
+ { skip: !open }
157
+ );
158
+
159
+ const allItems: MenuItemLike[] = Array.isArray(menu) ? menu : [];
160
+ const items = maxItems > 0 ? allItems.slice(0, maxItems) : allItems;
161
+
162
+ /**
163
+ * The scroll lock is page-wide state, so it is the shared overlay
164
+ * primitive's counted one rather than a capture of `document.body` taken
165
+ * here.
166
+ *
167
+ * This block used to record the body's overflow itself, with no counter at
168
+ * all. Opened over a panel that had already frozen the page — a filter
169
+ * drawer, a quick view — it recorded `hidden` as "the page's own value", so
170
+ * that panel closing thawed the page under a drawer still up, and this
171
+ * drawer closing wrote `hidden` back for good and left a storefront nobody
172
+ * could scroll. The primitive keeps the page's own value with the first
173
+ * holder and restores it only when the last one lets go.
174
+ *
175
+ * `modal: false` is deliberate: the drawer draws a backdrop but has never
176
+ * carried `role="dialog"`, `aria-modal` or a focus trap, and giving it any
177
+ * of those means moving markup a published storefront already renders.
178
+ * `closeOnEscape: false` for the same reason — the drawer has never answered
179
+ * Escape, and arming it is a product decision, not part of this migration.
180
+ * What it joins the primitive for is the one counted lock and the one stack.
181
+ *
182
+ * `isDesigner` keeps it inert: the preview iframe is the author's canvas,
183
+ * not a shopper's viewport.
184
+ */
185
+ useOverlay({
186
+ open,
187
+ isDesigner,
188
+ modal: false,
189
+ lockScroll,
190
+ closeOnEscape: false
191
+ });
192
+
193
+ const isExpanded = (key: string) => expanded.includes(key);
194
+
195
+ const toggleExpanded = (key: string) =>
196
+ setExpanded((current) => {
197
+ if (current.includes(key)) {
198
+ return current.filter((entry) => entry !== key);
199
+ }
200
+ return expandMode === 'multi' ? [...current, key] : [key];
201
+ });
202
+
203
+ // Navigating inside the designer iframe would drop the author out of the
204
+ // page they are editing, so every drawer link is inert there.
205
+ const handleNavigate = (event: React.MouseEvent) => {
206
+ if (isDesigner) event.preventDefault();
207
+ if (closeOnNavigate) setOpen(false);
208
+ };
209
+
210
+ // A designer who marked a child as the row layout gets that layout repeated
211
+ // per drawer entry; otherwise the built-in rows render unchanged.
212
+ const customRows = hasItemTemplate(block, currentBreakpoint) ? (
213
+ <ItemTemplateList
214
+ block={block}
215
+ items={items.map((item, index) => ({
216
+ key: String(item.pk ?? index),
217
+ name: String(item.label ?? ''),
218
+ label: String(item.label ?? ''),
219
+ url: String(item.url ?? ''),
220
+ hasChildren: (item.children || []).length > 0
221
+ }))}
222
+ placeholderId={placeholderId}
223
+ sectionId={sectionId}
224
+ isDesigner={isDesigner}
225
+ selectedBlockId={selectedBlockId}
226
+ currentBreakpoint={currentBreakpoint}
227
+ designerPlaceholderCount={4}
228
+ />
229
+ ) : null;
230
+
231
+ return (
232
+ <>
233
+ <button
234
+ type="button"
235
+ data-hamburger-menu=""
236
+ {...partAttrs('trigger', { open })}
237
+ aria-label={ariaLabel}
238
+ aria-expanded={open}
239
+ onClick={() => setOpen(true)}
240
+ style={{
241
+ display: 'inline-flex',
242
+ alignItems: 'center',
243
+ gap: showTriggerLabel ? '8px' : undefined,
244
+ background: 'none',
245
+ border: 'none',
246
+ padding: 0,
247
+ cursor: 'pointer',
248
+ color: 'inherit'
249
+ }}
250
+ >
251
+ <svg
252
+ width={iconSize}
253
+ height={iconSize}
254
+ viewBox="0 0 24 24"
255
+ fill="none"
256
+ stroke="currentColor"
257
+ strokeWidth="1.8"
258
+ strokeLinecap="round"
259
+ data-hamburger-icon=""
260
+ {...partAttrs('icon')}
261
+ >
262
+ <path d="M4 6h16M4 12h16M4 18h16" />
263
+ </svg>
264
+ {showTriggerLabel && (
265
+ <span data-hamburger-label="" {...partAttrs('label')}>
266
+ {triggerLabel}
267
+ </span>
268
+ )}
269
+ </button>
270
+
271
+ {open && (
272
+ <div
273
+ data-hamburger-drawer=""
274
+ style={{ position: 'fixed', inset: 0, zIndex: 1000 }}
275
+ >
276
+ {showOverlay && (
277
+ <div
278
+ data-hamburger-overlay=""
279
+ {...partAttrs('overlay')}
280
+ onClick={() => setOpen(false)}
281
+ style={{
282
+ position: 'absolute',
283
+ inset: 0,
284
+ background: 'rgba(0,0,0,0.5)'
285
+ }}
286
+ />
287
+ )}
288
+ <div
289
+ data-hamburger-panel=""
290
+ {...partAttrs('drawer', { open: true })}
291
+ style={{
292
+ position: 'absolute',
293
+ top: 0,
294
+ bottom: 0,
295
+ [side === 'right' ? 'right' : 'left']: 0,
296
+ width: `min(${drawerWidth}px, ${drawerMaxWidth}vw)`,
297
+ background: '#ffffff',
298
+ color: '#111827',
299
+ display: 'flex',
300
+ flexDirection: 'column',
301
+ boxShadow: '0 0 24px rgba(0,0,0,0.2)',
302
+ overflowY: 'auto'
303
+ }}
304
+ >
305
+ {showClose && (
306
+ <div
307
+ data-hamburger-header=""
308
+ {...partAttrs('header')}
309
+ style={{
310
+ display: 'flex',
311
+ justifyContent: 'flex-end',
312
+ padding: '12px'
313
+ }}
314
+ >
315
+ <button
316
+ type="button"
317
+ aria-label={closeLabel}
318
+ data-hamburger-close=""
319
+ {...partAttrs('close')}
320
+ onClick={() => setOpen(false)}
321
+ style={{
322
+ background: 'none',
323
+ border: 'none',
324
+ cursor: 'pointer',
325
+ padding: '4px'
326
+ }}
327
+ >
328
+ <svg
329
+ width="20"
330
+ height="20"
331
+ viewBox="0 0 24 24"
332
+ fill="none"
333
+ stroke="currentColor"
334
+ strokeWidth="1.8"
335
+ strokeLinecap="round"
336
+ >
337
+ <path d="M6 6l12 12M18 6L6 18" />
338
+ </svg>
339
+ </button>
340
+ </div>
341
+ )}
342
+ <nav
343
+ data-hamburger-nav=""
344
+ {...partAttrs('menu')}
345
+ style={{ padding: '0 16px 24px' }}
346
+ >
347
+ {items.length === 0 && !customRows && isDesigner && (
348
+ <span
349
+ data-hamburger-empty=""
350
+ {...partAttrs('empty', { empty: true })}
351
+ style={{ display: 'block', padding: '14px 0', opacity: 0.6 }}
352
+ >
353
+ {emptyText}
354
+ </span>
355
+ )}
356
+
357
+ {customRows ??
358
+ items.map((item, index) => {
359
+ const key = String(item.pk ?? index);
360
+ const hasChildren = (item.children || []).length > 0;
361
+ const rowOpen = hasChildren && isExpanded(key);
362
+
363
+ return (
364
+ <div
365
+ key={key}
366
+ data-hamburger-item=""
367
+ {...partAttrs('item-row', { open: rowOpen })}
368
+ style={{ borderBottom: '1px solid #f3f4f6' }}
369
+ >
370
+ <div
371
+ style={{
372
+ display: 'flex',
373
+ alignItems: 'center',
374
+ justifyContent: 'space-between'
375
+ }}
376
+ >
377
+ <NextLink
378
+ href={item.url || '#'}
379
+ data-hamburger-link=""
380
+ {...partAttrs('item')}
381
+ onClick={handleNavigate}
382
+ style={{
383
+ display: 'block',
384
+ padding: '14px 0',
385
+ color: 'inherit',
386
+ textDecoration: 'none',
387
+ fontWeight: 500
388
+ }}
389
+ >
390
+ {item.label}
391
+ </NextLink>
392
+ {hasChildren && (
393
+ <button
394
+ type="button"
395
+ aria-label={toggleLabel}
396
+ aria-expanded={rowOpen}
397
+ data-hamburger-toggle=""
398
+ {...partAttrs('toggle', { open: rowOpen })}
399
+ onClick={() => toggleExpanded(key)}
400
+ style={{
401
+ background: 'none',
402
+ border: 'none',
403
+ cursor: 'pointer',
404
+ padding: '8px'
405
+ }}
406
+ >
407
+ <svg
408
+ width="16"
409
+ height="16"
410
+ viewBox="0 0 24 24"
411
+ fill="none"
412
+ stroke="currentColor"
413
+ strokeWidth="1.8"
414
+ style={{
415
+ transform: rowOpen ? 'rotate(180deg)' : 'none',
416
+ transition: 'transform 150ms'
417
+ }}
418
+ >
419
+ <path d="M6 9l6 6 6-6" />
420
+ </svg>
421
+ </button>
422
+ )}
423
+ </div>
424
+ {hasChildren && rowOpen && (
425
+ <div
426
+ data-hamburger-submenu=""
427
+ {...partAttrs('submenu', { open: true })}
428
+ style={{ paddingBottom: '10px' }}
429
+ >
430
+ {(item.children || []).map((child, childIndex) => (
431
+ <NextLink
432
+ key={child.pk ?? childIndex}
433
+ href={child.url || '#'}
434
+ data-hamburger-submenu-link=""
435
+ {...partAttrs('submenu-item')}
436
+ onClick={handleNavigate}
437
+ style={{
438
+ display: 'block',
439
+ padding: '8px 0 8px 12px',
440
+ color: '#4b5563',
441
+ textDecoration: 'none',
442
+ fontSize: '14px'
443
+ }}
444
+ >
445
+ {child.label}
446
+ </NextLink>
447
+ ))}
448
+ </div>
449
+ )}
450
+ </div>
451
+ );
452
+ })}
453
+ </nav>
454
+ </div>
455
+ </div>
456
+ )}
457
+ </>
458
+ );
459
+ };
460
+
461
+ export default HamburgerMenuBlock;