@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -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,373 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import {
8
+ MoneyCurrencyDisplay,
9
+ MoneyCurrencyPosition,
10
+ MoneyDecimals,
11
+ MoneyStyleOptions
12
+ } from './commerce-format';
13
+ import {
14
+ DesignerPlaceholder,
15
+ formatPrice,
16
+ getPreviousPrice,
17
+ getVariantPriceRange,
18
+ parsePriceValue,
19
+ pickProductField,
20
+ readToggle,
21
+ useThemeProduct
22
+ } from './product-block-utils';
23
+
24
+ const ALLOWED_ORDERS = ['retail-first', 'price-first'];
25
+ const ALLOWED_DISCOUNT_MODES = ['percent', 'amount', 'both'];
26
+ const ALLOWED_ALIGNMENTS = ['flex-start', 'center', 'flex-end'];
27
+ const ALLOWED_DECIMALS = ['always', 'auto', 'never'];
28
+ const ALLOWED_CURRENCY_DISPLAYS = ['', 'symbol', 'code', 'custom', 'none'];
29
+ const ALLOWED_CURRENCY_POSITIONS = ['', 'before', 'after'];
30
+
31
+ /**
32
+ * Functional PDP block: current price with the retail price struck through
33
+ * when it is actually higher, and an optional saving badge. Values stay
34
+ * commerce-driven; the editor owns placement, order, currency visibility,
35
+ * the money format, the badge wording and — through named parts — the styling.
36
+ *
37
+ * Three additions are switched off by default, so a published composition that
38
+ * never touched them renders exactly the markup it always did: the price
39
+ * history caption (showPreviousPrice), the variant price range
40
+ * (showPriceRange) and the percentage-plus-amount badge (discountMode: both).
41
+ *
42
+ * Parts: row, price, price-min, range-separator, price-max, retail-price,
43
+ * discount, discount-percent, discount-amount, history, history-caption,
44
+ * previous-price.
45
+ * Properties: showRetail, showCurrency, decimals, currencyDisplay,
46
+ * currencyText, currencyPosition, gap, retailColor, order, align,
47
+ * showDiscountBadge, discountMode, discountTemplate, discountBothTemplate,
48
+ * showPreviousPrice, previousPriceTemplate, showPriceRange,
49
+ * priceRangeSeparator.
50
+ */
51
+ const ProductPriceBlock = ({
52
+ block,
53
+ currentBreakpoint = 'desktop',
54
+ isDesigner = false
55
+ }: BlockRendererProps) => {
56
+ const product = useThemeProduct();
57
+ const { locale, currency: activeCurrency } = useLocalization();
58
+
59
+ const prop = (key: string, fallback: string) =>
60
+ String(
61
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
62
+ );
63
+
64
+ const showRetail = readToggle(
65
+ block.properties?.showRetail,
66
+ currentBreakpoint,
67
+ true
68
+ );
69
+ const showCurrency = readToggle(
70
+ block.properties?.showCurrency,
71
+ currentBreakpoint,
72
+ true
73
+ );
74
+ // Designs are written in the brand's own money format ("1.599 TL"), which no
75
+ // locale produces. Every value below is validated against the list the editor
76
+ // offers, so a hand-edited composition cannot reach Intl with a bad option.
77
+ const decimalsValue = prop('decimals', 'always');
78
+ const currencyDisplayValue = prop('currencyDisplay', '');
79
+ const currencyPositionValue = prop('currencyPosition', '');
80
+ const money: MoneyStyleOptions = {
81
+ decimals: (ALLOWED_DECIMALS.includes(decimalsValue)
82
+ ? decimalsValue
83
+ : 'always') as MoneyDecimals,
84
+ currencyDisplay: (ALLOWED_CURRENCY_DISPLAYS.includes(currencyDisplayValue)
85
+ ? currencyDisplayValue
86
+ : '') as MoneyCurrencyDisplay,
87
+ currencyText: prop('currencyText', ''),
88
+ currencyPosition: (ALLOWED_CURRENCY_POSITIONS.includes(
89
+ currencyPositionValue
90
+ )
91
+ ? currencyPositionValue
92
+ : '') as MoneyCurrencyPosition
93
+ };
94
+ const gap = Number(
95
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 8)
96
+ );
97
+ const retailColor = String(
98
+ getResponsiveValue(block.properties?.retailColor, currentBreakpoint, '')
99
+ );
100
+ const orderValue = prop('order', 'retail-first');
101
+ const order = ALLOWED_ORDERS.includes(orderValue)
102
+ ? orderValue
103
+ : 'retail-first';
104
+ const alignValue = prop('align', 'flex-start');
105
+ const align = ALLOWED_ALIGNMENTS.includes(alignValue)
106
+ ? alignValue
107
+ : 'flex-start';
108
+ const showDiscountBadge = readToggle(
109
+ block.properties?.showDiscountBadge,
110
+ currentBreakpoint,
111
+ false
112
+ );
113
+ const discountModeValue = prop('discountMode', 'percent');
114
+ const discountMode = ALLOWED_DISCOUNT_MODES.includes(discountModeValue)
115
+ ? discountModeValue
116
+ : 'percent';
117
+ const discountTemplate = prop('discountTemplate', '-{value}');
118
+ const discountBothTemplate = prop(
119
+ 'discountBothTemplate',
120
+ '-{percent} ({amount})'
121
+ );
122
+ const showPreviousPrice = readToggle(
123
+ block.properties?.showPreviousPrice,
124
+ currentBreakpoint,
125
+ false
126
+ );
127
+ const previousPriceTemplate = prop(
128
+ 'previousPriceTemplate',
129
+ 'Lowest price in the last 30 days: {value}'
130
+ );
131
+ const showPriceRange = readToggle(
132
+ block.properties?.showPriceRange,
133
+ currentBreakpoint,
134
+ false
135
+ );
136
+ const priceRangeSeparator = prop('priceRangeSeparator', ' – ');
137
+
138
+ const priceValue = pickProductField(product, 'price');
139
+ const retailValue =
140
+ pickProductField(product, 'retailPrice') ??
141
+ pickProductField(product, 'retail_price');
142
+ // Commerce sends the currency on the product; the active currency is only
143
+ // the fallback, and the grid block resolves it the same way so both render
144
+ // the same money string for the same value.
145
+ const currency =
146
+ pickProductField<string>(product, 'currency') ??
147
+ pickProductField<string>(product, 'currency_type') ??
148
+ activeCurrency ??
149
+ '';
150
+
151
+ const priceText = formatPrice(
152
+ priceValue,
153
+ currency,
154
+ showCurrency,
155
+ locale,
156
+ money
157
+ );
158
+
159
+ if (!priceText) {
160
+ return isDesigner ? (
161
+ <DesignerPlaceholder
162
+ label="Product price (no price)"
163
+ attribute="data-product-price"
164
+ />
165
+ ) : null;
166
+ }
167
+
168
+ const retailText = formatPrice(
169
+ retailValue,
170
+ currency,
171
+ showCurrency,
172
+ locale,
173
+ money
174
+ );
175
+ const priceNumber = parsePriceValue(priceValue);
176
+ const retailNumber = parsePriceValue(retailValue);
177
+ // A retail price equal to (or below) the active price is not a discount and
178
+ // must not be struck through — commerce sends both fields unconditionally.
179
+ const hasDiscount =
180
+ showRetail &&
181
+ Boolean(retailText) &&
182
+ priceNumber !== null &&
183
+ retailNumber !== null &&
184
+ retailNumber > priceNumber;
185
+
186
+ const safeGap = Number.isFinite(gap) && gap >= 0 ? gap : 8;
187
+
188
+ const saving =
189
+ showDiscountBadge && hasDiscount && priceNumber !== null && retailNumber !== null
190
+ ? {
191
+ amount: retailNumber - priceNumber,
192
+ percent: Math.round(
193
+ ((retailNumber - priceNumber) / retailNumber) * 100
194
+ )
195
+ }
196
+ : null;
197
+ // The saving is money, so it goes through the shared formatter with the
198
+ // explicit locale rather than being pasted together from raw numbers.
199
+ const savedAmountText = saving
200
+ ? formatPrice(saving.amount, currency, showCurrency, locale, money)
201
+ : null;
202
+ const savedPercentText = saving ? `${saving.percent}%` : null;
203
+ const discountText =
204
+ discountMode === 'amount' ? savedAmountText : savedPercentText;
205
+
206
+ const priceRange = showPriceRange ? getVariantPriceRange(product) : null;
207
+ const rangeMinText = priceRange
208
+ ? formatPrice(priceRange.min, currency, showCurrency, locale, money)
209
+ : null;
210
+ const rangeMaxText = priceRange
211
+ ? formatPrice(priceRange.max, currency, showCurrency, locale, money)
212
+ : null;
213
+ const hasRange = Boolean(rangeMinText && rangeMaxText);
214
+
215
+ const previousPriceText = showPreviousPrice
216
+ ? formatPrice(
217
+ getPreviousPrice(product),
218
+ currency,
219
+ showCurrency,
220
+ locale,
221
+ money
222
+ )
223
+ : null;
224
+
225
+ /**
226
+ * Prints a template with its placeholders swapped for their own parts, so a
227
+ * brand can colour the saved amount without touching the words around it.
228
+ * The delimiters are kept by the capture group; a template that names no
229
+ * placeholder simply renders as text. `textPart` names the literal words
230
+ * when they are stylable in their own right, as the legal caption is.
231
+ */
232
+ const renderTemplate = (
233
+ template: string,
234
+ tokens: Record<string, { text: string; part: string }>,
235
+ textPart?: string
236
+ ) =>
237
+ template.split(/(\{[a-z]+\})/gi).map((segment, index) => {
238
+ const token = tokens[segment];
239
+
240
+ if (token) {
241
+ return (
242
+ <span key={index} {...partAttrs(token.part)}>
243
+ {token.text}
244
+ </span>
245
+ );
246
+ }
247
+
248
+ if (!segment) return null;
249
+
250
+ return textPart ? (
251
+ <span key={index} {...partAttrs(textPart)}>
252
+ {segment}
253
+ </span>
254
+ ) : (
255
+ <React.Fragment key={index}>{segment}</React.Fragment>
256
+ );
257
+ });
258
+
259
+ // 'both' is the only mode that needs two values in one badge; 'percent' and
260
+ // 'amount' keep printing a single string child, which is the markup every
261
+ // published badge has today.
262
+ const badgeContent =
263
+ discountMode === 'both'
264
+ ? savedAmountText && savedPercentText
265
+ ? renderTemplate(discountBothTemplate, {
266
+ '{percent}': { text: savedPercentText, part: 'discount-percent' },
267
+ '{amount}': { text: savedAmountText, part: 'discount-amount' }
268
+ })
269
+ : null
270
+ : discountText
271
+ ? discountTemplate.replace(/\{value\}/g, discountText)
272
+ : null;
273
+
274
+ // A cleared template would otherwise drop the price out of a line whose
275
+ // whole purpose is to state it, so the placeholder is appended instead.
276
+ const historyTemplate = previousPriceTemplate.includes('{value}')
277
+ ? previousPriceTemplate
278
+ : `${previousPriceTemplate} {value}`.trim();
279
+
280
+ /**
281
+ * The caption joins the existing wrapping row as a full-width child rather
282
+ * than arriving in a new wrapper: the row already wraps, so a 100% basis
283
+ * puts the caption on its own line under the price — and a composition with
284
+ * the caption switched off keeps byte-for-byte the DOM it renders today.
285
+ */
286
+ const historyNode = previousPriceText ? (
287
+ <span
288
+ data-product-previous-price=""
289
+ {...partAttrs('history')}
290
+ style={{
291
+ flexBasis: '100%',
292
+ fontSize: '0.75em',
293
+ color: 'var(--product-previous-price-color, #6b7280)'
294
+ }}
295
+ >
296
+ {renderTemplate(
297
+ historyTemplate,
298
+ {
299
+ '{value}': { text: previousPriceText, part: 'previous-price' }
300
+ },
301
+ 'history-caption'
302
+ )}
303
+ </span>
304
+ ) : showPreviousPrice && isDesigner ? (
305
+ // Designer-only: the toggle is on but commerce sent no price history, and
306
+ // an author needs to see that the caption is missing for a reason.
307
+ <DesignerPlaceholder
308
+ label="Lowest price (no price history)"
309
+ attribute="data-product-previous-price"
310
+ />
311
+ ) : null;
312
+
313
+ const retailNode = hasDiscount ? (
314
+ <span
315
+ data-product-retail-price=""
316
+ {...partAttrs('retail-price')}
317
+ style={{
318
+ textDecoration: 'line-through',
319
+ fontSize: '0.85em',
320
+ color: retailColor || 'var(--product-retail-price-color, #9ca3af)'
321
+ }}
322
+ >
323
+ {retailText}
324
+ </span>
325
+ ) : null;
326
+
327
+ return (
328
+ <div
329
+ data-product-price=""
330
+ data-product-price-discounted={hasDiscount ? 'true' : 'false'}
331
+ {...partAttrs('row')}
332
+ style={{
333
+ display: 'flex',
334
+ alignItems: 'baseline',
335
+ justifyContent: align,
336
+ flexWrap: 'wrap',
337
+ gap: `${safeGap}px`
338
+ }}
339
+ >
340
+ {order === 'retail-first' && retailNode}
341
+ <span data-product-current-price="" {...partAttrs('price')}>
342
+ {hasRange ? (
343
+ <>
344
+ <span {...partAttrs('price-min')}>{rangeMinText}</span>
345
+ <span {...partAttrs('range-separator')}>{priceRangeSeparator}</span>
346
+ <span {...partAttrs('price-max')}>{rangeMaxText}</span>
347
+ </>
348
+ ) : (
349
+ priceText
350
+ )}
351
+ </span>
352
+ {order === 'price-first' && retailNode}
353
+ {badgeContent && (
354
+ <span
355
+ data-product-discount-badge=""
356
+ {...partAttrs('discount')}
357
+ style={{
358
+ fontSize: '0.75em',
359
+ padding: '2px 6px',
360
+ borderRadius: '2px',
361
+ background: 'var(--product-discount-badge-bg, #dc2626)',
362
+ color: 'var(--product-discount-badge-color, #ffffff)'
363
+ }}
364
+ >
365
+ {badgeContent}
366
+ </span>
367
+ )}
368
+ {historyNode}
369
+ </div>
370
+ );
371
+ };
372
+
373
+ export default ProductPriceBlock;