@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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,1184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
useGetRetailStoreQuery,
|
|
12
|
+
useGetStoresQuery
|
|
13
|
+
} from '@akinon/next/data/client/address';
|
|
14
|
+
import { useGetRetailStoreStockMutation } from '@akinon/next/data/client/product';
|
|
15
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
16
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
17
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
18
|
+
import { formatDecimal } from './commerce-format';
|
|
19
|
+
import {
|
|
20
|
+
pickProductField,
|
|
21
|
+
readToggle,
|
|
22
|
+
useThemeProduct
|
|
23
|
+
} from './product-block-utils';
|
|
24
|
+
import type {
|
|
25
|
+
ThemeProduct,
|
|
26
|
+
ThemeVariantGroup
|
|
27
|
+
} from './product-block-utils';
|
|
28
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
29
|
+
import { listingPlaceholderStyle } from './listing-context';
|
|
30
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
31
|
+
|
|
32
|
+
interface StoreLike {
|
|
33
|
+
pk?: number | string;
|
|
34
|
+
name?: string;
|
|
35
|
+
address?: string;
|
|
36
|
+
phone_number?: string;
|
|
37
|
+
latitude?: string | number;
|
|
38
|
+
longitude?: string | number;
|
|
39
|
+
city?: { name?: string } | string | null;
|
|
40
|
+
township?: { name?: string; city?: { name?: string } | null } | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One row of `GET /retail_store_stock/{productPk}/?city_id=…&size=…`.
|
|
45
|
+
*
|
|
46
|
+
* Commerce answers with the store's own fields plus a stock count, and NO
|
|
47
|
+
* store pk — which is why rows are matched back onto the store list by name
|
|
48
|
+
* and by coordinates rather than by id.
|
|
49
|
+
*/
|
|
50
|
+
interface StoreStockRow {
|
|
51
|
+
name?: string;
|
|
52
|
+
address?: string;
|
|
53
|
+
latitude?: string | number;
|
|
54
|
+
longitude?: string | number;
|
|
55
|
+
stock?: string | number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RetailStoreCity {
|
|
59
|
+
pk?: number | string;
|
|
60
|
+
name?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 'unknown' is a first-class answer, not a fallback for 'unavailable'. The
|
|
65
|
+
* stock endpoint is scoped to ONE city and needs a product; outside that scope
|
|
66
|
+
* commerce has told us nothing, and a shopper who reads "not in stock" for a
|
|
67
|
+
* store nobody asked about is being sent away for no reason.
|
|
68
|
+
*/
|
|
69
|
+
type AvailabilityState = 'available' | 'unavailable' | 'unknown';
|
|
70
|
+
|
|
71
|
+
const ALL_CITIES = '';
|
|
72
|
+
const ALLOWED_DISPLAY_MODES = ['inline', 'trigger'];
|
|
73
|
+
const ALLOWED_PANEL_PLACEMENTS = ['center', 'right'];
|
|
74
|
+
const ALLOWED_DISTANCE_UNITS = ['km', 'mi'];
|
|
75
|
+
|
|
76
|
+
const EARTH_RADIUS_KM = 6371;
|
|
77
|
+
const MILES_PER_KM = 0.621371;
|
|
78
|
+
|
|
79
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript):/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The "check availability" route is typed by a merchant and ends up in an
|
|
83
|
+
* href. Browsers strip whitespace and control characters while parsing a url,
|
|
84
|
+
* so a tab wedged into "javascript:" still resolves to a live handler; the
|
|
85
|
+
* value is normalised the same way before it is judged. A rejected url is
|
|
86
|
+
* dropped rather than escaped, which leaves the label as plain text — the same
|
|
87
|
+
* place an unset url lands.
|
|
88
|
+
*/
|
|
89
|
+
const safeHelpHref = (value: string): string => {
|
|
90
|
+
const trimmed = String(value || '').trim();
|
|
91
|
+
if (!trimmed) return '';
|
|
92
|
+
|
|
93
|
+
const normalized = Array.from(trimmed)
|
|
94
|
+
.filter((character) => character.charCodeAt(0) > 32)
|
|
95
|
+
.join('')
|
|
96
|
+
.toLowerCase();
|
|
97
|
+
|
|
98
|
+
return SCRIPT_SCHEME.test(normalized) ? '' : trimmed;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Commerce nests the city under the township on retail stores, but some brands
|
|
103
|
+
* flatten it onto the record. Both shapes are probed so the filter is populated
|
|
104
|
+
* either way instead of silently offering a single empty option.
|
|
105
|
+
*/
|
|
106
|
+
const resolveCity = (store: StoreLike): string => {
|
|
107
|
+
const nested = store.township?.city?.name;
|
|
108
|
+
if (typeof nested === 'string' && nested.trim()) return nested.trim();
|
|
109
|
+
|
|
110
|
+
const flat = store.city;
|
|
111
|
+
if (typeof flat === 'string' && flat.trim()) return flat.trim();
|
|
112
|
+
if (flat && typeof flat === 'object' && typeof flat.name === 'string') {
|
|
113
|
+
return flat.name.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return '';
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Only a complete coordinate pair produces a usable map link. */
|
|
120
|
+
const mapHref = (store: StoreLike): string => {
|
|
121
|
+
const lat = Number(store.latitude);
|
|
122
|
+
const lng = Number(store.longitude);
|
|
123
|
+
|
|
124
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return '';
|
|
125
|
+
if (lat === 0 && lng === 0) return '';
|
|
126
|
+
|
|
127
|
+
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const clampColumns = (value: unknown, fallback: number) => {
|
|
131
|
+
const numeric = Math.round(Number(value));
|
|
132
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
133
|
+
return Math.min(4, Math.max(1, numeric));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The store list and the stock answer are two different commerce records of
|
|
138
|
+
* the same shop, typed by two different teams — "Kanyon AVM" against
|
|
139
|
+
* "Kanyon Avm.". Casing, spacing and the separators brands use are folded away
|
|
140
|
+
* so the two still meet; anything more aggressive would start merging shops.
|
|
141
|
+
*/
|
|
142
|
+
const normalizeStoreKey = (value: unknown, locale: string): string =>
|
|
143
|
+
String(value || '')
|
|
144
|
+
.toLocaleLowerCase(locale || undefined)
|
|
145
|
+
.replace(/[\s.,;:/\\_-]+/g, ' ')
|
|
146
|
+
.trim();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A coordinate bucket, used only when the names disagree. Rounded to ~11m so
|
|
150
|
+
* two records of the same shop still land together; a bucket is dropped
|
|
151
|
+
* entirely when either half is missing or the pair is the null island, which
|
|
152
|
+
* is what commerce sends for a store nobody geocoded.
|
|
153
|
+
*/
|
|
154
|
+
const coordinateKey = (lat: unknown, lng: unknown): string => {
|
|
155
|
+
const a = Number(lat);
|
|
156
|
+
const b = Number(lng);
|
|
157
|
+
|
|
158
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
|
|
159
|
+
if (a === 0 && b === 0) return '';
|
|
160
|
+
|
|
161
|
+
return `${Math.round(a * 1e4)},${Math.round(b * 1e4)}`;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180;
|
|
165
|
+
|
|
166
|
+
/** Great-circle distance in km, or null unless both points are complete. */
|
|
167
|
+
const distanceInKm = (
|
|
168
|
+
origin: { lat: number; lng: number } | null,
|
|
169
|
+
store: StoreLike
|
|
170
|
+
): number | null => {
|
|
171
|
+
if (!origin) return null;
|
|
172
|
+
if (!coordinateKey(store.latitude, store.longitude)) return null;
|
|
173
|
+
|
|
174
|
+
const lat = Number(store.latitude);
|
|
175
|
+
const lng = Number(store.longitude);
|
|
176
|
+
const deltaLat = toRadians(lat - origin.lat);
|
|
177
|
+
const deltaLng = toRadians(lng - origin.lng);
|
|
178
|
+
const a =
|
|
179
|
+
Math.sin(deltaLat / 2) ** 2 +
|
|
180
|
+
Math.cos(toRadians(origin.lat)) *
|
|
181
|
+
Math.cos(toRadians(lat)) *
|
|
182
|
+
Math.sin(deltaLng / 2) ** 2;
|
|
183
|
+
|
|
184
|
+
return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A stock count commerce did not send, or sent as something unparseable, is
|
|
189
|
+
* NOT a zero. Only a real number decides between available and unavailable.
|
|
190
|
+
*/
|
|
191
|
+
const readStockState = (row: StoreStockRow | undefined): AvailabilityState => {
|
|
192
|
+
if (!row) return 'unknown';
|
|
193
|
+
|
|
194
|
+
const numeric = Number.parseInt(String(row.stock ?? ''), 10);
|
|
195
|
+
if (!Number.isFinite(numeric)) return 'unknown';
|
|
196
|
+
|
|
197
|
+
return numeric > 0 ? 'available' : 'unavailable';
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The value the stock endpoint's `size` parameter wants: the selected option of
|
|
202
|
+
* the variant group the brand names. Commerce keys that group differently per
|
|
203
|
+
* catalogue ('size', 'beden', 'shoe_size'), so the key is authorable; a product
|
|
204
|
+
* with exactly one group falls back to it, and a product with none is simply
|
|
205
|
+
* asked about without a size.
|
|
206
|
+
*/
|
|
207
|
+
const readSelectedVariantValue = (
|
|
208
|
+
product: ThemeProduct | null,
|
|
209
|
+
attributeKey: string
|
|
210
|
+
): string => {
|
|
211
|
+
const groups = product?.variants;
|
|
212
|
+
if (!Array.isArray(groups) || groups.length === 0) return '';
|
|
213
|
+
|
|
214
|
+
const wanted = attributeKey.trim().toLowerCase();
|
|
215
|
+
const match =
|
|
216
|
+
groups.find(
|
|
217
|
+
(group: ThemeVariantGroup) =>
|
|
218
|
+
String(group?.attribute_key || '').toLowerCase() === wanted ||
|
|
219
|
+
String(group?.attribute_name || '').toLowerCase() === wanted
|
|
220
|
+
) ?? (groups.length === 1 ? groups[0] : undefined);
|
|
221
|
+
|
|
222
|
+
if (!match) return '';
|
|
223
|
+
|
|
224
|
+
const selected = (match.options || []).find((option) => option?.is_selected);
|
|
225
|
+
|
|
226
|
+
return selected ? String(selected.value ?? '') : '';
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Functional content block: the brand's store/branch list with a city filter
|
|
231
|
+
* and an optional map link per store. Store CONTENT stays commerce-driven (the
|
|
232
|
+
* retail store service); the editor owns the filter, the cap and the styling.
|
|
233
|
+
*
|
|
234
|
+
* Two opt-in modes sit on top of that list, both off by default so a published
|
|
235
|
+
* block renders exactly what it rendered before:
|
|
236
|
+
*
|
|
237
|
+
* - `productMode` scopes the block to the ambient product and answers "is it
|
|
238
|
+
* in stock here?" per store, from `/retail_store_stock/{pk}/`. That endpoint
|
|
239
|
+
* is scoped to a single city and needs a product, so outside that scope the
|
|
240
|
+
* marker says 'unknown' and can carry an authorable link instead — never a
|
|
241
|
+
* guessed in-stock claim.
|
|
242
|
+
* - `displayMode: 'trigger'` renders the block as a button that opens the list
|
|
243
|
+
* in a panel over the page, which is the "Mağazada Bul" control every
|
|
244
|
+
* audited product page puts on the buy row. Opened, it is a real dialog:
|
|
245
|
+
* focus moves in, Tab is held inside, Escape and the close button both back
|
|
246
|
+
* out, focus returns to the trigger and the page behind it stops scrolling.
|
|
247
|
+
* All of that is `useOverlay`'s rather than this block's, so two panels on
|
|
248
|
+
* one page queue instead of fighting. None of it runs in the designer, where
|
|
249
|
+
* the panel is held open to be styled and the canvas stays the author's.
|
|
250
|
+
*
|
|
251
|
+
* Properties: showCityFilter, cityFilterLabel, allCitiesLabel, limit, sort,
|
|
252
|
+
* columns, columnsMobile, showAddress, showCity, showPhone, showMap, mapLabel,
|
|
253
|
+
* loadingLabel, emptyLabel, showEmptyState, productMode, variantAttributeKey,
|
|
254
|
+
* availableLabel, unavailableLabel, unknownLabel, availabilityHelpUrl,
|
|
255
|
+
* availabilityNotice, showDistance, distanceUnit, distanceTemplate,
|
|
256
|
+
* locationLabel, locationDeniedLabel, displayMode, triggerLabel, panelTitle,
|
|
257
|
+
* panelCloseLabel, panelPlacement.
|
|
258
|
+
* Parts: filter, filter-label, select, list, card, name, address, city, phone,
|
|
259
|
+
* map-link, empty, availability, distance, location-button, notice,
|
|
260
|
+
* notice-link, trigger, backdrop, panel, panel-title, panel-close.
|
|
261
|
+
*/
|
|
262
|
+
const StoreLocatorBlock = ({
|
|
263
|
+
block,
|
|
264
|
+
placeholderId,
|
|
265
|
+
sectionId,
|
|
266
|
+
selectedBlockId = null,
|
|
267
|
+
currentBreakpoint = 'desktop',
|
|
268
|
+
isDesigner = false
|
|
269
|
+
}: BlockRendererProps) => {
|
|
270
|
+
const prop = (key: string, fallback: string) =>
|
|
271
|
+
String(
|
|
272
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const showCityFilter = readToggle(
|
|
276
|
+
block.properties?.showCityFilter,
|
|
277
|
+
currentBreakpoint,
|
|
278
|
+
true
|
|
279
|
+
);
|
|
280
|
+
const cityFilterLabel = prop('cityFilterLabel', 'City');
|
|
281
|
+
const allCitiesLabel = prop('allCitiesLabel', 'All cities');
|
|
282
|
+
const mapLabel = prop('mapLabel', 'View on map');
|
|
283
|
+
const loadingLabel = prop('loadingLabel', 'Store locator (loading)');
|
|
284
|
+
const emptyLabel = prop('emptyLabel', 'Store locator (no stores)');
|
|
285
|
+
const showEmptyState = readToggle(
|
|
286
|
+
block.properties?.showEmptyState,
|
|
287
|
+
currentBreakpoint,
|
|
288
|
+
false
|
|
289
|
+
);
|
|
290
|
+
const showAddress = readToggle(
|
|
291
|
+
block.properties?.showAddress,
|
|
292
|
+
currentBreakpoint,
|
|
293
|
+
true
|
|
294
|
+
);
|
|
295
|
+
const showCity = readToggle(
|
|
296
|
+
block.properties?.showCity,
|
|
297
|
+
currentBreakpoint,
|
|
298
|
+
true
|
|
299
|
+
);
|
|
300
|
+
const showPhone = readToggle(
|
|
301
|
+
block.properties?.showPhone,
|
|
302
|
+
currentBreakpoint,
|
|
303
|
+
true
|
|
304
|
+
);
|
|
305
|
+
const showMap = readToggle(block.properties?.showMap, currentBreakpoint, true);
|
|
306
|
+
const sort = prop('sort', 'default');
|
|
307
|
+
const columns = clampColumns(
|
|
308
|
+
getResponsiveValue(block.properties?.columns, currentBreakpoint, 1),
|
|
309
|
+
1
|
|
310
|
+
);
|
|
311
|
+
const columnsMobile = clampColumns(
|
|
312
|
+
getResponsiveValue(block.properties?.columnsMobile, currentBreakpoint, 1),
|
|
313
|
+
1
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// A `target: 'property'` slider stores a bare number, so a unit would arrive
|
|
317
|
+
// here as NaN; the fallback keeps the block populated either way.
|
|
318
|
+
const rawLimit = Number(
|
|
319
|
+
getResponsiveValue(block.properties?.limit, currentBreakpoint, 12)
|
|
320
|
+
);
|
|
321
|
+
const limit = Number.isFinite(rawLimit)
|
|
322
|
+
? Math.min(100, Math.max(1, Math.round(rawLimit)))
|
|
323
|
+
: 12;
|
|
324
|
+
|
|
325
|
+
// Both new modes default to the block's original behaviour: `productMode`
|
|
326
|
+
// off leaves every availability hook idle, and 'inline' is the layout every
|
|
327
|
+
// published composition already has.
|
|
328
|
+
const productMode = readToggle(
|
|
329
|
+
block.properties?.productMode,
|
|
330
|
+
currentBreakpoint,
|
|
331
|
+
false
|
|
332
|
+
);
|
|
333
|
+
const variantAttributeKey = prop('variantAttributeKey', 'size');
|
|
334
|
+
const availableLabel = prop('availableLabel', 'In stock');
|
|
335
|
+
const unavailableLabel = prop('unavailableLabel', 'Not in stock');
|
|
336
|
+
const unknownLabel = prop('unknownLabel', 'Check availability');
|
|
337
|
+
const availabilityHelpUrl = prop('availabilityHelpUrl', '');
|
|
338
|
+
const availabilityNotice = prop(
|
|
339
|
+
'availabilityNotice',
|
|
340
|
+
'Pick a city to see stock for this product'
|
|
341
|
+
);
|
|
342
|
+
const showDistance = readToggle(
|
|
343
|
+
block.properties?.showDistance,
|
|
344
|
+
currentBreakpoint,
|
|
345
|
+
false
|
|
346
|
+
);
|
|
347
|
+
const distanceUnitValue = prop('distanceUnit', 'km');
|
|
348
|
+
const distanceUnit = ALLOWED_DISTANCE_UNITS.includes(distanceUnitValue)
|
|
349
|
+
? distanceUnitValue
|
|
350
|
+
: 'km';
|
|
351
|
+
const distanceTemplate = prop('distanceTemplate', '{value} km');
|
|
352
|
+
const locationLabel = prop('locationLabel', 'Use my location');
|
|
353
|
+
const locationDeniedLabel = prop(
|
|
354
|
+
'locationDeniedLabel',
|
|
355
|
+
'Location unavailable'
|
|
356
|
+
);
|
|
357
|
+
const displayModeValue = prop('displayMode', 'inline');
|
|
358
|
+
const displayMode = ALLOWED_DISPLAY_MODES.includes(displayModeValue)
|
|
359
|
+
? displayModeValue
|
|
360
|
+
: 'inline';
|
|
361
|
+
const triggerLabel = prop('triggerLabel', 'Find in store');
|
|
362
|
+
const panelTitle = prop('panelTitle', 'Find in store');
|
|
363
|
+
const panelCloseLabel = prop('panelCloseLabel', 'Close');
|
|
364
|
+
const panelPlacementValue = prop('panelPlacement', 'center');
|
|
365
|
+
const panelPlacement = ALLOWED_PANEL_PLACEMENTS.includes(panelPlacementValue)
|
|
366
|
+
? panelPlacementValue
|
|
367
|
+
: 'center';
|
|
368
|
+
|
|
369
|
+
const { locale } = useLocalization();
|
|
370
|
+
const { data, isLoading } = useGetStoresQuery(undefined as never);
|
|
371
|
+
const [selectedCity, setSelectedCity] = useState(ALL_CITIES);
|
|
372
|
+
|
|
373
|
+
const product = useThemeProduct();
|
|
374
|
+
const productPk = pickProductField<number | string>(product, 'pk');
|
|
375
|
+
const variantValue = productMode
|
|
376
|
+
? readSelectedVariantValue(product, variantAttributeKey)
|
|
377
|
+
: '';
|
|
378
|
+
|
|
379
|
+
// The one gate every availability request passes through. In the designer
|
|
380
|
+
// there is no shopper, no product and no reason to hit commerce, so the mode
|
|
381
|
+
// renders its markup with everything 'unknown' instead of fetching.
|
|
382
|
+
const canQueryStock =
|
|
383
|
+
productMode &&
|
|
384
|
+
!isDesigner &&
|
|
385
|
+
productPk !== undefined &&
|
|
386
|
+
productPk !== null &&
|
|
387
|
+
String(productPk) !== '';
|
|
388
|
+
|
|
389
|
+
// `/retail_store_stock/` is keyed by CITY PK while this block has always
|
|
390
|
+
// filtered by city NAME, so the city list is what bridges the two. It is
|
|
391
|
+
// skipped entirely unless the mode is on, keeping the request count of a
|
|
392
|
+
// plain store list exactly where it was.
|
|
393
|
+
const { data: cityData } = useGetRetailStoreQuery(undefined as never, {
|
|
394
|
+
skip: !canQueryStock
|
|
395
|
+
});
|
|
396
|
+
const [fetchStock] = useGetRetailStoreStockMutation();
|
|
397
|
+
/**
|
|
398
|
+
* The answer is kept WITH the question it answers.
|
|
399
|
+
*
|
|
400
|
+
* The hook's own `data` outlives the request that produced it, so between a
|
|
401
|
+
* city change and the next response the previous city's rows are still in
|
|
402
|
+
* hand — and a store name that exists in both cities ("Merkez") would be
|
|
403
|
+
* painted in stock on the strength of a different city's answer. Pinning the
|
|
404
|
+
* signature makes a mismatched answer unusable instead of merely unlikely.
|
|
405
|
+
*/
|
|
406
|
+
const [stockAnswer, setStockAnswer] = useState<{
|
|
407
|
+
signature: string;
|
|
408
|
+
rows: StoreStockRow[];
|
|
409
|
+
} | null>(null);
|
|
410
|
+
|
|
411
|
+
const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(
|
|
412
|
+
null
|
|
413
|
+
);
|
|
414
|
+
const [locationDenied, setLocationDenied] = useState(false);
|
|
415
|
+
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
416
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
417
|
+
|
|
418
|
+
const stores = useMemo(
|
|
419
|
+
() =>
|
|
420
|
+
((data as { results?: StoreLike[] } | undefined)?.results ?? []).filter(
|
|
421
|
+
(store) => Boolean(store?.name)
|
|
422
|
+
),
|
|
423
|
+
[data]
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const cities = useMemo(() => {
|
|
427
|
+
const unique = new Set<string>();
|
|
428
|
+
stores.forEach((store) => {
|
|
429
|
+
const city = resolveCity(store);
|
|
430
|
+
if (city) unique.add(city);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Collated with the storefront locale so Turkish city names sort the way a
|
|
434
|
+
// shopper expects rather than by code point.
|
|
435
|
+
return Array.from(unique).sort((a, b) => a.localeCompare(b, locale));
|
|
436
|
+
}, [stores, locale]);
|
|
437
|
+
|
|
438
|
+
const selectedCityId = useMemo(() => {
|
|
439
|
+
if (!canQueryStock || !selectedCity) return '';
|
|
440
|
+
|
|
441
|
+
const wanted = normalizeStoreKey(selectedCity, locale);
|
|
442
|
+
const match = (
|
|
443
|
+
(cityData as { results?: RetailStoreCity[] } | undefined)?.results ?? []
|
|
444
|
+
).find((city) => normalizeStoreKey(city?.name, locale) === wanted);
|
|
445
|
+
|
|
446
|
+
return match?.pk === undefined || match?.pk === null
|
|
447
|
+
? ''
|
|
448
|
+
: String(match.pk);
|
|
449
|
+
}, [canQueryStock, selectedCity, cityData, locale]);
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The exact question being asked of commerce, or '' when there is none.
|
|
453
|
+
* `/retail_store_stock/` needs a product AND a city, so the empty signature
|
|
454
|
+
* is the whole degrade path: no product on the page, no city chosen, or the
|
|
455
|
+
* designer.
|
|
456
|
+
*/
|
|
457
|
+
const stockQuery = useMemo(() => {
|
|
458
|
+
if (!canQueryStock || !selectedCityId) return '';
|
|
459
|
+
|
|
460
|
+
const params = new URLSearchParams();
|
|
461
|
+
params.set('city_id', selectedCityId);
|
|
462
|
+
// Commerce rejects an empty `size`; a product without variants is asked
|
|
463
|
+
// about as a whole, exactly as the legacy find-in-store form does.
|
|
464
|
+
if (variantValue) params.set('size', variantValue);
|
|
465
|
+
|
|
466
|
+
return `${productPk}|${params.toString()}`;
|
|
467
|
+
}, [canQueryStock, selectedCityId, variantValue, productPk]);
|
|
468
|
+
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
if (!stockQuery) return;
|
|
471
|
+
|
|
472
|
+
let cancelled = false;
|
|
473
|
+
const queryString = stockQuery.slice(stockQuery.indexOf('|') + 1);
|
|
474
|
+
|
|
475
|
+
fetchStock({ productPk: String(productPk), queryString })
|
|
476
|
+
.unwrap()
|
|
477
|
+
.then((rows) => {
|
|
478
|
+
if (cancelled) return;
|
|
479
|
+
setStockAnswer({
|
|
480
|
+
signature: stockQuery,
|
|
481
|
+
rows: (rows ?? []) as unknown as StoreStockRow[]
|
|
482
|
+
});
|
|
483
|
+
})
|
|
484
|
+
.catch(() => {
|
|
485
|
+
// A request that failed is not an out-of-stock answer. Recording an
|
|
486
|
+
// empty row set for this signature leaves every marker 'unknown',
|
|
487
|
+
// which is the only thing we can honestly say.
|
|
488
|
+
if (!cancelled) setStockAnswer({ signature: stockQuery, rows: [] });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return () => {
|
|
492
|
+
cancelled = true;
|
|
493
|
+
};
|
|
494
|
+
}, [stockQuery, productPk, fetchStock]);
|
|
495
|
+
|
|
496
|
+
const stockIndex = useMemo(() => {
|
|
497
|
+
const byName = new Map<string, StoreStockRow>();
|
|
498
|
+
const byCoordinate = new Map<string, StoreStockRow>();
|
|
499
|
+
|
|
500
|
+
if (!stockQuery || stockAnswer?.signature !== stockQuery) {
|
|
501
|
+
return { byName, byCoordinate };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
stockAnswer.rows.forEach((row) => {
|
|
505
|
+
const nameKey = normalizeStoreKey(row?.name, locale);
|
|
506
|
+
if (nameKey && !byName.has(nameKey)) byName.set(nameKey, row);
|
|
507
|
+
|
|
508
|
+
const coordinate = coordinateKey(row?.latitude, row?.longitude);
|
|
509
|
+
if (coordinate && !byCoordinate.has(coordinate)) {
|
|
510
|
+
byCoordinate.set(coordinate, row);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
return { byName, byCoordinate };
|
|
515
|
+
}, [stockQuery, stockAnswer, locale]);
|
|
516
|
+
|
|
517
|
+
const resolveAvailability = useCallback(
|
|
518
|
+
(store: StoreLike): AvailabilityState => {
|
|
519
|
+
// Every branch that cannot produce a commerce-backed answer lands on
|
|
520
|
+
// 'unknown': mode off, no product, no city, request in flight, request
|
|
521
|
+
// failed, or a store this city's answer never mentioned. The index is
|
|
522
|
+
// empty in all of them, so this is one rule rather than five.
|
|
523
|
+
const row =
|
|
524
|
+
stockIndex.byName.get(normalizeStoreKey(store.name, locale)) ??
|
|
525
|
+
stockIndex.byCoordinate.get(
|
|
526
|
+
coordinateKey(store.latitude, store.longitude)
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
return readStockState(row);
|
|
530
|
+
},
|
|
531
|
+
[stockIndex, locale]
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const resolveDistance = useCallback(
|
|
535
|
+
(store: StoreLike): number | null => {
|
|
536
|
+
const km = distanceInKm(origin, store);
|
|
537
|
+
if (km === null) return null;
|
|
538
|
+
|
|
539
|
+
return distanceUnit === 'mi' ? km * MILES_PER_KM : km;
|
|
540
|
+
},
|
|
541
|
+
[origin, distanceUnit]
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const requestLocation = useCallback(() => {
|
|
545
|
+
// Nothing is prompted, measured or stored inside the designer.
|
|
546
|
+
if (isDesigner) return;
|
|
547
|
+
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
548
|
+
setLocationDenied(true);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
navigator.geolocation.getCurrentPosition(
|
|
553
|
+
(position) => {
|
|
554
|
+
setLocationDenied(false);
|
|
555
|
+
setOrigin({
|
|
556
|
+
lat: position.coords.latitude,
|
|
557
|
+
lng: position.coords.longitude
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
() => setLocationDenied(true)
|
|
561
|
+
);
|
|
562
|
+
}, [isDesigner]);
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The whole modal contract — the focus trap, the `aria-modal` claim, the
|
|
566
|
+
* scroll lock, Escape, and returning focus to the trigger — is the shared
|
|
567
|
+
* overlay primitive's, not this block's.
|
|
568
|
+
*
|
|
569
|
+
* It used to be three effects here, and the focus trap among them registered
|
|
570
|
+
* a DOCUMENT-level `focusin` listener that pulled focus back into THIS panel.
|
|
571
|
+
* A product page carries this block twice, once on the mobile buy row and
|
|
572
|
+
* once on the desktop one, so two open panels is the ordinary composition —
|
|
573
|
+
* and two such listeners read each other's focus as "outside" and pulled
|
|
574
|
+
* against each other until the tab died with a call-stack overflow. The
|
|
575
|
+
* primitive keeps one stack for the page: only the topmost overlay traps
|
|
576
|
+
* focus, claims `aria-modal` and answers Escape, and the scroll lock is
|
|
577
|
+
* counted across every block rather than per block.
|
|
578
|
+
*
|
|
579
|
+
* `isDesigner` keeps all of it inert: on the canvas the panel is held open to
|
|
580
|
+
* be styled, and nothing may steal focus, freeze the page or close the thing
|
|
581
|
+
* the author is working on.
|
|
582
|
+
*/
|
|
583
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
584
|
+
open: displayMode === 'trigger' && isPanelOpen,
|
|
585
|
+
onClose: () => setIsPanelOpen(false),
|
|
586
|
+
isDesigner,
|
|
587
|
+
modal: true,
|
|
588
|
+
returnFocusRef: triggerRef
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const visibleStores = useMemo(() => {
|
|
592
|
+
const filtered = selectedCity
|
|
593
|
+
? stores.filter((store) => resolveCity(store) === selectedCity)
|
|
594
|
+
: stores;
|
|
595
|
+
|
|
596
|
+
// 'default' keeps whatever order commerce returned, which is what every
|
|
597
|
+
// published block shows today.
|
|
598
|
+
const ordered =
|
|
599
|
+
sort === 'default'
|
|
600
|
+
? filtered
|
|
601
|
+
: [...filtered].sort((a, b) => {
|
|
602
|
+
if (sort === 'distance') {
|
|
603
|
+
const first = distanceInKm(origin, a);
|
|
604
|
+
const second = distanceInKm(origin, b);
|
|
605
|
+
// A store we cannot measure sinks below every store we can,
|
|
606
|
+
// rather than pretending to be the nearest.
|
|
607
|
+
if (first === null && second === null) return 0;
|
|
608
|
+
if (first === null) return 1;
|
|
609
|
+
if (second === null) return -1;
|
|
610
|
+
|
|
611
|
+
return first - second;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (sort === 'city') {
|
|
615
|
+
return resolveCity(a).localeCompare(resolveCity(b), locale);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const compared = String(a.name || '').localeCompare(
|
|
619
|
+
String(b.name || ''),
|
|
620
|
+
locale
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
return sort === 'name-desc' ? -compared : compared;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return ordered.slice(0, limit);
|
|
627
|
+
}, [stores, selectedCity, limit, sort, locale, origin]);
|
|
628
|
+
|
|
629
|
+
const cards = useMemo(
|
|
630
|
+
() =>
|
|
631
|
+
visibleStores.map((store, index) => {
|
|
632
|
+
// With the mode off there is no availability to bind, and an authored
|
|
633
|
+
// row must not be handed "Check availability" for a block that never
|
|
634
|
+
// asked commerce anything.
|
|
635
|
+
const availability = productMode ? resolveAvailability(store) : '';
|
|
636
|
+
const distance = resolveDistance(store);
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
id: String(store.pk ?? `store-${index}`),
|
|
640
|
+
name: String(store.name || ''),
|
|
641
|
+
address: String(store.address || ''),
|
|
642
|
+
city: resolveCity(store),
|
|
643
|
+
phone: String(store.phone_number || ''),
|
|
644
|
+
url: mapHref(store),
|
|
645
|
+
latitude: store.latitude ?? '',
|
|
646
|
+
longitude: store.longitude ?? '',
|
|
647
|
+
availability,
|
|
648
|
+
availabilityLabel:
|
|
649
|
+
availability === 'available'
|
|
650
|
+
? availableLabel
|
|
651
|
+
: availability === 'unavailable'
|
|
652
|
+
? unavailableLabel
|
|
653
|
+
: availability === 'unknown'
|
|
654
|
+
? unknownLabel
|
|
655
|
+
: '',
|
|
656
|
+
distance:
|
|
657
|
+
distance === null ? '' : formatDecimal(distance, locale, 1)
|
|
658
|
+
};
|
|
659
|
+
}),
|
|
660
|
+
[
|
|
661
|
+
visibleStores,
|
|
662
|
+
productMode,
|
|
663
|
+
resolveAvailability,
|
|
664
|
+
resolveDistance,
|
|
665
|
+
availableLabel,
|
|
666
|
+
unavailableLabel,
|
|
667
|
+
unknownLabel,
|
|
668
|
+
locale
|
|
669
|
+
]
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
// A designer who marked a child as the card layout gets that layout repeated
|
|
673
|
+
// per store; otherwise the built-in card renders unchanged.
|
|
674
|
+
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
675
|
+
<ItemTemplateList
|
|
676
|
+
block={block}
|
|
677
|
+
items={cards}
|
|
678
|
+
placeholderId={placeholderId}
|
|
679
|
+
sectionId={sectionId}
|
|
680
|
+
isDesigner={isDesigner}
|
|
681
|
+
selectedBlockId={selectedBlockId}
|
|
682
|
+
currentBreakpoint={currentBreakpoint}
|
|
683
|
+
/>
|
|
684
|
+
) : null;
|
|
685
|
+
|
|
686
|
+
const listId = String(block.id || 'store-locator').replace(
|
|
687
|
+
/[^a-zA-Z0-9_-]/g,
|
|
688
|
+
''
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The trigger form wraps the SAME body the inline form renders, so the two
|
|
693
|
+
* cannot drift. In 'inline' mode the wrapper is skipped entirely and the body
|
|
694
|
+
* keeps the root `data-store-locator` attribute it has always carried.
|
|
695
|
+
*/
|
|
696
|
+
const withTriggerShell = (body: React.ReactNode): React.ReactElement => {
|
|
697
|
+
if (displayMode !== 'trigger') return body as React.ReactElement;
|
|
698
|
+
|
|
699
|
+
// The designer needs the panel visible to style it; a shopper does not.
|
|
700
|
+
const open = isDesigner || isPanelOpen;
|
|
701
|
+
|
|
702
|
+
return (
|
|
703
|
+
<div
|
|
704
|
+
data-store-locator=""
|
|
705
|
+
data-store-locator-mode="trigger"
|
|
706
|
+
style={{ position: 'relative' }}
|
|
707
|
+
>
|
|
708
|
+
<button
|
|
709
|
+
type="button"
|
|
710
|
+
ref={triggerRef}
|
|
711
|
+
data-store-locator-trigger=""
|
|
712
|
+
{...partAttrs('trigger', { open })}
|
|
713
|
+
aria-haspopup="dialog"
|
|
714
|
+
aria-expanded={open ? 'true' : 'false'}
|
|
715
|
+
// The designer holds the panel open, so a click there would only
|
|
716
|
+
// desynchronise the state the author is styling against.
|
|
717
|
+
onClick={() => {
|
|
718
|
+
if (!isDesigner) setIsPanelOpen((previous) => !previous);
|
|
719
|
+
}}
|
|
720
|
+
style={{
|
|
721
|
+
display: 'inline-flex',
|
|
722
|
+
alignItems: 'center',
|
|
723
|
+
gap: '8px',
|
|
724
|
+
padding: '10px 16px',
|
|
725
|
+
border: '1px solid var(--store-locator-border, #e5e7eb)',
|
|
726
|
+
background: 'transparent',
|
|
727
|
+
color: 'inherit',
|
|
728
|
+
font: 'inherit',
|
|
729
|
+
cursor: 'pointer'
|
|
730
|
+
}}
|
|
731
|
+
>
|
|
732
|
+
{triggerLabel}
|
|
733
|
+
</button>
|
|
734
|
+
|
|
735
|
+
{open && (
|
|
736
|
+
<>
|
|
737
|
+
<div
|
|
738
|
+
data-store-locator-backdrop=""
|
|
739
|
+
{...partAttrs('backdrop')}
|
|
740
|
+
// Decoration behind the dialog: it carries no content, and the
|
|
741
|
+
// close button is the route assistive tech is offered instead.
|
|
742
|
+
aria-hidden="true"
|
|
743
|
+
// Inert in the designer: closing the panel from a stray canvas
|
|
744
|
+
// click would hide the thing being styled.
|
|
745
|
+
onClick={() => {
|
|
746
|
+
if (!isDesigner) setIsPanelOpen(false);
|
|
747
|
+
}}
|
|
748
|
+
style={{
|
|
749
|
+
position: 'fixed',
|
|
750
|
+
inset: 0,
|
|
751
|
+
background: 'var(--store-locator-backdrop, rgba(0,0,0,0.4))',
|
|
752
|
+
zIndex: 40
|
|
753
|
+
}}
|
|
754
|
+
/>
|
|
755
|
+
<div
|
|
756
|
+
data-store-locator-panel=""
|
|
757
|
+
{...partAttrs('panel', { open: true })}
|
|
758
|
+
role="dialog"
|
|
759
|
+
// Carries the panel ref and `aria-modal`, and the claim is made
|
|
760
|
+
// only where it is true: never in the designer, where the panel
|
|
761
|
+
// is held open next to a canvas the author must keep reaching,
|
|
762
|
+
// and never on the lower of two open panels, which is not the
|
|
763
|
+
// one hiding the page. Spread after `role` so the attribute
|
|
764
|
+
// order the golden captured stays put.
|
|
765
|
+
{...overlay.overlayProps}
|
|
766
|
+
aria-label={panelTitle}
|
|
767
|
+
// The dialog itself is the fallback focus target while it holds
|
|
768
|
+
// focus, but it must stay out of the Tab order otherwise.
|
|
769
|
+
tabIndex={-1}
|
|
770
|
+
style={{
|
|
771
|
+
position: 'fixed',
|
|
772
|
+
zIndex: 41,
|
|
773
|
+
display: 'flex',
|
|
774
|
+
flexDirection: 'column',
|
|
775
|
+
gap: 'var(--store-locator-gap, 16px)',
|
|
776
|
+
background: 'var(--store-locator-panel-bg, #ffffff)',
|
|
777
|
+
color: 'inherit',
|
|
778
|
+
padding: '20px',
|
|
779
|
+
overflowY: 'auto',
|
|
780
|
+
...(panelPlacement === 'right'
|
|
781
|
+
? {
|
|
782
|
+
top: 0,
|
|
783
|
+
right: 0,
|
|
784
|
+
bottom: 0,
|
|
785
|
+
width: 'min(420px, 100%)'
|
|
786
|
+
}
|
|
787
|
+
: {
|
|
788
|
+
top: '50%',
|
|
789
|
+
left: '50%',
|
|
790
|
+
transform: 'translate(-50%, -50%)',
|
|
791
|
+
width: 'min(560px, calc(100% - 32px))',
|
|
792
|
+
maxHeight: 'calc(100% - 64px)'
|
|
793
|
+
})
|
|
794
|
+
}}
|
|
795
|
+
>
|
|
796
|
+
<div
|
|
797
|
+
style={{
|
|
798
|
+
display: 'flex',
|
|
799
|
+
alignItems: 'center',
|
|
800
|
+
justifyContent: 'space-between',
|
|
801
|
+
gap: '12px'
|
|
802
|
+
}}
|
|
803
|
+
>
|
|
804
|
+
<span
|
|
805
|
+
data-store-locator-panel-title=""
|
|
806
|
+
{...partAttrs('panel-title')}
|
|
807
|
+
style={{ fontWeight: 600 }}
|
|
808
|
+
>
|
|
809
|
+
{panelTitle}
|
|
810
|
+
</span>
|
|
811
|
+
<button
|
|
812
|
+
type="button"
|
|
813
|
+
data-store-locator-panel-close=""
|
|
814
|
+
{...partAttrs('panel-close')}
|
|
815
|
+
onClick={() => {
|
|
816
|
+
if (!isDesigner) setIsPanelOpen(false);
|
|
817
|
+
}}
|
|
818
|
+
style={{
|
|
819
|
+
border: 'none',
|
|
820
|
+
background: 'transparent',
|
|
821
|
+
color: 'inherit',
|
|
822
|
+
font: 'inherit',
|
|
823
|
+
cursor: 'pointer',
|
|
824
|
+
padding: '4px'
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
{panelCloseLabel}
|
|
828
|
+
</button>
|
|
829
|
+
</div>
|
|
830
|
+
|
|
831
|
+
{body}
|
|
832
|
+
</div>
|
|
833
|
+
</>
|
|
834
|
+
)}
|
|
835
|
+
</div>
|
|
836
|
+
);
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
/** In 'trigger' mode the shell owns the root attribute, so the body drops it. */
|
|
840
|
+
const bodyRootAttrs =
|
|
841
|
+
displayMode === 'trigger' ? {} : { 'data-store-locator': '' };
|
|
842
|
+
|
|
843
|
+
// The mode is on but commerce has not been asked anything a shopper can act
|
|
844
|
+
// on — no product on the page, or no city picked. Saying so is the honest
|
|
845
|
+
// alternative to painting every store with a guess.
|
|
846
|
+
const showNotice = productMode && Boolean(availabilityNotice) && !stockQuery;
|
|
847
|
+
const helpHref = safeHelpHref(availabilityHelpUrl);
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The same route the unknown marker carries, offered where there is no
|
|
851
|
+
* marker to carry it.
|
|
852
|
+
*
|
|
853
|
+
* Inside the overlay the list can be the whole content, and both states that
|
|
854
|
+
* leave it empty of answers — no city chosen yet, or no store matching at
|
|
855
|
+
* all — used to end with the shopper facing a wall of "unknown" or a bare
|
|
856
|
+
* "no stores" line and nowhere to go. It is the SAME authorable label and
|
|
857
|
+
* the SAME url, not a second pair of properties, so a brand words its
|
|
858
|
+
* fallback once.
|
|
859
|
+
*/
|
|
860
|
+
const availabilityRoute =
|
|
861
|
+
productMode && helpHref && unknownLabel ? (
|
|
862
|
+
<a
|
|
863
|
+
data-store-locator-notice-link=""
|
|
864
|
+
{...partAttrs('notice-link')}
|
|
865
|
+
href={isDesigner ? '#' : helpHref}
|
|
866
|
+
onClick={(event) => {
|
|
867
|
+
if (isDesigner) event.preventDefault();
|
|
868
|
+
}}
|
|
869
|
+
style={{ fontSize: '0.9em', color: 'inherit', width: 'fit-content' }}
|
|
870
|
+
>
|
|
871
|
+
{unknownLabel}
|
|
872
|
+
</a>
|
|
873
|
+
) : null;
|
|
874
|
+
|
|
875
|
+
if (visibleStores.length === 0 && !customCards) {
|
|
876
|
+
// With the availability mode off this is the empty message the block has
|
|
877
|
+
// always rendered; the route only appears for a composition that opted in.
|
|
878
|
+
const emptyContent = (
|
|
879
|
+
<>
|
|
880
|
+
{isLoading ? loadingLabel : emptyLabel}
|
|
881
|
+
{availabilityRoute}
|
|
882
|
+
</>
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
if (!isDesigner) {
|
|
886
|
+
// Historically the storefront rendered nothing here, so the notice is
|
|
887
|
+
// opt-in and the block stays invisible until a brand asks for it.
|
|
888
|
+
if (!showEmptyState && displayMode !== 'trigger') return null;
|
|
889
|
+
|
|
890
|
+
return withTriggerShell(
|
|
891
|
+
<div
|
|
892
|
+
{...bodyRootAttrs}
|
|
893
|
+
data-store-locator-state="empty"
|
|
894
|
+
{...partAttrs('empty')}
|
|
895
|
+
>
|
|
896
|
+
{emptyContent}
|
|
897
|
+
</div>
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return withTriggerShell(
|
|
902
|
+
<div
|
|
903
|
+
{...bodyRootAttrs}
|
|
904
|
+
data-store-locator-state="empty"
|
|
905
|
+
{...partAttrs('empty')}
|
|
906
|
+
style={listingPlaceholderStyle}
|
|
907
|
+
>
|
|
908
|
+
{emptyContent}
|
|
909
|
+
</div>
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Column counts are block PROPERTIES, and properties are resolved with the
|
|
914
|
+
// designer breakpoint only — on the live storefront currentBreakpoint is
|
|
915
|
+
// always 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
|
|
916
|
+
// generator uses keeps real mobile viewports in sync with the editor. A
|
|
917
|
+
// single-column list keeps the original flex layout so nothing published
|
|
918
|
+
// against it shifts.
|
|
919
|
+
const isGrid = columns > 1 || columnsMobile > 1;
|
|
920
|
+
const baseColumns = currentBreakpoint === 'mobile' ? columnsMobile : columns;
|
|
921
|
+
// A designer-authored card is a block tree, not an <li>, so the container
|
|
922
|
+
// drops to a <div> in that case rather than nesting invalid list children.
|
|
923
|
+
const ListTag = customCards ? 'div' : 'ul';
|
|
924
|
+
|
|
925
|
+
const renderAvailability = (state: AvailabilityState) => {
|
|
926
|
+
const label =
|
|
927
|
+
state === 'available'
|
|
928
|
+
? availableLabel
|
|
929
|
+
: state === 'unavailable'
|
|
930
|
+
? unavailableLabel
|
|
931
|
+
: unknownLabel;
|
|
932
|
+
|
|
933
|
+
if (!label) return null;
|
|
934
|
+
|
|
935
|
+
const attrs = {
|
|
936
|
+
'data-store-locator-availability': '',
|
|
937
|
+
'data-store-locator-availability-state': state,
|
|
938
|
+
...partAttrs('availability', {
|
|
939
|
+
available: state === 'available',
|
|
940
|
+
unavailable: state === 'unavailable',
|
|
941
|
+
unknown: state === 'unknown'
|
|
942
|
+
})
|
|
943
|
+
};
|
|
944
|
+
const style: React.CSSProperties = {
|
|
945
|
+
fontSize: '0.85em',
|
|
946
|
+
color:
|
|
947
|
+
state === 'available'
|
|
948
|
+
? 'var(--store-locator-available-color, #15803d)'
|
|
949
|
+
: state === 'unavailable'
|
|
950
|
+
? 'var(--store-locator-unavailable-color, #b91c1c)'
|
|
951
|
+
: 'var(--store-locator-unknown-color, #6b7280)',
|
|
952
|
+
// 'unknown' is deliberately neither of the other two: a neutral colour
|
|
953
|
+
// plus a dotted underline reads as "we have not checked", which is what
|
|
954
|
+
// it means.
|
|
955
|
+
borderBottom:
|
|
956
|
+
state === 'unknown'
|
|
957
|
+
? '1px dotted var(--store-locator-unknown-color, #6b7280)'
|
|
958
|
+
: undefined,
|
|
959
|
+
width: 'fit-content'
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
// An unknown answer can carry the brand's own "check availability" route —
|
|
963
|
+
// a help page, a store-finder, a WhatsApp line. Without one it stays text,
|
|
964
|
+
// because a link to nowhere is worse than a plain label.
|
|
965
|
+
if (state === 'unknown' && helpHref) {
|
|
966
|
+
return (
|
|
967
|
+
<a
|
|
968
|
+
{...attrs}
|
|
969
|
+
href={isDesigner ? '#' : helpHref}
|
|
970
|
+
onClick={(event) => {
|
|
971
|
+
if (isDesigner) event.preventDefault();
|
|
972
|
+
}}
|
|
973
|
+
style={style}
|
|
974
|
+
>
|
|
975
|
+
{label}
|
|
976
|
+
</a>
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return (
|
|
981
|
+
<span {...attrs} style={style}>
|
|
982
|
+
{label}
|
|
983
|
+
</span>
|
|
984
|
+
);
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const body = (
|
|
988
|
+
<div
|
|
989
|
+
{...bodyRootAttrs}
|
|
990
|
+
style={{
|
|
991
|
+
display: 'flex',
|
|
992
|
+
flexDirection: 'column',
|
|
993
|
+
gap: 'var(--store-locator-gap, 16px)'
|
|
994
|
+
}}
|
|
995
|
+
>
|
|
996
|
+
{showCityFilter && cities.length > 0 && (
|
|
997
|
+
<label
|
|
998
|
+
data-store-locator-filter=""
|
|
999
|
+
{...partAttrs('filter')}
|
|
1000
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}
|
|
1001
|
+
>
|
|
1002
|
+
<span {...partAttrs('filter-label')}>{cityFilterLabel}</span>
|
|
1003
|
+
<select
|
|
1004
|
+
value={selectedCity}
|
|
1005
|
+
{...partAttrs('select')}
|
|
1006
|
+
// Filtering is local state only, so it stays harmless in the
|
|
1007
|
+
// designer and needs no navigation guard.
|
|
1008
|
+
onChange={(event) => setSelectedCity(event.target.value)}
|
|
1009
|
+
style={{
|
|
1010
|
+
border: '1px solid var(--store-locator-border, #e5e7eb)',
|
|
1011
|
+
background: 'transparent',
|
|
1012
|
+
color: 'inherit',
|
|
1013
|
+
padding: '8px 10px'
|
|
1014
|
+
}}
|
|
1015
|
+
>
|
|
1016
|
+
<option value={ALL_CITIES}>{allCitiesLabel}</option>
|
|
1017
|
+
{cities.map((city) => (
|
|
1018
|
+
<option key={city} value={city}>
|
|
1019
|
+
{city}
|
|
1020
|
+
</option>
|
|
1021
|
+
))}
|
|
1022
|
+
</select>
|
|
1023
|
+
</label>
|
|
1024
|
+
)}
|
|
1025
|
+
|
|
1026
|
+
{showDistance && (
|
|
1027
|
+
<button
|
|
1028
|
+
type="button"
|
|
1029
|
+
data-store-locator-location=""
|
|
1030
|
+
{...partAttrs('location-button', { disabled: locationDenied })}
|
|
1031
|
+
onClick={requestLocation}
|
|
1032
|
+
style={{
|
|
1033
|
+
display: 'inline-flex',
|
|
1034
|
+
alignItems: 'center',
|
|
1035
|
+
gap: '8px',
|
|
1036
|
+
width: 'fit-content',
|
|
1037
|
+
padding: '8px 12px',
|
|
1038
|
+
border: '1px solid var(--store-locator-border, #e5e7eb)',
|
|
1039
|
+
background: 'transparent',
|
|
1040
|
+
color: 'inherit',
|
|
1041
|
+
font: 'inherit',
|
|
1042
|
+
cursor: 'pointer'
|
|
1043
|
+
}}
|
|
1044
|
+
>
|
|
1045
|
+
{locationDenied ? locationDeniedLabel : locationLabel}
|
|
1046
|
+
</button>
|
|
1047
|
+
)}
|
|
1048
|
+
|
|
1049
|
+
{showNotice && (
|
|
1050
|
+
<>
|
|
1051
|
+
<span
|
|
1052
|
+
data-store-locator-notice=""
|
|
1053
|
+
{...partAttrs('notice')}
|
|
1054
|
+
style={{ fontSize: '0.9em', opacity: 0.8 }}
|
|
1055
|
+
>
|
|
1056
|
+
{availabilityNotice}
|
|
1057
|
+
</span>
|
|
1058
|
+
{availabilityRoute}
|
|
1059
|
+
</>
|
|
1060
|
+
)}
|
|
1061
|
+
|
|
1062
|
+
{isGrid && (
|
|
1063
|
+
<style>
|
|
1064
|
+
{`[data-store-locator-id="${listId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
|
|
1065
|
+
`@media (max-width:1023px){[data-store-locator-id="${listId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}`}
|
|
1066
|
+
</style>
|
|
1067
|
+
)}
|
|
1068
|
+
|
|
1069
|
+
<ListTag
|
|
1070
|
+
{...partAttrs('list')}
|
|
1071
|
+
{...(isGrid ? { 'data-store-locator-id': listId } : {})}
|
|
1072
|
+
style={{
|
|
1073
|
+
display: isGrid ? 'grid' : 'flex',
|
|
1074
|
+
flexDirection: isGrid ? undefined : 'column',
|
|
1075
|
+
gap: 'var(--store-locator-gap, 16px)',
|
|
1076
|
+
listStyle: 'none',
|
|
1077
|
+
margin: 0,
|
|
1078
|
+
padding: 0
|
|
1079
|
+
}}
|
|
1080
|
+
>
|
|
1081
|
+
{customCards ??
|
|
1082
|
+
visibleStores.map((store, index) => {
|
|
1083
|
+
const href = mapHref(store);
|
|
1084
|
+
const city = resolveCity(store);
|
|
1085
|
+
const distance = showDistance ? resolveDistance(store) : null;
|
|
1086
|
+
|
|
1087
|
+
return (
|
|
1088
|
+
<li
|
|
1089
|
+
key={store.pk ?? `store-${index}`}
|
|
1090
|
+
data-store-locator-item=""
|
|
1091
|
+
{...partAttrs('card')}
|
|
1092
|
+
style={{
|
|
1093
|
+
display: 'flex',
|
|
1094
|
+
flexDirection: 'column',
|
|
1095
|
+
gap: '4px',
|
|
1096
|
+
paddingBottom: '12px',
|
|
1097
|
+
borderBottom: '1px solid var(--store-locator-border, #e5e7eb)'
|
|
1098
|
+
}}
|
|
1099
|
+
>
|
|
1100
|
+
<span
|
|
1101
|
+
data-store-locator-name=""
|
|
1102
|
+
{...partAttrs('name')}
|
|
1103
|
+
style={{ fontWeight: 600 }}
|
|
1104
|
+
>
|
|
1105
|
+
{store.name}
|
|
1106
|
+
</span>
|
|
1107
|
+
|
|
1108
|
+
{productMode && renderAvailability(resolveAvailability(store))}
|
|
1109
|
+
|
|
1110
|
+
{distance !== null && (
|
|
1111
|
+
<span
|
|
1112
|
+
data-store-locator-distance=""
|
|
1113
|
+
{...partAttrs('distance')}
|
|
1114
|
+
style={{ opacity: 0.7, fontSize: '0.85em' }}
|
|
1115
|
+
>
|
|
1116
|
+
{distanceTemplate.replace(
|
|
1117
|
+
/\{value\}/g,
|
|
1118
|
+
formatDecimal(distance, locale, 1)
|
|
1119
|
+
)}
|
|
1120
|
+
</span>
|
|
1121
|
+
)}
|
|
1122
|
+
|
|
1123
|
+
{showAddress && store.address && (
|
|
1124
|
+
<span
|
|
1125
|
+
data-store-locator-address=""
|
|
1126
|
+
{...partAttrs('address')}
|
|
1127
|
+
style={{ opacity: 0.8 }}
|
|
1128
|
+
>
|
|
1129
|
+
{store.address}
|
|
1130
|
+
</span>
|
|
1131
|
+
)}
|
|
1132
|
+
|
|
1133
|
+
{showCity && city && (
|
|
1134
|
+
<span
|
|
1135
|
+
data-store-locator-city=""
|
|
1136
|
+
{...partAttrs('city')}
|
|
1137
|
+
style={{ opacity: 0.7 }}
|
|
1138
|
+
>
|
|
1139
|
+
{city}
|
|
1140
|
+
</span>
|
|
1141
|
+
)}
|
|
1142
|
+
|
|
1143
|
+
{showPhone && store.phone_number && (
|
|
1144
|
+
<a
|
|
1145
|
+
data-store-locator-phone=""
|
|
1146
|
+
{...partAttrs('phone')}
|
|
1147
|
+
href={isDesigner ? '#' : `tel:${store.phone_number}`}
|
|
1148
|
+
onClick={(event) => {
|
|
1149
|
+
if (isDesigner) event.preventDefault();
|
|
1150
|
+
}}
|
|
1151
|
+
style={{ color: 'inherit' }}
|
|
1152
|
+
>
|
|
1153
|
+
{store.phone_number}
|
|
1154
|
+
</a>
|
|
1155
|
+
)}
|
|
1156
|
+
|
|
1157
|
+
{showMap && href && (
|
|
1158
|
+
<a
|
|
1159
|
+
data-store-locator-map=""
|
|
1160
|
+
{...partAttrs('map-link')}
|
|
1161
|
+
// Google Maps is off-origin, so this is a plain anchor rather
|
|
1162
|
+
// than NextLink; it is still inert inside the designer.
|
|
1163
|
+
href={isDesigner ? '#' : href}
|
|
1164
|
+
target={isDesigner ? undefined : '_blank'}
|
|
1165
|
+
rel="noopener noreferrer"
|
|
1166
|
+
onClick={(event) => {
|
|
1167
|
+
if (isDesigner) event.preventDefault();
|
|
1168
|
+
}}
|
|
1169
|
+
style={{ color: 'inherit' }}
|
|
1170
|
+
>
|
|
1171
|
+
{mapLabel}
|
|
1172
|
+
</a>
|
|
1173
|
+
)}
|
|
1174
|
+
</li>
|
|
1175
|
+
);
|
|
1176
|
+
})}
|
|
1177
|
+
</ListTag>
|
|
1178
|
+
</div>
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
return withTriggerShell(body);
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
export default StoreLocatorBlock;
|