@akinon/pz-similar-products 1.115.0-rc.23 → 1.116.0-rc.0

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.
@@ -1,47 +1,43 @@
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
- import { useImageSearchFeature } from '../hooks/use-image-search-feature';
6
+ import { twMerge } from 'tailwind-merge';
7
7
 
8
8
  interface ImageSearchButtonProps {
9
9
  onClick: () => void;
10
10
  className?: string;
11
+ settings?: any;
11
12
  }
12
13
 
13
14
  export function ImageSearchButton({
14
15
  onClick,
15
- className
16
+ className,
17
+ settings
16
18
  }: ImageSearchButtonProps) {
17
19
  const { t } = useLocalization();
18
- const { isEnabled, isLoading } = useImageSearchFeature();
19
20
 
20
- if (isLoading || !isEnabled) {
21
- return null;
22
- }
21
+ const imageSearchButtonIconName =
22
+ settings?.iconNames?.imageSearchButton || 'search';
23
+ const imageSearchButtonIconClassName = twMerge(
24
+ 'text-black',
25
+ settings?.customStyles?.imageSearchButtonIcon
26
+ );
23
27
 
24
28
  return (
25
29
  <Button
26
30
  onClick={onClick}
27
- className={`flex items-center justify-center mr-2 text-gray-500 focus:outline-none border-none bg-transparent ${className || ''}`}
31
+ className={`flex items-center justify-center mr-2 text-gray-500 focus:outline-none border-none bg-transparent ${
32
+ className || ''
33
+ }`}
28
34
  title={t('common.search.image_search.title')}
29
35
  >
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>
36
+ <Icon
37
+ name={imageSearchButtonIconName}
38
+ size={20}
39
+ className={imageSearchButtonIconClassName}
40
+ />
45
41
  </Button>
46
42
  );
47
43
  }
@@ -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
@@ -5,7 +5,6 @@ import { Product } from '@akinon/next/types';
5
5
  import { SimilarProductsButton } from './search-button';
6
6
  import { SimilarProductsPlugin } from './main';
7
7
  import { mergeSettings } from '../utils';
8
- import { useImageSearchFeature } from '../hooks/use-image-search-feature';
9
8
 
