@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,939 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { Product } from '@akinon/next/types';
|
|
3
|
+
import {
|
|
4
|
+
useLazyGetSimilarProductsByUrlQuery,
|
|
5
|
+
useGetSimilarProductsByImageMutation,
|
|
6
|
+
useLazyGetSimilarProductsListQuery,
|
|
7
|
+
type SimilarProductsListResponse
|
|
8
|
+
} from '../data/endpoints';
|
|
9
|
+
import { type Facet, type SortOption } from '@akinon/next/types/commerce';
|
|
10
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
11
|
+
import {
|
|
12
|
+
validateImageFile,
|
|
13
|
+
validateImageFromDataUrl,
|
|
14
|
+
type ImageValidationResult
|
|
15
|
+
} from '../utils/image-validation';
|
|
16
|
+
|
|
17
|
+
type SearchResults = SimilarProductsListResponse;
|
|
18
|
+
|
|
19
|
+
interface SearchParams {
|
|
20
|
+
[key: string]: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useSimilarProducts(product: Product) {
|
|
24
|
+
const { t } = useLocalization();
|
|
25
|
+
const [currentImageUrl, setCurrentImageUrl] = useState('');
|
|
26
|
+
const [fileError, setFileError] = useState('');
|
|
27
|
+
const [searchResults, setSearchResults] = useState<SearchResults | null>(
|
|
28
|
+
null
|
|
29
|
+
);
|
|
30
|
+
const [resultsKey, setResultsKey] = useState(0);
|
|
31
|
+
const [lastProductIds, setLastProductIds] = useState<number[]>([]);
|
|
32
|
+
const [hasUploadedImage, setHasUploadedImage] = useState(false);
|
|
33
|
+
const [isCropProcessing, setIsCropProcessing] = useState(false);
|
|
34
|
+
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]));
|
|
35
|
+
const [allLoadedProducts, setAllLoadedProducts] = useState<Product[]>([]);
|
|
36
|
+
|
|
37
|
+
const [fetchSimilarProductsByUrl, { isLoading: isUrlSearchLoading }] =
|
|
38
|
+
useLazyGetSimilarProductsByUrlQuery();
|
|
39
|
+
|
|
40
|
+
const [getSimilarProductsByImage, { isLoading: isImageSearchLoading }] =
|
|
41
|
+
useGetSimilarProductsByImageMutation();
|
|
42
|
+
|
|
43
|
+
const [fetchProductsList, { isLoading: isProductsListLoading }] =
|
|
44
|
+
useLazyGetSimilarProductsListQuery();
|
|
45
|
+
|
|
46
|
+
const isLoading =
|
|
47
|
+
isUrlSearchLoading ||
|
|
48
|
+
isImageSearchLoading ||
|
|
49
|
+
isProductsListLoading ||
|
|
50
|
+
isCropProcessing;
|
|
51
|
+
|
|
52
|
+
const createEmptySearchResults = useCallback(
|
|
53
|
+
(): SearchResults => ({
|
|
54
|
+
products: [],
|
|
55
|
+
facets: [],
|
|
56
|
+
sorters: [],
|
|
57
|
+
search_text: null,
|
|
58
|
+
pagination: {
|
|
59
|
+
current_page: 1,
|
|
60
|
+
num_pages: 1,
|
|
61
|
+
page_size: 1,
|
|
62
|
+
total_count: 0
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
[]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const updateResultsAndKey = useCallback(
|
|
69
|
+
(results: SearchResults, isLoadMore: boolean = false) => {
|
|
70
|
+
if (isLoadMore) {
|
|
71
|
+
setAllLoadedProducts((prev) => [...prev, ...results.products]);
|
|
72
|
+
setSearchResults((prevResults) => ({
|
|
73
|
+
...results,
|
|
74
|
+
products: [...(prevResults?.products || []), ...results.products]
|
|
75
|
+
}));
|
|
76
|
+
} else {
|
|
77
|
+
setAllLoadedProducts(results.products);
|
|
78
|
+
setSearchResults(results);
|
|
79
|
+
setLoadedPages(new Set([1]));
|
|
80
|
+
}
|
|
81
|
+
setResultsKey((prevKey) => prevKey + 1);
|
|
82
|
+
},
|
|
83
|
+
[]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const buildSearchParams = useCallback(
|
|
87
|
+
(facets?: Facet[], sortValue?: string, page?: number): SearchParams => {
|
|
88
|
+
const searchParams: SearchParams = {};
|
|
89
|
+
|
|
90
|
+
if (page) {
|
|
91
|
+
searchParams.page = String(page);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Test için farklı parametre isimlerini deniyoruz
|
|
95
|
+
searchParams.page_size = '1';
|
|
96
|
+
searchParams.pageSize = '1';
|
|
97
|
+
searchParams.limit = '1';
|
|
98
|
+
searchParams.per_page = '1';
|
|
99
|
+
searchParams.size = '1';
|
|
100
|
+
console.log('🔍 API çağrısı searchParams:', searchParams);
|
|
101
|
+
|
|
102
|
+
if (facets) {
|
|
103
|
+
facets.forEach((facet) => {
|
|
104
|
+
if (String(facet.key) === 'category_ids') return;
|
|
105
|
+
|
|
106
|
+
const selectedChoices =
|
|
107
|
+
facet.data?.choices?.filter((choice) => choice.is_selected) || [];
|
|
108
|
+
|
|
109
|
+
if (selectedChoices.length > 0) {
|
|
110
|
+
const values = selectedChoices.map((choice) =>
|
|
111
|
+
String(choice.value)
|
|
112
|
+
);
|
|
113
|
+
const searchKey = String(facet.search_key || facet.key);
|
|
114
|
+
searchParams[searchKey] = values.join(',');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (sortValue) {
|
|
120
|
+
searchParams.sorter = sortValue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return searchParams;
|
|
124
|
+
},
|
|
125
|
+
[]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const preserveFacetState = useCallback(
|
|
129
|
+
(responseFacets: Facet[], existingFacets: Facet[]) => {
|
|
130
|
+
return (
|
|
131
|
+
responseFacets?.map((responseFacet) => {
|
|
132
|
+
const existingFacet = existingFacets?.find(
|
|
133
|
+
(f) => String(f.key) === String(responseFacet.key)
|
|
134
|
+
);
|
|
135
|
+
if (existingFacet && responseFacet.data?.choices) {
|
|
136
|
+
return {
|
|
137
|
+
...responseFacet,
|
|
138
|
+
data: {
|
|
139
|
+
...responseFacet.data,
|
|
140
|
+
choices: responseFacet.data.choices.map((choice) => {
|
|
141
|
+
const existingChoice = existingFacet.data?.choices?.find(
|
|
142
|
+
(c) => String(c.value) === String(choice.value)
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
...choice,
|
|
146
|
+
is_selected: existingChoice?.is_selected || false
|
|
147
|
+
};
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return responseFacet;
|
|
153
|
+
}) ||
|
|
154
|
+
existingFacets ||
|
|
155
|
+
[]
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
[]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const updateSorterState = useCallback(
|
|
162
|
+
(sorters: SortOption[], selectedValue: string) => {
|
|
163
|
+
return (
|
|
164
|
+
sorters?.map((sorter) => ({
|
|
165
|
+
...sorter,
|
|
166
|
+
is_selected: String(sorter.value) === String(selectedValue)
|
|
167
|
+
})) || []
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
[]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const sortProducts = useCallback(
|
|
174
|
+
(products: SearchResults['products'], sortValue: string) => {
|
|
175
|
+
if (!products || products.length === 0) return products;
|
|
176
|
+
|
|
177
|
+
const sortedProducts = [...products];
|
|
178
|
+
|
|
179
|
+
switch (sortValue) {
|
|
180
|
+
case 'price':
|
|
181
|
+
return sortedProducts.sort((a, b) => {
|
|
182
|
+
const priceA = parseFloat(String(a.price || '0'));
|
|
183
|
+
const priceB = parseFloat(String(b.price || '0'));
|
|
184
|
+
return isNaN(priceA) || isNaN(priceB) ? 0 : priceA - priceB;
|
|
185
|
+
});
|
|
186
|
+
case '-price':
|
|
187
|
+
return sortedProducts.sort((a, b) => {
|
|
188
|
+
const priceA = parseFloat(String(a.price || '0'));
|
|
189
|
+
const priceB = parseFloat(String(b.price || '0'));
|
|
190
|
+
return isNaN(priceA) || isNaN(priceB) ? 0 : priceB - priceA;
|
|
191
|
+
});
|
|
192
|
+
case 'newcomers':
|
|
193
|
+
return sortedProducts.sort((a, b) => {
|
|
194
|
+
const pkA = parseInt(String(a.pk || '0'), 10);
|
|
195
|
+
const pkB = parseInt(String(b.pk || '0'), 10);
|
|
196
|
+
return isNaN(pkA) || isNaN(pkB) ? 0 : pkB - pkA;
|
|
197
|
+
});
|
|
198
|
+
case 'default':
|
|
199
|
+
default:
|
|
200
|
+
return products;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
[]
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const createSearchFilter = useCallback(
|
|
207
|
+
(filterData: number[] | Record<string, unknown> | unknown) => {
|
|
208
|
+
let filterObject: Record<string, unknown>;
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(filterData)) {
|
|
211
|
+
filterObject = {
|
|
212
|
+
'products.pk': filterData,
|
|
213
|
+
default_sorting_deactivated: true
|
|
214
|
+
};
|
|
215
|
+
} else if (typeof filterData === 'object' && filterData !== null) {
|
|
216
|
+
filterObject = {
|
|
217
|
+
...(filterData as Record<string, unknown>),
|
|
218
|
+
default_sorting_deactivated: true
|
|
219
|
+
};
|
|
220
|
+
} else {
|
|
221
|
+
filterObject = {
|
|
222
|
+
default_sorting_deactivated: true
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const jsonString = JSON.stringify(filterObject);
|
|
227
|
+
return btoa(jsonString);
|
|
228
|
+
},
|
|
229
|
+
[]
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const executeProductSearch = useCallback(
|
|
233
|
+
async (
|
|
234
|
+
productIds: number[],
|
|
235
|
+
searchParams: SearchParams = {},
|
|
236
|
+
currentSortValue?: string
|
|
237
|
+
): Promise<SearchResults | null> => {
|
|
238
|
+
try {
|
|
239
|
+
const base64Filter = createSearchFilter(productIds);
|
|
240
|
+
const result = await fetchProductsList({
|
|
241
|
+
filter: base64Filter,
|
|
242
|
+
searchParams,
|
|
243
|
+
isExclude: false
|
|
244
|
+
}).unwrap();
|
|
245
|
+
|
|
246
|
+
const sortedProducts = currentSortValue
|
|
247
|
+
? sortProducts(result.products, currentSortValue)
|
|
248
|
+
: result.products;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
...result,
|
|
252
|
+
products: sortedProducts,
|
|
253
|
+
facets: preserveFacetState(
|
|
254
|
+
result.facets,
|
|
255
|
+
searchResults?.facets || []
|
|
256
|
+
),
|
|
257
|
+
sorters: updateSorterState(
|
|
258
|
+
result.sorters,
|
|
259
|
+
currentSortValue ||
|
|
260
|
+
searchResults?.sorters?.find((s) => s.is_selected)?.value ||
|
|
261
|
+
'default'
|
|
262
|
+
)
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('Product search failed:', error);
|
|
266
|
+
return createEmptySearchResults();
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
[
|
|
270
|
+
createSearchFilter,
|
|
271
|
+
fetchProductsList,
|
|
272
|
+
sortProducts,
|
|
273
|
+
preserveFacetState,
|
|
274
|
+
updateSorterState,
|
|
275
|
+
searchResults,
|
|
276
|
+
createEmptySearchResults
|
|
277
|
+
]
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const extractProductIds = useCallback((data: unknown): number[] => {
|
|
281
|
+
if (!data) return [];
|
|
282
|
+
|
|
283
|
+
if (data && typeof data === 'object') {
|
|
284
|
+
const obj = data as Record<string, unknown>;
|
|
285
|
+
|
|
286
|
+
const possibleArrayKeys = [
|
|
287
|
+
'product_ids',
|
|
288
|
+
'productIds',
|
|
289
|
+
'products',
|
|
290
|
+
'results'
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
for (const key of possibleArrayKeys) {
|
|
294
|
+
if (obj[key] && Array.isArray(obj[key])) {
|
|
295
|
+
const arr = obj[key] as unknown[];
|
|
296
|
+
const filtered = arr
|
|
297
|
+
.map((item) => {
|
|
298
|
+
if (typeof item === 'number') return item;
|
|
299
|
+
if (typeof item === 'string') {
|
|
300
|
+
const parsed = parseInt(item, 10);
|
|
301
|
+
return isNaN(parsed) ? null : parsed;
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
})
|
|
305
|
+
.filter((id): id is number => id !== null && id > 0);
|
|
306
|
+
|
|
307
|
+
if (filtered.length > 0) return filtered;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (obj.similar_products && Array.isArray(obj.similar_products)) {
|
|
312
|
+
return obj.similar_products
|
|
313
|
+
.map((item) => {
|
|
314
|
+
if (item && typeof item === 'object' && 'product_id' in item) {
|
|
315
|
+
const productId = (item as any).product_id;
|
|
316
|
+
if (typeof productId === 'number') return productId;
|
|
317
|
+
if (typeof productId === 'string') {
|
|
318
|
+
const parsed = parseInt(productId, 10);
|
|
319
|
+
return isNaN(parsed) ? null : parsed;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
})
|
|
324
|
+
.filter((id): id is number => id !== null && id > 0);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (Array.isArray(data)) {
|
|
329
|
+
return data
|
|
330
|
+
.map((item) => {
|
|
331
|
+
if (typeof item === 'number') return item;
|
|
332
|
+
if (typeof item === 'string') {
|
|
333
|
+
const parsed = parseInt(item, 10);
|
|
334
|
+
return isNaN(parsed) ? null : parsed;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
})
|
|
338
|
+
.filter((id): id is number => id !== null && id > 0);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return [];
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
const handleSearchResults = useCallback(
|
|
345
|
+
async (data: unknown) => {
|
|
346
|
+
const productIds = extractProductIds(data);
|
|
347
|
+
|
|
348
|
+
if (productIds.length > 0) {
|
|
349
|
+
const result = await executeProductSearch(productIds);
|
|
350
|
+
if (result) {
|
|
351
|
+
const resultWithCorrectSorterState = {
|
|
352
|
+
...result,
|
|
353
|
+
sorters: updateSorterState(result.sorters, 'default')
|
|
354
|
+
};
|
|
355
|
+
updateResultsAndKey(resultWithCorrectSorterState);
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
updateResultsAndKey(createEmptySearchResults());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
setLastProductIds(productIds);
|
|
362
|
+
},
|
|
363
|
+
[
|
|
364
|
+
extractProductIds,
|
|
365
|
+
executeProductSearch,
|
|
366
|
+
updateSorterState,
|
|
367
|
+
updateResultsAndKey,
|
|
368
|
+
createEmptySearchResults
|
|
369
|
+
]
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const validateImageFileWithTranslation = useCallback(
|
|
373
|
+
async (file: File): Promise<ImageValidationResult> => {
|
|
374
|
+
try {
|
|
375
|
+
const result = await validateImageFile(file, 5);
|
|
376
|
+
|
|
377
|
+
if (!result.isValid) {
|
|
378
|
+
if (result.error?.includes('size')) {
|
|
379
|
+
return {
|
|
380
|
+
isValid: false,
|
|
381
|
+
error: t('common.similar_products.errors.file_size_too_large')
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (result.error?.includes('not a valid image')) {
|
|
385
|
+
return {
|
|
386
|
+
isValid: false,
|
|
387
|
+
error: t('common.similar_products.errors.invalid_image_file')
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (result.error?.includes('format')) {
|
|
391
|
+
return {
|
|
392
|
+
isValid: false,
|
|
393
|
+
error: t('common.similar_products.errors.invalid_image_format')
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return result;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return {
|
|
401
|
+
isValid: false,
|
|
402
|
+
error: t('common.similar_products.errors.processing_error')
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
[t]
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const handleImageSearch = useCallback(
|
|
410
|
+
async (
|
|
411
|
+
searchFn: (image: string) => Promise<unknown>,
|
|
412
|
+
imageData: string,
|
|
413
|
+
errorPrefix: string
|
|
414
|
+
) => {
|
|
415
|
+
setFileError('');
|
|
416
|
+
try {
|
|
417
|
+
const result = await searchFn(imageData);
|
|
418
|
+
await handleSearchResults(result);
|
|
419
|
+
return result;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
console.error(`${errorPrefix} failed:`, error);
|
|
422
|
+
setFileError(t('common.similar_products.errors.search_failed'));
|
|
423
|
+
updateResultsAndKey(createEmptySearchResults());
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
[
|
|
428
|
+
handleSearchResults,
|
|
429
|
+
updateResultsAndKey,
|
|
430
|
+
createEmptySearchResults,
|
|
431
|
+
t,
|
|
432
|
+
setFileError
|
|
433
|
+
]
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const fetchSimilarProductsByImageUrl = useCallback(
|
|
437
|
+
async (imageUrl: string) => {
|
|
438
|
+
setFileError('');
|
|
439
|
+
try {
|
|
440
|
+
const productPk = product?.pk;
|
|
441
|
+
const excludedIds = productPk ? [productPk] : undefined;
|
|
442
|
+
|
|
443
|
+
const result = await fetchSimilarProductsByUrl({
|
|
444
|
+
url: imageUrl,
|
|
445
|
+
limit: 20,
|
|
446
|
+
excluded_product_ids: excludedIds
|
|
447
|
+
}).unwrap();
|
|
448
|
+
|
|
449
|
+
await handleSearchResults(result);
|
|
450
|
+
return result;
|
|
451
|
+
} catch (error) {
|
|
452
|
+
console.error('Image search request failed:', error);
|
|
453
|
+
setFileError(t('common.similar_products.errors.connection_failed'));
|
|
454
|
+
updateResultsAndKey(createEmptySearchResults());
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
[
|
|
459
|
+
fetchSimilarProductsByUrl,
|
|
460
|
+
product?.pk,
|
|
461
|
+
handleSearchResults,
|
|
462
|
+
updateResultsAndKey,
|
|
463
|
+
createEmptySearchResults,
|
|
464
|
+
t,
|
|
465
|
+
setFileError
|
|
466
|
+
]
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
if (product?.productimage_set?.length > 0) {
|
|
471
|
+
const initialImageUrl = product.productimage_set[0].image;
|
|
472
|
+
setCurrentImageUrl(initialImageUrl);
|
|
473
|
+
}
|
|
474
|
+
}, [product]);
|
|
475
|
+
|
|
476
|
+
const fetchSimilarProductsByBase64 = useCallback(
|
|
477
|
+
async (base64Image: string) => {
|
|
478
|
+
const base64Data = base64Image.startsWith('data:')
|
|
479
|
+
? base64Image.split(',')[1]
|
|
480
|
+
: base64Image;
|
|
481
|
+
|
|
482
|
+
return handleImageSearch(
|
|
483
|
+
async () =>
|
|
484
|
+
getSimilarProductsByImage({
|
|
485
|
+
image: base64Data,
|
|
486
|
+
limit: 20
|
|
487
|
+
}).unwrap(),
|
|
488
|
+
base64Image,
|
|
489
|
+
'Image search'
|
|
490
|
+
);
|
|
491
|
+
},
|
|
492
|
+
[getSimilarProductsByImage, handleImageSearch]
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const fetchSimilarProductsByImageCrop = useCallback(
|
|
496
|
+
async (dataString: string) => {
|
|
497
|
+
setFileError('');
|
|
498
|
+
if (dataString.startsWith('data:application/x-cors-fallback;base64,')) {
|
|
499
|
+
try {
|
|
500
|
+
const fallbackDataEncoded = dataString.replace(
|
|
501
|
+
'data:application/x-cors-fallback;base64,',
|
|
502
|
+
''
|
|
503
|
+
);
|
|
504
|
+
const fallbackData = JSON.parse(atob(fallbackDataEncoded));
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const proxyResponse = await fetch('/api/image-proxy', {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
headers: { 'Content-Type': 'application/json' },
|
|
510
|
+
body: JSON.stringify({ imageUrl: fallbackData.originalUrl })
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (proxyResponse.ok) {
|
|
514
|
+
const proxyResult = await proxyResponse.json();
|
|
515
|
+
if (proxyResult.success && proxyResult.base64Image) {
|
|
516
|
+
return new Promise((resolve) => {
|
|
517
|
+
const img = new Image();
|
|
518
|
+
img.onload = () => {
|
|
519
|
+
try {
|
|
520
|
+
const canvas = document.createElement('canvas');
|
|
521
|
+
const ctx = canvas.getContext('2d');
|
|
522
|
+
|
|
523
|
+
if (!ctx) {
|
|
524
|
+
throw new Error('No canvas context');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const cropX = fallbackData.crop.x * fallbackData.scale.x;
|
|
528
|
+
const cropY = fallbackData.crop.y * fallbackData.scale.y;
|
|
529
|
+
const cropWidth =
|
|
530
|
+
fallbackData.crop.width * fallbackData.scale.x;
|
|
531
|
+
const cropHeight =
|
|
532
|
+
fallbackData.crop.height * fallbackData.scale.y;
|
|
533
|
+
|
|
534
|
+
canvas.width = cropWidth;
|
|
535
|
+
canvas.height = cropHeight;
|
|
536
|
+
|
|
537
|
+
ctx.drawImage(
|
|
538
|
+
img,
|
|
539
|
+
cropX,
|
|
540
|
+
cropY,
|
|
541
|
+
cropWidth,
|
|
542
|
+
cropHeight,
|
|
543
|
+
0,
|
|
544
|
+
0,
|
|
545
|
+
cropWidth,
|
|
546
|
+
cropHeight
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const croppedBase64 = canvas.toDataURL('image/jpeg');
|
|
550
|
+
|
|
551
|
+
const base64Data = croppedBase64.startsWith('data:')
|
|
552
|
+
? croppedBase64.split(',')[1]
|
|
553
|
+
: croppedBase64;
|
|
554
|
+
|
|
555
|
+
const result = handleImageSearch(
|
|
556
|
+
async () =>
|
|
557
|
+
getSimilarProductsByImage({
|
|
558
|
+
image: base64Data,
|
|
559
|
+
limit: 20,
|
|
560
|
+
excluded_product_ids: product?.pk
|
|
561
|
+
? [product.pk]
|
|
562
|
+
: undefined
|
|
563
|
+
}).unwrap(),
|
|
564
|
+
croppedBase64,
|
|
565
|
+
'Proxy crop search'
|
|
566
|
+
);
|
|
567
|
+
resolve(result);
|
|
568
|
+
} catch (cropError) {
|
|
569
|
+
console.error('Client-side crop failed:', cropError);
|
|
570
|
+
const result = fetchSimilarProductsByImageUrl(
|
|
571
|
+
fallbackData.originalUrl
|
|
572
|
+
);
|
|
573
|
+
resolve(result);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
img.onerror = () => {
|
|
578
|
+
console.error('Failed to load proxy image');
|
|
579
|
+
const result = fetchSimilarProductsByImageUrl(
|
|
580
|
+
fallbackData.originalUrl
|
|
581
|
+
);
|
|
582
|
+
resolve(result);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
img.src = proxyResult.base64Image;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} catch (proxyError) {
|
|
590
|
+
console.warn('Proxy method failed:', proxyError);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return fetchSimilarProductsByImageUrl(fallbackData.originalUrl);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
console.error('Failed to parse CORS fallback data:', error);
|
|
596
|
+
setFileError(
|
|
597
|
+
t('common.similar_products.errors.crop_processing_error')
|
|
598
|
+
);
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const base64Data = dataString.startsWith('data:')
|
|
604
|
+
? dataString.split(',')[1]
|
|
605
|
+
: dataString;
|
|
606
|
+
|
|
607
|
+
return handleImageSearch(
|
|
608
|
+
async () =>
|
|
609
|
+
getSimilarProductsByImage({
|
|
610
|
+
image: base64Data,
|
|
611
|
+
limit: 20,
|
|
612
|
+
excluded_product_ids: product?.pk ? [product.pk] : undefined
|
|
613
|
+
}).unwrap(),
|
|
614
|
+
dataString,
|
|
615
|
+
'Image crop search'
|
|
616
|
+
);
|
|
617
|
+
},
|
|
618
|
+
[
|
|
619
|
+
getSimilarProductsByImage,
|
|
620
|
+
handleImageSearch,
|
|
621
|
+
product,
|
|
622
|
+
fetchSimilarProductsByImageUrl,
|
|
623
|
+
setFileError,
|
|
624
|
+
t
|
|
625
|
+
]
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const handleFileUpload = async (
|
|
629
|
+
event: React.ChangeEvent<HTMLInputElement>
|
|
630
|
+
) => {
|
|
631
|
+
setFileError('');
|
|
632
|
+
const file = event.target.files?.[0];
|
|
633
|
+
if (!file) return;
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
const validation = await validateImageFileWithTranslation(file);
|
|
637
|
+
if (!validation.isValid) {
|
|
638
|
+
setFileError(validation.error!);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const reader = new FileReader();
|
|
643
|
+
reader.onload = async (e) => {
|
|
644
|
+
const dataUrl = e.target?.result as string;
|
|
645
|
+
setCurrentImageUrl(dataUrl);
|
|
646
|
+
setHasUploadedImage(true);
|
|
647
|
+
|
|
648
|
+
const metadataValidation = await validateImageFromDataUrl(dataUrl);
|
|
649
|
+
if (!metadataValidation.isValid) {
|
|
650
|
+
setFileError(
|
|
651
|
+
t('common.similar_products.errors.invalid_image_properties')
|
|
652
|
+
);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
fetchSimilarProductsByBase64(dataUrl);
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
reader.onerror = () =>
|
|
659
|
+
setFileError(t('common.similar_products.errors.file_read_error'));
|
|
660
|
+
reader.readAsDataURL(file);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
setFileError(t('common.similar_products.errors.processing_error'));
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const handleImageCrop = async (base64Image: string) => {
|
|
667
|
+
if (isCropProcessing || isImageSearchLoading || isProductsListLoading) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
setIsCropProcessing(true);
|
|
672
|
+
setFileError('');
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
setCurrentImageUrl(base64Image);
|
|
676
|
+
|
|
677
|
+
const metadataValidation = await validateImageFromDataUrl(base64Image);
|
|
678
|
+
if (!metadataValidation.isValid) {
|
|
679
|
+
setFileError(
|
|
680
|
+
t('common.similar_products.errors.invalid_image_properties')
|
|
681
|
+
);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (hasUploadedImage) {
|
|
686
|
+
await fetchSimilarProductsByBase64(base64Image);
|
|
687
|
+
} else {
|
|
688
|
+
await fetchSimilarProductsByImageCrop(base64Image);
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
setFileError(t('common.similar_products.errors.crop_processing_error'));
|
|
692
|
+
} finally {
|
|
693
|
+
setIsCropProcessing(false);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const handleSortChange = async (sortValue: string) => {
|
|
698
|
+
if (lastProductIds.length === 0) return;
|
|
699
|
+
|
|
700
|
+
if (searchResults?.sorters) {
|
|
701
|
+
const updatedSearchResults = {
|
|
702
|
+
...searchResults,
|
|
703
|
+
sorters: updateSorterState(searchResults.sorters, sortValue)
|
|
704
|
+
};
|
|
705
|
+
updateResultsAndKey(updatedSearchResults);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const searchParams = buildSearchParams(searchResults?.facets, sortValue);
|
|
709
|
+
const result = await executeProductSearch(
|
|
710
|
+
lastProductIds,
|
|
711
|
+
searchParams,
|
|
712
|
+
sortValue
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
if (result) {
|
|
716
|
+
updateResultsAndKey(result);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const handlePageChange = async (page: number, sortValue?: string) => {
|
|
721
|
+
if (lastProductIds.length === 0) return;
|
|
722
|
+
|
|
723
|
+
const currentSort =
|
|
724
|
+
sortValue || searchResults?.sorters?.find((s) => s.is_selected)?.value;
|
|
725
|
+
const searchParams = buildSearchParams(
|
|
726
|
+
searchResults?.facets,
|
|
727
|
+
currentSort,
|
|
728
|
+
page
|
|
729
|
+
);
|
|
730
|
+
const result = await executeProductSearch(
|
|
731
|
+
lastProductIds,
|
|
732
|
+
searchParams,
|
|
733
|
+
currentSort
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
if (result) {
|
|
737
|
+
updateResultsAndKey(result);
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const updateFacetSelection = useCallback(
|
|
742
|
+
(facetKey: string, choiceValue: string | number, isSelected: boolean) => {
|
|
743
|
+
if (!searchResults || String(facetKey) === 'category_ids') return null;
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
...searchResults,
|
|
747
|
+
facets:
|
|
748
|
+
searchResults.facets?.map((facet) => {
|
|
749
|
+
if (String(facet.key) === String(facetKey)) {
|
|
750
|
+
return {
|
|
751
|
+
...facet,
|
|
752
|
+
data: {
|
|
753
|
+
...facet.data,
|
|
754
|
+
choices:
|
|
755
|
+
facet.data?.choices?.map((choice) => {
|
|
756
|
+
if (String(choice.value) === String(choiceValue)) {
|
|
757
|
+
return { ...choice, is_selected: isSelected };
|
|
758
|
+
}
|
|
759
|
+
return choice;
|
|
760
|
+
}) || []
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
return facet;
|
|
765
|
+
}) || []
|
|
766
|
+
};
|
|
767
|
+
},
|
|
768
|
+
[searchResults]
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
const handleFacetChange = async (
|
|
772
|
+
facetKey: string,
|
|
773
|
+
choiceValue: string | number
|
|
774
|
+
) => {
|
|
775
|
+
if (
|
|
776
|
+
!searchResults ||
|
|
777
|
+
lastProductIds.length === 0 ||
|
|
778
|
+
String(facetKey) === 'category_ids'
|
|
779
|
+
)
|
|
780
|
+
return;
|
|
781
|
+
|
|
782
|
+
const targetFacet = searchResults.facets?.find(
|
|
783
|
+
(f) => String(f.key) === String(facetKey)
|
|
784
|
+
);
|
|
785
|
+
const targetChoice = targetFacet?.data?.choices?.find(
|
|
786
|
+
(c) => String(c.value) === String(choiceValue)
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
const updatedSearchResults = updateFacetSelection(
|
|
790
|
+
facetKey,
|
|
791
|
+
choiceValue,
|
|
792
|
+
!(targetChoice?.is_selected || false)
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
if (!updatedSearchResults) return;
|
|
796
|
+
|
|
797
|
+
const resultsWithResetSorter = {
|
|
798
|
+
...updatedSearchResults,
|
|
799
|
+
sorters:
|
|
800
|
+
updatedSearchResults.sorters?.map((sorter) => ({
|
|
801
|
+
...sorter,
|
|
802
|
+
is_selected: sorter.value === 'default'
|
|
803
|
+
})) || []
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
updateResultsAndKey(resultsWithResetSorter);
|
|
807
|
+
|
|
808
|
+
const searchParams = buildSearchParams(resultsWithResetSorter.facets);
|
|
809
|
+
const result = await executeProductSearch(lastProductIds, searchParams);
|
|
810
|
+
|
|
811
|
+
if (result) {
|
|
812
|
+
const finalResult = {
|
|
813
|
+
...result,
|
|
814
|
+
facets: preserveFacetState(
|
|
815
|
+
result.facets,
|
|
816
|
+
resultsWithResetSorter.facets
|
|
817
|
+
),
|
|
818
|
+
sorters: resultsWithResetSorter.sorters // Reset edilmiş sorter'ları kullan
|
|
819
|
+
};
|
|
820
|
+
updateResultsAndKey(finalResult);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const removeFacetFilter = async (
|
|
825
|
+
facetKey: string,
|
|
826
|
+
choiceValue: string | number
|
|
827
|
+
) => {
|
|
828
|
+
if (
|
|
829
|
+
!searchResults ||
|
|
830
|
+
lastProductIds.length === 0 ||
|
|
831
|
+
String(facetKey) === 'category_ids'
|
|
832
|
+
)
|
|
833
|
+
return;
|
|
834
|
+
|
|
835
|
+
const updatedSearchResults = updateFacetSelection(
|
|
836
|
+
facetKey,
|
|
837
|
+
choiceValue,
|
|
838
|
+
false
|
|
839
|
+
);
|
|
840
|
+
if (!updatedSearchResults) return;
|
|
841
|
+
|
|
842
|
+
const resultsWithResetSorter = {
|
|
843
|
+
...updatedSearchResults,
|
|
844
|
+
sorters:
|
|
845
|
+
updatedSearchResults.sorters?.map((sorter) => ({
|
|
846
|
+
...sorter,
|
|
847
|
+
is_selected: sorter.value === 'default'
|
|
848
|
+
})) || []
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
const searchParams = buildSearchParams(resultsWithResetSorter.facets);
|
|
852
|
+
const result = await executeProductSearch(lastProductIds, searchParams);
|
|
853
|
+
|
|
854
|
+
if (result) {
|
|
855
|
+
const finalResult = {
|
|
856
|
+
...result,
|
|
857
|
+
facets: preserveFacetState(
|
|
858
|
+
result.facets,
|
|
859
|
+
resultsWithResetSorter.facets
|
|
860
|
+
),
|
|
861
|
+
sorters: resultsWithResetSorter.sorters
|
|
862
|
+
};
|
|
863
|
+
updateResultsAndKey(finalResult);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const clearError = useCallback(() => {
|
|
868
|
+
setFileError('');
|
|
869
|
+
}, []);
|
|
870
|
+
|
|
871
|
+
const clearFileInput = useCallback(
|
|
872
|
+
(fileInputRef: React.RefObject<HTMLInputElement>) => {
|
|
873
|
+
if (fileInputRef.current) {
|
|
874
|
+
fileInputRef.current.value = '';
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
[]
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
const handleLoadMore = async () => {
|
|
881
|
+
if (!searchResults?.pagination || isLoading) return;
|
|
882
|
+
|
|
883
|
+
const nextPage = searchResults.pagination.current_page + 1;
|
|
884
|
+
if (nextPage > searchResults.pagination.num_pages) return;
|
|
885
|
+
|
|
886
|
+
const currentSortValue =
|
|
887
|
+
searchResults.sorters?.find((s) => s.is_selected)?.value || 'default';
|
|
888
|
+
const searchParams = buildSearchParams(
|
|
889
|
+
searchResults.facets,
|
|
890
|
+
currentSortValue,
|
|
891
|
+
nextPage
|
|
892
|
+
);
|
|
893
|
+
const result = await executeProductSearch(
|
|
894
|
+
lastProductIds,
|
|
895
|
+
searchParams,
|
|
896
|
+
currentSortValue
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
if (result) {
|
|
900
|
+
setLoadedPages((prev) => new Set([...Array.from(prev), nextPage]));
|
|
901
|
+
|
|
902
|
+
updateResultsAndKey(
|
|
903
|
+
{
|
|
904
|
+
...result,
|
|
905
|
+
pagination: {
|
|
906
|
+
...result.pagination,
|
|
907
|
+
current_page: nextPage
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
true
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
currentImageUrl,
|
|
917
|
+
setCurrentImageUrl,
|
|
918
|
+
isLoading,
|
|
919
|
+
fileError,
|
|
920
|
+
searchResults,
|
|
921
|
+
resultsKey,
|
|
922
|
+
hasUploadedImage,
|
|
923
|
+
setHasUploadedImage,
|
|
924
|
+
handleFileUpload,
|
|
925
|
+
handleImageCrop,
|
|
926
|
+
fetchSimilarProductsByImageUrl,
|
|
927
|
+
fetchSimilarProductsByBase64,
|
|
928
|
+
fetchSimilarProductsByImageCrop,
|
|
929
|
+
handleSortChange,
|
|
930
|
+
handlePageChange,
|
|
931
|
+
handleFacetChange,
|
|
932
|
+
removeFacetFilter,
|
|
933
|
+
handleLoadMore,
|
|
934
|
+
loadedPages: Array.from(loadedPages),
|
|
935
|
+
allLoadedProducts,
|
|
936
|
+
clearError,
|
|
937
|
+
clearFileInput
|
|
938
|
+
};
|
|
939
|
+
}
|