@akinon/pz-similar-products 1.92.0-rc.31 → 1.92.0-rc.33

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.
@@ -58,6 +58,10 @@ export interface SimilarProductsModalProps {
58
58
  showResetButton?: boolean;
59
59
  settings?: any;
60
60
  className?: string;
61
+ searchText?: string;
62
+ setSearchText?: (text: string) => void;
63
+ handleTextSearch?: () => void;
64
+ handleClearText?: () => void;
61
65
  }
62
66
 
63
67
  export interface FilterSidebarProps {
@@ -67,6 +71,8 @@ export interface FilterSidebarProps {
67
71
  isLoading: boolean;
68
72
  handleFacetChange: (facetKey: string, choiceValue: string | number) => void;
69
73
  removeFacetFilter: (facetKey: string, choiceValue: string | number) => void;
74
+ searchText?: string;
75
+ setSearchText?: (text: string) => void;
70
76
  currentImageUrl: string;
71
77
  isCropping: boolean;
72
78
  imageRef: React.RefObject<HTMLImageElement>;
@@ -126,6 +132,8 @@ export interface CustomRendererProps {
126
132
  onSortChange: (value: string) => void;
127
133
  onFilterMenuToggle: () => void;
128
134
  isLoading: boolean;
135
+ searchText?: string;
136
+ setSearchText?: (text: string) => void;
129
137
  }) => React.ReactNode;
130
138
  renderSortDropdown?: (props: {
131
139
  sorters: SortOption[];
@@ -141,6 +149,17 @@ export interface CustomRendererProps {
141
149
  onClick: () => void;
142
150
  isLoading: boolean;
143
151
  }) => React.ReactNode;
152
+ renderModalSearchInput?: (props: {
153
+ searchText: string;
154
+ setSearchText: (text: string) => void;
155
+ isLoading: boolean;
156
+ placeholder: string;
157
+ onSearch?: () => void;
158
+ }) => React.ReactNode;
159
+ renderSearchIcon?: (props: {
160
+ disabled: boolean;
161
+ onClick?: () => void;
162
+ }) => React.ReactNode;
144
163
  renderEmptyState?: () => React.ReactNode;
145
164
  };
146
165
  filterSidebar?: {
@@ -219,6 +238,12 @@ export interface CustomRendererProps {
219
238
  onClearAll: () => void;
220
239
  isLoading: boolean;
221
240
  }) => React.ReactNode;
241
+ renderTextSearch?: (props: {
242
+ searchText: string;
243
+ setSearchText: (text: string) => void;
244
+ isLoading: boolean;
245
+ placeholder: string;
246
+ }) => React.ReactNode;
222
247
  };
