@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,641 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
6
|
+
import { useFetchCheckoutResultQuery } from '@akinon/next/data/client/checkout';
|
|
7
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
8
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
9
|
+
import { useThemePageContext } from '../theme-page-context';
|
|
10
|
+
import { formatDate, formatMoney, parseAmount } from './commerce-format';
|
|
11
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
12
|
+
import { groupOrderItems, readToggle } from './product-block-utils';
|
|
13
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
|
+
|
|
15
|
+
interface OrderAddressLike {
|
|
16
|
+
title?: string;
|
|
17
|
+
line?: string;
|
|
18
|
+
postcode?: string;
|
|
19
|
+
first_name?: string;
|
|
20
|
+
last_name?: string;
|
|
21
|
+
phone_number?: string;
|
|
22
|
+
city?: { name?: string };
|
|
23
|
+
township?: { name?: string };
|
|
24
|
+
district?: { name?: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface OrderItemLike {
|
|
28
|
+
id?: number;
|
|
29
|
+
price?: string;
|
|
30
|
+
status?: { label?: string };
|
|
31
|
+
product?: {
|
|
32
|
+
pk?: number;
|
|
33
|
+
name?: string;
|
|
34
|
+
image?: string;
|
|
35
|
+
absolute_url?: string;
|
|
36
|
+
} | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OrderLike {
|
|
40
|
+
id?: number;
|
|
41
|
+
number?: string;
|
|
42
|
+
amount?: string;
|
|
43
|
+
shipping_amount?: string;
|
|
44
|
+
created_date?: string;
|
|
45
|
+
user_email?: string;
|
|
46
|
+
currency?: { value?: string };
|
|
47
|
+
status?: { label?: string };
|
|
48
|
+
shipping_address?: OrderAddressLike;
|
|
49
|
+
orderitem_set?: OrderItemLike[];
|
|
50
|
+
discountitem_set?: Array<{ name?: string; amount?: string }>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RecapRow {
|
|
54
|
+
key: string;
|
|
55
|
+
label: string;
|
|
56
|
+
value: string;
|
|
57
|
+
type: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Route words that can sit in the last path segment when no token is present
|
|
62
|
+
* (/orders/completed with a trailing slash, for instance). Treating one of
|
|
63
|
+
* them as a token would fire a guaranteed 404 against commerce.
|
|
64
|
+
*/
|
|
65
|
+
const RESERVED_SEGMENTS = new Set([
|
|
66
|
+
'completed',
|
|
67
|
+
'complete',
|
|
68
|
+
'success',
|
|
69
|
+
'order',
|
|
70
|
+
'orders',
|
|
71
|
+
'checkout'
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Order tokens are long opaque hashes; anything short is a route segment. */
|
|
75
|
+
const MIN_TOKEN_LENGTH = 8;
|
|
76
|
+
|
|
77
|
+
const resolveToken = (pathname: string, tokenParam: string | null): string => {
|
|
78
|
+
const fromQuery = (tokenParam || '').trim();
|
|
79
|
+
if (fromQuery) return fromQuery;
|
|
80
|
+
|
|
81
|
+
const segments = String(pathname || '')
|
|
82
|
+
.split('/')
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
const last = segments[segments.length - 1] || '';
|
|
85
|
+
|
|
86
|
+
if (!last || last.length < MIN_TOKEN_LENGTH) return '';
|
|
87
|
+
if (RESERVED_SEGMENTS.has(last.toLowerCase())) return '';
|
|
88
|
+
|
|
89
|
+
return last;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Replaces the {number} / {email} placeholders an author may use in copy. */
|
|
93
|
+
const fillTemplate = (
|
|
94
|
+
template: string,
|
|
95
|
+
values: { number: string; email: string }
|
|
96
|
+
) =>
|
|
97
|
+
template
|
|
98
|
+
.replace(/\{number\}/g, values.number)
|
|
99
|
+
.replace(/\{email\}/g, values.email);
|
|
100
|
+
|
|
101
|
+
const formatAddress = (address?: OrderAddressLike): string[] => {
|
|
102
|
+
if (!address) return [];
|
|
103
|
+
|
|
104
|
+
const locality = [
|
|
105
|
+
address.district?.name,
|
|
106
|
+
address.township?.name,
|
|
107
|
+
address.city?.name
|
|
108
|
+
]
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join(' / ');
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
address.title,
|
|
114
|
+
[address.line, address.postcode].filter(Boolean).join(' '),
|
|
115
|
+
locality,
|
|
116
|
+
[address.first_name, address.last_name].filter(Boolean).join(' '),
|
|
117
|
+
address.phone_number
|
|
118
|
+
].filter((entry): entry is string => Boolean(entry && entry.trim()));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const readCount = (value: unknown, fallback: number): number => {
|
|
122
|
+
const numeric = Math.round(Number(value));
|
|
123
|
+
|
|
124
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const SuccessGlyph = ({ icon }: { icon: string }) =>
|
|
128
|
+
icon === 'circle-check' ? (
|
|
129
|
+
<svg
|
|
130
|
+
width="40"
|
|
131
|
+
height="40"
|
|
132
|
+
viewBox="0 0 24 24"
|
|
133
|
+
fill="none"
|
|
134
|
+
stroke="currentColor"
|
|
135
|
+
strokeWidth="1.6"
|
|
136
|
+
strokeLinecap="round"
|
|
137
|
+
strokeLinejoin="round"
|
|
138
|
+
>
|
|
139
|
+
<circle cx="12" cy="12" r="9" />
|
|
140
|
+
<path d="M8 12.5l2.5 2.5L16 9.5" />
|
|
141
|
+
</svg>
|
|
142
|
+
) : (
|
|
143
|
+
<svg
|
|
144
|
+
width="40"
|
|
145
|
+
height="40"
|
|
146
|
+
viewBox="0 0 24 24"
|
|
147
|
+
fill="none"
|
|
148
|
+
stroke="currentColor"
|
|
149
|
+
strokeWidth="2"
|
|
150
|
+
strokeLinecap="round"
|
|
151
|
+
strokeLinejoin="round"
|
|
152
|
+
>
|
|
153
|
+
<path d="M4 12.5l5 5L20 6.5" />
|
|
154
|
+
</svg>
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Thank-you recap for the order completion page. Purely a read of an order
|
|
159
|
+
* that already exists: the order is fetched by the token pz puts in the
|
|
160
|
+
* completion URL (/orders/completed/<token>), which is the same read the
|
|
161
|
+
* brand's own completed page performs. Nothing here can create, mutate or
|
|
162
|
+
* re-submit an order.
|
|
163
|
+
*
|
|
164
|
+
* Parts: root, header, icon, title, subtitle, meta, row, row-label, row-value,
|
|
165
|
+
* number, list, item, image, content, name, quantity, line-total, totals,
|
|
166
|
+
* total, total-label, total-value, address, address-title, address-line, cta,
|
|
167
|
+
* empty. A child marked as the item template replaces the line rows, the
|
|
168
|
+
* totals rows or the meta rows (`itemTemplateTarget`).
|
|
169
|
+
*
|
|
170
|
+
* `title` and `subtitle` accept {number} and {email} placeholders.
|
|
171
|
+
*/
|
|
172
|
+
const OrderSuccessBlock = ({
|
|
173
|
+
block,
|
|
174
|
+
placeholderId,
|
|
175
|
+
sectionId,
|
|
176
|
+
selectedBlockId = null,
|
|
177
|
+
currentBreakpoint = 'desktop',
|
|
178
|
+
isDesigner = false
|
|
179
|
+
}: BlockRendererProps) => {
|
|
180
|
+
const prop = (key: string, fallback: string) =>
|
|
181
|
+
String(
|
|
182
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const boolProp = (key: string, fallback: boolean) =>
|
|
186
|
+
readToggle(block.properties?.[key], currentBreakpoint, fallback);
|
|
187
|
+
|
|
188
|
+
const titleTemplate = prop('title', 'Thank you for your order');
|
|
189
|
+
const subtitleTemplate = prop(
|
|
190
|
+
'subtitle',
|
|
191
|
+
'We sent the confirmation to {email}.'
|
|
192
|
+
);
|
|
193
|
+
const continueHref = prop('continueHref', '/').trim();
|
|
194
|
+
const continueLabel = prop('continueLabel', 'Continue shopping').trim();
|
|
195
|
+
const showLineItems = boolProp('showLineItems', true);
|
|
196
|
+
const showIcon = boolProp('showIcon', false);
|
|
197
|
+
const successIcon = prop('successIcon', 'check');
|
|
198
|
+
const showMeta = boolProp('showMeta', true);
|
|
199
|
+
const showOrderNumber = boolProp('showOrderNumber', true);
|
|
200
|
+
const showOrderDate = boolProp('showOrderDate', true);
|
|
201
|
+
const showEmail = boolProp('showEmail', true);
|
|
202
|
+
const showTotals = boolProp('showTotals', true);
|
|
203
|
+
const showSubtotal = boolProp('showSubtotal', true);
|
|
204
|
+
const showDiscounts = boolProp('showDiscounts', true);
|
|
205
|
+
const showShipping = boolProp('showShipping', true);
|
|
206
|
+
const showAddress = boolProp('showAddress', true);
|
|
207
|
+
const showItemImage = boolProp('showItemImage', true);
|
|
208
|
+
const showItemQuantity = boolProp('showItemQuantity', true);
|
|
209
|
+
|
|
210
|
+
const numberLabel = prop('numberLabel', 'Order number');
|
|
211
|
+
const dateLabel = prop('dateLabel', 'Order date');
|
|
212
|
+
const emailLabel = prop('emailLabel', 'E-mail');
|
|
213
|
+
const subtotalLabel = prop('subtotalLabel', 'Subtotal');
|
|
214
|
+
const discountLabel = prop('discountLabel', 'Discount');
|
|
215
|
+
const shippingLabel = prop('shippingLabel', 'Shipping');
|
|
216
|
+
const totalLabel = prop('totalLabel', 'Total');
|
|
217
|
+
const addressLabel = prop('addressLabel', 'Shipping address');
|
|
218
|
+
const quantityPrefix = prop('quantityPrefix', '×');
|
|
219
|
+
const discountPrefix = prop('discountPrefix', '-');
|
|
220
|
+
const emptyLabel = prop('emptyLabel', '').trim();
|
|
221
|
+
const loadingLabel = prop('loadingLabel', '').trim();
|
|
222
|
+
const templateTarget = prop('itemTemplateTarget', 'items');
|
|
223
|
+
|
|
224
|
+
const imageWidth = readCount(
|
|
225
|
+
getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 56),
|
|
226
|
+
56
|
|
227
|
+
);
|
|
228
|
+
const imageHeight = readCount(
|
|
229
|
+
getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 75),
|
|
230
|
+
75
|
|
231
|
+
);
|
|
232
|
+
const maxItems = readCount(
|
|
233
|
+
getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
|
|
234
|
+
0
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const pathname = usePathname();
|
|
238
|
+
const searchParams = useSearchParams();
|
|
239
|
+
const pageContext = useThemePageContext();
|
|
240
|
+
const { locale, currency: localeCurrency } = useLocalization();
|
|
241
|
+
|
|
242
|
+
// No page supplies an order page context yet, so this read is inert for now;
|
|
243
|
+
// it is kept so the block switches over without a code change once it is.
|
|
244
|
+
const contextOrder = (pageContext?.order ?? null) as OrderLike | null;
|
|
245
|
+
const token = resolveToken(pathname || '', searchParams?.get('token') ?? null);
|
|
246
|
+
|
|
247
|
+
const { data, isLoading } = useFetchCheckoutResultQuery(token, {
|
|
248
|
+
skip: !token || Boolean(contextOrder)
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const order =
|
|
252
|
+
contextOrder ??
|
|
253
|
+
((data as { order?: OrderLike } | undefined)?.order || null);
|
|
254
|
+
|
|
255
|
+
if (!order) {
|
|
256
|
+
const authoredLabel = isLoading ? loadingLabel : emptyLabel;
|
|
257
|
+
|
|
258
|
+
if (!isDesigner && !authoredLabel) return null;
|
|
259
|
+
|
|
260
|
+
const designerLabel = isLoading
|
|
261
|
+
? 'Order recap (loading)'
|
|
262
|
+
: 'Order recap (no order token in URL)';
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div
|
|
266
|
+
data-order-success=""
|
|
267
|
+
data-order-success-state="empty"
|
|
268
|
+
{...partAttrs('empty', { empty: true, loading: isLoading })}
|
|
269
|
+
>
|
|
270
|
+
{authoredLabel || (isDesigner ? designerLabel : '')}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The order carries the currency it was placed in; the storefront currency
|
|
276
|
+
// is only the fallback.
|
|
277
|
+
const currency = order.currency?.value || localeCurrency || '';
|
|
278
|
+
const money = (value?: string | number) =>
|
|
279
|
+
formatMoney(value, { locale, currency });
|
|
280
|
+
|
|
281
|
+
const items = Array.isArray(order.orderitem_set) ? order.orderitem_set : [];
|
|
282
|
+
|
|
283
|
+
// pz writes one order line per unit, so identical products are collapsed and
|
|
284
|
+
// counted — otherwise a quantity of three reads as three separate rows. The
|
|
285
|
+
// grouping is shared with the account order detail block so both read the
|
|
286
|
+
// same payload the same way.
|
|
287
|
+
const allGroupedItems = groupOrderItems(items);
|
|
288
|
+
const groupedItems =
|
|
289
|
+
maxItems > 0 ? allGroupedItems.slice(0, maxItems) : allGroupedItems;
|
|
290
|
+
|
|
291
|
+
// Commerce does not send an order subtotal, so it is summed from the lines
|
|
292
|
+
// with the shared parser: Number() returns NaN for "1.299,90".
|
|
293
|
+
const subtotal = items.reduce(
|
|
294
|
+
(total, item) => total + (parseAmount(item.price) ?? 0),
|
|
295
|
+
0
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const discounts = (order.discountitem_set || []).filter(
|
|
299
|
+
(discount) => (parseAmount(discount?.amount) ?? 0) > 0
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const orderNumber = order.number || (order.id ? `#${order.id}` : '');
|
|
303
|
+
const email = order.user_email || '';
|
|
304
|
+
const addressLines = formatAddress(order.shipping_address);
|
|
305
|
+
|
|
306
|
+
const metaRows: RecapRow[] = [];
|
|
307
|
+
|
|
308
|
+
if (showOrderNumber && orderNumber) {
|
|
309
|
+
metaRows.push({
|
|
310
|
+
key: 'number',
|
|
311
|
+
label: numberLabel,
|
|
312
|
+
value: orderNumber,
|
|
313
|
+
type: 'number'
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (showOrderDate && order.created_date) {
|
|
318
|
+
metaRows.push({
|
|
319
|
+
key: 'date',
|
|
320
|
+
label: dateLabel,
|
|
321
|
+
value: formatDate(order.created_date, locale),
|
|
322
|
+
type: 'date'
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (showEmail && email) {
|
|
327
|
+
metaRows.push({
|
|
328
|
+
key: 'email',
|
|
329
|
+
label: emailLabel,
|
|
330
|
+
value: email,
|
|
331
|
+
type: 'email'
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const totalsRows: RecapRow[] = [];
|
|
336
|
+
|
|
337
|
+
if (showSubtotal && subtotal > 0) {
|
|
338
|
+
totalsRows.push({
|
|
339
|
+
key: 'subtotal',
|
|
340
|
+
label: subtotalLabel,
|
|
341
|
+
value: money(subtotal),
|
|
342
|
+
type: 'subtotal'
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (showDiscounts) {
|
|
347
|
+
discounts.forEach((discount, index) => {
|
|
348
|
+
totalsRows.push({
|
|
349
|
+
key: `${discount.name || 'discount'}-${index}`,
|
|
350
|
+
label: discount.name || discountLabel,
|
|
351
|
+
value: `${discountPrefix}${money(discount.amount)}`,
|
|
352
|
+
type: 'discount'
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (showShipping && order.shipping_amount !== undefined) {
|
|
358
|
+
totalsRows.push({
|
|
359
|
+
key: 'shipping',
|
|
360
|
+
label: shippingLabel,
|
|
361
|
+
value: money(order.shipping_amount),
|
|
362
|
+
type: 'shipping'
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
totalsRows.push({
|
|
367
|
+
key: 'total',
|
|
368
|
+
label: totalLabel,
|
|
369
|
+
value: money(order.amount),
|
|
370
|
+
type: 'total'
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Plain objects so a designer binds item.name / item.image / item.quantity /
|
|
374
|
+
// item.price / item.url in their own row layout.
|
|
375
|
+
const templateItems = groupedItems.map((item) => ({
|
|
376
|
+
key: item.key,
|
|
377
|
+
name: item.name,
|
|
378
|
+
image: item.image,
|
|
379
|
+
url: item.href,
|
|
380
|
+
quantity: item.quantity,
|
|
381
|
+
price: money(item.price),
|
|
382
|
+
total: item.total === null ? '' : money(item.total)
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
const hasTemplate = hasItemTemplate(block, currentBreakpoint);
|
|
386
|
+
|
|
387
|
+
const templateList = (rows: Record<string, unknown>[]) => (
|
|
388
|
+
<ItemTemplateList
|
|
389
|
+
block={block}
|
|
390
|
+
items={rows}
|
|
391
|
+
placeholderId={placeholderId}
|
|
392
|
+
sectionId={sectionId}
|
|
393
|
+
isDesigner={isDesigner}
|
|
394
|
+
selectedBlockId={selectedBlockId}
|
|
395
|
+
currentBreakpoint={currentBreakpoint}
|
|
396
|
+
/>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const customItems =
|
|
400
|
+
hasTemplate && templateTarget === 'items'
|
|
401
|
+
? templateList(templateItems as unknown as Record<string, unknown>[])
|
|
402
|
+
: null;
|
|
403
|
+
const customTotals =
|
|
404
|
+
hasTemplate && templateTarget === 'totals'
|
|
405
|
+
? templateList(totalsRows as unknown as Record<string, unknown>[])
|
|
406
|
+
: null;
|
|
407
|
+
const customMeta =
|
|
408
|
+
hasTemplate && templateTarget === 'meta'
|
|
409
|
+
? templateList(metaRows as unknown as Record<string, unknown>[])
|
|
410
|
+
: null;
|
|
411
|
+
|
|
412
|
+
const rowStyle: React.CSSProperties = {
|
|
413
|
+
display: 'flex',
|
|
414
|
+
justifyContent: 'space-between',
|
|
415
|
+
gap: '12px',
|
|
416
|
+
padding: '4px 0'
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const renderRow = (row: RecapRow) =>
|
|
420
|
+
row.type === 'total' ? (
|
|
421
|
+
<div
|
|
422
|
+
key={row.key}
|
|
423
|
+
style={{ ...rowStyle, fontWeight: 600 }}
|
|
424
|
+
data-order-success-row="total"
|
|
425
|
+
{...partAttrs('total')}
|
|
426
|
+
>
|
|
427
|
+
<span {...partAttrs('total-label')}>{row.label}</span>
|
|
428
|
+
<span data-order-success-total="" {...partAttrs('total-value')}>
|
|
429
|
+
{row.value}
|
|
430
|
+
</span>
|
|
431
|
+
</div>
|
|
432
|
+
) : (
|
|
433
|
+
<div
|
|
434
|
+
key={row.key}
|
|
435
|
+
style={rowStyle}
|
|
436
|
+
data-order-success-row={row.type}
|
|
437
|
+
{...partAttrs('row')}
|
|
438
|
+
>
|
|
439
|
+
<span {...partAttrs('row-label')}>{row.label}</span>
|
|
440
|
+
<span
|
|
441
|
+
{...(row.type === 'number'
|
|
442
|
+
? { 'data-order-success-number': '' }
|
|
443
|
+
: null)}
|
|
444
|
+
{...(row.type === 'email' ? { 'data-order-success-email': '' } : null)}
|
|
445
|
+
{...partAttrs(row.type === 'number' ? 'number' : 'row-value')}
|
|
446
|
+
>
|
|
447
|
+
{row.value}
|
|
448
|
+
</span>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
data-order-success=""
|
|
455
|
+
{...partAttrs('root')}
|
|
456
|
+
style={{
|
|
457
|
+
display: 'flex',
|
|
458
|
+
flexDirection: 'column',
|
|
459
|
+
gap: 'var(--order-success-gap, 20px)'
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<div data-order-success-header="" {...partAttrs('header')}>
|
|
463
|
+
{showIcon && (
|
|
464
|
+
<div
|
|
465
|
+
data-order-success-icon=""
|
|
466
|
+
aria-hidden="true"
|
|
467
|
+
{...partAttrs('icon')}
|
|
468
|
+
style={{ marginBottom: '8px' }}
|
|
469
|
+
>
|
|
470
|
+
<SuccessGlyph icon={successIcon} />
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
|
|
474
|
+
<div
|
|
475
|
+
data-order-success-title=""
|
|
476
|
+
{...partAttrs('title')}
|
|
477
|
+
style={{ fontWeight: 600 }}
|
|
478
|
+
>
|
|
479
|
+
{fillTemplate(titleTemplate, { number: orderNumber, email })}
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
{subtitleTemplate && (
|
|
483
|
+
<div
|
|
484
|
+
data-order-success-subtitle=""
|
|
485
|
+
{...partAttrs('subtitle')}
|
|
486
|
+
style={{ marginTop: '6px' }}
|
|
487
|
+
>
|
|
488
|
+
{fillTemplate(subtitleTemplate, { number: orderNumber, email })}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
{showMeta && (
|
|
494
|
+
<div data-order-success-meta="" {...partAttrs('meta')}>
|
|
495
|
+
{customMeta ?? metaRows.map(renderRow)}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
|
|
499
|
+
{showLineItems && (groupedItems.length > 0 || customItems) && (
|
|
500
|
+
<ul
|
|
501
|
+
data-order-success-items=""
|
|
502
|
+
{...partAttrs('list')}
|
|
503
|
+
style={{
|
|
504
|
+
display: 'flex',
|
|
505
|
+
flexDirection: 'column',
|
|
506
|
+
gap: '12px',
|
|
507
|
+
listStyle: 'none',
|
|
508
|
+
margin: 0,
|
|
509
|
+
padding: 0
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{customItems ??
|
|
513
|
+
groupedItems.map((item) => (
|
|
514
|
+
<li
|
|
515
|
+
key={item.key}
|
|
516
|
+
data-order-success-item=""
|
|
517
|
+
{...partAttrs('item')}
|
|
518
|
+
style={{
|
|
519
|
+
display: 'flex',
|
|
520
|
+
alignItems: 'flex-start',
|
|
521
|
+
gap: '12px'
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
{showItemImage && item.image && (
|
|
525
|
+
// Plain img: order thumbnails come from arbitrary commerce
|
|
526
|
+
// CDNs that the brand next.config may not allow.
|
|
527
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
528
|
+
<img
|
|
529
|
+
src={item.image}
|
|
530
|
+
alt={item.name}
|
|
531
|
+
width={imageWidth}
|
|
532
|
+
height={imageHeight}
|
|
533
|
+
{...partAttrs('image')}
|
|
534
|
+
style={{
|
|
535
|
+
objectFit: 'cover',
|
|
536
|
+
display: 'block',
|
|
537
|
+
flex: '0 0 auto'
|
|
538
|
+
}}
|
|
539
|
+
/>
|
|
540
|
+
)}
|
|
541
|
+
|
|
542
|
+
<div
|
|
543
|
+
{...partAttrs('content')}
|
|
544
|
+
style={{ flex: '1 1 auto', minWidth: 0 }}
|
|
545
|
+
>
|
|
546
|
+
{item.href ? (
|
|
547
|
+
<NextLink
|
|
548
|
+
// NextLink warns when href resolves to undefined, so the
|
|
549
|
+
// order line url is resolved here as well as guarded above.
|
|
550
|
+
href={isDesigner ? '#' : item.href || '#'}
|
|
551
|
+
data-order-success-item-name=""
|
|
552
|
+
{...partAttrs('name')}
|
|
553
|
+
// Navigating inside the designer iframe would drop the
|
|
554
|
+
// author out of the page they are editing.
|
|
555
|
+
onClick={(event) => {
|
|
556
|
+
if (isDesigner) event.preventDefault();
|
|
557
|
+
}}
|
|
558
|
+
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
559
|
+
>
|
|
560
|
+
{item.name}
|
|
561
|
+
</NextLink>
|
|
562
|
+
) : (
|
|
563
|
+
<span data-order-success-item-name="" {...partAttrs('name')}>
|
|
564
|
+
{item.name}
|
|
565
|
+
</span>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
{showItemQuantity && (
|
|
569
|
+
<div
|
|
570
|
+
{...partAttrs('quantity')}
|
|
571
|
+
style={{ marginTop: '2px', opacity: 0.8 }}
|
|
572
|
+
>
|
|
573
|
+
{quantityPrefix}
|
|
574
|
+
{item.quantity}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
<span
|
|
580
|
+
data-order-success-item-price=""
|
|
581
|
+
{...partAttrs('line-total')}
|
|
582
|
+
style={{ flex: '0 0 auto' }}
|
|
583
|
+
>
|
|
584
|
+
{money(item.price)}
|
|
585
|
+
</span>
|
|
586
|
+
</li>
|
|
587
|
+
))}
|
|
588
|
+
</ul>
|
|
589
|
+
)}
|
|
590
|
+
|
|
591
|
+
{showTotals && (
|
|
592
|
+
<div data-order-success-totals="" {...partAttrs('totals')}>
|
|
593
|
+
{customTotals ?? totalsRows.map(renderRow)}
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
596
|
+
|
|
597
|
+
{showAddress && addressLines.length > 0 && (
|
|
598
|
+
<div data-order-success-address="" {...partAttrs('address')}>
|
|
599
|
+
<div
|
|
600
|
+
{...partAttrs('address-title')}
|
|
601
|
+
style={{ fontWeight: 600, marginBottom: '6px' }}
|
|
602
|
+
>
|
|
603
|
+
{addressLabel}
|
|
604
|
+
</div>
|
|
605
|
+
{addressLines.map((line, index) => (
|
|
606
|
+
<div key={`address-line-${index}`} {...partAttrs('address-line')}>
|
|
607
|
+
{line}
|
|
608
|
+
</div>
|
|
609
|
+
))}
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
|
|
613
|
+
{continueHref && continueLabel && (
|
|
614
|
+
<NextLink
|
|
615
|
+
// NextLink warns when href resolves to undefined, so the continue CTA
|
|
616
|
+
// target is resolved here as well as guarded above.
|
|
617
|
+
href={isDesigner ? '#' : continueHref || '#'}
|
|
618
|
+
data-order-success-continue=""
|
|
619
|
+
{...partAttrs('cta')}
|
|
620
|
+
// Navigating inside the designer iframe would drop the author out of
|
|
621
|
+
// the page they are editing, so the CTA is inert there.
|
|
622
|
+
onClick={(event) => {
|
|
623
|
+
if (isDesigner) event.preventDefault();
|
|
624
|
+
}}
|
|
625
|
+
style={{
|
|
626
|
+
display: 'block',
|
|
627
|
+
textAlign: 'center',
|
|
628
|
+
padding: '12px 20px',
|
|
629
|
+
background: 'var(--order-success-cta-bg, #111827)',
|
|
630
|
+
color: 'var(--order-success-cta-color, #ffffff)',
|
|
631
|
+
textDecoration: 'none'
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
{continueLabel}
|
|
635
|
+
</NextLink>
|
|
636
|
+
)}
|
|
637
|
+
</div>
|
|
638
|
+
);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
export default OrderSuccessBlock;
|