@akinon/pz-theme 2.0.38-rc.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,133 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useSession } from 'next-auth/react';
6
+ import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+ import { readToggle } from './product-block-utils';
9
+
10
+ const clampNumber = (
11
+ value: unknown,
12
+ fallback: number,
13
+ min: number,
14
+ max: number
15
+ ) => {
16
+ const numeric = Math.round(Number(value));
17
+ if (!Number.isFinite(numeric)) return fallback;
18
+ return Math.min(max, Math.max(min, numeric));
19
+ };
20
+
21
+ /**
22
+ * Functional chrome block: session-aware account entry point. Renders a
23
+ * login link for guests and an account link for authenticated users; the
24
+ * editor owns placement, labels, icon and styling.
25
+ * Properties: accountHref, loginHref, accountLabel, loginLabel, showLabel,
26
+ * showIcon, iconSize, gap, labelPosition, ariaLabel.
27
+ * Parts: link, icon, label. The signed-in state stays readable from custom
28
+ * CSS through the block's own `data-auth-state` attribute.
29
+ */
30
+ const AccountButtonBlock = ({
31
+ block,
32
+ currentBreakpoint = 'desktop',
33
+ isDesigner = false
34
+ }: BlockRendererProps) => {
35
+ const { status } = useSession();
36
+ const isAuthenticated = status === 'authenticated';
37
+
38
+ const prop = (key: string, fallback: string) =>
39
+ String(
40
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
41
+ );
42
+
43
+ const href = isAuthenticated
44
+ ? prop('accountHref', '/account')
45
+ : prop('loginHref', '/auth');
46
+ const label = isAuthenticated
47
+ ? prop('accountLabel', 'Account')
48
+ : prop('loginLabel', 'Login');
49
+ const showLabel =
50
+ getResponsiveValue(
51
+ block.properties?.showLabel,
52
+ currentBreakpoint,
53
+ false
54
+ ) === true ||
55
+ String(
56
+ getResponsiveValue(
57
+ block.properties?.showLabel,
58
+ currentBreakpoint,
59
+ 'false'
60
+ )
61
+ ) === 'true';
62
+ const showIcon = readToggle(
63
+ block.properties?.showIcon,
64
+ currentBreakpoint,
65
+ true
66
+ );
67
+ const iconSize = clampNumber(
68
+ getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 24),
69
+ 24,
70
+ 12,
71
+ 64
72
+ );
73
+ const gap = clampNumber(
74
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 6),
75
+ 6,
76
+ 0,
77
+ 32
78
+ );
79
+ const labelPosition = prop('labelPosition', 'after');
80
+ // Empty on purpose: the link has always announced itself with its own
81
+ // label, so an unset override must keep doing exactly that.
82
+ const ariaLabel = prop('ariaLabel', '');
83
+
84
+ const labelNode = showLabel ? (
85
+ <span data-account-label="" {...partAttrs('label')}>
86
+ {label}
87
+ </span>
88
+ ) : null;
89
+
90
+ return (
91
+ <NextLink
92
+ href={href}
93
+ data-account-button=""
94
+ data-auth-state={isAuthenticated ? 'authenticated' : 'guest'}
95
+ {...partAttrs('link')}
96
+ aria-label={ariaLabel || label}
97
+ // Navigating inside the designer iframe would drop the author out of the
98
+ // page they are editing.
99
+ onClick={(event) => {
100
+ if (isDesigner) event.preventDefault();
101
+ }}
102
+ style={{
103
+ display: 'inline-flex',
104
+ alignItems: 'center',
105
+ gap: `${gap}px`,
106
+ color: 'inherit',
107
+ textDecoration: 'none'
108
+ }}
109
+ >
110
+ {labelPosition === 'before' && labelNode}
111
+
112
+ {showIcon && (
113
+ <svg
114
+ width={iconSize}
115
+ height={iconSize}
116
+ viewBox="0 0 24 24"
117
+ fill="none"
118
+ stroke="currentColor"
119
+ strokeWidth="1.5"
120
+ data-account-icon=""
121
+ {...partAttrs('icon')}
122
+ >
123
+ <circle cx="12" cy="8" r="4" />
124
+ <path d="M4 20c0-4 3.6-6 8-6s8 2 8 6" />
125
+ </svg>
126
+ )}
127
+
128
+ {labelPosition !== 'before' && labelNode}
129
+ </NextLink>
130
+ );
131
+ };
132
+
133
+ export default AccountButtonBlock;
@@ -0,0 +1,407 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo, useState } from 'react';
4
+ import ThemeBlock, { Block } from '../theme-block';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import { readToggle } from './product-block-utils';
8
+ import {
9
+ findItemTemplate,
10
+ hasItemTemplate,
11
+ ItemTemplateList
12
+ } from './item-template';
13
+
14
+ const DISMISS_KEY_PREFIX = 'pz-announcement-dismissed:';
15
+
16
+ const readDismissed = (key: string): boolean => {
17
+ // sessionStorage throws in Safari private mode and is absent during SSR.
18
+ try {
19
+ return window.sessionStorage.getItem(key) === '1';
20
+ } catch {
21
+ return false;
22
+ }
23
+ };
24
+
25
+ const writeDismissed = (key: string) => {
26
+ try {
27
+ window.sessionStorage.setItem(key, '1');
28
+ } catch {
29
+ // A storage failure only costs the shopper the memory of the dismissal.
30
+ }
31
+ };
32
+
33
+ /**
34
+ * Close glyphs on the same 24x24 stroke grid the other chrome blocks draw on,
35
+ * so `stroke: currentColor` keeps the strip's colour. `cross` is the shape the
36
+ * bar has always shipped with and stays the default.
37
+ */
38
+ const CLOSE_ICONS: Record<string, React.ReactNode> = {
39
+ cross: <path d="M6 6l12 12M18 6L6 18" />,
40
+ 'cross-circle': (
41
+ <>
42
+ <circle cx="12" cy="12" r="9" />
43
+ <path d="M9.5 9.5l5 5M14.5 9.5l-5 5" />
44
+ </>
45
+ ),
46
+ chevron: <path d="M9 6l6 6-6 6" />,
47
+ minus: <path d="M6 12h12" />
48
+ };
49
+
50
+ const ALIGNMENTS = new Set([
51
+ 'center',
52
+ 'flex-start',
53
+ 'flex-end',
54
+ 'space-between'
55
+ ]);
56
+
57
+ const clampNumber = (
58
+ value: unknown,
59
+ fallback: number,
60
+ min: number,
61
+ max: number
62
+ ) => {
63
+ const numeric = Math.round(Number(value));
64
+ if (!Number.isFinite(numeric)) return fallback;
65
+ return Math.min(max, Math.max(min, numeric));
66
+ };
67
+
68
+ /**
69
+ * Functional chrome block: the thin strip above the header. Messages are
70
+ * authored either as child blocks (text, link, icon — the richer path, which
71
+ * wins when present) or as a separator-delimited list in the `messages`
72
+ * property; a group marked as the row layout repeats the designer's own layout
73
+ * once per message instead. The strip rotates on an editor-owned interval and
74
+ * can be dismissed for the rest of the browsing session.
75
+ * Properties: messages, separator, maxMessages, rotateSeconds, pauseOnHover,
76
+ * align, gap, regionLabel, dismissible, closeLabel, closeIcon, closeIconSize,
77
+ * closePosition.
78
+ * Parts: strip, message-list, message, close, icon, empty-state.
79
+ */
80
+ const AnnouncementBarBlock = ({
81
+ block,
82
+ placeholderId,
83
+ sectionId,
84
+ selectedBlockId = null,
85
+ currentBreakpoint = 'desktop',
86
+ isDesigner = false
87
+ }: BlockRendererProps) => {
88
+ const readText = (key: string, fallback: string): string => {
89
+ const value = getResponsiveValue(
90
+ block.properties?.[key],
91
+ currentBreakpoint,
92
+ fallback
93
+ );
94
+ return value === undefined || value === null ? fallback : String(value);
95
+ };
96
+
97
+ const rotateSeconds = Number(
98
+ getResponsiveValue(block.properties?.rotateSeconds, currentBreakpoint, 5)
99
+ );
100
+ const dismissible = readToggle(
101
+ block.properties?.dismissible,
102
+ currentBreakpoint,
103
+ true
104
+ );
105
+ const pauseOnHover = readToggle(
106
+ block.properties?.pauseOnHover,
107
+ currentBreakpoint,
108
+ false
109
+ );
110
+ const messagesText = String(
111
+ getResponsiveValue(block.properties?.messages, currentBreakpoint, '')
112
+ );
113
+ // An empty separator would split the text into single characters, so the
114
+ // authored value only counts when it has something to split on.
115
+ const separatorValue = readText('separator', '|');
116
+ const separator = separatorValue.length > 0 ? separatorValue : '|';
117
+ const regionLabel = readText('regionLabel', 'Announcements');
118
+ const closeLabel = readText('closeLabel', 'Close announcement');
119
+ const closeIcon =
120
+ CLOSE_ICONS[readText('closeIcon', 'cross')] || CLOSE_ICONS.cross;
121
+ const closeIconSize = clampNumber(
122
+ getResponsiveValue(block.properties?.closeIconSize, currentBreakpoint, 16),
123
+ 16,
124
+ 8,
125
+ 48
126
+ );
127
+ const closeAtStart = readText('closePosition', 'end') === 'start';
128
+ const alignValue = readText('align', 'center');
129
+ const align = ALIGNMENTS.has(alignValue) ? alignValue : 'center';
130
+ const gap = clampNumber(
131
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 12),
132
+ 12,
133
+ 0,
134
+ 64
135
+ );
136
+ const maxMessages = clampNumber(
137
+ getResponsiveValue(block.properties?.maxMessages, currentBreakpoint, 0),
138
+ 0,
139
+ 0,
140
+ 20
141
+ );
142
+
143
+ // The row layout lives among the children, so it must not also be drawn as
144
+ // one of the messages.
145
+ const template = findItemTemplate(block, currentBreakpoint);
146
+ const templateId = template?.id;
147
+
148
+ const childBlocks: Block[] = useMemo(
149
+ () =>
150
+ (block.blocks || [])
151
+ .filter((child) => (isDesigner ? true : !child.hidden))
152
+ .filter((child) => !templateId || child.id !== templateId)
153
+ .sort((a, b) => (a.order || 0) - (b.order || 0)),
154
+ [block.blocks, isDesigner, templateId]
155
+ );
156
+
157
+ const textMessages = useMemo(() => {
158
+ const entries = messagesText
159
+ .split(separator)
160
+ .map((entry) => entry.trim())
161
+ .filter(Boolean);
162
+
163
+ return maxMessages > 0 ? entries.slice(0, maxMessages) : entries;
164
+ }, [messagesText, separator, maxMessages]);
165
+
166
+ const messageItems = useMemo(
167
+ () =>
168
+ textMessages.map((text, index) => ({
169
+ text,
170
+ label: text,
171
+ value: text,
172
+ index: index + 1
173
+ })),
174
+ [textMessages]
175
+ );
176
+
177
+ const visibleChildBlocks =
178
+ maxMessages > 0 ? childBlocks.slice(0, maxMessages) : childBlocks;
179
+
180
+ const useCustomRows =
181
+ hasItemTemplate(block, currentBreakpoint) &&
182
+ (textMessages.length > 0 || isDesigner);
183
+
184
+ const count = useCustomRows
185
+ ? textMessages.length
186
+ : visibleChildBlocks.length > 0
187
+ ? visibleChildBlocks.length
188
+ : textMessages.length;
189
+
190
+ const [activeIndex, setActiveIndex] = useState(0);
191
+ const [dismissed, setDismissed] = useState(false);
192
+ const [paused, setPaused] = useState(false);
193
+
194
+ const dismissKey = `${DISMISS_KEY_PREFIX}${block.id || 'announcement'}`;
195
+
196
+ useEffect(() => {
197
+ // Read after mount only: the server has no session storage, so deciding
198
+ // during render would hydrate as a mismatch. The designer always shows the
199
+ // strip, otherwise an author could not style a bar they once closed.
200
+ if (isDesigner) return;
201
+ setDismissed(readDismissed(dismissKey));
202
+ }, [dismissKey, isDesigner]);
203
+
204
+ useEffect(() => {
205
+ setActiveIndex(0);
206
+ }, [count]);
207
+
208
+ useEffect(() => {
209
+ // The designer stacks every message so each one stays selectable, so there
210
+ // is nothing to rotate there.
211
+ if (isDesigner || count < 2 || paused) return;
212
+ if (!Number.isFinite(rotateSeconds) || rotateSeconds <= 0) return;
213
+
214
+ const timer = setInterval(
215
+ () => setActiveIndex((index) => (index + 1) % count),
216
+ rotateSeconds * 1000
217
+ );
218
+
219
+ return () => clearInterval(timer);
220
+ }, [isDesigner, count, rotateSeconds, paused]);
221
+
222
+ if (count === 0 && !useCustomRows) {
223
+ return isDesigner ? (
224
+ <div
225
+ data-announcement-bar=""
226
+ data-announcement-bar-state="empty"
227
+ {...partAttrs('empty-state', { empty: true })}
228
+ >
229
+ Announcement bar (no messages)
230
+ </div>
231
+ ) : null;
232
+ }
233
+
234
+ if (dismissed && !isDesigner) return null;
235
+
236
+ const dismiss = () => {
237
+ // Dismissal must not leak out of the designer into the author's session.
238
+ if (isDesigner) return;
239
+ setDismissed(true);
240
+ writeDismissed(dismissKey);
241
+ };
242
+
243
+ const messageStyle = (index: number): React.CSSProperties =>
244
+ isDesigner
245
+ ? { display: 'block' }
246
+ : { display: index === activeIndex ? 'block' : 'none' };
247
+
248
+ const createActionHandler = (actionType: string, blockId?: string) => () => {
249
+ if (window.parent) {
250
+ window.parent.postMessage(
251
+ { type: actionType, data: { placeholderId, sectionId, blockId } },
252
+ '*'
253
+ );
254
+ }
255
+ };
256
+
257
+ // Rename is a separate message because it carries the new label; without it
258
+ // the toolbar exposes no way to retitle a message block.
259
+ const createRenameHandler =
260
+ (blockId?: string) => (newLabel: string) => {
261
+ if (window.parent) {
262
+ window.parent.postMessage(
263
+ {
264
+ type: 'RENAME_BLOCK',
265
+ data: { placeholderId, sectionId, blockId, label: newLabel }
266
+ },
267
+ '*'
268
+ );
269
+ }
270
+ };
271
+
272
+ const customRows = useCustomRows ? (
273
+ <ItemTemplateList
274
+ block={block}
275
+ items={messageItems}
276
+ placeholderId={placeholderId}
277
+ sectionId={sectionId}
278
+ isDesigner={isDesigner}
279
+ selectedBlockId={selectedBlockId}
280
+ currentBreakpoint={currentBreakpoint}
281
+ designerPlaceholderCount={2}
282
+ />
283
+ ) : null;
284
+
285
+ // Custom rows are rendered by the iterator, so the per-message `display`
286
+ // the built-in path applies inline is not available. A scoped rule hides
287
+ // every row but the active one, which keeps rotation working for a
288
+ // designer-authored layout. `:not()` rather than a display value so the
289
+ // row keeps whatever display its own styles give it.
290
+ const stripId = String(block.id || 'announcement').replace(
291
+ /[^a-zA-Z0-9_-]/g,
292
+ ''
293
+ );
294
+ const rotationCss =
295
+ !isDesigner && useCustomRows && count > 1
296
+ ? `[data-announcement-id="${stripId}"] [data-part="message-list"] > *:not(:nth-child(${
297
+ activeIndex + 1
298
+ })){display:none !important;}`
299
+ : '';
300
+
301
+ return (
302
+ <div
303
+ data-announcement-bar=""
304
+ data-announcement-bar-index={activeIndex}
305
+ data-announcement-id={stripId}
306
+ {...partAttrs('strip')}
307
+ role="region"
308
+ aria-label={regionLabel || undefined}
309
+ aria-live="polite"
310
+ onMouseEnter={pauseOnHover ? () => setPaused(true) : undefined}
311
+ onMouseLeave={pauseOnHover ? () => setPaused(false) : undefined}
312
+ style={{
313
+ display: 'flex',
314
+ alignItems: 'center',
315
+ justifyContent: align,
316
+ flexDirection: closeAtStart ? 'row-reverse' : 'row',
317
+ gap: `${gap}px`,
318
+ width: '100%'
319
+ }}
320
+ >
321
+ {rotationCss && <style>{rotationCss}</style>}
322
+
323
+ <div
324
+ data-announcement-messages=""
325
+ {...partAttrs('message-list')}
326
+ style={{ flex: '1 1 auto', textAlign: 'center', minWidth: 0 }}
327
+ >
328
+ {customRows ??
329
+ (visibleChildBlocks.length > 0
330
+ ? visibleChildBlocks.map((child, index) => (
331
+ <div
332
+ key={child.id || `announcement-${index}`}
333
+ data-announcement-message=""
334
+ {...partAttrs('message', { selected: index === activeIndex })}
335
+ style={messageStyle(index)}
336
+ >
337
+ <ThemeBlock
338
+ block={child}
339
+ placeholderId={placeholderId}
340
+ sectionId={sectionId}
341
+ isDesigner={isDesigner}
342
+ isSelected={selectedBlockId === child.id}
343
+ selectedBlockId={selectedBlockId}
344
+ currentBreakpoint={currentBreakpoint}
345
+ onMoveUp={createActionHandler('MOVE_BLOCK_UP', child.id)}
346
+ onMoveDown={createActionHandler('MOVE_BLOCK_DOWN', child.id)}
347
+ onDuplicate={createActionHandler(
348
+ 'DUPLICATE_BLOCK',
349
+ child.id
350
+ )}
351
+ onToggleVisibility={createActionHandler(
352
+ 'TOGGLE_BLOCK_VISIBILITY',
353
+ child.id
354
+ )}
355
+ onDelete={createActionHandler('DELETE_BLOCK', child.id)}
356
+ onRename={createRenameHandler(child.id)}
357
+ />
358
+ </div>
359
+ ))
360
+ : textMessages.map((message, index) => (
361
+ <div
362
+ key={`${message}-${index}`}
363
+ data-announcement-message=""
364
+ {...partAttrs('message', { selected: index === activeIndex })}
365
+ style={messageStyle(index)}
366
+ >
367
+ {message}
368
+ </div>
369
+ )))}
370
+ </div>
371
+
372
+ {dismissible && (
373
+ <button
374
+ type="button"
375
+ data-announcement-close=""
376
+ {...partAttrs('close')}
377
+ aria-label={closeLabel || undefined}
378
+ onClick={dismiss}
379
+ style={{
380
+ flex: '0 0 auto',
381
+ background: 'none',
382
+ border: 'none',
383
+ padding: 0,
384
+ cursor: 'pointer',
385
+ color: 'inherit',
386
+ lineHeight: 0
387
+ }}
388
+ >
389
+ <svg
390
+ width={closeIconSize}
391
+ height={closeIconSize}
392
+ viewBox="0 0 24 24"
393
+ fill="none"
394
+ stroke="currentColor"
395
+ strokeWidth="1.8"
396
+ strokeLinecap="round"
397
+ {...partAttrs('icon')}
398
+ >
399
+ {closeIcon}
400
+ </svg>
401
+ </button>
402
+ )}
403
+ </div>
404
+ );
405
+ };
406
+
407
+ export default AnnouncementBarBlock;
@@ -18,6 +18,13 @@ import LinkBlock from './link-block';
18
18
  import LottieBlock from './lottie-block';