223
248
  resultsGrid?: {
224
249
  renderGrid?: (props: ResultsGridProps) => React.ReactNode;
@@ -309,6 +334,7 @@ export interface SimilarProductsSettings {
309
334
  resultsPerPage?: number;
310
335
  enableCropping?: boolean;
311
336
  enableFileUpload?: boolean;
337
+ enableTextSearch?: boolean;
312
338
  paginationType?: 'pagination' | 'load-more' | 'infinite-scroll';
313
339
  loadMoreText?: string;
314
340
  loadMoreStyle?: 'button' | 'auto';
@@ -329,10 +355,22 @@ export interface SimilarProductsSettings {
329
355
  controlsContainer?: string;
330
356
  controlsInner?: string;
331
357
  controlsLeft?: string;
358
+ controlsCenter?: string;
332
359
  controlsRight?: string;
333
360
  itemCount?: string;
334
361
  sortDropdown?: string;
335
362
  filterToggleButton?: string;
363
+ modalSearchInput?: string;
364
+ modalSearchContainer?: string;
365
+ modalSearchButton?: string;
366
+ modalSearchIcon?: string;
367
+ modalSearchClearButton?: string;
368
+ modalSearchClearIcon?: string;
369
+ searchButtonIcon?: string;
370
+ modalCloseIcon?: string;
371
+ filterIcon?: string;
372
+ filterRemoveIcon?: string;
373
+ imageSearchButtonIcon?: string;
336
374
 
337
375
  filterSidebarMobileHeader?: string;
338
376
  filterSidebarMobileTitle?: string;
@@ -414,6 +452,19 @@ export interface SimilarProductsSettings {
414
452
  mobileActiveFilterTag?: string;
415
453
  mobileClearAllButton?: string;
416
454
  filterSidebarMobileOverlay?: string;
455
+
456
+ textSearchContainer?: string;
457
+ textSearchLabel?: string;
458
+ textSearchInput?: string;
459
+ };
460
+ iconNames?: {
461
+ searchButton?: string;
462
+ modalClose?: string;
463
+ filter?: string;
464
+ filterRemove?: string;
465
+ modalSearch?: string;
466
+ modalSearchClear?: string;
467
+ imageSearchButton?: string;
417
468
  };
418
469
  customRenderers?: {
419
470
  Modal?: React.ComponentType<SimilarProductsModalProps>;
@@ -7,6 +7,7 @@ export const defaultSettings: Required<SimilarProductsSettings> = {
7
7
  resultsPerPage: 20,
8
8
  enableCropping: true,
9
9
  enableFileUpload: true,
10
+ enableTextSearch: false,
10
11
  customStyles: {
11
12
  modal: '',
12
13
  filterSidebar: '',
@@ -15,7 +16,31 @@ export const defaultSettings: Required<SimilarProductsSettings> = {
15
16
  productItem: '',
16
17
  pagination: '',
17
18
  filterGroup: '',
18
- imageSection: ''
19
+ imageSection: '',
20
+ textSearchContainer: '',
21
+ textSearchLabel: '',
22
+ textSearchInput: '',
23
+ controlsCenter: '',
24
+ modalSearchInput: '',
25
+ modalSearchContainer: '',
26
+ modalSearchButton: '',
27
+ modalSearchIcon: '',
28
+ modalSearchClearButton: '',
29
+ modalSearchClearIcon: '',
30
+ searchButtonIcon: '',
31
+ modalCloseIcon: '',
32
+ filterIcon: '',
33
+ filterRemoveIcon: '',
34
+ imageSearchButtonIcon: ''
35
+ },
36
+ iconNames: {
37
+ searchButton: 'search',
38
+ modalClose: 'close',
39
+ filter: 'filter',
40
+ filterRemove: 'close',
41
+ modalSearch: 'search',
42
+ modalSearchClear: 'close',
43
+ imageSearchButton: 'search'
19
44
  },
20
45
  customRenderers: {
21
46
  render: {}
@@ -43,6 +68,10 @@ export function mergeSettings(
43
68
  ...defaultSettings.customStyles,
44
69
  ...userSettings.customStyles
45
70
  },
71
+ iconNames: {
72
+ ...defaultSettings.iconNames,
73
+ ...userSettings.iconNames
74
+ },
46
75
  customRenderers: {
47
76
  ...defaultSettings.customRenderers,
48
77
  ...userSettings.customRenderers,
@@ -100,13 +129,19 @@ export function validateImageFile(
100
129
  export function debounce<T extends (...args: any[]) => any>(
101
130
  func: T,
102
131
  wait: number
103
- ): (...args: Parameters<T>) => void {
132
+ ): ((...args: Parameters<T>) => void) & { cancel: () => void } {
104
133
  let timeout: NodeJS.Timeout;
105
134
 
106
- return (...args: Parameters<T>) => {
135
+ const debounced = (...args: Parameters<T>) => {
107
136
  clearTimeout(timeout);
108
137
  timeout = setTimeout(() => func(...args), wait);
109
138
  };
139
+
140
+ debounced.cancel = () => {
141
+ clearTimeout(timeout);
142
+ };
143
+
144
+ return debounced as ((...args: Parameters<T>) => void) & { cancel: () => void };
110
145
  }
111
146
 
112
147
  export function dataURLToBlob(dataURL: string): Blob {
@@ -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
@@ -928,7 +948,11 @@ export function SimilarProductsFilterSidebar({
928
948
  disabled={isLoading}
929
949
  className="hover:bg-gray-200 rounded-full p-0 w-5 h-5 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
930
950
  >
931
- <Icon name="close" size={10} />
951
+ <Icon
952
+ name={filterRemoveIconName}
953
+ size={10}
954
+ className={filterRemoveIconClassName}
955
+ />
932
956
  </Button>
933
957
  </div>
934
958
  ))}
@@ -10,12 +10,14 @@ interface HeaderImageSearchFeatureProps {
10
10
  className?: string;
11
11
  isEnabled?: boolean;
12
12
  settings?: any;
13
+ enableTextSearch?: boolean;
13
14
  }
14
15
 
15
16
  export function HeaderImageSearchFeature({
16
17
  className,
17
18
  isEnabled: isEnabledProp,
18
- settings
19
+ settings: userSettings,
20
+ enableTextSearch = false
19
21
  }: HeaderImageSearchFeatureProps) {
20
22
  const { isEnabled: hookIsEnabled, isLoading } = useImageSearchFeature();
21
23
 
@@ -29,6 +31,11 @@ export function HeaderImageSearchFeature({
29
31
  const [isImageSearchModalOpen, setIsImageSearchModalOpen] = useState(false);
30
32
  const [isResultsModalOpen, setIsResultsModalOpen] = useState(false);
31
33
  const [uploadedImageFile, setUploadedImageFile] = useState<File | null>(null);
34
+
35
+ const settings = {
36
+ ...userSettings,
37
+ enableTextSearch
38
+ };
32
39
 
33
40
  if (isLoading || !finalIsEnabled) {
34
41
  return null;
@@ -60,6 +67,7 @@ export function HeaderImageSearchFeature({
60
67
  <ImageSearchButton
61
68
  onClick={handleOpenImageSearch}
62
69
  className={className}
70
+ settings={settings}
63
71
  />
64
72
  <SimilarProductsPlugin
65
73
  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,
@@ -57,9 +84,47 @@ export function SimilarProductsPlugin({
57
84
  fetchSimilarProductsByImageUrl,
58
85
  fetchSimilarProductsByImageCrop,
59
86
  clearError,
60
- clearResults
87
+ clearResults,
88
+ handleTextSearch,
89
+ handleClearText,
90
+ resetCropState
61
91
  } = useSimilarProducts(product);
62
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
+
63
128
  const {
64
129
  isCropping,
65
130
  crop,
@@ -74,7 +139,7 @@ export function SimilarProductsPlugin({
74
139
  resetCrop
75
140
  } = useImageCropper(
76
141
  (loading) => {},
77
- fetchSimilarProductsByImageCrop,
142
+ cropProcessImageFunction,
78
143
  clearError
79
144
  );
80
145
 
@@ -85,8 +150,12 @@ export function SimilarProductsPlugin({
85
150
  product.productimage_set[0].image;
86
151
  setCurrentImageUrl(originalImageUrl);
87
152
  setHasUploadedImage(false);
153
+ hasUploadedImageRef.current = false;
154
+ setUploadFlag(false);
155
+ setSearchText('');
88
156
  resetCrop();
89
- fetchSimilarProductsByImageUrl(originalImageUrl);
157
+ resetCropState();
158
+ fetchSimilarProductsByImageUrl(originalImageUrl, '');
90
159
  }
91
160
  };
92
161
 
@@ -108,6 +177,8 @@ export function SimilarProductsPlugin({
108
177
 
109
178
  if (!isOpen) {
110
179
  setHasInitialSearchDone(false);
180
+ hasUploadedImageRef.current = false;
181
+ setUploadFlag(false);
111
182
  clearError();
112
183
  clearResults();
113
184
  }
@@ -129,7 +200,9 @@ export function SimilarProductsPlugin({
129
200
  if (result) {
130
201
  setCurrentImageUrl(result);
131
202
  setHasUploadedImage(true);
132
- fetchSimilarProductsByImageCrop(result);
203
+ hasUploadedImageRef.current = true;
204
+ setUploadFlag(true);
205
+ fetchSimilarProductsByImageCrop(result, false);
133
206
  }
134
207
  };
135
208
  reader.readAsDataURL(uploadedImageFile);
@@ -193,6 +266,10 @@ export function SimilarProductsPlugin({
193
266
  settings={settings}
194
267
  className={settings.customStyles?.modal}
195
268
  showResetButton={showResetButton}
269
+ searchText={searchText}
270
+ setSearchText={setSearchText}
271
+ handleTextSearch={handleTextSearch}
272
+ handleClearText={handleClearText}
196
273
  />
197
274
 
198
275
  <ImageSearchModalComponent
@@ -13,6 +13,7 @@ interface ProductImageSearchFeatureProps {
13
13
  settings?: any;
14
14
  className?: string;
15
15
  isEnabled?: boolean;
16
+ enableTextSearch?: boolean;
16
17
  }
17
18
 
18
19
  export function ProductImageSearchFeature({
@@ -20,9 +21,13 @@ export function ProductImageSearchFeature({
20
21
  activeIndex = 0,
21
22
  settings: userSettings,
22
23
  className = 'absolute top-6 left-6 z-[20]',
23
- isEnabled: isEnabledProp
24
+ isEnabled: isEnabledProp,
25
+ enableTextSearch = false
24
26
  }: ProductImageSearchFeatureProps) {
25
- const settings = mergeSettings(userSettings);
27
+ const settings = mergeSettings({
28
+ ...userSettings,
29
+ enableTextSearch
30
+ });
26
31
  const [isModalOpen, setIsModalOpen] = useState(false);
27
32
  const { isEnabled: hookIsEnabled } = useImageSearchFeature();
28
33
 
@@ -41,7 +46,7 @@ export function ProductImageSearchFeature({
41
46
  <>
42
47
  {finalIsEnabled && (
43
48
  <>
44
- <SimilarProductsButton onClick={handleClick} className={className} />
49
+ <SimilarProductsButton onClick={handleClick} className={className} settings={settings} />
45
50
 
46
51
  <SimilarProductsPlugin
47
52
  product={product}
@@ -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>