@akinon/pz-similar-products 1.92.0-snapshot-ZERO-3457-20250627111231 → 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.
@@ -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
  }
@@ -79,114 +79,126 @@ export function ImageSearchModal({
79
79
  };
80
80
 
81
81
  return (
82
- <Modal
83
- portalId="image-search-modal"
84
- title={t('common.search.image_search.title')}
85
- open={isOpen}
86
- setOpen={setIsOpen}
87
- className={`w-full max-w-2xl max-h-[90vh] overflow-auto ${
88
- className || ''
89
- }`}
90
- >
91
- <div
92
- className={twMerge(
93
- 'grid grid-cols-1 md:grid-cols-2 gap-4',
94
- settings?.customStyles?.imageSearchContent
95
- )}
96
- >
82
+ <>
83
+ {isOpen && (
97
84
  <div
98
85
  className={twMerge(
99
- 'md:col-span-1 p-4',
100
- settings?.customStyles?.imageSearchUploadSection
86
+ 'fixed inset-0 bg-black/50 z-[60]',
87
+ settings?.customStyles?.imageSearchModalOverlay
101
88
  )}
102
- >
103
- <p
104
- className={twMerge(
105
- 'mb-4 text-lg font-medium',
106
- settings?.customStyles?.imageSearchUploadTitle
107
- )}
108
- >
109
- {t('common.search.image_search.upload_image')}
110
- </p>
111
- <div
112
- className={twMerge(
113
- 'border-2 border-dashed border-gray-300 rounded-lg p-8 text-center',
114
- settings?.customStyles?.imageSearchUploadArea
115
- )}
116
- >
117
- <Input
118
- type="file"
119
- ref={fileInputRef}
120
- onChange={handleFileSelect}
121
- accept="image/*"
122
- className="hidden"
123
- />
124
- <Button
125
- className={twMerge(
126
- 'w-full py-4 px-6 border-2 border-primary text-white hover:bg-primary hover:text-white transition-colors rounded-md',
127
- settings?.customStyles?.imageSearchUploadButton
128
- )}
129
- onClick={() => fileInputRef.current?.click()}
130
- >
131
- {t('common.search.image_search.select_image')}
132
- </Button>
133
- </div>
134
- </div>
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
+ >
135
102
  <div
136
103
  className={twMerge(
137
- 'md:col-span-1 bg-gray-50 p-4 rounded-md h-fit my-4',
138
- settings?.customStyles?.imageSearchTipsSection
104
+ 'grid grid-cols-1 md:grid-cols-2 gap-4',
105
+ settings?.customStyles?.imageSearchContent
139
106
  )}
140
107
  >
141
- <p
142
- className={twMerge(
143
- 'mb-2 font-medium',
144
- settings?.customStyles?.imageSearchTipsTitle
145
- )}
146
- >
147
- {t('common.search.image_search.best_results')}
148
- </p>
149
- <ul
108
+ <div
150
109
  className={twMerge(
151
- 'text-left list-disc pl-5 text-sm text-gray-600',
152
- settings?.customStyles?.imageSearchTipsList
110
+ 'md:col-span-1 p-4',
111
+ settings?.customStyles?.imageSearchUploadSection
153
112
  )}
154
113
  >
155
- <li
114
+ <p
156
115
  className={twMerge(
157
- 'mb-2',
158
- settings?.customStyles?.imageSearchTipsItem
116
+ 'mb-4 text-lg font-medium',
117
+ settings?.customStyles?.imageSearchUploadTitle
159
118
  )}
160
119
  >
161
- {t('common.search.image_search.tip_1')}
162
- </li>
163
- <li
120
+ {t('common.search.image_search.upload_image')}
121
+ </p>
122
+ <div
164
123
  className={twMerge(
165
- 'mb-2',
166
- settings?.customStyles?.imageSearchTipsItem
124
+ 'border-2 border-dashed border-gray-300 rounded-lg p-8 text-center',
125
+ settings?.customStyles?.imageSearchUploadArea
167
126
  )}
168
127
  >
169
- {t('common.search.image_search.tip_2')}
170
- </li>
171
- <li
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
172
153
  className={twMerge(
173
- 'mb-2',
174
- settings?.customStyles?.imageSearchTipsItem
154
+ 'mb-2 font-medium',
155
+ settings?.customStyles?.imageSearchTipsTitle
175
156
  )}
176
157
  >
177
- {t('common.search.image_search.tip_3')}
178
- </li>
179
- <li
158
+ {t('common.search.image_search.best_results')}
159
+ </p>
160
+ <ul
180
161
  className={twMerge(
181
- 'mb-2',
182
- settings?.customStyles?.imageSearchTipsItem
162
+ 'text-left list-disc pl-5 text-sm text-gray-600',
163
+ settings?.customStyles?.imageSearchTipsList
183
164
  )}
184
165
  >
185
- {t('common.search.image_search.tip_4')}
186
- </li>
187
- </ul>
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>
199
+ </div>
188
200
  </div>
189
- </div>
190
- </Modal>
201
+ </Modal>
202
+ </>
191
203
  );
192
204
  }
@@ -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>