@akinon/pz-theme 2.0.38-rc.0 → 2.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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;