@akinon/pz-virtual-try-on 2.0.0-beta.15 → 2.0.0-beta.17
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/CHANGELOG.md +33 -0
- package/README.md +403 -7
- package/package.json +4 -3
- package/src/components/barcode-scanner.tsx +422 -0
- package/src/data/barcode-endpoints.ts +34 -0
- package/src/hooks/use-barcode-search.ts +172 -0
- package/src/hooks/use-image-cropper.ts +58 -8
- package/src/hooks/use-virtual-try-on-async.ts +1 -0
- package/src/hooks/use-virtual-try-on.ts +30 -4
- package/src/index.ts +26 -1
- package/src/types/barcode.ts +308 -0
- package/src/types/index.ts +32 -0
- package/src/utils/error-mapping.ts +3 -1
- package/src/utils/index.ts +115 -0
- package/src/views/barcode-scanner-button.tsx +63 -0
- package/src/views/barcode-scanner-modal.tsx +632 -0
- package/src/views/barcode-scanner-plugin.tsx +232 -0
- package/src/views/basket-async-modal.tsx +7 -2
- package/src/views/main.tsx +15 -1
- package/src/views/virtual-try-on-upload-modal.tsx +62 -44
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
|
4
|
+
import { twMerge } from 'tailwind-merge';
|
|
5
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
6
|
+
import type {
|
|
7
|
+
BarcodeScannerProps,
|
|
8
|
+
BarcodeScanResult,
|
|
9
|
+
BarcodeFormat
|
|
10
|
+
} from '../types/barcode';
|
|
11
|
+
|
|
12
|
+
import { LoaderSpinner } from '@theme/components';
|
|
13
|
+
|
|
14
|
+
const mapBarcodeFormat = (format: string): BarcodeFormat => {
|
|
15
|
+
const formatMap: Record<string, BarcodeFormat> = {
|
|
16
|
+
QR_CODE: 'QR_CODE',
|
|
17
|
+
AZTEC: 'AZTEC',
|
|
18
|
+
CODABAR: 'CODABAR',
|
|
19
|
+
CODE_39: 'CODE_39',
|
|
20
|
+
CODE_93: 'CODE_93',
|
|
21
|
+
CODE_128: 'CODE_128',
|
|
22
|
+
DATA_MATRIX: 'DATA_MATRIX',
|
|
23
|
+
ITF: 'ITF',
|
|
24
|
+
EAN_13: 'EAN_13',
|
|
25
|
+
EAN_8: 'EAN_8',
|
|
26
|
+
PDF_417: 'PDF_417',
|
|
27
|
+
UPC_A: 'UPC_A',
|
|
28
|
+
UPC_E: 'UPC_E',
|
|
29
|
+
RSS_14: 'RSS_14',
|
|
30
|
+
RSS_EXPANDED: 'RSS_EXPANDED'
|
|
31
|
+
};
|
|
32
|
+
return formatMap[format] || 'CODE_128';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const isValidEAN13 = (barcode: string): boolean => {
|
|
36
|
+
if (!/^\d{13}$/.test(barcode)) return false;
|
|
37
|
+
|
|
38
|
+
let sum = 0;
|
|
39
|
+
for (let i = 0; i < 12; i++) {
|
|
40
|
+
const digit = parseInt(barcode[i], 10);
|
|
41
|
+
sum += i % 2 === 0 ? digit : digit * 3;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const checkDigit = (10 - (sum % 10)) % 10;
|
|
45
|
+
return checkDigit === parseInt(barcode[12], 10);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Validate barcode based on expected formats
|
|
49
|
+
const isValidBarcode = (barcode: string): boolean => {
|
|
50
|
+
// EAN-13: 13 digits
|
|
51
|
+
if (/^\d{13}$/.test(barcode)) {
|
|
52
|
+
return isValidEAN13(barcode);
|
|
53
|
+
}
|
|
54
|
+
// EAN-8: 8 digits
|
|
55
|
+
if (/^\d{8}$/.test(barcode)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// UPC-A: 12 digits
|
|
59
|
+
if (/^\d{12}$/.test(barcode)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
// UPC-E: 6-8 digits
|
|
63
|
+
if (/^\d{6,8}$/.test(barcode)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
// CODE-128, CODE-39, etc: alphanumeric, at least 4 chars
|
|
67
|
+
if (barcode.length >= 4) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function BarcodeScanner({
|
|
74
|
+
onScan,
|
|
75
|
+
onError,
|
|
76
|
+
onPermissionDenied,
|
|
77
|
+
isActive,
|
|
78
|
+
className,
|
|
79
|
+
settings
|
|
80
|
+
}: BarcodeScannerProps) {
|
|
81
|
+
const { t } = useLocalization();
|
|
82
|
+
const scannerRef = useRef<any>(null);
|
|
83
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
84
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
85
|
+
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
|
86
|
+
const lastScanRef = useRef<string>('');
|
|
87
|
+
const lastScanTimeRef = useRef<number>(0);
|
|
88
|
+
const scannerIdRef = useRef<string>(`barcode-scanner-${Date.now()}`);
|
|
89
|
+
|
|
90
|
+
const stopScanning = useCallback(async () => {
|
|
91
|
+
if (scannerRef.current) {
|
|
92
|
+
try {
|
|
93
|
+
const state = scannerRef.current.getState();
|
|
94
|
+
if (state === 2) {
|
|
95
|
+
// SCANNING state
|
|
96
|
+
await scannerRef.current.stop();
|
|
97
|
+
}
|
|
98
|
+
scannerRef.current.clear();
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Ignore stop/clear errors
|
|
101
|
+
console.debug('Scanner cleanup:', e);
|
|
102
|
+
}
|
|
103
|
+
scannerRef.current = null;
|
|
104
|
+
}
|
|
105
|
+
setIsInitialized(false);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const startScanning = useCallback(async () => {
|
|
109
|
+
if (!isActive || scannerRef.current) return;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Dynamic import html5-qrcode only on client side
|
|
113
|
+
const { Html5Qrcode, Html5QrcodeSupportedFormats } = await import(
|
|
114
|
+
'html5-qrcode'
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Configure supported formats - prioritize EAN/UPC for product barcodes
|
|
118
|
+
const formatsToSupport = [
|
|
119
|
+
Html5QrcodeSupportedFormats.EAN_13,
|
|
120
|
+
Html5QrcodeSupportedFormats.EAN_8,
|
|
121
|
+
Html5QrcodeSupportedFormats.UPC_A,
|
|
122
|
+
Html5QrcodeSupportedFormats.UPC_E,
|
|
123
|
+
Html5QrcodeSupportedFormats.CODE_128,
|
|
124
|
+
Html5QrcodeSupportedFormats.CODE_39
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const scanner = new Html5Qrcode(scannerIdRef.current, {
|
|
128
|
+
formatsToSupport,
|
|
129
|
+
verbose: false
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
scannerRef.current = scanner;
|
|
133
|
+
|
|
134
|
+
const onScanSuccess = (decodedText: string, decodedResult: any) => {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
|
|
137
|
+
// Validate barcode - reject partial reads
|
|
138
|
+
if (!isValidBarcode(decodedText)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Debounce: prevent same barcode from being scanned multiple times
|
|
143
|
+
if (
|
|
144
|
+
decodedText === lastScanRef.current &&
|
|
145
|
+
now - lastScanTimeRef.current < 2000
|
|
146
|
+
) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lastScanRef.current = decodedText;
|
|
151
|
+
lastScanTimeRef.current = now;
|
|
152
|
+
|
|
153
|
+
const scanResult: BarcodeScanResult = {
|
|
154
|
+
text: decodedText,
|
|
155
|
+
format: mapBarcodeFormat(
|
|
156
|
+
decodedResult?.result?.format?.formatName || 'CODE_128'
|
|
157
|
+
),
|
|
158
|
+
timestamp: now
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Vibrate on successful scan if enabled and supported
|
|
162
|
+
if (settings?.vibrateOnScan && navigator.vibrate) {
|
|
163
|
+
navigator.vibrate(100);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
onScan(scanResult);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const onScanFailure = (error: string) => {
|
|
170
|
+
// Ignore "No barcode found" errors - this is normal
|
|
171
|
+
if (
|
|
172
|
+
!error.includes('No MultiFormat Readers') &&
|
|
173
|
+
!error.includes('NotFoundException')
|
|
174
|
+
) {
|
|
175
|
+
console.debug('Scan error:', error);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const config = {
|
|
180
|
+
fps: 10,
|
|
181
|
+
qrbox: { width: 250, height: 250 },
|
|
182
|
+
aspectRatio: 1.0,
|
|
183
|
+
disableFlip: false
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await scanner.start(
|
|
188
|
+
{ facingMode: 'environment' },
|
|
189
|
+
config,
|
|
190
|
+
onScanSuccess,
|
|
191
|
+
onScanFailure
|
|
192
|
+
);
|
|
193
|
+
} catch (backCameraError) {
|
|
194
|
+
try {
|
|
195
|
+
await scanner.start(
|
|
196
|
+
{ facingMode: 'user' },
|
|
197
|
+
config,
|
|
198
|
+
onScanSuccess,
|
|
199
|
+
onScanFailure
|
|
200
|
+
);
|
|
201
|
+
} catch (frontCameraError) {
|
|
202
|
+
const cameras = await Html5Qrcode.getCameras();
|
|
203
|
+
if (cameras && cameras.length > 0) {
|
|
204
|
+
await scanner.start(
|
|
205
|
+
cameras[0].id,
|
|
206
|
+
config,
|
|
207
|
+
onScanSuccess,
|
|
208
|
+
onScanFailure
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
throw new Error('No camera found');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setHasPermission(true);
|
|
217
|
+
setIsInitialized(true);
|
|
218
|
+
} catch (error: any) {
|
|
219
|
+
console.error('Failed to start barcode scanner:', error);
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
error.name === 'NotAllowedError' ||
|
|
223
|
+
error.message?.includes('Permission denied') ||
|
|
224
|
+
error.message?.includes('permission')
|
|
225
|
+
) {
|
|
226
|
+
setHasPermission(false);
|
|
227
|
+
onPermissionDenied?.();
|
|
228
|
+
} else {
|
|
229
|
+
onError?.(error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}, [isActive, onScan, onError, onPermissionDenied, settings?.vibrateOnScan]);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (isActive) {
|
|
236
|
+
startScanning();
|
|
237
|
+
} else {
|
|
238
|
+
stopScanning();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
stopScanning();
|
|
243
|
+
};
|
|
244
|
+
}, [isActive, startScanning, stopScanning]);
|
|
245
|
+
|
|
246
|
+
// Custom renderer support
|
|
247
|
+
if (settings?.customRenderers?.renderScanner) {
|
|
248
|
+
return settings.customRenderers.renderScanner({
|
|
249
|
+
onScan,
|
|
250
|
+
onError,
|
|
251
|
+
onPermissionDenied,
|
|
252
|
+
isActive,
|
|
253
|
+
className,
|
|
254
|
+
settings
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div
|
|
260
|
+
ref={containerRef}
|
|
261
|
+
className={twMerge(
|
|
262
|
+
'relative w-full h-full overflow-hidden bg-black',
|
|
263
|
+
settings?.customStyles?.scannerContainer,
|
|
264
|
+
className
|
|
265
|
+
)}
|
|
266
|
+
>
|
|
267
|
+
<div
|
|
268
|
+
id={scannerIdRef.current}
|
|
269
|
+
className="w-full h-full"
|
|
270
|
+
style={{ minHeight: '300px' }}
|
|
271
|
+
/>
|
|
272
|
+
|
|
273
|
+
{/* Custom overlay on top of scanner */}
|
|
274
|
+
{isInitialized && (
|
|
275
|
+
<div
|
|
276
|
+
className={twMerge(
|
|
277
|
+
'absolute inset-0 pointer-events-none',
|
|
278
|
+
settings?.customStyles?.scannerOverlay
|
|
279
|
+
)}
|
|
280
|
+
>
|
|
281
|
+
{settings?.customRenderers?.renderScannerOverlay?.({
|
|
282
|
+
isScanning: isActive && isInitialized
|
|
283
|
+
})}
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{isActive && !isInitialized && (
|
|
288
|
+
<>
|
|
289
|
+
{settings?.customRenderers?.renderLoading ? (
|
|
290
|
+
settings.customRenderers.renderLoading({})
|
|
291
|
+
) : (
|
|
292
|
+
<div
|
|
293
|
+
className={twMerge(
|
|
294
|
+
'absolute inset-0 flex items-center justify-center bg-black/80',
|
|
295
|
+
settings?.customStyles?.loadingContainer
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
<div className="text-white text-center">
|
|
299
|
+
{settings?.customRenderers?.renderLoadingSpinner ? (
|
|
300
|
+
settings.customRenderers.renderLoadingSpinner()
|
|
301
|
+
) : (
|
|
302
|
+
<>
|
|
303
|
+
<div
|
|
304
|
+
className={twMerge(
|
|
305
|
+
'animate-spin w-8 h-8 border-2 border-white border-t-transparent rounded-full mx-auto mb-2',
|
|
306
|
+
settings?.customStyles?.loadingSpinner
|
|
307
|
+
)}
|
|
308
|
+
/>
|
|
309
|
+
<LoaderSpinner />
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{hasPermission === false && (
|
|
319
|
+
<>
|
|
320
|
+
{settings?.customRenderers?.renderPermissionDenied ? (
|
|
321
|
+
settings.customRenderers.renderPermissionDenied({})
|
|
322
|
+
) : (
|
|
323
|
+
<div
|
|
324
|
+
className={twMerge(
|
|
325
|
+
'absolute inset-0 flex items-center justify-center bg-black/80',
|
|
326
|
+
settings?.customStyles?.permissionDeniedContainer
|
|
327
|
+
)}
|
|
328
|
+
>
|
|
329
|
+
<div className="text-white text-center p-4">
|
|
330
|
+
{settings?.customRenderers?.renderPermissionDeniedIcon ? (
|
|
331
|
+
settings.customRenderers.renderPermissionDeniedIcon()
|
|
332
|
+
) : (
|
|
333
|
+
<svg
|
|
334
|
+
className={twMerge(
|
|
335
|
+
'w-12 h-12 mx-auto mb-3',
|
|
336
|
+
settings?.customStyles?.permissionDeniedIcon
|
|
337
|
+
)}
|
|
338
|
+
fill="none"
|
|
339
|
+
viewBox="0 0 24 24"
|
|
340
|
+
stroke="currentColor"
|
|
341
|
+
>
|
|
342
|
+
<path
|
|
343
|
+
strokeLinecap="round"
|
|
344
|
+
strokeLinejoin="round"
|
|
345
|
+
strokeWidth={1.5}
|
|
346
|
+
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
|
347
|
+
/>
|
|
348
|
+
<path
|
|
349
|
+
strokeLinecap="round"
|
|
350
|
+
strokeLinejoin="round"
|
|
351
|
+
strokeWidth={1.5}
|
|
352
|
+
d="M3 3l18 18"
|
|
353
|
+
/>
|
|
354
|
+
</svg>
|
|
355
|
+
)}
|
|
356
|
+
{settings?.customRenderers?.renderPermissionDeniedTitle ? (
|
|
357
|
+
settings.customRenderers.renderPermissionDeniedTitle({
|
|
358
|
+
title: t('product.barcode_scanner.permission_denied_title')
|
|
359
|
+
})
|
|
360
|
+
) : (
|
|
361
|
+
<p
|
|
362
|
+
className={twMerge(
|
|
363
|
+
'mb-2 font-medium',
|
|
364
|
+
settings?.customStyles?.permissionDeniedTitle
|
|
365
|
+
)}
|
|
366
|
+
>
|
|
367
|
+
{t('product.barcode_scanner.permission_denied_title')}
|
|
368
|
+
</p>
|
|
369
|
+
)}
|
|
370
|
+
{settings?.customRenderers?.renderPermissionDeniedMessage ? (
|
|
371
|
+
settings.customRenderers.renderPermissionDeniedMessage({
|
|
372
|
+
message: t(
|
|
373
|
+
'product.barcode_scanner.permission_denied_message'
|
|
374
|
+
)
|
|
375
|
+
})
|
|
376
|
+
) : (
|
|
377
|
+
<p
|
|
378
|
+
className={twMerge(
|
|
379
|
+
'text-sm text-gray-300',
|
|
380
|
+
settings?.customStyles?.permissionDeniedMessage
|
|
381
|
+
)}
|
|
382
|
+
>
|
|
383
|
+
{t('product.barcode_scanner.permission_denied_message')}
|
|
384
|
+
</p>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
</>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
<style jsx global>{`
|
|
393
|
+
#${scannerIdRef.current} video {
|
|
394
|
+
width: 100% !important;
|
|
395
|
+
height: 100% !important;
|
|
396
|
+
object-fit: cover !important;
|
|
397
|
+
}
|
|
398
|
+
#${scannerIdRef.current} img[alt='Info icon'] {
|
|
399
|
+
display: none !important;
|
|
400
|
+
}
|
|
401
|
+
#${scannerIdRef.current} > div:last-child {
|
|
402
|
+
display: none !important;
|
|
403
|
+
}
|
|
404
|
+
#${scannerIdRef.current}__scan_region {
|
|
405
|
+
min-height: 300px !important;
|
|
406
|
+
}
|
|
407
|
+
#${scannerIdRef.current}__scan_region video {
|
|
408
|
+
border-radius: 0 !important;
|
|
409
|
+
}
|
|
410
|
+
#${scannerIdRef.current}__dashboard_section {
|
|
411
|
+
display: none !important;
|
|
412
|
+
}
|
|
413
|
+
#${scannerIdRef.current}__dashboard_section_csr {
|
|
414
|
+
display: none !important;
|
|
415
|
+
}
|
|
416
|
+
#${scannerIdRef.current}__header_message {
|
|
417
|
+
display: none !important;
|
|
418
|
+
}
|
|
419
|
+
`}</style>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { api } from '@akinon/next/data/client/api';
|
|
2
|
+
import type { BarcodeSearchResponse } from '../types/barcode';
|
|
3
|
+
|
|
4
|
+
export const barcodeScannerApi = api.injectEndpoints({
|
|
5
|
+
endpoints: (build) => ({
|
|
6
|
+
searchProductByBarcode: build.query<
|
|
7
|
+
BarcodeSearchResponse,
|
|
8
|
+
{ barcode: string; page?: number; limit?: number }
|
|
9
|
+
>({
|
|
10
|
+
query: ({ barcode, page = 1, limit = 12 }) => {
|
|
11
|
+
const params = new URLSearchParams();
|
|
12
|
+
params.append('search_text', barcode);
|
|
13
|
+
params.append('page', String(page));
|
|
14
|
+
params.append('limit', String(limit));
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
url: `/api/barcode-search?${params.toString()}`,
|
|
18
|
+
method: 'GET',
|
|
19
|
+
headers: {
|
|
20
|
+
Accept: 'application/json'
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
keepUnusedDataFor: 0
|
|
26
|
+
})
|
|
27
|
+
}),
|
|
28
|
+
overrideExisting: true
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const {
|
|
32
|
+
useSearchProductByBarcodeQuery,
|
|
33
|
+
useLazySearchProductByBarcodeQuery
|
|
34
|
+
} = barcodeScannerApi;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { Product } from '@akinon/next/types';
|
|
5
|
+
import type {
|
|
6
|
+
BarcodeScanResult,
|
|
7
|
+
ScannedProductState,
|
|
8
|
+
BarcodeSearchResponse
|
|
9
|
+
} from '../types/barcode';
|
|
10
|
+
|
|
11
|
+
export interface UseBarcodeSearchReturn {
|
|
12
|
+
// State
|
|
13
|
+
scannedProducts: Product[];
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
lastScannedBarcode: string | null;
|
|
17
|
+
|
|
18
|
+
// Actions
|
|
19
|
+
searchByBarcode: (barcode: string) => Promise<Product[]>;
|
|
20
|
+
handleScanResult: (result: BarcodeScanResult) => Promise<void>;
|
|
21
|
+
removeProduct: (productPk: number) => void;
|
|
22
|
+
clearProducts: () => void;
|
|
23
|
+
clearError: () => void;
|
|
24
|
+
reset: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useBarcodeSearch(
|
|
28
|
+
maxProducts: number = 10
|
|
29
|
+
): UseBarcodeSearchReturn {
|
|
30
|
+
const [state, setState] = useState<ScannedProductState>({
|
|
31
|
+
products: [],
|
|
32
|
+
isLoading: false,
|
|
33
|
+
error: null,
|
|
34
|
+
searchedBarcode: null
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const searchByBarcode = useCallback(
|
|
38
|
+
async (barcode: string): Promise<Product[]> => {
|
|
39
|
+
setState((prev) => ({
|
|
40
|
+
...prev,
|
|
41
|
+
isLoading: true,
|
|
42
|
+
error: null,
|
|
43
|
+
searchedBarcode: barcode
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const params = new URLSearchParams();
|
|
48
|
+
params.append('search_text', barcode);
|
|
49
|
+
|
|
50
|
+
const response = await fetch(
|
|
51
|
+
`/api/barcode-search?${params.toString()}`,
|
|
52
|
+
{
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
Accept: 'application/json'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error('search_failed');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await response.json();
|
|
65
|
+
|
|
66
|
+
// API returns { results: [...] } not { products: [...] }
|
|
67
|
+
const products = result.results || result.products || [];
|
|
68
|
+
|
|
69
|
+
if (products.length > 0) {
|
|
70
|
+
const newProduct = products[0];
|
|
71
|
+
|
|
72
|
+
setState((prev) => {
|
|
73
|
+
// Check if product already exists
|
|
74
|
+
const exists = prev.products.some((p) => p.pk === newProduct.pk);
|
|
75
|
+
if (exists) {
|
|
76
|
+
return {
|
|
77
|
+
...prev,
|
|
78
|
+
isLoading: false,
|
|
79
|
+
error: null
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check max products limit
|
|
84
|
+
if (prev.products.length >= maxProducts) {
|
|
85
|
+
return {
|
|
86
|
+
...prev,
|
|
87
|
+
isLoading: false,
|
|
88
|
+
error: `Maximum ${maxProducts} products allowed`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
...prev,
|
|
94
|
+
products: [...prev.products, newProduct],
|
|
95
|
+
isLoading: false,
|
|
96
|
+
error: null
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return [newProduct];
|
|
101
|
+
} else {
|
|
102
|
+
setState((prev) => ({
|
|
103
|
+
...prev,
|
|
104
|
+
isLoading: false,
|
|
105
|
+
error: 'product_not_found'
|
|
106
|
+
}));
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
} catch (error: any) {
|
|
110
|
+
const errorMessage =
|
|
111
|
+
error?.data?.error || error?.message || 'search_failed';
|
|
112
|
+
setState((prev) => ({
|
|
113
|
+
...prev,
|
|
114
|
+
isLoading: false,
|
|
115
|
+
error: errorMessage
|
|
116
|
+
}));
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[maxProducts]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const handleScanResult = useCallback(
|
|
124
|
+
async (result: BarcodeScanResult): Promise<void> => {
|
|
125
|
+
await searchByBarcode(result.text);
|
|
126
|
+
},
|
|
127
|
+
[searchByBarcode]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const removeProduct = useCallback((productPk: number): void => {
|
|
131
|
+
setState((prev) => ({
|
|
132
|
+
...prev,
|
|
133
|
+
products: prev.products.filter((p) => p.pk !== productPk)
|
|
134
|
+
}));
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const clearProducts = useCallback((): void => {
|
|
138
|
+
setState((prev) => ({
|
|
139
|
+
...prev,
|
|
140
|
+
products: []
|
|
141
|
+
}));
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
const clearError = useCallback((): void => {
|
|
145
|
+
setState((prev) => ({
|
|
146
|
+
...prev,
|
|
147
|
+
error: null
|
|
148
|
+
}));
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const reset = useCallback((): void => {
|
|
152
|
+
setState({
|
|
153
|
+
products: [],
|
|
154
|
+
isLoading: false,
|
|
155
|
+
error: null,
|
|
156
|
+
searchedBarcode: null
|
|
157
|
+
});
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
scannedProducts: state.products,
|
|
162
|
+
isLoading: state.isLoading,
|
|
163
|
+
error: state.error,
|
|
164
|
+
lastScannedBarcode: state.searchedBarcode,
|
|
165
|
+
searchByBarcode,
|
|
166
|
+
handleScanResult,
|
|
167
|
+
removeProduct,
|
|
168
|
+
clearProducts,
|
|
169
|
+
clearError,
|
|
170
|
+
reset
|
|
171
|
+
};
|
|
172
|
+
}
|