19
19
  import EmbedBlock from './embed-block';
20
20
  import MapBlock from './map-block';
21
+ import NavMenuBlock from './nav-menu-block';
22
+ import MiniBasketBlock from './mini-basket-block';
23
+ import HamburgerMenuBlock from './hamburger-menu-block';
24
+ import AccountButtonBlock from './account-button-block';
25
+ import SearchBoxBlock from './search-box-block';
26
+ import WishlistButtonBlock from './wishlist-button-block';
27
+ import LanguageSelectorBlock from './language-selector-block';
21
28
  import ProductGalleryBlock from './product-gallery-block';
22
29
  import ProductTitleBlock from './product-title-block';
23
30
  import ProductPriceBlock from './product-price-block';
@@ -43,6 +50,12 @@ import ChangePasswordFormBlock from './change-password-form-block';
43
50
  import OrderDetailBlock from './order-detail-block';
44
51
  import CouponListBlock from './coupon-list-block';
45
52
  import FavouritesGridBlock from './favourites-grid-block';
53
+ import MegaMenuBlock from './mega-menu-block';
54
+ import CurrencySelectorBlock from './currency-selector-block';
55
+ import AnnouncementBarBlock from './announcement-bar-block';
56
+ import NewsletterFormBlock from './newsletter-form-block';
57
+ import SocialLinksBlock from './social-links-block';
58
+ import PaymentBadgesBlock from './payment-badges-block';
46
59
  import CheckoutSummaryBlock from './checkout-summary-block';
