@akinon/pz-virtual-try-on 2.0.0-beta.16 → 2.0.0-beta.18

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,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
+ }