@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,292 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useSession } from 'next-auth/react';
|
|
5
|
+
import {
|
|
6
|
+
useGetBasketOffersQuery,
|
|
7
|
+
useGetExpiredBasketOffersQuery
|
|
8
|
+
} from '@akinon/next/data/client/account';
|
|
9
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
10
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
11
|
+
import { formatDate, formatMoney } from './commerce-format';
|
|
12
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
13
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
|
+
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
15
|
+
|
|
16
|
+
interface CouponLike {
|
|
17
|
+
pk?: number;
|
|
18
|
+
label?: string;
|
|
19
|
+
voucher_code?: string;
|
|
20
|
+
amount?: string;
|
|
21
|
+
percentage?: string | number;
|
|
22
|
+
start_datetime?: string;
|
|
23
|
+
end_datetime?: string;
|
|
24
|
+
created_date?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The active-coupon endpoint answers with `{ discounts: [...] }` while the
|
|
29
|
+
* expired one answers with the paginated `{ results: [...] }` shape. Both are
|
|
30
|
+
* probed (plus a bare array) so one reader covers every response.
|
|
31
|
+
*/
|
|
32
|
+
const readCoupons = (payload: unknown): CouponLike[] => {
|
|
33
|
+
if (Array.isArray(payload)) return payload as CouponLike[];
|
|
34
|
+
|
|
35
|
+
const record = (payload ?? {}) as Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
if (Array.isArray(record.discounts)) return record.discounts as CouponLike[];
|
|
38
|
+
if (Array.isArray(record.results)) return record.results as CouponLike[];
|
|
39
|
+
|
|
40
|
+
return [];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Functional account block: the shopper's coupons/vouchers. Coupon CONTENT
|
|
45
|
+
* stays commerce-driven (account offers service); the editor owns whether the
|
|
46
|
+
* expired ones are listed too, which fields the card carries, the card columns
|
|
47
|
+
* and the styling.
|
|
48
|
+
* Properties: showExpired, showLabel, showCode, showValue, showValidity, limit,
|
|
49
|
+
* columns, percentagePrefix, validitySeparator, emptyLabel.
|
|
50
|
+
* Parts: list, item (disabled on an expired coupon), label, code, value,
|
|
51
|
+
* validity, empty.
|
|
52
|
+
*/
|
|
53
|
+
const CouponListBlock = ({
|
|
54
|
+
block,
|
|
55
|
+
placeholderId,
|
|
56
|
+
sectionId,
|
|
57
|
+
selectedBlockId = null,
|
|
58
|
+
currentBreakpoint = 'desktop',
|
|
59
|
+
isDesigner = false
|
|
60
|
+
}: BlockRendererProps) => {
|
|
61
|
+
const showExpired = readToggle(
|
|
62
|
+
block.properties?.showExpired,
|
|
63
|
+
currentBreakpoint,
|
|
64
|
+
false
|
|
65
|
+
);
|
|
66
|
+
const showLabel = readToggle(
|
|
67
|
+
block.properties?.showLabel,
|
|
68
|
+
currentBreakpoint,
|
|
69
|
+
true
|
|
70
|
+
);
|
|
71
|
+
const showCode = readToggle(
|
|
72
|
+
block.properties?.showCode,
|
|
73
|
+
currentBreakpoint,
|
|
74
|
+
true
|
|
75
|
+
);
|
|
76
|
+
const showValue = readToggle(
|
|
77
|
+
block.properties?.showValue,
|
|
78
|
+
currentBreakpoint,
|
|
79
|
+
true
|
|
80
|
+
);
|
|
81
|
+
const showValidity = readToggle(
|
|
82
|
+
block.properties?.showValidity,
|
|
83
|
+
currentBreakpoint,
|
|
84
|
+
true
|
|
85
|
+
);
|
|
86
|
+
const text = (key: string, fallback: string) =>
|
|
87
|
+
String(
|
|
88
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
89
|
+
);
|
|
90
|
+
const percentagePrefix = text('percentagePrefix', '%');
|
|
91
|
+
const validitySeparator = text('validitySeparator', ' – ');
|
|
92
|
+
const emptyLabel = text('emptyLabel', '');
|
|
93
|
+
const rawLimit = Math.round(
|
|
94
|
+
Number(getResponsiveValue(block.properties?.limit, currentBreakpoint, 0))
|
|
95
|
+
);
|
|
96
|
+
// 0 keeps the whole wallet on screen, which is what the block has always done.
|
|
97
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
|
|
98
|
+
const rawColumns = Math.round(
|
|
99
|
+
Number(getResponsiveValue(block.properties?.columns, currentBreakpoint, 1))
|
|
100
|
+
);
|
|
101
|
+
const columns =
|
|
102
|
+
Number.isFinite(rawColumns) && rawColumns > 0 ? Math.min(rawColumns, 4) : 1;
|
|
103
|
+
|
|
104
|
+
const { locale, currency } = useLocalization();
|
|
105
|
+
const { status } = useSession();
|
|
106
|
+
const isAuthenticated = status === 'authenticated';
|
|
107
|
+
|
|
108
|
+
// Guests have no coupon wallet; skipping avoids a guaranteed 403 on every
|
|
109
|
+
// page that hosts this block.
|
|
110
|
+
const { data: activeData, isLoading } = useGetBasketOffersQuery(undefined, {
|
|
111
|
+
skip: !isAuthenticated
|
|
112
|
+
});
|
|
113
|
+
// Called unconditionally (hook order is fixed) but skipped when the toggle is
|
|
114
|
+
// off, so the extra request is only paid for when the list is actually shown.
|
|
115
|
+
const { data: expiredData } = useGetExpiredBasketOffersQuery(undefined, {
|
|
116
|
+
skip: !isAuthenticated || !showExpired
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const allCoupons: Array<CouponLike & { expired: boolean }> = [
|
|
120
|
+
...readCoupons(activeData).map((coupon) => ({ ...coupon, expired: false })),
|
|
121
|
+
...(showExpired
|
|
122
|
+
? readCoupons(expiredData).map((coupon) => ({ ...coupon, expired: true }))
|
|
123
|
+
: [])
|
|
124
|
+
];
|
|
125
|
+
const coupons = limit > 0 ? allCoupons.slice(0, limit) : allCoupons;
|
|
126
|
+
|
|
127
|
+
const describe = (coupon: CouponLike) => {
|
|
128
|
+
const validUntil = formatDate(coupon.end_datetime, locale);
|
|
129
|
+
const validFrom = formatDate(
|
|
130
|
+
coupon.start_datetime || coupon.created_date,
|
|
131
|
+
locale
|
|
132
|
+
);
|
|
133
|
+
const amount = formatMoney(coupon.amount, { locale, currency });
|
|
134
|
+
const percentage =
|
|
135
|
+
coupon.percentage !== undefined &&
|
|
136
|
+
coupon.percentage !== null &&
|
|
137
|
+
String(coupon.percentage).trim()
|
|
138
|
+
? `${percentagePrefix}${coupon.percentage}`
|
|
139
|
+
: '';
|
|
140
|
+
|
|
141
|
+
return { validFrom, validUntil, amount, percentage };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const rows = coupons.map((coupon, index) => {
|
|
145
|
+
const { validFrom, validUntil, amount, percentage } = describe(coupon);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
key: String(coupon.pk ?? coupon.voucher_code ?? index),
|
|
149
|
+
pk: coupon.pk ?? '',
|
|
150
|
+
name: coupon.label || '',
|
|
151
|
+
label: coupon.label || '',
|
|
152
|
+
code: coupon.voucher_code || '',
|
|
153
|
+
value: percentage || amount,
|
|
154
|
+
amount,
|
|
155
|
+
percentage,
|
|
156
|
+
validFrom,
|
|
157
|
+
validUntil,
|
|
158
|
+
// `expiry` is the name the binding picker offers for the end date.
|
|
159
|
+
expiry: validUntil,
|
|
160
|
+
validity: [validFrom, validUntil].filter(Boolean).join(validitySeparator),
|
|
161
|
+
expired: coupon.expired
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// A designer who marked a child as the card layout gets that layout repeated
|
|
166
|
+
// per coupon; otherwise the built-in card renders unchanged.
|
|
167
|
+
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
168
|
+
<ItemTemplateList
|
|
169
|
+
block={block}
|
|
170
|
+
items={rows}
|
|
171
|
+
placeholderId={placeholderId}
|
|
172
|
+
sectionId={sectionId}
|
|
173
|
+
isDesigner={isDesigner}
|
|
174
|
+
selectedBlockId={selectedBlockId}
|
|
175
|
+
currentBreakpoint={currentBreakpoint}
|
|
176
|
+
/>
|
|
177
|
+
) : null;
|
|
178
|
+
|
|
179
|
+
if (coupons.length === 0 && !customCards) {
|
|
180
|
+
if (isDesigner) {
|
|
181
|
+
return (
|
|
182
|
+
<DesignerPlaceholder
|
|
183
|
+
label={isLoading ? 'Coupons (loading)' : 'Coupons (none)'}
|
|
184
|
+
attribute="data-coupon-list"
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Without an empty message the block stays absent, which is what every
|
|
190
|
+
// published composition renders today.
|
|
191
|
+
if (!isAuthenticated || isLoading || !emptyLabel) return null;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
data-coupon-list=""
|
|
196
|
+
data-coupon-list-state="empty"
|
|
197
|
+
{...partAttrs('empty', { empty: true })}
|
|
198
|
+
>
|
|
199
|
+
{emptyLabel}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// One column keeps the flex column the block has always rendered; a grid is
|
|
205
|
+
// only introduced once the author actually asks for more than one.
|
|
206
|
+
const listLayout: React.CSSProperties =
|
|
207
|
+
columns > 1
|
|
208
|
+
? {
|
|
209
|
+
display: 'grid',
|
|
210
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`
|
|
211
|
+
}
|
|
212
|
+
: { display: 'flex', flexDirection: 'column' };
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<ul
|
|
216
|
+
data-coupon-list=""
|
|
217
|
+
data-coupon-list-columns={columns}
|
|
218
|
+
{...partAttrs('list')}
|
|
219
|
+
style={{
|
|
220
|
+
...listLayout,
|
|
221
|
+
gap: 'var(--coupon-list-gap, 16px)',
|
|
222
|
+
listStyle: 'none',
|
|
223
|
+
margin: 0,
|
|
224
|
+
padding: 0
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{customCards ??
|
|
228
|
+
coupons.map((coupon, index) => {
|
|
229
|
+
const { validFrom, validUntil, amount, percentage } =
|
|
230
|
+
describe(coupon);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<li
|
|
234
|
+
key={coupon.pk ?? coupon.voucher_code ?? index}
|
|
235
|
+
data-coupon-item=""
|
|
236
|
+
data-coupon-item-expired={coupon.expired ? 'true' : 'false'}
|
|
237
|
+
{...partAttrs('item', { disabled: coupon.expired })}
|
|
238
|
+
style={{
|
|
239
|
+
display: 'flex',
|
|
240
|
+
flexDirection: 'column',
|
|
241
|
+
gap: '6px',
|
|
242
|
+
padding: '16px',
|
|
243
|
+
border: '1px dashed var(--coupon-list-border, #e5e7eb)',
|
|
244
|
+
// Expired coupons stay listed but read as inactive.
|
|
245
|
+
opacity: coupon.expired ? 0.6 : 1
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
{showLabel && coupon.label && (
|
|
249
|
+
<span
|
|
250
|
+
data-coupon-label=""
|
|
251
|
+
{...partAttrs('label')}
|
|
252
|
+
style={{ fontWeight: 600 }}
|
|
253
|
+
>
|
|
254
|
+
{coupon.label}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{showCode && coupon.voucher_code && (
|
|
259
|
+
<span
|
|
260
|
+
data-coupon-code=""
|
|
261
|
+
{...partAttrs('code')}
|
|
262
|
+
style={{ letterSpacing: '0.08em' }}
|
|
263
|
+
>
|
|
264
|
+
{coupon.voucher_code}
|
|
265
|
+
</span>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{showValue && (percentage || amount) && (
|
|
269
|
+
<span data-coupon-value="" {...partAttrs('value')}>
|
|
270
|
+
{percentage || amount}
|
|
271
|
+
</span>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{showValidity && (validFrom || validUntil) && (
|
|
275
|
+
<span
|
|
276
|
+
data-coupon-validity=""
|
|
277
|
+
{...partAttrs('validity')}
|
|
278
|
+
style={{ opacity: 0.8 }}
|
|
279
|
+
>
|
|
280
|
+
{[validFrom, validUntil]
|
|
281
|
+
.filter(Boolean)
|
|
282
|
+
.join(validitySeparator)}
|
|
283
|
+
</span>
|
|
284
|
+
)}
|
|
285
|
+
</li>
|
|
286
|
+
);
|
|
287
|
+
})}
|
|
288
|
+
</ul>
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export default CouponListBlock;
|