47
60
  import CheckoutStepsBlock from './checkout-steps-block';
48
61
  import OrderSuccessBlock from './order-success-block';
@@ -115,6 +128,13 @@ blockRendererRegistry.register('link', LinkBlock);
115
128
  blockRendererRegistry.register('lottie', LottieBlock);
116
129
  blockRendererRegistry.register('embed', EmbedBlock);
117
130
  blockRendererRegistry.register('map', MapBlock);
131
+ blockRendererRegistry.register('nav-menu', NavMenuBlock);
132
+ blockRendererRegistry.register('mini-basket', MiniBasketBlock);
133
+ blockRendererRegistry.register('hamburger-menu', HamburgerMenuBlock);
134
+ blockRendererRegistry.register('account-button', AccountButtonBlock);
135
+ blockRendererRegistry.register('search-box', SearchBoxBlock);
136
+ blockRendererRegistry.register('wishlist-button', WishlistButtonBlock);
137
+ blockRendererRegistry.register('language-selector', LanguageSelectorBlock);
118
138
  blockRendererRegistry.register('product-gallery', ProductGalleryBlock);
119
139
  blockRendererRegistry.register('product-title', ProductTitleBlock);
120
140
  blockRendererRegistry.register('product-price', ProductPriceBlock);
@@ -144,6 +164,12 @@ blockRendererRegistry.register('coupon-list', CouponListBlock);
144
164
  blockRendererRegistry.register('favourites-grid', FavouritesGridBlock);
145
165
 
146
166
  // Chrome extras
167
+ blockRendererRegistry.register('mega-menu', MegaMenuBlock);
168
+ blockRendererRegistry.register('currency-selector', CurrencySelectorBlock);
169
+ blockRendererRegistry.register('announcement-bar', AnnouncementBarBlock);
170
+ blockRendererRegistry.register('newsletter-form', NewsletterFormBlock);
171
+ blockRendererRegistry.register('social-links', SocialLinksBlock);
172
+ blockRendererRegistry.register('payment-badges', PaymentBadgesBlock);
147
173
 
148
174
  // Order flow
149
175
  blockRendererRegistry.register('checkout-summary', CheckoutSummaryBlock);