@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-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__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
5
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
6
|
+
import {
|
|
7
|
+
DesignerPlaceholder,
|
|
8
|
+
getProductName,
|
|
9
|
+
pickProductField,
|
|
10
|
+
readToggle,
|
|
11
|
+
useThemeProduct
|
|
12
|
+
} from './product-block-utils';
|
|
13
|
+
|
|
14
|
+
type ShareNetwork =
|
|
15
|
+
| 'whatsapp'
|
|
16
|
+
| 'x'
|
|
17
|
+
| 'facebook'
|
|
18
|
+
| 'pinterest'
|
|
19
|
+
| 'linkedin'
|
|
20
|
+
| 'telegram'
|
|
21
|
+
| 'email'
|
|
22
|
+
| 'copy';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every network the block can draw, in the order both reference designs put
|
|
26
|
+
* them. The toggle key is what the editor writes, so the pair lives here once
|
|
27
|
+
* instead of being repeated in the reader and the renderer.
|
|
28
|
+
*/
|
|
29
|
+
const NETWORKS: Array<{
|
|
30
|
+
id: ShareNetwork;
|
|
31
|
+
toggleKey: string;
|
|
32
|
+
enabledByDefault: boolean;
|
|
33
|
+
label: string;
|
|
34
|
+
}> = [
|
|
35
|
+
{
|
|
36
|
+
id: 'whatsapp',
|
|
37
|
+
toggleKey: 'showWhatsapp',
|
|
38
|
+
enabledByDefault: true,
|
|
39
|
+
label: 'WhatsApp'
|
|
40
|
+
},
|
|
41
|
+
{ id: 'x', toggleKey: 'showX', enabledByDefault: true, label: 'X' },
|
|
42
|
+
{
|
|
43
|
+
id: 'facebook',
|
|
44
|
+
toggleKey: 'showFacebook',
|
|
45
|
+
enabledByDefault: true,
|
|
46
|
+
label: 'Facebook'
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'pinterest',
|
|
50
|
+
toggleKey: 'showPinterest',
|
|
51
|
+
enabledByDefault: true,
|
|
52
|
+
label: 'Pinterest'
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'linkedin',
|
|
56
|
+
toggleKey: 'showLinkedin',
|
|
57
|
+
enabledByDefault: false,
|
|
58
|
+
label: 'LinkedIn'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'telegram',
|
|
62
|
+
toggleKey: 'showTelegram',
|
|
63
|
+
enabledByDefault: false,
|
|
64
|
+
label: 'Telegram'
|
|
65
|
+
},
|
|
66
|
+
{ id: 'email', toggleKey: 'showEmail', enabledByDefault: true, label: 'Email' },
|
|
67
|
+
{ id: 'copy', toggleKey: 'showCopy', enabledByDefault: true, label: '' }
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stroke glyphs on the same 24x24 grid the other chrome blocks draw on, so a
|
|
72
|
+
* single `stroke: currentColor` inherits whatever colour the row sits in.
|
|
73
|
+
*/
|
|
74
|
+
const GLYPHS: Record<ShareNetwork | 'share', React.ReactNode> = {
|
|
75
|
+
whatsapp: (
|
|
76
|
+
<>
|
|
77
|
+
<path d="M3.5 20.5l1.4-4.2A8.2 8.2 0 1 1 8 19.4z" />
|
|
78
|
+
<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" />
|
|
79
|
+
</>
|
|
80
|
+
),
|
|
81
|
+
x: <path d="M4 4l16 16M20 4L4 20" />,
|
|
82
|
+
facebook: (
|
|
83
|
+
<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" />
|
|
84
|
+
),
|
|
85
|
+
pinterest: (
|
|
86
|
+
<>
|
|
87
|
+
<circle cx="12" cy="12" r="9" />
|
|
88
|
+
<path d="M9.5 21l2.2-8.5" />
|
|
89
|
+
<path d="M9 11a3 3 0 1 1 5 2.2" />
|
|
90
|
+
</>
|
|
91
|
+
),
|
|
92
|
+
linkedin: (
|
|
93
|
+
<>
|
|
94
|
+
<path d="M6.5 9.5V20" />
|
|
95
|
+
<circle cx="6.5" cy="5" r="1.1" />
|
|
96
|
+
<path d="M11 20v-6.5a3.5 3.5 0 0 1 7 0V20" />
|
|
97
|
+
</>
|
|
98
|
+
),
|
|
99
|
+
telegram: <path d="M21 4.5L2.8 11.3l5 1.8L18 7l-7.5 7.4.3 5 2.7-3.4 4 3z" />,
|
|
100
|
+
email: (
|
|
101
|
+
<>
|
|
102
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
103
|
+
<path d="M3.5 6.5L12 13l8.5-6.5" />
|
|
104
|
+
</>
|
|
105
|
+
),
|
|
106
|
+
copy: (
|
|
107
|
+
<>
|
|
108
|
+
<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7L11.5 7" />
|
|
109
|
+
<path d="M14 10a4 4 0 0 0-5.7 0l-3 3A4 4 0 0 0 11 18.7l1.5-1.5" />
|
|
110
|
+
</>
|
|
111
|
+
),
|
|
112
|
+
share: (
|
|
113
|
+
<>
|
|
114
|
+
<circle cx="18" cy="5" r="2.6" />
|
|
115
|
+
<circle cx="6" cy="12" r="2.6" />
|
|
116
|
+
<circle cx="18" cy="19" r="2.6" />
|
|
117
|
+
<path d="M8.3 10.8l7.4-4.3M8.3 13.2l7.4 4.3" />
|
|
118
|
+
</>
|
|
119
|
+
)
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const HTTP_SCHEME = /^https?:\/\//i;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A share target receives the URL as a query parameter, so a relative product
|
|
126
|
+
* path would send the shopper to the network's own domain. `window` does not
|
|
127
|
+
* exist on the server, which is why the origin is handed in: it is the authored
|
|
128
|
+
* `baseUrl` until hydration and the browser's own origin afterwards. Anything
|
|
129
|
+
* that does not resolve to http(s) returns '' rather than being passed on —
|
|
130
|
+
* the value is also what "copy link" puts on the clipboard.
|
|
131
|
+
*/
|
|
132
|
+
const toAbsoluteUrl = (path: string, origin: string): string => {
|
|
133
|
+
const trimmed = String(path || '').trim();
|
|
134
|
+
if (!trimmed) return '';
|
|
135
|
+
|
|
136
|
+
const isAbsolute = HTTP_SCHEME.test(trimmed);
|
|
137
|
+
if (!isAbsolute && !origin) return '';
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const resolved = isAbsolute ? new URL(trimmed) : new URL(trimmed, origin);
|
|
141
|
+
|
|
142
|
+
return resolved.protocol === 'http:' || resolved.protocol === 'https:'
|
|
143
|
+
? resolved.toString()
|
|
144
|
+
: '';
|
|
145
|
+
} catch {
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `network=Label` pairs, one per `;` or newline. The built-in names are English
|
|
152
|
+
* proper nouns; a brand running in another language has no other way to reach
|
|
153
|
+
* the accessible name of an icon.
|
|
154
|
+
*/
|
|
155
|
+
const parseLabelOverrides = (raw: string): Record<string, string> =>
|
|
156
|
+
String(raw || '')
|
|
157
|
+
.split(/[;\n]/)
|
|
158
|
+
.map((entry) => entry.trim())
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.reduce<Record<string, string>>((acc, entry) => {
|
|
161
|
+
const separatorIndex = entry.indexOf('=');
|
|
162
|
+
if (separatorIndex <= 0) return acc;
|
|
163
|
+
|
|
164
|
+
const key = entry.slice(0, separatorIndex).trim().toLowerCase();
|
|
165
|
+
const value = entry.slice(separatorIndex + 1).trim();
|
|
166
|
+
if (key && value) acc[key] = value;
|
|
167
|
+
|
|
168
|
+
return acc;
|
|
169
|
+
}, {});
|
|
170
|
+
|
|
171
|
+
const clampNumber = (
|
|
172
|
+
value: unknown,
|
|
173
|
+
fallback: number,
|
|
174
|
+
min: number,
|
|
175
|
+
max: number
|
|
176
|
+
) => {
|
|
177
|
+
const numeric = Math.round(Number(value));
|
|
178
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
179
|
+
|
|
180
|
+
return Math.min(max, Math.max(min, numeric));
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
interface ShareTargets {
|
|
184
|
+
url: string;
|
|
185
|
+
text: string;
|
|
186
|
+
subject: string;
|
|
187
|
+
body: string;
|
|
188
|
+
image: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Every interpolated value goes through encodeURIComponent, without exception. */
|
|
192
|
+
const buildNetworkHref = (
|
|
193
|
+
network: ShareNetwork,
|
|
194
|
+
{ url, text, subject, body, image }: ShareTargets
|
|
195
|
+
): string => {
|
|
196
|
+
const enc = encodeURIComponent;
|
|
197
|
+
|
|
198
|
+
switch (network) {
|
|
199
|
+
case 'whatsapp':
|
|
200
|
+
return `https://wa.me/?text=${enc([text, url].filter(Boolean).join(' '))}`;
|
|
201
|
+
case 'x':
|
|
202
|
+
return `https://x.com/intent/post?url=${enc(url)}&text=${enc(text)}`;
|
|
203
|
+
case 'facebook':
|
|
204
|
+
return `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}`;
|
|
205
|
+
case 'pinterest':
|
|
206
|
+
return `https://www.pinterest.com/pin/create/button/?url=${enc(
|
|
207
|
+
url
|
|
208
|
+
)}&media=${enc(image)}&description=${enc(text)}`;
|
|
209
|
+
case 'linkedin':
|
|
210
|
+
return `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`;
|
|
211
|
+
case 'telegram':
|
|
212
|
+
return `https://t.me/share/url?url=${enc(url)}&text=${enc(text)}`;
|
|
213
|
+
case 'email':
|
|
214
|
+
return `mailto:?subject=${enc(subject)}&body=${enc(body)}`;
|
|
215
|
+
default:
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Functional block: sharing the ambient product — a repeated row's own product
|
|
222
|
+
* on a card, the page's product on a PDP — and the current page URL when there
|
|
223
|
+
* is no product at all, which is what makes it usable on a blog post or a
|
|
224
|
+
* campaign page. Two layouts cover both reference designs: an inline row of
|
|
225
|
+
* network icons (Nautica) and a single trigger that reveals them (GANT).
|
|
226
|
+
*
|
|
227
|
+
* `nativeShare` is the switch that keeps this inert: with it off the block
|
|
228
|
+
* never touches `navigator`, so the markup is decided entirely by `layout` and
|
|
229
|
+
* the server and the client agree. Turned on, the trigger feature-detects
|
|
230
|
+
* `navigator.share` AT CLICK TIME — detecting at import or render time would
|
|
231
|
+
* branch the markup on something the server cannot know, which hydrates as a
|
|
232
|
+
* mismatch — and falls back to revealing the icon row when it is absent.
|
|
233
|
+
*
|
|
234
|
+
* Properties: layout, nativeShare, showWhatsapp, showX, showFacebook,
|
|
235
|
+
* showPinterest, showLinkedin, showTelegram, showEmail, showCopy, triggerLabel,
|
|
236
|
+
* triggerAriaLabel, showTriggerIcon, panelPlacement, showLabels, labelOverrides,
|
|
237
|
+
* copyLabel, copyConfirmLabel, copyFailLabel, copyFallback, confirmDuration,
|
|
238
|
+
* shareTextTemplate, emailSubjectTemplate, emailBodyTemplate, iconSize, gap,
|
|
239
|
+
* openInNewTab, baseUrl, emptyLabel.
|
|
240
|
+
* Parts: root, trigger, list, item, icon, label, feedback, fallback-input.
|
|
241
|
+
*/
|
|
242
|
+
const ShareBlock = ({
|
|
243
|
+
block,
|
|
244
|
+
currentBreakpoint = 'desktop',
|
|
245
|
+
isDesigner = false
|
|
246
|
+
}: BlockRendererProps) => {
|
|
247
|
+
const product = useThemeProduct();
|
|
248
|
+
|
|
249
|
+
const prop = (key: string, fallback: string) =>
|
|
250
|
+
String(
|
|
251
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const layoutValue = prop('layout', 'inline');
|
|
255
|
+
const layout = layoutValue === 'trigger' ? 'trigger' : 'inline';
|
|
256
|
+
const nativeShare = readToggle(
|
|
257
|
+
block.properties?.nativeShare,
|
|
258
|
+
currentBreakpoint,
|
|
259
|
+
false
|
|
260
|
+
);
|
|
261
|
+
const triggerLabel = prop('triggerLabel', 'Share');
|
|
262
|
+
const triggerAriaLabel = prop('triggerAriaLabel', '');
|
|
263
|
+
const showTriggerIcon = readToggle(
|
|
264
|
+
block.properties?.showTriggerIcon,
|
|
265
|
+
currentBreakpoint,
|
|
266
|
+
true
|
|
267
|
+
);
|
|
268
|
+
const panelPlacementValue = prop('panelPlacement', 'below');
|
|
269
|
+
const panelPlacement = ['below', 'above', 'inline'].includes(
|
|
270
|
+
panelPlacementValue
|
|
271
|
+
)
|
|
272
|
+
? panelPlacementValue
|
|
273
|
+
: 'below';
|
|
274
|
+
const showLabels = readToggle(
|
|
275
|
+
block.properties?.showLabels,
|
|
276
|
+
currentBreakpoint,
|
|
277
|
+
false
|
|
278
|
+
);
|
|
279
|
+
const labelOverrides = parseLabelOverrides(prop('labelOverrides', ''));
|
|
280
|
+
const copyLabel = prop('copyLabel', 'Copy link');
|
|
281
|
+
const copyConfirmLabel = prop('copyConfirmLabel', 'Link copied');
|
|
282
|
+
const copyFailLabel = prop('copyFailLabel', 'Copy the link below');
|
|
283
|
+
const copyFallbackValue = prop('copyFallback', 'input');
|
|
284
|
+
const copyFallback = copyFallbackValue === 'hide' ? 'hide' : 'input';
|
|
285
|
+
const confirmDuration = clampNumber(
|
|
286
|
+
getResponsiveValue(block.properties?.confirmDuration, currentBreakpoint, 2),
|
|
287
|
+
2,
|
|
288
|
+
1,
|
|
289
|
+
15
|
|
290
|
+
);
|
|
291
|
+
const shareTextTemplate = prop('shareTextTemplate', '{title}');
|
|
292
|
+
const emailSubjectTemplate = prop('emailSubjectTemplate', '{title}');
|
|
293
|
+
const emailBodyTemplate = prop('emailBodyTemplate', '{title} {url}');
|
|
294
|
+
const iconSize = clampNumber(
|
|
295
|
+
getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 20),
|
|
296
|
+
20,
|
|
297
|
+
12,
|
|
298
|
+
64
|
|
299
|
+
);
|
|
300
|
+
const gap = clampNumber(
|
|
301
|
+
getResponsiveValue(block.properties?.gap, currentBreakpoint, 12),
|
|
302
|
+
12,
|
|
303
|
+
0,
|
|
304
|
+
48
|
|
305
|
+
);
|
|
306
|
+
const openInNewTab = readToggle(
|
|
307
|
+
block.properties?.openInNewTab,
|
|
308
|
+
currentBreakpoint,
|
|
309
|
+
true
|
|
310
|
+
);
|
|
311
|
+
const baseUrl = prop('baseUrl', '');
|
|
312
|
+
const emptyLabel = prop('emptyLabel', 'Share (no networks enabled)');
|
|
313
|
+
|
|
314
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
315
|
+
const [feedback, setFeedback] = useState<'copied' | 'failed' | null>(null);
|
|
316
|
+
const [fallbackUrl, setFallbackUrl] = useState('');
|
|
317
|
+
// Initialised to the optimistic answer so the server markup and the first
|
|
318
|
+
// client render agree; the effect below corrects it after hydration, which is
|
|
319
|
+
// the only moment `navigator` may be consulted safely.
|
|
320
|
+
const [clipboardAvailable, setClipboardAvailable] = useState(true);
|
|
321
|
+
const [browserUrl, setBrowserUrl] = useState({ origin: '', href: '' });
|
|
322
|
+
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
323
|
+
const fallbackInput = useRef<HTMLInputElement | null>(null);
|
|
324
|
+
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
setBrowserUrl({
|
|
327
|
+
origin: window.location.origin,
|
|
328
|
+
// The fragment is the shopper's scroll position, not part of what they
|
|
329
|
+
// meant to share.
|
|
330
|
+
href: window.location.href.split('#')[0]
|
|
331
|
+
});
|
|
332
|
+
setClipboardAvailable(
|
|
333
|
+
typeof navigator !== 'undefined' &&
|
|
334
|
+
typeof navigator.clipboard?.writeText === 'function'
|
|
335
|
+
);
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
useEffect(
|
|
339
|
+
() => () => {
|
|
340
|
+
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
|
341
|
+
},
|
|
342
|
+
[]
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
if (!fallbackUrl || !fallbackInput.current) return;
|
|
347
|
+
|
|
348
|
+
fallbackInput.current.focus();
|
|
349
|
+
fallbackInput.current.select();
|
|
350
|
+
}, [fallbackUrl]);
|
|
351
|
+
|
|
352
|
+
const origin = browserUrl.origin || baseUrl;
|
|
353
|
+
const productPath = String(pickProductField<string>(product, 'url') ?? '');
|
|
354
|
+
const productImages = Array.isArray(product?.images) ? product?.images : [];
|
|
355
|
+
const productImagePath = String(
|
|
356
|
+
pickProductField<string>(product, 'image') ??
|
|
357
|
+
productImages?.[0]?.image ??
|
|
358
|
+
productImages?.[0]?.url ??
|
|
359
|
+
''
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const shareUrl = productPath
|
|
363
|
+
? toAbsoluteUrl(productPath, origin)
|
|
364
|
+
: browserUrl.href || toAbsoluteUrl('/', origin);
|
|
365
|
+
const productName = getProductName(product);
|
|
366
|
+
const applyTemplate = (template: string) =>
|
|
367
|
+
template
|
|
368
|
+
.replace(/\{title\}/g, productName)
|
|
369
|
+
.replace(/\{url\}/g, shareUrl)
|
|
370
|
+
.trim();
|
|
371
|
+
|
|
372
|
+
const targets: ShareTargets = {
|
|
373
|
+
url: shareUrl,
|
|
374
|
+
text: applyTemplate(shareTextTemplate),
|
|
375
|
+
subject: applyTemplate(emailSubjectTemplate),
|
|
376
|
+
body: applyTemplate(emailBodyTemplate),
|
|
377
|
+
image: toAbsoluteUrl(productImagePath, origin)
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const label = (network: ShareNetwork, fallback: string) =>
|
|
381
|
+
labelOverrides[network] || fallback;
|
|
382
|
+
|
|
383
|
+
const enabled = NETWORKS.filter((network) =>
|
|
384
|
+
readToggle(
|
|
385
|
+
block.properties?.[network.toggleKey],
|
|
386
|
+
currentBreakpoint,
|
|
387
|
+
network.enabledByDefault
|
|
388
|
+
)
|
|
389
|
+
).filter(
|
|
390
|
+
// A copy option that cannot copy and has nothing to fall back on is a
|
|
391
|
+
// button that does nothing, so the author's choice is to hide it.
|
|
392
|
+
(network) =>
|
|
393
|
+
network.id !== 'copy' || clipboardAvailable || copyFallback === 'input'
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
if (enabled.length === 0) {
|
|
397
|
+
return isDesigner ? (
|
|
398
|
+
<DesignerPlaceholder label={emptyLabel} attribute="data-share" />
|
|
399
|
+
) : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const handleCopy = async () => {
|
|
403
|
+
// Designer: no clipboard write, no timer, nothing written anywhere.
|
|
404
|
+
if (isDesigner) return;
|
|
405
|
+
if (!shareUrl) return;
|
|
406
|
+
|
|
407
|
+
const clipboard =
|
|
408
|
+
typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
|
|
409
|
+
|
|
410
|
+
if (clipboard && typeof clipboard.writeText === 'function') {
|
|
411
|
+
try {
|
|
412
|
+
await clipboard.writeText(shareUrl);
|
|
413
|
+
setFallbackUrl('');
|
|
414
|
+
setFeedback('copied');
|
|
415
|
+
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
|
416
|
+
confirmTimer.current = setTimeout(
|
|
417
|
+
() => setFeedback(null),
|
|
418
|
+
confirmDuration * 1000
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
return;
|
|
422
|
+
} catch {
|
|
423
|
+
// Denied permission, or a non-secure origin that exposes the API but
|
|
424
|
+
// refuses the write. Falling through is the honest outcome: claiming a
|
|
425
|
+
// copy that never happened is worse than admitting it.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
setFeedback('failed');
|
|
430
|
+
setFallbackUrl(copyFallback === 'input' ? shareUrl : '');
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const handleTrigger = async () => {
|
|
434
|
+
if (
|
|
435
|
+
!isDesigner &&
|
|
436
|
+
nativeShare &&
|
|
437
|
+
typeof navigator !== 'undefined' &&
|
|
438
|
+
typeof navigator.share === 'function'
|
|
439
|
+
) {
|
|
440
|
+
try {
|
|
441
|
+
await navigator.share({
|
|
442
|
+
title: productName || undefined,
|
|
443
|
+
text: targets.text || undefined,
|
|
444
|
+
url: shareUrl || undefined
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
// A dismissed sheet is a completed interaction, so the panel stays
|
|
450
|
+
// shut; anything else means the native path is unusable here and the
|
|
451
|
+
// icon row takes over.
|
|
452
|
+
if ((error as { name?: string })?.name === 'AbortError') return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
setIsOpen((current) => !current);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const usesTrigger = layout === 'trigger' || nativeShare;
|
|
460
|
+
const listVisible = !usesTrigger || isOpen;
|
|
461
|
+
const listId = `${block.id}-share-list`;
|
|
462
|
+
|
|
463
|
+
const glyph = (network: ShareNetwork | 'share') => (
|
|
464
|
+
<svg
|
|
465
|
+
width={iconSize}
|
|
466
|
+
height={iconSize}
|
|
467
|
+
viewBox="0 0 24 24"
|
|
468
|
+
fill="none"
|
|
469
|
+
stroke="currentColor"
|
|
470
|
+
strokeWidth={1.6}
|
|
471
|
+
strokeLinecap="round"
|
|
472
|
+
strokeLinejoin="round"
|
|
473
|
+
aria-hidden="true"
|
|
474
|
+
focusable="false"
|
|
475
|
+
{...partAttrs('icon')}
|
|
476
|
+
>
|
|
477
|
+
{GLYPHS[network]}
|
|
478
|
+
</svg>
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const list = listVisible ? (
|
|
482
|
+
<div
|
|
483
|
+
id={listId}
|
|
484
|
+
data-share-list=""
|
|
485
|
+
{...partAttrs('list', { open: usesTrigger && isOpen })}
|
|
486
|
+
style={{
|
|
487
|
+
display: 'flex',
|
|
488
|
+
alignItems: 'center',
|
|
489
|
+
flexWrap: 'wrap',
|
|
490
|
+
gap: `${gap}px`,
|
|
491
|
+
...(usesTrigger && panelPlacement !== 'inline'
|
|
492
|
+
? {
|
|
493
|
+
position: 'absolute',
|
|
494
|
+
zIndex: 20,
|
|
495
|
+
left: 0,
|
|
496
|
+
...(panelPlacement === 'above'
|
|
497
|
+
? { bottom: 'calc(100% + 8px)' }
|
|
498
|
+
: { top: 'calc(100% + 8px)' }),
|
|
499
|
+
background: 'var(--share-panel-bg, #ffffff)',
|
|
500
|
+
border: '1px solid var(--share-panel-border, #e5e7eb)',
|
|
501
|
+
padding: '8px'
|
|
502
|
+
}
|
|
503
|
+
: null)
|
|
504
|
+
}}
|
|
505
|
+
>
|
|
506
|
+
{enabled.map((network) => {
|
|
507
|
+
if (network.id === 'copy') {
|
|
508
|
+
const copied = feedback === 'copied';
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<button
|
|
512
|
+
key={network.id}
|
|
513
|
+
type="button"
|
|
514
|
+
data-share-item="copy"
|
|
515
|
+
{...partAttrs('item', { checked: copied })}
|
|
516
|
+
aria-label={showLabels ? undefined : label('copy', copyLabel)}
|
|
517
|
+
onClick={handleCopy}
|
|
518
|
+
style={{
|
|
519
|
+
display: 'inline-flex',
|
|
520
|
+
alignItems: 'center',
|
|
521
|
+
gap: showLabels ? '8px' : 0,
|
|
522
|
+
border: 'none',
|
|
523
|
+
background: 'transparent',
|
|
524
|
+
color: 'inherit',
|
|
525
|
+
font: 'inherit',
|
|
526
|
+
padding: 0,
|
|
527
|
+
lineHeight: showLabels ? 'normal' : 0,
|
|
528
|
+
cursor: 'pointer'
|
|
529
|
+
}}
|
|
530
|
+
>
|
|
531
|
+
{glyph('copy')}
|
|
532
|
+
{showLabels && (
|
|
533
|
+
<span data-share-label="" {...partAttrs('label')}>
|
|
534
|
+
{label('copy', copyLabel)}
|
|
535
|
+
</span>
|
|
536
|
+
)}
|
|
537
|
+
</button>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const href = buildNetworkHref(network.id, targets);
|
|
542
|
+
const text = label(network.id, network.label);
|
|
543
|
+
// mailto never wants a second tab, and navigating inside the designer
|
|
544
|
+
// iframe would drop the author out of the page they are editing.
|
|
545
|
+
const opensNewTab =
|
|
546
|
+
!isDesigner && openInNewTab && network.id !== 'email';
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<a
|
|
550
|
+
key={network.id}
|
|
551
|
+
href={isDesigner ? '#' : href}
|
|
552
|
+
onClick={(event) => {
|
|
553
|
+
if (isDesigner) event.preventDefault();
|
|
554
|
+
}}
|
|
555
|
+
data-share-item={network.id}
|
|
556
|
+
{...partAttrs('item')}
|
|
557
|
+
aria-label={showLabels ? undefined : text}
|
|
558
|
+
title={showLabels ? undefined : text}
|
|
559
|
+
{...(opensNewTab
|
|
560
|
+
? { target: '_blank', rel: 'noopener noreferrer' }
|
|
561
|
+
: {})}
|
|
562
|
+
style={{
|
|
563
|
+
display: 'inline-flex',
|
|
564
|
+
alignItems: 'center',
|
|
565
|
+
gap: showLabels ? '8px' : 0,
|
|
566
|
+
color: 'inherit',
|
|
567
|
+
textDecoration: 'none',
|
|
568
|
+
lineHeight: showLabels ? 'normal' : 0
|
|
569
|
+
}}
|
|
570
|
+
>
|
|
571
|
+
{glyph(network.id)}
|
|
572
|
+
{showLabels && (
|
|
573
|
+
<span data-share-label="" {...partAttrs('label')}>
|
|
574
|
+
{text}
|
|
575
|
+
</span>
|
|
576
|
+
)}
|
|
577
|
+
</a>
|
|
578
|
+
);
|
|
579
|
+
})}
|
|
580
|
+
</div>
|
|
581
|
+
) : null;
|
|
582
|
+
|
|
583
|
+
return (
|
|
584
|
+
<div
|
|
585
|
+
data-share=""
|
|
586
|
+
data-share-layout={layout}
|
|
587
|
+
data-share-open={usesTrigger && isOpen ? 'true' : 'false'}
|
|
588
|
+
{...partAttrs('root', { open: usesTrigger && isOpen })}
|
|
589
|
+
style={{
|
|
590
|
+
display: 'flex',
|
|
591
|
+
alignItems: 'center',
|
|
592
|
+
flexWrap: 'wrap',
|
|
593
|
+
gap: `${gap}px`,
|
|
594
|
+
// Only a floating panel needs a positioned ancestor; an inline row must
|
|
595
|
+
// not silently become a containing block for whatever a brand puts
|
|
596
|
+
// inside it.
|
|
597
|
+
...(usesTrigger && panelPlacement !== 'inline'
|
|
598
|
+
? { position: 'relative' }
|
|
599
|
+
: null)
|
|
600
|
+
}}
|
|
601
|
+
>
|
|
602
|
+
{usesTrigger && (
|
|
603
|
+
<button
|
|
604
|
+
type="button"
|
|
605
|
+
data-share-trigger=""
|
|
606
|
+
{...partAttrs('trigger', { open: isOpen })}
|
|
607
|
+
aria-expanded={isOpen}
|
|
608
|
+
aria-controls={listVisible ? listId : undefined}
|
|
609
|
+
aria-label={triggerAriaLabel || undefined}
|
|
610
|
+
onClick={handleTrigger}
|
|
611
|
+
style={{
|
|
612
|
+
display: 'inline-flex',
|
|
613
|
+
alignItems: 'center',
|
|
614
|
+
gap: '8px',
|
|
615
|
+
border: 'none',
|
|
616
|
+
background: 'transparent',
|
|
617
|
+
color: 'inherit',
|
|
618
|
+
font: 'inherit',
|
|
619
|
+
padding: 0,
|
|
620
|
+
cursor: 'pointer'
|
|
621
|
+
}}
|
|
622
|
+
>
|
|
623
|
+
{showTriggerIcon && glyph('share')}
|
|
624
|
+
{triggerLabel && (
|
|
625
|
+
<span data-share-trigger-label="" {...partAttrs('label')}>
|
|
626
|
+
{triggerLabel}
|
|
627
|
+
</span>
|
|
628
|
+
)}
|
|
629
|
+
</button>
|
|
630
|
+
)}
|
|
631
|
+
|
|
632
|
+
{list}
|
|
633
|
+
|
|
634
|
+
{feedback && (
|
|
635
|
+
<span
|
|
636
|
+
data-share-feedback={feedback}
|
|
637
|
+
{...partAttrs('feedback', { invalid: feedback === 'failed' })}
|
|
638
|
+
role="status"
|
|
639
|
+
aria-live="polite"
|
|
640
|
+
>
|
|
641
|
+
{feedback === 'copied' ? copyConfirmLabel : copyFailLabel}
|
|
642
|
+
</span>
|
|
643
|
+
)}
|
|
644
|
+
|
|
645
|
+
{fallbackUrl && (
|
|
646
|
+
<input
|
|
647
|
+
ref={fallbackInput}
|
|
648
|
+
type="text"
|
|
649
|
+
readOnly
|
|
650
|
+
value={fallbackUrl}
|
|
651
|
+
data-share-fallback-input=""
|
|
652
|
+
{...partAttrs('fallback-input')}
|
|
653
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
654
|
+
style={{
|
|
655
|
+
border: '1px solid var(--share-panel-border, #e5e7eb)',
|
|
656
|
+
background: 'transparent',
|
|
657
|
+
color: 'inherit',
|
|
658
|
+
font: 'inherit',
|
|
659
|
+
padding: '6px 8px'
|
|
660
|
+
}}
|
|
661
|
+
/>
|
|
662
|
+
)}
|
|
663
|
+
</div>
|
|
664
|
+
);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
export default ShareBlock;
|