@akinon/pz-similar-products 1.92.0-rc.16

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.
@@ -0,0 +1,422 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Modal, Button, Icon, Select } from '@akinon/next/components';
5
+ import { useLocalization } from '@akinon/next/hooks';
6
+ import { SimilarProductsFilterSidebar } from './filters';
7
+ import { SimilarProductsResultsGrid } from './results';
8
+ import { SimilarProductsModalProps } from '../types';
9
+ import { twMerge } from 'tailwind-merge';
10
+
11
+ export function SimilarProductsModal({
12
+ isOpen,
13
+ onClose,
14
+ searchResults,
15
+ resultsKey,
16
+ isLoading,
17
+ isFilterMenuOpen,
18
+ setIsFilterMenuOpen,
19
+ handleSortChange,
20
+ handlePageChange,
21
+ handleFacetChange,
22
+ removeFacetFilter,
23
+ handleLoadMore,
24
+ loadedPages,
25
+ currentImageUrl,
26
+ isCropping,
27
+ imageRef,
28
+ fileInputRef,
29
+ crop,
30
+ setCrop,
31
+ completedCrop,
32
+ setCompletedCrop,
33
+ handleCropComplete,
34
+ toggleCropMode,
35
+ processCompletedCrop,
36
+ processManualCrop,
37
+ resetCrop,
38
+ product,
39
+ activeIndex,
40
+ hasUploadedImage,
41
+ handleFileUpload,
42
+ handleResetToOriginal,
43
+ fileError,
44
+ showResetButton = true,
45
+ settings,
46
+ className
47
+ }: SimilarProductsModalProps) {
48
+ const { t } = useLocalization();
49
+
50
+ const cssVariables = React.useMemo(() => {
51
+ if (!settings?.cssVariables && !settings?.theme) return {};
52
+
53
+ const variables: Record<string, string> = {};
54
+
55
+ if (settings?.cssVariables) {
56
+ Object.entries(settings.cssVariables).forEach(([key, value]) => {
57
+ if (typeof value === 'string') {
58
+ variables[`--${key}`] = value;
59
+ }
60
+ });
61
+ }
62
+
63
+ if (settings?.theme) {
64
+ const { colors, spacing, borderRadius, fonts } = settings.theme;
65
+
66
+ if (colors) {
67
+ Object.entries(colors).forEach(([key, value]) => {
68
+ if (value && typeof value === 'string')
69
+ variables[`--color-${key}`] = value;
70
+ });
71
+ }
72
+
73
+ if (spacing) {
74
+ Object.entries(spacing).forEach(([key, value]) => {
75
+ if (value && typeof value === 'string')
76
+ variables[`--spacing-${key}`] = value;
77
+ });
78
+ }
79
+
80
+ if (borderRadius) {
81
+ Object.entries(borderRadius).forEach(([key, value]) => {
82
+ if (value && typeof value === 'string')
83
+ variables[`--border-radius-${key}`] = value;
84
+ });
85
+ }
86
+
87
+ if (fonts) {
88
+ Object.entries(fonts).forEach(([key, value]) => {
89
+ if (value && typeof value === 'string')
90
+ variables[`--font-${key}`] = value;
91
+ });
92
+ }
93
+ }
94
+
95
+ return variables;
96
+ }, [settings?.cssVariables, settings?.theme]);
97
+
98
+ const modalClassName = twMerge(
99
+ 'w-full max-w-6xl h-[95vh] flex flex-col',
100
+ settings?.customStyles?.modal,
101
+ className
102
+ );
103
+
104
+ const FilterSidebarComponent =
105
+ settings?.customRenderers?.FilterSidebar || SimilarProductsFilterSidebar;
106
+ const ResultsGridComponent =
107
+ settings?.customRenderers?.ResultsGrid || SimilarProductsResultsGrid;
108
+
109
+ if (settings?.customRenderers?.render?.modal?.renderModal) {
110
+ return (
111
+ <div style={cssVariables}>
112
+ {settings.customRenderers.render.modal.renderModal({
113
+ isOpen,
114
+ onClose,
115
+ searchResults,
116
+ resultsKey,
117
+ isLoading,
118
+ isFilterMenuOpen,
119
+ setIsFilterMenuOpen,
120
+ handleSortChange,
121
+ handlePageChange,
122
+ handleFacetChange,
123
+ removeFacetFilter,
124
+ handleLoadMore,
125
+ loadedPages,
126
+ currentImageUrl,
127
+ isCropping,
128
+ imageRef,
129
+ fileInputRef,
130
+ crop,
131
+ setCrop,
132
+ completedCrop,
133
+ setCompletedCrop,
134
+ handleCropComplete,
135
+ toggleCropMode,
136
+ processCompletedCrop,
137
+ processManualCrop,
138
+ resetCrop,
139
+ product,
140
+ activeIndex,
141
+ hasUploadedImage,
142
+ handleFileUpload,
143
+ handleResetToOriginal,
144
+ fileError,
145
+ showResetButton,
146
+ settings,
147
+ className: modalClassName
148
+ })}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ const activeFilters =
154
+ searchResults?.facets
155
+ ?.filter((facet) => facet.key !== 'category_ids')
156
+ ?.flatMap(
157
+ (facet) =>
158
+ facet.data.choices
159
+ ?.filter((choice) => choice.is_selected)
160
+ .map((choice) => ({
161
+ key: facet.key,
162
+ value: choice.value.toString(),
163
+ label: choice.label
164
+ })) || []
165
+ ) || [];
166
+
167
+ const renderHeader = () => {
168
+ if (settings?.customRenderers?.render?.modal?.renderHeader) {
169
+ return settings.customRenderers.render.modal.renderHeader({
170
+ title: t('common.product.similar_styles'),
171
+ onClose
172
+ });
173
+ }
174
+ return null;
175
+ };
176
+
177
+ const renderActiveFilters = () => {
178
+ if (activeFilters.length === 0) return null;
179
+
180
+ if (settings?.customRenderers?.render?.modal?.renderActiveFilters) {
181
+ return settings.customRenderers.render.modal.renderActiveFilters({
182
+ filters: activeFilters,
183
+ onRemove: (key: string, value: string) => {
184
+ if (!isLoading) {
185
+ handleFacetChange(key, value);
186
+ }
187
+ },
188
+ isLoading
189
+ });
190
+ }
191
+
192
+ const containerClassName = twMerge(
193
+ 'border-b border-gray-200 p-3 md:p-4',
194
+ settings?.customStyles?.activeFiltersContainer
195
+ );
196
+
197
+ return (
198
+ <div className={containerClassName}>
199
+ <div className="flex flex-wrap gap-2">
200
+ {activeFilters.map((filter) => {
201
+ if (
202
+ settings?.customRenderers?.render?.modal?.renderActiveFilterTag
203
+ ) {
204
+ return settings.customRenderers.render.modal.renderActiveFilterTag(
205
+ {
206
+ filter,
207
+ onRemove: () => {
208
+ if (!isLoading) {
209
+ handleFacetChange(filter.key, filter.value);
210
+ }
211
+ },
212
+ isLoading
213
+ }
214
+ );
215
+ }
216
+
217
+ const tagClassName = twMerge(
218
+ 'flex items-center gap-1 px-2 py-1 md:px-3 bg-gray-100 rounded-full text-xs md:text-sm',
219
+ settings?.customStyles?.activeFilterTag
220
+ );
221
+
222
+ const buttonClassName = twMerge(
223
+ 'hover:bg-gray-200 rounded-full p-0 w-6 h-6 disabled:opacity-50 disabled:cursor-not-allowed ml-1',
224
+ settings?.customStyles?.activeFilterTagButton
225
+ );
226
+
227
+ return (
228
+ <div
229
+ key={`${filter.key}-${filter.value}`}
230
+ className={tagClassName}
231
+ >
232
+ <span>{filter.label}</span>
233
+ <Button
234
+ appearance="ghost"
235
+ size="sm"
236
+ onClick={() => {
237
+ if (!isLoading) {
238
+ handleFacetChange(filter.key, filter.value);
239
+ }
240
+ }}
241
+ disabled={isLoading}
242
+ className={buttonClassName}
243
+ >
244
+ <Icon name="close" size={12} />
245
+ </Button>
246
+ </div>
247
+ );
248
+ })}
249
+ </div>
250
+ </div>
251
+ );
252
+ };
253
+
254
+ const renderControls = () => {
255
+ if (settings?.customRenderers?.render?.modal?.renderControls) {
256
+ return settings.customRenderers.render.modal.renderControls({
257
+ totalCount: searchResults?.pagination?.total_count || 0,
258
+ sorters: searchResults?.sorters || [],
259
+ selectedSorter:
260
+ searchResults?.sorters?.find((sorter) => sorter.is_selected)?.value ||
261
+ '',
262
+ onSortChange: handleSortChange,
263
+ onFilterMenuToggle: () => setIsFilterMenuOpen(true),
264
+ isLoading
265
+ });
266
+ }
267
+
268
+ const containerClassName = twMerge(
269
+ 'border-b border-gray-200',
270
+ settings?.customStyles?.controlsContainer
271
+ );
272
+
273
+ const itemCountClassName = twMerge(
274
+ 'text-xs md:text-sm text-gray-500',
275
+ settings?.customStyles?.itemCount
276
+ );
277
+
278
+ const filterToggleClassName = twMerge(
279
+ 'md:hidden text-xs',
280
+ settings?.customStyles?.filterToggleButton
281
+ );
282
+
283
+ const sortDropdownClassName = twMerge(
284
+ 'h-10 px-4 text-md md:text-xs bg-gray-200 hover:bg-gray-400 transition-colors duration-200 border-gray-300 focus:border-primary focus:ring-1 focus:ring-primary w-full md:w-40 min-w-[120px]',
285
+ settings?.customStyles?.sortDropdown
286
+ );
287
+
288
+ const renderItemCount = () => {
289
+ if (settings?.customRenderers?.render?.modal?.renderItemCount) {
290
+ return settings.customRenderers.render.modal.renderItemCount({
291
+ count: searchResults?.pagination?.total_count || 0,
292
+ isLoading
293
+ });
294
+ }
295
+ return (
296
+ <div className={itemCountClassName}>
297
+ {searchResults?.pagination?.total_count || 0} items
298
+ </div>
299
+ );
300
+ };
301
+
302
+ const renderFilterToggle = () => {
303
+ if (settings?.customRenderers?.render?.modal?.renderFilterToggleButton) {
304
+ return settings.customRenderers.render.modal.renderFilterToggleButton({
305
+ onClick: () => setIsFilterMenuOpen(true),
306
+ isLoading
307
+ });
308
+ }
309
+ return (
310
+ <Button
311
+ appearance="outlined"
312
+ size="sm"
313
+ className={filterToggleClassName}
314
+ onClick={() => setIsFilterMenuOpen(true)}
315
+ data-testid="similar-products-filter"
316
+ >
317
+ <Icon name="filter" size={14} />
318
+ {t('common.product.filters')}
319
+ </Button>
320
+ );
321
+ };
322
+
323
+ const renderSortDropdown = () => {
324
+ if (settings?.customRenderers?.render?.modal?.renderSortDropdown) {
325
+ return settings.customRenderers.render.modal.renderSortDropdown({
326
+ sorters: searchResults?.sorters || [],
327
+ selectedValue:
328
+ searchResults?.sorters?.find((sorter) => sorter.is_selected)
329
+ ?.value || '',
330
+ onChange: handleSortChange,
331
+ disabled: isLoading
332
+ });
333
+ }
334
+ return (
335
+ <Select
336
+ options={searchResults?.sorters || []}
337
+ value={
338
+ searchResults?.sorters?.find((sorter) => sorter.is_selected)
339
+ ?.value || ''
340
+ }
341
+ onChange={(e) => handleSortChange(e.currentTarget.value)}
342
+ disabled={isLoading}
343
+ borderless={false}
344
+ className={sortDropdownClassName}
345
+ />
346
+ );
347
+ };
348
+
349
+ return (
350
+ <div className={containerClassName}>
351
+ <div className="flex items-center justify-between p-3 md:p-4">
352
+ <div className="flex items-center gap-3">
353
+ {renderItemCount()}
354
+ {renderFilterToggle()}
355
+ </div>
356
+ <div className="relative">{renderSortDropdown()}</div>
357
+ </div>
358
+ </div>
359
+ );
360
+ };
361
+
362
+ return (
363
+ <div style={cssVariables}>
364
+ <Modal
365
+ portalId="similar-styles-modal"
366
+ title={renderHeader() || t('common.product.similar_styles')}
367
+ open={isOpen}
368
+ setOpen={onClose}
369
+ className={modalClassName}
370
+ >
371
+ {renderActiveFilters()}
372
+ {renderControls()}
373
+
374
+ {/* Main Content */}
375
+ <div className="flex flex-col md:grid md:grid-cols-5 gap-0 flex-1 overflow-hidden">
376
+ <FilterSidebarComponent
377
+ isFilterMenuOpen={isFilterMenuOpen}
378
+ setIsFilterMenuOpen={setIsFilterMenuOpen}
379
+ searchResults={searchResults}
380
+ isLoading={isLoading}
381
+ handleFacetChange={handleFacetChange}
382
+ removeFacetFilter={removeFacetFilter}
383
+ currentImageUrl={currentImageUrl}
384
+ isCropping={isCropping}
385
+ imageRef={imageRef}
386
+ fileInputRef={fileInputRef}
387
+ crop={crop}
388
+ setCrop={setCrop}
389
+ completedCrop={completedCrop}
390
+ setCompletedCrop={setCompletedCrop}
391
+ handleCropComplete={handleCropComplete}
392
+ toggleCropMode={toggleCropMode}
393
+ processCompletedCrop={processCompletedCrop}
394
+ processManualCrop={processManualCrop}
395
+ resetCrop={resetCrop}
396
+ product={product}
397
+ hasUploadedImage={hasUploadedImage}
398
+ handleFileUpload={handleFileUpload}
399
+ handleResetToOriginal={handleResetToOriginal}
400
+ fileError={fileError}
401
+ showResetButton={showResetButton}
402
+ settings={settings}
403
+ className={settings?.customStyles?.filterSidebar}
404
+ />
405
+
406
+ <div className="flex-1 md:col-span-4 p-3 md:p-4 overflow-y-auto">
407
+ <ResultsGridComponent
408
+ searchResults={searchResults}
409
+ resultsKey={resultsKey}
410
+ isLoading={isLoading}
411
+ handlePageChange={handlePageChange}
412
+ handleLoadMore={handleLoadMore}
413
+ loadedPages={loadedPages}
414
+ settings={settings}
415
+ className={settings?.customStyles?.resultsGrid}
416
+ />
417
+ </div>
418
+ </div>
419
+ </Modal>
420
+ </div>
421
+ );
422
+ }