@akinon/pz-similar-products 1.92.0-rc.20 → 1.92.0-rc.22

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.
@@ -8,18 +8,29 @@ import { SimilarProductsPlugin } from './main';
8
8
 
9
9
  interface HeaderImageSearchFeatureProps {
10
10
  className?: string;
11
+ isEnabled?: boolean;
12
+ settings?: any;
11
13
  }
12
14
 
13
15
  export function HeaderImageSearchFeature({
14
- className
16
+ className,
17
+ isEnabled: isEnabledProp,
18
+ settings
15
19
  }: HeaderImageSearchFeatureProps) {
16
- const { isEnabled, isLoading } = useImageSearchFeature();
20
+ const { isEnabled: hookIsEnabled, isLoading } = useImageSearchFeature();
21
+
22
+ const envEnabled = process.env.NEXT_PUBLIC_ENABLE_IMAGE_SEARCH === 'true';
23
+ const finalIsEnabled = envEnabled
24
+ ? true
25
+ : isEnabledProp !== undefined
26
+ ? isEnabledProp
27
+ : hookIsEnabled;
17
28
 
18
29
  const [isImageSearchModalOpen, setIsImageSearchModalOpen] = useState(false);
19
30
  const [isResultsModalOpen, setIsResultsModalOpen] = useState(false);
20
31
  const [uploadedImageFile, setUploadedImageFile] = useState<File | null>(null);
21
32
 
22
- if (isLoading || !isEnabled) {
33
+ if (isLoading || !finalIsEnabled) {
23
34
  return null;
24
35
  }
25
36
 
@@ -44,7 +55,7 @@ export function HeaderImageSearchFeature({
44
55
 
45
56
  return (
46
57
  <>
47
- {isEnabled && (
58
+ {finalIsEnabled && (
48
59
  <>
49
60
  <ImageSearchButton
50
61
  onClick={handleOpenImageSearch}
@@ -60,6 +71,7 @@ export function HeaderImageSearchFeature({
60
71
  uploadedImageFile={uploadedImageFile}
61
72
  onImageUpload={handleImageUpload}
62
73
  showResetButton={false}
74
+ settings={settings}
63
75
  />
64
76
  </>
65
77
  )}
@@ -3,6 +3,8 @@
3
3
  import React, { useEffect } from 'react';
4
4
  import { Modal, Button, Input } from '@akinon/next/components';
5
5
  import { useLocalization } from '@akinon/next/hooks';
6
+ import { twMerge } from 'tailwind-merge';
7
+ import { convertWebPToJPEG } from '../utils/image-conversion';
6
8
 
7
9
  interface ImageSearchModalProps {
8
10
  isOpen: boolean;
@@ -11,63 +13,33 @@ interface ImageSearchModalProps {
11
13
  handleImageFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
12
14
  className?: string;
13
15
  clearError?: () => void;
16
+ settings?: any;
14
17
  }
15
18
 
16
- const convertWebPToJPEG = (file: File): Promise<File> => {
17
- return new Promise((resolve, reject) => {
18
- const canvas = document.createElement('canvas');
19
- const ctx = canvas.getContext('2d');
20
- const img = new Image();
21
-
22
- img.onload = () => {
23
- canvas.width = img.naturalWidth;
24
- canvas.height = img.naturalHeight;
25
-
26
- if (!ctx) {
27
- reject(new Error('Canvas context not available'));
28
- return;
29
- }
30
-
31
- ctx.drawImage(img, 0, 0);
32
-
33
- canvas.toBlob(
34
- (blob) => {
35
- if (!blob) {
36
- reject(new Error('Failed to convert image'));
37
- return;
38
- }
39
-
40
- const convertedFile = new File(
41
- [blob],
42
- file.name.replace(/\.webp$/i, '.jpg'),
43
- {
44
- type: 'image/jpeg',
45
- lastModified: Date.now()
46
- }
47
- );
48
-
49
- resolve(convertedFile);
50
- },
51
- 'image/jpeg',
52
- 0.9
53
- );
54
- };
55
-
56
- img.onerror = () => reject(new Error('Failed to load image'));
57
- img.src = URL.createObjectURL(file);
58
- });
59
- };
60
-
61
19
  export function ImageSearchModal({
62
20
  isOpen,
63
21
  setIsOpen,
64
22
  fileInputRef,
65
23
  handleImageFileChange,
66
24
  className,
67
- clearError
25
+ clearError,
26
+ settings
68
27
  }: ImageSearchModalProps) {
69
28
  const { t } = useLocalization();
70
29
 
30
+ // Check for custom modal renderer first
31
+ if (settings?.customRenderers?.render?.imageSearchModal?.renderModal) {
32
+ return settings.customRenderers.render.imageSearchModal.renderModal({
33
+ isOpen,
34
+ setIsOpen,
35
+ fileInputRef,
36
+ handleImageFileChange,
37
+ className,
38
+ clearError,
39
+ settings
40
+ });
41
+ }
42
+
71
43
  useEffect(() => {
72
44
  if (isOpen && clearError) {
73
45
  clearError();
@@ -107,46 +79,126 @@ export function ImageSearchModal({
107
79
  };
108
80
 
109
81
  return (
110
- <Modal
111
- portalId="image-search-modal"
112
- title={t('common.search.image_search.title')}
113
- open={isOpen}
114
- setOpen={setIsOpen}
115
- className={`w-full max-w-2xl max-h-[90vh] overflow-auto ${className || ''}`}
116
- >
117
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
118
- <div className="md:col-span-1 p-4">
119
- <p className="mb-4 text-lg font-medium">
120
- {t('common.search.image_search.upload_image')}
121
- </p>
122
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
123
- <Input
124
- type="file"
125
- ref={fileInputRef}
126
- onChange={handleFileSelect}
127
- accept="image/*"
128
- className="hidden"
129
- />
130
- <Button
131
- className="w-full py-4 px-6 border-2 border-primary text-white hover:bg-primary hover:text-white transition-colors rounded-md"
132
- onClick={() => fileInputRef.current?.click()}
82
+ <>
83
+ {isOpen && (
84
+ <div
85
+ className={twMerge(
86
+ 'fixed inset-0 bg-black/50 z-[60]',
87
+ settings?.customStyles?.imageSearchModalOverlay
88
+ )}
89
+ onClick={() => setIsOpen(false)}
90
+ />
91
+ )}
92
+
93
+ <Modal
94
+ portalId="image-search-modal"
95
+ title={t('common.search.image_search.title')}
96
+ open={isOpen}
97
+ setOpen={setIsOpen}
98
+ className={`w-full max-w-2xl max-h-[90vh] overflow-auto ${
99
+ className || ''
100
+ }`}
101
+ >
102
+ <div
103
+ className={twMerge(
104
+ 'grid grid-cols-1 md:grid-cols-2 gap-4',
105
+ settings?.customStyles?.imageSearchContent
106
+ )}
107
+ >
108
+ <div
109
+ className={twMerge(
110
+ 'md:col-span-1 p-4',
111
+ settings?.customStyles?.imageSearchUploadSection
112
+ )}
113
+ >
114
+ <p
115
+ className={twMerge(
116
+ 'mb-4 text-lg font-medium',
117
+ settings?.customStyles?.imageSearchUploadTitle
118
+ )}
119
+ >
120
+ {t('common.search.image_search.upload_image')}
121
+ </p>
122
+ <div
123
+ className={twMerge(
124
+ 'border-2 border-dashed border-gray-300 rounded-lg p-8 text-center',
125
+ settings?.customStyles?.imageSearchUploadArea
126
+ )}
133
127
  >
134
- {t('common.search.image_search.select_image')}
135
- </Button>
128
+ <Input
129
+ type="file"
130
+ ref={fileInputRef}
131
+ onChange={handleFileSelect}
132
+ accept="image/*"
133
+ className="hidden"
134
+ />
135
+ <Button
136
+ className={twMerge(
137
+ 'w-full py-4 px-6 border-2 border-primary text-white hover:bg-primary hover:text-white transition-colors rounded-md',
138
+ settings?.customStyles?.imageSearchUploadButton
139
+ )}
140
+ onClick={() => fileInputRef.current?.click()}
141
+ >
142
+ {t('common.search.image_search.select_image')}
143
+ </Button>
144
+ </div>
145
+ </div>
146
+ <div
147
+ className={twMerge(
148
+ 'md:col-span-1 bg-gray-50 p-4 rounded-md h-fit my-4',
149
+ settings?.customStyles?.imageSearchTipsSection
150
+ )}
151
+ >
152
+ <p
153
+ className={twMerge(
154
+ 'mb-2 font-medium',
155
+ settings?.customStyles?.imageSearchTipsTitle
156
+ )}
157
+ >
158
+ {t('common.search.image_search.best_results')}
159
+ </p>
160
+ <ul
161
+ className={twMerge(
162
+ 'text-left list-disc pl-5 text-sm text-gray-600',
163
+ settings?.customStyles?.imageSearchTipsList
164
+ )}
165
+ >
166
+ <li
167
+ className={twMerge(
168
+ 'mb-2',
169
+ settings?.customStyles?.imageSearchTipsItem
170
+ )}
171
+ >
172
+ {t('common.search.image_search.tip_1')}
173
+ </li>
174
+ <li
175
+ className={twMerge(
176
+ 'mb-2',
177
+ settings?.customStyles?.imageSearchTipsItem
178
+ )}
179
+ >
180
+ {t('common.search.image_search.tip_2')}
181
+ </li>
182
+ <li
183
+ className={twMerge(
184
+ 'mb-2',
185
+ settings?.customStyles?.imageSearchTipsItem
186
+ )}
187
+ >
188
+ {t('common.search.image_search.tip_3')}
189
+ </li>
190
+ <li
191
+ className={twMerge(
192
+ 'mb-2',
193
+ settings?.customStyles?.imageSearchTipsItem
194
+ )}
195
+ >
196
+ {t('common.search.image_search.tip_4')}
197
+ </li>
198
+ </ul>
136
199
  </div>
137
200
  </div>
138
- <div className="md:col-span-1 bg-gray-50 p-4 rounded-md h-fit my-4">
139
- <p className="mb-2 font-medium">
140
- {t('common.search.image_search.best_results')}
141
- </p>
142
- <ul className="text-left list-disc pl-5 text-sm text-gray-600">
143
- <li className="mb-2">{t('common.search.image_search.tip_1')}</li>
144
- <li className="mb-2">{t('common.search.image_search.tip_2')}</li>
145
- <li className="mb-2">{t('common.search.image_search.tip_3')}</li>
146
- <li className="mb-2">{t('common.search.image_search.tip_4')}</li>
147
- </ul>
148
- </div>
149
- </div>
150
- </Modal>
201
+ </Modal>
202
+ </>
151
203
  );
152
204
  }
@@ -56,7 +56,8 @@ export function SimilarProductsPlugin({
56
56
  loadedPages,
57
57
  fetchSimilarProductsByImageUrl,
58
58
  fetchSimilarProductsByImageCrop,
59
- clearError
59
+ clearError,
60
+ clearResults
60
61
  } = useSimilarProducts(product);
61
62
 
62
63
  const {
@@ -108,6 +109,7 @@ export function SimilarProductsPlugin({
108
109
  if (!isOpen) {
109
110
  setHasInitialSearchDone(false);
110
111
  clearError();
112
+ clearResults();
111
113
  }
112
114
  }, [
113
115
  isOpen,
@@ -115,7 +117,8 @@ export function SimilarProductsPlugin({
115
117
  activeIndex,
116
118
  hasUploadedImage,
117
119
  hasInitialSearchDone,
118
- clearError
120
+ clearError,
121
+ clearResults
119
122
  ]);
120
123
 
121
124
  useEffect(() => {
@@ -142,6 +145,11 @@ export function SimilarProductsPlugin({
142
145
  }
143
146
  };
144
147
 
148
+ const handleModalClose = () => {
149
+ resetCrop();
150
+ onClose();
151
+ };
152
+
145
153
  const ModalComponent =
146
154
  settings.customRenderers?.Modal || SimilarProductsModal;
147
155
  const ImageSearchModalComponent =
@@ -151,7 +159,7 @@ export function SimilarProductsPlugin({
151
159
  <>
152
160
  <ModalComponent
153
161
  isOpen={isOpen}
154
- onClose={onClose}
162
+ onClose={handleModalClose}
155
163
  searchResults={searchResults}
156
164
  resultsKey={resultsKey}
157
165
  isLoading={isLoading}
@@ -194,6 +202,7 @@ export function SimilarProductsPlugin({
194
202
  handleImageFileChange={handleImageSearchFileChange}
195
203
  className={settings.customStyles?.imageSearchModal}
196
204
  clearError={clearError}
205
+ settings={settings}
197
206
  />
198
207
  </>
199
208
  );
@@ -12,17 +12,26 @@ interface ProductImageSearchFeatureProps {
12
12
  activeIndex?: number;
13
13
  settings?: any;
14
14
  className?: string;
15
+ isEnabled?: boolean;
15
16
  }
16
17
 
17
18
  export function ProductImageSearchFeature({
18
19
  product,
19
20
  activeIndex = 0,
20
21
  settings: userSettings,
21
- className = 'absolute top-6 left-6 z-[20]'
22
+ className = 'absolute top-6 left-6 z-[20]',
23
+ isEnabled: isEnabledProp
22
24
  }: ProductImageSearchFeatureProps) {
23
25
  const settings = mergeSettings(userSettings);
24
26
  const [isModalOpen, setIsModalOpen] = useState(false);
25
- const { isEnabled } = useImageSearchFeature();
27
+ const { isEnabled: hookIsEnabled } = useImageSearchFeature();
28
+
29
+ const envEnabled = process.env.NEXT_PUBLIC_ENABLE_IMAGE_SEARCH === 'true';
30
+ const finalIsEnabled = envEnabled
31
+ ? true
32
+ : isEnabledProp !== undefined
33
+ ? isEnabledProp
34
+ : hookIsEnabled;
26
35
 
27
36
  const handleClick = () => {
28
37
  setIsModalOpen(true);
@@ -30,7 +39,7 @@ export function ProductImageSearchFeature({
30
39
 
31
40
  return (
32
41
  <>
33
- {isEnabled && (
42
+ {finalIsEnabled && (
34
43
  <>
35
44
  <SimilarProductsButton onClick={handleClick} className={className} />
36
45
 
@@ -95,7 +95,12 @@ export function SimilarProductsResultsGrid({
95
95
 
96
96
  return (
97
97
  <div className={emptyStateClassName}>
98
- <div className="text-center">
98
+ <div
99
+ className={twMerge(
100
+ 'text-center',
101
+ settings?.customStyles?.emptyStateInner
102
+ )}
103
+ >
99
104
  <svg
100
105
  className={emptyStateIconClassName}
101
106
  fill="none"
@@ -229,7 +234,12 @@ export function SimilarProductsResultsGrid({
229
234
  )}
230
235
 
231
236
  {renderProductPrice() || (
232
- <div className="price-container">
237
+ <div
238
+ className={twMerge(
239
+ 'price-container',
240
+ settings?.customStyles?.productPriceContainer
241
+ )}
242
+ >
233
243
  <span className={productPriceClassName}>
234
244
  {product.price} ₺
235
245
  </span>
@@ -516,10 +526,14 @@ export function SimilarProductsResultsGrid({
516
526
  );
517
527
  };
518
528
 
519
- if (isLoading && (!searchResults || !searchResults.products)) {
529
+ if (isLoading && (!searchResults || !searchResults.products?.length)) {
520
530
  return renderLoadingState();
521
531
  }
522
532
 
533
+ if (!isLoading && searchResults && searchResults.products?.length === 0) {
534
+ return renderEmptyState();
535
+ }
536
+
523
537
  const renderLoadingOverlay = () => {
524
538
  if (settings?.customRenderers?.render?.resultsGrid?.renderLoadingOverlay) {
525
539
  return settings.customRenderers.render.resultsGrid.renderLoadingOverlay();
@@ -575,16 +589,14 @@ export function SimilarProductsResultsGrid({
575
589
  <div className={gridClassName}>
576
590
  {isLoading &&
577
591
  searchResults &&
578
- searchResults.products &&
592
+ searchResults.products?.length > 0 &&
579
593
  renderLoadingOverlay()}
580
594
 
581
- {searchResults && searchResults.products?.length > 0 ? (
595
+ {searchResults && searchResults.products?.length > 0 && (
582
596
  <div className={resultsContainerClassName}>
583
597
  {renderGridContainer()}
584
598
  {renderPagination()}
585
599
  </div>
586
- ) : (
587
- renderEmptyState()
588
600
  )}
589
601
  </div>
590
602
  );
@@ -196,7 +196,12 @@ export function SimilarProductsModal({
196
196
 
197
197
  return (
198
198
  <div className={containerClassName}>
199
- <div className="flex flex-wrap gap-2">
199
+ <div
200
+ className={twMerge(
201
+ 'flex flex-wrap gap-2',
202
+ settings?.customStyles?.activeFiltersWrapper
203
+ )}
204
+ >
200
205
  {activeFilters.map((filter) => {
201
206
  if (
202
207
  settings?.customRenderers?.render?.modal?.renderActiveFilterTag
@@ -321,6 +326,10 @@ export function SimilarProductsModal({
321
326
  };
322
327
 
323
328
  const renderSortDropdown = () => {
329
+ if (!searchResults?.products?.length || !searchResults?.sorters?.length) {
330
+ return null;
331
+ }
332
+
324
333
  if (settings?.customRenderers?.render?.modal?.renderSortDropdown) {
325
334
  return settings.customRenderers.render.modal.renderSortDropdown({
326
335
  sorters: searchResults?.sorters || [],
@@ -348,12 +357,29 @@ export function SimilarProductsModal({
348
357
 
349
358
  return (
350
359
  <div className={containerClassName}>
351
- <div className="flex items-center justify-between p-3 md:p-4">
352
- <div className="flex items-center gap-3">
360
+ <div
361
+ className={twMerge(
362
+ 'flex items-center justify-between p-3 md:p-4',
363
+ settings?.customStyles?.controlsInner
364
+ )}
365
+ >
366
+ <div
367
+ className={twMerge(
368
+ 'flex items-center gap-3',
369
+ settings?.customStyles?.controlsLeft
370
+ )}
371
+ >
353
372
  {renderItemCount()}
354
373
  {renderFilterToggle()}
355
374
  </div>
356
- <div className="relative">{renderSortDropdown()}</div>
375
+ <div
376
+ className={twMerge(
377
+ 'relative',
378
+ settings?.customStyles?.controlsRight
379
+ )}
380
+ >
381
+ {renderSortDropdown()}
382
+ </div>
357
383
  </div>
358
384
  </div>
359
385
  );
@@ -371,8 +397,12 @@ export function SimilarProductsModal({
371
397
  {renderActiveFilters()}
372
398
  {renderControls()}
373
399
 
374
- {/* Main Content */}
375
- <div className="flex flex-col md:grid md:grid-cols-5 gap-0 flex-1 overflow-hidden">
400
+ <div
401
+ className={twMerge(
402
+ 'flex flex-col md:grid md:grid-cols-5 gap-0 flex-1 overflow-hidden',
403
+ settings?.customStyles?.modalContent
404
+ )}
405
+ >
376
406
  <FilterSidebarComponent
377
407
  isFilterMenuOpen={isFilterMenuOpen}
378
408
  setIsFilterMenuOpen={setIsFilterMenuOpen}
@@ -403,7 +433,12 @@ export function SimilarProductsModal({
403
433
  className={settings?.customStyles?.filterSidebar}
404
434
  />
405
435
 
406
- <div className="flex-1 md:col-span-4 p-3 md:p-4 overflow-y-auto">
436
+ <div
437
+ className={twMerge(
438
+ 'flex-1 md:col-span-4 p-3 md:p-4 overflow-y-auto',
439
+ settings?.customStyles?.resultsContainer
440
+ )}
441
+ >
407
442
  <ResultsGridComponent
408
443
  searchResults={searchResults}
409
444
  resultsKey={resultsKey}