@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.
- package/.gitattributes +15 -0
- package/.prettierrc +13 -0
- package/CHANGELOG.md +3 -0
- package/README.md +1372 -0
- package/package.json +21 -0
- package/src/data/endpoints.ts +122 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-image-cropper.ts +264 -0
- package/src/hooks/use-image-search-feature.ts +32 -0
- package/src/hooks/use-similar-products.ts +939 -0
- package/src/index.ts +33 -0
- package/src/types/index.ts +419 -0
- package/src/utils/image-validation.ts +303 -0
- package/src/utils/index.ts +161 -0
- package/src/views/filters.tsx +858 -0
- package/src/views/header-image-search-feature.tsx +68 -0
- package/src/views/image-search-button.tsx +47 -0
- package/src/views/image-search.tsx +152 -0
- package/src/views/main.tsx +200 -0
- package/src/views/product-image-search-feature.tsx +48 -0
- package/src/views/results.tsx +591 -0
- package/src/views/search-button.tsx +38 -0
- package/src/views/search-modal.tsx +422 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
export interface ImageValidationResult {
|
|
2
|
+
isValid: boolean;
|
|
3
|
+
error?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const IMAGE_SIGNATURES = {
|
|
7
|
+
jpeg: [
|
|
8
|
+
{ bytes: 'ffd8', minLength: 2, description: 'JPEG (Generic)' },
|
|
9
|
+
{ bytes: 'ffd8ffe0', minLength: 4, description: 'JPEG/JFIF' },
|
|
10
|
+
{ bytes: 'ffd8ffe1', minLength: 4, description: 'JPEG/EXIF' },
|
|
11
|
+
{ bytes: 'ffd8ffe2', minLength: 4, description: 'JPEG/Canon' },
|
|
12
|
+
{ bytes: 'ffd8ffe3', minLength: 4, description: 'JPEG/Samsung' },
|
|
13
|
+
{ bytes: 'ffd8ffe8', minLength: 4, description: 'JPEG/SPIFF' },
|
|
14
|
+
{ bytes: 'ffd8ffed', minLength: 4, description: 'JPEG/Photoshop' }
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
png: [{ bytes: '89504e470d0a1a0a', minLength: 8, description: 'PNG' }],
|
|
18
|
+
|
|
19
|
+
gif: [
|
|
20
|
+
{ bytes: '474946383761', minLength: 6, description: 'GIF87a' },
|
|
21
|
+
{ bytes: '474946383961', minLength: 6, description: 'GIF89a' }
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
webp: {
|
|
25
|
+
riff: '52494646',
|
|
26
|
+
webp: '57454250',
|
|
27
|
+
minLength: 12,
|
|
28
|
+
description: 'WebP'
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
avif: {
|
|
32
|
+
ftyp: '66747970',
|
|
33
|
+
avif: '61766966',
|
|
34
|
+
minLength: 12,
|
|
35
|
+
description: 'AVIF'
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
heic: [
|
|
39
|
+
{
|
|
40
|
+
ftyp: '66747970',
|
|
41
|
+
brand: '68656963',
|
|
42
|
+
minLength: 12,
|
|
43
|
+
description: 'HEIC'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
ftyp: '66747970',
|
|
47
|
+
brand: '6d696631',
|
|
48
|
+
minLength: 12,
|
|
49
|
+
description: 'HEIF'
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
bmp: [{ bytes: '424d', minLength: 2, description: 'BMP' }],
|
|
54
|
+
|
|
55
|
+
tiff: [
|
|
56
|
+
{ bytes: '49492a00', minLength: 4, description: 'TIFF (Little Endian)' },
|
|
57
|
+
{ bytes: '4d4d002a', minLength: 4, description: 'TIFF (Big Endian)' }
|
|
58
|
+
]
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
const SUPPORTED_MIME_TYPES = {
|
|
62
|
+
'image/jpeg': 'jpeg',
|
|
63
|
+
'image/jpg': 'jpeg',
|
|
64
|
+
'image/png': 'png',
|
|
65
|
+
'image/gif': 'gif',
|
|
66
|
+
'image/webp': 'webp',
|
|
67
|
+
'image/avif': 'avif',
|
|
68
|
+
'image/heic': 'heic',
|
|
69
|
+
'image/heif': 'heic',
|
|
70
|
+
'image/bmp': 'bmp',
|
|
71
|
+
'image/tiff': 'tiff',
|
|
72
|
+
'image/tif': 'tiff'
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
const FORMAT_NAMES = {
|
|
76
|
+
jpeg: 'JPEG',
|
|
77
|
+
png: 'PNG',
|
|
78
|
+
gif: 'GIF',
|
|
79
|
+
webp: 'WebP',
|
|
80
|
+
avif: 'AVIF',
|
|
81
|
+
heic: 'HEIC/HEIF',
|
|
82
|
+
bmp: 'BMP',
|
|
83
|
+
tiff: 'TIFF'
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
export const isValidImage = (file: File): Promise<boolean> => {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
if (!(file.type in SUPPORTED_MIME_TYPES)) {
|
|
89
|
+
resolve(false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const img = new Image();
|
|
94
|
+
img.onload = () => resolve(img.width > 0 && img.height > 0);
|
|
95
|
+
img.onerror = () => resolve(false);
|
|
96
|
+
img.src = URL.createObjectURL(file);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const checkImageSignature = (file: File): Promise<boolean> => {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const formatKey =
|
|
103
|
+
SUPPORTED_MIME_TYPES[file.type as keyof typeof SUPPORTED_MIME_TYPES];
|
|
104
|
+
if (!formatKey) {
|
|
105
|
+
resolve(false);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const reader = new FileReader();
|
|
110
|
+
reader.onloadend = (e) => {
|
|
111
|
+
const result = e.target?.result as ArrayBuffer;
|
|
112
|
+
const arr = new Uint8Array(result);
|
|
113
|
+
const header = Array.from(arr)
|
|
114
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
115
|
+
.join('');
|
|
116
|
+
|
|
117
|
+
let isValid = false;
|
|
118
|
+
|
|
119
|
+
switch (formatKey) {
|
|
120
|
+
case 'jpeg':
|
|
121
|
+
case 'png':
|
|
122
|
+
case 'gif':
|
|
123
|
+
case 'bmp':
|
|
124
|
+
case 'tiff':
|
|
125
|
+
const signatures = IMAGE_SIGNATURES[formatKey];
|
|
126
|
+
isValid = signatures.some((sig) => header.startsWith(sig.bytes));
|
|
127
|
+
break;
|
|
128
|
+
|
|
129
|
+
case 'webp':
|
|
130
|
+
if (header.length >= 24) {
|
|
131
|
+
const hasRiff = header.startsWith(IMAGE_SIGNATURES.webp.riff);
|
|
132
|
+
const hasWebp = header.substr(16, 8) === IMAGE_SIGNATURES.webp.webp;
|
|
133
|
+
isValid = hasRiff && hasWebp;
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'avif':
|
|
138
|
+
if (header.length >= 24) {
|
|
139
|
+
const hasFtyp = header.substr(8, 8) === IMAGE_SIGNATURES.avif.ftyp;
|
|
140
|
+
const hasAvif = header.indexOf(IMAGE_SIGNATURES.avif.avif) !== -1;
|
|
141
|
+
isValid = hasFtyp && hasAvif;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
|
|
145
|
+
case 'heic':
|
|
146
|
+
if (header.length >= 24) {
|
|
147
|
+
const hasFtyp =
|
|
148
|
+
header.substr(8, 8) === IMAGE_SIGNATURES.heic[0].ftyp;
|
|
149
|
+
const hasHeic = IMAGE_SIGNATURES.heic.some(
|
|
150
|
+
(variant) => header.indexOf(variant.brand) !== -1
|
|
151
|
+
);
|
|
152
|
+
isValid = hasFtyp && hasHeic;
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
resolve(isValid);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
reader.onerror = () => resolve(false);
|
|
161
|
+
|
|
162
|
+
const bytesToRead = ['webp', 'avif', 'heic'].includes(formatKey) ? 24 : 8;
|
|
163
|
+
reader.readAsArrayBuffer(file.slice(0, bytesToRead));
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const validateImageMetadata = (dataUrl: string): Promise<boolean> => {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const img = new Image();
|
|
170
|
+
img.onload = () => {
|
|
171
|
+
if (img.width > 10000 || img.height > 10000) {
|
|
172
|
+
resolve(false);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ratio = img.width / img.height;
|
|
177
|
+
if (ratio > 5 || ratio < 0.2) {
|
|
178
|
+
resolve(false);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
resolve(true);
|
|
183
|
+
};
|
|
184
|
+
img.onerror = () => resolve(false);
|
|
185
|
+
img.src = dataUrl;
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const validateImageFile = async (
|
|
190
|
+
file: File,
|
|
191
|
+
maxSizeInMB: number = 5
|
|
192
|
+
): Promise<ImageValidationResult> => {
|
|
193
|
+
const supportedFormats = Object.values(FORMAT_NAMES);
|
|
194
|
+
const supportedTypes = Object.keys(SUPPORTED_MIME_TYPES);
|
|
195
|
+
|
|
196
|
+
if (file.size > maxSizeInMB * 1024 * 1024) {
|
|
197
|
+
return {
|
|
198
|
+
isValid: false,
|
|
199
|
+
error: `File size should be less than ${maxSizeInMB}MB. Supported formats: ${supportedFormats.join(
|
|
200
|
+
', '
|
|
201
|
+
)}`
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!supportedTypes.includes(file.type)) {
|
|
206
|
+
return {
|
|
207
|
+
isValid: false,
|
|
208
|
+
error: `Unsupported file type. Please use: ${supportedFormats.join(', ')}`
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const isImage = await isValidImage(file);
|
|
213
|
+
if (!isImage) {
|
|
214
|
+
return {
|
|
215
|
+
isValid: false,
|
|
216
|
+
error: `The selected file is not a valid image. Supported formats: ${supportedFormats.join(
|
|
217
|
+
', '
|
|
218
|
+
)}`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const validSignature = await checkImageSignature(file);
|
|
223
|
+
if (!validSignature) {
|
|
224
|
+
return {
|
|
225
|
+
isValid: false,
|
|
226
|
+
error: `Invalid image format detected. Supported formats: ${supportedFormats.join(
|
|
227
|
+
', '
|
|
228
|
+
)}`
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { isValid: true };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const validateImageFromDataUrl = async (
|
|
236
|
+
dataUrl: string
|
|
237
|
+
): Promise<ImageValidationResult> => {
|
|
238
|
+
const validMetadata = await validateImageMetadata(dataUrl);
|
|
239
|
+
if (!validMetadata) {
|
|
240
|
+
return {
|
|
241
|
+
isValid: false,
|
|
242
|
+
error: 'Invalid image properties detected (dimensions or aspect ratio)'
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { isValid: true };
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const debugImageSignature = async (
|
|
250
|
+
file: File
|
|
251
|
+
): Promise<{
|
|
252
|
+
mimeType: string;
|
|
253
|
+
format: string;
|
|
254
|
+
expectedSignatures: string[];
|
|
255
|
+
actualHeader: string;
|
|
256
|
+
isValid: boolean;
|
|
257
|
+
}> => {
|
|
258
|
+
const formatKey =
|
|
259
|
+
SUPPORTED_MIME_TYPES[file.type as keyof typeof SUPPORTED_MIME_TYPES];
|
|
260
|
+
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
const reader = new FileReader();
|
|
263
|
+
reader.onloadend = (e) => {
|
|
264
|
+
const result = e.target?.result as ArrayBuffer;
|
|
265
|
+
const arr = new Uint8Array(result);
|
|
266
|
+
const header = Array.from(arr.slice(0, 24))
|
|
267
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
268
|
+
.join('');
|
|
269
|
+
|
|
270
|
+
let expectedSignatures: string[] = [];
|
|
271
|
+
|
|
272
|
+
if (formatKey && formatKey in IMAGE_SIGNATURES) {
|
|
273
|
+
const sigs = IMAGE_SIGNATURES[formatKey];
|
|
274
|
+
if (Array.isArray(sigs)) {
|
|
275
|
+
expectedSignatures = sigs.map((s) => s.bytes);
|
|
276
|
+
} else if ('riff' in sigs) {
|
|
277
|
+
expectedSignatures = [sigs.riff + 'XXXXXXXX' + sigs.webp];
|
|
278
|
+
} else if ('ftyp' in sigs) {
|
|
279
|
+
expectedSignatures = [sigs.ftyp];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
resolve({
|
|
284
|
+
mimeType: file.type,
|
|
285
|
+
format: formatKey || 'unknown',
|
|
286
|
+
expectedSignatures,
|
|
287
|
+
actualHeader: header,
|
|
288
|
+
isValid: formatKey ? true : false
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
reader.onerror = () =>
|
|
293
|
+
resolve({
|
|
294
|
+
mimeType: file.type,
|
|
295
|
+
format: 'error',
|
|
296
|
+
expectedSignatures: [],
|
|
297
|
+
actualHeader: '',
|
|
298
|
+
isValid: false
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
reader.readAsArrayBuffer(file.slice(0, 24));
|
|
302
|
+
});
|
|
303
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { SimilarProductsSettings } from '../types';
|
|
2
|
+
|
|
3
|
+
export const defaultSettings: Required<SimilarProductsSettings> = {
|
|
4
|
+
maxFileSize: 5,
|
|
5
|
+
imageFormats: ['jpg', 'jpeg', 'png', 'webp'],
|
|
6
|
+
cropAspectRatio: undefined,
|
|
7
|
+
resultsPerPage: 20,
|
|
8
|
+
enableCropping: true,
|
|
9
|
+
enableFileUpload: true,
|
|
10
|
+
customStyles: {
|
|
11
|
+
modal: '',
|
|
12
|
+
filterSidebar: '',
|
|
13
|
+
resultsGrid: '',
|
|
14
|
+
imageSearchModal: '',
|
|
15
|
+
productItem: '',
|
|
16
|
+
pagination: '',
|
|
17
|
+
filterGroup: '',
|
|
18
|
+
imageSection: ''
|
|
19
|
+
},
|
|
20
|
+
customRenderers: {
|
|
21
|
+
render: {}
|
|
22
|
+
},
|
|
23
|
+
theme: {},
|
|
24
|
+
cssVariables: {}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function mergeSettings(
|
|
28
|
+
userSettings?: Partial<SimilarProductsSettings>
|
|
29
|
+
): Required<SimilarProductsSettings> {
|
|
30
|
+
if (!userSettings) {
|
|
31
|
+
return defaultSettings;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
...defaultSettings,
|
|
36
|
+
...userSettings,
|
|
37
|
+
customStyles: {
|
|
38
|
+
...defaultSettings.customStyles,
|
|
39
|
+
...userSettings.customStyles
|
|
40
|
+
},
|
|
41
|
+
customRenderers: {
|
|
42
|
+
...defaultSettings.customRenderers,
|
|
43
|
+
...userSettings.customRenderers,
|
|
44
|
+
render: {
|
|
45
|
+
...defaultSettings.customRenderers.render,
|
|
46
|
+
...userSettings.customRenderers?.render
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
theme: {
|
|
50
|
+
...defaultSettings.theme,
|
|
51
|
+
...userSettings.theme
|
|
52
|
+
},
|
|
53
|
+
cssVariables: {
|
|
54
|
+
...defaultSettings.cssVariables,
|
|
55
|
+
...userSettings.cssVariables
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateImageFile(
|
|
61
|
+
file: File,
|
|
62
|
+
settings: Required<SimilarProductsSettings>
|
|
63
|
+
): { isValid: boolean; error?: string } {
|
|
64
|
+
const maxSizeBytes = settings.maxFileSize * 1024 * 1024;
|
|
65
|
+
if (file.size > maxSizeBytes) {
|
|
66
|
+
return {
|
|
67
|
+
isValid: false,
|
|
68
|
+
error: `File size exceeds ${settings.maxFileSize}MB limit`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
|
73
|
+
if (!fileExtension || !settings.imageFormats.includes(fileExtension)) {
|
|
74
|
+
return {
|
|
75
|
+
isValid: false,
|
|
76
|
+
error: `File format not supported. Allowed formats: ${settings.imageFormats.join(
|
|
77
|
+
', '
|
|
78
|
+
)}`
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const validMimeTypes = settings.imageFormats.map(
|
|
83
|
+
(format) => `image/${format === 'jpg' ? 'jpeg' : format}`
|
|
84
|
+
);
|
|
85
|
+
if (!validMimeTypes.includes(file.type)) {
|
|
86
|
+
return {
|
|
87
|
+
isValid: false,
|
|
88
|
+
error: 'Invalid file type'
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { isValid: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function debounce<T extends (...args: any[]) => any>(
|
|
96
|
+
func: T,
|
|
97
|
+
wait: number
|
|
98
|
+
): (...args: Parameters<T>) => void {
|
|
99
|
+
let timeout: NodeJS.Timeout;
|
|
100
|
+
|
|
101
|
+
return (...args: Parameters<T>) => {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
timeout = setTimeout(() => func(...args), wait);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function dataURLToBlob(dataURL: string): Blob {
|
|
108
|
+
const arr = dataURL.split(',');
|
|
109
|
+
const mime = arr[0].match(/:(.*?);/)?.[1];
|
|
110
|
+
const bstr = atob(arr[1]);
|
|
111
|
+
let n = bstr.length;
|
|
112
|
+
const u8arr = new Uint8Array(n);
|
|
113
|
+
|
|
114
|
+
while (n--) {
|
|
115
|
+
u8arr[n] = bstr.charCodeAt(n);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new Blob([u8arr], { type: mime });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatFileSize(bytes: number): string {
|
|
122
|
+
if (bytes === 0) return '0 Bytes';
|
|
123
|
+
|
|
124
|
+
const k = 1024;
|
|
125
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
126
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
127
|
+
|
|
128
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function supportsFileAPI(): boolean {
|
|
132
|
+
return !!(window.File && window.FileReader && window.FileList && window.Blob);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getDevicePixelRatio(): number {
|
|
136
|
+
return window.devicePixelRatio || 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function calculateOptimalDimensions(
|
|
140
|
+
originalWidth: number,
|
|
141
|
+
originalHeight: number,
|
|
142
|
+
maxWidth: number,
|
|
143
|
+
maxHeight: number
|
|
144
|
+
): { width: number; height: number } {
|
|
145
|
+
const aspectRatio = originalWidth / originalHeight;
|
|
146
|
+
|
|
147
|
+
let width = originalWidth;
|
|
148
|
+
let height = originalHeight;
|
|
149
|
+
|
|
150
|
+
if (width > maxWidth) {
|
|
151
|
+
width = maxWidth;
|
|
152
|
+
height = width / aspectRatio;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (height > maxHeight) {
|
|
156
|
+
height = maxHeight;
|
|
157
|
+
width = height * aspectRatio;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
161
|
+
}
|