@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. 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;