@akinon/next 2.0.0-beta.2 → 2.0.0-beta.20

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 (189) hide show
  1. package/.eslintrc.js +12 -0
  2. package/CHANGELOG.md +377 -7
  3. package/__tests__/next-config.test.ts +83 -0
  4. package/__tests__/tsconfig.json +23 -0
  5. package/api/auth.ts +133 -44
  6. package/api/barcode-search.ts +59 -0
  7. package/api/cache.ts +41 -5
  8. package/api/client.ts +21 -4
  9. package/api/form.ts +85 -0
  10. package/api/image-proxy.ts +75 -0
  11. package/api/product-categories.ts +53 -0
  12. package/api/similar-product-list.ts +63 -0
  13. package/api/similar-products.ts +111 -0
  14. package/api/virtual-try-on.ts +382 -0
  15. package/assets/styles/index.scss +84 -0
  16. package/babel.config.js +6 -0
  17. package/bin/pz-generate-routes.js +115 -0
  18. package/bin/pz-prebuild.js +1 -0
  19. package/bin/pz-predev.js +1 -0
  20. package/bin/pz-run-tests.js +99 -0
  21. package/bin/run-prebuild-tests.js +46 -0
  22. package/components/accordion.tsx +20 -5
  23. package/components/button.tsx +51 -36
  24. package/components/client-root.tsx +138 -2
  25. package/components/file-input.tsx +65 -3
  26. package/components/index.ts +1 -0
  27. package/components/input.tsx +1 -1
  28. package/components/link.tsx +46 -16
  29. package/components/logger-popup.tsx +213 -0
  30. package/components/modal.tsx +32 -16
  31. package/components/plugin-module.tsx +62 -3
  32. package/components/price.tsx +2 -2
  33. package/components/select.tsx +1 -1
  34. package/components/selected-payment-option-view.tsx +21 -0
  35. package/components/theme-editor/blocks/accordion-block.tsx +136 -0
  36. package/components/theme-editor/blocks/block-renderer-registry.tsx +77 -0
  37. package/components/theme-editor/blocks/button-block.tsx +593 -0
  38. package/components/theme-editor/blocks/counter-block.tsx +348 -0
  39. package/components/theme-editor/blocks/divider-block.tsx +20 -0
  40. package/components/theme-editor/blocks/embed-block.tsx +208 -0
  41. package/components/theme-editor/blocks/group-block.tsx +116 -0
  42. package/components/theme-editor/blocks/hotspot-block.tsx +147 -0
  43. package/components/theme-editor/blocks/icon-block.tsx +230 -0
  44. package/components/theme-editor/blocks/image-block.tsx +137 -0
  45. package/components/theme-editor/blocks/image-gallery-block.tsx +269 -0
  46. package/components/theme-editor/blocks/input-block.tsx +123 -0
  47. package/components/theme-editor/blocks/link-block.tsx +216 -0
  48. package/components/theme-editor/blocks/lottie-block.tsx +325 -0
  49. package/components/theme-editor/blocks/map-block.tsx +89 -0
  50. package/components/theme-editor/blocks/slider-block.tsx +595 -0
  51. package/components/theme-editor/blocks/tab-block.tsx +10 -0
  52. package/components/theme-editor/blocks/text-block.tsx +52 -0
  53. package/components/theme-editor/blocks/video-block.tsx +122 -0
  54. package/components/theme-editor/components/action-toolbar.tsx +305 -0
  55. package/components/theme-editor/components/designer-overlay.tsx +74 -0
  56. package/components/theme-editor/components/with-designer-features.tsx +142 -0
  57. package/components/theme-editor/dynamic-font-loader.tsx +79 -0
  58. package/components/theme-editor/hooks/use-designer-features.tsx +100 -0
  59. package/components/theme-editor/hooks/use-external-designer.tsx +95 -0
  60. package/components/theme-editor/hooks/use-native-widget-data.ts +188 -0
  61. package/components/theme-editor/hooks/use-visibility-context.ts +27 -0
  62. package/components/theme-editor/placeholder-registry.ts +31 -0
  63. package/components/theme-editor/sections/before-after-section.tsx +245 -0
  64. package/components/theme-editor/sections/contact-form-section.tsx +563 -0
  65. package/components/theme-editor/sections/countdown-campaign-banner-section.tsx +433 -0
  66. package/components/theme-editor/sections/coupon-banner-section.tsx +710 -0
  67. package/components/theme-editor/sections/divider-section.tsx +62 -0
  68. package/components/theme-editor/sections/featured-product-spotlight-section.tsx +507 -0
  69. package/components/theme-editor/sections/find-in-store-section.tsx +1995 -0
  70. package/components/theme-editor/sections/hover-showcase-section.tsx +326 -0
  71. package/components/theme-editor/sections/image-hotspot-section.tsx +142 -0
  72. package/components/theme-editor/sections/installment-options-section.tsx +1065 -0
  73. package/components/theme-editor/sections/notification-banner-section.tsx +173 -0
  74. package/components/theme-editor/sections/order-tracking-lookup-section.tsx +1379 -0
  75. package/components/theme-editor/sections/posts-slider-section.tsx +472 -0
  76. package/components/theme-editor/sections/pre-order-launch-banner-section.tsx +663 -0
  77. package/components/theme-editor/sections/section-renderer-registry.tsx +89 -0
  78. package/components/theme-editor/sections/section-wrapper.tsx +135 -0
  79. package/components/theme-editor/sections/shipping-threshold-progress-section.tsx +586 -0
  80. package/components/theme-editor/sections/stats-counter-section.tsx +486 -0
  81. package/components/theme-editor/sections/tabs-section.tsx +578 -0
  82. package/components/theme-editor/theme-block.tsx +102 -0
  83. package/components/theme-editor/theme-placeholder-client.tsx +218 -0
  84. package/components/theme-editor/theme-placeholder-wrapper.tsx +732 -0
  85. package/components/theme-editor/theme-placeholder.tsx +288 -0
  86. package/components/theme-editor/theme-section.tsx +1224 -0
  87. package/components/theme-editor/theme-settings-context.tsx +13 -0
  88. package/components/theme-editor/utils/index.ts +792 -0
  89. package/components/theme-editor/utils/iterator-utils.ts +234 -0
  90. package/components/theme-editor/utils/publish-window.ts +86 -0
  91. package/components/theme-editor/utils/visibility-rules.ts +188 -0
  92. package/data/client/account.ts +17 -2
  93. package/data/client/api.ts +2 -0
  94. package/data/client/basket.ts +66 -5
  95. package/data/client/checkout.ts +391 -99
  96. package/data/client/misc.ts +38 -2
  97. package/data/client/product.ts +19 -2
  98. package/data/client/user.ts +16 -8
  99. package/data/server/category.ts +11 -9
  100. package/data/server/flatpage.ts +11 -4
  101. package/data/server/form.ts +15 -4
  102. package/data/server/landingpage.ts +11 -4
  103. package/data/server/list.ts +5 -4
  104. package/data/server/menu.ts +11 -3
  105. package/data/server/product.ts +111 -55
  106. package/data/server/seo.ts +14 -4
  107. package/data/server/special-page.ts +5 -4
  108. package/data/server/widget.ts +90 -5
  109. package/data/urls.ts +16 -5
  110. package/hocs/client/with-segment-defaults.tsx +2 -2
  111. package/hocs/server/with-segment-defaults.tsx +65 -20
  112. package/hooks/index.ts +4 -0
  113. package/hooks/use-localization.ts +24 -10
  114. package/hooks/use-logger-context.tsx +114 -0
  115. package/hooks/use-logger.ts +92 -0
  116. package/hooks/use-loyalty-availability.ts +21 -0
  117. package/hooks/use-payment-options.ts +2 -1
  118. package/hooks/use-pz-params.ts +37 -0
  119. package/hooks/use-router.ts +51 -14
  120. package/hooks/use-sentry-uncaught-errors.ts +24 -0
  121. package/instrumentation/index.ts +10 -1
  122. package/instrumentation/node.ts +2 -20
  123. package/jest.config.js +25 -0
  124. package/lib/cache-handler.mjs +534 -16
  125. package/lib/cache.ts +272 -37
  126. package/localization/index.ts +2 -1
  127. package/localization/provider.tsx +2 -5
  128. package/middlewares/bfcache-headers.ts +18 -0
  129. package/middlewares/checkout-provider.ts +1 -1
  130. package/middlewares/complete-gpay.ts +32 -26
  131. package/middlewares/complete-masterpass.ts +33 -26
  132. package/middlewares/complete-wallet.ts +182 -0
  133. package/middlewares/default.ts +360 -215
  134. package/middlewares/index.ts +10 -2
  135. package/middlewares/locale.ts +34 -11
  136. package/middlewares/masterpass-rest-callback.ts +230 -0
  137. package/middlewares/oauth-login.ts +200 -57
  138. package/middlewares/pretty-url.ts +21 -8
  139. package/middlewares/redirection-payment.ts +32 -26
  140. package/middlewares/saved-card-redirection.ts +33 -26
  141. package/middlewares/three-d-redirection.ts +32 -26
  142. package/middlewares/url-redirection.ts +11 -1
  143. package/middlewares/wallet-complete-redirection.ts +206 -0
  144. package/package.json +25 -10
  145. package/plugins.d.ts +19 -4
  146. package/plugins.js +10 -1
  147. package/redux/actions.ts +47 -0
  148. package/redux/middlewares/checkout.ts +63 -138
  149. package/redux/middlewares/index.ts +14 -10
  150. package/redux/middlewares/pre-order/address.ts +7 -2
  151. package/redux/middlewares/pre-order/attribute-based-shipping-option.ts +7 -1
  152. package/redux/middlewares/pre-order/data-source-shipping-option.ts +7 -1
  153. package/redux/middlewares/pre-order/delivery-option.ts +7 -1
  154. package/redux/middlewares/pre-order/index.ts +16 -10
  155. package/redux/middlewares/pre-order/installment-option.ts +8 -1
  156. package/redux/middlewares/pre-order/payment-option-reset.ts +37 -0
  157. package/redux/middlewares/pre-order/payment-option.ts +7 -1
  158. package/redux/middlewares/pre-order/pre-order-validation.ts +8 -3
  159. package/redux/middlewares/pre-order/redirection.ts +8 -2
  160. package/redux/middlewares/pre-order/set-pre-order.ts +6 -2
  161. package/redux/middlewares/pre-order/shipping-option.ts +7 -1
  162. package/redux/middlewares/pre-order/shipping-step.ts +5 -1
  163. package/redux/reducers/checkout.ts +23 -3
  164. package/redux/reducers/index.ts +11 -3
  165. package/redux/reducers/root.ts +7 -2
  166. package/redux/reducers/widget.ts +80 -0
  167. package/sentry/index.ts +69 -13
  168. package/tailwind/content.js +16 -0
  169. package/types/commerce/account.ts +5 -1
  170. package/types/commerce/checkout.ts +35 -1
  171. package/types/commerce/widget.ts +33 -0
  172. package/types/index.ts +101 -6
  173. package/types/next-auth.d.ts +2 -2
  174. package/types/widget.ts +80 -0
  175. package/utils/app-fetch.ts +7 -2
  176. package/utils/generate-commerce-search-params.ts +3 -2
  177. package/utils/get-checkout-path.ts +3 -0
  178. package/utils/get-root-hostname.ts +28 -0
  179. package/utils/index.ts +64 -10
  180. package/utils/localization.ts +4 -0
  181. package/utils/mobile-3d-iframe.ts +8 -2
  182. package/utils/override-middleware.ts +7 -12
  183. package/utils/pz-segments.ts +92 -0
  184. package/utils/redirect-ignore.ts +35 -0
  185. package/utils/redirect.ts +9 -3
  186. package/utils/redirection-iframe.ts +8 -2
  187. package/utils/widget-styles.ts +107 -0
  188. package/views/error-page.tsx +93 -0
  189. package/with-pz-config.js +13 -6
