@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,1224 @@
1
+ 'use client';
2
+
3
+ import ThemeBlock, { Block } from './theme-block';
4
+ import ActionToolbar from './components/action-toolbar';
5
+ import { twMerge } from 'tailwind-merge';
6
+ import clsx from 'clsx';
7
+ import { useEffect, useMemo, useRef } from 'react';
8
+ import SectionWrapper from './sections/section-wrapper';
9
+ import sectionRendererRegistry from './sections/section-renderer-registry';
10
+ import { buildIteratorBlock } from './utils/iterator-utils';
11
+
12
+ export interface Section {
13
+ id: string;
14
+ type: string;
15
+ name: string;
16
+ label: string;
17
+ properties: any;
18
+ styles: any;
19
+ blocks: Block[];
20
+ order: number;
21
+ hidden: boolean;
22
+ locked?: boolean;
23
+ dataSourceId?: string;
24
+ dataSource?: any;
25
+ }
26
+
27
+ interface ThemeSectionProps {
28
+ section: Section;
29
+ placeholderId: string;
30
+ isDesigner?: boolean;
31
+ isSelected?: boolean;
32
+ selectedBlockId?: string | null;
33
+ currentBreakpoint?: string;
34
+ onSelect?: (sectionId: string) => void;
35
+ onMoveUp?: () => void;
36
+ onMoveDown?: () => void;
37
+ onDuplicate?: () => void;
38
+ onToggleVisibility?: () => void;
39
+ onDelete?: () => void;
40
+ onRename?: (newLabel: string) => void;
41
+ }
42
+
43
+ const applyInheritedLocksToBlocks = (
44
+ blocks: Block[],
45
+ parentLocked = false
46
+ ): Block[] =>
47
+ blocks.map((block) => {
48
+ const isLocked = parentLocked || block.locked === true;
49
+
50
+ return {
51
+ ...block,
52
+ locked: isLocked,
53
+ blocks: block.blocks
54
+ ? applyInheritedLocksToBlocks(block.blocks, isLocked)
55
+ : block.blocks
56
+ };
57
+ });
58
+
59
+ export default function ThemeSection({
60
+ section,
61
+ placeholderId,
62
+ isDesigner = false,
63
+ isSelected = false,
64
+ selectedBlockId = null,
65
+ currentBreakpoint = 'desktop',
66
+ onSelect,
67
+ onMoveUp,
68
+ onMoveDown,
69
+ onDuplicate,
70
+ onToggleVisibility,
71
+ onDelete,
72
+ onRename
73
+ }: ThemeSectionProps) {
74
+ const effectiveSection = useMemo(
75
+ () => ({
76
+ ...section,
77
+ blocks: applyInheritedLocksToBlocks(
78
+ section.blocks || [],
79
+ section.locked === true
80
+ )
81
+ }),
82
+ [section]
83
+ );
84
+
85
+ const readSectionString = (value: unknown): string => {
86
+ if (value == null) return '';
87
+ if (typeof value === 'string') return value;
88
+ if (typeof value === 'object' && !Array.isArray(value)) {
89
+ const responsive = value as Record<string, unknown>;
90
+ const picked =
91
+ responsive.desktop ?? responsive.mobile ?? Object.values(responsive)[0];
92
+ return picked == null ? '' : String(picked);
93
+ }
94
+ return String(value);
95
+ };
96
+
97
+ const isNewsletterSection =
98
+ effectiveSection.type === 'newsletter-signup-banner';
99
+ const newsletterEndpoint = readSectionString(
100
+ effectiveSection.properties?.['api-endpoint']
101
+ ).trim();
102
+ const newsletterSuccessMessage = readSectionString(
103
+ effectiveSection.properties?.['success-message']
104
+ ).trim();
105
+ const newsletterErrorMessage = readSectionString(
106
+ effectiveSection.properties?.['error-message']
107
+ ).trim();
108
+
109
+ const sectionRef = useRef<HTMLElement>(null);
110
+
111
+ useEffect(() => {
112
+ if (!isDesigner) return;
113
+
114
+ const handleMessage = (event: MessageEvent) => {
115
+ if (
116
+ event.data?.type === 'SCROLL_TO_SECTION' &&
117
+ event.data?.data?.sectionId === effectiveSection.id
118
+ ) {
119
+ sectionRef.current?.scrollIntoView({
120
+ behavior: 'smooth',
121
+ block: 'center'
122
+ });
123
+ }
124
+ };
125
+
126
+ window.addEventListener('message', handleMessage);
127
+ return () => window.removeEventListener('message', handleMessage);
128
+ }, [effectiveSection.id, isDesigner]);
129
+
130
+ const handleClick = (e: React.MouseEvent) => {
131
+ if (isDesigner && onSelect) {
132
+ e.stopPropagation();
133
+ onSelect(effectiveSection.id);
134
+
135
+ if (window.parent) {
136
+ window.parent.postMessage(
137
+ {
138
+ type: 'SELECT_SECTION',
139
+ data: {
140
+ placeholderId,
141
+ sectionId: effectiveSection.id
142
+ }
143
+ },
144
+ '*'
145
+ );
146
+ }
147
+ }
148
+ };
149
+
150
+ const isFrequentlyBoughtTogetherSection =
151
+ effectiveSection.properties?.dataSourceVariant ===
152
+ 'frequently-bought-together' ||
153
+ effectiveSection.name === 'Frequently Bought Together';
154
+
155
+ const getProductsFromDataPath = (
156
+ dataPath?: string
157
+ ): Record<string, unknown>[] => {
158
+ if (!effectiveSection.dataSource?.details) return [];
159
+
160
+ const isEditorMode = isDesigner && window.parent !== window;
161
+ const collectionData = isEditorMode
162
+ ? effectiveSection.dataSource.details.collection?.products ||
163
+ effectiveSection.dataSource.details.collection?.data
164
+ : effectiveSection.dataSource.details.collection?.data ||
165
+ effectiveSection.dataSource.details.collection?.products;
166
+
167
+ if (!collectionData) return [];
168
+
169
+ if (Array.isArray(collectionData)) {
170
+ return collectionData;
171
+ }
172
+
173
+ if (Array.isArray((collectionData as any)?.products)) {
174
+ return (collectionData as any).products;
175
+ }
176
+
177
+ if (!dataPath) return [];
178
+
179
+ const pathParts = dataPath.split('.');
180
+ let value: unknown = collectionData;
181
+
182
+ for (const part of pathParts) {
183
+ value = (value as Record<string, unknown>)?.[part];
184
+ if (value === undefined) break;
185
+ }
186
+
187
+ return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
188
+ };
189
+
190
+ const getIteratorRenderMeta = (iteratorBlock: Block) => {
191
+ const dataPath =
192
+ (iteratorBlock.iteratorDataPath as string | undefined) ||
193
+ (iteratorBlock.properties?.iteratorDataPath as string | undefined);
194
+ const products = getProductsFromDataPath(dataPath);
195
+
196
+ const useIteratorCount =
197
+ iteratorBlock.properties?.useIteratorCount === true ||
198
+ isFrequentlyBoughtTogetherSection;
199
+ const iteratorCount = Number(iteratorBlock.properties?.iteratorCount) || 1;
200
+ const actualCount = useIteratorCount
201
+ ? Math.max(1, iteratorCount)
202
+ : products.length > 0
203
+ ? products.length
204
+ : iteratorCount;
205
+
206
+ return { products, actualCount };
207
+ };
208
+
209
+ const parsePriceValue = (value: unknown): number => {
210
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
211
+ if (typeof value !== 'string') return 0;
212
+
213
+ const cleaned = value.trim().replace(/[^\d,.-]/g, '');
214
+ if (!cleaned) return 0;
215
+
216
+ let normalized = cleaned;
217
+ const hasComma = normalized.includes(',');
218
+ const hasDot = normalized.includes('.');
219
+
220
+ if (hasComma && hasDot) {
221
+ const lastComma = normalized.lastIndexOf(',');
222
+ const lastDot = normalized.lastIndexOf('.');
223
+ normalized =
224
+ lastComma > lastDot
225
+ ? normalized.replace(/\./g, '').replace(',', '.')
226
+ : normalized.replace(/,/g, '');
227
+ } else if (hasComma) {
228
+ const unsigned = normalized.replace(/^-/, '');
229
+ const isThousandsPattern = /^\d{1,3}(,\d{3})+$/.test(unsigned);
230
+ normalized = isThousandsPattern
231
+ ? normalized.replace(/,/g, '')
232
+ : normalized.replace(/,/g, '.');
233
+ }
234
+
235
+ const parsed = Number(normalized);
236
+ return Number.isFinite(parsed) ? parsed : 0;
237
+ };
238
+
239
+ const escapeHtml = (value: string): string =>
240
+ value
241
+ .replace(/&/g, '&amp;')
242
+ .replace(/</g, '&lt;')
243
+ .replace(/>/g, '&gt;')
244
+ .replace(/"/g, '&quot;')
245
+ .replace(/'/g, '&#39;');
246
+
247
+ const formatDisplayPrice = (value: unknown): string => {
248
+ if (typeof value === 'number' && Number.isFinite(value)) {
249
+ const hasDecimals = Math.abs(value % 1) > 0.00001;
250
+ return value.toLocaleString('tr-TR', {
251
+ minimumFractionDigits: hasDecimals ? 2 : 0,
252
+ maximumFractionDigits: 2
253
+ });
254
+ }
255
+
256
+ if (typeof value !== 'string') return '0';
257
+ const raw = value.trim();
258
+ if (!raw) return '0';
259
+ if (/[A-Za-z]/.test(raw)) return raw;
260
+
261
+ const parsed = parsePriceValue(raw);
262
+ const hasDecimals = Math.abs(parsed % 1) > 0.00001;
263
+ return parsed.toLocaleString('tr-TR', {
264
+ minimumFractionDigits: hasDecimals ? 2 : 0,
265
+ maximumFractionDigits: 2
266
+ });
267
+ };
268
+
269
+ const getPriceWithCurrency = (value: unknown, currency: unknown): string => {
270
+ const amount = formatDisplayPrice(value);
271
+ const currencyLabel = normalizeCurrencyLabel(currency);
272
+
273
+ if (
274
+ amount.toUpperCase().includes(currencyLabel) ||
275
+ /[A-Za-z]{2,}/.test(amount)
276
+ ) {
277
+ return amount;
278
+ }
279
+
280
+ return `${amount} ${currencyLabel}`;
281
+ };
282
+
283
+ const buildFrequentlyBoughtPriceHtml = (
284
+ productData: Record<string, unknown>
285
+ ): string => {
286
+ const activePrice =
287
+ ((productData as any)?.active_price as
288
+ | Record<string, unknown>
289
+ | undefined) || {};
290
+ const currentRaw = activePrice?.price ?? (productData as any)?.price;
291
+ const retailRaw =
292
+ activePrice?.retail_price ?? (productData as any)?.retail_price;
293
+ const currency =
294
+ activePrice?.currency_type ??
295
+ (productData as any)?.currency_type ??
296
+ (productData as any)?.currency;
297
+
298
+ const currentNumeric = parsePriceValue(currentRaw);
299
+ const retailNumeric = parsePriceValue(retailRaw);
300
+
301
+ const currentText = escapeHtml(getPriceWithCurrency(currentRaw, currency));
302
+ const retailText = escapeHtml(getPriceWithCurrency(retailRaw, currency));
303
+
304
+ if (retailNumeric > 0 && retailNumeric > currentNumeric) {
305
+ return `<p style="margin:0;font-size:14px;line-height:1.3;"><span style='color:#94a3b8;text-decoration:line-through;margin-right:6px;'>${retailText}</span><span style='color:#0f172a;font-weight:700;'>${currentText}</span></p>`;
306
+ }
307
+
308
+ return `<p style="margin:0;font-size:14px;line-height:1.3;"><span style='color:#0f172a;font-weight:700;'>${currentText}</span></p>`;
309
+ };
310
+
311
+ const formatBundleAmount = (
312
+ amount: number,
313
+ currencyLabel: string
314
+ ): string => {
315
+ const isIntegerAmount = Math.abs(amount % 1) < 0.00001;
316
+ const formatted = new Intl.NumberFormat('en-US', {
317
+ minimumFractionDigits: isIntegerAmount ? 0 : 2,
318
+ maximumFractionDigits: 2
319
+ }).format(amount);
320
+ return `${formatted} ${currencyLabel}`;
321
+ };
322
+
323
+ const normalizeCurrencyLabel = (rawCurrency: unknown): string => {
324
+ if (typeof rawCurrency !== 'string') return 'TL';
325
+ const normalized = rawCurrency.trim().toUpperCase();
326
+ if (!normalized || normalized === 'TRY' || normalized === 'TL') return 'TL';
327
+ return normalized;
328
+ };
329
+
330
+ const computeFrequentlyBoughtSummary = (
331
+ products: Record<string, unknown>[],
332
+ count: number
333
+ ) => {
334
+ const selectedProducts = products.slice(0, Math.max(0, count));
335
+ let bundleTotal = 0;
336
+ let retailTotal = 0;
337
+ let currencyLabel = 'TL';
338
+
339
+ selectedProducts.forEach((product, index) => {
340
+ const activePrice =
341
+ ((product as any)?.active_price as
342
+ | Record<string, unknown>
343
+ | undefined) || {};
344
+ const currentPrice = parsePriceValue(
345
+ activePrice?.price ?? (product as any)?.price
346
+ );
347
+ const originalPrice = parsePriceValue(
348
+ activePrice?.retail_price ??
349
+ (product as any)?.retail_price ??
350
+ activePrice?.price ??
351
+ (product as any)?.price
352
+ );
353
+
354
+ if (index === 0) {
355
+ currencyLabel = normalizeCurrencyLabel(
356
+ activePrice?.currency_type ??
357
+ (product as any)?.currency_type ??
358
+ (product as any)?.currency
359
+ );
360
+ }
361
+
362
+ bundleTotal += currentPrice;
363
+ retailTotal += originalPrice > 0 ? originalPrice : currentPrice;
364
+ });
365
+
366
+ if (retailTotal < bundleTotal) {
367
+ retailTotal = bundleTotal;
368
+ }
369
+
370
+ const savingsTotal = Math.max(0, retailTotal - bundleTotal);
371
+ const bundleText = formatBundleAmount(bundleTotal, currencyLabel);
372
+ const retailText = formatBundleAmount(retailTotal, currencyLabel);
373
+ const savingsText = formatBundleAmount(savingsTotal, currencyLabel);
374
+ const showRetailPrice = retailTotal > bundleTotal;
375
+
376
+ return {
377
+ bundlePriceHtml: showRetailPrice
378
+ ? `<p style="margin:0;font-size:22px;line-height:1.2;display:flex;align-items:baseline;gap:8px;flex-wrap:nowrap;white-space:nowrap;"><span style='color:#94a3b8;text-decoration-line:line-through;text-decoration-color:#94a3b8;text-decoration-thickness:2px;font-size:16px;white-space:nowrap;display:inline-block;'>${retailText}</span><span style='color:#ffffff;font-weight:700;white-space:nowrap;display:inline-block;'>${bundleText}</span></p>`
379
+ : `<p style="margin:0;font-size:22px;line-height:1.2;"><span style='color:#ffffff;font-weight:700;'>${bundleText}</span></p>`,
380
+ savingsHtml: `<p style="margin:0;color:#22c55e;font-size:13px;font-weight:600;">You save ${savingsText}</p>`
381
+ };
382
+ };
383
+
384
+ const applyFrequentlyBoughtSummaryValues = (
385
+ sourceBlock: Block,
386
+ bundlePriceHtml: string,
387
+ savingsHtml: string
388
+ ): Block => {
389
+ const normalizedLabel = (sourceBlock.label || '').toLowerCase();
390
+ const nextBlock: Block = { ...sourceBlock };
391
+
392
+ if (sourceBlock.type === 'text') {
393
+ if (normalizedLabel.includes('bundle price')) {
394
+ nextBlock.value = bundlePriceHtml;
395
+ } else if (normalizedLabel.includes('savings')) {
396
+ nextBlock.value = savingsHtml;
397
+ }
398
+ }
399
+
400
+ if (sourceBlock.blocks && sourceBlock.blocks.length > 0) {
401
+ nextBlock.blocks = sourceBlock.blocks.map((childBlock) =>
402
+ applyFrequentlyBoughtSummaryValues(
403
+ childBlock,
404
+ bundlePriceHtml,
405
+ savingsHtml
406
+ )
407
+ );
408
+ }
409
+
410
+ return nextBlock;
411
+ };
412
+
413
+ const findFirstIteratorBlock = (sourceBlock: Block): Block | null => {
414
+ if (sourceBlock.isIterator) return sourceBlock;
415
+ if (!sourceBlock.blocks || sourceBlock.blocks.length === 0) return null;
416
+
417
+ for (const childBlock of sourceBlock.blocks) {
418
+ const found = findFirstIteratorBlock(childBlock);
419
+ if (found) return found;
420
+ }
421
+
422
+ return null;
423
+ };
424
+
425
+ const hasTabBlocks = effectiveSection.blocks.some(
426
+ (block) => block.type === 'tab'
427
+ );
428
+
429
+ const CustomSectionRenderer = hasTabBlocks
430
+ ? sectionRendererRegistry.getRenderer('tabs')
431
+ : sectionRendererRegistry.getRenderer(effectiveSection.type);
432
+
433
+ if (CustomSectionRenderer) {
434
+ return (
435
+ <SectionWrapper
436
+ section={effectiveSection}
437
+ placeholderId={placeholderId}
438
+ isDesigner={isDesigner}
439
+ isSelected={isSelected}
440
+ onSelect={onSelect}
441
+ onMoveUp={onMoveUp}
442
+ onMoveDown={onMoveDown}
443
+ onDuplicate={onDuplicate}
444
+ onToggleVisibility={onToggleVisibility}
445
+ onDelete={onDelete}
446
+ onRename={onRename}
447
+ >
448
+ <CustomSectionRenderer
449
+ section={effectiveSection}
450
+ currentBreakpoint={currentBreakpoint}
451
+ placeholderId={placeholderId}
452
+ isDesigner={isDesigner}
453
+ selectedBlockId={selectedBlockId}
454
+ />
455
+ </SectionWrapper>
456
+ );
457
+ }
458
+
459
+ const resolveIteratorsRecursively = (block: Block): Block => {
460
+ const normalizedType = String(effectiveSection.type || '').toLowerCase();
461
+ const normalizedVariant = String(
462
+ effectiveSection.properties?.dataSourceVariant || ''
463
+ ).toLowerCase();
464
+ const normalizedLabel = String(effectiveSection.label || '').toLowerCase();
465
+ const normalizedName = String(effectiveSection.name || '').toLowerCase();
466
+ const detectRelatedUpsellStructure = (blocks: Block[] = []) => {
467
+ let hasRelated = false;
468
+ let hasUpsell = false;
469
+ let hasGrid = false;
470
+
471
+ const walk = (items: Block[]) => {
472
+ items.forEach((item) => {
473
+ const itemLabel = String(item.label || '').toLowerCase();
474
+ if (itemLabel.includes('related')) hasRelated = true;
475
+ if (itemLabel.includes('upsell')) hasUpsell = true;
476
+ if (
477
+ item.styles &&
478
+ typeof item.styles === 'object' &&
479
+ item.styles['grid-template-columns'] !== undefined
480
+ ) {
481
+ hasGrid = true;
482
+ }
483
+ if (item.blocks && item.blocks.length > 0) {
484
+ walk(item.blocks);
485
+ }
486
+ });
487
+ };
488
+
489
+ walk(blocks);
490
+ return hasRelated && hasUpsell && hasGrid;
491
+ };
492
+ const hasRelatedUpsellStructure = detectRelatedUpsellStructure(
493
+ effectiveSection.blocks
494
+ );
495
+ const hasItemsPerColumnProperty =
496
+ effectiveSection.properties?.['items-per-column-desktop'] !== undefined ||
497
+ effectiveSection.properties?.itemsPerColumnDesktop !== undefined ||
498
+ effectiveSection.properties?.['items-per-column-mobile'] !== undefined ||
499
+ effectiveSection.properties?.itemsPerColumnMobile !== undefined ||
500
+ effectiveSection.properties?.['items-per-column'] !== undefined ||
501
+ effectiveSection.properties?.itemsPerColumn !== undefined;
502
+ const isRelatedUpsellSplitSection =
503
+ normalizedType === 'related-upsell-split' ||
504
+ normalizedType.includes('related-upsell') ||
505
+ normalizedVariant === 'related-upsell-split' ||
506
+ normalizedVariant.includes('related-upsell') ||
507
+ normalizedLabel.includes('related + upsell') ||
508
+ (normalizedLabel.includes('related') &&
509
+ normalizedLabel.includes('upsell')) ||
510
+ normalizedName.includes('related + upsell') ||
511
+ (normalizedName.includes('related') &&
512
+ normalizedName.includes('upsell')) ||
513
+ hasRelatedUpsellStructure ||
514
+ hasItemsPerColumnProperty;
515
+
516
+ const readSectionValue = (keys: string[], fallback: unknown) => {
517
+ for (const candidate of keys) {
518
+ const candidateValue = effectiveSection.properties?.[candidate];
519
+ if (candidateValue !== undefined && candidateValue !== null) {
520
+ return candidateValue;
521
+ }
522
+ }
523
+ return fallback;
524
+ };
525
+
526
+ const parseCount = (value: unknown, fallback: number): number => {
527
+ if (typeof value === 'number' && Number.isFinite(value)) {
528
+ return value;
529
+ }
530
+ if (typeof value === 'string') {
531
+ const parsed = Number(value);
532
+ if (Number.isFinite(parsed)) {
533
+ return parsed;
534
+ }
535
+ }
536
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
537
+ const responsive = value as Record<string, unknown>;
538
+ const orderedValues = [
539
+ responsive.desktop,
540
+ responsive.tablet,
541
+ responsive.mobile,
542
+ ...Object.values(responsive)
543
+ ];
544
+ for (const candidate of orderedValues) {
545
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
546
+ return candidate;
547
+ }
548
+ if (typeof candidate === 'string') {
549
+ const parsed = Number(candidate);
550
+ if (Number.isFinite(parsed)) {
551
+ return parsed;
552
+ }
553
+ }
554
+ }
555
+ }
556
+ return fallback;
557
+ };
558
+
559
+ const getSplitItemsPerColumn = () => {
560
+ const desktopCount = Math.max(
561
+ 1,
562
+ Math.min(
563
+ 6,
564
+ Math.floor(
565
+ parseCount(
566
+ readSectionValue(
567
+ ['items-per-column-desktop', 'itemsPerColumnDesktop'],
568
+ readSectionValue(['items-per-column', 'itemsPerColumn'], 3)
569
+ ),
570
+ 3
571
+ )
572
+ )
573
+ )
574
+ );
575
+ const mobileCount = Math.max(
576
+ 1,
577
+ Math.min(
578
+ 4,
579
+ Math.floor(
580
+ parseCount(
581
+ readSectionValue(
582
+ ['items-per-column-mobile', 'itemsPerColumnMobile'],
583
+ Math.min(desktopCount, 2)
584
+ ),
585
+ Math.min(desktopCount, 2)
586
+ )
587
+ )
588
+ )
589
+ );
590
+ const tabletCount = Math.max(1, Math.min(desktopCount, 2));
591
+ const activeCount =
592
+ currentBreakpoint === 'mobile' ? mobileCount : desktopCount;
593
+ return {
594
+ desktop: desktopCount,
595
+ tablet: tabletCount,
596
+ mobile: mobileCount,
597
+ active: activeCount
598
+ };
599
+ };
600
+
601
+ const inferSplitDataPaths = () => {
602
+ const collectArrayPaths = (
603
+ input: unknown,
604
+ prefix: string,
605
+ depth: number,
606
+ bucket: string[]
607
+ ) => {
608
+ if (depth > 2 || input == null) return;
609
+ if (Array.isArray(input)) {
610
+ if (prefix) bucket.push(prefix);
611
+ return;
612
+ }
613
+ if (typeof input !== 'object') return;
614
+
615
+ Object.entries(input as Record<string, unknown>).forEach(
616
+ ([key, value]) => {
617
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
618
+ if (Array.isArray(value)) {
619
+ bucket.push(nextPrefix);
620
+ } else if (value && typeof value === 'object') {
621
+ collectArrayPaths(value, nextPrefix, depth + 1, bucket);
622
+ }
623
+ }
624
+ );
625
+ };
626
+
627
+ const editorPayload =
628
+ effectiveSection.dataSource?.details?.collection?.products;
629
+ const runtimePayload =
630
+ effectiveSection.dataSource?.details?.collection?.data;
631
+ const samplePayload = runtimePayload ?? editorPayload;
632
+
633
+ if (Array.isArray(samplePayload)) {
634
+ return { relatedPath: 'products', upsellPath: 'products' };
635
+ }
636
+
637
+ const discoveredPaths: string[] = [];
638
+ collectArrayPaths(samplePayload, '', 0, discoveredPaths);
639
+ const uniquePaths = Array.from(new Set(discoveredPaths));
640
+
641
+ if (uniquePaths.length === 0) {
642
+ return { relatedPath: 'products', upsellPath: 'products' };
643
+ }
644
+
645
+ const pickByKeywords = (keywords: string[], exclude?: string) =>
646
+ uniquePaths.find((path) => {
647
+ if (exclude && path === exclude) return false;
648
+ const lower = path.toLowerCase();
649
+ return keywords.some((keyword) => lower.includes(keyword));
650
+ });
651
+
652
+ const relatedPath =
653
+ pickByKeywords(['related', 'similar', 'cross', 'recommend']) ||
654
+ (uniquePaths.includes('products') ? 'products' : uniquePaths[0]);
655
+ const upsellPath =
656
+ pickByKeywords(
657
+ ['upsell', 'complement', 'also', 'recommend', 'cross'],
658
+ relatedPath
659
+ ) || (uniquePaths.includes('products') ? 'products' : relatedPath);
660
+
661
+ return { relatedPath, upsellPath };
662
+ };
663
+
664
+ const isIteratorLike = (candidate: Block): boolean => {
665
+ if (candidate.isIterator) return true;
666
+ if (!isRelatedUpsellSplitSection) return false;
667
+ const normalizedLabel = (candidate.label || '').toLowerCase();
668
+ return (
669
+ normalizedLabel.includes('iterator') ||
670
+ Boolean(candidate.iteratorDataPath) ||
671
+ Boolean(candidate.properties?.iteratorDataPath) ||
672
+ candidate.properties?.iteratorCount !== undefined ||
673
+ candidate.properties?.useIteratorCount !== undefined ||
674
+ candidate.properties?.iteratorOffset !== undefined
675
+ );
676
+ };
677
+
678
+ const normalizeRelatedUpsellIterator = (iteratorBlock: Block): Block => {
679
+ if (!isRelatedUpsellSplitSection || !isIteratorLike(iteratorBlock)) {
680
+ return iteratorBlock;
681
+ }
682
+
683
+ const readString = (value: unknown, fallback: string): string => {
684
+ if (typeof value === 'string' && value.trim()) {
685
+ return value.trim();
686
+ }
687
+ return fallback;
688
+ };
689
+
690
+ const splitItemsPerColumn = getSplitItemsPerColumn();
691
+ const inferredPaths = inferSplitDataPaths();
692
+ const relatedDataPath = readString(inferredPaths.relatedPath, 'products');
693
+ const upsellDataPath = readString(
694
+ inferredPaths.upsellPath,
695
+ relatedDataPath
696
+ );
697
+
698
+ const isUpsellIterator = (iteratorBlock.label || '')
699
+ .toLowerCase()
700
+ .includes('upsell');
701
+ const isSameDataPath = relatedDataPath === upsellDataPath;
702
+ const nextPath = isUpsellIterator ? upsellDataPath : relatedDataPath;
703
+ const nextOffset =
704
+ isUpsellIterator && isSameDataPath ? splitItemsPerColumn.active : 0;
705
+
706
+ const existingStyles = iteratorBlock.styles || {};
707
+ const existingDisplay =
708
+ typeof existingStyles.display === 'object' &&
709
+ existingStyles.display !== null
710
+ ? existingStyles.display
711
+ : {};
712
+ const existingColumns =
713
+ typeof existingStyles['grid-template-columns'] === 'object' &&
714
+ existingStyles['grid-template-columns'] !== null
715
+ ? existingStyles['grid-template-columns']
716
+ : {};
717
+
718
+ return {
719
+ ...iteratorBlock,
720
+ isIterator: true,
721
+ iteratorDataPath: nextPath,
722
+ properties: {
723
+ ...(iteratorBlock.properties || {}),
724
+ iteratorCount: splitItemsPerColumn.active,
725
+ iteratorDataPath: nextPath,
726
+ useIteratorCount: true,
727
+ iteratorOffset: nextOffset
728
+ },
729
+ styles: {
730
+ ...existingStyles,
731
+ display: {
732
+ ...existingDisplay,
733
+ desktop: 'grid',
734
+ mobile: 'grid'
735
+ },
736
+ 'grid-template-columns': {
737
+ ...existingColumns,
738
+ desktop: `repeat(${splitItemsPerColumn.desktop}, minmax(0, 1fr))`,
739
+ tablet:
740
+ existingColumns.tablet ||
741
+ `repeat(${splitItemsPerColumn.tablet}, minmax(0, 1fr))`,
742
+ mobile: `repeat(${splitItemsPerColumn.mobile}, minmax(0, 1fr))`
743
+ }
744
+ }
745
+ };
746
+ };
747
+
748
+ const normalizeRelatedUpsellStaticGrid = (gridBlock: Block): Block => {
749
+ if (isIteratorLike(gridBlock) || !isRelatedUpsellSplitSection) {
750
+ return gridBlock;
751
+ }
752
+
753
+ const normalizedLabel = (gridBlock.label || '').toLowerCase();
754
+ const hasGridTemplateColumns =
755
+ gridBlock.styles &&
756
+ typeof gridBlock.styles === 'object' &&
757
+ gridBlock.styles['grid-template-columns'] !== undefined;
758
+ const hasProductCardChildren = Boolean(
759
+ gridBlock.blocks?.some((child) => {
760
+ const childLabel = String(child.label || '').toLowerCase();
761
+ return child.type === 'group' && childLabel.includes('product card');
762
+ })
763
+ );
764
+ const isSplitGrid =
765
+ normalizedLabel.includes('related grid') ||
766
+ normalizedLabel.includes('upsell grid') ||
767
+ (hasGridTemplateColumns && hasProductCardChildren);
768
+
769
+ if (!isSplitGrid || !gridBlock.blocks || gridBlock.blocks.length === 0) {
770
+ return gridBlock;
771
+ }
772
+
773
+ const hasIteratorChild = gridBlock.blocks.some((child) =>
774
+ isIteratorLike(child)
775
+ );
776
+ if (hasIteratorChild) {
777
+ return gridBlock;
778
+ }
779
+
780
+ const splitItemsPerColumn = getSplitItemsPerColumn();
781
+ const itemsPerColumn = splitItemsPerColumn.active;
782
+
783
+ const cardBlocks = gridBlock.blocks
784
+ .filter((child) =>
785
+ (child.label || '').toLowerCase().includes('product card')
786
+ )
787
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
788
+
789
+ if (cardBlocks.length === 0) {
790
+ return gridBlock;
791
+ }
792
+
793
+ const cloneCard = (index: number): Block => {
794
+ const cloneWithIndex = (target: Block, suffix: string): Block => ({
795
+ ...target,
796
+ id: `${target.id}-preview-${suffix}`,
797
+ properties: target.properties
798
+ ? { ...target.properties }
799
+ : target.properties,
800
+ styles: target.styles
801
+ ? JSON.parse(JSON.stringify(target.styles))
802
+ : target.styles,
803
+ blocks: target.blocks
804
+ ? target.blocks.map((child, childIndex) =>
805
+ cloneWithIndex(child, `${suffix}-${childIndex}`)
806
+ )
807
+ : undefined
808
+ });
809
+
810
+ const sourceCard = cardBlocks[index % cardBlocks.length];
811
+ const nextCard =
812
+ index < cardBlocks.length
813
+ ? {
814
+ ...sourceCard,
815
+ properties: sourceCard.properties
816
+ ? { ...sourceCard.properties }
817
+ : sourceCard.properties,
818
+ styles: sourceCard.styles
819
+ ? JSON.parse(JSON.stringify(sourceCard.styles))
820
+ : sourceCard.styles,
821
+ blocks: sourceCard.blocks
822
+ ? sourceCard.blocks.map((child, childIndex) =>
823
+ cloneWithIndex(child, `${index}-${childIndex}`)
824
+ )
825
+ : undefined
826
+ }
827
+ : cloneWithIndex(sourceCard, `${index}`);
828
+
829
+ nextCard.order = index;
830
+ nextCard.hidden = false;
831
+ return nextCard;
832
+ };
833
+
834
+ const normalizedCards = Array.from(
835
+ { length: itemsPerColumn },
836
+ (_, index) => cloneCard(index)
837
+ );
838
+
839
+ const existingStyles = gridBlock.styles || {};
840
+ const existingColumns =
841
+ typeof existingStyles['grid-template-columns'] === 'object' &&
842
+ existingStyles['grid-template-columns'] !== null
843
+ ? existingStyles['grid-template-columns']
844
+ : {};
845
+
846
+ return {
847
+ ...gridBlock,
848
+ styles: {
849
+ ...existingStyles,
850
+ 'grid-template-columns': {
851
+ ...existingColumns,
852
+ desktop: `repeat(${splitItemsPerColumn.desktop}, minmax(0, 1fr))`,
853
+ tablet:
854
+ existingColumns.tablet ||
855
+ `repeat(${splitItemsPerColumn.tablet}, minmax(0, 1fr))`,
856
+ mobile: `repeat(${splitItemsPerColumn.mobile}, minmax(0, 1fr))`
857
+ }
858
+ },
859
+ blocks: normalizedCards
860
+ };
861
+ };
862
+
863
+ const normalizedGridBlock = normalizeRelatedUpsellStaticGrid(block);
864
+
865
+ const normalizedBlock = isIteratorLike(normalizedGridBlock)
866
+ ? normalizeRelatedUpsellIterator(normalizedGridBlock)
867
+ : normalizedGridBlock;
868
+
869
+ const splitItemsPerColumn = getSplitItemsPerColumn();
870
+ const forcedItemsPerColumn = splitItemsPerColumn.active;
871
+ const inferredPathsForForce = inferSplitDataPaths();
872
+ const relatedDataPathRaw = String(inferredPathsForForce.relatedPath).trim();
873
+ const upsellDataPathRaw = String(inferredPathsForForce.upsellPath).trim();
874
+ const isUpsellIteratorLike = (normalizedBlock.label || '')
875
+ .toLowerCase()
876
+ .includes('upsell');
877
+ const forceIteratorOffset =
878
+ isUpsellIteratorLike && relatedDataPathRaw === upsellDataPathRaw
879
+ ? forcedItemsPerColumn
880
+ : 0;
881
+
882
+ const resolvedBlock = isIteratorLike(normalizedBlock)
883
+ ? buildIteratorBlock({
884
+ iteratorBlock: normalizedBlock,
885
+ sectionDataSource: effectiveSection.dataSource,
886
+ isDesigner,
887
+ forceIteratorCount: isRelatedUpsellSplitSection
888
+ ? forcedItemsPerColumn
889
+ : undefined,
890
+ forceIteratorOffset: isRelatedUpsellSplitSection
891
+ ? forceIteratorOffset
892
+ : undefined
893
+ })
894
+ : normalizedBlock;
895
+
896
+ if (!resolvedBlock.blocks || resolvedBlock.blocks.length === 0) {
897
+ return resolvedBlock;
898
+ }
899
+
900
+ return {
901
+ ...resolvedBlock,
902
+ blocks: resolvedBlock.blocks.map(resolveIteratorsRecursively)
903
+ };
904
+ };
905
+
906
+ return (
907
+ <section
908
+ ref={sectionRef}
909
+ data-section-id={effectiveSection.id}
910
+ data-newsletter-signup={isNewsletterSection ? 'true' : undefined}
911
+ data-newsletter-endpoint={
912
+ isNewsletterSection ? newsletterEndpoint : undefined
913
+ }
914
+ data-newsletter-success-message={
915
+ isNewsletterSection && newsletterSuccessMessage
916
+ ? newsletterSuccessMessage
917
+ : undefined
918
+ }
919
+ data-newsletter-error-message={
920
+ isNewsletterSection && newsletterErrorMessage
921
+ ? newsletterErrorMessage
922
+ : undefined
923
+ }
924
+ className={`theme-section relative ${
925
+ isDesigner ? 'cursor-pointer group/section' : ''
926
+ }`}
927
+ onClick={handleClick}
928
+ >
929
+ {isDesigner && (
930
+ <div
931
+ className={twMerge(
932
+ clsx(
933
+ 'absolute inset-0 pointer-events-none z-0 border-2 transition-all',
934
+ effectiveSection.locked
935
+ ? isSelected
936
+ ? 'border-dashed border-amber-400 bg-amber-400/[0.12] shadow-[0_0_0_1px_rgba(251,191,36,0.4)]'
937
+ : 'border-dashed border-amber-300/25 bg-amber-400/[0.03] group-hover/section:border-amber-300/55 group-hover/section:bg-amber-400/[0.07]'
938
+ : 'border-transparent group-hover/section:border-[#4482ff] group-hover/section:bg-[#4482ff]/10',
939
+ !effectiveSection.locked && isSelected && 'border-[#4482ff]'
940
+ )
941
+ )}
942
+ />
943
+ )}
944
+ {isDesigner && isSelected && (
945
+ <ActionToolbar
946
+ label={effectiveSection.label}
947
+ isLocked={effectiveSection.locked === true}
948
+ zIndex={20}
949
+ onMoveUp={onMoveUp}
950
+ onMoveDown={onMoveDown}
951
+ onDuplicate={onDuplicate}
952
+ onToggleVisibility={onToggleVisibility}
953
+ onDelete={onDelete}
954
+ onRename={onRename}
955
+ />
956
+ )}
957
+
958
+ <div className={twMerge(clsx('contents'))}>
959
+ {effectiveSection.blocks
960
+ .sort((a, b) => a.order - b.order)
961
+ .filter((block) => (isDesigner ? true : !block.hidden))
962
+ .map((block) => {
963
+ const resolvedBlock = resolveIteratorsRecursively(block);
964
+ const createActionHandler = (actionType: string) => () => {
965
+ if (window.parent) {
966
+ window.parent.postMessage(
967
+ {
968
+ type: actionType,
969
+ data: {
970
+ placeholderId,
971
+ sectionId: effectiveSection.id,
972
+ blockId: block.id
973
+ }
974
+ },
975
+ '*'
976
+ );
977
+ }
978
+ };
979
+
980
+ const handleRename = (newLabel: string) => {
981
+ if (window.parent) {
982
+ window.parent.postMessage(
983
+ {
984
+ type: 'RENAME_BLOCK',
985
+ data: {
986
+ placeholderId,
987
+ sectionId: effectiveSection.id,
988
+ blockId: block.id,
989
+ label: newLabel
990
+ }
991
+ },
992
+ '*'
993
+ );
994
+ }
995
+ };
996
+ const renderBlockWithActions = (renderedBlock: Block) => (
997
+ <ThemeBlock
998
+ key={renderedBlock.id}
999
+ block={renderedBlock}
1000
+ placeholderId={placeholderId}
1001
+ sectionId={effectiveSection.id}
1002
+ isDesigner={isDesigner}
1003
+ isSelected={selectedBlockId === block.id}
1004
+ selectedBlockId={selectedBlockId}
1005
+ currentBreakpoint={currentBreakpoint}
1006
+ onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
1007
+ onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
1008
+ onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
1009
+ onToggleVisibility={createActionHandler(
1010
+ 'TOGGLE_BLOCK_VISIBILITY'
1011
+ )}
1012
+ onDelete={createActionHandler('DELETE_BLOCK')}
1013
+ onRename={handleRename}
1014
+ />
1015
+ );
1016
+
1017
+ if (!isFrequentlyBoughtTogetherSection) {
1018
+ return renderBlockWithActions(resolvedBlock);
1019
+ }
1020
+
1021
+ const replaceFrequentlyBoughtBlockValues = (
1022
+ blockToReplace: Block,
1023
+ productData: Record<string, unknown>,
1024
+ productIndex: number
1025
+ ): Block => {
1026
+ const newBlock = { ...blockToReplace };
1027
+ const isPlaceholderMode =
1028
+ !productData || Object.keys(productData).length === 0;
1029
+
1030
+ if (newBlock.properties?.dataBinding && !isPlaceholderMode) {
1031
+ const bindingPath = String(
1032
+ newBlock.properties.dataBinding
1033
+ ).replace('item.', '');
1034
+ const pathParts = bindingPath.split('.');
1035
+
1036
+ let value: unknown = productData;
1037
+ for (const part of pathParts) {
1038
+ const arrayMatch = part.match(/^(.+)\[(\d+)\]$/);
1039
+ if (arrayMatch) {
1040
+ const [, arrayName, indexStr] = arrayMatch;
1041
+ const obj = value as Record<string, unknown>;
1042
+ const arr = obj?.[arrayName];
1043
+ value = Array.isArray(arr)
1044
+ ? arr[parseInt(indexStr, 10)]
1045
+ : undefined;
1046
+ } else {
1047
+ value = (value as Record<string, unknown>)?.[part];
1048
+ }
1049
+ if (value === undefined) break;
1050
+ }
1051
+
1052
+ if (value !== undefined) {
1053
+ if (
1054
+ newBlock.type === 'text' &&
1055
+ bindingPath === 'active_price.price'
1056
+ ) {
1057
+ newBlock.value =
1058
+ buildFrequentlyBoughtPriceHtml(productData);
1059
+ return newBlock;
1060
+ }
1061
+
1062
+ if (newBlock.properties.tag === 'a') {
1063
+ newBlock.properties = {
1064
+ ...newBlock.properties,
1065
+ href: value
1066
+ };
1067
+ } else {
1068
+ newBlock.value = value;
1069
+ }
1070
+ }
1071
+ } else if (isPlaceholderMode && newBlock.type === 'image') {
1072
+ const placeholderImages = [
1073
+ '/assets/images/product-placeholder-1.jpg',
1074
+ '/assets/images/product-placeholder-2.jpg',
1075
+ '/assets/images/product-placeholder-3.jpg',
1076
+ '/assets/images/product-placeholder-4.jpg'
1077
+ ];
1078
+ const placeholderIndex =
1079
+ productIndex % placeholderImages.length;
1080
+
1081
+ if (
1082
+ newBlock.value &&
1083
+ typeof newBlock.value === 'string' &&
1084
+ newBlock.value.includes('product-placeholder')
1085
+ ) {
1086
+ newBlock.value = placeholderImages[placeholderIndex];
1087
+ }
1088
+ }
1089
+
1090
+ if (newBlock.blocks && newBlock.blocks.length > 0) {
1091
+ newBlock.blocks = newBlock.blocks.map((childBlock) =>
1092
+ replaceFrequentlyBoughtBlockValues(
1093
+ childBlock,
1094
+ productData,
1095
+ productIndex
1096
+ )
1097
+ );
1098
+ }
1099
+
1100
+ return newBlock;
1101
+ };
1102
+
1103
+ if (block.isIterator && block.blocks && block.blocks.length > 0) {
1104
+ const template = block.blocks[0];
1105
+ const { products, actualCount } = getIteratorRenderMeta(block);
1106
+
1107
+ const clonedBlocks = Array.from(
1108
+ { length: actualCount },
1109
+ (_, index) => {
1110
+ const product = products[index] || {};
1111
+ const templateCopy = {
1112
+ ...template,
1113
+ id: `${template.id}-clone-${index}`,
1114
+ styleSourceId: template.id,
1115
+ blocks: template.blocks
1116
+ ? [
1117
+ ...template.blocks.map((childBlock) => ({
1118
+ ...childBlock,
1119
+ styleSourceId: childBlock.id
1120
+ }))
1121
+ ]
1122
+ : undefined
1123
+ };
1124
+
1125
+ return replaceFrequentlyBoughtBlockValues(
1126
+ templateCopy,
1127
+ product,
1128
+ index
1129
+ );
1130
+ }
1131
+ );
1132
+
1133
+ return renderBlockWithActions({
1134
+ ...block,
1135
+ blocks: clonedBlocks
1136
+ });
1137
+ }
1138
+
1139
+ const buildNestedIteratorClones = (
1140
+ iteratorBlock: Block
1141
+ ): Block[] => {
1142
+ if (!iteratorBlock.blocks || iteratorBlock.blocks.length === 0) {
1143
+ return iteratorBlock.blocks || [];
1144
+ }
1145
+
1146
+ const template = iteratorBlock.blocks[0];
1147
+ const { products, actualCount } =
1148
+ getIteratorRenderMeta(iteratorBlock);
1149
+
1150
+ return Array.from({ length: actualCount }, (_, index) => {
1151
+ const product = products[index] || {};
1152
+ const templateCopy = {
1153
+ ...template,
1154
+ id: `${template.id}-clone-${index}`,
1155
+ styleSourceId: template.id,
1156
+ blocks: template.blocks
1157
+ ? [
1158
+ ...template.blocks.map((childBlock) => ({
1159
+ ...childBlock,
1160
+ styleSourceId: childBlock.id
1161
+ }))
1162
+ ]
1163
+ : undefined
1164
+ };
1165
+
1166
+ return replaceFrequentlyBoughtBlockValues(
1167
+ templateCopy,
1168
+ product,
1169
+ index
1170
+ );
1171
+ });
1172
+ };
1173
+
1174
+ const expandNestedIterators = (sourceBlock: Block): Block => {
1175
+ if (!sourceBlock.blocks || sourceBlock.blocks.length === 0) {
1176
+ return sourceBlock;
1177
+ }
1178
+
1179
+ return {
1180
+ ...sourceBlock,
1181
+ blocks: sourceBlock.blocks.map((childBlock) => {
1182
+ if (
1183
+ childBlock.isIterator &&
1184
+ childBlock.blocks &&
1185
+ childBlock.blocks.length > 0
1186
+ ) {
1187
+ return {
1188
+ ...childBlock,
1189
+ blocks: buildNestedIteratorClones(childBlock)
1190
+ };
1191
+ }
1192
+
1193
+ return expandNestedIterators(childBlock);
1194
+ })
1195
+ };
1196
+ };
1197
+
1198
+ const renderedBlock = expandNestedIterators(block);
1199
+ let finalRenderedBlock = renderedBlock;
1200
+
1201
+ if (effectiveSection.dataSourceId || effectiveSection.dataSource) {
1202
+ const iteratorBlockForSummary = findFirstIteratorBlock(block);
1203
+
1204
+ if (iteratorBlockForSummary) {
1205
+ const { products, actualCount } = getIteratorRenderMeta(
1206
+ iteratorBlockForSummary
1207
+ );
1208
+ const { bundlePriceHtml, savingsHtml } =
1209
+ computeFrequentlyBoughtSummary(products, actualCount);
1210
+
1211
+ finalRenderedBlock = applyFrequentlyBoughtSummaryValues(
1212
+ renderedBlock,
1213
+ bundlePriceHtml,
1214
+ savingsHtml
1215
+ );
1216
+ }
1217
+ }
1218
+
1219
+ return renderBlockWithActions(finalRenderedBlock);
1220
+ })}
1221
+ </div>
1222
+ </section>
1223
+ );
1224
+ }