@akinon/pz-theme 2.0.38-rc.0 → 2.0.39-beta.0
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 +15 -31
- 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,391 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
8
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
9
|
+
|
|
10
|
+
interface SocialLinkItem {
|
|
11
|
+
icon?: string;
|
|
12
|
+
link?: string;
|
|
13
|
+
size?: number | string;
|
|
14
|
+
color?: string;
|
|
15
|
+
/** Per-entry override for the accessible name and the optional caption. */
|
|
16
|
+
label?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stroke glyphs on the shared 24x24 grid the other chrome blocks draw on, so a
|
|
21
|
+
* single `stroke: currentColor` inherits the header colour. The keys are the
|
|
22
|
+
* platform values the editor's social icon picker stores.
|
|
23
|
+
*/
|
|
24
|
+
const SOCIAL_GLYPHS: Record<string, React.ReactNode> = {
|
|
25
|
+
facebook: (
|
|
26
|
+
<path d="M14 8.5V7a1.5 1.5 0 0 1 1.5-1.5H17V3h-2A4 4 0 0 0 11 7v1.5H9V11h2v10h3V11h2.2l.4-2.5H14z" />
|
|
27
|
+
),
|
|
28
|
+
instagram: (
|
|
29
|
+
<>
|
|
30
|
+
<rect x="3" y="3" width="18" height="18" rx="5" />
|
|
31
|
+
<circle cx="12" cy="12" r="4" />
|
|
32
|
+
<circle cx="17.4" cy="6.6" r="0.6" />
|
|
33
|
+
</>
|
|
34
|
+
),
|
|
35
|
+
twitter: <path d="M4 4l16 16M20 4L4 20" />,
|
|
36
|
+
youtube: (
|
|
37
|
+
<>
|
|
38
|
+
<rect x="2.5" y="6" width="19" height="12" rx="4" />
|
|
39
|
+
<path d="M10 9.5l5 2.5-5 2.5z" />
|
|
40
|
+
</>
|
|
41
|
+
),
|
|
42
|
+
linkedin: (
|
|
43
|
+
<>
|
|
44
|
+
<path d="M6.5 9.5V20" />
|
|
45
|
+
<circle cx="6.5" cy="5" r="1.1" />
|
|
46
|
+
<path d="M11 20v-6.5a3.5 3.5 0 0 1 7 0V20" />
|
|
47
|
+
</>
|
|
48
|
+
),
|
|
49
|
+
tiktok: (
|
|
50
|
+
<>
|
|
51
|
+
<path d="M14 4v11a4 4 0 1 1-4-4" />
|
|
52
|
+
<path d="M14 4c.5 2.6 2.2 4.2 5 4.4" />
|
|
53
|
+
</>
|
|
54
|
+
),
|
|
55
|
+
pinterest: (
|
|
56
|
+
<>
|
|
57
|
+
<circle cx="12" cy="12" r="9" />
|
|
58
|
+
<path d="M9.5 21l2.2-8.5" />
|
|
59
|
+
<path d="M9 11a3 3 0 1 1 5 2.2" />
|
|
60
|
+
</>
|
|
61
|
+
),
|
|
62
|
+
whatsapp: (
|
|
63
|
+
<>
|
|
64
|
+
<path d="M3.5 20.5l1.4-4.2A8.2 8.2 0 1 1 8 19.4z" />
|
|
65
|
+
<path d="M9 9.5c0 3 2.5 5.5 5.5 5.5l1-1.6-2-1-1 .9a5 5 0 0 1-1.8-1.8l.9-1-1-2z" />
|
|
66
|
+
</>
|
|
67
|
+
),
|
|
68
|
+
telegram: <path d="M21 4.5L2.8 11.3l5 1.8L18 7l-7.5 7.4.3 5 2.7-3.4 4 3z" />,
|
|
69
|
+
discord: (
|
|
70
|
+
<>
|
|
71
|
+
<path d="M9 18c-2 0-4-1-4.5-2.5C4 12 5 8.5 6.5 7A11 11 0 0 1 12 5.8 11 11 0 0 1 17.5 7C19 8.5 20 12 19.5 15.5 19 17 17 18 15 18" />
|
|
72
|
+
<circle cx="9.5" cy="13" r="1.1" />
|
|
73
|
+
<circle cx="14.5" cy="13" r="1.1" />
|
|
74
|
+
</>
|
|
75
|
+
),
|
|
76
|
+
snapchat: (
|
|
77
|
+
<path d="M12 4c2.8 0 4.3 2 4.3 4.6v2c.9.5 1.8.4 2.4.9-.8 1.4-1.9 1.5-1.9 2.3 0 1 1.7 2 2.7 2.2-.9 1.2-2.8 1.2-3.7 1.9-.9.7-1.5 1.1-3.8 1.1s-2.9-.4-3.8-1.1c-.9-.7-2.8-.7-3.7-1.9 1-.2 2.7-1.2 2.7-2.2 0-.8-1.1-.9-1.9-2.3.6-.5 1.5-.4 2.4-.9v-2C7.7 6 9.2 4 12 4z" />
|
|
78
|
+
),
|
|
79
|
+
reddit: (
|
|
80
|
+
<>
|
|
81
|
+
<circle cx="12" cy="13" r="8" />
|
|
82
|
+
<circle cx="9.5" cy="12.5" r="1" />
|
|
83
|
+
<circle cx="14.5" cy="12.5" r="1" />
|
|
84
|
+
<path d="M9 16c1.8 1.2 4.2 1.2 6 0" />
|
|
85
|
+
<path d="M15 5.6l.7 3.4" />
|
|
86
|
+
<circle cx="16.4" cy="4.6" r="1.1" />
|
|
87
|
+
</>
|
|
88
|
+
),
|
|
89
|
+
twitch: (
|
|
90
|
+
<>
|
|
91
|
+
<path d="M4.5 4h15v9.5L15 18h-3l-3 3H7.5v-3h-3z" />
|
|
92
|
+
<path d="M11 8v4M15 8v4" />
|
|
93
|
+
</>
|
|
94
|
+
),
|
|
95
|
+
spotify: (
|
|
96
|
+
<>
|
|
97
|
+
<circle cx="12" cy="12" r="9" />
|
|
98
|
+
<path d="M7.5 9.5c3-1 6.5-.6 9 1" />
|
|
99
|
+
<path d="M8.2 13c2.4-.8 5.2-.5 7.3.9" />
|
|
100
|
+
<path d="M9 16.2c1.8-.6 3.9-.4 5.5.7" />
|
|
101
|
+
</>
|
|
102
|
+
),
|
|
103
|
+
apple: (
|
|
104
|
+
<>
|
|
105
|
+
<path d="M15.5 3c.2 1.4-.9 3-2.3 3.1-.2-1.4.9-3 2.3-3.1z" />
|
|
106
|
+
<path d="M18.5 16.5c-.6 1.6-1.9 3.5-3.2 3.5-1 0-1.4-.6-2.6-.6s-1.6.6-2.6.6c-1.4 0-3.2-2.6-3.7-4.6-.7-2.8 0-6.4 2.6-7 1.2-.3 2.3.6 3 .6.7 0 2-.8 3.3-.6.9.1 2.2.5 3 1.8-2.5 1.5-2.3 5 .2 6.3z" />
|
|
107
|
+
</>
|
|
108
|
+
),
|
|
109
|
+
google: (
|
|
110
|
+
<>
|
|
111
|
+
<path d="M20.5 12A8.5 8.5 0 1 1 17.9 5.9" />
|
|
112
|
+
<path d="M20.5 12H12" />
|
|
113
|
+
</>
|
|
114
|
+
),
|
|
115
|
+
email: (
|
|
116
|
+
<>
|
|
117
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
118
|
+
<path d="M3.5 6.5L12 13l8.5-6.5" />
|
|
119
|
+
</>
|
|
120
|
+
),
|
|
121
|
+
phone: (
|
|
122
|
+
<path d="M6 3h3l1.5 4-2 1.5a12 12 0 0 0 5 5L15 11.5 19 13v3.5a1.5 1.5 0 0 1-1.7 1.5A15.5 15.5 0 0 1 4.5 5.2 1.5 1.5 0 0 1 6 3z" />
|
|
123
|
+
),
|
|
124
|
+
globe: (
|
|
125
|
+
<>
|
|
126
|
+
<circle cx="12" cy="12" r="9" />
|
|
127
|
+
<path d="M3 12h18" />
|
|
128
|
+
<path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z" />
|
|
129
|
+
</>
|
|
130
|
+
)
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const PLATFORM_LABELS: Record<string, string> = {
|
|
134
|
+
twitter: 'Twitter / X',
|
|
135
|
+
globe: 'Website',
|
|
136
|
+
email: 'Email',
|
|
137
|
+
phone: 'Phone'
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const platformLabel = (platform: string) =>
|
|
141
|
+
PLATFORM_LABELS[platform] ||
|
|
142
|
+
(platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Link');
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `platform=Label` pairs, one per `;` or newline. The built-in names are
|
|
146
|
+
* English and singular ("Twitter / X"); a brand running in another language
|
|
147
|
+
* has no other way to reach the accessible name of an icon.
|
|
148
|
+
*/
|
|
149
|
+
const parseLabelOverrides = (raw: string): Record<string, string> =>
|
|
150
|
+
raw
|
|
151
|
+
.split(/[;\n]/)
|
|
152
|
+
.map((entry) => entry.trim())
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.reduce<Record<string, string>>((acc, entry) => {
|
|
155
|
+
const separatorIndex = entry.indexOf('=');
|
|
156
|
+
if (separatorIndex <= 0) return acc;
|
|
157
|
+
|
|
158
|
+
const key = entry.slice(0, separatorIndex).trim().toLowerCase();
|
|
159
|
+
const value = entry.slice(separatorIndex + 1).trim();
|
|
160
|
+
if (key && value) acc[key] = value;
|
|
161
|
+
|
|
162
|
+
return acc;
|
|
163
|
+
}, {});
|
|
164
|
+
|
|
165
|
+
const clampNumber = (
|
|
166
|
+
value: unknown,
|
|
167
|
+
fallback: number,
|
|
168
|
+
min: number,
|
|
169
|
+
max: number
|
|
170
|
+
) => {
|
|
171
|
+
const numeric = Math.round(Number(value));
|
|
172
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
173
|
+
return Math.min(max, Math.max(min, numeric));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Stroke width is the one dimension here that is meaningful below 1px. */
|
|
177
|
+
const clampDecimal = (
|
|
178
|
+
value: unknown,
|
|
179
|
+
fallback: number,
|
|
180
|
+
min: number,
|
|
181
|
+
max: number
|
|
182
|
+
) => {
|
|
183
|
+
const numeric = Number(value);
|
|
184
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
185
|
+
return Math.min(max, Math.max(min, numeric));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Functional chrome block: the row of social icons in the header or footer.
|
|
190
|
+
* The entries (platform + url, with an optional per-icon size, colour and
|
|
191
|
+
* label) are authored in the editor through the `items` repeatable property;
|
|
192
|
+
* the block owns the row layout, and a group marked as the row layout replaces
|
|
193
|
+
* the icon with whatever the designer built.
|
|
194
|
+
* Properties: items, iconSize, gap, strokeWidth, wrap, maxItems, showLabels,
|
|
195
|
+
* labelGap, showTooltip, openInNewTab, labelOverrides.
|
|
196
|
+
* Parts: list, link, icon, label.
|
|
197
|
+
*/
|
|
198
|
+
const SocialLinksBlock = ({
|
|
199
|
+
block,
|
|
200
|
+
placeholderId,
|
|
201
|
+
sectionId,
|
|
202
|
+
selectedBlockId = null,
|
|
203
|
+
currentBreakpoint = 'desktop',
|
|
204
|
+
isDesigner = false
|
|
205
|
+
}: BlockRendererProps) => {
|
|
206
|
+
// Read straight off properties: `items` is an array, and getResponsiveValue
|
|
207
|
+
// treats arrays as a malformed responsive map and returns the fallback.
|
|
208
|
+
const rawItems = block.properties?.items;
|
|
209
|
+
const items: SocialLinkItem[] = Array.isArray(rawItems) ? rawItems : [];
|
|
210
|
+
|
|
211
|
+
const iconSize = clampNumber(
|
|
212
|
+
getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 20),
|
|
213
|
+
20,
|
|
214
|
+
12,
|
|
215
|
+
64
|
|
216
|
+
);
|
|
217
|
+
const gap = clampNumber(
|
|
218
|
+
getResponsiveValue(block.properties?.gap, currentBreakpoint, 12),
|
|
219
|
+
12,
|
|
220
|
+
0,
|
|
221
|
+
48
|
|
222
|
+
);
|
|
223
|
+
const strokeWidth = clampDecimal(
|
|
224
|
+
getResponsiveValue(block.properties?.strokeWidth, currentBreakpoint, 1.6),
|
|
225
|
+
1.6,
|
|
226
|
+
0.5,
|
|
227
|
+
4
|
|
228
|
+
);
|
|
229
|
+
const wrap = readToggle(block.properties?.wrap, currentBreakpoint, true);
|
|
230
|
+
const showLabels = readToggle(
|
|
231
|
+
block.properties?.showLabels,
|
|
232
|
+
currentBreakpoint,
|
|
233
|
+
false
|
|
234
|
+
);
|
|
235
|
+
const showTooltip = readToggle(
|
|
236
|
+
block.properties?.showTooltip,
|
|
237
|
+
currentBreakpoint,
|
|
238
|
+
true
|
|
239
|
+
);
|
|
240
|
+
const openInNewTab = readToggle(
|
|
241
|
+
block.properties?.openInNewTab,
|
|
242
|
+
currentBreakpoint,
|
|
243
|
+
true
|
|
244
|
+
);
|
|
245
|
+
const labelGap = clampNumber(
|
|
246
|
+
getResponsiveValue(block.properties?.labelGap, currentBreakpoint, 8),
|
|
247
|
+
8,
|
|
248
|
+
0,
|
|
249
|
+
32
|
|
250
|
+
);
|
|
251
|
+
const maxItems = clampNumber(
|
|
252
|
+
getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
|
|
253
|
+
0,
|
|
254
|
+
0,
|
|
255
|
+
30
|
|
256
|
+
);
|
|
257
|
+
const labelOverrides = parseLabelOverrides(
|
|
258
|
+
String(
|
|
259
|
+
getResponsiveValue(
|
|
260
|
+
block.properties?.labelOverrides,
|
|
261
|
+
currentBreakpoint,
|
|
262
|
+
''
|
|
263
|
+
) ?? ''
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const visibleItems = items.filter((item) => item && (item.icon || item.link));
|
|
268
|
+
const limitedItems =
|
|
269
|
+
maxItems > 0 ? visibleItems.slice(0, maxItems) : visibleItems;
|
|
270
|
+
|
|
271
|
+
// Resolved once so the built-in icon and a designer-authored row read the
|
|
272
|
+
// same fields — `item.url`, `item.label`, `item.icon`.
|
|
273
|
+
const resolvedItems = limitedItems.map((item, index) => {
|
|
274
|
+
const platform = String(item.icon || 'globe').toLowerCase();
|
|
275
|
+
const href = String(item.link || '');
|
|
276
|
+
const label = String(
|
|
277
|
+
item.label || labelOverrides[platform] || platformLabel(platform)
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
key: `${platform}-${index}`,
|
|
282
|
+
icon: platform,
|
|
283
|
+
platform,
|
|
284
|
+
label,
|
|
285
|
+
name: label,
|
|
286
|
+
url: href,
|
|
287
|
+
link: href,
|
|
288
|
+
color: String(item.color || ''),
|
|
289
|
+
size: clampNumber(item.size, iconSize, 12, 64),
|
|
290
|
+
index: index + 1
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// A designer who marked a child as the row layout gets that layout repeated
|
|
295
|
+
// per entry; otherwise the built-in icon renders unchanged.
|
|
296
|
+
const customRows =
|
|
297
|
+
hasItemTemplate(block, currentBreakpoint) &&
|
|
298
|
+
(resolvedItems.length > 0 || isDesigner) ? (
|
|
299
|
+
<ItemTemplateList
|
|
300
|
+
block={block}
|
|
301
|
+
items={resolvedItems}
|
|
302
|
+
placeholderId={placeholderId}
|
|
303
|
+
sectionId={sectionId}
|
|
304
|
+
isDesigner={isDesigner}
|
|
305
|
+
selectedBlockId={selectedBlockId}
|
|
306
|
+
currentBreakpoint={currentBreakpoint}
|
|
307
|
+
designerPlaceholderCount={4}
|
|
308
|
+
/>
|
|
309
|
+
) : null;
|
|
310
|
+
|
|
311
|
+
if (resolvedItems.length === 0 && !customRows) {
|
|
312
|
+
return isDesigner ? (
|
|
313
|
+
<DesignerPlaceholder
|
|
314
|
+
label="Social links (no entries)"
|
|
315
|
+
attribute="data-social-links"
|
|
316
|
+
/>
|
|
317
|
+
) : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
data-social-links=""
|
|
323
|
+
{...partAttrs('list')}
|
|
324
|
+
style={{
|
|
325
|
+
display: 'flex',
|
|
326
|
+
alignItems: 'center',
|
|
327
|
+
flexWrap: wrap ? 'wrap' : 'nowrap',
|
|
328
|
+
gap: `${gap}px`
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
{customRows ??
|
|
332
|
+
resolvedItems.map((item) => {
|
|
333
|
+
const glyph = SOCIAL_GLYPHS[item.platform] || SOCIAL_GLYPHS.globe;
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<NextLink
|
|
337
|
+
key={item.key}
|
|
338
|
+
// Navigating inside the designer iframe would drop the author out
|
|
339
|
+
// of the page they are editing, so the icons are inert there.
|
|
340
|
+
href={isDesigner ? '#' : item.url || '#'}
|
|
341
|
+
onClick={(event) => {
|
|
342
|
+
if (isDesigner) event.preventDefault();
|
|
343
|
+
}}
|
|
344
|
+
aria-label={item.label}
|
|
345
|
+
title={showTooltip ? item.label : undefined}
|
|
346
|
+
data-social-link={item.platform}
|
|
347
|
+
{...partAttrs('link')}
|
|
348
|
+
{...(isDesigner || !openInNewTab
|
|
349
|
+
? {}
|
|
350
|
+
: { target: '_blank', rel: 'noopener noreferrer' })}
|
|
351
|
+
style={{
|
|
352
|
+
display: 'inline-flex',
|
|
353
|
+
alignItems: 'center',
|
|
354
|
+
justifyContent: 'center',
|
|
355
|
+
color: item.color || 'inherit',
|
|
356
|
+
textDecoration: 'none',
|
|
357
|
+
// Zero line-height keeps a lone icon from adding a text
|
|
358
|
+
// baseline; a caption needs its own leading back.
|
|
359
|
+
lineHeight: showLabels ? 'normal' : 0,
|
|
360
|
+
...(showLabels ? { gap: `${labelGap}px` } : null)
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
<svg
|
|
364
|
+
width={item.size}
|
|
365
|
+
height={item.size}
|
|
366
|
+
viewBox="0 0 24 24"
|
|
367
|
+
fill="none"
|
|
368
|
+
stroke="currentColor"
|
|
369
|
+
strokeWidth={strokeWidth}
|
|
370
|
+
strokeLinecap="round"
|
|
371
|
+
strokeLinejoin="round"
|
|
372
|
+
aria-hidden="true"
|
|
373
|
+
focusable="false"
|
|
374
|
+
{...partAttrs('icon')}
|
|
375
|
+
>
|
|
376
|
+
{glyph}
|
|
377
|
+
</svg>
|
|
378
|
+
|
|
379
|
+
{showLabels && (
|
|
380
|
+
<span data-social-link-label="" {...partAttrs('label')}>
|
|
381
|
+
{item.label}
|
|
382
|
+
</span>
|
|
383
|
+
)}
|
|
384
|
+
</NextLink>
|
|
385
|
+
);
|
|
386
|
+
})}
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
export default SocialLinksBlock;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
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
|
+
const HEART_PATH =
|
|
21
|
+
'M12 20s-7-4.35-7-9a4 4 0 0 1 7-2.65A4 4 0 0 1 19 11c0 4.65-7 9-7 9z';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Functional chrome block: favourites/wishlist entry point. The editor
|
|
25
|
+
* owns placement, target URL, label, icon fill and styling.
|
|
26
|
+
* Properties: href, ariaLabel, showLabel, label, labelPosition, showIcon,
|
|
27
|
+
* iconSize, iconVariant, gap.
|
|
28
|
+
* Parts: link, icon, label.
|
|
29
|
+
*/
|
|
30
|
+
const WishlistButtonBlock = ({
|
|
31
|
+
block,
|
|
32
|
+
currentBreakpoint = 'desktop',
|
|
33
|
+
isDesigner = false
|
|
34
|
+
}: BlockRendererProps) => {
|
|
35
|
+
const prop = (key: string, fallback: string) =>
|
|
36
|
+
String(
|
|
37
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const href = prop('href', '/account/favourite-products');
|
|
41
|
+
const ariaLabel = prop('ariaLabel', 'Favourites');
|
|
42
|
+
const label = prop('label', 'Favourites');
|
|
43
|
+
const labelPosition = prop('labelPosition', 'after');
|
|
44
|
+
const iconVariant = prop('iconVariant', 'outline');
|
|
45
|
+
const showLabel = readToggle(
|
|
46
|
+
block.properties?.showLabel,
|
|
47
|
+
currentBreakpoint,
|
|
48
|
+
false
|
|
49
|
+
);
|
|
50
|
+
const showIcon = readToggle(
|
|
51
|
+
block.properties?.showIcon,
|
|
52
|
+
currentBreakpoint,
|
|
53
|
+
true
|
|
54
|
+
);
|
|
55
|
+
const iconSize = clampNumber(
|
|
56
|
+
getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 24),
|
|
57
|
+
24,
|
|
58
|
+
12,
|
|
59
|
+
64
|
|
60
|
+
);
|
|
61
|
+
const gap = clampNumber(
|
|
62
|
+
getResponsiveValue(block.properties?.gap, currentBreakpoint, 6),
|
|
63
|
+
6,
|
|
64
|
+
0,
|
|
65
|
+
32
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const labelNode = showLabel ? (
|
|
69
|
+
<span data-wishlist-label="" {...partAttrs('label')}>
|
|
70
|
+
{label}
|
|
71
|
+
</span>
|
|
72
|
+
) : null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<NextLink
|
|
76
|
+
href={href}
|
|
77
|
+
data-wishlist-button=""
|
|
78
|
+
{...partAttrs('link')}
|
|
79
|
+
aria-label={ariaLabel}
|
|
80
|
+
// Navigating inside the designer iframe would drop the author out of the
|
|
81
|
+
// page they are editing.
|
|
82
|
+
onClick={(event) => {
|
|
83
|
+
if (isDesigner) event.preventDefault();
|
|
84
|
+
}}
|
|
85
|
+
style={{
|
|
86
|
+
display: 'inline-flex',
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
gap: showLabel ? `${gap}px` : undefined,
|
|
89
|
+
color: 'inherit',
|
|
90
|
+
textDecoration: 'none'
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{labelPosition === 'before' && labelNode}
|
|
94
|
+
|
|
95
|
+
{showIcon && (
|
|
96
|
+
<svg
|
|
97
|
+
width={iconSize}
|
|
98
|
+
height={iconSize}
|
|
99
|
+
viewBox="0 0 24 24"
|
|
100
|
+
fill={iconVariant === 'filled' ? 'currentColor' : 'none'}
|
|
101
|
+
stroke="currentColor"
|
|
102
|
+
strokeWidth="1.5"
|
|
103
|
+
data-wishlist-icon=""
|
|
104
|
+
{...partAttrs('icon')}
|
|
105
|
+
>
|
|
106
|
+
<path d={HEART_PATH} />
|
|
107
|
+
</svg>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{labelPosition !== 'before' && labelNode}
|
|
111
|
+
</NextLink>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default WishlistButtonBlock;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wraps the brand's original chrome markup.
|
|
9
|
+
*
|
|
10
|
+
* On the live site it renders untouched. Inside the theme editor it stays
|
|
11
|
+
* visible — showing an empty slot (or a template the brand never chose)
|
|
12
|
+
* would misrepresent what visitors actually see — but it is marked as
|
|
13
|
+
* coming from project code and offers to hand the area over to the
|
|
14
|
+
* editor. Taking over is deliberate: nothing is composed, saved or
|
|
15
|
+
* published until the designer asks for it.
|
|
16
|
+
*/
|
|
17
|
+
export function ChromeFallback({
|
|
18
|
+
slug,
|
|
19
|
+
children
|
|
20
|
+
}: {
|
|
21
|
+
slug: string;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
const [inDesigner, setInDesigner] = useState(false);
|
|
25
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
26
|
+
const [isOverridden, setIsOverridden] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (typeof window !== 'undefined' && window.self !== window.top) {
|
|
30
|
+
setInDesigner(true);
|
|
31
|
+
}
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
// Whether this area is composed is decided server-side at request time,
|
|
35
|
+
// so a takeover that just happened in the editor would otherwise leave
|
|
36
|
+
// the project markup stacked above the new composition. Follow the theme
|
|
37
|
+
// the editor pushes and step aside as soon as it has sections — and come
|
|
38
|
+
// back if the designer removes them again.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const handleMessage = (event: MessageEvent) => {
|
|
41
|
+
if (!isTrustedEditorEvent(event)) return;
|
|
42
|
+
|
|
43
|
+
const { type, data } = event.data || {};
|
|
44
|
+
if (type !== 'UPDATE_THEME' && type !== 'LOAD_THEME') return;
|
|
45
|
+
|
|
46
|
+
const placeholders = data?.theme?.placeholders;
|
|
47
|
+
if (!Array.isArray(placeholders)) return;
|
|
48
|
+
|
|
49
|
+
const placeholder = placeholders.find(
|
|
50
|
+
(item: { slug?: string; id?: string }) =>
|
|
51
|
+
item?.slug === slug || item?.id === slug
|
|
52
|
+
);
|
|
53
|
+
const sections = placeholder?.sections;
|
|
54
|
+
// Mirror the server's takeover rule exactly: only a *composed* section
|
|
55
|
+
// replaces the project markup, and a composed section is one that reached
|
|
56
|
+
// the saved order.
|
|
57
|
+
//
|
|
58
|
+
// The `native` exclusion guards against the retired native widget path
|
|
59
|
+
// rather than supporting it. That path styled a project component in place
|
|
60
|
+
// instead of replacing it, so its sections never entered the order; the
|
|
61
|
+
// runtime is gone, but the editor can still hold such a section in a
|
|
62
|
+
// stored theme and push it here, and withdrawing the project markup for
|
|
63
|
+
// one would show the designer an empty area no visitor ever sees. It goes
|
|
64
|
+
// when the editor-side machinery does.
|
|
65
|
+
setIsOverridden(
|
|
66
|
+
Array.isArray(sections) &&
|
|
67
|
+
sections.some(
|
|
68
|
+
(section: { hidden?: boolean; type?: string }) =>
|
|
69
|
+
!section?.hidden && section?.type !== 'native'
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
window.addEventListener('message', handleMessage);
|
|
75
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
76
|
+
}, [slug]);
|
|
77
|
+
|
|
78
|
+
if (!inDesigner) {
|
|
79
|
+
return <div data-chrome-fallback={slug}>{children}</div>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isOverridden) return null;
|
|
83
|
+
|
|
84
|
+
const requestOverride = () => {
|
|
85
|
+
postToEditor({ type: 'REQUEST_CHROME_OVERRIDE', data: { slug } });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
data-chrome-fallback={slug}
|
|
91
|
+
data-chrome-managed-by="project"
|
|
92
|
+
style={{ position: 'relative' }}
|
|
93
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
94
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
95
|
+
>
|
|
96
|
+
{/* The brand markup is not editable, so swallow interactions with it */}
|
|
97
|
+
<div style={{ pointerEvents: 'none' }}>{children}</div>
|
|
98
|
+
|
|
99
|
+
<div
|
|
100
|
+
style={{
|
|
101
|
+
position: 'absolute',
|
|
102
|
+
inset: 0,
|
|
103
|
+
display: 'flex',
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
gap: '12px',
|
|
107
|
+
background: isHovered ? 'rgba(15, 23, 42, 0.55)' : 'transparent',
|
|
108
|
+
outline: isHovered ? '2px dashed #60a5fa' : 'none',
|
|
109
|
+
outlineOffset: '-2px',
|
|
110
|
+
transition: 'background 120ms ease',
|
|
111
|
+
zIndex: 30
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{isHovered && (
|
|
115
|
+
<>
|
|
116
|
+
<span
|
|
117
|
+
style={{
|
|
118
|
+
color: '#ffffff',
|
|
119
|
+
fontSize: '13px',
|
|
120
|
+
fontWeight: 500,
|
|
121
|
+
textShadow: '0 1px 2px rgba(0,0,0,0.4)'
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
This {slug} comes from the project code
|
|
125
|
+
</span>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={requestOverride}
|
|
129
|
+
style={{
|
|
130
|
+
background: '#2563eb',
|
|
131
|
+
color: '#ffffff',
|
|
132
|
+
border: 'none',
|
|
133
|
+
borderRadius: '6px',
|
|
134
|
+
padding: '8px 14px',
|
|
135
|
+
fontSize: '13px',
|
|
136
|
+
fontWeight: 600,
|
|
137
|
+
cursor: 'pointer'
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
Edit in theme editor
|
|
141
|
+
</button>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|