@akinon/pz-theme 2.0.0-beta.21

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