@akinon/pz-similar-products 1.92.0-rc.16

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.
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { Product } from '@akinon/next/types';
5
+ import { useImageSearchFeature } from '../hooks/use-image-search-feature';
6
+ import { ImageSearchButton } from './image-search-button';
7
+ import { SimilarProductsPlugin } from './main';
8
+
9
+ interface HeaderImageSearchFeatureProps {
10
+ className?: string;
11
+ }
12
+
13
+ export function HeaderImageSearchFeature({
14
+ className
15
+ }: HeaderImageSearchFeatureProps) {
16
+ const { isEnabled, isLoading } = useImageSearchFeature();
17
+
18
+ const [isImageSearchModalOpen, setIsImageSearchModalOpen] = useState(false);
19
+ const [isResultsModalOpen, setIsResultsModalOpen] = useState(false);
20
+ const [uploadedImageFile, setUploadedImageFile] = useState<File | null>(null);
21
+
22
+ if (isLoading || !isEnabled) {
23
+ return null;
24
+ }
25
+
26
+ const handleOpenImageSearch = () => {
27
+ setIsImageSearchModalOpen(true);
28
+ };
29
+
30
+ const handleImageUpload = (file: File) => {
31
+ setUploadedImageFile(file);
32
+ setIsImageSearchModalOpen(false);
33
+ setIsResultsModalOpen(true);
34
+ };
35
+
36
+ const handleResultsModalClose = () => {
37
+ setIsResultsModalOpen(false);
38
+ setUploadedImageFile(null);
39
+ };
40
+
41
+ const handleImageSearchModalClose = () => {
42
+ setIsImageSearchModalOpen(false);
43
+ };
44
+
45
+ return (
46
+ <>
47
+ {isEnabled && (
48
+ <>
49
+ <ImageSearchButton
50
+ onClick={handleOpenImageSearch}
51
+ className={className}
52
+ />
53
+ <SimilarProductsPlugin
54
+ product={{} as Product}
55
+ isOpen={isResultsModalOpen}
56
+ onClose={handleResultsModalClose}
57
+ activeIndex={0}
58
+ showImageSearchModal={isImageSearchModalOpen}
59
+ onImageSearchModalClose={handleImageSearchModalClose}
60
+ uploadedImageFile={uploadedImageFile}
61
+ onImageUpload={handleImageUpload}
62
+ showResetButton={false}
63
+ />
64
+ </>
65
+ )}
66
+ </>
67
+ );
68
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Button } from '@akinon/next/components';
5
+ import { useLocalization } from '@akinon/next/hooks';
6
+ import { useImageSearchFeature } from '../hooks/use-image-search-feature';
7
+
8
+ interface ImageSearchButtonProps {
9
+ onClick: () => void;
10
+ className?: string;
11
+ }
12
+
13
+ export function ImageSearchButton({
14
+ onClick,
15
+ className
16
+ }: ImageSearchButtonProps) {
17
+ const { t } = useLocalization();
18
+ const { isEnabled, isLoading } = useImageSearchFeature();
19
+
20
+ if (isLoading || !isEnabled) {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <Button
26
+ onClick={onClick}
27
+ className={`flex items-center justify-center mr-2 text-gray-500 focus:outline-none border-none bg-transparent ${className || ''}`}
28
+ title={t('common.search.image_search.title')}
29
+ >
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>
45
+ </Button>
46
+ );
47
+ }
@@ -0,0 +1,152 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect } from 'react';
4
+ import { Modal, Button, Input } from '@akinon/next/components';
5
+ import { useLocalization } from '@akinon/next/hooks';
6
+
7
+ interface ImageSearchModalProps {
8
+ isOpen: boolean;
9
+ setIsOpen: (open: boolean) => void;
10
+ fileInputRef: React.RefObject<HTMLInputElement>;
11
+ handleImageFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
12
+ className?: string;
13
+ clearError?: () => void;
14
+ }
15
+
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
+ export function ImageSearchModal({
62
+ isOpen,
63
+ setIsOpen,
64
+ fileInputRef,
65
+ handleImageFileChange,
66
+ className,
67
+ clearError
68
+ }: ImageSearchModalProps) {
69
+ const { t } = useLocalization();
70
+
71
+ useEffect(() => {
72
+ if (isOpen && clearError) {
73
+ clearError();
74
+ }
75
+ }, [isOpen, clearError]);
76
+
77
+ const handleFileSelect = async (
78
+ event: React.ChangeEvent<HTMLInputElement>
79
+ ) => {
80
+ const file = event.target.files?.[0];
81
+
82
+ if (!file) return;
83
+
84
+ if (file.type === 'image/webp') {
85
+ try {
86
+ const convertedFile = await convertWebPToJPEG(file);
87
+
88
+ const modifiedEvent = {
89
+ ...event,
90
+ target: {
91
+ ...event.target,
92
+ files: [convertedFile] as any
93
+ }
94
+ };
95
+
96
+ handleImageFileChange(
97
+ modifiedEvent as React.ChangeEvent<HTMLInputElement>
98
+ );
99
+ } catch (error) {
100
+ console.error('WebP conversion failed:', error);
101
+
102
+ handleImageFileChange(event);
103
+ }
104
+ } else {
105
+ handleImageFileChange(event);
106
+ }
107
+ };
108
+
109
+ 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()}
133
+ >
134
+ {t('common.search.image_search.select_image')}
135
+ </Button>
136
+ </div>
137
+ </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>
151
+ );
152
+ }
@@ -0,0 +1,200 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect } from 'react';
4
+ import { Product } from '@akinon/next/types';
5
+ import { useImageCropper } from '../hooks/use-image-cropper';
6
+ import { SimilarProductsModal } from './search-modal';
7
+ import { ImageSearchModal } from './image-search';
8
+ import { useSimilarProducts } from '../hooks/use-similar-products';
9
+ import { mergeSettings } from '../utils';
10
+
11
+ interface SimilarProductsPluginProps {
12
+ product: Product;
13
+ isOpen?: boolean;
14
+ onClose?: () => void;
15
+ activeIndex?: number;
16
+ settings?: any;
17
+ showImageSearchModal?: boolean;
18
+ onImageSearchModalClose?: () => void;
19
+ uploadedImageFile?: File | null;
20
+ onImageUpload?: (file: File) => void;
21
+ showResetButton?: boolean;
22
+ }
23
+
24
+ export function SimilarProductsPlugin({
25
+ product,
26
+ isOpen = false,
27
+ onClose = () => {},
28
+ activeIndex = 0,
29
+ settings: userSettings,
30
+ showImageSearchModal = false,
31
+ onImageSearchModalClose = () => {},
32
+ uploadedImageFile = null,
33
+ onImageUpload = () => {},
34
+ showResetButton = true
35
+ }: SimilarProductsPluginProps) {
36
+ const settings = mergeSettings(userSettings);
37
+ const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
38
+
39
+ const fileInputRef = useRef<HTMLInputElement>(null);
40
+
41
+ const {
42
+ currentImageUrl,
43
+ setCurrentImageUrl,
44
+ isLoading,
45
+ fileError,
46
+ searchResults,
47
+ resultsKey,
48
+ hasUploadedImage,
49
+ setHasUploadedImage,
50
+ handleFileUpload,
51
+ handleSortChange,
52
+ handlePageChange,
53
+ handleFacetChange,
54
+ removeFacetFilter,
55
+ handleLoadMore,
56
+ loadedPages,
57
+ fetchSimilarProductsByImageUrl,
58
+ fetchSimilarProductsByImageCrop,
59
+ clearError
60
+ } = useSimilarProducts(product);
61
+
62
+ const {
63
+ isCropping,
64
+ crop,
65
+ setCrop,
66
+ completedCrop,
67
+ setCompletedCrop,
68
+ imageRef,
69
+ handleCropComplete,
70
+ toggleCropMode,
71
+ processCompletedCrop,
72
+ processManualCrop,
73
+ resetCrop
74
+ } = useImageCropper(
75
+ (loading) => {},
76
+ fetchSimilarProductsByImageCrop,
77
+ clearError
78
+ );
79
+
80
+ const handleResetToOriginal = () => {
81
+ if (product?.productimage_set?.length > 0) {
82
+ const originalImageUrl =
83
+ product.productimage_set[activeIndex]?.image ||
84
+ product.productimage_set[0].image;
85
+ setCurrentImageUrl(originalImageUrl);
86
+ setHasUploadedImage(false);
87
+ resetCrop();
88
+ fetchSimilarProductsByImageUrl(originalImageUrl);
89
+ }
90
+ };
91
+
92
+ const [hasInitialSearchDone, setHasInitialSearchDone] = useState(false);
93
+
94
+ useEffect(() => {
95
+ if (
96
+ isOpen &&
97
+ product?.productimage_set?.length > 0 &&
98
+ !hasUploadedImage &&
99
+ !hasInitialSearchDone
100
+ ) {
101
+ const initialImageUrl =
102
+ product.productimage_set[activeIndex]?.image ||
103
+ product.productimage_set[0].image;
104
+ fetchSimilarProductsByImageUrl(initialImageUrl);
105
+ setHasInitialSearchDone(true);
106
+ }
107
+
108
+ if (!isOpen) {
109
+ setHasInitialSearchDone(false);
110
+ clearError();
111
+ }
112
+ }, [
113
+ isOpen,
114
+ product?.productimage_set?.length,
115
+ activeIndex,
116
+ hasUploadedImage,
117
+ hasInitialSearchDone,
118
+ clearError
119
+ ]);
120
+
121
+ useEffect(() => {
122
+ if (uploadedImageFile && isOpen) {
123
+ const reader = new FileReader();
124
+ reader.onload = (e) => {
125
+ const result = e.target?.result as string;
126
+ if (result) {
127
+ setCurrentImageUrl(result);
128
+ setHasUploadedImage(true);
129
+ fetchSimilarProductsByImageCrop(result);
130
+ }
131
+ };
132
+ reader.readAsDataURL(uploadedImageFile);
133
+ }
134
+ }, [uploadedImageFile, isOpen]);
135
+
136
+ const handleImageSearchFileChange = (
137
+ event: React.ChangeEvent<HTMLInputElement>
138
+ ) => {
139
+ const file = event.target.files?.[0];
140
+ if (file) {
141
+ onImageUpload(file);
142
+ }
143
+ };
144
+
145
+ const ModalComponent =
146
+ settings.customRenderers?.Modal || SimilarProductsModal;
147
+ const ImageSearchModalComponent =
148
+ settings.customRenderers?.ImageSearchModal || ImageSearchModal;
149
+
150
+ return (
151
+ <>
152
+ <ModalComponent
153
+ isOpen={isOpen}
154
+ onClose={onClose}
155
+ searchResults={searchResults}
156
+ resultsKey={resultsKey}
157
+ isLoading={isLoading}
158
+ isFilterMenuOpen={isFilterMenuOpen}
159
+ setIsFilterMenuOpen={setIsFilterMenuOpen}
160
+ handleSortChange={handleSortChange}
161
+ handlePageChange={handlePageChange}
162
+ handleFacetChange={handleFacetChange}
163
+ removeFacetFilter={removeFacetFilter}
164
+ handleLoadMore={handleLoadMore}
165
+ loadedPages={loadedPages}
166
+ currentImageUrl={currentImageUrl}
167
+ isCropping={isCropping}
168
+ imageRef={imageRef}
169
+ fileInputRef={fileInputRef}
170
+ crop={crop}
171
+ setCrop={setCrop}
172
+ completedCrop={completedCrop}
173
+ setCompletedCrop={setCompletedCrop}
174
+ handleCropComplete={handleCropComplete}
175
+ toggleCropMode={toggleCropMode}
176
+ processCompletedCrop={processCompletedCrop}
177
+ processManualCrop={processManualCrop}
178
+ resetCrop={resetCrop}
179
+ product={product}
180
+ activeIndex={activeIndex}
181
+ hasUploadedImage={hasUploadedImage}
182
+ handleFileUpload={handleFileUpload}
183
+ handleResetToOriginal={handleResetToOriginal}
184
+ fileError={fileError}
185
+ settings={settings}
186
+ className={settings.customStyles?.modal}
187
+ showResetButton={showResetButton}
188
+ />
189
+
190
+ <ImageSearchModalComponent
191
+ isOpen={showImageSearchModal}
192
+ setIsOpen={onImageSearchModalClose}
193
+ fileInputRef={fileInputRef}
194
+ handleImageFileChange={handleImageSearchFileChange}
195
+ className={settings.customStyles?.imageSearchModal}
196
+ clearError={clearError}
197
+ />
198
+ </>
199
+ );
200
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { Product } from '@akinon/next/types';
5
+ import { SimilarProductsButton } from './search-button';
6
+ import { SimilarProductsPlugin } from './main';
7
+ import { mergeSettings } from '../utils';
8
+ import { useImageSearchFeature } from '../hooks/use-image-search-feature';
9
+
10
+ interface ProductImageSearchFeatureProps {
11
+ product: Product;
12
+ activeIndex?: number;
13
+ settings?: any;
14
+ className?: string;
15
+ }
16
+
17
+ export function ProductImageSearchFeature({
18
+ product,
19
+ activeIndex = 0,
20
+ settings: userSettings,
21
+ className = 'absolute top-6 left-6 z-[20]'
22
+ }: ProductImageSearchFeatureProps) {
23
+ const settings = mergeSettings(userSettings);
24
+ const [isModalOpen, setIsModalOpen] = useState(false);
25
+ const { isEnabled } = useImageSearchFeature();
26
+
27
+ const handleClick = () => {
28
+ setIsModalOpen(true);
29
+ };
30
+
31
+ return (
32
+ <>
33
+ {isEnabled && (
34
+ <>
35
+ <SimilarProductsButton onClick={handleClick} className={className} />
36
+
37
+ <SimilarProductsPlugin
38
+ product={product}
39
+ isOpen={isModalOpen}
40
+ onClose={() => setIsModalOpen(false)}
41
+ activeIndex={activeIndex}
42
+ settings={settings}
43
+ />
44
+ </>
45
+ )}
46
+ </>
47
+ );
48
+ }