@akinon/pz-theme 2.0.38-beta.0 → 2.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -4
- 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,173 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { useGetBasketQuery } from '@akinon/next/data/client/basket';
|
|
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: basket entry point with live item count.
|
|
23
|
+
* Behavior is fixed (links to the basket); the editor owns placement,
|
|
24
|
+
* labels, badge behaviour and styling.
|
|
25
|
+
* Properties: href, ariaLabel, showLabel, label, labelPosition, showIcon,
|
|
26
|
+
* iconSize, gap, showCount, showZero, maxCount, badgePosition.
|
|
27
|
+
* Parts: link, icon, label, badge (state: empty).
|
|
28
|
+
*/
|
|
29
|
+
const MiniBasketBlock = ({
|
|
30
|
+
block,
|
|
31
|
+
currentBreakpoint = 'desktop',
|
|
32
|
+
isDesigner = false
|
|
33
|
+
}: BlockRendererProps) => {
|
|
34
|
+
const prop = (key: string, fallback: string) =>
|
|
35
|
+
String(
|
|
36
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const href = prop('href', '/basket');
|
|
40
|
+
const ariaLabel = prop('ariaLabel', 'Basket');
|
|
41
|
+
const label = prop('label', 'Basket');
|
|
42
|
+
const labelPosition = prop('labelPosition', 'after');
|
|
43
|
+
const badgePosition = prop('badgePosition', 'top-right');
|
|
44
|
+
const showLabel = readToggle(
|
|
45
|
+
block.properties?.showLabel,
|
|
46
|
+
currentBreakpoint,
|
|
47
|
+
false
|
|
48
|
+
);
|
|
49
|
+
const showIcon = readToggle(
|
|
50
|
+
block.properties?.showIcon,
|
|
51
|
+
currentBreakpoint,
|
|
52
|
+
true
|
|
53
|
+
);
|
|
54
|
+
const showCount = readToggle(
|
|
55
|
+
block.properties?.showCount,
|
|
56
|
+
currentBreakpoint,
|
|
57
|
+
true
|
|
58
|
+
);
|
|
59
|
+
const showZero = readToggle(
|
|
60
|
+
block.properties?.showZero,
|
|
61
|
+
currentBreakpoint,
|
|
62
|
+
false
|
|
63
|
+
);
|
|
64
|
+
const iconSize = clampNumber(
|
|
65
|
+
getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 24),
|
|
66
|
+
24,
|
|
67
|
+
12,
|
|
68
|
+
64
|
|
69
|
+
);
|
|
70
|
+
const gap = clampNumber(
|
|
71
|
+
getResponsiveValue(block.properties?.gap, currentBreakpoint, 6),
|
|
72
|
+
6,
|
|
73
|
+
0,
|
|
74
|
+
32
|
|
75
|
+
);
|
|
76
|
+
const maxCount = clampNumber(
|
|
77
|
+
getResponsiveValue(block.properties?.maxCount, currentBreakpoint, 0),
|
|
78
|
+
0,
|
|
79
|
+
0,
|
|
80
|
+
999
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const { data: basket } = useGetBasketQuery(undefined as never, {
|
|
84
|
+
refetchOnMountOrArgChange: false
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const quantity =
|
|
88
|
+
(basket as { total_quantity?: number } | undefined)?.total_quantity ?? 0;
|
|
89
|
+
|
|
90
|
+
const isEmpty = quantity <= 0;
|
|
91
|
+
const badgeVisible = showCount && (!isEmpty || showZero);
|
|
92
|
+
const countText =
|
|
93
|
+
maxCount > 0 && quantity > maxCount ? `${maxCount}+` : String(quantity);
|
|
94
|
+
|
|
95
|
+
const labelNode = showLabel ? (
|
|
96
|
+
<span data-mini-basket-label="" {...partAttrs('label')}>
|
|
97
|
+
{label}
|
|
98
|
+
</span>
|
|
99
|
+
) : null;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<NextLink
|
|
103
|
+
href={href}
|
|
104
|
+
data-mini-basket=""
|
|
105
|
+
{...partAttrs('link')}
|
|
106
|
+
style={{
|
|
107
|
+
position: 'relative',
|
|
108
|
+
display: 'inline-flex',
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
gap: showLabel ? `${gap}px` : undefined,
|
|
111
|
+
color: 'inherit',
|
|
112
|
+
textDecoration: 'none'
|
|
113
|
+
}}
|
|
114
|
+
aria-label={ariaLabel}
|
|
115
|
+
// Navigating inside the designer iframe would drop the author out of the
|
|
116
|
+
// page they are editing.
|
|
117
|
+
onClick={(event) => {
|
|
118
|
+
if (isDesigner) event.preventDefault();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{labelPosition === 'before' && labelNode}
|
|
122
|
+
|
|
123
|
+
{showIcon && (
|
|
124
|
+
<svg
|
|
125
|
+
width={iconSize}
|
|
126
|
+
height={iconSize}
|
|
127
|
+
viewBox="0 0 24 24"
|
|
128
|
+
fill="none"
|
|
129
|
+
stroke="currentColor"
|
|
130
|
+
strokeWidth="1.5"
|
|
131
|
+
data-mini-basket-icon=""
|
|
132
|
+
{...partAttrs('icon')}
|
|
133
|
+
>
|
|
134
|
+
<path d="M6 7h12l-1 13H7L6 7z" />
|
|
135
|
+
<path d="M9 7a3 3 0 0 1 6 0" />
|
|
136
|
+
</svg>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{badgeVisible && (
|
|
140
|
+
<span
|
|
141
|
+
data-mini-basket-count=""
|
|
142
|
+
{...partAttrs('badge', { empty: isEmpty })}
|
|
143
|
+
style={{
|
|
144
|
+
// The badge floats over the icon unless the brand asked for it to
|
|
145
|
+
// sit beside the icon in the flow.
|
|
146
|
+
position: badgePosition === 'inline' ? 'static' : 'absolute',
|
|
147
|
+
top: badgePosition === 'inline' ? undefined : '-6px',
|
|
148
|
+
right:
|
|
149
|
+
badgePosition === 'top-left' || badgePosition === 'inline'
|
|
150
|
+
? undefined
|
|
151
|
+
: '-8px',
|
|
152
|
+
left: badgePosition === 'top-left' ? '-8px' : undefined,
|
|
153
|
+
minWidth: '16px',
|
|
154
|
+
height: '16px',
|
|
155
|
+
borderRadius: '8px',
|
|
156
|
+
background: 'var(--mini-basket-badge, #ef4444)',
|
|
157
|
+
color: '#fff',
|
|
158
|
+
fontSize: '10px',
|
|
159
|
+
lineHeight: '16px',
|
|
160
|
+
textAlign: 'center',
|
|
161
|
+
padding: '0 4px'
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
{countText}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{labelPosition !== 'before' && labelNode}
|
|
169
|
+
</NextLink>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export default MiniBasketBlock;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { usePathname } from 'next/navigation';
|
|
6
|
+
import { useGetMenuQuery } from '@akinon/next/data/client/misc';
|
|
7
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
9
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
10
|
+
import { readToggle } from './product-block-utils';
|
|
11
|
+
|
|
12
|
+
interface MenuItemLike {
|
|
13
|
+
pk?: number | string;
|
|
14
|
+
label?: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
extra_context?: { attributes?: Record<string, unknown> };
|
|
17
|
+
children?: MenuItemLike[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Query strings and trailing slashes are noise when matching the open page. */
|
|
21
|
+
const normalizePath = (value: unknown): string => {
|
|
22
|
+
const path = String(value || '')
|
|
23
|
+
.split('?')[0]
|
|
24
|
+
.split('#')[0];
|
|
25
|
+
|
|
26
|
+
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Functional chrome block: renders the commerce navigation menu. The
|
|
31
|
+
* menu CONTENT stays commerce-driven (menu service); the editor owns
|
|
32
|
+
* placement, wording of the editor-only states, the optional submenu and
|
|
33
|
+
* styling.
|
|
34
|
+
* Properties: depth, parent, orientation, highlight*, showSubmenu,
|
|
35
|
+
* submenuTrigger, activeMatch, maxItems, emptyText, loadingText.
|
|
36
|
+
* Parts: menu, item (hover/focus/selected), submenu, submenu-item.
|
|
37
|
+
*/
|
|
38
|
+
const NavMenuBlock = ({
|
|
39
|
+
block,
|
|
40
|
+
placeholderId,
|
|
41
|
+
sectionId,
|
|
42
|
+
selectedBlockId = null,
|
|
43
|
+
currentBreakpoint = 'desktop',
|
|
44
|
+
isDesigner = false
|
|
45
|
+
}: BlockRendererProps) => {
|
|
46
|
+
const pathname = usePathname();
|
|
47
|
+
const [openKey, setOpenKey] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const depth = Number(
|
|
50
|
+
getResponsiveValue(block.properties?.depth, currentBreakpoint, 1)
|
|
51
|
+
);
|
|
52
|
+
const parent = block.properties?.parent
|
|
53
|
+
? String(getResponsiveValue(block.properties.parent, currentBreakpoint, ''))
|
|
54
|
+
: undefined;
|
|
55
|
+
const orientation = String(
|
|
56
|
+
getResponsiveValue(
|
|
57
|
+
block.properties?.orientation,
|
|
58
|
+
currentBreakpoint,
|
|
59
|
+
'horizontal'
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Brands routinely accent a couple of menu entries (sale, outlet, gift
|
|
64
|
+
// guide). Rather than forcing a separate manual link block, the editor
|
|
65
|
+
// names those entries and gives them their own colour/weight.
|
|
66
|
+
const highlightLabels = String(
|
|
67
|
+
getResponsiveValue(block.properties?.highlightLabels, currentBreakpoint, '')
|
|
68
|
+
)
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((entry) => entry.trim().toLocaleLowerCase())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
const highlightColor = String(
|
|
73
|
+
getResponsiveValue(block.properties?.highlightColor, currentBreakpoint, '')
|
|
74
|
+
);
|
|
75
|
+
const highlightWeight = String(
|
|
76
|
+
getResponsiveValue(block.properties?.highlightWeight, currentBreakpoint, '')
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const showSubmenu = readToggle(
|
|
80
|
+
block.properties?.showSubmenu,
|
|
81
|
+
currentBreakpoint,
|
|
82
|
+
false
|
|
83
|
+
);
|
|
84
|
+
const submenuTrigger =
|
|
85
|
+
String(
|
|
86
|
+
getResponsiveValue(
|
|
87
|
+
block.properties?.submenuTrigger,
|
|
88
|
+
currentBreakpoint,
|
|
89
|
+
'hover'
|
|
90
|
+
)
|
|
91
|
+
) === 'click'
|
|
92
|
+
? 'click'
|
|
93
|
+
: 'hover';
|
|
94
|
+
const activeMatch = String(
|
|
95
|
+
getResponsiveValue(
|
|
96
|
+
block.properties?.activeMatch,
|
|
97
|
+
currentBreakpoint,
|
|
98
|
+
'exact'
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
const maxItems = Number(
|
|
102
|
+
getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0)
|
|
103
|
+
);
|
|
104
|
+
const emptyText = String(
|
|
105
|
+
getResponsiveValue(
|
|
106
|
+
block.properties?.emptyText,
|
|
107
|
+
currentBreakpoint,
|
|
108
|
+
'Navigation menu (no items)'
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
const loadingText = String(
|
|
112
|
+
getResponsiveValue(block.properties?.loadingText, currentBreakpoint, '…')
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const isHighlighted = (label?: string) =>
|
|
116
|
+
Boolean(
|
|
117
|
+
label &&
|
|
118
|
+
highlightLabels.some((entry) =>
|
|
119
|
+
String(label).toLocaleLowerCase().includes(entry)
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// The entry for the page the shopper is on is what a brand reaches for first
|
|
124
|
+
// after colour; it carries `selected` so a part style can address it.
|
|
125
|
+
const isCurrent = (url?: string) => {
|
|
126
|
+
const target = normalizePath(url);
|
|
127
|
+
const current = normalizePath(pathname);
|
|
128
|
+
|
|
129
|
+
if (!target || target === '#' || !current) return false;
|
|
130
|
+
if (activeMatch === 'section' && target !== '/') {
|
|
131
|
+
return current === target || current.startsWith(`${target}/`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return current === target;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const { data: menu, isLoading } = useGetMenuQuery({
|
|
138
|
+
depth: Number.isFinite(depth) && depth > 0 ? depth : 1,
|
|
139
|
+
...(parent ? { parent } : {})
|
|
140
|
+
} as Parameters<typeof useGetMenuQuery>[0]);
|
|
141
|
+
|
|
142
|
+
const allItems: MenuItemLike[] = Array.isArray(menu) ? menu : [];
|
|
143
|
+
const items =
|
|
144
|
+
Number.isFinite(maxItems) && maxItems > 0
|
|
145
|
+
? allItems.slice(0, Math.round(maxItems))
|
|
146
|
+
: allItems;
|
|
147
|
+
|
|
148
|
+
// A designer who marked a child as the row layout gets that layout repeated
|
|
149
|
+
// per menu entry; otherwise the built-in links render unchanged.
|
|
150
|
+
const customItems = hasItemTemplate(block, currentBreakpoint) ? (
|
|
151
|
+
<ItemTemplateList
|
|
152
|
+
block={block}
|
|
153
|
+
items={items.map((item, index) => ({
|
|
154
|
+
key: String(item.pk ?? index),
|
|
155
|
+
name: String(item.label ?? ''),
|
|
156
|
+
label: String(item.label ?? ''),
|
|
157
|
+
url: String(item.url ?? ''),
|
|
158
|
+
hasChildren: (item.children || []).length > 0
|
|
159
|
+
}))}
|
|
160
|
+
placeholderId={placeholderId}
|
|
161
|
+
sectionId={sectionId}
|
|
162
|
+
isDesigner={isDesigner}
|
|
163
|
+
selectedBlockId={selectedBlockId}
|
|
164
|
+
currentBreakpoint={currentBreakpoint}
|
|
165
|
+
/>
|
|
166
|
+
) : null;
|
|
167
|
+
|
|
168
|
+
if (isLoading && isDesigner && !customItems) {
|
|
169
|
+
return (
|
|
170
|
+
<span data-nav-menu="" data-nav-menu-loading="">
|
|
171
|
+
{loadingText}
|
|
172
|
+
</span>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (items.length === 0 && isDesigner && !customItems) {
|
|
177
|
+
return <span data-nav-menu="">{emptyText}</span>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const submenuPanel = (item: MenuItemLike, key: string) => {
|
|
181
|
+
const children = item.children || [];
|
|
182
|
+
if (!showSubmenu || children.length === 0 || openKey !== key) return null;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
data-nav-submenu=""
|
|
187
|
+
{...partAttrs('submenu', { open: true })}
|
|
188
|
+
style={{
|
|
189
|
+
position: 'absolute',
|
|
190
|
+
top: '100%',
|
|
191
|
+
left: 0,
|
|
192
|
+
zIndex: 60,
|
|
193
|
+
minWidth: '200px',
|
|
194
|
+
display: 'flex',
|
|
195
|
+
flexDirection: 'column',
|
|
196
|
+
gap: '8px',
|
|
197
|
+
padding: '12px 16px',
|
|
198
|
+
background: '#ffffff',
|
|
199
|
+
color: '#111827',
|
|
200
|
+
boxShadow: '0 12px 24px rgba(0,0,0,0.12)',
|
|
201
|
+
textAlign: 'left'
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
{children.map((child, childIndex) => (
|
|
205
|
+
<NextLink
|
|
206
|
+
key={child.pk ?? childIndex}
|
|
207
|
+
href={isDesigner ? '#' : child.url || '#'}
|
|
208
|
+
data-nav-submenu-item=""
|
|
209
|
+
{...partAttrs('submenu-item', {
|
|
210
|
+
selected: isCurrent(child.url)
|
|
211
|
+
})}
|
|
212
|
+
onClick={(event) => {
|
|
213
|
+
// Navigating inside the designer iframe would drop the author
|
|
214
|
+
// out of the page they are editing.
|
|
215
|
+
if (isDesigner) event.preventDefault();
|
|
216
|
+
setOpenKey(null);
|
|
217
|
+
}}
|
|
218
|
+
style={{
|
|
219
|
+
color: 'inherit',
|
|
220
|
+
textDecoration: 'none',
|
|
221
|
+
fontSize: '0.92em'
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
{child.label}
|
|
225
|
+
</NextLink>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<nav
|
|
233
|
+
data-nav-menu=""
|
|
234
|
+
{...partAttrs('menu')}
|
|
235
|
+
style={{
|
|
236
|
+
display: 'flex',
|
|
237
|
+
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
|
238
|
+
alignItems: 'center',
|
|
239
|
+
gap: 'var(--nav-menu-gap, 24px)',
|
|
240
|
+
flexWrap: 'wrap'
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
{customItems ??
|
|
244
|
+
items.map((item, index) => {
|
|
245
|
+
const highlighted = isHighlighted(item.label);
|
|
246
|
+
const key = String(item.pk ?? index);
|
|
247
|
+
const current = isCurrent(item.url);
|
|
248
|
+
const hasChildren = (item.children || []).length > 0;
|
|
249
|
+
|
|
250
|
+
const link = (
|
|
251
|
+
<NextLink
|
|
252
|
+
key={key}
|
|
253
|
+
href={item.url || '#'}
|
|
254
|
+
data-nav-item={highlighted ? 'highlighted' : 'default'}
|
|
255
|
+
aria-current={current ? 'page' : undefined}
|
|
256
|
+
{...partAttrs('item', {
|
|
257
|
+
selected: current,
|
|
258
|
+
open: showSubmenu && hasChildren && openKey === key
|
|
259
|
+
})}
|
|
260
|
+
onClick={(event) => {
|
|
261
|
+
if (isDesigner) event.preventDefault();
|
|
262
|
+
if (showSubmenu && hasChildren && submenuTrigger === 'click') {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
setOpenKey(openKey === key ? null : key);
|
|
265
|
+
}
|
|
266
|
+
}}
|
|
267
|
+
style={{
|
|
268
|
+
textDecoration: 'none',
|
|
269
|
+
color:
|
|
270
|
+
highlighted && highlightColor ? highlightColor : 'inherit',
|
|
271
|
+
fontWeight:
|
|
272
|
+
highlighted && highlightWeight ? highlightWeight : 'inherit'
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{item.label}
|
|
276
|
+
</NextLink>
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Without a submenu the entry stays a bare link, exactly as it has
|
|
280
|
+
// always rendered — the positioning wrapper only appears when a panel
|
|
281
|
+
// actually needs somewhere to anchor.
|
|
282
|
+
if (!showSubmenu || !hasChildren) return link;
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<span
|
|
286
|
+
key={key}
|
|
287
|
+
data-nav-item-wrapper=""
|
|
288
|
+
onMouseEnter={() => {
|
|
289
|
+
if (submenuTrigger === 'hover') setOpenKey(key);
|
|
290
|
+
}}
|
|
291
|
+
onMouseLeave={() => {
|
|
292
|
+
if (submenuTrigger === 'hover') setOpenKey(null);
|
|
293
|
+
}}
|
|
294
|
+
style={{
|
|
295
|
+
position: 'relative',
|
|
296
|
+
display: 'inline-flex',
|
|
297
|
+
alignItems: 'center'
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
{link}
|
|
301
|
+
{submenuPanel(item, key)}
|
|
302
|
+
</span>
|
|
303
|
+
);
|
|
304
|
+
})}
|
|
305
|
+
</nav>
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export default NavMenuBlock;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { useEmailSubscriptionMutation } from '@akinon/next/data/client/misc';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
|
|
8
|
+
/** The wording the block has always shipped with; every label is editable. */
|
|
9
|
+
const DEFAULT_ERROR_MESSAGE = 'Subscription failed. Please try again.';
|
|
10
|
+
|
|
11
|
+
const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Functional chrome block: newsletter capture. The submit is real — it posts to
|
|
15
|
+
* the commerce email subscription endpoint through
|
|
16
|
+
* `useEmailSubscriptionMutation`, the same mutation the brand footer forms use.
|
|
17
|
+
* The editor owns the wording and whether the field and button sit on one row.
|
|
18
|
+
* Properties: placeholder, inputLabel, submitLabel, loadingLabel, successLabel,
|
|
19
|
+
* successBehavior, errorLabel, invalidLabel, layout.
|
|
20
|
+
* Parts: form, input, submit, success, error.
|
|
21
|
+
*/
|
|
22
|
+
const NewsletterFormBlock = ({
|
|
23
|
+
block,
|
|
24
|
+
currentBreakpoint = 'desktop',
|
|
25
|
+
isDesigner = false
|
|
26
|
+
}: BlockRendererProps) => {
|
|
27
|
+
const [email, setEmail] = useState('');
|
|
28
|
+
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
29
|
+
// Kept beside `status` rather than folded into it so the root
|
|
30
|
+
// `data-newsletter-form-state` keeps the four values it has always had.
|
|
31
|
+
const [errorKind, setErrorKind] = useState<'invalid' | 'request'>('request');
|
|
32
|
+
const [subscribe, { isLoading }] = useEmailSubscriptionMutation();
|
|
33
|
+
|
|
34
|
+
const prop = (key: string, fallback: string) => {
|
|
35
|
+
const value = getResponsiveValue(
|
|
36
|
+
block.properties?.[key],
|
|
37
|
+
currentBreakpoint,
|
|
38
|
+
fallback
|
|
39
|
+
);
|
|
40
|
+
return value === undefined || value === null ? fallback : String(value);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const placeholder = prop('placeholder', 'Your email address');
|
|
44
|
+
const inputLabel = prop('inputLabel', '');
|
|
45
|
+
const submitLabel = prop('submitLabel', 'Subscribe');
|
|
46
|
+
const loadingLabel = prop('loadingLabel', '');
|
|
47
|
+
const successLabel = prop('successLabel', 'Thanks for subscribing.');
|
|
48
|
+
const errorLabel = prop('errorLabel', DEFAULT_ERROR_MESSAGE);
|
|
49
|
+
const invalidLabel = prop('invalidLabel', '');
|
|
50
|
+
const layout = prop('layout', 'inline') === 'stacked' ? 'stacked' : 'inline';
|
|
51
|
+
const keepFormOnSuccess = prop('successBehavior', 'replace') === 'keep';
|
|
52
|
+
|
|
53
|
+
const errorMessage =
|
|
54
|
+
errorKind === 'invalid' && invalidLabel ? invalidLabel : errorLabel;
|
|
55
|
+
|
|
56
|
+
const handleSubmit = async (event: React.FormEvent) => {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
|
|
59
|
+
// A submit inside the designer must never reach the brand's subscriber list.
|
|
60
|
+
if (isDesigner || isLoading) return;
|
|
61
|
+
|
|
62
|
+
const value = email.trim();
|
|
63
|
+
if (!isEmail(value)) {
|
|
64
|
+
setErrorKind('invalid');
|
|
65
|
+
setStatus('error');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// `subscribe_contract` is a required field on the commerce payload; the
|
|
71
|
+
// form itself is the consent, so it is always sent as true.
|
|
72
|
+
await subscribe({ email: value, subscribe_contract: true }).unwrap();
|
|
73
|
+
setStatus('success');
|
|
74
|
+
setEmail('');
|
|
75
|
+
} catch {
|
|
76
|
+
setErrorKind('request');
|
|
77
|
+
setStatus('error');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const succeeded = status === 'success';
|
|
82
|
+
const showFields = !succeeded || keepFormOnSuccess;
|
|
83
|
+
|
|
84
|
+
const successNode = (
|
|
85
|
+
<span
|
|
86
|
+
data-newsletter-success=""
|
|
87
|
+
{...partAttrs('success')}
|
|
88
|
+
role="status"
|
|
89
|
+
// Only when the fields stay: the message then needs its own line instead
|
|
90
|
+
// of squeezing in beside the input. Replacing the fields keeps the
|
|
91
|
+
// unstyled span the block has always rendered.
|
|
92
|
+
style={keepFormOnSuccess ? { flex: '1 1 100%' } : undefined}
|
|
93
|
+
>
|
|
94
|
+
{successLabel}
|
|
95
|
+
</span>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<form
|
|
100
|
+
data-newsletter-form=""
|
|
101
|
+
data-newsletter-form-state={isLoading ? 'busy' : status}
|
|
102
|
+
{...partAttrs('form', { loading: isLoading, invalid: status === 'error' })}
|
|
103
|
+
onSubmit={handleSubmit}
|
|
104
|
+
noValidate
|
|
105
|
+
style={{
|
|
106
|
+
display: 'flex',
|
|
107
|
+
flexDirection: layout === 'stacked' ? 'column' : 'row',
|
|
108
|
+
alignItems: layout === 'stacked' ? 'stretch' : 'center',
|
|
109
|
+
gap: 'var(--newsletter-form-gap, 8px)',
|
|
110
|
+
width: '100%'
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{succeeded && !keepFormOnSuccess ? successNode : null}
|
|
114
|
+
|
|
115
|
+
{showFields && (
|
|
116
|
+
<>
|
|
117
|
+
<input
|
|
118
|
+
type="email"
|
|
119
|
+
value={email}
|
|
120
|
+
onChange={(event) => {
|
|
121
|
+
setEmail(event.target.value);
|
|
122
|
+
if (status === 'error') setStatus('idle');
|
|
123
|
+
}}
|
|
124
|
+
placeholder={placeholder}
|
|
125
|
+
aria-label={inputLabel || placeholder}
|
|
126
|
+
aria-invalid={status === 'error'}
|
|
127
|
+
data-newsletter-input=""
|
|
128
|
+
{...partAttrs('input', { invalid: status === 'error' })}
|
|
129
|
+
style={{
|
|
130
|
+
flex: layout === 'stacked' ? '0 0 auto' : '1 1 auto',
|
|
131
|
+
minWidth: 0,
|
|
132
|
+
border: '1px solid currentColor',
|
|
133
|
+
borderRadius: 'inherit',
|
|
134
|
+
background: 'transparent',
|
|
135
|
+
color: 'inherit',
|
|
136
|
+
font: 'inherit',
|
|
137
|
+
padding: '10px 12px',
|
|
138
|
+
outline: 'none'
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
|
|
142
|
+
<button
|
|
143
|
+
type="submit"
|
|
144
|
+
data-newsletter-submit=""
|
|
145
|
+
{...partAttrs('submit', {
|
|
146
|
+
loading: isLoading,
|
|
147
|
+
disabled: isLoading
|
|
148
|
+
})}
|
|
149
|
+
disabled={isLoading}
|
|
150
|
+
aria-busy={isLoading}
|
|
151
|
+
style={{
|
|
152
|
+
flex: '0 0 auto',
|
|
153
|
+
border: '1px solid currentColor',
|
|
154
|
+
borderRadius: 'inherit',
|
|
155
|
+
background: 'transparent',
|
|
156
|
+
color: 'inherit',
|
|
157
|
+
font: 'inherit',
|
|
158
|
+
padding: '10px 16px',
|
|
159
|
+
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
160
|
+
opacity: isLoading ? 0.7 : 1
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{isLoading && loadingLabel ? loadingLabel : submitLabel}
|
|
164
|
+
</button>
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{succeeded && keepFormOnSuccess ? successNode : null}
|
|
169
|
+
|
|
170
|
+
{status === 'error' && (
|
|
171
|
+
<span
|
|
172
|
+
data-newsletter-error=""
|
|
173
|
+
{...partAttrs('error', { invalid: true })}
|
|
174
|
+
role="alert"
|
|
175
|
+
style={{ flex: '1 1 100%', fontSize: '0.85em', opacity: 0.85 }}
|
|
176
|
+
>
|
|
177
|
+
{errorMessage}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</form>
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export default NewsletterFormBlock;
|