@akinon/pz-theme 2.0.37-beta.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 +4 -4
- 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,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(/ /gi, ' ')
|
|
589
|
+
.replace(/&/gi, '&')
|
|
590
|
+
.replace(/</gi, '<')
|
|
591
|
+
.replace(/>/gi, '>')
|
|
592
|
+
.replace(/"/gi, '"')
|
|
593
|
+
.replace(/'/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
|
+
);
|