@@ -0,0 +1,1995 @@
1
+ 'use client';
2
+
3
+ import { useLocalization } from '@akinon/next/hooks';
4
+ import { buildClientRequestUrl } from '@akinon/next/utils';
5
+ import Settings from 'settings';
6
+ import clsx from 'clsx';
7
+ import React, { useEffect, useMemo, useState } from 'react';
8
+
9
+ import { useGetRetailStoreQuery } from '../../../data/client/address';
10
+ import {
11
+ useGetProductByPkQuery,
12
+ useGetRetailStoreStockMutation
13
+ } from '../../../data/client/product';
14
+ import {
15
+ Product,
16
+ ProductResult,
17
+ StockResultType,
18
+ VariantType
19
+ } from '../../../types';
20
+ import { Image } from '../../image';
21
+ import ThemeBlock, { Block } from '../theme-block';
22
+ import { WithDesignerFeatures } from '../components/with-designer-features';
23
+ import { useThemeSettingsContext } from '../theme-settings-context';
24
+ import { Section } from '../theme-section';
25
+ import {
26
+ getCSSStyles,
27
+ getResponsiveValue,
28
+ resolveThemeCssVariables
29
+ } from '../utils';
30
+
31
+ interface FindInStoreSectionProps {
32
+ section: Section;
33
+ currentBreakpoint?: string;
34
+ placeholderId?: string;
35
+ isDesigner?: boolean;
36
+ selectedBlockId?: string | null;
37
+ }
38
+
39
+ type ProductRecord = Product & Record<string, unknown>;
40
+
41
+ type StoreOption = {
42
+ pk: number | string;
43
+ name: string;
44
+ };
45
+
46
+ type StoreStockItem = StockResultType[number];
47
+
48
+ const readString = (
49
+ properties: Record<string, unknown> | undefined,
50
+ key: string,
51
+ fallback: string,
52
+ breakpoint: string
53
+ ): string => {
54
+ const value = getResponsiveValue(properties?.[key], breakpoint, fallback);
55
+ if (value == null) return fallback;
56
+ return String(value);
57
+ };
58
+
59
+ const readBoolean = (
60
+ properties: Record<string, unknown> | undefined,
61
+ key: string,
62
+ fallback: boolean,
63
+ breakpoint: string
64
+ ): boolean => {
65
+ const value = getResponsiveValue(properties?.[key], breakpoint, fallback);
66
+ if (typeof value === 'boolean') return value;
67
+ if (typeof value === 'string') {
68
+ const normalized = value.trim().toLowerCase();
69
+ if (normalized === 'true') return true;
70
+ if (normalized === 'false') return false;
71
+ }
72
+ return fallback;
73
+ };
74
+
75
+ const readNumber = (
76
+ properties: Record<string, unknown> | undefined,
77
+ key: string,
78
+ fallback: number,
79
+ breakpoint: string
80
+ ): number => {
81
+ const value = getResponsiveValue(properties?.[key], breakpoint, fallback);
82
+ const numeric =
83
+ typeof value === 'number' ? value : Number.parseInt(String(value), 10);
84
+ return Number.isFinite(numeric) ? numeric : fallback;
85
+ };
86
+
87
+ const parsePositiveInt = (value: unknown): number | null => {
88
+ const parsed =
89
+ typeof value === 'number'
90
+ ? value
91
+ : Number.parseInt(String(value || ''), 10);
92
+
93
+ if (!Number.isFinite(parsed) || parsed <= 0) {
94
+ return null;
95
+ }
96
+
97
+ return parsed;
98
+ };
99
+
100
+ const getProductImage = (product: Partial<ProductRecord> | null): string => {
101
+ if (!product) return '';
102
+ if (typeof product.image === 'string' && product.image) return product.image;
103
+
104
+ const imageSet = product.productimage_set;
105
+ if (Array.isArray(imageSet) && imageSet[0]?.image) {
106
+ return String(imageSet[0].image);
107
+ }
108
+
109
+ return '';
110
+ };
111
+
112
+ const parsePriceValue = (value: unknown): number | null => {
113
+ if (typeof value === 'number' && Number.isFinite(value)) {
114
+ return value;
115
+ }
116
+
117
+ if (typeof value !== 'string') return null;
118
+
119
+ const cleaned = value.trim().replace(/[^\d,.-]/g, '');
120
+ if (!cleaned) return null;
121
+
122
+ let normalized = cleaned;
123
+ const hasComma = normalized.includes(',');
124
+ const hasDot = normalized.includes('.');
125
+
126
+ if (hasComma && hasDot) {
127
+ const lastComma = normalized.lastIndexOf(',');
128
+ const lastDot = normalized.lastIndexOf('.');
129
+ normalized =
130
+ lastComma > lastDot
131
+ ? normalized.replace(/\./g, '').replace(',', '.')
132
+ : normalized.replace(/,/g, '');
133
+ } else if (hasComma) {
134
+ const unsigned = normalized.replace(/^-/, '');
135
+ const isThousandsPattern = /^\d{1,3}(,\d{3})+$/.test(unsigned);
136
+ normalized = isThousandsPattern
137
+ ? normalized.replace(/,/g, '')
138
+ : normalized.replace(/,/g, '.');
139
+ }
140
+
141
+ const parsed = Number(normalized);
142
+ return Number.isFinite(parsed) ? parsed : null;
143
+ };
144
+
145
+ const normalizeCurrencyLabel = (currency: unknown): string => {
146
+ const raw = String(currency || '')
147
+ .trim()
148
+ .toUpperCase();
149
+ if (!raw) return 'TL';
150
+
151
+ const map: Record<string, string> = {
152
+ TRY: 'TL',
153
+ TL: 'TL',
154
+ USD: 'USD',
155
+ EUR: 'EUR',
156
+ GBP: 'GBP'
157
+ };
158
+
159
+ return map[raw] || raw;
160
+ };
161
+
162
+ const formatPriceWithCurrency = (
163
+ value: unknown,
164
+ currency: unknown
165
+ ): string | null => {
166
+ if (value == null || value === '') return null;
167
+
168
+ const price = parsePriceValue(value);
169
+ if (price === null) {
170
+ const text = String(value).trim();
171
+ return text || null;
172
+ }
173
+
174
+ const hasDecimals = Math.abs(price % 1) > 0.00001;
175
+ const formatted = price.toLocaleString('tr-TR', {
176
+ minimumFractionDigits: hasDecimals ? 2 : 0,
177
+ maximumFractionDigits: 2
178
+ });
179
+
180
+ return `${formatted} ${normalizeCurrencyLabel(currency)}`;
181
+ };
182
+
183
+ const getDiscountState = (
184
+ currentPrice: unknown,
185
+ retailPrice: unknown
186
+ ): { current: string | null; retail: string | null; hasDiscount: boolean } => {
187
+ const currentNumeric = parsePriceValue(currentPrice);
188
+ const retailNumeric = parsePriceValue(retailPrice);
189
+
190
+ return {
191
+ current: String(currentPrice || '').trim() ? String(currentPrice) : null,
192
+ retail: String(retailPrice || '').trim() ? String(retailPrice) : null,
193
+ hasDiscount:
194
+ currentNumeric !== null &&
195
+ retailNumeric !== null &&
196
+ retailNumeric > currentNumeric
197
+ };
198
+ };
199
+
200
+ const findSizeAttributeKey = (): string => {
201
+ const match = Settings.commonProductAttributes.find(
202
+ (item) => item.translationKey === 'size'
203
+ );
204
+
205
+ return String(match?.key || 'size').toLowerCase();
206
+ };
207
+
208
+ const getSizeOptions = (variants: VariantType[] | undefined) => {
209
+ const sizeKey = findSizeAttributeKey();
210
+
211
+ if (!variants?.length) return [];
212
+
213
+ const seen = new Set<string>();
214
+
215
+ return variants
216
+ .filter((variant) => {
217
+ const attributeName = String(variant.attribute_name || '').toLowerCase();
218
+ const attributeKey = String(variant.attribute_key || '').toLowerCase();
219
+
220
+ return (
221
+ attributeName === sizeKey ||
222
+ attributeKey === sizeKey ||
223
+ attributeName.includes('size') ||
224
+ attributeName.includes('beden') ||
225
+ attributeKey.includes('size') ||
226
+ attributeKey.includes('beden')
227
+ );
228
+ })
229
+ .flatMap((variant) => variant.options || [])
230
+ .filter((option) => {
231
+ if (!option?.value || seen.has(String(option.value))) {
232
+ return false;
233
+ }
234
+ seen.add(String(option.value));
235
+ return true;
236
+ })
237
+ .map((option) => ({
238
+ value: String(option.value),
239
+ label: option.label || String(option.value)
240
+ }));
241
+ };
242
+
243
+ const getStockTone = (
244
+ t: (key: string) => string,
245
+ stock: string | number
246
+ ): { label: string; background: string; color: string } => {
247
+ const numeric = Number.parseInt(String(stock || 0), 10);
248
+
249
+ if (!numeric) {
250
+ return {
251
+ label: t('product.find_in_store.out_of_stock'),
252
+ background: '#fee2e2',
253
+ color: '#b91c1c'
254
+ };
255
+ }
256
+
257
+ if (numeric < 5) {
258
+ return {
259
+ label: t('product.find_in_store.limited_stock'),
260
+ background: '#fef3c7',
261
+ color: '#92400e'
262
+ };
263
+ }
264
+
265
+ return {
266
+ label: t('product.find_in_store.available'),
267
+ background: '#dcfce7',
268
+ color: '#166534'
269
+ };
270
+ };
271
+
272
+ const createStoreHours = (
273
+ hours: StoreStockItem['store_hours'],
274
+ t: (key: string) => string
275
+ ): string => {
276
+ if (!Array.isArray(hours) || !hours.length) {
277
+ return t('product.find_in_store.unknown');
278
+ }
279
+
280
+ const firstEntry = Array.isArray(hours[0]) ? hours[0] : hours;
281
+ if (!Array.isArray(firstEntry) || firstEntry.length < 2) {
282
+ return t('product.find_in_store.unknown');
283
+ }
284
+
285
+ const start =
286
+ typeof firstEntry[0] === 'string' ? firstEntry[0].slice(0, 5) : '';
287
+ const end =
288
+ typeof firstEntry[1] === 'string' ? firstEntry[1].slice(0, 5) : '';
289
+
290
+ return start && end
291
+ ? `${start} - ${end}`
292
+ : t('product.find_in_store.unknown');
293
+ };
294
+
295
+ const replaceTokens = (
296
+ input: string,
297
+ tokens: Record<string, string>
298
+ ): string => {
299
+ let output = input;
300
+
301
+ Object.entries(tokens).forEach(([key, value]) => {
302
+ output = output.split(`{{${key}}}`).join(value);
303
+ });
304
+
305
+ return output;
306
+ };
307
+
308
+ const replaceTokensInUnknown = (
309
+ value: unknown,
310
+ tokens: Record<string, string>
311
+ ): unknown => {
312
+ if (typeof value === 'string') {
313
+ return replaceTokens(value, tokens);
314
+ }
315
+
316
+ if (Array.isArray(value)) {
317
+ return value.map((item) => replaceTokensInUnknown(item, tokens));
318
+ }
319
+
320
+ if (typeof value === 'object' && value !== null) {
321
+ return Object.fromEntries(
322
+ Object.entries(value).map(([key, nestedValue]) => [
323
+ key,
324
+ replaceTokensInUnknown(nestedValue, tokens)
325
+ ])
326
+ );
327
+ }
328
+
329
+ return value;
330
+ };
331
+
332
+ const resolveResponsiveString = (
333
+ value: unknown,
334
+ breakpoint: string
335
+ ): string => {
336
+ if (typeof value === 'string') return value;
337
+
338
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
339
+ const responsiveValue = getResponsiveValue(value, breakpoint, '');
340
+ if (typeof responsiveValue === 'string') {
341
+ return responsiveValue;
342
+ }
343
+ }
344
+
345
+ return '';
346
+ };
347
+
348
+ const getFindInStoreRole = (
349
+ block: Pick<Block, 'properties'>,
350
+ breakpoint: string
351
+ ): string =>
352
+ resolveResponsiveString(block.properties?.findInStoreRole, breakpoint);
353
+
354
+ const CONTROL_LAYOUT_STYLE_KEYS = new Set([
355
+ 'display',
356
+ 'width',
357
+ 'height',
358
+ 'min-width',
359
+ 'min-height',
360
+ 'max-width',
361
+ 'max-height',
362
+ 'padding-top',
363
+ 'padding-right',
364
+ 'padding-bottom',
365
+ 'padding-left',
366
+ 'font-size',
367
+ 'font-weight',
368
+ 'line-height',
369
+ 'align-items',
370
+ 'justify-content'
371
+ ]);
372
+
373
+ const cloneFindInStoreBlock = (
374
+ block: Block,
375
+ tokens: Record<string, string>,
376
+ overrides?: {
377
+ properties?: Record<string, unknown>;
378
+ value?: unknown;
379
+ }
380
+ ): Block => ({
381
+ ...block,
382
+ styleSourceId: block.styleSourceId || block.id,
383
+ value:
384
+ overrides && Object.prototype.hasOwnProperty.call(overrides, 'value')
385
+ ? overrides.value
386
+ : (replaceTokensInUnknown(block.value, tokens) as Block['value']),
387
+ properties: {
388
+ ...(replaceTokensInUnknown(block.properties || {}, tokens) as Record<
389
+ string,
390
+ unknown
391
+ >),
392
+ ...(overrides?.properties || {})
393
+ },
394
+ styles: replaceTokensInUnknown(block.styles || {}, tokens) as Block['styles'],
395
+ blocks: block.blocks?.map((childBlock) =>
396
+ cloneFindInStoreBlock(childBlock, tokens)
397
+ )
398
+ });
399
+
400
+ const findFindInStoreRoleBlock = (
401
+ blocks: Block[],
402
+ role: string,
403
+ breakpoint: string
404
+ ): Block | null => {
405
+ for (const block of blocks) {
406
+ if (getFindInStoreRole(block, breakpoint) === role) {
407
+ return block;
408
+ }
409
+
410
+ if (block.blocks?.length) {
411
+ const nestedMatch = findFindInStoreRoleBlock(
412
+ block.blocks,
413
+ role,
414
+ breakpoint
415
+ );
416
+
417
+ if (nestedMatch) {
418
+ return nestedMatch;
419
+ }
420
+ }
421
+ }
422
+
423
+ return null;
424
+ };
425
+
426
+ const buildPrettyUrlCandidates = (rawInput: string): string[] => {
427
+ const trimmed = rawInput.trim();
428
+ if (!trimmed) return [];
429
+
430
+ let pathname = trimmed;
431
+
432
+ try {
433
+ pathname = new URL(trimmed, window.location.origin).pathname;
434
+ } catch (_error) {
435
+ pathname = trimmed;
436
+ }
437
+
438
+ pathname = pathname.split('?')[0]?.split('#')[0]?.trim() || '';
439
+ if (!pathname) return [];
440
+
441
+ if (!pathname.startsWith('/')) {
442
+ pathname = `/${pathname}`;
443
+ }
444
+
445
+ pathname = pathname.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
446
+ const pathSegments = pathname.split('/').filter(Boolean);
447
+ if (!pathSegments.length) return [];
448
+
449
+ const candidates = new Set<string>();
450
+ const addCandidate = (segments: string[]) => {
451
+ if (!segments.length) return;
452
+ candidates.add(`/${segments.join('/')}/`);
453
+ };
454
+
455
+ addCandidate(pathSegments);
456
+
457
+ const currentSegments = window.location.pathname.split('/').filter(Boolean);
458
+ const currentPrefix = currentSegments.slice(0, 3);
459
+ const hasCurrentPrefix =
460
+ currentPrefix.length > 0 &&
461
+ pathSegments.length > currentPrefix.length &&
462
+ currentPrefix.every((segment, index) => pathSegments[index] === segment);
463
+
464
+ if (hasCurrentPrefix) {
465
+ addCandidate(pathSegments.slice(currentPrefix.length));
466
+ }
467
+
468
+ for (let cut = 1; cut <= Math.min(3, pathSegments.length - 1); cut += 1) {
469
+ addCandidate(pathSegments.slice(cut));
470
+ }
471
+
472
+ return Array.from(candidates);
473
+ };
474
+
475
+ const resolveProductPkFromInput = async (
476
+ rawInput: string
477
+ ): Promise<number | null> => {
478
+ const directPk = parsePositiveInt(rawInput.trim());
479
+ if (directPk) {
480
+ return directPk;
481
+ }
482
+
483
+ const candidates = buildPrettyUrlCandidates(rawInput);
484
+
485
+ for (const candidate of candidates) {
486
+ const requestUrl = buildClientRequestUrl(
487
+ `/pretty_urls/?new_path__exact=${encodeURIComponent(candidate)}`,
488
+ {
489
+ cache: false
490
+ }
491
+ );
492
+
493
+ const response = await fetch(requestUrl);
494
+
495
+ if (!response.ok) {
496
+ continue;
497
+ }
498
+
499
+ const data = await response.json();
500
+ const firstResult = Array.isArray(data?.results) ? data.results[0] : null;
501
+ const oldPath =
502
+ firstResult && typeof firstResult.old_path === 'string'
503
+ ? firstResult.old_path
504
+ : '';
505
+ const kwargsProductId = parsePositiveInt(firstResult?.kwargs?.product_id);
506
+ const kwargsPk = parsePositiveInt(firstResult?.kwargs?.pk);
507
+
508
+ const productMatch = oldPath.match(/^\/product\/(\d+)\/$/);
509
+
510
+ if (productMatch) {
511
+ return Number.parseInt(productMatch[1], 10);
512
+ }
513
+
514
+ if (kwargsProductId) {
515
+ return kwargsProductId;
516
+ }
517
+
518
+ if (kwargsPk) {
519
+ return kwargsPk;
520
+ }
521
+ }
522
+
523
+ return null;
524
+ };
525
+
526
+ export default function FindInStoreSection({
527
+ section,
528
+ currentBreakpoint = 'desktop',
529
+ placeholderId = '',
530
+ isDesigner = false,
531
+ selectedBlockId = null
532
+ }: FindInStoreSectionProps) {
533
+ const { t } = useLocalization();
534
+ const themeSettings = useThemeSettingsContext();
535
+
536
+ const [productUrlInput, setProductUrlInput] = useState('');
537
+ const [resolvedProductPk, setResolvedProductPk] = useState<number | null>(
538
+ null
539
+ );
540
+ const [selectedCityId, setSelectedCityId] = useState('');
541
+ const [selectedSize, setSelectedSize] = useState('');
542
+ const [formError, setFormError] = useState('');
543
+ const [searchError, setSearchError] = useState('');
544
+ const [hasSubmitted, setHasSubmitted] = useState(false);
545
+ const [isResolvingProduct, setIsResolvingProduct] = useState(false);
546
+
547
+ const setupText = readString(
548
+ section.properties,
549
+ 'setup-text',
550
+ 'Paste a product detail URL or enter a product PK to load the product before checking pickup availability.',
551
+ currentBreakpoint
552
+ );
553
+ const urlPlaceholder = readString(
554
+ section.properties,
555
+ 'product-url-placeholder',
556
+ 'Paste a product URL or enter a product PK',
557
+ currentBreakpoint
558
+ );
559
+ const searchButtonText = readString(
560
+ section.properties,
561
+ 'search-button-text',
562
+ 'SEARCH PRODUCT',
563
+ currentBreakpoint
564
+ );
565
+ const urlRequiredText = readString(
566
+ section.properties,
567
+ 'url-required-text',
568
+ 'Please enter a product URL or product PK.',
569
+ currentBreakpoint
570
+ );
571
+ const invalidProductText = readString(
572
+ section.properties,
573
+ 'invalid-product-text',
574
+ 'We could not find a product for this URL or PK. Please try a valid product detail link or numeric product ID.',
575
+ currentBreakpoint
576
+ );
577
+ const initialProductInput = readString(
578
+ section.properties,
579
+ 'initial-product-input',
580
+ '',
581
+ currentBreakpoint
582
+ );
583
+ const buttonText = readString(
584
+ section.properties,
585
+ 'button-text',
586
+ t('product.find_in_store.submit'),
587
+ currentBreakpoint
588
+ );
589
+ const emptyStateText = readString(
590
+ section.properties,
591
+ 'empty-state-text',
592
+ t('product.find_in_store.store_not_found'),
593
+ currentBreakpoint
594
+ );
595
+
596
+ const { data: productResponse, isLoading: productLoading } =
597
+ useGetProductByPkQuery(resolvedProductPk as number, {
598
+ skip: !resolvedProductPk
599
+ });
600
+
601
+ const fullProduct = (productResponse as ProductResult | undefined)?.product;
602
+ const variants = (productResponse as ProductResult | undefined)?.variants;
603
+ const product = fullProduct as ProductRecord | null;
604
+ const activeProductPk =
605
+ parsePositiveInt(fullProduct?.pk) ?? resolvedProductPk;
606
+
607
+ const { data: retailStore } = useGetRetailStoreQuery();
608
+ const [
609
+ getRetailStock,
610
+ {
611
+ data: stockResponse,
612
+ isLoading: stockLoading,
613
+ isError: isStockError,
614
+ reset: resetStock
615
+ }
616
+ ] = useGetRetailStoreStockMutation();
617
+
618
+ const cityOptions = useMemo(
619
+ () =>
620
+ ((retailStore?.results || []) as StoreOption[]).map((item) => ({
621
+ value: String(item.pk),
622
+ label: item.name
623
+ })),
624
+ [retailStore?.results]
625
+ );
626
+
627
+ const sizeOptions = useMemo(() => getSizeOptions(variants), [variants]);
628
+
629
+ useEffect(() => {
630
+ setSelectedCityId('');
631
+ setSelectedSize('');
632
+ setFormError('');
633
+ setHasSubmitted(false);
634
+ resetStock();
635
+ }, [resolvedProductPk]); // eslint-disable-line react-hooks/exhaustive-deps
636
+
637
+ useEffect(() => {
638
+ if (sizeOptions.length === 1 && !selectedSize) {
639
+ setSelectedSize(sizeOptions[0].value);
640
+ }
641
+ }, [selectedSize, sizeOptions]);
642
+
643
+ useEffect(() => {
644
+ let cancelled = false;
645
+
646
+ const preloadInitialProduct = async () => {
647
+ const trimmedInitialProduct = initialProductInput.trim();
648
+ if (!trimmedInitialProduct) return;
649
+
650
+ setProductUrlInput(currentValue => currentValue || trimmedInitialProduct);
651
+ setIsResolvingProduct(true);
652
+
653
+ try {
654
+ const resolvedPk = await resolveProductPkFromInput(trimmedInitialProduct);
655
+ if (!cancelled && resolvedPk) {
656
+ setResolvedProductPk(resolvedPk);
657
+ setSearchError('');
658
+ }
659
+ } catch {
660
+ if (!cancelled) {
661
+ setSearchError('');
662
+ }
663
+ } finally {
664
+ if (!cancelled) {
665
+ setIsResolvingProduct(false);
666
+ }
667
+ }
668
+ };
669
+
670
+ preloadInitialProduct();
671
+
672
+ return () => {
673
+ cancelled = true;
674
+ };
675
+ }, [initialProductInput]);
676
+
677
+ const showProductCard = readBoolean(
678
+ section.properties,
679
+ 'show-product-card',
680
+ true,
681
+ currentBreakpoint
682
+ );
683
+ const showWorkingHours = readBoolean(
684
+ section.properties,
685
+ 'show-working-hours',
686
+ true,
687
+ currentBreakpoint
688
+ );
689
+ const showDirections = readBoolean(
690
+ section.properties,
691
+ 'show-directions',
692
+ true,
693
+ currentBreakpoint
694
+ );
695
+ const maxResults = readNumber(
696
+ section.properties,
697
+ 'max-results',
698
+ 6,
699
+ currentBreakpoint
700
+ );
701
+
702
+ const maxWidth = getResponsiveValue(
703
+ section.styles?.['max-width'],
704
+ currentBreakpoint,
705
+ 'normal'
706
+ ) as string;
707
+ const maxWidthClass =
708
+ maxWidth === 'narrow'
709
+ ? 'max-w-4xl'
710
+ : maxWidth === 'normal'
711
+ ? 'max-w-7xl'
712
+ : maxWidth === 'full'
713
+ ? 'w-full'
714
+ : '';
715
+ const hasMaxWidth = maxWidth !== 'none' && maxWidth !== 'full';
716
+
717
+ const filteredStyles = Object.fromEntries(
718
+ Object.entries(section.styles || {}).filter(
719
+ ([key]) =>
720
+ ![
721
+ 'max-width',
722
+ 'form-gap',
723
+ 'input-background-color',
724
+ 'input-text-color',
725
+ 'input-border-color',
726
+ 'input-border-radius',
727
+ 'input-padding',
728
+ 'button-background-color',
729
+ 'button-text-color',
730
+ 'button-hover-background-color',
731
+ 'button-border-radius',
732
+ 'button-padding',
733
+ 'panel-background-color',
734
+ 'panel-border-color',
735
+ 'panel-border-radius',
736
+ 'product-card-background-color',
737
+ 'product-card-border-color',
738
+ 'result-card-background-color',
739
+ 'result-card-border-color',
740
+ 'result-card-border-radius'
741
+ ].includes(key)
742
+ )
743
+ );
744
+
745
+ const sectionStyles = getCSSStyles(
746
+ filteredStyles,
747
+ themeSettings,
748
+ currentBreakpoint
749
+ );
750
+
751
+ const formGap = Number(
752
+ getResponsiveValue(section.styles?.['form-gap'], currentBreakpoint, 16)
753
+ );
754
+ const inputBg = resolveThemeCssVariables(
755
+ String(
756
+ getResponsiveValue(
757
+ section.styles?.['input-background-color'],
758
+ currentBreakpoint,
759
+ '#ffffff'
760
+ )
761
+ ),
762
+ themeSettings
763
+ );
764
+ const inputTextColor = resolveThemeCssVariables(
765
+ String(
766
+ getResponsiveValue(
767
+ section.styles?.['input-text-color'],
768
+ currentBreakpoint,
769
+ '#0f172a'
770
+ )
771
+ ),
772
+ themeSettings
773
+ );
774
+ const inputBorderColor = resolveThemeCssVariables(
775
+ String(
776
+ getResponsiveValue(
777
+ section.styles?.['input-border-color'],
778
+ currentBreakpoint,
779
+ '#cbd5e1'
780
+ )
781
+ ),
782
+ themeSettings
783
+ );
784
+ const inputBorderRadius = String(
785
+ getResponsiveValue(
786
+ section.styles?.['input-border-radius'],
787
+ currentBreakpoint,
788
+ '10px'
789
+ )
790
+ );
791
+ const inputPadding = String(
792
+ getResponsiveValue(
793
+ section.styles?.['input-padding'],
794
+ currentBreakpoint,
795
+ '14px 16px'
796
+ )
797
+ );
798
+ const buttonBg = resolveThemeCssVariables(
799
+ String(
800
+ getResponsiveValue(
801
+ section.styles?.['button-background-color'],
802
+ currentBreakpoint,
803
+ '#0f172a'
804
+ )
805
+ ),
806
+ themeSettings
807
+ );
808
+ const buttonTextColor = resolveThemeCssVariables(
809
+ String(
810
+ getResponsiveValue(
811
+ section.styles?.['button-text-color'],
812
+ currentBreakpoint,
813
+ '#ffffff'
814
+ )
815
+ ),
816
+ themeSettings
817
+ );
818
+ const buttonHoverBg = resolveThemeCssVariables(
819
+ String(
820
+ getResponsiveValue(
821
+ section.styles?.['button-hover-background-color'],
822
+ currentBreakpoint,
823
+ '#1e293b'
824
+ )
825
+ ),
826
+ themeSettings
827
+ );
828
+ const buttonBorderRadius = String(
829
+ getResponsiveValue(
830
+ section.styles?.['button-border-radius'],
831
+ currentBreakpoint,
832
+ '10px'
833
+ )
834
+ );
835
+ const buttonPadding = String(
836
+ getResponsiveValue(
837
+ section.styles?.['button-padding'],
838
+ currentBreakpoint,
839
+ '14px 28px'
840
+ )
841
+ );
842
+ const panelBackground = resolveThemeCssVariables(
843
+ String(
844
+ getResponsiveValue(
845
+ section.styles?.['panel-background-color'],
846
+ currentBreakpoint,
847
+ '#ffffff'
848
+ )
849
+ ),
850
+ themeSettings
851
+ );
852
+ const panelBorderColor = resolveThemeCssVariables(
853
+ String(
854
+ getResponsiveValue(
855
+ section.styles?.['panel-border-color'],
856
+ currentBreakpoint,
857
+ '#e2e8f0'
858
+ )
859
+ ),
860
+ themeSettings
861
+ );
862
+ const panelBorderRadius = String(
863
+ getResponsiveValue(
864
+ section.styles?.['panel-border-radius'],
865
+ currentBreakpoint,
866
+ '20px'
867
+ )
868
+ );
869
+ const productCardBackground = resolveThemeCssVariables(
870
+ String(
871
+ getResponsiveValue(
872
+ section.styles?.['product-card-background-color'],
873
+ currentBreakpoint,
874
+ '#ffffff'
875
+ )
876
+ ),
877
+ themeSettings
878
+ );
879
+ const productCardBorderColor = resolveThemeCssVariables(
880
+ String(
881
+ getResponsiveValue(
882
+ section.styles?.['product-card-border-color'],
883
+ currentBreakpoint,
884
+ '#e2e8f0'
885
+ )
886
+ ),
887
+ themeSettings
888
+ );
889
+ const resultCardBackground = resolveThemeCssVariables(
890
+ String(
891
+ getResponsiveValue(
892
+ section.styles?.['result-card-background-color'],
893
+ currentBreakpoint,
894
+ '#ffffff'
895
+ )
896
+ ),
897
+ themeSettings
898
+ );
899
+ const resultCardBorderColor = resolveThemeCssVariables(
900
+ String(
901
+ getResponsiveValue(
902
+ section.styles?.['result-card-border-color'],
903
+ currentBreakpoint,
904
+ '#e2e8f0'
905
+ )
906
+ ),
907
+ themeSettings
908
+ );
909
+ const resultCardBorderRadius = String(
910
+ getResponsiveValue(
911
+ section.styles?.['result-card-border-radius'],
912
+ currentBreakpoint,
913
+ '16px'
914
+ )
915
+ );
916
+
917
+ const sortedBlocks = [...(section.blocks || [])]
918
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
919
+ .filter((block) => (isDesigner ? true : !block.hidden));
920
+
921
+ const contentBlocks = sortedBlocks.filter(
922
+ (block) => !getFindInStoreRole(block, currentBreakpoint)
923
+ );
924
+
925
+ const productEyebrowBlock = findFindInStoreRoleBlock(
926
+ sortedBlocks,
927
+ 'product-eyebrow',
928
+ currentBreakpoint
929
+ );
930
+ const productLinkBlock = findFindInStoreRoleBlock(
931
+ sortedBlocks,
932
+ 'product-link',
933
+ currentBreakpoint
934
+ );
935
+ const productImageBlock = findFindInStoreRoleBlock(
936
+ sortedBlocks,
937
+ 'product-image',
938
+ currentBreakpoint
939
+ );
940
+ const productNameBlock = findFindInStoreRoleBlock(
941
+ sortedBlocks,
942
+ 'product-name',
943
+ currentBreakpoint
944
+ );
945
+ const productPriceBlock = findFindInStoreRoleBlock(
946
+ sortedBlocks,
947
+ 'product-price',
948
+ currentBreakpoint
949
+ );
950
+ const productCardWrapperBlock = findFindInStoreRoleBlock(
951
+ sortedBlocks,
952
+ 'product-card-wrapper',
953
+ currentBreakpoint
954
+ );
955
+ const searchPanelWrapperBlock = findFindInStoreRoleBlock(
956
+ sortedBlocks,
957
+ 'search-panel-wrapper',
958
+ currentBreakpoint
959
+ );
960
+ const availabilityPanelWrapperBlock = findFindInStoreRoleBlock(
961
+ sortedBlocks,
962
+ 'availability-panel-wrapper',
963
+ currentBreakpoint
964
+ );
965
+ const resultCardWrapperBlock = findFindInStoreRoleBlock(
966
+ sortedBlocks,
967
+ 'result-card-wrapper',
968
+ currentBreakpoint
969
+ );
970
+ const searchHeadingBlock = findFindInStoreRoleBlock(
971
+ sortedBlocks,
972
+ 'search-heading',
973
+ currentBreakpoint
974
+ );
975
+ const searchDescriptionBlock = findFindInStoreRoleBlock(
976
+ sortedBlocks,
977
+ 'search-description',
978
+ currentBreakpoint
979
+ );
980
+ const searchInputBlock = findFindInStoreRoleBlock(
981
+ sortedBlocks,
982
+ 'search-input',
983
+ currentBreakpoint
984
+ );
985
+ const searchButtonBlock = findFindInStoreRoleBlock(
986
+ sortedBlocks,
987
+ 'search-button',
988
+ currentBreakpoint
989
+ );
990
+ const availabilityHeadingBlock = findFindInStoreRoleBlock(
991
+ sortedBlocks,
992
+ 'availability-heading',
993
+ currentBreakpoint
994
+ );
995
+ const availabilityDescriptionBlock = findFindInStoreRoleBlock(
996
+ sortedBlocks,
997
+ 'availability-description',
998
+ currentBreakpoint
999
+ );
1000
+ const citySelectBlock = findFindInStoreRoleBlock(
1001
+ sortedBlocks,
1002
+ 'city-select',
1003
+ currentBreakpoint
1004
+ );
1005
+ const sizeSelectBlock = findFindInStoreRoleBlock(
1006
+ sortedBlocks,
1007
+ 'size-select',
1008
+ currentBreakpoint
1009
+ );
1010
+ const availabilityButtonBlock = findFindInStoreRoleBlock(
1011
+ sortedBlocks,
1012
+ 'availability-button',
1013
+ currentBreakpoint
1014
+ );
1015
+ const storeNameBlock = findFindInStoreRoleBlock(
1016
+ sortedBlocks,
1017
+ 'store-name',
1018
+ currentBreakpoint
1019
+ );
1020
+ const storeAddressBlock = findFindInStoreRoleBlock(
1021
+ sortedBlocks,
1022
+ 'store-address',
1023
+ currentBreakpoint
1024
+ );
1025
+ const storeStatusBlock = findFindInStoreRoleBlock(
1026
+ sortedBlocks,
1027
+ 'store-status',
1028
+ currentBreakpoint
1029
+ );
1030
+ const storeHoursBlock = findFindInStoreRoleBlock(
1031
+ sortedBlocks,
1032
+ 'store-hours',
1033
+ currentBreakpoint
1034
+ );
1035
+ const storeStockBlock = findFindInStoreRoleBlock(
1036
+ sortedBlocks,
1037
+ 'store-stock',
1038
+ currentBreakpoint
1039
+ );
1040
+ const storeDirectionsBlock = findFindInStoreRoleBlock(
1041
+ sortedBlocks,
1042
+ 'store-directions',
1043
+ currentBreakpoint
1044
+ );
1045
+
1046
+ const postBlockAction = (type: string, blockId: string, label?: string) => {
1047
+ if (!window.parent) return;
1048
+ window.parent.postMessage(
1049
+ {
1050
+ type,
1051
+ data: {
1052
+ placeholderId,
1053
+ sectionId: section.id,
1054
+ blockId,
1055
+ ...(label ? { label } : {})
1056
+ }
1057
+ },
1058
+ '*'
1059
+ );
1060
+ };
1061
+
1062
+ const renderBlock = (block: Block, keyOverride?: string) => {
1063
+ const actionBlockId = block.styleSourceId || block.id;
1064
+
1065
+ return (
1066
+ <ThemeBlock
1067
+ key={keyOverride || block.id}
1068
+ block={block}
1069
+ placeholderId={placeholderId}
1070
+ sectionId={section.id}
1071
+ isDesigner={isDesigner}
1072
+ isSelected={
1073
+ selectedBlockId === actionBlockId || selectedBlockId === block.id
1074
+ }
1075
+ selectedBlockId={selectedBlockId}
1076
+ currentBreakpoint={currentBreakpoint}
1077
+ onMoveUp={() => postBlockAction('MOVE_BLOCK_UP', actionBlockId)}
1078
+ onMoveDown={() => postBlockAction('MOVE_BLOCK_DOWN', actionBlockId)}
1079
+ onDuplicate={() => postBlockAction('DUPLICATE_BLOCK', actionBlockId)}
1080
+ onToggleVisibility={() =>
1081
+ postBlockAction('TOGGLE_BLOCK_VISIBILITY', actionBlockId)
1082
+ }
1083
+ onDelete={() => postBlockAction('DELETE_BLOCK', actionBlockId)}
1084
+ onRename={(newLabel) =>
1085
+ postBlockAction('RENAME_BLOCK', actionBlockId, newLabel)
1086
+ }
1087
+ />
1088
+ );
1089
+ };
1090
+
1091
+ const renderSelectableWrapper = (
1092
+ block: Block | null,
1093
+ children: React.ReactNode,
1094
+ options: {
1095
+ className?: string;
1096
+ style?: React.CSSProperties;
1097
+ keyOverride?: string;
1098
+ } = {}
1099
+ ) => {
1100
+ if (!block) {
1101
+ return (
1102
+ <div
1103
+ key={options.keyOverride}
1104
+ className={options.className}
1105
+ style={options.style}
1106
+ >
1107
+ {children}
1108
+ </div>
1109
+ );
1110
+ }
1111
+
1112
+ const actionBlockId = block.styleSourceId || block.id;
1113
+ const wrapperStyle = {
1114
+ ...(options.style || {}),
1115
+ ...getCSSStyles(block.styles || {}, themeSettings, currentBreakpoint)
1116
+ } as React.CSSProperties;
1117
+
1118
+ return (
1119
+ <WithDesignerFeatures
1120
+ key={options.keyOverride || block.id}
1121
+ block={block}
1122
+ placeholderId={placeholderId}
1123
+ sectionId={section.id}
1124
+ isDesigner={isDesigner}
1125
+ isSelected={
1126
+ selectedBlockId === actionBlockId || selectedBlockId === block.id
1127
+ }
1128
+ currentBreakpoint={currentBreakpoint}
1129
+ className={options.className}
1130
+ style={wrapperStyle}
1131
+ onMoveUp={() => postBlockAction('MOVE_BLOCK_UP', actionBlockId)}
1132
+ onMoveDown={() => postBlockAction('MOVE_BLOCK_DOWN', actionBlockId)}
1133
+ onDuplicate={() => postBlockAction('DUPLICATE_BLOCK', actionBlockId)}
1134
+ onToggleVisibility={() =>
1135
+ postBlockAction('TOGGLE_BLOCK_VISIBILITY', actionBlockId)
1136
+ }
1137
+ onDelete={() => postBlockAction('DELETE_BLOCK', actionBlockId)}
1138
+ onRename={(newLabel) =>
1139
+ postBlockAction('RENAME_BLOCK', actionBlockId, newLabel)
1140
+ }
1141
+ >
1142
+ {children}
1143
+ </WithDesignerFeatures>
1144
+ );
1145
+ };
1146
+
1147
+ const renderSelectableElement = (
1148
+ block: Block | null,
1149
+ children: React.ReactNode,
1150
+ options: {
1151
+ wrapperClassName?: string;
1152
+ wrapperStyle?: React.CSSProperties;
1153
+ keyOverride?: string;
1154
+ } = {}
1155
+ ) => {
1156
+ if (!block) {
1157
+ return (
1158
+ <div
1159
+ key={options.keyOverride}
1160
+ className={options.wrapperClassName}
1161
+ style={options.wrapperStyle}
1162
+ >
1163
+ {children}
1164
+ </div>
1165
+ );
1166
+ }
1167
+
1168
+ const actionBlockId = block.styleSourceId || block.id;
1169
+
1170
+ return (
1171
+ <WithDesignerFeatures
1172
+ key={options.keyOverride || block.id}
1173
+ block={block}
1174
+ placeholderId={placeholderId}
1175
+ sectionId={section.id}
1176
+ isDesigner={isDesigner}
1177
+ isSelected={
1178
+ selectedBlockId === actionBlockId || selectedBlockId === block.id
1179
+ }
1180
+ currentBreakpoint={currentBreakpoint}
1181
+ className={options.wrapperClassName}
1182
+ style={options.wrapperStyle}
1183
+ onMoveUp={() => postBlockAction('MOVE_BLOCK_UP', actionBlockId)}
1184
+ onMoveDown={() => postBlockAction('MOVE_BLOCK_DOWN', actionBlockId)}
1185
+ onDuplicate={() => postBlockAction('DUPLICATE_BLOCK', actionBlockId)}
1186
+ onToggleVisibility={() =>
1187
+ postBlockAction('TOGGLE_BLOCK_VISIBILITY', actionBlockId)
1188
+ }
1189
+ onDelete={() => postBlockAction('DELETE_BLOCK', actionBlockId)}
1190
+ onRename={(newLabel) =>
1191
+ postBlockAction('RENAME_BLOCK', actionBlockId, newLabel)
1192
+ }
1193
+ >
1194
+ {children}
1195
+ </WithDesignerFeatures>
1196
+ );
1197
+ };
1198
+
1199
+ const productName = product?.name || 'Selected product';
1200
+ const productUrl =
1201
+ typeof product?.absolute_url === 'string' ? product.absolute_url : '';
1202
+ const productImage = getProductImage(product);
1203
+ const activePrice = (product?.active_price || {}) as Record<string, unknown>;
1204
+ const priceValue = activePrice.price ?? product?.price;
1205
+ const retailPriceValue = activePrice.retail_price ?? product?.retail_price;
1206
+ const currency =
1207
+ activePrice.currency_type ?? product?.currency_type ?? product?.currency;
1208
+ const priceText = formatPriceWithCurrency(priceValue, currency);
1209
+ const retailPriceText = formatPriceWithCurrency(retailPriceValue, currency);
1210
+ const priceState = getDiscountState(priceValue, retailPriceValue);
1211
+
1212
+ const results = useMemo(
1213
+ () => ((stockResponse || []) as StoreStockItem[]).slice(0, maxResults),
1214
+ [maxResults, stockResponse]
1215
+ );
1216
+ const isMobile = currentBreakpoint === 'mobile';
1217
+ const isProductResolved = !!activeProductPk && !!product;
1218
+
1219
+ const displayProductName = isProductResolved
1220
+ ? productName
1221
+ : 'Yellow Gold Bell-Shaped Necklace';
1222
+ const displayProductPrice = isProductResolved
1223
+ ? priceText || '249,90 USD'
1224
+ : '249,90 USD';
1225
+ const displayProductImage = isProductResolved ? productImage || '' : '';
1226
+ const productCardTokens = {
1227
+ product_name: displayProductName,
1228
+ product_price: displayProductPrice
1229
+ };
1230
+
1231
+ const handleResolveProduct = async () => {
1232
+ if (!productUrlInput.trim()) {
1233
+ setSearchError(urlRequiredText);
1234
+ setResolvedProductPk(null);
1235
+ return;
1236
+ }
1237
+
1238
+ setIsResolvingProduct(true);
1239
+ setSearchError('');
1240
+ setFormError('');
1241
+ setHasSubmitted(false);
1242
+
1243
+ try {
1244
+ const resolvedPk = await resolveProductPkFromInput(productUrlInput);
1245
+
1246
+ if (!resolvedPk) {
1247
+ setResolvedProductPk(null);
1248
+ setSearchError(invalidProductText);
1249
+ return;
1250
+ }
1251
+
1252
+ setResolvedProductPk(resolvedPk);
1253
+ } catch (_error) {
1254
+ setResolvedProductPk(null);
1255
+ setSearchError(invalidProductText);
1256
+ } finally {
1257
+ setIsResolvingProduct(false);
1258
+ }
1259
+ };
1260
+
1261
+ const handleSubmit = async () => {
1262
+ if (!activeProductPk) {
1263
+ setFormError(setupText);
1264
+ return;
1265
+ }
1266
+
1267
+ if (!selectedCityId) {
1268
+ setFormError(t('product.find_in_store.required'));
1269
+ return;
1270
+ }
1271
+
1272
+ const resolvedSize =
1273
+ selectedSize || (sizeOptions.length === 1 ? sizeOptions[0].value : '');
1274
+
1275
+ if (sizeOptions.length > 1 && !resolvedSize) {
1276
+ setFormError(t('product.find_in_store.required'));
1277
+ return;
1278
+ }
1279
+
1280
+ setFormError('');
1281
+ setHasSubmitted(true);
1282
+
1283
+ const params = new URLSearchParams();
1284
+ params.set('city_id', selectedCityId);
1285
+ if (resolvedSize) {
1286
+ params.set('size', resolvedSize);
1287
+ }
1288
+
1289
+ await getRetailStock({
1290
+ productPk: String(activeProductPk),
1291
+ queryString: params.toString()
1292
+ });
1293
+ };
1294
+
1295
+ const inputStyle: React.CSSProperties = {
1296
+ width: '100%',
1297
+ backgroundColor: inputBg,
1298
+ color: inputTextColor,
1299
+ border: `1px solid ${inputBorderColor}`,
1300
+ borderRadius: inputBorderRadius,
1301
+ padding: inputPadding,
1302
+ fontSize: '15px',
1303
+ outline: 'none',
1304
+ fontFamily: 'inherit'
1305
+ };
1306
+
1307
+ const buttonStyle: React.CSSProperties = {
1308
+ width: '100%',
1309
+ backgroundColor: buttonBg,
1310
+ color: buttonTextColor,
1311
+ border: 'none',
1312
+ borderRadius: buttonBorderRadius,
1313
+ padding: buttonPadding,
1314
+ fontSize: '14px',
1315
+ fontWeight: 700,
1316
+ cursor: stockLoading || isResolvingProduct ? 'default' : 'pointer',
1317
+ opacity: stockLoading || isResolvingProduct ? 0.7 : 1,
1318
+ transition: 'all 0.2s ease',
1319
+ fontFamily: 'inherit',
1320
+ whiteSpace: 'nowrap',
1321
+ lineHeight: 1.2
1322
+ };
1323
+
1324
+ const secondaryButtonStyle: React.CSSProperties = {
1325
+ backgroundColor: 'transparent',
1326
+ color: buttonBg,
1327
+ border: `1px solid ${panelBorderColor}`,
1328
+ borderRadius: buttonBorderRadius,
1329
+ padding: '10px 16px',
1330
+ fontSize: '13px',
1331
+ fontWeight: 700,
1332
+ transition: 'all 0.2s ease',
1333
+ fontFamily: 'inherit',
1334
+ textDecoration: 'none',
1335
+ display: 'inline-flex',
1336
+ alignItems: 'center',
1337
+ justifyContent: 'center'
1338
+ };
1339
+
1340
+ const panelStyle: React.CSSProperties = {
1341
+ backgroundColor: panelBackground,
1342
+ border: `1px solid ${panelBorderColor}`,
1343
+ borderRadius: panelBorderRadius
1344
+ };
1345
+
1346
+ const productCardStyle: React.CSSProperties = {
1347
+ backgroundColor: productCardBackground,
1348
+ border: `1px solid ${productCardBorderColor}`,
1349
+ borderRadius: panelBorderRadius
1350
+ };
1351
+
1352
+ const resultCardStyle: React.CSSProperties = {
1353
+ backgroundColor: resultCardBackground,
1354
+ border: `1px solid ${resultCardBorderColor}`,
1355
+ borderRadius: resultCardBorderRadius
1356
+ };
1357
+ const mergeControlStyles = (
1358
+ baseStyle: React.CSSProperties,
1359
+ block: Block | null
1360
+ ): React.CSSProperties => ({
1361
+ ...baseStyle,
1362
+ ...(block
1363
+ ? getCSSStyles(
1364
+ Object.fromEntries(
1365
+ Object.entries(block.styles || {}).filter(
1366
+ ([styleKey]) => !CONTROL_LAYOUT_STYLE_KEYS.has(styleKey)
1367
+ )
1368
+ ),
1369
+ themeSettings,
1370
+ currentBreakpoint
1371
+ )
1372
+ : {})
1373
+ });
1374
+ const searchInputStyle = mergeControlStyles(inputStyle, searchInputBlock);
1375
+ const citySelectStyle = mergeControlStyles(inputStyle, citySelectBlock);
1376
+ const sizeSelectStyle = mergeControlStyles(inputStyle, sizeSelectBlock);
1377
+ const searchButtonControlStyle = mergeControlStyles(buttonStyle, searchButtonBlock);
1378
+ const availabilityButtonControlStyle = mergeControlStyles(
1379
+ buttonStyle,
1380
+ availabilityButtonBlock
1381
+ );
1382
+ const searchButtonBaseBackground =
1383
+ typeof searchButtonControlStyle.backgroundColor === 'string'
1384
+ ? searchButtonControlStyle.backgroundColor
1385
+ : buttonBg;
1386
+ const availabilityButtonBaseBackground =
1387
+ typeof availabilityButtonControlStyle.backgroundColor === 'string'
1388
+ ? availabilityButtonControlStyle.backgroundColor
1389
+ : buttonBg;
1390
+ const availabilityDescriptionText = isProductResolved
1391
+ ? t('product.find_in_store.description')
1392
+ : 'Search for a product URL first, then select a city and size to check store stock.';
1393
+ const shouldRenderProductCard =
1394
+ showProductCard && (isProductResolved || isDesigner);
1395
+
1396
+ return (
1397
+ <div
1398
+ className={clsx('w-full', hasMaxWidth && 'mx-auto', maxWidthClass)}
1399
+ style={sectionStyles}
1400
+ >
1401
+ <div className="flex flex-col gap-6">
1402
+ {contentBlocks.length > 0 && (
1403
+ <div className="flex flex-col gap-4">
1404
+ {contentBlocks.map((block) =>
1405
+ renderBlock(block, `content-${block.id}`)
1406
+ )}
1407
+ </div>
1408
+ )}
1409
+
1410
+ <div
1411
+ className={clsx(
1412
+ 'grid gap-6 items-stretch',
1413
+ shouldRenderProductCard && !isMobile
1414
+ ? 'grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]'
1415
+ : 'grid-cols-1'
1416
+ )}
1417
+ >
1418
+ {shouldRenderProductCard &&
1419
+ renderSelectableWrapper(
1420
+ productCardWrapperBlock,
1421
+ <div className="flex h-full flex-col gap-5">
1422
+ <div className="flex items-center justify-between gap-3">
1423
+ {productEyebrowBlock ? (
1424
+ renderBlock(
1425
+ cloneFindInStoreBlock(
1426
+ productEyebrowBlock,
1427
+ productCardTokens
1428
+ ),
1429
+ 'product-eyebrow'
1430
+ )
1431
+ ) : (
1432
+ <span className="text-[12px] font-semibold uppercase tracking-[0.18em] text-slate-500">
1433
+ Selected product
1434
+ </span>
1435
+ )}
1436
+
1437
+ {productLinkBlock && (productUrl || isDesigner) ? (
1438
+ renderBlock(
1439
+ cloneFindInStoreBlock(
1440
+ productLinkBlock,
1441
+ productCardTokens,
1442
+ {
1443
+ properties: {
1444
+ href: productUrl || '#'
1445
+ }
1446
+ }
1447
+ ),
1448
+ 'product-link'
1449
+ )
1450
+ ) : productUrl ? (
1451
+ <a
1452
+ href={productUrl}
1453
+ className="text-[13px] font-semibold text-slate-800 underline underline-offset-4"
1454
+ >
1455
+ View product
1456
+ </a>
1457
+ ) : null}
1458
+ </div>
1459
+
1460
+ <div
1461
+ className={clsx(
1462
+ 'flex h-full flex-col rounded-[24px] border border-slate-200 bg-white',
1463
+ isMobile ? 'p-5' : 'p-4 xl:p-5'
1464
+ )}
1465
+ >
1466
+ <div
1467
+ className={clsx(
1468
+ 'w-full',
1469
+ isMobile
1470
+ ? 'max-w-[320px]'
1471
+ : 'max-w-[240px] xl:max-w-[260px]'
1472
+ )}
1473
+ >
1474
+ {productImageBlock ? (
1475
+ renderBlock(
1476
+ cloneFindInStoreBlock(
1477
+ productImageBlock,
1478
+ productCardTokens,
1479
+ {
1480
+ value: displayProductImage
1481
+ }
1482
+ ),
1483
+ 'product-image'
1484
+ )
1485
+ ) : productImage ? (
1486
+ <div className="relative aspect-square w-full overflow-hidden rounded-[22px] bg-slate-100">
1487
+ <Image
1488
+ src={productImage}
1489
+ alt={productName}
1490
+ fill
1491
+ sizes="(min-width: 1280px) 260px, (min-width: 768px) 240px, 100vw"
1492
+ aspectRatio={1}
1493
+ imageClassName="object-contain"
1494
+ />
1495
+ </div>
1496
+ ) : (
1497
+ <div className="flex aspect-square items-center justify-center rounded-[22px] bg-slate-100 text-sm font-medium text-slate-400">
1498
+ No image
1499
+ </div>
1500
+ )}
1501
+ </div>
1502
+
1503
+ <div
1504
+ className={clsx(
1505
+ 'flex min-w-0 flex-1 flex-col gap-3',
1506
+ isMobile ? 'mt-5' : 'mt-4'
1507
+ )}
1508
+ >
1509
+ {productNameBlock ? (
1510
+ renderBlock(
1511
+ cloneFindInStoreBlock(
1512
+ productNameBlock,
1513
+ productCardTokens
1514
+ ),
1515
+ 'product-name'
1516
+ )
1517
+ ) : (
1518
+ <h3
1519
+ className={clsx(
1520
+ 'font-bold leading-[1.2] text-slate-950',
1521
+ isMobile ? 'text-[22px]' : 'text-[20px]'
1522
+ )}
1523
+ style={{
1524
+ display: '-webkit-box',
1525
+ WebkitLineClamp: isMobile ? 3 : 2,
1526
+ WebkitBoxOrient: 'vertical',
1527
+ overflow: 'hidden'
1528
+ }}
1529
+ >
1530
+ {displayProductName}
1531
+ </h3>
1532
+ )}
1533
+
1534
+ {productPriceBlock ? (
1535
+ renderBlock(
1536
+ cloneFindInStoreBlock(
1537
+ productPriceBlock,
1538
+ productCardTokens
1539
+ ),
1540
+ 'product-price'
1541
+ )
1542
+ ) : priceText ? (
1543
+ <div className="flex flex-wrap items-center gap-2">
1544
+ {priceState.hasDiscount && retailPriceText ? (
1545
+ <span className="text-[15px] font-medium text-slate-400 line-through">
1546
+ {retailPriceText}
1547
+ </span>
1548
+ ) : null}
1549
+ <span
1550
+ className={clsx(
1551
+ 'font-bold leading-none text-slate-950',
1552
+ isMobile ? 'text-[28px]' : 'text-[24px]'
1553
+ )}
1554
+ >
1555
+ {priceText}
1556
+ </span>
1557
+ </div>
1558
+ ) : null}
1559
+ </div>
1560
+ </div>
1561
+ </div>,
1562
+ {
1563
+ className: 'h-full overflow-hidden',
1564
+ style: productCardStyle,
1565
+ keyOverride: 'product-card-wrapper'
1566
+ }
1567
+ )}
1568
+
1569
+ <div className="flex h-full flex-col gap-4">
1570
+ {renderSelectableWrapper(
1571
+ searchPanelWrapperBlock,
1572
+ <div className="flex flex-col" style={{ gap: `${formGap}px` }}>
1573
+ <div className="flex flex-col gap-2">
1574
+ {searchHeadingBlock ? (
1575
+ renderBlock(
1576
+ cloneFindInStoreBlock(searchHeadingBlock, {}),
1577
+ 'search-heading'
1578
+ )
1579
+ ) : (
1580
+ <h3 className="text-[22px] font-bold leading-tight text-slate-950">
1581
+ Find a product first
1582
+ </h3>
1583
+ )}
1584
+ {searchDescriptionBlock ? (
1585
+ renderBlock(
1586
+ cloneFindInStoreBlock(searchDescriptionBlock, {
1587
+ setup_text: setupText
1588
+ }),
1589
+ 'search-description'
1590
+ )
1591
+ ) : (
1592
+ <p className="text-[15px] leading-7 text-slate-600">
1593
+ {setupText}
1594
+ </p>
1595
+ )}
1596
+ </div>
1597
+
1598
+ <div
1599
+ className={clsx(
1600
+ 'grid gap-3',
1601
+ isMobile ? 'grid-cols-1' : 'grid-cols-[minmax(0,1fr)_190px]'
1602
+ )}
1603
+ >
1604
+ {renderSelectableElement(
1605
+ searchInputBlock,
1606
+ <input
1607
+ type="text"
1608
+ value={productUrlInput}
1609
+ onChange={(event) => setProductUrlInput(event.target.value)}
1610
+ placeholder={urlPlaceholder}
1611
+ inputMode="text"
1612
+ style={searchInputStyle}
1613
+ />,
1614
+ {
1615
+ keyOverride: 'search-input'
1616
+ }
1617
+ )}
1618
+
1619
+ {renderSelectableElement(
1620
+ searchButtonBlock,
1621
+ <button
1622
+ type="button"
1623
+ style={searchButtonControlStyle}
1624
+ disabled={isResolvingProduct}
1625
+ onClick={handleResolveProduct}
1626
+ onMouseEnter={(event) => {
1627
+ if (!isResolvingProduct) {
1628
+ event.currentTarget.style.backgroundColor =
1629
+ buttonHoverBg;
1630
+ }
1631
+ }}
1632
+ onMouseLeave={(event) => {
1633
+ if (!isResolvingProduct) {
1634
+ event.currentTarget.style.backgroundColor =
1635
+ searchButtonBaseBackground;
1636
+ }
1637
+ }}
1638
+ >
1639
+ {isResolvingProduct ? 'Searching...' : searchButtonText}
1640
+ </button>,
1641
+ {
1642
+ keyOverride: 'search-button'
1643
+ }
1644
+ )}
1645
+ </div>
1646
+
1647
+ {searchError ? (
1648
+ <p className="rounded-[12px] border border-red-200 bg-red-50 px-4 py-3 text-sm leading-6 text-red-600">
1649
+ {searchError}
1650
+ </p>
1651
+ ) : null}
1652
+
1653
+ {!searchError && !isProductResolved && !isResolvingProduct ? (
1654
+ <p className="text-sm leading-6 text-slate-500">
1655
+ {setupText}
1656
+ </p>
1657
+ ) : null}
1658
+
1659
+ {resolvedProductPk && productLoading ? (
1660
+ <p className="text-sm leading-6 text-slate-500">
1661
+ Loading product details...
1662
+ </p>
1663
+ ) : null}
1664
+ </div>,
1665
+ {
1666
+ className: 'p-4 sm:p-5',
1667
+ style: panelStyle,
1668
+ keyOverride: 'search-panel-wrapper'
1669
+ }
1670
+ )}
1671
+
1672
+ {renderSelectableWrapper(
1673
+ availabilityPanelWrapperBlock,
1674
+ <div className="flex flex-col" style={{ gap: `${formGap}px` }}>
1675
+ <div className="flex flex-col gap-2">
1676
+ {availabilityHeadingBlock ? (
1677
+ renderBlock(
1678
+ cloneFindInStoreBlock(availabilityHeadingBlock, {}),
1679
+ 'availability-heading'
1680
+ )
1681
+ ) : (
1682
+ <h3 className="text-[22px] font-bold leading-tight text-slate-950">
1683
+ Pickup availability
1684
+ </h3>
1685
+ )}
1686
+ {availabilityDescriptionBlock ? (
1687
+ renderBlock(
1688
+ cloneFindInStoreBlock(availabilityDescriptionBlock, {
1689
+ availability_description: availabilityDescriptionText
1690
+ }),
1691
+ 'availability-description'
1692
+ )
1693
+ ) : (
1694
+ <p className="text-[15px] leading-7 text-slate-600">
1695
+ {availabilityDescriptionText}
1696
+ </p>
1697
+ )}
1698
+ </div>
1699
+
1700
+ <div
1701
+ className={clsx(
1702
+ 'grid gap-3',
1703
+ sizeOptions.length > 0 && !isMobile
1704
+ ? 'grid-cols-2'
1705
+ : 'grid-cols-1'
1706
+ )}
1707
+ >
1708
+ {renderSelectableElement(
1709
+ citySelectBlock,
1710
+ <select
1711
+ value={selectedCityId}
1712
+ onChange={(event) => {
1713
+ setSelectedCityId(event.target.value);
1714
+ setFormError('');
1715
+ }}
1716
+ style={citySelectStyle}
1717
+ disabled={!isProductResolved}
1718
+ >
1719
+ <option value="">
1720
+ {t('product.find_in_store.select_an_option')}
1721
+ </option>
1722
+ {cityOptions.map((option) => (
1723
+ <option key={option.value} value={option.value}>
1724
+ {option.label}
1725
+ </option>
1726
+ ))}
1727
+ </select>,
1728
+ {
1729
+ keyOverride: 'city-select'
1730
+ }
1731
+ )}
1732
+
1733
+ {sizeOptions.length > 0 && (
1734
+ renderSelectableElement(
1735
+ sizeSelectBlock,
1736
+ <select
1737
+ value={selectedSize}
1738
+ onChange={(event) => {
1739
+ setSelectedSize(event.target.value);
1740
+ setFormError('');
1741
+ }}
1742
+ style={sizeSelectStyle}
1743
+ disabled={!isProductResolved}
1744
+ >
1745
+ <option value="">
1746
+ {t('product.find_in_store.select_an_option')}
1747
+ </option>
1748
+ {sizeOptions.map((option) => (
1749
+ <option key={option.value} value={option.value}>
1750
+ {option.label}
1751
+ </option>
1752
+ ))}
1753
+ </select>,
1754
+ {
1755
+ keyOverride: 'size-select'
1756
+ }
1757
+ )
1758
+ )}
1759
+ </div>
1760
+
1761
+ {renderSelectableElement(
1762
+ availabilityButtonBlock,
1763
+ <button
1764
+ type="button"
1765
+ style={availabilityButtonControlStyle}
1766
+ disabled={stockLoading || !isProductResolved}
1767
+ onClick={handleSubmit}
1768
+ onMouseEnter={(event) => {
1769
+ if (!stockLoading && isProductResolved) {
1770
+ event.currentTarget.style.backgroundColor = buttonHoverBg;
1771
+ }
1772
+ }}
1773
+ onMouseLeave={(event) => {
1774
+ if (!stockLoading && isProductResolved) {
1775
+ event.currentTarget.style.backgroundColor =
1776
+ availabilityButtonBaseBackground;
1777
+ }
1778
+ }}
1779
+ >
1780
+ {stockLoading ? 'Checking...' : buttonText}
1781
+ </button>,
1782
+ {
1783
+ keyOverride: 'availability-button'
1784
+ }
1785
+ )}
1786
+
1787
+ {formError ? (
1788
+ <p className="rounded-[12px] border border-red-200 bg-red-50 px-4 py-3 text-sm leading-6 text-red-600">
1789
+ {formError}
1790
+ </p>
1791
+ ) : null}
1792
+ </div>,
1793
+ {
1794
+ className: 'p-4 sm:p-5',
1795
+ style: panelStyle,
1796
+ keyOverride: 'availability-panel-wrapper'
1797
+ }
1798
+ )}
1799
+ </div>
1800
+ </div>
1801
+
1802
+ {(stockLoading || hasSubmitted) && (
1803
+ <div className="flex flex-col gap-3">
1804
+ {stockLoading &&
1805
+ renderSelectableWrapper(
1806
+ resultCardWrapperBlock,
1807
+ <div className="flex min-h-[120px] items-center justify-center">
1808
+ <div className="text-sm font-medium text-slate-500">
1809
+ Checking stores...
1810
+ </div>
1811
+ </div>,
1812
+ {
1813
+ className: 'p-4',
1814
+ style: resultCardStyle,
1815
+ keyOverride: 'result-card-loading'
1816
+ }
1817
+ )}
1818
+
1819
+ {!stockLoading &&
1820
+ results.map((store, index) => {
1821
+ const tone = getStockTone(t, store.stock);
1822
+ const directionsUrl =
1823
+ store.latitude && store.longitude
1824
+ ? `https://maps.google.com/?q=${store.latitude},${store.longitude}`
1825
+ : '';
1826
+ const storeTokens = {
1827
+ store_name: store.name || 'Store',
1828
+ store_address: store.address || '',
1829
+ store_status: tone.label,
1830
+ store_hours: createStoreHours(store.store_hours, t),
1831
+ store_stock: `Stock: ${store.stock}`
1832
+ };
1833
+
1834
+ return renderSelectableWrapper(
1835
+ resultCardWrapperBlock
1836
+ ? {
1837
+ ...cloneFindInStoreBlock(
1838
+ resultCardWrapperBlock,
1839
+ storeTokens
1840
+ ),
1841
+ id: `${resultCardWrapperBlock.id}-clone-${index}`,
1842
+ styleSourceId:
1843
+ resultCardWrapperBlock.styleSourceId ||
1844
+ resultCardWrapperBlock.id
1845
+ }
1846
+ : null,
1847
+ <div className="flex flex-col gap-4">
1848
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
1849
+ <div className="min-w-0">
1850
+ {storeNameBlock ? (
1851
+ renderBlock(
1852
+ cloneFindInStoreBlock(storeNameBlock, storeTokens),
1853
+ `store-name-${index}`
1854
+ )
1855
+ ) : (
1856
+ <h4 className="text-lg font-bold leading-tight text-slate-950">
1857
+ {store.name}
1858
+ </h4>
1859
+ )}
1860
+ {storeAddressBlock ? (
1861
+ renderBlock(
1862
+ cloneFindInStoreBlock(
1863
+ storeAddressBlock,
1864
+ storeTokens
1865
+ ),
1866
+ `store-address-${index}`
1867
+ )
1868
+ ) : (
1869
+ <p className="mt-2 text-sm leading-6 text-slate-600">
1870
+ {store.address}
1871
+ </p>
1872
+ )}
1873
+ </div>
1874
+
1875
+ <div
1876
+ className="inline-flex w-fit items-center rounded-full px-3 py-1"
1877
+ style={{
1878
+ backgroundColor: tone.background,
1879
+ color: tone.color
1880
+ }}
1881
+ >
1882
+ {storeStatusBlock ? (
1883
+ renderBlock(
1884
+ cloneFindInStoreBlock(
1885
+ storeStatusBlock,
1886
+ storeTokens
1887
+ ),
1888
+ `store-status-${index}`
1889
+ )
1890
+ ) : (
1891
+ <span className="text-xs font-semibold">
1892
+ {tone.label}
1893
+ </span>
1894
+ )}
1895
+ </div>
1896
+ </div>
1897
+
1898
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1899
+ <div className="flex flex-col gap-1">
1900
+ {showWorkingHours &&
1901
+ (storeHoursBlock ? (
1902
+ renderBlock(
1903
+ cloneFindInStoreBlock(
1904
+ storeHoursBlock,
1905
+ storeTokens
1906
+ ),
1907
+ `store-hours-${index}`
1908
+ )
1909
+ ) : (
1910
+ <p className="text-sm leading-6 text-slate-500">
1911
+ <span className="font-semibold text-slate-700">
1912
+ {t('product.find_in_store.working_hours')}:
1913
+ </span>{' '}
1914
+ {createStoreHours(store.store_hours, t)}
1915
+ </p>
1916
+ ))}
1917
+ {storeStockBlock ? (
1918
+ renderBlock(
1919
+ cloneFindInStoreBlock(storeStockBlock, storeTokens),
1920
+ `store-stock-${index}`
1921
+ )
1922
+ ) : (
1923
+ <p className="text-sm leading-6 text-slate-500">
1924
+ <span className="font-semibold text-slate-700">
1925
+ Stock:
1926
+ </span>{' '}
1927
+ {store.stock}
1928
+ </p>
1929
+ )}
1930
+ </div>
1931
+
1932
+ {showDirections && directionsUrl ? (
1933
+ storeDirectionsBlock ? (
1934
+ renderBlock(
1935
+ cloneFindInStoreBlock(
1936
+ storeDirectionsBlock,
1937
+ storeTokens,
1938
+ {
1939
+ properties: {
1940
+ href: directionsUrl
1941
+ }
1942
+ }
1943
+ ),
1944
+ `store-directions-${index}`
1945
+ )
1946
+ ) : (
1947
+ <a
1948
+ href={directionsUrl}
1949
+ target="_blank"
1950
+ rel="noreferrer"
1951
+ style={secondaryButtonStyle}
1952
+ onMouseEnter={(event) => {
1953
+ event.currentTarget.style.backgroundColor =
1954
+ panelBackground;
1955
+ }}
1956
+ onMouseLeave={(event) => {
1957
+ event.currentTarget.style.backgroundColor =
1958
+ 'transparent';
1959
+ }}
1960
+ >
1961
+ {t('product.find_in_store.directions')}
1962
+ </a>
1963
+ )
1964
+ ) : null}
1965
+ </div>
1966
+ </div>,
1967
+ {
1968
+ className: 'p-4',
1969
+ style: resultCardStyle,
1970
+ keyOverride: `result-card-wrapper-${index}`
1971
+ }
1972
+ );
1973
+ })}
1974
+
1975
+ {!stockLoading &&
1976
+ !results.length &&
1977
+ renderSelectableWrapper(
1978
+ resultCardWrapperBlock,
1979
+ <div className="text-sm leading-6 text-slate-500">
1980
+ {isStockError
1981
+ ? t('product.find_in_store.store_not_found')
1982
+ : emptyStateText}
1983
+ </div>,
1984
+ {
1985
+ className: 'p-5',
1986
+ style: resultCardStyle,
1987
+ keyOverride: 'result-card-empty'
1988
+ }
1989
+ )}
1990
+ </div>
1991
+ )}
1992
+ </div>
1993
+ </div>
1994
+ );
1995
+ }