@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,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;