@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,639 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue } from '../utils';
5
+ import { useThemePageContext } from '../theme-page-context';
6
+ import {
7
+ formatMoney,
8
+ MoneyStyleOptions,
9
+ parseAmount
10
+ } from './commerce-format';
11
+
12
+ export interface ThemeProductImage {
13
+ pk?: number | string;
14
+ url?: string;
15
+ image?: string;
16
+ alt?: string;
17
+ order?: number;
18
+ }
19
+
20
+ /**
21
+ * Commerce sends a whole `Product` under every variant option, not the four
22
+ * fields the theme used to declare. The extra ones are what a swatch needs:
23
+ * the option's own image, its numeric `stock` for a low-stock marker, and the
24
+ * attribute record a colour code lives in.
25
+ */
26
+ export interface ThemeVariantOptionProduct {
27
+ pk?: number;
28
+ name?: string;
29
+ image?: string;
30
+ absolute_url?: string;
31
+ in_stock?: boolean;
32
+ stock?: number;
33
+ sku?: string;
34
+ price?: string | number;
35
+ retail_price?: string | number;
36
+ currency_type?: string;
37
+ productimage_set?: ThemeProductImage[];
38
+ attributes?: Record<string, unknown>;
39
+ attributes_kwargs?: Record<string, unknown>;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ export interface ThemeVariantOption {
44
+ label?: string;
45
+ value?: string;
46
+ order?: string;
47
+ in_stock?: boolean;
48
+ is_selected?: boolean;
49
+ is_selectable?: boolean;
50
+ is_selectable_without_stock?: boolean;
51
+ /** Some payloads put the swatch image and stock on the option itself. */
52
+ image?: string;
53
+ stock?: number;
54
+ attributes?: Record<string, unknown>;
55
+ attributes_kwargs?: Record<string, unknown>;
56
+ product?: ThemeVariantOptionProduct;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ export interface ThemeVariantGroup {
61
+ attribute_key?: string;
62
+ attribute_name?: string;
63
+ options?: ThemeVariantOption[];
64
+ }
65
+
66
+ export interface ThemeProduct {
67
+ pk?: number;
68
+ title?: string;
69
+ name?: string;
70
+ url?: string;
71
+ image?: string;
72
+ images?: ThemeProductImage[];
73
+ price?: string | number;
74
+ retailPrice?: string | number;
75
+ currency?: string;
76
+ description?: string;
77
+ /** `key` is the commerce attribute key, kept for includeKeys/excludeKeys. */
78
+ attributes?: Array<{ key?: string; label?: string; value?: string }>;
79
+ selectedVariant?: ThemeProduct | null;
80
+ variants?: ThemeVariantGroup[];
81
+ in_stock?: boolean;
82
+ stock?: number;
83
+ [key: string]: unknown;
84
+ }
85
+
86
+ /**
87
+ * The product a repeated row is currently rendering.
88
+ *
89
+ * Commerce blocks read "the product" off the page context, which on a PDP is
90
+ * the viewed product and on a listing is nothing at all. A favourite button
91
+ * dropped into a product card therefore acted on nothing — and the same card
92
+ * inside a recommendations row on a PDP acted on the *page's* product, so
93
+ * every heart in the row toggled the same wrong item.
94
+ *
95
+ * The context value is an object wrapping the product rather than the product
96
+ * itself, so "inside a row that has no product" (the empty rows the designer
97
+ * draws against) stays distinguishable from "not inside a row at all". Only
98
+ * the second may fall through to the page's product.
99
+ */
100
+ interface ThemeItemScopeValue {
101
+ product: ThemeProduct | null;
102
+ }
103
+
104
+ const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
105
+ null
106
+ );
107
+
108
+ export const ThemeItemScopeProvider = ({
109
+ product,
110
+ children
111
+ }: {
112
+ product: ThemeProduct | null;
113
+ children: React.ReactNode;
114
+ }) => {
115
+ const value = React.useMemo(() => ({ product }), [product]);
116
+
117
+ return (
118
+ <ThemeItemScopeContext.Provider value={value}>
119
+ {children}
120
+ </ThemeItemScopeContext.Provider>
121
+ );
122
+ };
123
+
124
+ /** Non-null while a repeated product row renders; null everywhere else. */
125
+ export const useThemeItemScope = (): ThemeItemScopeValue | null =>
126
+ React.useContext(ThemeItemScopeContext);
127
+
128
+ /**
129
+ * The ambient product: the row's item while a repeated product row renders,
130
+ * otherwise the product `buildProductPageContext()` publishes on the page
131
+ * context. Returning null instead of throwing keeps every block droppable on
132
+ * non-product pages without breaking the render.
133
+ */
134
+ export const useThemeProduct = (): ThemeProduct | null => {
135
+ const itemScope = React.useContext(ThemeItemScopeContext);
136
+ const pageContext = useThemePageContext();
137
+
138
+ // Outside a row — which is every composition that exists today — the page
139
+ // context is read exactly as it was before the scope existed. A scope that
140
+ // resolved no product falls through for the same reason: shadowing the page
141
+ // product with nothing would silence blocks that render correctly today, on
142
+ // a PDP whose recommendations row has not loaded yet or in the designer,
143
+ // where placeholder rows carry no data at all.
144
+ if (itemScope?.product) return itemScope.product;
145
+
146
+ const product = (pageContext as Record<string, unknown> | null)?.product;
147
+
148
+ return product && typeof product === 'object'
149
+ ? (product as ThemeProduct)
150
+ : null;
151
+ };
152
+
153
+ const isEmptyValue = (value: unknown) =>
154
+ value === undefined || value === null || value === '';
155
+
156
+ /**
157
+ * The same field under the two names that reach these blocks. A page-context
158
+ * product carries the raw commerce names (`in_stock`, `retail_price`,
159
+ * `absolute_url`); a normalised listing row carries the camel-case ones
160
+ * (`inStock`, `retailPrice`, `url`). Resolving the pair here is what lets a
161
+ * block ask for one name and work on both, instead of every block growing its
162
+ * own second lookup.
163
+ */
164
+ const PRODUCT_FIELD_ALIASES: Record<string, string[]> = {
165
+ title: ['name'],
166
+ name: ['title'],
167
+ url: ['absolute_url'],
168
+ absolute_url: ['url'],
169
+ in_stock: ['inStock'],
170
+ inStock: ['in_stock'],
171
+ retail_price: ['retailPrice'],
172
+ retailPrice: ['retail_price'],
173
+ currency: ['currency_type'],
174
+ currency_type: ['currency']
175
+ };
176
+
177
+ /**
178
+ * The viewed product already matches the PDP URL, so it wins. `selectedVariant`
179
+ * is only consulted for fields the base payload leaves empty — group products
180
+ * carry price/stock on the variant rather than on the parent.
181
+ *
182
+ * Aliases are consulted LAST, only where the requested name resolved to
183
+ * nothing on both. A commerce payload always carries the canonical name, so
184
+ * this cannot move any value a PDP block reads today.
185
+ */
186
+ export const pickProductField = <T,>(
187
+ product: ThemeProduct | null,
188
+ key: string
189
+ ): T | undefined => {
190
+ if (!product) return undefined;
191
+
192
+ const base = product[key];
193
+ if (!isEmptyValue(base)) return base as T;
194
+
195
+ const variant = product.selectedVariant;
196
+ if (variant && !isEmptyValue(variant[key])) return variant[key] as T;
197
+
198
+ for (const alias of PRODUCT_FIELD_ALIASES[key] || []) {
199
+ if (!isEmptyValue(product[alias])) return product[alias] as T;
200
+ if (variant && !isEmptyValue(variant[alias])) return variant[alias] as T;
201
+ }
202
+
203
+ return undefined;
204
+ };
205
+
206
+ /**
207
+ * Editor toggles arrive as booleans, as the strings a select would store, or
208
+ * as a per-breakpoint object. Resolving the breakpoint here is what lets every
209
+ * commerce block — PDP and listing alike — share a single toggle reader
210
+ * instead of two exports with the same name and different signatures.
211
+ */
212
+ export const readToggle = (
213
+ value: unknown,
214
+ currentBreakpoint: string,
215
+ fallback: boolean
216
+ ): boolean => {
217
+ const raw = getResponsiveValue(value, currentBreakpoint, fallback);
218
+ if (isEmptyValue(raw)) return fallback;
219
+ if (typeof raw === 'boolean') return raw;
220
+ if (typeof raw === 'number') return raw !== 0;
221
+
222
+ const normalized = String(raw).trim().toLowerCase();
223
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
224
+ return true;
225
+ }
226
+ if (normalized === 'false' || normalized === '0' || normalized === 'no') {
227
+ return false;
228
+ }
229
+
230
+ return fallback;
231
+ };
232
+
233
+ /** Kept for existing callers; the parsing itself lives in commerce-format. */
234
+ export const parsePriceValue = (value: unknown): number | null =>
235
+ parseAmount(value);
236
+
237
+ export interface OrderLineLike {
238
+ id?: number;
239
+ price?: string | number;
240
+ quantity?: string | number;
241
+ total_amount?: string | number;
242
+ product?: {
243
+ pk?: number;
244
+ name?: string;
245
+ image?: string;
246
+ absolute_url?: string;
247
+ } | null;
248
+ }
249
+
250
+ export interface GroupedOrderLine<T extends OrderLineLike> {
251
+ /** Stable React key: the product pk when the payload carries one. */
252
+ key: string;
253
+ name: string;
254
+ image: string;
255
+ href: string;
256
+ /** Unit price of the first line in the group, as it came from commerce. */
257
+ price: string;
258
+ /** Sum of the group's line totals; null when nothing in it parses. */
259
+ total: number | null;
260
+ quantity: number;
261
+ /** First line of the group — status, currency and shipment fields live on it. */
262
+ item: T;
263
+ }
264
+
265
+ /**
266
+ * pz writes ONE order line per unit, so three of the same product arrive as
267
+ * three lines. Every order block has to collapse them the same way, otherwise
268
+ * the same order reads as one "x3" row on the thank-you page and three "x1"
269
+ * rows in the account. Grouping lives here so the payload is read one way.
270
+ *
271
+ * `quantity` is summed rather than counted so a payload that does carry a line
272
+ * quantity (older commerce versions, and brands that merge lines server-side)
273
+ * is respected instead of being flattened back to one.
274
+ */
275
+ export const groupOrderItems = <T extends OrderLineLike>(
276
+ items: T[] | null | undefined
277
+ ): GroupedOrderLine<T>[] => {
278
+ const source = Array.isArray(items) ? items : [];
279
+
280
+ return source.reduce<GroupedOrderLine<T>[]>((accumulator, item) => {
281
+ const pk = item.product?.pk;
282
+ const key = String(pk ?? item.id ?? accumulator.length);
283
+ // Lines without a product pk cannot be proven identical, so they stay
284
+ // separate rows instead of collapsing into an arbitrary neighbour.
285
+ const existing =
286
+ pk === undefined || pk === null
287
+ ? undefined
288
+ : accumulator.find((entry) => entry.key === key);
289
+ const quantity = parseAmount(item.quantity) ?? 1;
290
+ const lineTotal = parseAmount(item.total_amount || item.price);
291
+
292
+ if (existing) {
293
+ existing.quantity += quantity;
294
+ existing.total =
295
+ lineTotal === null ? existing.total : (existing.total ?? 0) + lineTotal;
296
+
297
+ return accumulator;
298
+ }
299
+
300
+ accumulator.push({
301
+ key,
302
+ name: item.product?.name || '',
303
+ image: item.product?.image || '',
304
+ href: item.product?.absolute_url || '',
305
+ price: String(item.price ?? ''),
306
+ total: lineTotal,
307
+ quantity,
308
+ item
309
+ });
310
+
311
+ return accumulator;
312
+ }, []);
313
+ };
314
+
315
+ /**
316
+ * Every money string in the theme goes through commerce-format so the PDP, the
317
+ * product grid and the basket agree on separators and currency placement. The
318
+ * locale must be handed in by the caller (from `useLocalization()`): an
319
+ * implicit Intl locale resolves to the Node default on the server and to the
320
+ * browser locale on the client, which hydrates as a mismatch.
321
+ *
322
+ * `money` carries the brand's own format (digits, currency token, its side).
323
+ * Omitting it formats exactly as before, so callers that never learned about
324
+ * it are unaffected.
325
+ */
326
+ export const formatPrice = (
327
+ value: unknown,
328
+ currency: unknown,
329
+ showCurrency = true,
330
+ locale?: string,
331
+ money?: MoneyStyleOptions
332
+ ): string | null => {
333
+ if (isEmptyValue(value)) return null;
334
+
335
+ const formatted = formatMoney(value, {
336
+ ...money,
337
+ locale,
338
+ currency: String(currency || ''),
339
+ showCurrency
340
+ });
341
+ if (formatted) return formatted;
342
+
343
+ // Unparseable but non-empty values ("Call for price") stay visible as-is.
344
+ const text = String(value).trim();
345
+ return text || null;
346
+ };
347
+
348
+ export const isProductInStock = (product: ThemeProduct | null): boolean => {
349
+ if (!product) return false;
350
+
351
+ const inStock = pickProductField<boolean>(product, 'in_stock');
352
+ if (typeof inStock === 'boolean') return inStock;
353
+
354
+ const stock = Number(pickProductField(product, 'stock'));
355
+ if (Number.isFinite(stock)) return stock > 0;
356
+
357
+ // Unknown stock must not block the CTA — commerce still validates on add.
358
+ return true;
359
+ };
360
+
361
+ const toRecord = (value: unknown): Record<string, unknown> =>
362
+ value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
363
+
364
+ const toText = (value: unknown): string =>
365
+ typeof value === 'string'
366
+ ? value.trim()
367
+ : typeof value === 'number' && Number.isFinite(value)
368
+ ? String(value)
369
+ : '';
370
+
371
+ /**
372
+ * The image a variant option should show in a swatch. The option product is
373
+ * NOT run through `normalizeProductEntry()`, so `image` can be empty while the
374
+ * gallery holds the shot — both places are read here rather than in a renderer.
375
+ */
376
+ export const getVariantOptionImage = (
377
+ option: ThemeVariantOption | null | undefined
378
+ ): string => {
379
+ const product = option?.product;
380
+ const direct = toText(option?.image) || toText(product?.image);
381
+ if (direct) return direct;
382
+
383
+ const first = (product?.productimage_set || [])[0];
384
+
385
+ return toText(first?.image) || toText(first?.url);
386
+ };
387
+
388
+ /**
389
+ * The option's numeric stock, or null when the payload does not carry one.
390
+ * Null and 0 must stay distinguishable: a low-stock marker may only appear on
391
+ * a number commerce actually sent, never on an unknown.
392
+ */
393
+ export const getVariantOptionStock = (
394
+ option: ThemeVariantOption | null | undefined
395
+ ): number | null => {
396
+ const candidates = [option?.stock, option?.product?.stock];
397
+
398
+ for (const candidate of candidates) {
399
+ if (isEmptyValue(candidate)) continue;
400
+
401
+ const numeric = Number(candidate);
402
+ if (Number.isFinite(numeric)) return numeric;
403
+ }
404
+
405
+ return null;
406
+ };
407
+
408
+ /**
409
+ * Sold out, for one variant option — the package's single answer to that
410
+ * question.
411
+ *
412
+ * Commerce spells the same fact three ways across payloads, so all three are
413
+ * read: the option's own `in_stock`, the `is_selectable` commerce computed for
414
+ * it, and a numeric `stock` that reached zero. Every block that decides
415
+ * whether a LEAF can be sold has to give the same answer, because they are
416
+ * deciding about the same basket — the quick-view overlay in the
417
+ * recommendations strip held its own opinion and posted a sold-out leaf to the
418
+ * add endpoint.
419
+ *
420
+ * `variant-selector-block.tsx` still keeps a local `isOptionSoldOut` with
421
+ * exactly this body; collapse it onto this export the next time that file is
422
+ * opened, so the count of answers goes to one.
423
+ */
424
+ export const isVariantOptionSoldOut = (
425
+ option: ThemeVariantOption | null | undefined
426
+ ): boolean =>
427
+ option?.in_stock === false ||
428
+ option?.is_selectable === false ||
429
+ getVariantOptionStock(option) === 0;
430
+
431
+ const isColorKey = (key: string): boolean => {
432
+ const normalized = key.toLowerCase();
433
+
434
+ return (
435
+ normalized.includes('hex') ||
436
+ normalized.includes('color') ||
437
+ normalized.includes('colour') ||
438
+ normalized.includes('swatch') ||
439
+ normalized.includes('rgb')
440
+ );
441
+ };
442
+
443
+ /**
444
+ * Colour attributes hold either a machine colour ("#0A1F44", "0a1f44") or the
445
+ * shopper-facing name of one ("Kırmızı", "Navy Blue"). Only the first is usable
446
+ * as a CSS colour, so anything else resolves to '' and the renderer can fall
447
+ * back to a text swatch instead of painting an invalid value.
448
+ */
449
+ const asCssColor = (value: unknown): string => {
450
+ const text = toText(value);
451
+ if (!text) return '';
452
+
453
+ if (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text)) return text;
454
+ if (/^([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text)) return `#${text}`;
455
+ if (/^(rgb|rgba|hsl|hsla)\([^()]*\)$/i.test(text)) return text;
456
+ // A bare ASCII word may still be a CSS named colour; a browser drops it
457
+ // harmlessly when it is not. Anything with spaces or accents is a label.
458
+ if (/^[a-z]+$/i.test(text)) return text.toLowerCase();
459
+
460
+ return '';
461
+ };
462
+
463
+ /** Attribute records arrive as `{ key: value }` or as `{ key: { label, value } }`. */
464
+ const readColorFromRecord = (source: unknown): string => {
465
+ for (const [key, entry] of Object.entries(toRecord(source))) {
466
+ if (!isColorKey(key)) continue;
467
+
468
+ const nested = entry && typeof entry === 'object' ? toRecord(entry) : null;
469
+ const color = asCssColor(
470
+ nested ? nested.value ?? nested.hex ?? nested.label : entry
471
+ );
472
+ if (color) return color;
473
+ }
474
+
475
+ return '';
476
+ };
477
+
478
+ /**
479
+ * A CSS colour for the option's swatch, or '' when the payload has none.
480
+ * Probed from most to least specific: the option's own fields, its attribute
481
+ * record, then the same two on the product behind it.
482
+ */
483
+ export const getVariantOptionColor = (
484
+ option: ThemeVariantOption | null | undefined
485
+ ): string => {
486
+ if (!option) return '';
487
+
488
+ const sources = [
489
+ option,
490
+ option.attributes,
491
+ option.attributes_kwargs,
492
+ option.product?.attributes,
493
+ option.product?.attributes_kwargs,
494
+ option.product
495
+ ];
496
+
497
+ for (const source of sources) {
498
+ const color = readColorFromRecord(source);
499
+ if (color) return color;
500
+ }
501
+
502
+ return '';
503
+ };
504
+
505
+ /**
506
+ * The prior price commerce publishes for the price-history caption several
507
+ * markets require ("son 30 günün en düşük fiyatı", EU Omnibus).
508
+ *
509
+ * The payload field is `price_extra_field.previous_price` — that is what the
510
+ * PDP product carries and what brand storefronts read today. A row that came
511
+ * through `normalizeListingProduct()` has already flattened and formatted the
512
+ * same value onto `previousPrice`, so both spellings resolve here rather than
513
+ * in a block: the price block is dropped on a PDP and inside a product card,
514
+ * and one control has to work in both places.
515
+ *
516
+ * Nothing is derived: when commerce sends no previous price this returns
517
+ * undefined and the caller prints no caption. A price-history line is a legal
518
+ * claim, so an invented one is worse than a missing one.
519
+ */
520
+ export const getPreviousPrice = (
521
+ product: ThemeProduct | null
522
+ ): string | number | undefined => {
523
+ if (!product) return undefined;
524
+
525
+ for (const source of [product, product.selectedVariant]) {
526
+ if (!source) continue;
527
+
528
+ const nested = toRecord(source.price_extra_field).previous_price;
529
+ if (!isEmptyValue(nested)) return nested as string | number;
530
+
531
+ const flat = source.previousPrice ?? source.previous_price;
532
+ if (!isEmptyValue(flat)) return flat as string | number;
533
+ }
534
+
535
+ return undefined;
536
+ };
537
+
538
+ export interface ProductPriceRange {
539
+ min: number;
540
+ max: number;
541
+ }
542
+
543
+ /**
544
+ * The spread of a group product's variant prices, or null when there is none.
545
+ * Every variant option carries a whole product (see
546
+ * `ThemeVariantOptionProduct`), so the range is read off the payload the block
547
+ * already has instead of being fetched.
548
+ *
549
+ * Options commerce cannot sell are skipped: a sold-out variant's price is not
550
+ * a price a shopper can pay, and it is usually the one anchoring the low end.
551
+ * Null unless two prices actually differ, so a card falls back to the single
552
+ * price it shows today rather than printing "₺1.299 – ₺1.299".
553
+ */
554
+ export const getVariantPriceRange = (
555
+ product: ThemeProduct | null
556
+ ): ProductPriceRange | null => {
557
+ if (!product) return null;
558
+
559
+ const amounts: number[] = [];
560
+
561
+ (product.variants || []).forEach((group) => {
562
+ (group?.options || []).forEach((option) => {
563
+ if (option?.is_selectable === false) return;
564
+
565
+ const amount = parseAmount(option?.product?.price ?? option?.price);
566
+ if (amount !== null) amounts.push(amount);
567
+ });
568
+ });
569
+
570
+ if (amounts.length < 2) return null;
571
+
572
+ const min = Math.min(...amounts);
573
+ const max = Math.max(...amounts);
574
+
575
+ return max > min ? { min, max } : null;
576
+ };
577
+
578
+ export const getProductName = (product: ThemeProduct | null): string =>
579
+ String(
580
+ pickProductField<string>(product, 'title') ||
581
+ pickProductField<string>(product, 'name') ||
582
+ ''
583
+ );
584
+
585
+ export const stripHtml = (html: unknown): string =>
586
+ String(html || '')
587
+ .replace(/<[^>]*>/g, ' ')
588
+ .replace(/&nbsp;/gi, ' ')
589
+ .replace(/&amp;/gi, '&')
590
+ .replace(/&lt;/gi, '<')
591
+ .replace(/&gt;/gi, '>')
592
+ .replace(/&quot;/gi, '"')
593
+ .replace(/&#39;/gi, "'")
594
+ .replace(/\s+/g, ' ')
595
+ .trim();
596
+
597
+ /** Truncates on a word boundary so the excerpt never cuts mid-word. */
598
+ export const truncateText = (text: string, maxLength: number): string => {
599
+ if (!text || text.length <= maxLength) return text;
600
+
601
+ const sliced = text.slice(0, maxLength);
602
+ const lastSpace = sliced.lastIndexOf(' ');
603
+ const safe = lastSpace > maxLength * 0.6 ? sliced.slice(0, lastSpace) : sliced;
604
+
605
+ return `${safe.trimEnd()}…`;
606
+ };
607
+
608
+ /**
609
+ * Neutral, non-shipping hint so authors can still see and select a block that
610
+ * has no data behind it. Never rendered on the storefront.
611
+ *
612
+ * `attribute` carries the block's own root data-* attribute onto the
613
+ * placeholder: tooling (and the designer selection layer) keys on selectors
614
+ * like `[data-product-title]`, which would otherwise stop matching the moment
615
+ * the block has no data.
616
+ */
617
+ export const DesignerPlaceholder = ({
618
+ label,
619
+ attribute
620
+ }: {
621
+ label: string;
622
+ attribute?: string;
623
+ }) => (
624
+ <span
625
+ data-theme-block-placeholder=""
626
+ {...(attribute ? { [attribute]: '' } : {})}
627
+ style={{
628
+ display: 'inline-block',
629
+ padding: '6px 10px',
630
+ border: '1px dashed #d1d5db',
631
+ borderRadius: '4px',
632
+ color: '#6b7280',
633
+ fontSize: '12px',
634
+ lineHeight: 1.4
635
+ }}
636
+ >
637
+ {label}
638
+ </span>
639
+ );