@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.
@@ -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
+ }