@akinon/pz-similar-products 1.92.0-snapshot-ZERO-3457-20250627121541 → 1.93.0-rc.46

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.
@@ -5,7 +5,8 @@ import {
5
5
  Button,
6
6
  Icon,
7
7
  Accordion,
8
- LoaderSpinner
8
+ LoaderSpinner,
9
+ Input
9
10
  } from '@akinon/next/components';
10
11
  import { useLocalization } from '@akinon/next/hooks';
11
12
  import { FilterSidebarProps } from '../types';
@@ -55,6 +56,8 @@ export function SimilarProductsFilterSidebar({
55
56
  isLoading,
56
57
  handleFacetChange,
57
58
  removeFacetFilter,
59
+ searchText,
60
+ setSearchText,
58
61
  currentImageUrl,
59
62
  isCropping,
60
63
  imageRef,
@@ -166,6 +169,19 @@ export function SimilarProductsFilterSidebar({
166
169
  className
167
170
  );
168
171
 
172
+ const modalCloseIconClassName = twMerge(
173
+ '',
174
+ settings?.customStyles?.modalCloseIcon
175
+ );
176
+
177
+ const filterRemoveIconClassName = twMerge(
178
+ '',
179
+ settings?.customStyles?.filterRemoveIcon
180
+ );
181
+
182
+ const modalCloseIconName = settings?.iconNames?.modalClose || 'close';
183
+ const filterRemoveIconName = settings?.iconNames?.filterRemove || 'close';
184
+
169
185
  return (
170
186
  <>
171
187
  {isFilterMenuOpen && (
@@ -222,7 +238,11 @@ export function SimilarProductsFilterSidebar({
222
238
  settings?.customStyles?.filterSidebarMobileCloseButton
223
239
  )}
224
240
  >
225
- <Icon name="close" size={16} />
241
+ <Icon
242
+ name={modalCloseIconName}
243
+ size={16}
244
+ className={modalCloseIconClassName}
245
+ />
226
246
  </Button>
227
247
  </div>
228
248
  <div
@@ -445,7 +465,10 @@ export function SimilarProductsFilterSidebar({
445
465
  onDragEnd={() => {}}
446
466
  ruleOfThirds={false}
447
467
  aspect={settings?.cropAspectRatio}
448
- className="slider-crop"
468
+ className={twMerge(
469
+ 'slider-crop',
470
+ settings?.customStyles?.cropComponent
471
+ )}
449
472
  disabled={isLoading}
450
473
  keepSelection={true}
451
474
  >
@@ -453,22 +476,45 @@ export function SimilarProductsFilterSidebar({
453
476
  ref={imageRef}
454
477
  src={currentImageUrl || ''}
455
478
  alt={product?.name || 'Product image'}
456
- className="max-w-full max-h-[200px] md:max-h-[280px]"
479
+ className={twMerge(
480
+ 'max-w-full max-h-[200px] md:max-h-[280px]',
481
+ settings?.customStyles?.cropImage,
482
+ settings?.customStyles?.cropImageActive
483
+ )}
457
484
  style={{ transform: `scale(1) rotate(0deg)` }}
458
485
  />
459
486
  </ReactCrop>
460
487
  ) : (
461
- <div className="relative w-full h-full flex items-center justify-center">
488
+ <div
489
+ className={twMerge(
490
+ 'relative w-full h-full flex items-center justify-center',
491
+ settings?.customStyles?.cropImageContainer
492
+ )}
493
+ >
462
494
  <img
463
495
  ref={imageRef}
464
496
  src={currentImageUrl || ''}
465
497
  alt={product?.name || 'Product image'}
466
- className="max-w-full max-h-[200px] md:max-h-[280px] object-contain"
498
+ className={twMerge(
499
+ 'max-w-full max-h-[200px] md:max-h-[280px] object-contain',
500
+ settings?.customStyles?.cropImage,
501
+ settings?.customStyles?.cropImageNonCropping
502
+ )}
467
503
  />
468
504
  {!isCropping && completedCrop && (
469
- <div className="hidden md:block absolute inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-in-out">
505
+ <div
506
+ className={twMerge(
507
+ 'hidden md:block absolute inset-0 bg-black bg-opacity-50 transition-opacity duration-300 ease-in-out',
508
+ settings?.customStyles?.cropOverlay,
509
+ settings?.customStyles?.cropOverlayBackground
510
+ )}
511
+ >
470
512
  <div
471
- className="absolute transition-all duration-300 ease-in-out"
513
+ className={twMerge(
514
+ 'absolute transition-all duration-300 ease-in-out',
515
+ settings?.customStyles?.cropSelection,
516
+ settings?.customStyles?.cropSelectionHighlight
517
+ )}
472
518
  style={{
473
519
  width: `${completedCrop.width}px`,
474
520
  height: `${completedCrop.height}px`,
@@ -902,7 +948,11 @@ export function SimilarProductsFilterSidebar({
902
948
  disabled={isLoading}
903
949
  className="hover:bg-gray-200 rounded-full p-0 w-5 h-5 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
904
950
  >
905
- <Icon name="close" size={10} />
951
+ <Icon
952
+ name={filterRemoveIconName}
953
+ size={10}
954
+ className={filterRemoveIconClassName}
955
+ />
906
956
  </Button>
907
957
  </div>
908
958
  ))}
@@ -3,6 +3,7 @@
3
3
  import React, { useState, useRef } from 'react';
4
4
  import { Product } from '@akinon/next/types';
5
5
  import { useImageSearchFeature } from '../hooks/use-image-search-feature';
6
+ import { useTextSearchFeature } from '../hooks/use-text-search-feature';
6
7
  import { ImageSearchButton } from './image-search-button';
7
8
  import { SimilarProductsPlugin } from './main';
8
9
 
@@ -10,14 +11,19 @@ interface HeaderImageSearchFeatureProps {
10
11
  className?: string;
11
12
  isEnabled?: boolean;
12
13
  settings?: any;
14
+ enableTextSearch?: boolean;
13
15
  }
14
16
 
15
17
  export function HeaderImageSearchFeature({
16
18
  className,
17
19
  isEnabled: isEnabledProp,
18
- settings
20
+ settings: userSettings,
21
+ enableTextSearch = false
19
22
  }: HeaderImageSearchFeatureProps) {
20
23
  const { isEnabled: hookIsEnabled, isLoading } = useImageSearchFeature();
24
+ const { isEnabled: textSearchEnabled } = useTextSearchFeature({
25
+ enableTextSearch
26
+ });
21
27
 
22
28
  const envEnabled = process.env.NEXT_PUBLIC_ENABLE_IMAGE_SEARCH === 'true';
23
29
  const finalIsEnabled = envEnabled
@@ -30,6 +36,11 @@ export function HeaderImageSearchFeature({
30
36
  const [isResultsModalOpen, setIsResultsModalOpen] = useState(false);
31
37
  const [uploadedImageFile, setUploadedImageFile] = useState<File | null>(null);
32
38
 
39
+ const settings = {
40
+ ...userSettings,
41
+ enableTextSearch: textSearchEnabled
42
+ };
43
+
33
44
  if (isLoading || !finalIsEnabled) {
34
45
  return null;
35
46
  }
@@ -60,6 +71,7 @@ export function HeaderImageSearchFeature({
60
71
  <ImageSearchButton
61
72
  onClick={handleOpenImageSearch}
62
73
  className={className}
74
+ settings={settings}
63
75
  />
64
76
  <SimilarProductsPlugin
65
77
  product={{} as Product}
@@ -1,22 +1,32 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import { Button } from '@akinon/next/components';
4
+ import { Button, Icon } from '@akinon/next/components';
5
5
  import { useLocalization } from '@akinon/next/hooks';
6
6
  import { useImageSearchFeature } from '../hooks/use-image-search-feature';
7
+ import { twMerge } from 'tailwind-merge';
7
8
 
8
9
  interface ImageSearchButtonProps {
9
10
  onClick: () => void;
10
11
  className?: string;
12
+ settings?: any;
11
13
  }
12
14
 
13
15
  export function ImageSearchButton({
14
16
  onClick,
15
- className
17
+ className,
18
+ settings
16
19
  }: ImageSearchButtonProps) {
17
20
  const { t } = useLocalization();
18
21
  const { isEnabled, isLoading } = useImageSearchFeature();
19
22
 
23
+ const imageSearchButtonIconName =
24
+ settings?.iconNames?.imageSearchButton || 'search';
25
+ const imageSearchButtonIconClassName = twMerge(
26
+ 'text-black',
27
+ settings?.customStyles?.imageSearchButtonIcon
28
+ );
29
+
20
30
  if (isLoading || !isEnabled) {
21
31
  return null;
22
32
  }
@@ -24,24 +34,16 @@ export function ImageSearchButton({
24
34
  return (
25
35
  <Button
26
36
  onClick={onClick}
27
- className={`flex items-center justify-center mr-2 text-gray-500 focus:outline-none border-none bg-transparent ${className || ''}`}
37
+ className={`flex items-center justify-center mr-2 text-gray-500 focus:outline-none border-none bg-transparent ${
38
+ className || ''
39
+ }`}
28
40
  title={t('common.search.image_search.title')}
29
41
  >
30
- <svg
31
- xmlns="http://www.w3.org/2000/svg"
32
- width="20"
33
- height="20"
34
- viewBox="0 0 24 24"
35
- fill="none"
36
- stroke="currentColor"
37
- strokeWidth="2"
38
- strokeLinecap="round"
39
- strokeLinejoin="round"
40
- className="text-gray-500"
41
- >
42
- <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
43
- <circle cx="12" cy="13" r="4"></circle>
44
- </svg>
42
+ <Icon
43
+ name={imageSearchButtonIconName}
44
+ size={20}
45
+ className={imageSearchButtonIconClassName}
46
+ />
45
47
  </Button>
46
48
  );
47
49
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useRef, useEffect } from 'react';
3
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
4
4
  import { Product } from '@akinon/next/types';
5
5
  import { useImageCropper } from '../hooks/use-image-cropper';
6
6
  import { SimilarProductsModal } from './search-modal';
@@ -37,12 +37,39 @@ export function SimilarProductsPlugin({
37
37
  const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
38
38
 
39
39
  const fileInputRef = useRef<HTMLInputElement>(null);
40
+ const hasUploadedImageRef = useRef<boolean>(false);
41
+ const [isImageUploadedViaFileInput, setIsImageUploadedViaFileInput] = useState(false);
42
+
43
+ const UPLOAD_FLAG_KEY = `similar-products-upload-${product.pk}`;
44
+
45
+ const setUploadFlag = (value: boolean) => {
46
+ try {
47
+ if (value) {
48
+ sessionStorage.setItem(UPLOAD_FLAG_KEY, 'true');
49
+ } else {
50
+ sessionStorage.removeItem(UPLOAD_FLAG_KEY);
51
+ }
52
+ } catch (error) {
53
+ console.error('Session storage error:', error);
54
+ }
55
+ };
56
+
57
+ const getUploadFlag = () => {
58
+ try {
59
+ return sessionStorage.getItem(UPLOAD_FLAG_KEY) === 'true';
60
+ } catch (error) {
61
+ console.error('Session storage error:', error);
62
+ return false;
63
+ }
64
+ };
40
65
 
41
66
  const {
42
67
  currentImageUrl,
43
68
  setCurrentImageUrl,
44
69
  isLoading,
45
70
  fileError,
71
+ searchText,
72
+ setSearchText,
46
73
  searchResults,
47
74
  resultsKey,
48
75
  hasUploadedImage,
@@ -56,9 +83,48 @@ export function SimilarProductsPlugin({
56
83
  loadedPages,
57
84
  fetchSimilarProductsByImageUrl,
58
85
  fetchSimilarProductsByImageCrop,
59
- clearError
86
+ clearError,
87
+ clearResults,
88
+ handleTextSearch,
89
+ handleClearText,
90
+ resetCropState
60
91
  } = useSimilarProducts(product);
61
92
 
93
+ useEffect(() => {
94
+ if (hasUploadedImage) {
95
+ setIsImageUploadedViaFileInput(true);
96
+ setUploadFlag(true);
97
+ } else {
98
+ setIsImageUploadedViaFileInput(false);
99
+ setUploadFlag(false);
100
+ }
101
+ }, [hasUploadedImage]);
102
+
103
+ const cropProcessImageFunction = useCallback(
104
+ (base64Image: string) => {
105
+ let shouldExclude = true;
106
+
107
+ if (base64Image.startsWith('data:application/x-cors-fallback;base64,')) {
108
+ try {
109
+ const fallbackDataEncoded = base64Image.replace('data:application/x-cors-fallback;base64,', '');
110
+ const fallbackData = JSON.parse(atob(fallbackDataEncoded));
111
+
112
+ if (fallbackData.originalUrl && fallbackData.originalUrl.startsWith('data:')) {
113
+ shouldExclude = false;
114
+ }
115
+ } catch (error) {
116
+ console.error('CORS fallback parse error:', error);
117
+ }
118
+ } else {
119
+ const isUploadedFromStorage = getUploadFlag();
120
+ shouldExclude = !isUploadedFromStorage;
121
+ }
122
+
123
+ fetchSimilarProductsByImageCrop(base64Image, shouldExclude);
124
+ },
125
+ [fetchSimilarProductsByImageCrop, hasUploadedImage, currentImageUrl, uploadedImageFile, isImageUploadedViaFileInput]
126
+ );
127
+
62
128
  const {
63
129
  isCropping,
64
130
  crop,
@@ -73,7 +139,7 @@ export function SimilarProductsPlugin({
73
139
  resetCrop
74
140
  } = useImageCropper(
75
141
  (loading) => {},
76
- fetchSimilarProductsByImageCrop,
142
+ cropProcessImageFunction,
77
143
  clearError
78
144
  );
79
145
 
@@ -84,8 +150,12 @@ export function SimilarProductsPlugin({
84
150
  product.productimage_set[0].image;
85
151
  setCurrentImageUrl(originalImageUrl);
86
152
  setHasUploadedImage(false);
153
+ hasUploadedImageRef.current = false;
154
+ setUploadFlag(false);
155
+ setSearchText('');
87
156
  resetCrop();
88
- fetchSimilarProductsByImageUrl(originalImageUrl);
157
+ resetCropState();
158
+ fetchSimilarProductsByImageUrl(originalImageUrl, '');
89
159
  }
90
160
  };
91
161
 
@@ -107,7 +177,10 @@ export function SimilarProductsPlugin({
107
177
 
108
178
  if (!isOpen) {
109
179
  setHasInitialSearchDone(false);
180
+ hasUploadedImageRef.current = false;
181
+ setUploadFlag(false);
110
182
  clearError();
183
+ clearResults();
111
184
  }
112
185
  }, [
113
186
  isOpen,
@@ -115,7 +188,8 @@ export function SimilarProductsPlugin({
115
188
  activeIndex,
116
189
  hasUploadedImage,
117
190
  hasInitialSearchDone,
118
- clearError
191
+ clearError,
192
+ clearResults
119
193
  ]);
120
194
 
121
195
  useEffect(() => {
@@ -126,7 +200,9 @@ export function SimilarProductsPlugin({
126
200
  if (result) {
127
201
  setCurrentImageUrl(result);
128
202
  setHasUploadedImage(true);
129
- fetchSimilarProductsByImageCrop(result);
203
+ hasUploadedImageRef.current = true;
204
+ setUploadFlag(true);
205
+ fetchSimilarProductsByImageCrop(result, false);
130
206
  }
131
207
  };
132
208
  reader.readAsDataURL(uploadedImageFile);
@@ -142,6 +218,11 @@ export function SimilarProductsPlugin({
142
218
  }
143
219
  };
144
220
 
221
+ const handleModalClose = () => {
222
+ resetCrop();
223
+ onClose();
224
+ };
225
+
145
226
  const ModalComponent =
146
227
  settings.customRenderers?.Modal || SimilarProductsModal;
147
228
  const ImageSearchModalComponent =
@@ -151,7 +232,7 @@ export function SimilarProductsPlugin({
151
232
  <>
152
233
  <ModalComponent
153
234
  isOpen={isOpen}
154
- onClose={onClose}
235
+ onClose={handleModalClose}
155
236
  searchResults={searchResults}
156
237
  resultsKey={resultsKey}
157
238
  isLoading={isLoading}
@@ -185,6 +266,10 @@ export function SimilarProductsPlugin({
185
266
  settings={settings}
186
267
  className={settings.customStyles?.modal}
187
268
  showResetButton={showResetButton}
269
+ searchText={searchText}
270
+ setSearchText={setSearchText}
271
+ handleTextSearch={handleTextSearch}
272
+ handleClearText={handleClearText}
188
273
  />
189
274
 
190
275
  <ImageSearchModalComponent
@@ -6,6 +6,7 @@ import { SimilarProductsButton } from './search-button';
6
6
  import { SimilarProductsPlugin } from './main';
7
7
  import { mergeSettings } from '../utils';
8
8
  import { useImageSearchFeature } from '../hooks/use-image-search-feature';
9
+ import { useTextSearchFeature } from '../hooks/use-text-search-feature';
9
10
 
10
11
  interface ProductImageSearchFeatureProps {
11
12
  product: Product;
@@ -13,6 +14,7 @@ interface ProductImageSearchFeatureProps {
13
14
  settings?: any;
14
15
  className?: string;
15
16
  isEnabled?: boolean;
17
+ enableTextSearch?: boolean;
16
18
  }
17
19
 
18
20
  export function ProductImageSearchFeature({
@@ -20,11 +22,19 @@ export function ProductImageSearchFeature({
20
22
  activeIndex = 0,
21
23
  settings: userSettings,
22
24
  className = 'absolute top-6 left-6 z-[20]',
23
- isEnabled: isEnabledProp
25
+ isEnabled: isEnabledProp,
26
+ enableTextSearch = false
24
27
  }: ProductImageSearchFeatureProps) {
25
- const settings = mergeSettings(userSettings);
26
28
  const [isModalOpen, setIsModalOpen] = useState(false);
27
29
  const { isEnabled: hookIsEnabled } = useImageSearchFeature();
30
+ const { isEnabled: textSearchEnabled } = useTextSearchFeature({
31
+ enableTextSearch
32
+ });
33
+
34
+ const settings = mergeSettings({
35
+ ...userSettings,
36
+ enableTextSearch: textSearchEnabled
37
+ });
28
38
 
29
39
  const envEnabled = process.env.NEXT_PUBLIC_ENABLE_IMAGE_SEARCH === 'true';
30
40
  const finalIsEnabled = envEnabled
@@ -41,7 +51,11 @@ export function ProductImageSearchFeature({
41
51
  <>
42
52
  {finalIsEnabled && (
43
53
  <>
44
- <SimilarProductsButton onClick={handleClick} className={className} />
54
+ <SimilarProductsButton
55
+ onClick={handleClick}
56
+ className={className}
57
+ settings={settings}
58
+ />
45
59
 
46
60
  <SimilarProductsPlugin
47
61
  product={product}
@@ -526,10 +526,14 @@ export function SimilarProductsResultsGrid({
526
526
  );
527
527
  };
528
528
 
529
- if (isLoading && (!searchResults || !searchResults.products)) {
529
+ if (isLoading && (!searchResults || !searchResults.products?.length)) {
530
530
  return renderLoadingState();
531
531
  }
532
532
 
533
+ if (!isLoading && searchResults && searchResults.products?.length === 0) {
534
+ return renderEmptyState();
535
+ }
536
+
533
537
  const renderLoadingOverlay = () => {
534
538
  if (settings?.customRenderers?.render?.resultsGrid?.renderLoadingOverlay) {
535
539
  return settings.customRenderers.render.resultsGrid.renderLoadingOverlay();
@@ -585,16 +589,14 @@ export function SimilarProductsResultsGrid({
585
589
  <div className={gridClassName}>
586
590
  {isLoading &&
587
591
  searchResults &&
588
- searchResults.products &&
592
+ searchResults.products?.length > 0 &&
589
593
  renderLoadingOverlay()}
590
594
 
591
- {searchResults && searchResults.products?.length > 0 ? (
595
+ {searchResults && searchResults.products?.length > 0 && (
592
596
  <div className={resultsContainerClassName}>
593
597
  {renderGridContainer()}
594
598
  {renderPagination()}
595
599
  </div>
596
- ) : (
597
- renderEmptyState()
598
600
  )}
599
601
  </div>
600
602
  );
@@ -3,22 +3,32 @@
3
3
  import React from 'react';
4
4
  import { Icon } from '@akinon/next/components';
5
5
  import { useLocalization } from '@akinon/next/hooks';
6
+ import { twMerge } from 'tailwind-merge';
6
7
 
7
8
  interface SimilarProductsButtonProps {
8
9
  onClick: () => void;
9
10
  className?: string;
10
11
  isLoading?: boolean;
11
12
  disabled?: boolean;
13
+ settings?: any;
12
14
  }
13
15
 
14
16
  export function SimilarProductsButton({
15
17
  onClick,
16
18
  className = '',
17
19
  isLoading = false,
18
- disabled = false
20
+ disabled = false,
21
+ settings
19
22
  }: SimilarProductsButtonProps) {
20
23
  const { t } = useLocalization();
21
24
 
25
+ const searchButtonIconClassName = twMerge(
26
+ 'fill-black',
27
+ settings?.customStyles?.searchButtonIcon
28
+ );
29
+
30
+ const searchButtonIconName = settings?.iconNames?.searchButton || 'search';
31
+
22
32
  return (
23
33
  <button
24
34
  onClick={onClick}
@@ -28,7 +38,7 @@ export function SimilarProductsButton({
28
38
  } ${className}`}
29
39
  >
30
40
  <div className="flex items-center gap-2">
31
- <Icon name="search" size={16} className="fill-black" />
41
+ <Icon name={searchButtonIconName} size={16} className={searchButtonIconClassName} />
32
42
  <span className="text-xs font-medium text-black uppercase">
33
43
  {t('common.product.view_similar_styles')}
34
44
  </span>