pharmacy-erp 1.2.0 → 1.3.0
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/frontend/package.json +1 -0
- package/frontend/pnpm-lock.yaml +9 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +41 -7
- package/frontend/src/app/[locale]/(pos)/pos/page.tsx +103 -60
- package/frontend/src/components/ui/barcode-scanner.tsx +222 -0
- package/frontend/src/components/ui/scan-feedback.tsx +100 -0
- package/frontend/src/hooks/use-barcode-scanner.ts +84 -0
- package/package.json +1 -1
package/frontend/package.json
CHANGED
package/frontend/pnpm-lock.yaml
CHANGED
|
@@ -53,6 +53,9 @@ importers:
|
|
|
53
53
|
framer-motion:
|
|
54
54
|
specifier: ^13.1.0
|
|
55
55
|
version: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
|
56
|
+
html5-qrcode:
|
|
57
|
+
specifier: ^2.3.8
|
|
58
|
+
version: 2.3.8
|
|
56
59
|
jspdf:
|
|
57
60
|
specifier: ^4.2.1
|
|
58
61
|
version: 4.2.1
|
|
@@ -1633,6 +1636,7 @@ packages:
|
|
|
1633
1636
|
eslint@9.39.5:
|
|
1634
1637
|
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
|
|
1635
1638
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
|
1639
|
+
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
|
1636
1640
|
hasBin: true
|
|
1637
1641
|
peerDependencies:
|
|
1638
1642
|
jiti: '*'
|
|
@@ -1837,6 +1841,9 @@ packages:
|
|
|
1837
1841
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
|
1838
1842
|
engines: {node: '>=8.0.0'}
|
|
1839
1843
|
|
|
1844
|
+
html5-qrcode@2.3.8:
|
|
1845
|
+
resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==}
|
|
1846
|
+
|
|
1840
1847
|
ignore@5.3.2:
|
|
1841
1848
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
|
1842
1849
|
engines: {node: '>= 4'}
|
|
@@ -4587,6 +4594,8 @@ snapshots:
|
|
|
4587
4594
|
text-segmentation: 1.0.3
|
|
4588
4595
|
optional: true
|
|
4589
4596
|
|
|
4597
|
+
html5-qrcode@2.3.8: {}
|
|
4598
|
+
|
|
4590
4599
|
ignore@5.3.2: {}
|
|
4591
4600
|
|
|
4592
4601
|
ignore@7.0.6: {}
|
|
@@ -8,8 +8,10 @@ import { Select } from '@/components/ui/select';
|
|
|
8
8
|
import { Switch } from '@/components/ui/switch';
|
|
9
9
|
import { Badge } from '@/components/ui/badge';
|
|
10
10
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
11
|
+
import { BarcodeScanner } from '@/components/ui/barcode-scanner';
|
|
12
|
+
import { useScanFeedback } from '@/components/ui/scan-feedback';
|
|
11
13
|
import { toast } from 'react-hot-toast';
|
|
12
|
-
import { Search, Plus, Edit, Trash2, Package, Pill, Beaker, Tag, DollarSign, Archive, AlertCircle, ChevronDown, Upload } from 'lucide-react';
|
|
14
|
+
import { Search, Plus, Edit, Trash2, Package, Pill, Beaker, Tag, DollarSign, Archive, AlertCircle, ChevronDown, Upload, Camera } from 'lucide-react';
|
|
13
15
|
import { BulkImportModal } from '@/components/ui/bulk-import-modal';
|
|
14
16
|
import { cn } from '@/lib/utils';
|
|
15
17
|
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
|
@@ -32,10 +34,12 @@ export default function ProductsPage() {
|
|
|
32
34
|
|
|
33
35
|
const [slideOpen, setSlideOpen] = useState(false);
|
|
34
36
|
const [importModalOpen, setImportModalOpen] = useState(false);
|
|
37
|
+
const [scannerOpen, setScannerOpen] = useState(false);
|
|
35
38
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
36
39
|
|
|
37
40
|
const [deleteDialog, setDeleteDialog] = useState({ open: false, id: '', name: '' });
|
|
38
41
|
const [isSaving, setIsSaving] = useState(false);
|
|
42
|
+
const { playSuccess, playError } = useScanFeedback();
|
|
39
43
|
|
|
40
44
|
// Form State
|
|
41
45
|
const defaultForm = {
|
|
@@ -521,13 +525,23 @@ export default function ProductsPage() {
|
|
|
521
525
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
522
526
|
/>
|
|
523
527
|
</div>
|
|
524
|
-
<div className="space-y-1.5">
|
|
528
|
+
<div className="space-y-1.5 relative">
|
|
525
529
|
<label className="text-sm text-muted-foreground">Barcode</label>
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
530
|
+
<div className="flex gap-2">
|
|
531
|
+
<input
|
|
532
|
+
type="text"
|
|
533
|
+
value={formData.barcode} onChange={e => setFormData({...formData, barcode: e.target.value})}
|
|
534
|
+
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
535
|
+
/>
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() => setScannerOpen(true)}
|
|
539
|
+
className="shrink-0 p-2 border border-border rounded-lg hover:bg-surface text-brand-500 transition-colors"
|
|
540
|
+
title="Scan Barcode"
|
|
541
|
+
>
|
|
542
|
+
<Camera size={20} />
|
|
543
|
+
</button>
|
|
544
|
+
</div>
|
|
531
545
|
</div>
|
|
532
546
|
</div>
|
|
533
547
|
</div>
|
|
@@ -748,6 +762,26 @@ export default function ProductsPage() {
|
|
|
748
762
|
onOpenChange={setImportModalOpen}
|
|
749
763
|
onImportComplete={fetchData}
|
|
750
764
|
/>
|
|
765
|
+
|
|
766
|
+
{/* Barcode Scanner Modal */}
|
|
767
|
+
<BarcodeScanner
|
|
768
|
+
isOpen={scannerOpen}
|
|
769
|
+
onClose={() => setScannerOpen(false)}
|
|
770
|
+
title="Scan Barcode / QR Code"
|
|
771
|
+
onScan={(code) => {
|
|
772
|
+
setScannerOpen(false);
|
|
773
|
+
// Check if barcode already exists in loaded medicines
|
|
774
|
+
const exists = medicines.find(m => m.barcode === code);
|
|
775
|
+
if (exists) {
|
|
776
|
+
playError();
|
|
777
|
+
toast.error(`Barcode belongs to existing medicine: ${exists.name_en}`);
|
|
778
|
+
} else {
|
|
779
|
+
playSuccess();
|
|
780
|
+
setFormData(prev => ({ ...prev, barcode: code }));
|
|
781
|
+
toast.success("Barcode filled!");
|
|
782
|
+
}
|
|
783
|
+
}}
|
|
784
|
+
/>
|
|
751
785
|
</div>
|
|
752
786
|
);
|
|
753
787
|
}
|
|
@@ -12,11 +12,15 @@ import PaymentSettingsModal from '@/components/pos/PaymentSettingsModal';
|
|
|
12
12
|
import { CashSessionGuard } from '@/components/pos/CashSessionGuard';
|
|
13
13
|
import { CloseSessionModal } from '@/components/pos/CloseSessionModal';
|
|
14
14
|
import { SaleReceipt } from '@/components/pos/SaleReceipt';
|
|
15
|
+
import { BarcodeScanner } from '@/components/ui/barcode-scanner';
|
|
16
|
+
import { useScanFeedback } from '@/components/ui/scan-feedback';
|
|
17
|
+
import { useBarcodeScanner } from '@/hooks/use-barcode-scanner';
|
|
15
18
|
import type { Product } from '@/components/pos/ProductCatalog';
|
|
16
19
|
import {
|
|
17
20
|
Menu,
|
|
18
21
|
Search,
|
|
19
22
|
ScanBarcode,
|
|
23
|
+
Camera,
|
|
20
24
|
ChevronDown,
|
|
21
25
|
User,
|
|
22
26
|
X,
|
|
@@ -50,6 +54,9 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
|
|
|
50
54
|
const [receiptOpen, setReceiptOpen] = useState(false);
|
|
51
55
|
const [saleReceiptData, setSaleReceiptData] = useState<any>(null);
|
|
52
56
|
|
|
57
|
+
// Camera Scanner State
|
|
58
|
+
const [cameraScannerOpen, setCameraScannerOpen] = useState(false);
|
|
59
|
+
|
|
53
60
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
54
61
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
55
62
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
|
@@ -154,82 +161,99 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
|
|
|
154
161
|
[addItem]
|
|
155
162
|
);
|
|
156
163
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
// Scan feedback (beep sounds + visual flash)
|
|
165
|
+
const { playSuccess, playError, flashElement } = useScanFeedback();
|
|
166
|
+
|
|
167
|
+
// Shared barcode scan handler (used by both USB scanner and camera scanner)
|
|
168
|
+
const handleBarcodeScan = useCallback(
|
|
169
|
+
(scannedCode: string) => {
|
|
170
|
+
// First, try local lookup by product ID or barcode
|
|
171
|
+
const found = products.find(
|
|
172
|
+
(p) =>
|
|
173
|
+
p.id === scannedCode ||
|
|
174
|
+
p.name.toLowerCase() === scannedCode.toLowerCase()
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (found) {
|
|
178
|
+
handleProductClick(found);
|
|
179
|
+
playSuccess();
|
|
180
|
+
flashElement('green', searchInputRef.current);
|
|
181
|
+
toast.success(`✅ Scanned: ${found.name}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Fallback: search via API (matches barcode field in database)
|
|
186
|
+
api
|
|
187
|
+
.searchMedicines(scannedCode)
|
|
188
|
+
.then((res: any) => {
|
|
189
|
+
if (Array.isArray(res) && res.length > 0) {
|
|
190
|
+
const match = products.find((p) => p.medicine_id === res[0].id);
|
|
191
|
+
if (match) {
|
|
192
|
+
handleProductClick(match);
|
|
193
|
+
playSuccess();
|
|
194
|
+
flashElement('green', searchInputRef.current);
|
|
195
|
+
toast.success(`✅ Scanned: ${match.name}`);
|
|
196
|
+
} else {
|
|
197
|
+
playError();
|
|
198
|
+
flashElement('red', searchInputRef.current);
|
|
199
|
+
toast.error(`Scanned item not in stock catalog`);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
playError();
|
|
203
|
+
flashElement('red', searchInputRef.current);
|
|
204
|
+
toast.error(`Barcode not found: ${scannedCode}`);
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
.catch(() => {
|
|
208
|
+
playError();
|
|
209
|
+
flashElement('red', searchInputRef.current);
|
|
210
|
+
toast.error(`Product not found for code: ${scannedCode}`);
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
[products, handleProductClick, playSuccess, playError, flashElement]
|
|
214
|
+
);
|
|
161
215
|
|
|
216
|
+
// USB/Bluetooth hardware barcode scanner hook
|
|
217
|
+
useBarcodeScanner({
|
|
218
|
+
onScan: handleBarcodeScan,
|
|
219
|
+
enabled: !paymentMode && !cameraScannerOpen,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Camera scanner callback
|
|
223
|
+
const handleCameraScan = useCallback(
|
|
224
|
+
(code: string) => {
|
|
225
|
+
setCameraScannerOpen(false);
|
|
226
|
+
handleBarcodeScan(code);
|
|
227
|
+
},
|
|
228
|
+
[handleBarcodeScan]
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Keyboard shortcuts (F2 = search, F4 = camera, F9 = payment, Escape)
|
|
232
|
+
useEffect(() => {
|
|
162
233
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
163
|
-
// Global keyboard shortcuts
|
|
164
234
|
if (e.key === 'F2') {
|
|
165
235
|
e.preventDefault();
|
|
166
236
|
searchInputRef.current?.focus();
|
|
167
|
-
|
|
237
|
+
}
|
|
238
|
+
if (e.key === 'F4') {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
setCameraScannerOpen(true);
|
|
168
241
|
}
|
|
169
242
|
if (e.key === 'F9' && cart.length > 0) {
|
|
170
243
|
e.preventDefault();
|
|
171
244
|
setPaymentMode(true);
|
|
172
|
-
return;
|
|
173
245
|
}
|
|
174
246
|
if (e.key === 'Escape') {
|
|
247
|
+
if (cameraScannerOpen) setCameraScannerOpen(false);
|
|
175
248
|
if (paymentMode) setPaymentMode(false);
|
|
176
249
|
if (showDropdown) setShowDropdown(false);
|
|
177
250
|
if (userMenuOpen) setUserMenuOpen(false);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// If user is focused on an input/textarea (not scanning outside), skip global barcode hook
|
|
182
|
-
const activeTag = document.activeElement?.tagName.toLowerCase();
|
|
183
|
-
const isInputActive = activeTag === 'input' || activeTag === 'textarea';
|
|
184
|
-
|
|
185
|
-
const currentTime = Date.now();
|
|
186
|
-
const timeDiff = currentTime - lastKeyTime;
|
|
187
|
-
lastKeyTime = currentTime;
|
|
188
|
-
|
|
189
|
-
// Barcode scanners usually send characters within 30-50ms of each other
|
|
190
|
-
if (timeDiff > 80) {
|
|
191
|
-
barcodeBuffer = '';
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (e.key === 'Enter') {
|
|
195
|
-
if (barcodeBuffer.length >= 3) {
|
|
196
|
-
e.preventDefault();
|
|
197
|
-
const scannedCode = barcodeBuffer.trim();
|
|
198
|
-
barcodeBuffer = '';
|
|
199
|
-
|
|
200
|
-
// Look up scanned barcode in products list or via API search
|
|
201
|
-
const found = products.find(p => p.id === scannedCode || p.name.toLowerCase() === scannedCode.toLowerCase());
|
|
202
|
-
if (found) {
|
|
203
|
-
handleProductClick(found);
|
|
204
|
-
toast.success(`Scanned: ${found.name}`);
|
|
205
|
-
} else {
|
|
206
|
-
api.searchMedicines(scannedCode)
|
|
207
|
-
.then((res: any) => {
|
|
208
|
-
if (Array.isArray(res) && res.length > 0) {
|
|
209
|
-
const match = products.find(p => p.medicine_id === res[0].id);
|
|
210
|
-
if (match) {
|
|
211
|
-
handleProductClick(match);
|
|
212
|
-
toast.success(`Scanned: ${match.name}`);
|
|
213
|
-
} else {
|
|
214
|
-
toast.error(`Scanned item not in stock catalog`);
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
toast.error(`Barcode not found: ${scannedCode}`);
|
|
218
|
-
}
|
|
219
|
-
})
|
|
220
|
-
.catch(() => {
|
|
221
|
-
toast.error(`Product not found for code: ${scannedCode}`);
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} else if (e.key.length === 1 && !isInputActive) {
|
|
226
|
-
barcodeBuffer += e.key;
|
|
227
251
|
}
|
|
228
252
|
};
|
|
229
253
|
|
|
230
254
|
window.addEventListener('keydown', handleKeyDown);
|
|
231
255
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
232
|
-
}, [cart.length, paymentMode, showDropdown, userMenuOpen,
|
|
256
|
+
}, [cart.length, paymentMode, showDropdown, userMenuOpen, cameraScannerOpen]);
|
|
233
257
|
|
|
234
258
|
// Click outside to close dropdowns
|
|
235
259
|
useEffect(() => {
|
|
@@ -330,10 +354,18 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
|
|
|
330
354
|
{theme === 'dark' ? <Sun size={18} className="md:w-5 md:h-5" /> : <Moon size={18} className="md:w-5 md:h-5" />}
|
|
331
355
|
</button>
|
|
332
356
|
|
|
357
|
+
<button
|
|
358
|
+
onClick={() => setCameraScannerOpen(true)}
|
|
359
|
+
className="p-1.5 md:p-2 bg-transparent border border-border rounded-lg text-foreground hover:bg-surface transition-colors shrink-0"
|
|
360
|
+
title="Scan with Camera (F4)"
|
|
361
|
+
>
|
|
362
|
+
<Camera size={18} className="md:w-5 md:h-5 text-brand-500" />
|
|
363
|
+
</button>
|
|
364
|
+
|
|
333
365
|
<button
|
|
334
366
|
onClick={() => searchInputRef.current?.focus()}
|
|
335
367
|
className="p-1.5 md:p-2 bg-transparent border border-border rounded-lg text-foreground hover:bg-surface transition-colors shrink-0"
|
|
336
|
-
title="
|
|
368
|
+
title="Focus Barcode Search (F2)"
|
|
337
369
|
>
|
|
338
370
|
<ScanBarcode size={18} className="md:w-5 md:h-5" />
|
|
339
371
|
</button>
|
|
@@ -440,13 +472,24 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
|
|
|
440
472
|
onClose={() => setSettingsOpen(false)}
|
|
441
473
|
/>
|
|
442
474
|
|
|
443
|
-
{/*
|
|
475
|
+
{/* Payment Success Receipt */}
|
|
444
476
|
<SaleReceipt
|
|
445
477
|
open={receiptOpen}
|
|
446
|
-
onClose={() =>
|
|
478
|
+
onClose={() => {
|
|
479
|
+
setReceiptOpen(false);
|
|
480
|
+
setSaleReceiptData(null);
|
|
481
|
+
}}
|
|
447
482
|
saleData={saleReceiptData}
|
|
448
483
|
/>
|
|
449
484
|
|
|
485
|
+
{/* Camera Barcode Scanner */}
|
|
486
|
+
<BarcodeScanner
|
|
487
|
+
isOpen={cameraScannerOpen}
|
|
488
|
+
onClose={() => setCameraScannerOpen(false)}
|
|
489
|
+
onScan={handleCameraScan}
|
|
490
|
+
title="Scan Product Barcode"
|
|
491
|
+
/>
|
|
492
|
+
|
|
450
493
|
{/* ===== CLOSE REGISTER MODAL ===== */}
|
|
451
494
|
<CloseSessionModal
|
|
452
495
|
open={closeSessionOpen}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { X, Camera, SwitchCamera, Flashlight, Loader2 } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface BarcodeScannerProps {
|
|
7
|
+
/** Whether the scanner modal is open */
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
/** Called when the modal is closed */
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
/** Called when a barcode/QR code is successfully scanned */
|
|
12
|
+
onScan: (code: string) => void;
|
|
13
|
+
/** Title shown at the top of the modal */
|
|
14
|
+
title?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Camera-based barcode and QR code scanner modal.
|
|
19
|
+
*
|
|
20
|
+
* Uses the `html5-qrcode` library to access the device camera and decode
|
|
21
|
+
* barcodes in real-time. Supports EAN-13, EAN-8, UPC-A, Code-128, QR Code,
|
|
22
|
+
* Data Matrix, and more.
|
|
23
|
+
*
|
|
24
|
+
* Works on mobile (front/back camera), tablets, and desktop webcams.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* const [scannerOpen, setScannerOpen] = useState(false);
|
|
29
|
+
*
|
|
30
|
+
* <BarcodeScanner
|
|
31
|
+
* isOpen={scannerOpen}
|
|
32
|
+
* onClose={() => setScannerOpen(false)}
|
|
33
|
+
* onScan={(code) => {
|
|
34
|
+
* console.log('Scanned:', code);
|
|
35
|
+
* setScannerOpen(false);
|
|
36
|
+
* }}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function BarcodeScanner({ isOpen, onClose, onScan, title = 'Scan Barcode' }: BarcodeScannerProps) {
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const [loading, setLoading] = useState(true);
|
|
43
|
+
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
|
|
44
|
+
const scannerRef = useRef<any>(null);
|
|
45
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const hasScannedRef = useRef(false);
|
|
47
|
+
|
|
48
|
+
const stopScanner = useCallback(async () => {
|
|
49
|
+
if (scannerRef.current) {
|
|
50
|
+
try {
|
|
51
|
+
const state = scannerRef.current.getState();
|
|
52
|
+
// State 2 = SCANNING
|
|
53
|
+
if (state === 2) {
|
|
54
|
+
await scannerRef.current.stop();
|
|
55
|
+
}
|
|
56
|
+
scannerRef.current.clear();
|
|
57
|
+
} catch {
|
|
58
|
+
// Ignore cleanup errors
|
|
59
|
+
}
|
|
60
|
+
scannerRef.current = null;
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const startScanner = useCallback(async () => {
|
|
65
|
+
if (!isOpen) return;
|
|
66
|
+
|
|
67
|
+
setLoading(true);
|
|
68
|
+
setError(null);
|
|
69
|
+
hasScannedRef.current = false;
|
|
70
|
+
|
|
71
|
+
// Dynamic import to avoid SSR issues
|
|
72
|
+
const { Html5Qrcode } = await import('html5-qrcode');
|
|
73
|
+
|
|
74
|
+
// Clean up any previous instance
|
|
75
|
+
await stopScanner();
|
|
76
|
+
|
|
77
|
+
// Wait for DOM to be ready
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
79
|
+
|
|
80
|
+
const readerId = 'barcode-reader';
|
|
81
|
+
const readerElement = document.getElementById(readerId);
|
|
82
|
+
if (!readerElement) {
|
|
83
|
+
setError('Scanner container not found');
|
|
84
|
+
setLoading(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const scanner = new Html5Qrcode(readerId);
|
|
90
|
+
scannerRef.current = scanner;
|
|
91
|
+
|
|
92
|
+
await scanner.start(
|
|
93
|
+
{ facingMode },
|
|
94
|
+
{
|
|
95
|
+
fps: 10,
|
|
96
|
+
qrbox: { width: 250, height: 250 },
|
|
97
|
+
aspectRatio: 1.0,
|
|
98
|
+
},
|
|
99
|
+
(decodedText: string) => {
|
|
100
|
+
// Prevent double scans
|
|
101
|
+
if (hasScannedRef.current) return;
|
|
102
|
+
hasScannedRef.current = true;
|
|
103
|
+
|
|
104
|
+
onScan(decodedText);
|
|
105
|
+
},
|
|
106
|
+
() => {
|
|
107
|
+
// QR decode failure — this fires continuously, ignore
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
setLoading(false);
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
setLoading(false);
|
|
114
|
+
if (err?.message?.includes('NotAllowedError') || err?.name === 'NotAllowedError') {
|
|
115
|
+
setError('Camera permission denied. Please allow camera access in your browser settings.');
|
|
116
|
+
} else if (err?.message?.includes('NotFoundError') || err?.name === 'NotFoundError') {
|
|
117
|
+
setError('No camera found on this device.');
|
|
118
|
+
} else {
|
|
119
|
+
setError(`Camera error: ${err?.message || 'Unknown error'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}, [isOpen, facingMode, onScan, stopScanner]);
|
|
123
|
+
|
|
124
|
+
// Start scanner when modal opens
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (isOpen) {
|
|
127
|
+
startScanner();
|
|
128
|
+
}
|
|
129
|
+
return () => {
|
|
130
|
+
stopScanner();
|
|
131
|
+
};
|
|
132
|
+
}, [isOpen, startScanner, stopScanner]);
|
|
133
|
+
|
|
134
|
+
// Toggle front/back camera
|
|
135
|
+
const toggleCamera = useCallback(async () => {
|
|
136
|
+
setFacingMode(prev => prev === 'environment' ? 'user' : 'environment');
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
if (!isOpen) return null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
143
|
+
<div className="bg-background border border-border rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
|
|
144
|
+
{/* Header */}
|
|
145
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
146
|
+
<div className="flex items-center gap-2">
|
|
147
|
+
<Camera size={20} className="text-brand-500" />
|
|
148
|
+
<h3 className="font-semibold text-foreground">{title}</h3>
|
|
149
|
+
</div>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => { stopScanner(); onClose(); }}
|
|
152
|
+
className="p-1.5 rounded-lg hover:bg-surface transition-colors text-foreground/60 hover:text-foreground"
|
|
153
|
+
>
|
|
154
|
+
<X size={20} />
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Camera View */}
|
|
159
|
+
<div className="relative bg-black">
|
|
160
|
+
<div
|
|
161
|
+
id="barcode-reader"
|
|
162
|
+
ref={containerRef}
|
|
163
|
+
className="w-full aspect-square"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{/* Loading overlay */}
|
|
167
|
+
{loading && (
|
|
168
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 text-white gap-3">
|
|
169
|
+
<Loader2 size={32} className="animate-spin text-brand-500" />
|
|
170
|
+
<p className="text-sm text-white/80">Starting camera...</p>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Error overlay */}
|
|
175
|
+
{error && (
|
|
176
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 text-white gap-3 p-6">
|
|
177
|
+
<Camera size={48} className="text-red-400" />
|
|
178
|
+
<p className="text-sm text-center text-red-300">{error}</p>
|
|
179
|
+
<button
|
|
180
|
+
onClick={startScanner}
|
|
181
|
+
className="px-4 py-2 bg-brand-500 text-white rounded-lg text-sm hover:bg-brand-600 transition-colors"
|
|
182
|
+
>
|
|
183
|
+
Try Again
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Scan overlay frame */}
|
|
189
|
+
{!loading && !error && (
|
|
190
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
191
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
192
|
+
<div className="w-[250px] h-[250px] border-2 border-brand-500/50 rounded-lg">
|
|
193
|
+
{/* Corner accents */}
|
|
194
|
+
<div className="absolute top-0 left-0 w-6 h-6 border-t-3 border-l-3 border-brand-400 rounded-tl-lg" />
|
|
195
|
+
<div className="absolute top-0 right-0 w-6 h-6 border-t-3 border-r-3 border-brand-400 rounded-tr-lg" />
|
|
196
|
+
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-3 border-l-3 border-brand-400 rounded-bl-lg" />
|
|
197
|
+
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-3 border-r-3 border-brand-400 rounded-br-lg" />
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Controls */}
|
|
205
|
+
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
|
206
|
+
<p className="text-xs text-foreground/50">
|
|
207
|
+
Point camera at barcode or QR code
|
|
208
|
+
</p>
|
|
209
|
+
<div className="flex items-center gap-2">
|
|
210
|
+
<button
|
|
211
|
+
onClick={toggleCamera}
|
|
212
|
+
className="p-2 rounded-lg bg-surface hover:bg-border transition-colors text-foreground/70"
|
|
213
|
+
title="Switch Camera"
|
|
214
|
+
>
|
|
215
|
+
<SwitchCamera size={18} />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
const BEEP_DURATION = 150;
|
|
6
|
+
const BEEP_VOLUME = 0.3;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a beep sound using the Web Audio API.
|
|
10
|
+
* No external audio files needed — works everywhere.
|
|
11
|
+
*/
|
|
12
|
+
function createBeep(frequency: number, duration: number, volume: number) {
|
|
13
|
+
try {
|
|
14
|
+
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
15
|
+
const oscillator = audioCtx.createOscillator();
|
|
16
|
+
const gainNode = audioCtx.createGain();
|
|
17
|
+
|
|
18
|
+
oscillator.connect(gainNode);
|
|
19
|
+
gainNode.connect(audioCtx.destination);
|
|
20
|
+
|
|
21
|
+
oscillator.frequency.value = frequency;
|
|
22
|
+
oscillator.type = 'sine';
|
|
23
|
+
gainNode.gain.value = volume;
|
|
24
|
+
|
|
25
|
+
oscillator.start();
|
|
26
|
+
oscillator.stop(audioCtx.currentTime + duration / 1000);
|
|
27
|
+
|
|
28
|
+
// Clean up
|
|
29
|
+
oscillator.onended = () => audioCtx.close();
|
|
30
|
+
} catch {
|
|
31
|
+
// Audio not supported — fail silently
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hook that provides audio and visual feedback for barcode scanning.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const { playSuccess, playError, flashElement } = useScanFeedback();
|
|
41
|
+
*
|
|
42
|
+
* // On successful scan
|
|
43
|
+
* playSuccess();
|
|
44
|
+
* flashElement('green');
|
|
45
|
+
*
|
|
46
|
+
* // On failed scan
|
|
47
|
+
* playError();
|
|
48
|
+
* flashElement('red');
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function useScanFeedback() {
|
|
52
|
+
const flashTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
53
|
+
const flashElementRef = useRef<HTMLElement | null>(null);
|
|
54
|
+
|
|
55
|
+
/** Play a success beep (high pitch) */
|
|
56
|
+
const playSuccess = useCallback(() => {
|
|
57
|
+
createBeep(1200, BEEP_DURATION, BEEP_VOLUME);
|
|
58
|
+
// Double beep for success
|
|
59
|
+
setTimeout(() => createBeep(1500, BEEP_DURATION, BEEP_VOLUME), 100);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
/** Play an error beep (low pitch) */
|
|
63
|
+
const playError = useCallback(() => {
|
|
64
|
+
createBeep(300, 300, BEEP_VOLUME);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Flash the border of a target element green or red.
|
|
69
|
+
* Pass the element directly or use the ref returned by `getFlashRef`.
|
|
70
|
+
*/
|
|
71
|
+
const flashElement = useCallback((color: 'green' | 'red', element?: HTMLElement | null) => {
|
|
72
|
+
const target = element || flashElementRef.current;
|
|
73
|
+
if (!target) return;
|
|
74
|
+
|
|
75
|
+
// Clear any existing flash
|
|
76
|
+
if (flashTimeoutRef.current) {
|
|
77
|
+
clearTimeout(flashTimeoutRef.current);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const borderColor = color === 'green' ? 'rgb(34, 197, 94)' : 'rgb(239, 68, 68)';
|
|
81
|
+
const shadowColor = color === 'green' ? 'rgba(34, 197, 94, 0.4)' : 'rgba(239, 68, 68, 0.4)';
|
|
82
|
+
|
|
83
|
+
target.style.transition = 'box-shadow 0.15s ease-in, border-color 0.15s ease-in';
|
|
84
|
+
target.style.borderColor = borderColor;
|
|
85
|
+
target.style.boxShadow = `0 0 0 3px ${shadowColor}`;
|
|
86
|
+
|
|
87
|
+
flashTimeoutRef.current = setTimeout(() => {
|
|
88
|
+
target.style.borderColor = '';
|
|
89
|
+
target.style.boxShadow = '';
|
|
90
|
+
target.style.transition = '';
|
|
91
|
+
}, 600);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
playSuccess,
|
|
96
|
+
playError,
|
|
97
|
+
flashElement,
|
|
98
|
+
flashElementRef,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface UseBarcodeOptions {
|
|
6
|
+
/** Callback when a barcode is scanned */
|
|
7
|
+
onScan: (code: string) => void;
|
|
8
|
+
/** Minimum barcode length (default: 3) */
|
|
9
|
+
minLength?: number;
|
|
10
|
+
/** Maximum time between keystrokes in ms (default: 80) */
|
|
11
|
+
maxDelay?: number;
|
|
12
|
+
/** Whether the scanner is enabled (default: true) */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom hook that detects USB/Bluetooth barcode scanner input.
|
|
18
|
+
*
|
|
19
|
+
* Hardware barcode scanners work like fast keyboards — they type characters
|
|
20
|
+
* very quickly (under 50ms between keystrokes) and end with Enter.
|
|
21
|
+
* This hook detects that pattern and fires `onScan` with the scanned code.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* useBarcodeScanner({
|
|
26
|
+
* onScan: (code) => {
|
|
27
|
+
* console.log('Scanned:', code);
|
|
28
|
+
* // Look up product by barcode and add to cart
|
|
29
|
+
* },
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function useBarcodeScanner({
|
|
34
|
+
onScan,
|
|
35
|
+
minLength = 3,
|
|
36
|
+
maxDelay = 80,
|
|
37
|
+
enabled = true,
|
|
38
|
+
}: UseBarcodeOptions) {
|
|
39
|
+
const bufferRef = useRef('');
|
|
40
|
+
const lastKeyTimeRef = useRef(Date.now());
|
|
41
|
+
const onScanRef = useRef(onScan);
|
|
42
|
+
|
|
43
|
+
// Keep callback ref updated without re-registering the listener
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
onScanRef.current = onScan;
|
|
46
|
+
}, [onScan]);
|
|
47
|
+
|
|
48
|
+
const handleKeyDown = useCallback(
|
|
49
|
+
(e: KeyboardEvent) => {
|
|
50
|
+
// Skip if focused on an input/textarea (let the input handle it)
|
|
51
|
+
const activeTag = document.activeElement?.tagName.toLowerCase();
|
|
52
|
+
const isInputActive = activeTag === 'input' || activeTag === 'textarea';
|
|
53
|
+
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const timeDiff = now - lastKeyTimeRef.current;
|
|
56
|
+
lastKeyTimeRef.current = now;
|
|
57
|
+
|
|
58
|
+
// Reset buffer if too much time has passed between keystrokes
|
|
59
|
+
if (timeDiff > maxDelay) {
|
|
60
|
+
bufferRef.current = '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (e.key === 'Enter') {
|
|
64
|
+
const code = bufferRef.current.trim();
|
|
65
|
+
if (code.length >= minLength) {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
bufferRef.current = '';
|
|
69
|
+
onScanRef.current(code);
|
|
70
|
+
}
|
|
71
|
+
} else if (e.key.length === 1 && !isInputActive) {
|
|
72
|
+
bufferRef.current += e.key;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[minLength, maxDelay]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!enabled) return;
|
|
80
|
+
|
|
81
|
+
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
|
82
|
+
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
|
83
|
+
}, [enabled, handleKeyDown]);
|
|
84
|
+
}
|
package/package.json
CHANGED