@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,1065 @@
1
+ 'use client';
2
+
3
+ import { Image } from '@akinon/next/components/image';
4
+ import { useGetInstallmentsQuery, useGetProductByPkQuery } from '@akinon/next/data/client/product';
5
+ import { useLocalization } from '@akinon/next/hooks';
6
+ import clsx from 'clsx';
7
+ import React, { useEffect, useMemo, useState } from 'react';
8
+
9
+ import { LoaderSpinner } from '../../loader-spinner';
10
+ import { Price } from '../../price';
11
+ import ThemeBlock, { Block } from '../theme-block';
12
+ import { WithDesignerFeatures } from '../components/with-designer-features';
13
+ import { useThemeSettingsContext } from '../theme-settings-context';
14
+ import { Section } from '../theme-section';
15
+ import {
16
+ getCSSStyles,
17
+ getResponsiveValue,
18
+ resolveThemeCssVariables
19
+ } from '../utils';
20
+
21
+ interface InstallmentOptionsSectionProps {
22
+ section: Section;
23
+ currentBreakpoint?: string;
24
+ placeholderId?: string;
25
+ isDesigner?: boolean;
26
+ selectedBlockId?: string | null;
27
+ }
28
+
29
+ interface InstallmentPlan {
30
+ installment_count: number;
31
+ single_installment_amount: string;
32
+ total_amount: string;
33
+ }
34
+
35
+ interface InstallmentCard {
36
+ pk: number;
37
+ slug: string;
38
+ name: string;
39
+ card_type?: {
40
+ logo?: string;
41
+ };
42
+ installments: InstallmentPlan[];
43
+ }
44
+
45
+ interface InstallmentResponse {
46
+ results: InstallmentCard[];
47
+ }
48
+
49
+ type ProductLike = Record<string, unknown>;
50
+
51
+ const PLACEHOLDER_PRODUCT = {
52
+ name: 'Premium Running Sneaker',
53
+ price: '4.999 TL',
54
+ retail_price: '5.699 TL',
55
+ currency_type: 'TRY',
56
+ absolute_url: '#'
57
+ };
58
+
59
+ const PLACEHOLDER_INSTALLMENTS: InstallmentCard[] = [
60
+ {
61
+ pk: 1,
62
+ slug: 'visa',
63
+ name: 'Visa',
64
+ installments: [
65
+ {
66
+ installment_count: 1,
67
+ single_installment_amount: '4.999',
68
+ total_amount: '4.999'
69
+ },
70
+ {
71
+ installment_count: 3,
72
+ single_installment_amount: '1.716,33',
73
+ total_amount: '5.149'
74
+ },
75
+ {
76
+ installment_count: 6,
77
+ single_installment_amount: '899,83',
78
+ total_amount: '5.399'
79
+ }
80
+ ]
81
+ },
82
+ {
83
+ pk: 2,
84
+ slug: 'mastercard',
85
+ name: 'Mastercard',
86
+ installments: [
87
+ {
88
+ installment_count: 1,
89
+ single_installment_amount: '4.999',
90
+ total_amount: '4.999'
91
+ },
92
+ {
93
+ installment_count: 4,
94
+ single_installment_amount: '1.337,25',
95
+ total_amount: '5.349'
96
+ },
97
+ {
98
+ installment_count: 8,
99
+ single_installment_amount: '706,12',
100
+ total_amount: '5.649'
101
+ }
102
+ ]
103
+ },
104
+ {
105
+ pk: 3,
106
+ slug: 'amex',
107
+ name: 'Amex',
108
+ installments: [
109
+ {
110
+ installment_count: 1,
111
+ single_installment_amount: '4.999',
112
+ total_amount: '4.999'
113
+ },
114
+ {
115
+ installment_count: 2,
116
+ single_installment_amount: '2.599,50',
117
+ total_amount: '5.199'
118
+ },
119
+ {
120
+ installment_count: 5,
121
+ single_installment_amount: '1.119,80',
122
+ total_amount: '5.599'
123
+ }
124
+ ]
125
+ }
126
+ ];
127
+
128
+ const customStyleKeys = [
129
+ 'max-width',
130
+ 'panel-background-color',
131
+ 'panel-border-color',
132
+ 'panel-border-radius',
133
+ 'product-card-background-color',
134
+ 'product-card-border-color',
135
+ 'logo-card-background-color',
136
+ 'logo-card-border-color',
137
+ 'logo-card-active-background-color',
138
+ 'logo-card-active-border-color',
139
+ 'table-header-color',
140
+ 'table-row-border-color',
141
+ 'highlight-row-background-color',
142
+ 'highlight-row-text-color'
143
+ ];
144
+
145
+ const readStringProperty = (
146
+ properties: Record<string, unknown> | undefined,
147
+ key: string,
148
+ fallback: string,
149
+ breakpoint: string
150
+ ): string => {
151
+ const value = getResponsiveValue(properties?.[key], breakpoint, fallback);
152
+ if (value == null) return fallback;
153
+ return String(value);
154
+ };
155
+
156
+ const readBooleanProperty = (
157
+ properties: Record<string, unknown> | undefined,
158
+ key: string,
159
+ fallback: boolean,
160
+ breakpoint: string
161
+ ): boolean => {
162
+ const value = getResponsiveValue(properties?.[key], breakpoint, fallback);
163
+ if (typeof value === 'boolean') return value;
164
+ if (typeof value === 'string') {
165
+ const normalized = value.trim().toLowerCase();
166
+ if (normalized === 'true') return true;
167
+ if (normalized === 'false') return false;
168
+ }
169
+ return fallback;
170
+ };
171
+
172
+ const readStyleValue = (
173
+ styles: Record<string, unknown> | undefined,
174
+ key: string,
175
+ fallback: string,
176
+ breakpoint: string,
177
+ themeSettings: Record<string, unknown>
178
+ ): string => {
179
+ return resolveThemeCssVariables(
180
+ String(getResponsiveValue(styles?.[key], breakpoint, fallback)),
181
+ themeSettings
182
+ );
183
+ };
184
+
185
+ const parsePriceValue = (value: unknown): number | null => {
186
+ if (typeof value === 'number' && Number.isFinite(value)) {
187
+ return value;
188
+ }
189
+
190
+ if (typeof value !== 'string') return null;
191
+ const cleaned = value.trim().replace(/[^\d,.-]/g, '');
192
+ if (!cleaned) return null;
193
+
194
+ let normalized = cleaned;
195
+ const hasComma = normalized.includes(',');
196
+ const hasDot = normalized.includes('.');
197
+
198
+ if (hasComma && hasDot) {
199
+ const lastComma = normalized.lastIndexOf(',');
200
+ const lastDot = normalized.lastIndexOf('.');
201
+ normalized =
202
+ lastComma > lastDot
203
+ ? normalized.replace(/\./g, '').replace(',', '.')
204
+ : normalized.replace(/,/g, '');
205
+ } else if (hasComma) {
206
+ const unsigned = normalized.replace(/^-/, '');
207
+ const isThousandsPattern = /^\d{1,3}(,\d{3})+$/.test(unsigned);
208
+ normalized = isThousandsPattern
209
+ ? normalized.replace(/,/g, '')
210
+ : normalized.replace(/,/g, '.');
211
+ }
212
+
213
+ const parsed = Number(normalized);
214
+ return Number.isFinite(parsed) ? parsed : null;
215
+ };
216
+
217
+ const normalizeCurrencyLabel = (currency: unknown): string => {
218
+ const raw = String(currency || '').trim().toUpperCase();
219
+ if (!raw) return 'TL';
220
+
221
+ const map: Record<string, string> = {
222
+ TRY: 'TL',
223
+ TL: 'TL',
224
+ USD: 'USD',
225
+ EUR: 'EUR',
226
+ GBP: 'GBP'
227
+ };
228
+
229
+ return map[raw] || raw;
230
+ };
231
+
232
+ const formatPriceWithCurrency = (
233
+ value: unknown,
234
+ currency: unknown
235
+ ): string | null => {
236
+ if (value == null || value === '') return null;
237
+
238
+ const price = parsePriceValue(value);
239
+ if (price === null) {
240
+ const text = String(value).trim();
241
+ return text || null;
242
+ }
243
+
244
+ const hasDecimals = Math.abs(price % 1) > 0.00001;
245
+ const formatted = price.toLocaleString('tr-TR', {
246
+ minimumFractionDigits: hasDecimals ? 2 : 0,
247
+ maximumFractionDigits: 2
248
+ });
249
+
250
+ return `${formatted} ${normalizeCurrencyLabel(currency)}`;
251
+ };
252
+
253
+ const getCollectionProducts = (
254
+ section: Section,
255
+ isDesigner: boolean
256
+ ): ProductLike[] => {
257
+ const collectionDetails = section.dataSource?.details?.collection;
258
+ const staticData = section.dataSource?.details?.static?.data;
259
+ const isEditorMode =
260
+ typeof window !== 'undefined' && isDesigner && window.parent !== window;
261
+
262
+ const collectionPayload = isEditorMode
263
+ ? collectionDetails?.products || collectionDetails?.data
264
+ : collectionDetails?.data || collectionDetails?.products;
265
+
266
+ if (Array.isArray(collectionPayload)) {
267
+ return collectionPayload as ProductLike[];
268
+ }
269
+
270
+ if (Array.isArray((collectionPayload as ProductLike | undefined)?.products)) {
271
+ return (collectionPayload as ProductLike).products as ProductLike[];
272
+ }
273
+
274
+ if (Array.isArray((collectionPayload as ProductLike | undefined)?.items)) {
275
+ return (collectionPayload as ProductLike).items as ProductLike[];
276
+ }
277
+
278
+ if (Array.isArray(staticData)) {
279
+ return staticData as ProductLike[];
280
+ }
281
+
282
+ return [];
283
+ };
284
+
285
+ const parseProductPk = (value: unknown): number | null => {
286
+ const raw = String(value ?? '').trim();
287
+ if (!raw) return null;
288
+ const parsed = Number.parseInt(raw, 10);
289
+ return Number.isFinite(parsed) ? parsed : null;
290
+ };
291
+
292
+ const getProductImage = (product: ProductLike | null): string | null => {
293
+ if (!product) return null;
294
+
295
+ if (typeof product.image === 'string' && product.image) {
296
+ return product.image;
297
+ }
298
+
299
+ const imageSet = product.productimage_set;
300
+ if (Array.isArray(imageSet)) {
301
+ const firstImage = imageSet[0] as ProductLike | undefined;
302
+ if (typeof firstImage?.image === 'string' && firstImage.image) {
303
+ return firstImage.image;
304
+ }
305
+ }
306
+
307
+ return null;
308
+ };
309
+
310
+ const getProductPrice = (product: ProductLike | null): string | null => {
311
+ if (!product) return null;
312
+ const activePrice = (product.active_price as ProductLike | undefined) || {};
313
+ return formatPriceWithCurrency(
314
+ activePrice.price ?? product.price,
315
+ activePrice.currency_type ?? product.currency_type
316
+ );
317
+ };
318
+
319
+ const getProductRetailPrice = (product: ProductLike | null): string | null => {
320
+ if (!product) return null;
321
+ const activePrice = (product.active_price as ProductLike | undefined) || {};
322
+ const retailValue = activePrice.retail_price ?? product.retail_price;
323
+ const currentValue = activePrice.price ?? product.price;
324
+ const retailPrice = parsePriceValue(retailValue);
325
+ const currentPrice = parsePriceValue(currentValue);
326
+
327
+ if (
328
+ retailPrice === null ||
329
+ currentPrice === null ||
330
+ retailPrice <= currentPrice
331
+ ) {
332
+ return null;
333
+ }
334
+
335
+ return formatPriceWithCurrency(
336
+ retailValue,
337
+ activePrice.currency_type ?? product.currency_type
338
+ );
339
+ };
340
+
341
+ const getFallbackCardLabel = (name: string): string => {
342
+ const words = name
343
+ .split(/\s+/)
344
+ .map((part) => part.trim())
345
+ .filter(Boolean);
346
+
347
+ if (words.length === 0) return 'CARD';
348
+ if (words.length === 1) return words[0].slice(0, 4).toUpperCase();
349
+
350
+ return words
351
+ .slice(0, 2)
352
+ .map((word) => word[0])
353
+ .join('')
354
+ .toUpperCase();
355
+ };
356
+
357
+ const resolveResponsiveString = (
358
+ value: unknown,
359
+ breakpoint: string
360
+ ): string => {
361
+ if (typeof value === 'string') return value;
362
+
363
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
364
+ const responsiveValue = getResponsiveValue(value, breakpoint, '');
365
+ if (typeof responsiveValue === 'string') {
366
+ return responsiveValue;
367
+ }
368
+ }
369
+
370
+ return '';
371
+ };
372
+
373
+ const getInstallmentRole = (
374
+ block: Pick<Block, 'properties'>,
375
+ breakpoint: string
376
+ ): string =>
377
+ resolveResponsiveString(block.properties?.installmentRole, breakpoint);
378
+
379
+ const findInstallmentRoleBlock = (
380
+ blocks: Block[],
381
+ role: string,
382
+ breakpoint: string
383
+ ): Block | null => {
384
+ for (const block of blocks) {
385
+ if (getInstallmentRole(block, breakpoint) === role) {
386
+ return block;
387
+ }
388
+
389
+ if (block.blocks?.length) {
390
+ const nestedMatch = findInstallmentRoleBlock(
391
+ block.blocks,
392
+ role,
393
+ breakpoint
394
+ );
395
+ if (nestedMatch) {
396
+ return nestedMatch;
397
+ }
398
+ }
399
+ }
400
+
401
+ return null;
402
+ };
403
+
404
+ export default function InstallmentOptionsSection({
405
+ section,
406
+ currentBreakpoint = 'desktop',
407
+ placeholderId = '',
408
+ isDesigner = false,
409
+ selectedBlockId = null
410
+ }: InstallmentOptionsSectionProps) {
411
+ const themeSettings = useThemeSettingsContext();
412
+ const { t } = useLocalization();
413
+ const isMobile = currentBreakpoint === 'mobile';
414
+
415
+ const maxWidth = getResponsiveValue(
416
+ section.styles?.['max-width'],
417
+ currentBreakpoint,
418
+ 'normal'
419
+ );
420
+ const maxWidthClass =
421
+ maxWidth === 'narrow'
422
+ ? 'max-w-4xl'
423
+ : maxWidth === 'normal'
424
+ ? 'max-w-7xl'
425
+ : '';
426
+ const hasMaxWidth = maxWidth !== 'none';
427
+
428
+ const filteredStyles = Object.fromEntries(
429
+ Object.entries(section.styles || {}).filter(
430
+ ([key]) => !customStyleKeys.includes(key)
431
+ )
432
+ );
433
+
434
+ const sectionStyles = getCSSStyles(
435
+ filteredStyles,
436
+ themeSettings,
437
+ currentBreakpoint
438
+ );
439
+
440
+ const showProductCard = readBooleanProperty(
441
+ section.properties,
442
+ 'show-product-card',
443
+ true,
444
+ currentBreakpoint
445
+ );
446
+ const showCardLogos = readBooleanProperty(
447
+ section.properties,
448
+ 'show-card-logos',
449
+ true,
450
+ currentBreakpoint
451
+ );
452
+ const highlightBestOption = readBooleanProperty(
453
+ section.properties,
454
+ 'highlight-best-option',
455
+ true,
456
+ currentBreakpoint
457
+ );
458
+ const emptyStateText = readStringProperty(
459
+ section.properties,
460
+ 'empty-state-text',
461
+ 'Installment options not available for this product.',
462
+ currentBreakpoint
463
+ );
464
+
465
+ const panelBackground = readStyleValue(
466
+ section.styles,
467
+ 'panel-background-color',
468
+ '#ffffff',
469
+ currentBreakpoint,
470
+ themeSettings
471
+ );
472
+ const panelBorder = readStyleValue(
473
+ section.styles,
474
+ 'panel-border-color',
475
+ '#e2e8f0',
476
+ currentBreakpoint,
477
+ themeSettings
478
+ );
479
+ const panelRadius = readStyleValue(
480
+ section.styles,
481
+ 'panel-border-radius',
482
+ '16px',
483
+ currentBreakpoint,
484
+ themeSettings
485
+ );
486
+ const productCardBackground = readStyleValue(
487
+ section.styles,
488
+ 'product-card-background-color',
489
+ '#f8fafc',
490
+ currentBreakpoint,
491
+ themeSettings
492
+ );
493
+ const productCardBorder = readStyleValue(
494
+ section.styles,
495
+ 'product-card-border-color',
496
+ '#e2e8f0',
497
+ currentBreakpoint,
498
+ themeSettings
499
+ );
500
+ const logoCardBackground = readStyleValue(
501
+ section.styles,
502
+ 'logo-card-background-color',
503
+ '#ffffff',
504
+ currentBreakpoint,
505
+ themeSettings
506
+ );
507
+ const logoCardBorder = readStyleValue(
508
+ section.styles,
509
+ 'logo-card-border-color',
510
+ '#e2e8f0',
511
+ currentBreakpoint,
512
+ themeSettings
513
+ );
514
+ const logoCardActiveBackground = readStyleValue(
515
+ section.styles,
516
+ 'logo-card-active-background-color',
517
+ '#eff6ff',
518
+ currentBreakpoint,
519
+ themeSettings
520
+ );
521
+ const logoCardActiveBorder = readStyleValue(
522
+ section.styles,
523
+ 'logo-card-active-border-color',
524
+ '#0f172a',
525
+ currentBreakpoint,
526
+ themeSettings
527
+ );
528
+ const tableHeaderColor = readStyleValue(
529
+ section.styles,
530
+ 'table-header-color',
531
+ '#64748b',
532
+ currentBreakpoint,
533
+ themeSettings
534
+ );
535
+ const tableRowBorderColor = readStyleValue(
536
+ section.styles,
537
+ 'table-row-border-color',
538
+ '#e2e8f0',
539
+ currentBreakpoint,
540
+ themeSettings
541
+ );
542
+ const highlightRowBackground = readStyleValue(
543
+ section.styles,
544
+ 'highlight-row-background-color',
545
+ '#ecfdf5',
546
+ currentBreakpoint,
547
+ themeSettings
548
+ );
549
+ const highlightRowText = readStyleValue(
550
+ section.styles,
551
+ 'highlight-row-text-color',
552
+ '#166534',
553
+ currentBreakpoint,
554
+ themeSettings
555
+ );
556
+
557
+ const sectionProducts = useMemo(
558
+ () => getCollectionProducts(section, isDesigner),
559
+ [section, isDesigner]
560
+ );
561
+ const collectionProduct = (sectionProducts[0] as ProductLike | undefined) || null;
562
+ const propertyProductPk = parseProductPk(
563
+ readStringProperty(section.properties, 'product-pk', '', currentBreakpoint)
564
+ );
565
+ const resolvedProductPk = parseProductPk(collectionProduct?.pk) ?? propertyProductPk;
566
+
567
+ const shouldFetchProduct = !collectionProduct && resolvedProductPk !== null;
568
+ const { data: productResponse } = useGetProductByPkQuery(
569
+ resolvedProductPk as number,
570
+ {
571
+ skip: !shouldFetchProduct
572
+ }
573
+ );
574
+ const { data: installmentsData, isLoading } = useGetInstallmentsQuery(
575
+ resolvedProductPk as number,
576
+ {
577
+ skip: resolvedProductPk === null
578
+ }
579
+ );
580
+
581
+ const product = useMemo<ProductLike | null>((): ProductLike | null => {
582
+ if (collectionProduct) return collectionProduct;
583
+ const fetchedProduct = (
584
+ productResponse as { product?: ProductLike } | undefined
585
+ )?.product;
586
+ return fetchedProduct ?? null;
587
+ }, [collectionProduct, productResponse]);
588
+
589
+ const usePlaceholderData = isDesigner && resolvedProductPk === null && !product;
590
+
591
+ const installmentCards = useMemo<InstallmentCard[]>(() => {
592
+ if (usePlaceholderData) {
593
+ return PLACEHOLDER_INSTALLMENTS;
594
+ }
595
+ return ((installmentsData as InstallmentResponse | undefined)?.results || []).filter(
596
+ (card): card is InstallmentCard =>
597
+ Boolean(card && card.slug && Array.isArray(card.installments))
598
+ );
599
+ }, [installmentsData, usePlaceholderData]);
600
+
601
+ const [activeCardSlug, setActiveCardSlug] = useState<string | null>(null);
602
+
603
+ useEffect(() => {
604
+ if (installmentCards.length === 0) {
605
+ setActiveCardSlug(null);
606
+ return;
607
+ }
608
+
609
+ const hasCurrentCard = installmentCards.some(
610
+ (card) => card.slug === activeCardSlug
611
+ );
612
+
613
+ if (!hasCurrentCard) {
614
+ setActiveCardSlug(installmentCards[0].slug);
615
+ }
616
+ }, [activeCardSlug, installmentCards]);
617
+
618
+ const activeCard =
619
+ installmentCards.find((card) => card.slug === activeCardSlug) ||
620
+ installmentCards[0] ||
621
+ null;
622
+
623
+ const sortedBlocks = [...(section.blocks || [])]
624
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
625
+ .filter((block) => (isDesigner ? true : !block.hidden));
626
+
627
+ const contentBlocks = sortedBlocks.filter(
628
+ (block) => !getInstallmentRole(block, currentBreakpoint)
629
+ );
630
+ const productCardWrapperBlock = findInstallmentRoleBlock(
631
+ sortedBlocks,
632
+ 'product-card-wrapper',
633
+ currentBreakpoint
634
+ );
635
+ const installmentPanelWrapperBlock = findInstallmentRoleBlock(
636
+ sortedBlocks,
637
+ 'installment-panel-wrapper',
638
+ currentBreakpoint
639
+ );
640
+ const logoCardWrapperBlock = findInstallmentRoleBlock(
641
+ sortedBlocks,
642
+ 'logo-card-wrapper',
643
+ currentBreakpoint
644
+ );
645
+ const tableWrapperBlock = findInstallmentRoleBlock(
646
+ sortedBlocks,
647
+ 'table-wrapper',
648
+ currentBreakpoint
649
+ );
650
+
651
+ const productName = String(
652
+ product?.name || PLACEHOLDER_PRODUCT.name
653
+ ).trim();
654
+ const productUrl = String(
655
+ product?.absolute_url || PLACEHOLDER_PRODUCT.absolute_url
656
+ ).trim();
657
+ const productImage = getProductImage(product);
658
+ const productPrice = getProductPrice(product) || PLACEHOLDER_PRODUCT.price;
659
+ const productRetailPrice = getProductRetailPrice(product);
660
+
661
+ const renderBlock = (block: Block) => (
662
+ <ThemeBlock
663
+ key={block.id}
664
+ block={block}
665
+ placeholderId={placeholderId}
666
+ sectionId={section.id}
667
+ isDesigner={isDesigner}
668
+ isSelected={selectedBlockId === block.id}
669
+ selectedBlockId={selectedBlockId}
670
+ currentBreakpoint={currentBreakpoint}
671
+ onMoveUp={() => {
672
+ if (window.parent) {
673
+ window.parent.postMessage(
674
+ {
675
+ type: 'MOVE_BLOCK_UP',
676
+ data: { placeholderId, sectionId: section.id, blockId: block.id }
677
+ },
678
+ '*'
679
+ );
680
+ }
681
+ }}
682
+ onMoveDown={() => {
683
+ if (window.parent) {
684
+ window.parent.postMessage(
685
+ {
686
+ type: 'MOVE_BLOCK_DOWN',
687
+ data: { placeholderId, sectionId: section.id, blockId: block.id }
688
+ },
689
+ '*'
690
+ );
691
+ }
692
+ }}
693
+ onDuplicate={() => {
694
+ if (window.parent) {
695
+ window.parent.postMessage(
696
+ {
697
+ type: 'DUPLICATE_BLOCK',
698
+ data: { placeholderId, sectionId: section.id, blockId: block.id }
699
+ },
700
+ '*'
701
+ );
702
+ }
703
+ }}
704
+ onToggleVisibility={() => {
705
+ if (window.parent) {
706
+ window.parent.postMessage(
707
+ {
708
+ type: 'TOGGLE_BLOCK_VISIBILITY',
709
+ data: { placeholderId, sectionId: section.id, blockId: block.id }
710
+ },
711
+ '*'
712
+ );
713
+ }
714
+ }}
715
+ onDelete={() => {
716
+ if (window.parent) {
717
+ window.parent.postMessage(
718
+ {
719
+ type: 'DELETE_BLOCK',
720
+ data: { placeholderId, sectionId: section.id, blockId: block.id }
721
+ },
722
+ '*'
723
+ );
724
+ }
725
+ }}
726
+ onRename={(newLabel) => {
727
+ if (window.parent) {
728
+ window.parent.postMessage(
729
+ {
730
+ type: 'RENAME_BLOCK',
731
+ data: {
732
+ placeholderId,
733
+ sectionId: section.id,
734
+ blockId: block.id,
735
+ label: newLabel
736
+ }
737
+ },
738
+ '*'
739
+ );
740
+ }
741
+ }}
742
+ />
743
+ );
744
+
745
+ const postBlockAction = (type: string, blockId: string, label?: string) => {
746
+ if (!window.parent) return;
747
+ window.parent.postMessage(
748
+ {
749
+ type,
750
+ data: {
751
+ placeholderId,
752
+ sectionId: section.id,
753
+ blockId,
754
+ ...(label ? { label } : {})
755
+ }
756
+ },
757
+ '*'
758
+ );
759
+ };
760
+
761
+ const renderSelectableWrapper = (
762
+ block: Block | null,
763
+ children: React.ReactNode,
764
+ options: {
765
+ className?: string;
766
+ style?: React.CSSProperties;
767
+ keyOverride?: string;
768
+ } = {}
769
+ ) => {
770
+ if (!block) {
771
+ return (
772
+ <div
773
+ key={options.keyOverride}
774
+ className={options.className}
775
+ style={options.style}
776
+ >
777
+ {children}
778
+ </div>
779
+ );
780
+ }
781
+
782
+ const actionBlockId = block.styleSourceId || block.id;
783
+ const wrapperStyle = {
784
+ ...getCSSStyles(block.styles || {}, themeSettings, currentBreakpoint),
785
+ ...(options.style || {})
786
+ } as React.CSSProperties;
787
+
788
+ return (
789
+ <WithDesignerFeatures
790
+ key={options.keyOverride || block.id}
791
+ block={block}
792
+ placeholderId={placeholderId}
793
+ sectionId={section.id}
794
+ isDesigner={isDesigner}
795
+ isSelected={
796
+ selectedBlockId === actionBlockId || selectedBlockId === block.id
797
+ }
798
+ currentBreakpoint={currentBreakpoint}
799
+ className={options.className}
800
+ style={wrapperStyle}
801
+ onMoveUp={() => postBlockAction('MOVE_BLOCK_UP', actionBlockId)}
802
+ onMoveDown={() => postBlockAction('MOVE_BLOCK_DOWN', actionBlockId)}
803
+ onDuplicate={() => postBlockAction('DUPLICATE_BLOCK', actionBlockId)}
804
+ onToggleVisibility={() =>
805
+ postBlockAction('TOGGLE_BLOCK_VISIBILITY', actionBlockId)
806
+ }
807
+ onDelete={() => postBlockAction('DELETE_BLOCK', actionBlockId)}
808
+ onRename={(newLabel) =>
809
+ postBlockAction('RENAME_BLOCK', actionBlockId, newLabel)
810
+ }
811
+ >
812
+ {children}
813
+ </WithDesignerFeatures>
814
+ );
815
+ };
816
+
817
+ return (
818
+ <div
819
+ className={hasMaxWidth ? `mx-auto ${maxWidthClass}` : undefined}
820
+ style={sectionStyles}
821
+ >
822
+ <div className="flex flex-col gap-4">
823
+ {contentBlocks.map(renderBlock)}
824
+
825
+ <div
826
+ className={clsx(
827
+ 'grid gap-4',
828
+ !showProductCard || isMobile
829
+ ? 'grid-cols-1'
830
+ : 'grid-cols-[300px_1fr]'
831
+ )}
832
+ >
833
+ {showProductCard &&
834
+ renderSelectableWrapper(
835
+ productCardWrapperBlock,
836
+ <>
837
+ <div className="mb-4 flex items-center justify-between gap-3">
838
+ <p className="m-0 text-xs font-bold uppercase tracking-[0.16em] text-[#64748b]">
839
+ Selected Product
840
+ </p>
841
+ {productUrl && productUrl !== '#' && (
842
+ <a
843
+ href={productUrl}
844
+ className="text-xs font-semibold text-[#0f172a] underline underline-offset-4"
845
+ >
846
+ View product
847
+ </a>
848
+ )}
849
+ </div>
850
+
851
+ <div
852
+ className="rounded-[14px] border bg-white p-3"
853
+ style={{
854
+ borderColor: productCardBorder
855
+ }}
856
+ >
857
+ <div className="relative mb-3 aspect-square w-full overflow-hidden rounded-[12px] bg-[#f8fafc]">
858
+ {productImage ? (
859
+ <Image
860
+ src={productImage}
861
+ alt={productName}
862
+ fill
863
+ sizes="280px"
864
+ aspectRatio={1}
865
+ imageClassName="object-cover"
866
+ />
867
+ ) : (
868
+ <div className="flex h-full w-full items-center justify-center bg-[#e2e8f0] text-sm font-semibold tracking-wide text-[#64748b]">
869
+ Product Preview
870
+ </div>
871
+ )}
872
+ </div>
873
+
874
+ <div className="flex min-w-0 flex-col gap-2">
875
+ <p
876
+ className="m-0 overflow-hidden text-[15px] font-medium leading-[1.45] text-[#0f172a]"
877
+ style={{
878
+ display: '-webkit-box',
879
+ WebkitLineClamp: isMobile ? 3 : 2,
880
+ WebkitBoxOrient: 'vertical'
881
+ }}
882
+ >
883
+ {productName}
884
+ </p>
885
+
886
+ <div className="flex flex-wrap items-center gap-2">
887
+ {productRetailPrice && (
888
+ <span className="text-sm font-normal text-[#94a3b8] line-through">
889
+ {productRetailPrice}
890
+ </span>
891
+ )}
892
+ <span className="text-[17px] font-semibold text-[#0f172a]">
893
+ {productPrice}
894
+ </span>
895
+ </div>
896
+ </div>
897
+ </div>
898
+ </>,
899
+ {
900
+ className: 'min-w-0 border p-4',
901
+ style: {
902
+ backgroundColor: productCardBackground,
903
+ borderColor: productCardBorder,
904
+ borderRadius: panelRadius
905
+ },
906
+ keyOverride: 'installment-product-card-wrapper'
907
+ }
908
+ )}
909
+
910
+ {renderSelectableWrapper(
911
+ installmentPanelWrapperBlock,
912
+ <>
913
+ {isLoading && !usePlaceholderData ? (
914
+ <div className="flex min-h-[220px] items-center justify-center">
915
+ <LoaderSpinner />
916
+ </div>
917
+ ) : installmentCards.length > 0 ? (
918
+ <>
919
+ {showCardLogos && (
920
+ <div className="flex flex-wrap gap-2">
921
+ {installmentCards.map((card) => {
922
+ const isActive = activeCard?.slug === card.slug;
923
+ return renderSelectableWrapper(
924
+ logoCardWrapperBlock
925
+ ? {
926
+ ...logoCardWrapperBlock,
927
+ id: `${logoCardWrapperBlock.id}-clone-${card.pk}`,
928
+ styleSourceId:
929
+ logoCardWrapperBlock.styleSourceId ||
930
+ logoCardWrapperBlock.id
931
+ }
932
+ : null,
933
+ (
934
+ <button
935
+ key={card.pk}
936
+ type="button"
937
+ onClick={() => setActiveCardSlug(card.slug)}
938
+ className={clsx(
939
+ 'flex min-w-[72px] flex-1 items-center justify-center px-3 py-2 transition-colors lg:min-w-[96px] lg:flex-none',
940
+ !logoCardWrapperBlock && 'border'
941
+ )}
942
+ style={{
943
+ backgroundColor: logoCardWrapperBlock
944
+ ? 'transparent'
945
+ : isActive
946
+ ? logoCardActiveBackground
947
+ : logoCardBackground,
948
+ borderColor: logoCardWrapperBlock
949
+ ? 'transparent'
950
+ : isActive
951
+ ? logoCardActiveBorder
952
+ : logoCardBorder,
953
+ borderRadius: logoCardWrapperBlock ? '0px' : '12px'
954
+ }}
955
+ >
956
+ {card.card_type?.logo ? (
957
+ <Image
958
+ src={card.card_type.logo}
959
+ alt={card.name}
960
+ width={56}
961
+ height={20}
962
+ imageClassName={clsx(
963
+ 'h-auto max-h-5 w-auto max-w-full object-contain',
964
+ !isActive && 'grayscale'
965
+ )}
966
+ />
967
+ ) : (
968
+ <span className="text-xs font-bold uppercase tracking-[0.16em] text-[#0f172a]">
969
+ {getFallbackCardLabel(card.name)}
970
+ </span>
971
+ )}
972
+ </button>
973
+ ),
974
+ {
975
+ keyOverride: `installment-logo-card-${card.pk}`
976
+ }
977
+ );
978
+ })}
979
+ </div>
980
+ )}
981
+
982
+ {renderSelectableWrapper(
983
+ tableWrapperBlock,
984
+ <div
985
+ className={clsx(
986
+ 'overflow-hidden rounded-[12px]',
987
+ !tableWrapperBlock && 'border'
988
+ )}
989
+ style={{
990
+ borderColor: tableRowBorderColor
991
+ }}
992
+ >
993
+ <div
994
+ className="grid grid-cols-3 gap-4 border-b px-4 py-3 text-xs font-bold uppercase tracking-[0.14em]"
995
+ style={{
996
+ borderColor: tableRowBorderColor,
997
+ color: tableHeaderColor,
998
+ backgroundColor: '#f8fafc'
999
+ }}
1000
+ >
1001
+ <span>{t('product.number_of_installments')}</span>
1002
+ <span className="text-right">{t('product.monthly_amount')}</span>
1003
+ <span className="text-right">{t('product.total_amount')}</span>
1004
+ </div>
1005
+
1006
+ <div className="overflow-x-auto">
1007
+ {(activeCard?.installments || []).map((installment, index) => {
1008
+ const isHighlighted = highlightBestOption && index === 0;
1009
+ return (
1010
+ <div
1011
+ key={`${activeCard?.slug || 'card'}-${installment.installment_count}`}
1012
+ className="grid grid-cols-3 gap-4 border-b px-4 py-3 last:border-b-0"
1013
+ style={{
1014
+ borderColor: tableRowBorderColor,
1015
+ backgroundColor: isHighlighted
1016
+ ? highlightRowBackground
1017
+ : panelBackground,
1018
+ color: isHighlighted ? highlightRowText : '#0f172a'
1019
+ }}
1020
+ >
1021
+ <div className="min-w-0 text-sm font-semibold">
1022
+ {installment.installment_count === 1
1023
+ ? t('product.cash')
1024
+ : `${installment.installment_count} ${t('product.installment')}`}
1025
+ </div>
1026
+ <div className="text-right text-sm font-semibold">
1027
+ <Price value={installment.single_installment_amount} />
1028
+ </div>
1029
+ <div className="text-right text-sm font-semibold">
1030
+ <Price value={installment.total_amount} />
1031
+ </div>
1032
+ </div>
1033
+ );
1034
+ })}
1035
+ </div>
1036
+ </div>,
1037
+ {
1038
+ keyOverride: 'installment-table-wrapper'
1039
+ }
1040
+ )}
1041
+ </>
1042
+ ) : (
1043
+ <div
1044
+ className="flex min-h-[220px] items-center justify-center rounded-[12px] border border-dashed px-6 text-center text-sm font-medium text-[#64748b]"
1045
+ style={{ borderColor: panelBorder }}
1046
+ >
1047
+ {emptyStateText}
1048
+ </div>
1049
+ )}
1050
+ </>,
1051
+ {
1052
+ className: 'flex min-w-0 flex-col gap-4 border p-4 lg:p-5',
1053
+ style: {
1054
+ backgroundColor: panelBackground,
1055
+ borderColor: panelBorder,
1056
+ borderRadius: panelRadius
1057
+ },
1058
+ keyOverride: 'installment-panel-wrapper'
1059
+ }
1060
+ )}
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+ );
1065
+ }