@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.
- package/CHANGELOG.md +7 -31
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useRef } from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { useGetWidgetQuery } from '@akinon/next/data/client/misc';
|
|
6
|
+
import {
|
|
7
|
+
useAddProductMutation,
|
|
8
|
+
useGetProductByPkQuery
|
|
9
|
+
} from '@akinon/next/data/client/product';
|
|
10
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
11
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
12
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
13
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
15
|
+
import { DESIGNER_SAMPLE_PRODUCT } from './item-template-utils';
|
|
16
|
+
import {
|
|
17
|
+
formatListingPrice,
|
|
18
|
+
listingPlaceholderStyle,
|
|
19
|
+
readToggle
|
|
20
|
+
} from './listing-context';
|
|
21
|
+
import {
|
|
22
|
+
isProductInStock,
|
|
23
|
+
isVariantOptionSoldOut,
|
|
24
|
+
parsePriceValue,
|
|
25
|
+
pickProductField,
|
|
26
|
+
ThemeProduct,
|
|
27
|
+
ThemeVariantGroup,
|
|
28
|
+
ThemeVariantOption,
|
|
29
|
+
useThemeProduct
|
|
30
|
+
} from './product-block-utils';
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_RECENTLY_VIEWED_KEY,
|
|
33
|
+
deferIdleTask,
|
|
34
|
+
readRecentlyViewed,
|
|
35
|
+
recordRecentlyViewed,
|
|
36
|
+
RecentlyViewedEntry
|
|
37
|
+
} from './recently-viewed-store';
|
|
38
|
+
|
|
39
|
+
/** Fallback track gap; the card width is derived from whatever is in use. */
|
|
40
|
+
const DEFAULT_CARD_GAP = 16;
|
|
41
|
+
|
|
42
|
+
const ALLOWED_TITLE_TAGS = ['div', 'h2', 'h3', 'span'];
|
|
43
|
+
const ALLOWED_IMAGE_RATIOS = ['', '1/1', '3/4', '4/3'];
|
|
44
|
+
/** `collection` is the source the block has always had, so it is the default. */
|
|
45
|
+
const ALLOWED_SOURCES = ['collection', 'recently-viewed'];
|
|
46
|
+
const ALLOWED_QUICK_VIEW_ICONS = ['eye', 'plus', 'none'];
|
|
47
|
+
|
|
48
|
+
const DEFAULT_QUICK_VIEW_FEEDBACK = 2000;
|
|
49
|
+
|
|
50
|
+
const clampNumber = (
|
|
51
|
+
value: unknown,
|
|
52
|
+
fallback: number,
|
|
53
|
+
min: number,
|
|
54
|
+
max: number
|
|
55
|
+
) => {
|
|
56
|
+
const numeric = Math.round(Number(value));
|
|
57
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
58
|
+
return Math.min(max, Math.max(min, numeric));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const toText = (value: unknown): string =>
|
|
62
|
+
typeof value === 'string'
|
|
63
|
+
? value
|
|
64
|
+
: typeof value === 'number' && Number.isFinite(value)
|
|
65
|
+
? String(value)
|
|
66
|
+
: '';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A commerce product id, or undefined. The value is a merchant payload on its
|
|
70
|
+
* way into an endpoint path, so anything that is not a positive integer is
|
|
71
|
+
* refused rather than coerced: `pk` also feeds the React key fallback
|
|
72
|
+
* ("recommendation-3"), and a string like that appended to `/product/…/`
|
|
73
|
+
* would be a request built out of untrusted text.
|
|
74
|
+
*/
|
|
75
|
+
const toProductPk = (value: unknown): number | undefined => {
|
|
76
|
+
const numeric = Number(value);
|
|
77
|
+
|
|
78
|
+
return Number.isFinite(numeric) && numeric > 0
|
|
79
|
+
? Math.trunc(numeric)
|
|
80
|
+
: undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A merchant URL that is safe to put in an `href` or an `src`. Schemes that can
|
|
85
|
+
* execute (`javascript:`, `data:text/html`) are dropped rather than escaped —
|
|
86
|
+
* escaping does nothing to a URL the browser will run on click. Relative and
|
|
87
|
+
* protocol-relative paths carry no scheme to abuse, so they pass through.
|
|
88
|
+
*/
|
|
89
|
+
const safeUrl = (value: unknown): string => {
|
|
90
|
+
const text = toText(value).trim();
|
|
91
|
+
if (!text) return '';
|
|
92
|
+
if (/^[/?#]/.test(text)) return text;
|
|
93
|
+
|
|
94
|
+
return /^https?:\/\//i.test(text) ? text : '';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const ChevronIcon = ({ direction }: { direction: 'prev' | 'next' }) => (
|
|
98
|
+
<svg
|
|
99
|
+
width="16"
|
|
100
|
+
height="16"
|
|
101
|
+
viewBox="0 0 24 24"
|
|
102
|
+
fill="none"
|
|
103
|
+
stroke="currentColor"
|
|
104
|
+
strokeWidth="1.8"
|
|
105
|
+
strokeLinecap="round"
|
|
106
|
+
strokeLinejoin="round"
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
>
|
|
109
|
+
<path d={direction === 'prev' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} />
|
|
110
|
+
</svg>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const QuickViewIcon = ({ name }: { name: string }) => {
|
|
114
|
+
if (name === 'none') return null;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<svg
|
|
118
|
+
width="16"
|
|
119
|
+
height="16"
|
|
120
|
+
viewBox="0 0 24 24"
|
|
121
|
+
fill="none"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
strokeWidth="1.8"
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
strokeLinejoin="round"
|
|
126
|
+
aria-hidden="true"
|
|
127
|
+
>
|
|
128
|
+
{name === 'plus' ? (
|
|
129
|
+
<path d="M12 5v14M5 12h14" />
|
|
130
|
+
) : (
|
|
131
|
+
<>
|
|
132
|
+
<path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6z" />
|
|
133
|
+
<circle cx="12" cy="12" r="2.6" />
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</svg>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const CloseIcon = () => (
|
|
141
|
+
<svg
|
|
142
|
+
width="18"
|
|
143
|
+
height="18"
|
|
144
|
+
viewBox="0 0 24 24"
|
|
145
|
+
fill="none"
|
|
146
|
+
stroke="currentColor"
|
|
147
|
+
strokeWidth="1.8"
|
|
148
|
+
strokeLinecap="round"
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
>
|
|
151
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
152
|
+
</svg>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
interface RecommendationCard {
|
|
156
|
+
key: string;
|
|
157
|
+
/**
|
|
158
|
+
* The commerce product id. `key` falls back to an index ("recommendation-3")
|
|
159
|
+
* so it can never address an endpoint; this can. It is also what opens the
|
|
160
|
+
* row's item scope — see `resolveItemProduct()` — so a favourite or
|
|
161
|
+
* add-to-cart block inside an authored card acts on the card's own product
|
|
162
|
+
* instead of falling through to the page's.
|
|
163
|
+
*/
|
|
164
|
+
pk?: number;
|
|
165
|
+
name: string;
|
|
166
|
+
url: string;
|
|
167
|
+
image: string;
|
|
168
|
+
price: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Functional PDP/campaign block: a related-products slider.
|
|
173
|
+
*
|
|
174
|
+
* v1 core ships no recommendation endpoint, so the data path is the
|
|
175
|
+
* collection/widget one every storefront already uses: the author names a
|
|
176
|
+
* collection and `getWidget` returns its products in merchandiser order. The
|
|
177
|
+
* cards are laid out exactly like the product grid so a recommendation strip
|
|
178
|
+
* and a PLP card stay visually identical.
|
|
179
|
+
*
|
|
180
|
+
* `source` picks where the cards come from. It defaults to `collection` — the
|
|
181
|
+
* only behaviour that existed — and `recently-viewed` swaps in the products
|
|
182
|
+
* this browser has looked at, which the block also records (see
|
|
183
|
+
* `recently-viewed-store.ts` for what is stored and why).
|
|
184
|
+
*
|
|
185
|
+
* ACTING ON A CARD WITHOUT LEAVING THE PAGE comes in two halves, and only one
|
|
186
|
+
* of them is built here.
|
|
187
|
+
*
|
|
188
|
+
* Adding to basket from a card was already reachable and is not rebuilt: a
|
|
189
|
+
* designer marks a child as the card layout and drops the existing add-to-cart
|
|
190
|
+
* or favourite block into it, and `ItemTemplateList` wraps every row in the
|
|
191
|
+
* item scope so those blocks act on the ROW's product. What that mechanism was
|
|
192
|
+
* missing is the id — the card model published `key` (which falls back to an
|
|
193
|
+
* index) and no `pk`, so `resolveItemProduct()` refused the scope and the
|
|
194
|
+
* blocks silently fell through to the page's product. Publishing `pk` is what
|
|
195
|
+
* turns the existing mechanism on.
|
|
196
|
+
*
|
|
197
|
+
* The overlay is the half that did not exist. A recommendation row carries a
|
|
198
|
+
* SUMMARY — the collection/widget payload and the stored history both stop at
|
|
199
|
+
* name, url, image, price — and no variant matrix, so there is nothing to pick
|
|
200
|
+
* a size from. Quick view therefore fetches `/product/{pk}/` on demand, for the
|
|
201
|
+
* one card the shopper opened, and shows the summary it already has while that
|
|
202
|
+
* request is in flight. Off by default; every published strip renders exactly
|
|
203
|
+
* as it did.
|
|
204
|
+
*
|
|
205
|
+
* Parts: title, arrow-list, arrow, track, card-slot, card, card-image,
|
|
206
|
+
* card-name, card-price, quick-view-trigger, quick-view-backdrop,
|
|
207
|
+
* quick-view-panel, quick-view-close, quick-view-media, quick-view-body,
|
|
208
|
+
* quick-view-name, quick-view-price, quick-view-old-price, quick-view-status,
|
|
209
|
+
* quick-view-retry, quick-view-size-label, quick-view-size-list,
|
|
210
|
+
* quick-view-size, quick-view-add, quick-view-link.
|
|
211
|
+
* Properties: source, title, titleTag, collectionId, limit, columnsDesktop,
|
|
212
|
+
* columnsMobile, cardGap, showName, showPrice, imageRatio, showArrows,
|
|
213
|
+
* prevLabel, nextLabel, recentlyViewedKey, recentlyViewedCapacity,
|
|
214
|
+
* recentlyViewedMaxAge, recentlyViewedEmptyLabel, showQuickView,
|
|
215
|
+
* quickViewTriggerLabel, quickViewTriggerIcon, quickViewWidth,
|
|
216
|
+
* showQuickViewBackdrop, quickViewCloseOnBackdrop, quickViewCloseOnEscape,
|
|
217
|
+
* quickViewLockScroll, showQuickViewClose, quickViewCloseLabel,
|
|
218
|
+
* quickViewLoadingLabel, quickViewErrorLabel, quickViewRetryLabel,
|
|
219
|
+
* quickViewSizeLabel, quickViewSelectSizeLabel, quickViewAddLabel,
|
|
220
|
+
* quickViewAddingLabel, quickViewAddedLabel, quickViewAddErrorLabel,
|
|
221
|
+
* quickViewOutOfStockLabel, quickViewFeedbackDuration, showQuickViewLink,
|
|
222
|
+
* quickViewLinkLabel.
|
|
223
|
+
*/
|
|
224
|
+
const ProductRecommendationsBlock = ({
|
|
225
|
+
block,
|
|
226
|
+
placeholderId,
|
|
227
|
+
sectionId,
|
|
228
|
+
selectedBlockId = null,
|
|
229
|
+
currentBreakpoint = 'desktop',
|
|
230
|
+
isDesigner = false
|
|
231
|
+
}: BlockRendererProps) => {
|
|
232
|
+
const { locale, currency: localeCurrency } = useLocalization();
|
|
233
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
234
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
235
|
+
// The ambient product: on a PDP this is what the shopper is looking at, and
|
|
236
|
+
// it is both what gets recorded and what is excluded from its own strip.
|
|
237
|
+
const ambientProduct = useThemeProduct();
|
|
238
|
+
const [recentEntries, setRecentEntries] = React.useState<
|
|
239
|
+
RecentlyViewedEntry[]
|
|
240
|
+
>([]);
|
|
241
|
+
/** The card whose overlay is open, or null. Closed on load, designer too. */
|
|
242
|
+
const [quickViewCard, setQuickViewCard] =
|
|
243
|
+
React.useState<RecommendationCard | null>(null);
|
|
244
|
+
/** The variant option value the shopper picked, '' until they pick one. */
|
|
245
|
+
const [chosenOption, setChosenOption] = React.useState('');
|
|
246
|
+
const [addFeedback, setAddFeedback] = React.useState<
|
|
247
|
+
'idle' | 'added' | 'error'
|
|
248
|
+
>('idle');
|
|
249
|
+
// The element that opened the overlay, so focus can be handed back to the
|
|
250
|
+
// exact card the shopper came from rather than to the top of the strip.
|
|
251
|
+
const quickViewOpenerRef = useRef<HTMLElement | null>(null);
|
|
252
|
+
const addFeedbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
253
|
+
|
|
254
|
+
const prop = (key: string, fallback: string) =>
|
|
255
|
+
String(
|
|
256
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const sourceValue = prop('source', 'collection');
|
|
260
|
+
const source = ALLOWED_SOURCES.includes(sourceValue)
|
|
261
|
+
? sourceValue
|
|
262
|
+
: 'collection';
|
|
263
|
+
const isRecentlyViewed = source === 'recently-viewed';
|
|
264
|
+
|
|
265
|
+
const title = String(
|
|
266
|
+
getResponsiveValue(block.properties?.title, currentBreakpoint, '')
|
|
267
|
+
).trim();
|
|
268
|
+
const titleTagValue = prop('titleTag', 'div');
|
|
269
|
+
const TitleTag = (
|
|
270
|
+
ALLOWED_TITLE_TAGS.includes(titleTagValue) ? titleTagValue : 'div'
|
|
271
|
+
) as React.ElementType;
|
|
272
|
+
const collectionId = String(
|
|
273
|
+
getResponsiveValue(block.properties?.collectionId, currentBreakpoint, '')
|
|
274
|
+
).trim();
|
|
275
|
+
const limit = clampNumber(
|
|
276
|
+
getResponsiveValue(block.properties?.limit, currentBreakpoint, 8),
|
|
277
|
+
8,
|
|
278
|
+
1,
|
|
279
|
+
24
|
|
280
|
+
);
|
|
281
|
+
const columnsDesktop = clampNumber(
|
|
282
|
+
getResponsiveValue(block.properties?.columnsDesktop, currentBreakpoint, 4),
|
|
283
|
+
4,
|
|
284
|
+
2,
|
|
285
|
+
6
|
|
286
|
+
);
|
|
287
|
+
const columnsMobile = clampNumber(
|
|
288
|
+
getResponsiveValue(block.properties?.columnsMobile, currentBreakpoint, 2),
|
|
289
|
+
2,
|
|
290
|
+
1,
|
|
291
|
+
3
|
|
292
|
+
);
|
|
293
|
+
const cardGap = clampNumber(
|
|
294
|
+
getResponsiveValue(
|
|
295
|
+
block.properties?.cardGap,
|
|
296
|
+
currentBreakpoint,
|
|
297
|
+
DEFAULT_CARD_GAP
|
|
298
|
+
),
|
|
299
|
+
DEFAULT_CARD_GAP,
|
|
300
|
+
0,
|
|
301
|
+
64
|
|
302
|
+
);
|
|
303
|
+
const showName = readToggle(
|
|
304
|
+
block.properties?.showName,
|
|
305
|
+
currentBreakpoint,
|
|
306
|
+
true
|
|
307
|
+
);
|
|
308
|
+
const showPrice = readToggle(
|
|
309
|
+
block.properties?.showPrice,
|
|
310
|
+
currentBreakpoint,
|
|
311
|
+
true
|
|
312
|
+
);
|
|
313
|
+
const imageRatioValue = prop('imageRatio', '');
|
|
314
|
+
const imageRatio = ALLOWED_IMAGE_RATIOS.includes(imageRatioValue)
|
|
315
|
+
? imageRatioValue
|
|
316
|
+
: '';
|
|
317
|
+
const showArrows = readToggle(
|
|
318
|
+
block.properties?.showArrows,
|
|
319
|
+
currentBreakpoint,
|
|
320
|
+
false
|
|
321
|
+
);
|
|
322
|
+
const prevLabel = prop('prevLabel', 'Previous products');
|
|
323
|
+
const nextLabel = prop('nextLabel', 'Next products');
|
|
324
|
+
const recentlyViewedKey =
|
|
325
|
+
prop('recentlyViewedKey', DEFAULT_RECENTLY_VIEWED_KEY).trim() ||
|
|
326
|
+
DEFAULT_RECENTLY_VIEWED_KEY;
|
|
327
|
+
const recentlyViewedCapacity = clampNumber(
|
|
328
|
+
getResponsiveValue(
|
|
329
|
+
block.properties?.recentlyViewedCapacity,
|
|
330
|
+
currentBreakpoint,
|
|
331
|
+
20
|
|
332
|
+
),
|
|
333
|
+
20,
|
|
334
|
+
1,
|
|
335
|
+
60
|
|
336
|
+
);
|
|
337
|
+
const recentlyViewedMaxAge = clampNumber(
|
|
338
|
+
getResponsiveValue(
|
|
339
|
+
block.properties?.recentlyViewedMaxAge,
|
|
340
|
+
currentBreakpoint,
|
|
341
|
+
30
|
|
342
|
+
),
|
|
343
|
+
30,
|
|
344
|
+
0,
|
|
345
|
+
365
|
|
346
|
+
);
|
|
347
|
+
const recentlyViewedEmptyLabel = prop(
|
|
348
|
+
'recentlyViewedEmptyLabel',
|
|
349
|
+
'Recently viewed'
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Off by default: a published strip has never drawn a trigger over its cards
|
|
353
|
+
// and must keep not drawing one.
|
|
354
|
+
const showQuickView = readToggle(
|
|
355
|
+
block.properties?.showQuickView,
|
|
356
|
+
currentBreakpoint,
|
|
357
|
+
false
|
|
358
|
+
);
|
|
359
|
+
const quickViewTriggerLabel = prop('quickViewTriggerLabel', 'Quick view');
|
|
360
|
+
const quickViewTriggerIconValue = prop('quickViewTriggerIcon', 'eye');
|
|
361
|
+
const quickViewTriggerIcon = ALLOWED_QUICK_VIEW_ICONS.includes(
|
|
362
|
+
quickViewTriggerIconValue
|
|
363
|
+
)
|
|
364
|
+
? quickViewTriggerIconValue
|
|
365
|
+
: 'eye';
|
|
366
|
+
const quickViewWidth = clampNumber(
|
|
367
|
+
getResponsiveValue(block.properties?.quickViewWidth, currentBreakpoint, 720),
|
|
368
|
+
720,
|
|
369
|
+
280,
|
|
370
|
+
1200
|
|
371
|
+
);
|
|
372
|
+
const showQuickViewBackdrop = readToggle(
|
|
373
|
+
block.properties?.showQuickViewBackdrop,
|
|
374
|
+
currentBreakpoint,
|
|
375
|
+
true
|
|
376
|
+
);
|
|
377
|
+
const quickViewCloseOnBackdrop = readToggle(
|
|
378
|
+
block.properties?.quickViewCloseOnBackdrop,
|
|
379
|
+
currentBreakpoint,
|
|
380
|
+
true
|
|
381
|
+
);
|
|
382
|
+
const quickViewCloseOnEscape = readToggle(
|
|
383
|
+
block.properties?.quickViewCloseOnEscape,
|
|
384
|
+
currentBreakpoint,
|
|
385
|
+
true
|
|
386
|
+
);
|
|
387
|
+
const quickViewLockScroll = readToggle(
|
|
388
|
+
block.properties?.quickViewLockScroll,
|
|
389
|
+
currentBreakpoint,
|
|
390
|
+
true
|
|
391
|
+
);
|
|
392
|
+
const showQuickViewClose = readToggle(
|
|
393
|
+
block.properties?.showQuickViewClose,
|
|
394
|
+
currentBreakpoint,
|
|
395
|
+
true
|
|
396
|
+
);
|
|
397
|
+
const quickViewCloseLabel = prop('quickViewCloseLabel', 'Close');
|
|
398
|
+
const quickViewLoadingLabel = prop('quickViewLoadingLabel', 'Loading…');
|
|
399
|
+
const quickViewErrorLabel = prop(
|
|
400
|
+
'quickViewErrorLabel',
|
|
401
|
+
'This product could not be loaded.'
|
|
402
|
+
);
|
|
403
|
+
const quickViewRetryLabel = prop('quickViewRetryLabel', 'Try again');
|
|
404
|
+
const quickViewSizeLabel = prop('quickViewSizeLabel', 'Size');
|
|
405
|
+
const quickViewSelectSizeLabel = prop(
|
|
406
|
+
'quickViewSelectSizeLabel',
|
|
407
|
+
'Select a size'
|
|
408
|
+
);
|
|
409
|
+
const quickViewAddLabel = prop('quickViewAddLabel', 'Add to basket');
|
|
410
|
+
const quickViewAddingLabel = prop('quickViewAddingLabel', 'Adding…');
|
|
411
|
+
const quickViewAddedLabel = prop('quickViewAddedLabel', 'Added');
|
|
412
|
+
const quickViewAddErrorLabel = prop('quickViewAddErrorLabel', 'Try again');
|
|
413
|
+
const quickViewOutOfStockLabel = prop(
|
|
414
|
+
'quickViewOutOfStockLabel',
|
|
415
|
+
'Out of stock'
|
|
416
|
+
);
|
|
417
|
+
const quickViewFeedbackDuration = clampNumber(
|
|
418
|
+
getResponsiveValue(
|
|
419
|
+
block.properties?.quickViewFeedbackDuration,
|
|
420
|
+
currentBreakpoint,
|
|
421
|
+
DEFAULT_QUICK_VIEW_FEEDBACK
|
|
422
|
+
),
|
|
423
|
+
DEFAULT_QUICK_VIEW_FEEDBACK,
|
|
424
|
+
500,
|
|
425
|
+
10000
|
|
426
|
+
);
|
|
427
|
+
const showQuickViewLink = readToggle(
|
|
428
|
+
block.properties?.showQuickViewLink,
|
|
429
|
+
currentBreakpoint,
|
|
430
|
+
true
|
|
431
|
+
);
|
|
432
|
+
const quickViewLinkLabel = prop('quickViewLinkLabel', 'View full details');
|
|
433
|
+
|
|
434
|
+
// The hook has to run unconditionally to keep the hook order stable; `skip`
|
|
435
|
+
// is what prevents a request before the author has named a collection — and
|
|
436
|
+
// now also a request for a collection the recently-viewed source never uses.
|
|
437
|
+
const { data, isFetching } = useGetWidgetQuery(collectionId, {
|
|
438
|
+
skip: !collectionId || isRecentlyViewed
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The overlay's own request. A row carries a summary only, so the variant
|
|
443
|
+
* matrix a size picker needs exists nowhere on this page — it has to come
|
|
444
|
+
* from the product endpoint, for the ONE card the shopper opened. Skipped
|
|
445
|
+
* until then, so a strip that nobody touches still costs a single widget
|
|
446
|
+
* call; skipped in the designer, where there is no real product behind a
|
|
447
|
+
* canvas card and a sample stands in instead.
|
|
448
|
+
*/
|
|
449
|
+
const quickViewPk = quickViewCard ? toProductPk(quickViewCard.pk) : undefined;
|
|
450
|
+
const {
|
|
451
|
+
data: quickViewDetail,
|
|
452
|
+
isFetching: isQuickViewLoading,
|
|
453
|
+
isError: isQuickViewFailed,
|
|
454
|
+
refetch: refetchQuickView
|
|
455
|
+
} = useGetProductByPkQuery(quickViewPk ?? 0, {
|
|
456
|
+
skip: isDesigner || quickViewPk === undefined
|
|
457
|
+
});
|
|
458
|
+
const [addProduct, { isLoading: isAdding }] = useAddProductMutation();
|
|
459
|
+
|
|
460
|
+
const currentPk = toText(pickProductField(ambientProduct, 'pk'));
|
|
461
|
+
// `pickProductField` already resolves title/name and url/absolute_url, so a
|
|
462
|
+
// page-context product and a normalised listing row both land here.
|
|
463
|
+
const currentName = toText(pickProductField(ambientProduct, 'title'));
|
|
464
|
+
const currentUrl = toText(pickProductField(ambientProduct, 'url'));
|
|
465
|
+
const currentImage = toText(pickProductField(ambientProduct, 'image'));
|
|
466
|
+
const currentPrice = toText(pickProductField(ambientProduct, 'price'));
|
|
467
|
+
const currentCurrency = toText(pickProductField(ambientProduct, 'currency'));
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Reads the history, then records this view off the critical path.
|
|
471
|
+
*
|
|
472
|
+
* Deps are primitives on purpose: the ambient product object is rebuilt on
|
|
473
|
+
* every page-context change, and depending on it would rewrite storage in a
|
|
474
|
+
* loop. In the designer nothing is read, recorded or scheduled — an author
|
|
475
|
+
* clicking through pages must not fill their own browser with history, and
|
|
476
|
+
* `isDesigner` is what keeps the write path off the storefront preview.
|
|
477
|
+
*/
|
|
478
|
+
React.useEffect(() => {
|
|
479
|
+
if (!isRecentlyViewed || isDesigner) return;
|
|
480
|
+
|
|
481
|
+
setRecentEntries(
|
|
482
|
+
readRecentlyViewed(recentlyViewedKey, recentlyViewedMaxAge)
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// Nothing identifies the page's product (a homepage strip, say), so there
|
|
486
|
+
// is a history to read but no view to add.
|
|
487
|
+
if (!currentPk) return;
|
|
488
|
+
|
|
489
|
+
return deferIdleTask(() =>
|
|
490
|
+
recordRecentlyViewed(
|
|
491
|
+
recentlyViewedKey,
|
|
492
|
+
{
|
|
493
|
+
pk: currentPk,
|
|
494
|
+
name: currentName,
|
|
495
|
+
url: currentUrl,
|
|
496
|
+
image: currentImage,
|
|
497
|
+
price: currentPrice,
|
|
498
|
+
currency: currentCurrency,
|
|
499
|
+
viewedAt: Date.now()
|
|
500
|
+
},
|
|
501
|
+
recentlyViewedCapacity
|
|
502
|
+
)
|
|
503
|
+
);
|
|
504
|
+
}, [
|
|
505
|
+
isRecentlyViewed,
|
|
506
|
+
isDesigner,
|
|
507
|
+
recentlyViewedKey,
|
|
508
|
+
recentlyViewedMaxAge,
|
|
509
|
+
recentlyViewedCapacity,
|
|
510
|
+
currentPk,
|
|
511
|
+
currentName,
|
|
512
|
+
currentUrl,
|
|
513
|
+
currentImage,
|
|
514
|
+
currentPrice,
|
|
515
|
+
currentCurrency
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Every way out of the overlay is a designer toggle, so a composition can
|
|
520
|
+
* legally have all of them off and seal a shopper inside. Only a POINTER
|
|
521
|
+
* route counts — Escape does not exist on the phone this pattern is used on
|
|
522
|
+
* most — and when none survives, the close button is forced back on and
|
|
523
|
+
* Escape re-armed. An authored composition must never be a trap.
|
|
524
|
+
*/
|
|
525
|
+
const hasPointerDismissRoute =
|
|
526
|
+
showQuickViewClose || (showQuickViewBackdrop && quickViewCloseOnBackdrop);
|
|
527
|
+
const quickViewCloseVisible = showQuickViewClose || !hasPointerDismissRoute;
|
|
528
|
+
const escapeClosesQuickView =
|
|
529
|
+
quickViewCloseOnEscape || !hasPointerDismissRoute;
|
|
530
|
+
const quickViewOpen = quickViewCard !== null;
|
|
531
|
+
|
|
532
|
+
const closeQuickView = () => {
|
|
533
|
+
setQuickViewCard(null);
|
|
534
|
+
setChosenOption('');
|
|
535
|
+
setAddFeedback('idle');
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Focus, Escape and the page scroll lock are the shared overlay contract
|
|
540
|
+
* rather than this block's own — and this block is exactly the reason it has
|
|
541
|
+
* to be shared: a strip ships more than once on a product page (a "you may
|
|
542
|
+
* also like" one and a "recently viewed" one), so a per-block focus trap
|
|
543
|
+
* fought its twin over focus and a per-block scroll lock captured the
|
|
544
|
+
* ALREADY LOCKED overflow as "the page's own value".
|
|
545
|
+
*
|
|
546
|
+
* `modal` is what the overlay honestly is: only a backdrop actually covers
|
|
547
|
+
* the page, and without one `aria-modal` would tell assistive tech the
|
|
548
|
+
* opposite of what is true. `manageFocus` stays on either way — moving focus
|
|
549
|
+
* in and handing it back is right for a panel the shopper opened, while the
|
|
550
|
+
* TRAP that backs the `aria-modal` claim is what the hook gates on `modal`.
|
|
551
|
+
*/
|
|
552
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
553
|
+
open: quickViewOpen,
|
|
554
|
+
onClose: closeQuickView,
|
|
555
|
+
isDesigner,
|
|
556
|
+
modal: showQuickViewBackdrop,
|
|
557
|
+
lockScroll: quickViewLockScroll,
|
|
558
|
+
closeOnEscape: escapeClosesQuickView,
|
|
559
|
+
manageFocus: true,
|
|
560
|
+
returnFocusRef: quickViewOpenerRef,
|
|
561
|
+
// The card the shopper came from can be gone by the time the overlay
|
|
562
|
+
// closes (a recently-viewed strip re-reads its history), and the strip
|
|
563
|
+
// beats dropping a keyboard shopper at the top of the document.
|
|
564
|
+
fallbackFocusRef: rootRef
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
React.useEffect(
|
|
568
|
+
() => () => {
|
|
569
|
+
if (addFeedbackTimer.current) clearTimeout(addFeedbackTimer.current);
|
|
570
|
+
},
|
|
571
|
+
[]
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
const collectionProducts: RecommendationCard[] = (
|
|
575
|
+
(data?.products || []) as Array<Record<string, unknown>>
|
|
576
|
+
)
|
|
577
|
+
.slice(0, limit)
|
|
578
|
+
// Annotated so the element type matches the predicate below: left inferred,
|
|
579
|
+
// the object literal makes `pk` required while a card's `pk` is optional,
|
|
580
|
+
// and a type predicate must be assignable to what it narrows.
|
|
581
|
+
.map((entry, index): RecommendationCard | null => {
|
|
582
|
+
const name = toText(entry.name) || toText(entry.title);
|
|
583
|
+
const url = toText(entry.absolute_url) || toText(entry.url);
|
|
584
|
+
if (!name && !url) return null;
|
|
585
|
+
|
|
586
|
+
const images = Array.isArray(entry.productimage_set)
|
|
587
|
+
? (entry.productimage_set as Array<Record<string, unknown>>)
|
|
588
|
+
: [];
|
|
589
|
+
// Commerce prices each product in its own currency; the storefront
|
|
590
|
+
// currency is only the fallback for payloads that omit it.
|
|
591
|
+
const currency = toText(entry.currency_type) || localeCurrency || '';
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
key: toText(entry.pk) || `recommendation-${index}`,
|
|
595
|
+
pk: toProductPk(entry.pk),
|
|
596
|
+
name,
|
|
597
|
+
url: url || '#',
|
|
598
|
+
image: toText(entry.image) || toText(images[0]?.image),
|
|
599
|
+
price: formatListingPrice(entry.price, currency, locale)
|
|
600
|
+
};
|
|
601
|
+
})
|
|
602
|
+
.filter((card): card is RecommendationCard => card !== null);
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The stored amount is re-formatted here rather than read back as a string,
|
|
606
|
+
* so a shopper who switches locale or currency sees the money in the format
|
|
607
|
+
* the rest of the page uses. The AMOUNT itself is still the one captured at
|
|
608
|
+
* view time — see recently-viewed-store.ts.
|
|
609
|
+
*/
|
|
610
|
+
const recentlyViewedProducts: RecommendationCard[] = recentEntries
|
|
611
|
+
// A product must never recommend itself back to the page it is on.
|
|
612
|
+
.filter((entry) => !currentPk || entry.pk !== currentPk)
|
|
613
|
+
.filter((entry) => Boolean(entry.name || entry.url))
|
|
614
|
+
.slice(0, limit)
|
|
615
|
+
.map((entry) => ({
|
|
616
|
+
key: entry.pk,
|
|
617
|
+
pk: toProductPk(entry.pk),
|
|
618
|
+
name: entry.name,
|
|
619
|
+
url: entry.url || '#',
|
|
620
|
+
image: entry.image,
|
|
621
|
+
price: formatListingPrice(
|
|
622
|
+
entry.price,
|
|
623
|
+
entry.currency || localeCurrency || '',
|
|
624
|
+
locale
|
|
625
|
+
)
|
|
626
|
+
}));
|
|
627
|
+
|
|
628
|
+
const products = isRecentlyViewed
|
|
629
|
+
? recentlyViewedProducts
|
|
630
|
+
: collectionProducts;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The designer has no browsing history behind the canvas, so a recently
|
|
634
|
+
* viewed strip would collapse to nothing and stop being selectable, sizeable
|
|
635
|
+
* or stylable. Skeleton rows keep the track and every card part on screen.
|
|
636
|
+
* Storefront-side this is null, which is what leaves "empty renders nothing"
|
|
637
|
+
* intact.
|
|
638
|
+
*/
|
|
639
|
+
const designerRows: RecommendationCard[] | null =
|
|
640
|
+
isDesigner &&
|
|
641
|
+
isRecentlyViewed &&
|
|
642
|
+
products.length === 0 &&
|
|
643
|
+
// An authored card layout already draws its own placeholder rows
|
|
644
|
+
// (`designerPlaceholderCount`), so adding built-in ones would double them.
|
|
645
|
+
!hasItemTemplate(block, currentBreakpoint)
|
|
646
|
+
? Array.from(
|
|
647
|
+
{ length: Math.min(limit, columnsDesktop + 1) },
|
|
648
|
+
(_, index) => ({
|
|
649
|
+
key: `recently-viewed-placeholder-${index}`,
|
|
650
|
+
name: recentlyViewedEmptyLabel,
|
|
651
|
+
url: '#',
|
|
652
|
+
image: '',
|
|
653
|
+
price: ''
|
|
654
|
+
})
|
|
655
|
+
)
|
|
656
|
+
: null;
|
|
657
|
+
|
|
658
|
+
// A designer who marked a child as the card layout gets that layout repeated
|
|
659
|
+
// per product; otherwise the built-in card renders unchanged.
|
|
660
|
+
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
661
|
+
<ItemTemplateList
|
|
662
|
+
block={block}
|
|
663
|
+
items={products as unknown as Record<string, unknown>[]}
|
|
664
|
+
placeholderId={placeholderId}
|
|
665
|
+
sectionId={sectionId}
|
|
666
|
+
isDesigner={isDesigner}
|
|
667
|
+
selectedBlockId={selectedBlockId}
|
|
668
|
+
currentBreakpoint={currentBreakpoint}
|
|
669
|
+
designerPlaceholderCount={4}
|
|
670
|
+
/>
|
|
671
|
+
) : null;
|
|
672
|
+
|
|
673
|
+
// An empty history is the normal state on a first visit, so the strip
|
|
674
|
+
// renders nothing at all rather than an empty carousel — `designerRows` is
|
|
675
|
+
// null on the storefront and this returns before any markup is emitted.
|
|
676
|
+
if (products.length === 0 && !customCards && !designerRows) {
|
|
677
|
+
if (!isDesigner) return null;
|
|
678
|
+
|
|
679
|
+
return (
|
|
680
|
+
<div data-product-recommendations="" style={listingPlaceholderStyle}>
|
|
681
|
+
{!collectionId
|
|
682
|
+
? 'Recommendations (set a collection id)'
|
|
683
|
+
: isFetching
|
|
684
|
+
? 'Recommendations (loading)'
|
|
685
|
+
: 'Recommendations (no products in this collection)'}
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Column count is a block PROPERTY, and properties resolve with the designer
|
|
691
|
+
// breakpoint only — on the live storefront currentBreakpoint is always
|
|
692
|
+
// 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
|
|
693
|
+
// generator uses keeps real mobile viewports in sync with the editor.
|
|
694
|
+
const trackId = String(block.id || 'recommendations').replace(
|
|
695
|
+
/[^a-zA-Z0-9_-]/g,
|
|
696
|
+
''
|
|
697
|
+
);
|
|
698
|
+
const cardWidth = (columns: number) =>
|
|
699
|
+
`calc((100% - ${(columns - 1) * cardGap}px) / ${columns})`;
|
|
700
|
+
|
|
701
|
+
const scrollTrack = (direction: -1 | 1) => {
|
|
702
|
+
const track = trackRef.current;
|
|
703
|
+
if (!track) return;
|
|
704
|
+
track.scrollBy({
|
|
705
|
+
left: direction * track.clientWidth,
|
|
706
|
+
behavior: 'smooth'
|
|
707
|
+
});
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const arrowStyle: React.CSSProperties = {
|
|
711
|
+
display: 'inline-flex',
|
|
712
|
+
alignItems: 'center',
|
|
713
|
+
justifyContent: 'center',
|
|
714
|
+
width: '32px',
|
|
715
|
+
height: '32px',
|
|
716
|
+
padding: 0,
|
|
717
|
+
border: 'none',
|
|
718
|
+
borderRadius: '50%',
|
|
719
|
+
background:
|
|
720
|
+
'var(--product-recommendations-arrow-bg, rgba(255, 255, 255, 0.85))',
|
|
721
|
+
color: 'inherit',
|
|
722
|
+
cursor: 'pointer'
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* What the overlay draws. The row's own summary goes up immediately and the
|
|
727
|
+
* fetched detail takes over as it lands, so opening a card never answers
|
|
728
|
+
* with an empty panel over a slow connection. In the designer the sample
|
|
729
|
+
* product stands in for one the canvas cannot have — without it an author
|
|
730
|
+
* would switch quick view on and find nothing to style.
|
|
731
|
+
*/
|
|
732
|
+
const quickViewProduct: ThemeProduct | null = isDesigner
|
|
733
|
+
? DESIGNER_SAMPLE_PRODUCT
|
|
734
|
+
: quickViewDetail?.product
|
|
735
|
+
? // Through `unknown` deliberately: commerce sends `attributes` as a map
|
|
736
|
+
// while ThemeProduct models it as a list, so the two do not overlap
|
|
737
|
+
// enough for a direct assertion. Nothing on this path reads
|
|
738
|
+
// `attributes` — the fields quick view shows (title, price, images,
|
|
739
|
+
// variants, stock) agree in both shapes.
|
|
740
|
+
(({
|
|
741
|
+
...(quickViewDetail.product as Record<string, unknown>),
|
|
742
|
+
variants: quickViewDetail.variants,
|
|
743
|
+
selectedVariant: quickViewDetail.selected_variant ?? null,
|
|
744
|
+
in_stock: quickViewDetail.in_stock
|
|
745
|
+
} as unknown) as ThemeProduct)
|
|
746
|
+
: null;
|
|
747
|
+
|
|
748
|
+
const quickViewName =
|
|
749
|
+
quickViewCard?.name ||
|
|
750
|
+
toText(pickProductField(quickViewProduct, 'title')) ||
|
|
751
|
+
'';
|
|
752
|
+
const quickViewImage =
|
|
753
|
+
safeUrl(pickProductField(quickViewProduct, 'image')) ||
|
|
754
|
+
safeUrl(quickViewCard?.image);
|
|
755
|
+
const quickViewHref = safeUrl(quickViewCard?.url);
|
|
756
|
+
// Commerce prices each product in its own currency; the storefront currency
|
|
757
|
+
// is only the fallback for payloads that omit it.
|
|
758
|
+
const quickViewCurrency =
|
|
759
|
+
toText(pickProductField(quickViewProduct, 'currency')) ||
|
|
760
|
+
localeCurrency ||
|
|
761
|
+
'';
|
|
762
|
+
const quickViewAmount = pickProductField(quickViewProduct, 'price');
|
|
763
|
+
const quickViewPrice =
|
|
764
|
+
quickViewAmount === undefined
|
|
765
|
+
? quickViewCard?.price || ''
|
|
766
|
+
: formatListingPrice(quickViewAmount, quickViewCurrency, locale);
|
|
767
|
+
const quickViewRetail = parsePriceValue(
|
|
768
|
+
pickProductField(quickViewProduct, 'retail_price')
|
|
769
|
+
);
|
|
770
|
+
const quickViewCurrent = parsePriceValue(quickViewAmount);
|
|
771
|
+
// Nothing is derived: a struck price is only drawn when commerce actually
|
|
772
|
+
// sent a higher one, never computed from a discount the payload implies.
|
|
773
|
+
const quickViewOldPrice =
|
|
774
|
+
quickViewRetail !== null &&
|
|
775
|
+
quickViewCurrent !== null &&
|
|
776
|
+
quickViewRetail > quickViewCurrent
|
|
777
|
+
? formatListingPrice(quickViewRetail, quickViewCurrency, locale)
|
|
778
|
+
: '';
|
|
779
|
+
|
|
780
|
+
const variantGroups = (
|
|
781
|
+
pickProductField<ThemeVariantGroup[]>(quickViewProduct, 'variants') || []
|
|
782
|
+
).filter(
|
|
783
|
+
(group) => Array.isArray(group?.options) && group.options.length > 0
|
|
784
|
+
);
|
|
785
|
+
/**
|
|
786
|
+
* Commerce orders variant groups from the most general to the most specific,
|
|
787
|
+
* and only the LAST group's options are sellable leaves — each carries its
|
|
788
|
+
* own product, and that product's pk is what goes in the basket. An earlier
|
|
789
|
+
* group (colour) is a different product altogether, so picking there is a
|
|
790
|
+
* navigation and belongs on the product page, not in an overlay that adds.
|
|
791
|
+
*/
|
|
792
|
+
const sizeGroup = variantGroups[variantGroups.length - 1] || null;
|
|
793
|
+
const sizeOptions: ThemeVariantOption[] = sizeGroup?.options || [];
|
|
794
|
+
/**
|
|
795
|
+
* The option the shopper actually picked.
|
|
796
|
+
*
|
|
797
|
+
* The empty-string guard is the whole point: `chosenOption` starts '' and an
|
|
798
|
+
* option whose payload carries no `value` also reads '', so an unguarded
|
|
799
|
+
* match selected that leaf on the first render — before the shopper had
|
|
800
|
+
* touched anything. Nothing showed as pressed (the size button has always
|
|
801
|
+
* guarded `value !== ''`), `needsSize` went false, and the add button posted
|
|
802
|
+
* a size nobody chose. The button's guard and this one have to be the same
|
|
803
|
+
* guard, because they are answering the same question.
|
|
804
|
+
*/
|
|
805
|
+
const chosenSize =
|
|
806
|
+
(chosenOption
|
|
807
|
+
? sizeOptions.find((option) => toText(option?.value) === chosenOption)
|
|
808
|
+
: null) || null;
|
|
809
|
+
// Commerce names the attribute ("Beden", "Numara"); the authored label is
|
|
810
|
+
// the fallback for a payload that did not, never an override of it.
|
|
811
|
+
const sizeGroupLabel =
|
|
812
|
+
toText(sizeGroup?.attribute_name) || quickViewSizeLabel;
|
|
813
|
+
|
|
814
|
+
const quickViewStatus = isQuickViewLoading
|
|
815
|
+
? quickViewLoadingLabel
|
|
816
|
+
: isQuickViewFailed
|
|
817
|
+
? quickViewErrorLabel
|
|
818
|
+
: '';
|
|
819
|
+
// The summary carries no stock and no variants, so adding before the detail
|
|
820
|
+
// lands would put a parent product in the basket for something that is only
|
|
821
|
+
// sellable by size. The button waits.
|
|
822
|
+
const isQuickViewReady = quickViewProduct !== null;
|
|
823
|
+
/**
|
|
824
|
+
* The stock of the thing that would actually go in the basket.
|
|
825
|
+
*
|
|
826
|
+
* A card carries ONE pk while the overlay sells whatever LEAF the shopper
|
|
827
|
+
* picked, so asking the card's product answered about a different product in
|
|
828
|
+
* both directions. A card pointing at a size that had sold through declared
|
|
829
|
+
* a product with two sellable sizes dead — the everyday state after a size
|
|
830
|
+
* sells out — and a parent commerce still called in stock let a sold-out
|
|
831
|
+
* leaf reach the add endpoint.
|
|
832
|
+
*
|
|
833
|
+
* With nothing picked yet the parent's own flag still says nothing about the
|
|
834
|
+
* sizes under it, so only "every size is gone" can honestly answer sold out;
|
|
835
|
+
* anything else is `needsSize`, and the button asks for a size.
|
|
836
|
+
*/
|
|
837
|
+
const quickViewInStock = sizeGroup
|
|
838
|
+
? chosenSize
|
|
839
|
+
? !isVariantOptionSoldOut(chosenSize)
|
|
840
|
+
: !sizeOptions.every((option) => isVariantOptionSoldOut(option))
|
|
841
|
+
: isProductInStock(quickViewProduct);
|
|
842
|
+
const needsSize = sizeGroup !== null && chosenSize === null;
|
|
843
|
+
const quickViewAddPk = sizeGroup
|
|
844
|
+
? toProductPk(chosenSize?.product?.pk)
|
|
845
|
+
: toProductPk(pickProductField(quickViewProduct, 'pk')) ||
|
|
846
|
+
toProductPk(quickViewCard?.pk);
|
|
847
|
+
const quickViewAddDisabled =
|
|
848
|
+
!isQuickViewReady ||
|
|
849
|
+
isAdding ||
|
|
850
|
+
!quickViewInStock ||
|
|
851
|
+
needsSize ||
|
|
852
|
+
// The designer has no real id behind the sample, and a permanently dead
|
|
853
|
+
// button is not something an author can judge their styling against.
|
|
854
|
+
(!isDesigner && quickViewAddPk === undefined);
|
|
855
|
+
|
|
856
|
+
const quickViewAddText = !isQuickViewReady
|
|
857
|
+
? // Nothing is known yet, and "Out of stock" is a claim rather than a
|
|
858
|
+
// silence — the status line is what reports the wait.
|
|
859
|
+
quickViewAddLabel
|
|
860
|
+
: !quickViewInStock
|
|
861
|
+
? quickViewOutOfStockLabel
|
|
862
|
+
: isAdding
|
|
863
|
+
? quickViewAddingLabel
|
|
864
|
+
: addFeedback === 'added'
|
|
865
|
+
? quickViewAddedLabel
|
|
866
|
+
: addFeedback === 'error'
|
|
867
|
+
? quickViewAddErrorLabel
|
|
868
|
+
: needsSize
|
|
869
|
+
? quickViewSelectSizeLabel
|
|
870
|
+
: quickViewAddLabel;
|
|
871
|
+
|
|
872
|
+
const openQuickView = (
|
|
873
|
+
card: RecommendationCard,
|
|
874
|
+
trigger: HTMLElement | null
|
|
875
|
+
) => {
|
|
876
|
+
quickViewOpenerRef.current = trigger;
|
|
877
|
+
setChosenOption('');
|
|
878
|
+
setAddFeedback('idle');
|
|
879
|
+
setQuickViewCard(card);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const handleQuickViewAdd = async () => {
|
|
883
|
+
// A click inside the designer must never reach the shopper's real basket,
|
|
884
|
+
// and the feedback timer below must never run on the canvas.
|
|
885
|
+
if (isDesigner || quickViewAddDisabled || quickViewAddPk === undefined) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
await addProduct({
|
|
891
|
+
product: quickViewAddPk,
|
|
892
|
+
quantity: 1,
|
|
893
|
+
attributes: {}
|
|
894
|
+
}).unwrap();
|
|
895
|
+
setAddFeedback('added');
|
|
896
|
+
} catch {
|
|
897
|
+
setAddFeedback('error');
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (addFeedbackTimer.current) clearTimeout(addFeedbackTimer.current);
|
|
901
|
+
addFeedbackTimer.current = setTimeout(
|
|
902
|
+
() => setAddFeedback('idle'),
|
|
903
|
+
quickViewFeedbackDuration
|
|
904
|
+
);
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const quickViewButtonStyle: React.CSSProperties = {
|
|
908
|
+
border: '1px solid var(--product-recommendations-quick-view-border, #d1d5db)',
|
|
909
|
+
background: 'transparent',
|
|
910
|
+
color: 'inherit',
|
|
911
|
+
font: 'inherit',
|
|
912
|
+
cursor: 'pointer'
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
return (
|
|
916
|
+
<div
|
|
917
|
+
ref={rootRef}
|
|
918
|
+
data-product-recommendations=""
|
|
919
|
+
data-product-recommendations-id={trackId}
|
|
920
|
+
// The overlay's last-resort focus target, and a plain div refuses focus:
|
|
921
|
+
// without this the fallback silently did nothing and a shopper whose
|
|
922
|
+
// trigger card had gone was dropped at the top of the document. `-1`
|
|
923
|
+
// keeps it out of the Tab order, and it is only emitted where the
|
|
924
|
+
// overlay exists at all, so a strip without quick view is untouched.
|
|
925
|
+
tabIndex={showQuickView ? -1 : undefined}
|
|
926
|
+
>
|
|
927
|
+
<style>
|
|
928
|
+
{`[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
|
|
929
|
+
columnsDesktop
|
|
930
|
+
)};}` +
|
|
931
|
+
`@media (max-width:1023px){[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
|
|
932
|
+
columnsMobile
|
|
933
|
+
)};}}`}
|
|
934
|
+
</style>
|
|
935
|
+
|
|
936
|
+
{title && (
|
|
937
|
+
<TitleTag
|
|
938
|
+
data-product-recommendations-title=""
|
|
939
|
+
{...partAttrs('title')}
|
|
940
|
+
style={{ marginBottom: '12px', fontWeight: 600 }}
|
|
941
|
+
>
|
|
942
|
+
{title}
|
|
943
|
+
</TitleTag>
|
|
944
|
+
)}
|
|
945
|
+
|
|
946
|
+
{showArrows && (
|
|
947
|
+
<div
|
|
948
|
+
data-product-recommendations-arrows=""
|
|
949
|
+
{...partAttrs('arrow-list')}
|
|
950
|
+
style={{
|
|
951
|
+
display: 'flex',
|
|
952
|
+
justifyContent: 'flex-end',
|
|
953
|
+
gap: '8px',
|
|
954
|
+
marginBottom: '8px'
|
|
955
|
+
}}
|
|
956
|
+
>
|
|
957
|
+
<button
|
|
958
|
+
type="button"
|
|
959
|
+
data-product-recommendations-arrow="prev"
|
|
960
|
+
{...partAttrs('arrow')}
|
|
961
|
+
aria-label={prevLabel}
|
|
962
|
+
onClick={() => scrollTrack(-1)}
|
|
963
|
+
style={arrowStyle}
|
|
964
|
+
>
|
|
965
|
+
<ChevronIcon direction="prev" />
|
|
966
|
+
</button>
|
|
967
|
+
<button
|
|
968
|
+
type="button"
|
|
969
|
+
data-product-recommendations-arrow="next"
|
|
970
|
+
{...partAttrs('arrow')}
|
|
971
|
+
aria-label={nextLabel}
|
|
972
|
+
onClick={() => scrollTrack(1)}
|
|
973
|
+
style={arrowStyle}
|
|
974
|
+
>
|
|
975
|
+
<ChevronIcon direction="next" />
|
|
976
|
+
</button>
|
|
977
|
+
</div>
|
|
978
|
+
)}
|
|
979
|
+
|
|
980
|
+
<div
|
|
981
|
+
ref={trackRef}
|
|
982
|
+
data-product-recommendations-track=""
|
|
983
|
+
{...partAttrs('track')}
|
|
984
|
+
style={{
|
|
985
|
+
display: 'grid',
|
|
986
|
+
gridAutoFlow: 'column',
|
|
987
|
+
gap: `var(--product-recommendations-gap, ${cardGap}px)`,
|
|
988
|
+
overflowX: 'auto',
|
|
989
|
+
scrollSnapType: 'x mandatory'
|
|
990
|
+
}}
|
|
991
|
+
>
|
|
992
|
+
{customCards ??
|
|
993
|
+
(designerRows ?? products).map((product) => {
|
|
994
|
+
const card = (
|
|
995
|
+
<NextLink
|
|
996
|
+
key={product.key}
|
|
997
|
+
// NextLink warns when href resolves to undefined, so a card whose
|
|
998
|
+
// product url the collection omitted still gets a resolved value.
|
|
999
|
+
href={isDesigner ? '#' : product.url || '#'}
|
|
1000
|
+
data-product-card=""
|
|
1001
|
+
{...partAttrs('card')}
|
|
1002
|
+
// Navigating inside the designer iframe would drop the author out
|
|
1003
|
+
// of the page they are editing, so cards are inert there.
|
|
1004
|
+
onClick={(event) => {
|
|
1005
|
+
if (isDesigner) event.preventDefault();
|
|
1006
|
+
}}
|
|
1007
|
+
style={{
|
|
1008
|
+
display: 'flex',
|
|
1009
|
+
flexDirection: 'column',
|
|
1010
|
+
gap: '8px',
|
|
1011
|
+
color: 'inherit',
|
|
1012
|
+
textDecoration: 'none',
|
|
1013
|
+
scrollSnapAlign: 'start'
|
|
1014
|
+
}}
|
|
1015
|
+
>
|
|
1016
|
+
{product.image ? (
|
|
1017
|
+
/* Plain img: collection thumbnails come from arbitrary commerce
|
|
1018
|
+
CDNs that the brand next.config may not allow. */
|
|
1019
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
1020
|
+
<img
|
|
1021
|
+
src={product.image}
|
|
1022
|
+
alt={product.name}
|
|
1023
|
+
loading="lazy"
|
|
1024
|
+
data-product-card-image=""
|
|
1025
|
+
{...partAttrs('card-image')}
|
|
1026
|
+
style={{
|
|
1027
|
+
width: '100%',
|
|
1028
|
+
height: 'auto',
|
|
1029
|
+
aspectRatio: imageRatio || undefined,
|
|
1030
|
+
display: 'block',
|
|
1031
|
+
objectFit: 'cover'
|
|
1032
|
+
}}
|
|
1033
|
+
/>
|
|
1034
|
+
) : (
|
|
1035
|
+
<span
|
|
1036
|
+
data-product-card-image=""
|
|
1037
|
+
{...partAttrs('card-image')}
|
|
1038
|
+
style={{
|
|
1039
|
+
display: 'block',
|
|
1040
|
+
width: '100%',
|
|
1041
|
+
aspectRatio: imageRatio || '3 / 4',
|
|
1042
|
+
background: '#f1f5f9'
|
|
1043
|
+
}}
|
|
1044
|
+
/>
|
|
1045
|
+
)}
|
|
1046
|
+
|
|
1047
|
+
{showName && (
|
|
1048
|
+
<span data-product-card-name="" {...partAttrs('card-name')}>
|
|
1049
|
+
{product.name}
|
|
1050
|
+
</span>
|
|
1051
|
+
)}
|
|
1052
|
+
|
|
1053
|
+
{showPrice && product.price && (
|
|
1054
|
+
<span data-product-card-price="" {...partAttrs('card-price')}>
|
|
1055
|
+
{product.price}
|
|
1056
|
+
</span>
|
|
1057
|
+
)}
|
|
1058
|
+
</NextLink>
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
// Untouched unless the author asked for a trigger: a published
|
|
1062
|
+
// strip has never had a wrapper around its cards, and the card is
|
|
1063
|
+
// an anchor, which may not contain a button.
|
|
1064
|
+
if (!showQuickView) return card;
|
|
1065
|
+
|
|
1066
|
+
// Storefront-side a card whose payload carried no usable id cannot
|
|
1067
|
+
// be opened, so no dead trigger is drawn over it. The designer
|
|
1068
|
+
// keeps it, so placeholder rows stay stylable.
|
|
1069
|
+
const canQuickView = isDesigner || product.pk !== undefined;
|
|
1070
|
+
const isThisCardOpen = quickViewCard?.key === product.key;
|
|
1071
|
+
|
|
1072
|
+
return (
|
|
1073
|
+
<div
|
|
1074
|
+
key={product.key}
|
|
1075
|
+
data-product-card-slot=""
|
|
1076
|
+
{...partAttrs('card-slot', { open: isThisCardOpen })}
|
|
1077
|
+
style={{ position: 'relative', scrollSnapAlign: 'start' }}
|
|
1078
|
+
>
|
|
1079
|
+
{card}
|
|
1080
|
+
{canQuickView && (
|
|
1081
|
+
<button
|
|
1082
|
+
type="button"
|
|
1083
|
+
data-product-card-quick-view=""
|
|
1084
|
+
{...partAttrs('quick-view-trigger', {
|
|
1085
|
+
open: isThisCardOpen
|
|
1086
|
+
})}
|
|
1087
|
+
aria-haspopup="dialog"
|
|
1088
|
+
aria-expanded={isThisCardOpen}
|
|
1089
|
+
onClick={(event) =>
|
|
1090
|
+
openQuickView(product, event.currentTarget)
|
|
1091
|
+
}
|
|
1092
|
+
style={{
|
|
1093
|
+
position: 'absolute',
|
|
1094
|
+
left: '50%',
|
|
1095
|
+
bottom: '8px',
|
|
1096
|
+
transform: 'translateX(-50%)',
|
|
1097
|
+
display: 'inline-flex',
|
|
1098
|
+
alignItems: 'center',
|
|
1099
|
+
gap: '6px',
|
|
1100
|
+
padding: '6px 12px',
|
|
1101
|
+
border: 'none',
|
|
1102
|
+
background:
|
|
1103
|
+
'var(--product-recommendations-quick-view-bg, rgba(255, 255, 255, 0.92))',
|
|
1104
|
+
color: 'inherit',
|
|
1105
|
+
font: 'inherit',
|
|
1106
|
+
cursor: 'pointer'
|
|
1107
|
+
}}
|
|
1108
|
+
>
|
|
1109
|
+
<QuickViewIcon name={quickViewTriggerIcon} />
|
|
1110
|
+
<span>{quickViewTriggerLabel}</span>
|
|
1111
|
+
</button>
|
|
1112
|
+
)}
|
|
1113
|
+
</div>
|
|
1114
|
+
);
|
|
1115
|
+
})}
|
|
1116
|
+
</div>
|
|
1117
|
+
|
|
1118
|
+
{quickViewOpen && quickViewCard && (
|
|
1119
|
+
<div
|
|
1120
|
+
data-product-recommendations-quick-view=""
|
|
1121
|
+
style={{
|
|
1122
|
+
position: 'fixed',
|
|
1123
|
+
inset: 0,
|
|
1124
|
+
zIndex: 1000,
|
|
1125
|
+
display: 'flex',
|
|
1126
|
+
padding: '16px',
|
|
1127
|
+
// With the backdrop off nothing is drawn over the page, but this
|
|
1128
|
+
// root still covers the whole viewport — it would swallow every
|
|
1129
|
+
// click as an invisible sheet of glass. Letting events through it
|
|
1130
|
+
// (and re-enabling them on the panel) keeps the page alive.
|
|
1131
|
+
pointerEvents: showQuickViewBackdrop ? undefined : 'none'
|
|
1132
|
+
}}
|
|
1133
|
+
>
|
|
1134
|
+
{showQuickViewBackdrop && (
|
|
1135
|
+
<div
|
|
1136
|
+
data-product-card-quick-view-backdrop=""
|
|
1137
|
+
{...partAttrs('quick-view-backdrop', { open: true })}
|
|
1138
|
+
onClick={() => {
|
|
1139
|
+
if (quickViewCloseOnBackdrop) closeQuickView();
|
|
1140
|
+
}}
|
|
1141
|
+
style={{
|
|
1142
|
+
position: 'absolute',
|
|
1143
|
+
inset: 0,
|
|
1144
|
+
background: 'rgba(0, 0, 0, 0.5)'
|
|
1145
|
+
}}
|
|
1146
|
+
/>
|
|
1147
|
+
)}
|
|
1148
|
+
|
|
1149
|
+
<div
|
|
1150
|
+
{...overlay.overlayProps}
|
|
1151
|
+
data-product-card-quick-view-panel=""
|
|
1152
|
+
{...partAttrs('quick-view-panel', {
|
|
1153
|
+
open: true,
|
|
1154
|
+
loading: isQuickViewLoading
|
|
1155
|
+
})}
|
|
1156
|
+
role="dialog"
|
|
1157
|
+
aria-label={quickViewName || quickViewTriggerLabel}
|
|
1158
|
+
aria-busy={isQuickViewLoading}
|
|
1159
|
+
// Focus target of last resort, for an overlay whose every control
|
|
1160
|
+
// the designer turned off.
|
|
1161
|
+
tabIndex={-1}
|
|
1162
|
+
style={{
|
|
1163
|
+
position: 'relative',
|
|
1164
|
+
// `margin: auto` inside the flex root is what centres it; the
|
|
1165
|
+
// backdrop is out of flow and does not compete.
|
|
1166
|
+
margin: 'auto',
|
|
1167
|
+
display: 'flex',
|
|
1168
|
+
flexWrap: 'wrap',
|
|
1169
|
+
gap: '20px',
|
|
1170
|
+
width: '100%',
|
|
1171
|
+
maxWidth: `${quickViewWidth}px`,
|
|
1172
|
+
maxHeight: '85vh',
|
|
1173
|
+
overflowY: 'auto',
|
|
1174
|
+
padding: '20px',
|
|
1175
|
+
background:
|
|
1176
|
+
'var(--product-recommendations-quick-view-panel-bg, #ffffff)',
|
|
1177
|
+
color: 'inherit',
|
|
1178
|
+
textAlign: 'left',
|
|
1179
|
+
outline: 'none',
|
|
1180
|
+
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
|
|
1181
|
+
pointerEvents: showQuickViewBackdrop ? undefined : 'auto'
|
|
1182
|
+
}}
|
|
1183
|
+
>
|
|
1184
|
+
{quickViewCloseVisible && (
|
|
1185
|
+
<button
|
|
1186
|
+
type="button"
|
|
1187
|
+
data-product-card-quick-view-close=""
|
|
1188
|
+
{...partAttrs('quick-view-close')}
|
|
1189
|
+
aria-label={quickViewCloseLabel}
|
|
1190
|
+
onClick={closeQuickView}
|
|
1191
|
+
style={{
|
|
1192
|
+
position: 'absolute',
|
|
1193
|
+
top: '12px',
|
|
1194
|
+
right: '12px',
|
|
1195
|
+
display: 'inline-flex',
|
|
1196
|
+
padding: '4px',
|
|
1197
|
+
border: 'none',
|
|
1198
|
+
background: 'none',
|
|
1199
|
+
color: 'inherit',
|
|
1200
|
+
cursor: 'pointer'
|
|
1201
|
+
}}
|
|
1202
|
+
>
|
|
1203
|
+
<CloseIcon />
|
|
1204
|
+
</button>
|
|
1205
|
+
)}
|
|
1206
|
+
|
|
1207
|
+
{quickViewImage && (
|
|
1208
|
+
/* Plain img, for the same reason the card uses one: commerce
|
|
1209
|
+
thumbnails come from CDNs the brand next.config may not list. */
|
|
1210
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
1211
|
+
<img
|
|
1212
|
+
src={quickViewImage}
|
|
1213
|
+
alt={quickViewName}
|
|
1214
|
+
data-product-card-quick-view-image=""
|
|
1215
|
+
{...partAttrs('quick-view-media')}
|
|
1216
|
+
style={{
|
|
1217
|
+
flex: '1 1 220px',
|
|
1218
|
+
minWidth: 0,
|
|
1219
|
+
maxWidth: '100%',
|
|
1220
|
+
aspectRatio: imageRatio || '3 / 4',
|
|
1221
|
+
objectFit: 'cover'
|
|
1222
|
+
}}
|
|
1223
|
+
/>
|
|
1224
|
+
)}
|
|
1225
|
+
|
|
1226
|
+
<div
|
|
1227
|
+
data-product-card-quick-view-body=""
|
|
1228
|
+
{...partAttrs('quick-view-body')}
|
|
1229
|
+
style={{
|
|
1230
|
+
flex: '1 1 240px',
|
|
1231
|
+
minWidth: 0,
|
|
1232
|
+
display: 'flex',
|
|
1233
|
+
flexDirection: 'column',
|
|
1234
|
+
gap: '12px'
|
|
1235
|
+
}}
|
|
1236
|
+
>
|
|
1237
|
+
{quickViewName && (
|
|
1238
|
+
<span
|
|
1239
|
+
data-product-card-quick-view-name=""
|
|
1240
|
+
{...partAttrs('quick-view-name')}
|
|
1241
|
+
style={{ fontWeight: 600 }}
|
|
1242
|
+
>
|
|
1243
|
+
{quickViewName}
|
|
1244
|
+
</span>
|
|
1245
|
+
)}
|
|
1246
|
+
|
|
1247
|
+
{quickViewPrice && (
|
|
1248
|
+
<span
|
|
1249
|
+
data-product-card-quick-view-price=""
|
|
1250
|
+
{...partAttrs('quick-view-price')}
|
|
1251
|
+
>
|
|
1252
|
+
{quickViewPrice}
|
|
1253
|
+
{quickViewOldPrice && (
|
|
1254
|
+
<span
|
|
1255
|
+
data-product-card-quick-view-old-price=""
|
|
1256
|
+
{...partAttrs('quick-view-old-price')}
|
|
1257
|
+
style={{
|
|
1258
|
+
marginLeft: '8px',
|
|
1259
|
+
opacity: 0.6,
|
|
1260
|
+
textDecoration: 'line-through'
|
|
1261
|
+
}}
|
|
1262
|
+
>
|
|
1263
|
+
{quickViewOldPrice}
|
|
1264
|
+
</span>
|
|
1265
|
+
)}
|
|
1266
|
+
</span>
|
|
1267
|
+
)}
|
|
1268
|
+
|
|
1269
|
+
{quickViewStatus && (
|
|
1270
|
+
<span
|
|
1271
|
+
data-product-card-quick-view-status=""
|
|
1272
|
+
{...partAttrs('quick-view-status', {
|
|
1273
|
+
loading: isQuickViewLoading,
|
|
1274
|
+
invalid: isQuickViewFailed
|
|
1275
|
+
})}
|
|
1276
|
+
// The panel opens before the request settles, so the outcome
|
|
1277
|
+
// has to be announced rather than silently swapped in.
|
|
1278
|
+
aria-live="polite"
|
|
1279
|
+
style={{ opacity: 0.7 }}
|
|
1280
|
+
>
|
|
1281
|
+
{quickViewStatus}
|
|
1282
|
+
</span>
|
|
1283
|
+
)}
|
|
1284
|
+
|
|
1285
|
+
{isQuickViewFailed && (
|
|
1286
|
+
<button
|
|
1287
|
+
type="button"
|
|
1288
|
+
data-product-card-quick-view-retry=""
|
|
1289
|
+
{...partAttrs('quick-view-retry')}
|
|
1290
|
+
onClick={() => {
|
|
1291
|
+
// Nothing was requested in the designer, so there is
|
|
1292
|
+
// nothing to request again.
|
|
1293
|
+
if (isDesigner) return;
|
|
1294
|
+
refetchQuickView();
|
|
1295
|
+
}}
|
|
1296
|
+
style={{ ...quickViewButtonStyle, padding: '8px 12px' }}
|
|
1297
|
+
>
|
|
1298
|
+
{quickViewRetryLabel}
|
|
1299
|
+
</button>
|
|
1300
|
+
)}
|
|
1301
|
+
|
|
1302
|
+
{sizeOptions.length > 0 && (
|
|
1303
|
+
<>
|
|
1304
|
+
<span
|
|
1305
|
+
data-product-card-quick-view-size-label=""
|
|
1306
|
+
{...partAttrs('quick-view-size-label')}
|
|
1307
|
+
>
|
|
1308
|
+
{sizeGroupLabel}
|
|
1309
|
+
</span>
|
|
1310
|
+
<div
|
|
1311
|
+
data-product-card-quick-view-sizes=""
|
|
1312
|
+
{...partAttrs('quick-view-size-list')}
|
|
1313
|
+
role="group"
|
|
1314
|
+
aria-label={sizeGroupLabel}
|
|
1315
|
+
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}
|
|
1316
|
+
>
|
|
1317
|
+
{sizeOptions.map((option, index) => {
|
|
1318
|
+
const value = toText(option?.value);
|
|
1319
|
+
const label = toText(option?.label) || value;
|
|
1320
|
+
const isChosen = value !== '' && value === chosenOption;
|
|
1321
|
+
const isSoldOut = isVariantOptionSoldOut(option);
|
|
1322
|
+
/**
|
|
1323
|
+
* A leaf with no product behind it cannot be added; in
|
|
1324
|
+
* the designer the sample carries none by design, so the
|
|
1325
|
+
* author still sees a real row of pickable sizes. A leaf
|
|
1326
|
+
* with no STOCK behind it cannot be added either, and
|
|
1327
|
+
* offering it is how a sold-out size reached the basket
|
|
1328
|
+
* — unless commerce explicitly says this one may still
|
|
1329
|
+
* be picked, which is how a shopper gets to see WHICH
|
|
1330
|
+
* size is gone rather than a row that simply omits it.
|
|
1331
|
+
*/
|
|
1332
|
+
const isUnavailable =
|
|
1333
|
+
option?.is_selectable === false ||
|
|
1334
|
+
(isSoldOut &&
|
|
1335
|
+
option?.is_selectable_without_stock !== true) ||
|
|
1336
|
+
// A leaf the payload gave no value to cannot be named
|
|
1337
|
+
// by `chosenOption`, so picking it could never take
|
|
1338
|
+
// effect — offering it would be a control whose click
|
|
1339
|
+
// does nothing.
|
|
1340
|
+
value === '' ||
|
|
1341
|
+
(!isDesigner &&
|
|
1342
|
+
toProductPk(option?.product?.pk) === undefined);
|
|
1343
|
+
|
|
1344
|
+
return (
|
|
1345
|
+
<button
|
|
1346
|
+
type="button"
|
|
1347
|
+
key={value || `quick-view-size-${index}`}
|
|
1348
|
+
data-product-card-quick-view-size={value}
|
|
1349
|
+
{...partAttrs('quick-view-size', {
|
|
1350
|
+
selected: isChosen,
|
|
1351
|
+
disabled: isUnavailable,
|
|
1352
|
+
// Sold out is not the same fact as unpickable: the
|
|
1353
|
+
// one size a shopper is still allowed to select
|
|
1354
|
+
// and cannot buy has to be stylable as gone.
|
|
1355
|
+
unavailable: isSoldOut
|
|
1356
|
+
})}
|
|
1357
|
+
aria-pressed={isChosen}
|
|
1358
|
+
disabled={isUnavailable}
|
|
1359
|
+
onClick={() => setChosenOption(value)}
|
|
1360
|
+
style={{
|
|
1361
|
+
...quickViewButtonStyle,
|
|
1362
|
+
padding: '6px 12px',
|
|
1363
|
+
cursor: isUnavailable ? 'not-allowed' : 'pointer',
|
|
1364
|
+
opacity: isUnavailable ? 0.45 : 1,
|
|
1365
|
+
fontWeight: isChosen ? 600 : 400
|
|
1366
|
+
}}
|
|
1367
|
+
>
|
|
1368
|
+
{label}
|
|
1369
|
+
</button>
|
|
1370
|
+
);
|
|
1371
|
+
})}
|
|
1372
|
+
</div>
|
|
1373
|
+
</>
|
|
1374
|
+
)}
|
|
1375
|
+
|
|
1376
|
+
<button
|
|
1377
|
+
type="button"
|
|
1378
|
+
data-product-card-quick-view-add=""
|
|
1379
|
+
data-product-card-quick-view-add-state={
|
|
1380
|
+
!isQuickViewReady
|
|
1381
|
+
? 'pending'
|
|
1382
|
+
: !quickViewInStock
|
|
1383
|
+
? 'out-of-stock'
|
|
1384
|
+
: isAdding
|
|
1385
|
+
? 'busy'
|
|
1386
|
+
: addFeedback
|
|
1387
|
+
}
|
|
1388
|
+
{...partAttrs('quick-view-add', {
|
|
1389
|
+
loading: isAdding,
|
|
1390
|
+
disabled: quickViewAddDisabled
|
|
1391
|
+
})}
|
|
1392
|
+
disabled={quickViewAddDisabled}
|
|
1393
|
+
aria-busy={isAdding}
|
|
1394
|
+
onClick={handleQuickViewAdd}
|
|
1395
|
+
style={{
|
|
1396
|
+
padding: '12px 16px',
|
|
1397
|
+
border: 'none',
|
|
1398
|
+
background:
|
|
1399
|
+
'var(--product-recommendations-quick-view-add-bg, #111827)',
|
|
1400
|
+
color:
|
|
1401
|
+
'var(--product-recommendations-quick-view-add-color, #ffffff)',
|
|
1402
|
+
font: 'inherit',
|
|
1403
|
+
cursor: quickViewAddDisabled ? 'not-allowed' : 'pointer',
|
|
1404
|
+
opacity: quickViewAddDisabled ? 0.6 : 1
|
|
1405
|
+
}}
|
|
1406
|
+
>
|
|
1407
|
+
{quickViewAddText}
|
|
1408
|
+
</button>
|
|
1409
|
+
|
|
1410
|
+
{showQuickViewLink && (isDesigner || quickViewHref) && (
|
|
1411
|
+
<NextLink
|
|
1412
|
+
// Navigating inside the designer iframe would drop the author
|
|
1413
|
+
// out of the page they are editing, so the link is inert.
|
|
1414
|
+
href={isDesigner ? '#' : quickViewHref}
|
|
1415
|
+
data-product-card-quick-view-link=""
|
|
1416
|
+
{...partAttrs('quick-view-link')}
|
|
1417
|
+
onClick={(event) => {
|
|
1418
|
+
if (isDesigner) event.preventDefault();
|
|
1419
|
+
}}
|
|
1420
|
+
style={{ color: 'inherit' }}
|
|
1421
|
+
>
|
|
1422
|
+
{quickViewLinkLabel}
|
|
1423
|
+
</NextLink>
|
|
1424
|
+
)}
|
|
1425
|
+
</div>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
)}
|
|
1429
|
+
</div>
|
|
1430
|
+
);
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
export default ProductRecommendationsBlock;
|