@akinon/pz-theme 2.0.37-rc.0 → 2.0.37
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 +7 -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,449 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useGetBasketQuery } from '@akinon/next/data/client/basket';
|
|
5
|
+
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
6
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
8
|
+
import { useThemePageContext } from '../theme-page-context';
|
|
9
|
+
import { formatMoney, parseAmount } from './commerce-format';
|
|
10
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
11
|
+
import { readToggle } from './product-block-utils';
|
|
12
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
13
|
+
|
|
14
|
+
/** Live basket line, as `getBasket` returns it. */
|
|
15
|
+
interface BasketLineLike {
|
|
16
|
+
id?: number;
|
|
17
|
+
quantity?: number;
|
|
18
|
+
image?: string;
|
|
19
|
+
price?: string;
|
|
20
|
+
unit_price?: string;
|
|
21
|
+
total_amount?: string;
|
|
22
|
+
currency_type?: string;
|
|
23
|
+
product?: { pk?: number; name?: string; image?: string } | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface BasketTotalsLike {
|
|
27
|
+
total_amount?: string;
|
|
28
|
+
total_product_amount?: string;
|
|
29
|
+
total_discount_amount?: string;
|
|
30
|
+
voucher_code?: string | null;
|
|
31
|
+
basketitem_set?: BasketLineLike[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Basket line as `buildBasketPageContext()` normalises it (different keys). */
|
|
35
|
+
interface ContextLineLike {
|
|
36
|
+
name?: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
image?: string;
|
|
39
|
+
quantity?: number;
|
|
40
|
+
price?: string;
|
|
41
|
+
unitPrice?: string;
|
|
42
|
+
totalAmount?: string;
|
|
43
|
+
currency?: string;
|
|
44
|
+
product?: { pk?: number } | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SummaryLine {
|
|
48
|
+
key: string;
|
|
49
|
+
name: string;
|
|
50
|
+
image: string;
|
|
51
|
+
quantity: number;
|
|
52
|
+
total: string;
|
|
53
|
+
currency: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TotalsRow {
|
|
57
|
+
key: string;
|
|
58
|
+
label: string;
|
|
59
|
+
value: string;
|
|
60
|
+
/** `total` is the grand total; it carries its own part and weight. */
|
|
61
|
+
type: 'subtotal' | 'shipping' | 'discount' | 'total';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const readCount = (value: unknown, fallback: number): number => {
|
|
65
|
+
const numeric = Math.round(Number(value));
|
|
66
|
+
|
|
67
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read-only checkout sidebar recap. Deliberately has no affordances: no
|
|
72
|
+
* quantity steppers, no remove buttons, no coupon form and no product links.
|
|
73
|
+
* A shopper who leaves the checkout page mid-flow loses the pre-order the
|
|
74
|
+
* payment step is built on, so this block only ever displays.
|
|
75
|
+
*
|
|
76
|
+
* Totals come from the basket (`useGetBasketQuery`, the same source as
|
|
77
|
+
* basket-summary-block). Shipping is the one figure the basket payload does
|
|
78
|
+
* not carry — it only exists on the checkout pre-order — so it is read from
|
|
79
|
+
* the checkout redux slice, read-only, and the row is skipped entirely
|
|
80
|
+
* outside checkout where no pre-order exists.
|
|
81
|
+
*
|
|
82
|
+
* Parts: root, list, item, image, content, name, quantity, line-total, totals,
|
|
83
|
+
* row, row-label, row-value, total, total-label, total-value, coupon,
|
|
84
|
+
* coupon-label, coupon-code, empty. A child marked as the item template
|
|
85
|
+
* replaces either the line rows or the totals rows (`itemTemplateTarget`).
|
|
86
|
+
*/
|
|
87
|
+
const CheckoutSummaryBlock = ({
|
|
88
|
+
block,
|
|
89
|
+
placeholderId,
|
|
90
|
+
sectionId,
|
|
91
|
+
selectedBlockId = null,
|
|
92
|
+
currentBreakpoint = 'desktop',
|
|
93
|
+
isDesigner = false
|
|
94
|
+
}: BlockRendererProps) => {
|
|
95
|
+
const prop = (key: string, fallback: string) =>
|
|
96
|
+
String(
|
|
97
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const boolProp = (key: string, fallback: boolean) =>
|
|
101
|
+
readToggle(block.properties?.[key], currentBreakpoint, fallback);
|
|
102
|
+
|
|
103
|
+
const showLineItems = boolProp('showLineItems', true);
|
|
104
|
+
const showCoupon = boolProp('showCoupon', true);
|
|
105
|
+
const showSubtotal = boolProp('showSubtotal', true);
|
|
106
|
+
const showShipping = boolProp('showShipping', true);
|
|
107
|
+
const showDiscount = boolProp('showDiscount', true);
|
|
108
|
+
const showItemImage = boolProp('showItemImage', true);
|
|
109
|
+
const showItemQuantity = boolProp('showItemQuantity', true);
|
|
110
|
+
|
|
111
|
+
const subtotalLabel = prop('subtotalLabel', 'Subtotal');
|
|
112
|
+
const shippingLabel = prop('shippingLabel', 'Shipping');
|
|
113
|
+
const discountLabel = prop('discountLabel', 'Discount');
|
|
114
|
+
const totalLabel = prop('totalLabel', 'Total');
|
|
115
|
+
const couponLabel = prop('couponLabel', 'Coupon');
|
|
116
|
+
const discountPrefix = prop('discountPrefix', '-');
|
|
117
|
+
const quantityPrefix = prop('quantityPrefix', '×');
|
|
118
|
+
const emptyLabel = prop('emptyLabel', '').trim();
|
|
119
|
+
const loadingLabel = prop('loadingLabel', '').trim();
|
|
120
|
+
const templateTarget = prop('itemTemplateTarget', 'items');
|
|
121
|
+
|
|
122
|
+
const imageWidth = readCount(
|
|
123
|
+
getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 56),
|
|
124
|
+
56
|
|
125
|
+
);
|
|
126
|
+
const imageHeight = readCount(
|
|
127
|
+
getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 75),
|
|
128
|
+
75
|
|
129
|
+
);
|
|
130
|
+
const maxItems = readCount(
|
|
131
|
+
getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
|
|
132
|
+
0
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const pageContext = useThemePageContext();
|
|
136
|
+
const { locale, currency: localeCurrency } = useLocalization();
|
|
137
|
+
const { data: basket, isLoading } = useGetBasketQuery(undefined as never);
|
|
138
|
+
|
|
139
|
+
// Read-only view of the checkout pre-order. Selecting a single primitive-ish
|
|
140
|
+
// branch keeps this from re-rendering on every unrelated checkout action.
|
|
141
|
+
const preOrder = useAppSelector(
|
|
142
|
+
(state: Record<string, any>) => state?.checkout?.preOrder
|
|
143
|
+
) as
|
|
144
|
+
| { shipping_amount?: string; unpaid_amount?: string }
|
|
145
|
+
| null
|
|
146
|
+
| undefined;
|
|
147
|
+
|
|
148
|
+
const liveBasket = basket as BasketTotalsLike | undefined;
|
|
149
|
+
const contextBasket = (pageContext?.basket ?? null) as {
|
|
150
|
+
totalAmount?: string;
|
|
151
|
+
totalProductAmount?: string;
|
|
152
|
+
totalDiscountAmount?: string;
|
|
153
|
+
voucherCode?: string;
|
|
154
|
+
items?: ContextLineLike[];
|
|
155
|
+
} | null;
|
|
156
|
+
|
|
157
|
+
const liveLines = liveBasket?.basketitem_set;
|
|
158
|
+
|
|
159
|
+
// The live query owns the post-mutation state; the SSR page context is only
|
|
160
|
+
// a first-paint fallback so the sidebar is not blank before it resolves.
|
|
161
|
+
const allLines: SummaryLine[] = Array.isArray(liveLines)
|
|
162
|
+
? liveLines.map((item, index) => ({
|
|
163
|
+
key: String(item.id ?? item.product?.pk ?? index),
|
|
164
|
+
name: item.product?.name || '',
|
|
165
|
+
image: item.image || item.product?.image || '',
|
|
166
|
+
quantity: Number(item.quantity ?? 1),
|
|
167
|
+
total: String(item.total_amount ?? item.price ?? ''),
|
|
168
|
+
// The line reports the currency it was priced in; the storefront
|
|
169
|
+
// currency is only the fallback when commerce omits it.
|
|
170
|
+
currency: item.currency_type || localeCurrency || ''
|
|
171
|
+
}))
|
|
172
|
+
: (contextBasket?.items || []).map((item, index) => ({
|
|
173
|
+
key: String(item.product?.pk ?? index),
|
|
174
|
+
name: item.name || item.title || '',
|
|
175
|
+
image: item.image || '',
|
|
176
|
+
quantity: Number(item.quantity ?? 1),
|
|
177
|
+
total: String(item.totalAmount ?? item.price ?? ''),
|
|
178
|
+
currency: item.currency || localeCurrency || ''
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
const lines = maxItems > 0 ? allLines.slice(0, maxItems) : allLines;
|
|
182
|
+
|
|
183
|
+
const totals = {
|
|
184
|
+
subtotal:
|
|
185
|
+
liveBasket?.total_product_amount ?? contextBasket?.totalProductAmount,
|
|
186
|
+
discount:
|
|
187
|
+
liveBasket?.total_discount_amount ?? contextBasket?.totalDiscountAmount,
|
|
188
|
+
// The pre-order's unpaid amount is the payable figure (it already includes
|
|
189
|
+
// shipping and any installment interest); the basket total is the
|
|
190
|
+
// pre-checkout fallback.
|
|
191
|
+
total:
|
|
192
|
+
preOrder?.unpaid_amount ??
|
|
193
|
+
liveBasket?.total_amount ??
|
|
194
|
+
contextBasket?.totalAmount,
|
|
195
|
+
shipping: preOrder?.shipping_amount,
|
|
196
|
+
voucher: liveBasket?.voucher_code ?? contextBasket?.voucherCode ?? ''
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const currency =
|
|
200
|
+
lines[0]?.currency ||
|
|
201
|
+
liveBasket?.basketitem_set?.[0]?.currency_type ||
|
|
202
|
+
localeCurrency ||
|
|
203
|
+
'';
|
|
204
|
+
|
|
205
|
+
const money = (value?: string | number) =>
|
|
206
|
+
formatMoney(value, { locale, currency });
|
|
207
|
+
|
|
208
|
+
const hasBasket = Boolean(liveBasket || contextBasket);
|
|
209
|
+
|
|
210
|
+
const rows: TotalsRow[] = [];
|
|
211
|
+
|
|
212
|
+
if (showSubtotal && totals.subtotal !== undefined) {
|
|
213
|
+
rows.push({
|
|
214
|
+
key: 'subtotal',
|
|
215
|
+
label: subtotalLabel,
|
|
216
|
+
value: money(totals.subtotal),
|
|
217
|
+
type: 'subtotal'
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Shipping only exists on the checkout pre-order, so the row is omitted
|
|
222
|
+
// rather than guessed at when there is none.
|
|
223
|
+
if (showShipping && totals.shipping !== undefined) {
|
|
224
|
+
rows.push({
|
|
225
|
+
key: 'shipping',
|
|
226
|
+
label: shippingLabel,
|
|
227
|
+
value: money(totals.shipping),
|
|
228
|
+
type: 'shipping'
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (showDiscount && (parseAmount(totals.discount) ?? 0) > 0) {
|
|
233
|
+
rows.push({
|
|
234
|
+
key: 'discount',
|
|
235
|
+
label: discountLabel,
|
|
236
|
+
value: `${discountPrefix}${money(totals.discount)}`,
|
|
237
|
+
type: 'discount'
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
rows.push({
|
|
242
|
+
key: 'total',
|
|
243
|
+
label: totalLabel,
|
|
244
|
+
value: money(totals.total),
|
|
245
|
+
type: 'total'
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Plain objects so a designer binds item.name / item.image / item.quantity /
|
|
249
|
+
// item.total in their own row layout.
|
|
250
|
+
const templateLines = lines.map((line) => ({
|
|
251
|
+
key: line.key,
|
|
252
|
+
name: line.name,
|
|
253
|
+
image: line.image,
|
|
254
|
+
quantity: line.quantity,
|
|
255
|
+
total: formatMoney(line.total, { locale, currency: line.currency }),
|
|
256
|
+
currency: line.currency
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
const hasTemplate = hasItemTemplate(block, currentBreakpoint);
|
|
260
|
+
|
|
261
|
+
const customLines =
|
|
262
|
+
hasTemplate && templateTarget !== 'totals' ? (
|
|
263
|
+
<ItemTemplateList
|
|
264
|
+
block={block}
|
|
265
|
+
items={templateLines as unknown as Record<string, unknown>[]}
|
|
266
|
+
placeholderId={placeholderId}
|
|
267
|
+
sectionId={sectionId}
|
|
268
|
+
isDesigner={isDesigner}
|
|
269
|
+
selectedBlockId={selectedBlockId}
|
|
270
|
+
currentBreakpoint={currentBreakpoint}
|
|
271
|
+
/>
|
|
272
|
+
) : null;
|
|
273
|
+
|
|
274
|
+
const customTotals =
|
|
275
|
+
hasTemplate && templateTarget === 'totals' ? (
|
|
276
|
+
<ItemTemplateList
|
|
277
|
+
block={block}
|
|
278
|
+
items={rows as unknown as Record<string, unknown>[]}
|
|
279
|
+
placeholderId={placeholderId}
|
|
280
|
+
sectionId={sectionId}
|
|
281
|
+
isDesigner={isDesigner}
|
|
282
|
+
selectedBlockId={selectedBlockId}
|
|
283
|
+
currentBreakpoint={currentBreakpoint}
|
|
284
|
+
/>
|
|
285
|
+
) : null;
|
|
286
|
+
|
|
287
|
+
if (!hasBasket) {
|
|
288
|
+
const authoredLabel = isLoading ? loadingLabel : emptyLabel;
|
|
289
|
+
|
|
290
|
+
if (!isDesigner && !authoredLabel) return null;
|
|
291
|
+
|
|
292
|
+
const designerLabel = isLoading
|
|
293
|
+
? 'Checkout summary (loading)'
|
|
294
|
+
: 'Checkout summary (no basket data)';
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
data-checkout-summary=""
|
|
299
|
+
data-checkout-summary-state="empty"
|
|
300
|
+
{...partAttrs('empty', { empty: true, loading: isLoading })}
|
|
301
|
+
>
|
|
302
|
+
{authoredLabel || (isDesigner ? designerLabel : '')}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const rowStyle: React.CSSProperties = {
|
|
308
|
+
display: 'flex',
|
|
309
|
+
justifyContent: 'space-between',
|
|
310
|
+
gap: '12px',
|
|
311
|
+
padding: '4px 0'
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<div
|
|
316
|
+
data-checkout-summary=""
|
|
317
|
+
{...partAttrs('root')}
|
|
318
|
+
style={{
|
|
319
|
+
display: 'flex',
|
|
320
|
+
flexDirection: 'column',
|
|
321
|
+
gap: 'var(--checkout-summary-gap, 12px)'
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
{showLineItems && (lines.length > 0 || customLines) && (
|
|
325
|
+
<ul
|
|
326
|
+
data-checkout-summary-items=""
|
|
327
|
+
{...partAttrs('list')}
|
|
328
|
+
style={{
|
|
329
|
+
display: 'flex',
|
|
330
|
+
flexDirection: 'column',
|
|
331
|
+
gap: '12px',
|
|
332
|
+
listStyle: 'none',
|
|
333
|
+
margin: 0,
|
|
334
|
+
padding: 0
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
{customLines ??
|
|
338
|
+
lines.map((line) => (
|
|
339
|
+
<li
|
|
340
|
+
key={line.key}
|
|
341
|
+
data-checkout-summary-item=""
|
|
342
|
+
{...partAttrs('item')}
|
|
343
|
+
style={{
|
|
344
|
+
display: 'flex',
|
|
345
|
+
alignItems: 'flex-start',
|
|
346
|
+
gap: '12px'
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{showItemImage && line.image && (
|
|
350
|
+
// Plain img: basket thumbnails come from arbitrary commerce
|
|
351
|
+
// CDNs that the brand next.config may not allow.
|
|
352
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
353
|
+
<img
|
|
354
|
+
src={line.image}
|
|
355
|
+
alt={line.name}
|
|
356
|
+
width={imageWidth}
|
|
357
|
+
height={imageHeight}
|
|
358
|
+
{...partAttrs('image')}
|
|
359
|
+
style={{
|
|
360
|
+
objectFit: 'cover',
|
|
361
|
+
display: 'block',
|
|
362
|
+
flex: '0 0 auto'
|
|
363
|
+
}}
|
|
364
|
+
/>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
<div
|
|
368
|
+
{...partAttrs('content')}
|
|
369
|
+
style={{ flex: '1 1 auto', minWidth: 0 }}
|
|
370
|
+
>
|
|
371
|
+
<span
|
|
372
|
+
data-checkout-summary-item-name=""
|
|
373
|
+
{...partAttrs('name')}
|
|
374
|
+
>
|
|
375
|
+
{line.name}
|
|
376
|
+
</span>
|
|
377
|
+
{showItemQuantity && (
|
|
378
|
+
<div
|
|
379
|
+
data-checkout-summary-item-quantity=""
|
|
380
|
+
{...partAttrs('quantity')}
|
|
381
|
+
style={{ marginTop: '2px', opacity: 0.8 }}
|
|
382
|
+
>
|
|
383
|
+
{quantityPrefix}
|
|
384
|
+
{line.quantity}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<span
|
|
390
|
+
data-checkout-summary-item-total=""
|
|
391
|
+
{...partAttrs('line-total')}
|
|
392
|
+
style={{ flex: '0 0 auto' }}
|
|
393
|
+
>
|
|
394
|
+
{formatMoney(line.total, { locale, currency: line.currency })}
|
|
395
|
+
</span>
|
|
396
|
+
</li>
|
|
397
|
+
))}
|
|
398
|
+
</ul>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
<div data-checkout-summary-totals="" {...partAttrs('totals')}>
|
|
402
|
+
{customTotals ??
|
|
403
|
+
rows.map((row) =>
|
|
404
|
+
row.type === 'total' ? (
|
|
405
|
+
<div
|
|
406
|
+
key={row.key}
|
|
407
|
+
style={{ ...rowStyle, fontWeight: 600 }}
|
|
408
|
+
data-checkout-summary-row="total"
|
|
409
|
+
{...partAttrs('total')}
|
|
410
|
+
>
|
|
411
|
+
<span {...partAttrs('total-label')}>{row.label}</span>
|
|
412
|
+
<span {...partAttrs('total-value')}>{row.value}</span>
|
|
413
|
+
</div>
|
|
414
|
+
) : (
|
|
415
|
+
<div
|
|
416
|
+
key={row.key}
|
|
417
|
+
style={rowStyle}
|
|
418
|
+
data-checkout-summary-row={row.type}
|
|
419
|
+
{...partAttrs('row')}
|
|
420
|
+
>
|
|
421
|
+
<span {...partAttrs('row-label')}>{row.label}</span>
|
|
422
|
+
<span {...partAttrs('row-value')}>{row.value}</span>
|
|
423
|
+
</div>
|
|
424
|
+
)
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
{/* Display only: applying or removing a coupon mid-checkout recalculates
|
|
429
|
+
the pre-order, which is the payment step's job — not this block's. */}
|
|
430
|
+
{showCoupon && totals.voucher && (
|
|
431
|
+
<div
|
|
432
|
+
data-checkout-summary-coupon=""
|
|
433
|
+
{...partAttrs('coupon', { selected: true })}
|
|
434
|
+
style={{ display: 'flex', justifyContent: 'space-between', gap: '12px' }}
|
|
435
|
+
>
|
|
436
|
+
<span {...partAttrs('coupon-label')}>{couponLabel}</span>
|
|
437
|
+
<span
|
|
438
|
+
data-checkout-summary-coupon-code=""
|
|
439
|
+
{...partAttrs('coupon-code')}
|
|
440
|
+
>
|
|
441
|
+
{totals.voucher}
|
|
442
|
+
</span>
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
export default CheckoutSummaryBlock;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
5
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
6
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
7
|
+
|
|
8
|
+
const SECOND = 1000;
|
|
9
|
+
const MINUTE = 60 * SECOND;
|
|
10
|
+
const HOUR = 60 * MINUTE;
|
|
11
|
+
const DAY = 24 * HOUR;
|
|
12
|
+
|
|
13
|
+
const pad = (value: number) => String(value).padStart(2, '0');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Functional content block: a campaign countdown for landing pages.
|
|
17
|
+
*
|
|
18
|
+
* The remaining time is only computed after mount. Rendering it during SSR
|
|
19
|
+
* would bake the server clock into the HTML and hydrate against a different
|
|
20
|
+
* client clock — a guaranteed mismatch on a value that changes every second —
|
|
21
|
+
* so the first paint shows placeholder digits with the final markup shape.
|
|
22
|
+
*
|
|
23
|
+
* Parts: root, segment, value, label, separator, expired.
|
|
24
|
+
*/
|
|
25
|
+
const CountdownBlock = ({
|
|
26
|
+
block,
|
|
27
|
+
currentBreakpoint = 'desktop',
|
|
28
|
+
isDesigner = false
|
|
29
|
+
}: BlockRendererProps) => {
|
|
30
|
+
const [now, setNow] = useState<number | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setNow(Date.now());
|
|
34
|
+
|
|
35
|
+
const timer = setInterval(() => setNow(Date.now()), SECOND);
|
|
36
|
+
return () => clearInterval(timer);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const prop = (key: string, fallback: string) =>
|
|
40
|
+
String(
|
|
41
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const boolProp = (key: string, fallback: boolean) =>
|
|
45
|
+
readToggle(block.properties?.[key], currentBreakpoint, fallback);
|
|
46
|
+
|
|
47
|
+
const targetDate = prop('targetDate', '').trim();
|
|
48
|
+
const expiredLabel = prop('expiredLabel', 'Campaign ended');
|
|
49
|
+
const showLabels = boolProp('showLabels', true);
|
|
50
|
+
const padDigits = boolProp('padDigits', true);
|
|
51
|
+
const separator = prop('separator', '');
|
|
52
|
+
const placeholder = prop('placeholder', '--');
|
|
53
|
+
|
|
54
|
+
const units = [
|
|
55
|
+
{
|
|
56
|
+
key: 'days',
|
|
57
|
+
ms: DAY,
|
|
58
|
+
label: prop('daysLabel', 'Days'),
|
|
59
|
+
shown: boolProp('showDays', true)
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'hours',
|
|
63
|
+
ms: HOUR,
|
|
64
|
+
label: prop('hoursLabel', 'Hours'),
|
|
65
|
+
shown: boolProp('showHours', true)
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'minutes',
|
|
69
|
+
ms: MINUTE,
|
|
70
|
+
label: prop('minutesLabel', 'Minutes'),
|
|
71
|
+
shown: boolProp('showMinutes', true)
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: 'seconds',
|
|
75
|
+
ms: SECOND,
|
|
76
|
+
label: prop('secondsLabel', 'Seconds'),
|
|
77
|
+
shown: boolProp('showSeconds', true)
|
|
78
|
+
}
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const target = targetDate ? Date.parse(targetDate) : Number.NaN;
|
|
82
|
+
|
|
83
|
+
if (!Number.isFinite(target)) {
|
|
84
|
+
return isDesigner ? (
|
|
85
|
+
<DesignerPlaceholder
|
|
86
|
+
label="Countdown (set a target date, e.g. 2026-12-31T23:59:00)"
|
|
87
|
+
attribute="data-countdown"
|
|
88
|
+
/>
|
|
89
|
+
) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const remaining = now === null ? null : Math.max(0, target - now);
|
|
93
|
+
const state =
|
|
94
|
+
remaining === null ? 'pending' : remaining === 0 ? 'expired' : 'running';
|
|
95
|
+
|
|
96
|
+
if (state === 'expired') {
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
data-countdown=""
|
|
100
|
+
data-countdown-state="expired"
|
|
101
|
+
{...partAttrs('expired', { empty: true })}
|
|
102
|
+
>
|
|
103
|
+
{expiredLabel}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A hidden unit rolls into the next visible one, so switching days off shows
|
|
109
|
+
// the total hours rather than silently dropping a day from the total.
|
|
110
|
+
let rest = remaining;
|
|
111
|
+
const segments = units
|
|
112
|
+
.filter((unit) => unit.shown)
|
|
113
|
+
.map((unit) => {
|
|
114
|
+
if (rest === null) return { key: unit.key, label: unit.label, value: null };
|
|
115
|
+
|
|
116
|
+
const value = Math.floor(rest / unit.ms);
|
|
117
|
+
rest = rest % unit.ms;
|
|
118
|
+
|
|
119
|
+
return { key: unit.key, label: unit.label, value };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-countdown=""
|
|
125
|
+
data-countdown-state={state}
|
|
126
|
+
{...partAttrs('root', { loading: remaining === null })}
|
|
127
|
+
style={{
|
|
128
|
+
display: 'flex',
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
gap: 'var(--countdown-gap, 12px)'
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{segments.map((segment, index) => (
|
|
134
|
+
<React.Fragment key={segment.key}>
|
|
135
|
+
<div
|
|
136
|
+
data-countdown-segment={segment.key}
|
|
137
|
+
{...partAttrs('segment')}
|
|
138
|
+
style={{
|
|
139
|
+
display: 'flex',
|
|
140
|
+
flexDirection: 'column',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
lineHeight: 1.2
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<span
|
|
146
|
+
data-countdown-value=""
|
|
147
|
+
{...partAttrs('value')}
|
|
148
|
+
style={{ fontVariantNumeric: 'tabular-nums' }}
|
|
149
|
+
>
|
|
150
|
+
{segment.value === null
|
|
151
|
+
? placeholder
|
|
152
|
+
: padDigits
|
|
153
|
+
? pad(segment.value)
|
|
154
|
+
: String(segment.value)}
|
|
155
|
+
</span>
|
|
156
|
+
{showLabels && (
|
|
157
|
+
<span
|
|
158
|
+
data-countdown-label=""
|
|
159
|
+
{...partAttrs('label')}
|
|
160
|
+
style={{ fontSize: '0.75em', opacity: 0.7 }}
|
|
161
|
+
>
|
|
162
|
+
{segment.label}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{separator && index < segments.length - 1 && (
|
|
168
|
+
<span
|
|
169
|
+
data-countdown-separator=""
|
|
170
|
+
aria-hidden="true"
|
|
171
|
+
{...partAttrs('separator')}
|
|
172
|
+
>
|
|
173
|
+
{separator}
|
|
174
|
+
</span>
|
|
175
|
+
)}
|
|
176
|
+
</React.Fragment>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export default CountdownBlock;
|