10
9
  interface ProductImageSearchFeatureProps {
11
10
  product: Product;
@@ -13,6 +12,7 @@ interface ProductImageSearchFeatureProps {
13
12
  settings?: any;
14
13
  className?: string;
15
14
  isEnabled?: boolean;
15
+ enableTextSearch?: boolean;
16
16
  }
17
17
 
18
18
  export function ProductImageSearchFeature({
@@ -20,18 +20,18 @@ export function ProductImageSearchFeature({
20
20
  activeIndex = 0,
21
21
  settings: userSettings,
22
22
  className = 'absolute top-6 left-6 z-[20]',
23
- isEnabled: isEnabledProp
23
+ isEnabled = false,
24
+ enableTextSearch = false
24
25
  }: ProductImageSearchFeatureProps) {
25
- const settings = mergeSettings(userSettings);
26
+ const settings = mergeSettings({
27
+ ...userSettings,
28
+ enableTextSearch
29
+ });
26
30
  const [isModalOpen, setIsModalOpen] = useState(false);
27
- const { isEnabled: hookIsEnabled } = useImageSearchFeature();
28
31
 
29
- const envEnabled = process.env.NEXT_PUBLIC_ENABLE_IMAGE_SEARCH === 'true';
30
- const finalIsEnabled = envEnabled
31
- ? true
32
- : isEnabledProp !== undefined
33
- ? isEnabledProp
34
- : hookIsEnabled;
32
+ if (!isEnabled) {
33
+ return null;
34
+ }
35
35
 
36
36
  const handleClick = () => {
37
37
  setIsModalOpen(true);
@@ -39,19 +39,19 @@ export function ProductImageSearchFeature({
39
39
 
40
40
  return (
41
41
  <>
42
- {finalIsEnabled && (
43
- <>
44
- <SimilarProductsButton onClick={handleClick} className={className} />
42
+ <SimilarProductsButton
43
+ onClick={handleClick}
44
+ className={className}
45
+ settings={settings}
46
+ />
45
47
 
46
- <SimilarProductsPlugin
47
- product={product}
48
- isOpen={isModalOpen}
49
- onClose={() => setIsModalOpen(false)}
50
- activeIndex={activeIndex}
51
- settings={settings}
52
- />
53
- </>
54
- )}
48
+ <SimilarProductsPlugin
49
+ product={product}
50
+ isOpen={isModalOpen}
51
+ onClose={() => setIsModalOpen(false)}
52
+ activeIndex={activeIndex}
53
+ settings={settings}
54
+ />
55
55
  </>
56
56
  );
57
57
  }
@@ -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>
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
- import { Modal, Button, Icon, Select } from '@akinon/next/components';
4
+ import { Modal, Button, Icon, Select, Input } from '@akinon/next/components';
5
5
  import { useLocalization } from '@akinon/next/hooks';
6
6
  import { SimilarProductsFilterSidebar } from './filters';
7
7
  import { SimilarProductsResultsGrid } from './results';
@@ -43,7 +43,11 @@ export function SimilarProductsModal({
43
43
  fileError,
44
44
  showResetButton = true,
45
45
  settings,
46
- className
46
+ className,
47
+ searchText,
48
+ setSearchText,
49
+ handleTextSearch,
50
+ handleClearText
47
51
  }: SimilarProductsModalProps) {
48
52
  const { t } = useLocalization();
49
53
 
@@ -164,6 +168,24 @@ export function SimilarProductsModal({
164
168
  })) || []
165
169
  ) || [];
166
170
 
171
+ const modalCloseIconName = settings?.iconNames?.modalClose || 'close';
172
+ const filterIconName = settings?.iconNames?.filter || 'filter';
173
+ const modalSearchIconName = settings?.iconNames?.modalSearch || 'search';
174
+ const modalSearchClearIconName =
175
+ settings?.iconNames?.modalSearchClear || 'close';
176
+
177
+ const modalCloseIconClassName = twMerge(
178
+ '',
179
+ settings?.customStyles?.modalCloseIcon
180
+ );
181
+
182
+ const filterIconClassName = twMerge('', settings?.customStyles?.filterIcon);
183
+
184
+ const modalSearchIconClassName = twMerge(
185
+ 'text-gray-500',
186
+ settings?.customStyles?.modalSearchIcon
187
+ );
188
+
167
189
  const renderHeader = () => {
168
190
  if (settings?.customRenderers?.render?.modal?.renderHeader) {
169
191
  return settings.customRenderers.render.modal.renderHeader({
@@ -246,7 +268,11 @@ export function SimilarProductsModal({
246
268
  disabled={isLoading}
247
269
  className={buttonClassName}
248
270
  >
249
- <Icon name="close" size={12} />
271
+ <Icon
272
+ name={modalCloseIconName}
273
+ size={12}
274
+ className={modalCloseIconClassName}
275
+ />
250
276
  </Button>
251
277
  </div>
252
278
  );
@@ -266,7 +292,9 @@ export function SimilarProductsModal({
266
292
  '',
267
293
  onSortChange: handleSortChange,
268
294
  onFilterMenuToggle: () => setIsFilterMenuOpen(true),
269
- isLoading
295
+ isLoading,
296
+ searchText,
297
+ setSearchText
270
298
  });
271
299
  }
272
300
 
@@ -281,15 +309,30 @@ export function SimilarProductsModal({
281
309
  );
282
310
 
283
311
  const filterToggleClassName = twMerge(
284
- 'md:hidden text-xs',
312
+ 'md:hidden text-xs px-3 py-2',
285
313
  settings?.customStyles?.filterToggleButton
286
314
  );
287
315
 
288
316
  const sortDropdownClassName = twMerge(
289
- '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]',
317
+ 'h-10 px-3 md:px-4 text-sm 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]',
290
318
  settings?.customStyles?.sortDropdown
291
319
  );
292
320
 
321
+ const modalSearchInputClassName = twMerge(
322
+ 'h-10 px-3 md:px-4 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-48 md:w-60 min-w-[180px]',
323
+ settings?.customStyles?.modalSearchInput
324
+ );
325
+
326
+ const modalSearchContainerClassName = twMerge(
327
+ 'relative flex items-center',
328
+ settings?.customStyles?.modalSearchContainer
329
+ );
330
+
331
+ const modalSearchButtonClassName = twMerge(
332
+ 'absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors',
333
+ settings?.customStyles?.modalSearchButton
334
+ );
335
+
293
336
  const renderItemCount = () => {
294
337
  if (settings?.customRenderers?.render?.modal?.renderItemCount) {
295
338
  return settings.customRenderers.render.modal.renderItemCount({
@@ -299,7 +342,9 @@ export function SimilarProductsModal({
299
342
  }
300
343
  return (
301
344
  <div className={itemCountClassName}>
302
- {searchResults?.pagination?.total_count || 0} items
345
+ <span className="hidden md:inline">
346
+ {searchResults?.pagination?.total_count || 0} items
347
+ </span>
303
348
  </div>
304
349
  );
305
350
  };
@@ -319,7 +364,11 @@ export function SimilarProductsModal({
319
364
  onClick={() => setIsFilterMenuOpen(true)}
320
365
  data-testid="similar-products-filter"
321
366
  >
322
- <Icon name="filter" size={14} />
367
+ <Icon
368
+ name={filterIconName}
369
+ size={14}
370
+ className={filterIconClassName}
371
+ />
323
372
  {t('common.product.filters')}
324
373
  </Button>
325
374
  );
@@ -355,26 +404,134 @@ export function SimilarProductsModal({
355
404
  );
356
405
  };
357
406
 
407
+ const renderModalSearchInput = () => {
408
+ if (
409
+ !settings?.enableTextSearch ||
410
+ searchText === undefined ||
411
+ !setSearchText ||
412
+ !searchResults?.products?.length ||
413
+ !searchResults?.sorters?.length
414
+ ) {
415
+ return null;
416
+ }
417
+
418
+ if (settings?.customRenderers?.render?.modal?.renderModalSearchInput) {
419
+ return settings.customRenderers.render.modal.renderModalSearchInput({
420
+ searchText,
421
+ setSearchText,
422
+ isLoading,
423
+ placeholder: t('common.search.placeholder'),
424
+ onSearch: handleTextSearch
425
+ });
426
+ }
427
+
428
+ return (
429
+ <div className={modalSearchContainerClassName}>
430
+ <Input
431
+ type="text"
432
+ value={searchText}
433
+ onChange={(e) => setSearchText(e.currentTarget.value)}
434
+ onKeyDown={(e) => {
435
+ if (e.key === 'Enter' && handleTextSearch) {
436
+ e.preventDefault();
437
+ handleTextSearch();
438
+ }
439
+ }}
440
+ placeholder={t('common.search.placeholder')}
441
+ className={twMerge(modalSearchInputClassName, 'pr-20')}
442
+ disabled={isLoading}
443
+ />
444
+ {searchText && (
445
+ <button
446
+ onClick={() => {
447
+ if (handleClearText) {
448
+ handleClearText();
449
+ }
450
+ }}
451
+ disabled={isLoading}
452
+ className={twMerge(
453
+ 'absolute right-12 top-1/2 -translate-y-1/2 p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors',
454
+ settings?.customStyles?.modalSearchClearButton
455
+ )}
456
+ >
457
+ <Icon
458
+ name={modalSearchClearIconName}
459
+ size={16}
460
+ className={twMerge(
461
+ 'text-gray-400 hover:text-gray-600',
462
+ settings?.customStyles?.modalSearchClearIcon
463
+ )}
464
+ />
465
+ </button>
466
+ )}
467
+ <button
468
+ onClick={() => {
469
+ if (handleTextSearch) {
470
+ handleTextSearch();
471
+ }
472
+ }}
473
+ disabled={isLoading}
474
+ className={modalSearchButtonClassName}
475
+ >
476
+ {settings?.customRenderers?.render?.modal?.renderSearchIcon ? (
477
+ settings.customRenderers.render.modal.renderSearchIcon({
478
+ disabled: isLoading,
479
+ onClick: handleTextSearch
480
+ })
481
+ ) : (
482
+ <Icon
483
+ name={modalSearchIconName}
484
+ size={18}
485
+ className={modalSearchIconClassName}
486
+ />
487
+ )}
488
+ </button>
489
+ </div>
490
+ );
491
+ };
492
+
358
493
  return (
359
494
  <div className={containerClassName}>
360
495
  <div
361
496
  className={twMerge(
362
- 'flex items-center justify-between p-3 md:p-4',
497
+ 'p-3 md:p-4',
498
+ settings?.enableTextSearch &&
499
+ searchText !== undefined &&
500
+ setSearchText
501
+ ? 'flex gap-3 md:grid md:grid-cols-3 md:gap-4 md:items-center'
502
+ : 'flex items-center justify-between',
363
503
  settings?.customStyles?.controlsInner
364
504
  )}
365
505
  >
366
506
  <div
367
507
  className={twMerge(
368
- 'flex items-center gap-3',
508
+ 'flex items-center gap-2 md:gap-3',
369
509
  settings?.customStyles?.controlsLeft
370
510
  )}
371
511
  >
372
512
  {renderItemCount()}
373
513
  {renderFilterToggle()}
374
514
  </div>
515
+ {settings?.enableTextSearch &&
516
+ searchText !== undefined &&
517
+ setSearchText && (
518
+ <div
519
+ className={twMerge(
520
+ 'flex items-center justify-center w-full',
521
+ settings?.customStyles?.controlsCenter
522
+ )}
523
+ >
524
+ {renderModalSearchInput()}
525
+ </div>
526
+ )}
375
527
  <div
376
528
  className={twMerge(
377
529
  'relative',
530
+ settings?.enableTextSearch &&
531
+ searchText !== undefined &&
532
+ setSearchText
533
+ ? 'flex justify-end md:mt-0'
534
+ : '',
378
535
  settings?.customStyles?.controlsRight
379
536
  )}
380
537
  >
@@ -410,6 +567,8 @@ export function SimilarProductsModal({
410
567
  isLoading={isLoading}
411
568
  handleFacetChange={handleFacetChange}
412
569
  removeFacetFilter={removeFacetFilter}
570
+ searchText={searchText}
571
+ setSearchText={setSearchText}
413
572
  currentImageUrl={currentImageUrl}
414
573
  isCropping={isCropping}
415
574
  imageRef={imageRef}