POS System: - Full POS interface with product grid, cart panel, and payment flow - Product and category management with barcode scanning support - Cash drawer operations and shift management - Order history and receipt generation - Thermal printer integration (ESC/POS protocol) - Gift card support with purchase and redemption - Inventory tracking with low stock alerts - Customer selection and walk-in support Tax Rate Integration: - ZIP-to-state mapping for automatic state detection - SST boundary data import for 24 member states - Static rates for uniform-rate states (IN, MA, CT, etc.) - Statewide jurisdiction fallback for simple lookups - Tax rate suggestion in location editor with auto-apply - Multiple data sources: SST, CDTFA, TX Comptroller, Avalara UI Improvements: - POS renders full-screen outside BusinessLayout - Clear cart button prominently in cart header - Tax rate limited to 2 decimal places - Location tax rate field with suggestion UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
463 lines
11 KiB
TypeScript
463 lines
11 KiB
TypeScript
/**
|
|
* BarcodeScannerStatus Component
|
|
*
|
|
* Displays barcode scanner status and provides manual barcode entry fallback.
|
|
* Shows visual feedback when scanner is active and receiving input.
|
|
*/
|
|
|
|
import React, { useState, KeyboardEvent } from 'react';
|
|
import { useBarcodeScanner } from '../hooks/useBarcodeScanner';
|
|
import { useBarcodeScanner as useProductLookup } from '../hooks/usePOSProducts';
|
|
import { useCart } from '../hooks/useCart';
|
|
|
|
interface BarcodeScannerStatusProps {
|
|
/**
|
|
* Enable/disable scanner
|
|
*/
|
|
enabled: boolean;
|
|
|
|
/**
|
|
* Callback when barcode is scanned (manually or via scanner)
|
|
*/
|
|
onScan: (barcode: string) => void;
|
|
|
|
/**
|
|
* Show manual entry input
|
|
* @default true
|
|
*/
|
|
showManualEntry?: boolean;
|
|
|
|
/**
|
|
* Compact mode - icon only
|
|
* @default false
|
|
*/
|
|
compact?: boolean;
|
|
|
|
/**
|
|
* Custom keystroke threshold (ms)
|
|
*/
|
|
keystrokeThreshold?: number;
|
|
|
|
/**
|
|
* Custom timeout (ms)
|
|
*/
|
|
timeout?: number;
|
|
|
|
/**
|
|
* Minimum barcode length
|
|
*/
|
|
minLength?: number;
|
|
|
|
/**
|
|
* Auto-add products to cart when scanned
|
|
* @default false
|
|
*/
|
|
autoAddToCart?: boolean;
|
|
}
|
|
|
|
export const BarcodeScannerStatus: React.FC<BarcodeScannerStatusProps> = ({
|
|
enabled,
|
|
onScan,
|
|
showManualEntry = true,
|
|
compact = false,
|
|
keystrokeThreshold,
|
|
timeout,
|
|
minLength,
|
|
autoAddToCart = false,
|
|
}) => {
|
|
const [manualBarcode, setManualBarcode] = useState('');
|
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
const [lastScanSuccess, setLastScanSuccess] = useState(false);
|
|
|
|
const { lookupBarcode } = useProductLookup();
|
|
const { addProduct } = useCart();
|
|
|
|
/**
|
|
* Handle barcode scan from hardware scanner or manual entry
|
|
*/
|
|
const handleScan = async (barcode: string) => {
|
|
// Call the callback
|
|
onScan(barcode);
|
|
|
|
// Auto-add to cart if enabled
|
|
if (autoAddToCart) {
|
|
try {
|
|
const product = await lookupBarcode(barcode);
|
|
if (product) {
|
|
addProduct(product);
|
|
setLastScanSuccess(true);
|
|
setTimeout(() => setLastScanSuccess(false), 1000);
|
|
} else {
|
|
setLastScanSuccess(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Barcode lookup failed:', error);
|
|
setLastScanSuccess(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Hook into hardware scanner
|
|
const { buffer, isScanning } = useBarcodeScanner({
|
|
onScan: handleScan,
|
|
enabled,
|
|
keystrokeThreshold,
|
|
timeout,
|
|
minLength,
|
|
});
|
|
|
|
/**
|
|
* Handle manual barcode entry
|
|
*/
|
|
const handleManualSubmit = () => {
|
|
const trimmed = manualBarcode.trim();
|
|
if (trimmed) {
|
|
handleScan(trimmed);
|
|
setManualBarcode('');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle Enter key in manual input
|
|
*/
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleManualSubmit();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get status text
|
|
*/
|
|
const getStatusText = () => {
|
|
if (isScanning) return 'Scanning...';
|
|
if (!enabled) return 'Scanner Inactive';
|
|
return 'Scanner Active';
|
|
};
|
|
|
|
/**
|
|
* Get status color class
|
|
*/
|
|
const getStatusClass = () => {
|
|
if (isScanning) return 'scanning';
|
|
if (lastScanSuccess) return 'success';
|
|
if (!enabled) return 'inactive';
|
|
return 'active';
|
|
};
|
|
|
|
if (compact) {
|
|
return (
|
|
<div
|
|
className={`barcode-scanner-compact ${getStatusClass()} compact`}
|
|
aria-label="Barcode scanner status"
|
|
onMouseEnter={() => setShowTooltip(true)}
|
|
onMouseLeave={() => setShowTooltip(false)}
|
|
>
|
|
<div className="scanner-icon">
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
<path d="M8 7v10" />
|
|
<path d="M12 7v10" />
|
|
<path d="M16 7v10" />
|
|
</svg>
|
|
</div>
|
|
|
|
{showTooltip && (
|
|
<div className="scanner-tooltip" role="tooltip">
|
|
{getStatusText()}
|
|
{buffer && <div className="buffer-display">Code: {buffer}</div>}
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
.barcode-scanner-compact {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 6px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.barcode-scanner-compact.inactive {
|
|
color: #9CA3AF;
|
|
background: #F3F4F6;
|
|
}
|
|
|
|
.barcode-scanner-compact.active {
|
|
color: #10B981;
|
|
background: #D1FAE5;
|
|
}
|
|
|
|
.barcode-scanner-compact.scanning {
|
|
color: #3B82F6;
|
|
background: #DBEAFE;
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
|
|
.barcode-scanner-compact.success {
|
|
color: #10B981;
|
|
background: #D1FAE5;
|
|
animation: flash 0.5s ease-in-out;
|
|
}
|
|
|
|
.scanner-tooltip {
|
|
position: absolute;
|
|
top: calc(100% + 8px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #1F2937;
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
z-index: 1000;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.scanner-tooltip::before {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
border: 6px solid transparent;
|
|
border-bottom-color: #1F2937;
|
|
}
|
|
|
|
.buffer-display {
|
|
margin-top: 4px;
|
|
font-family: 'Courier New', monospace;
|
|
color: #60A5FA;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.6; }
|
|
}
|
|
|
|
@keyframes flash {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`barcode-scanner-status ${getStatusClass()}`} aria-label="Barcode scanner status">
|
|
<div className="scanner-indicator">
|
|
<div className="status-icon">
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
<path d="M8 7v10" />
|
|
<path d="M12 7v10" />
|
|
<path d="M16 7v10" />
|
|
</svg>
|
|
</div>
|
|
|
|
<div className="status-text">
|
|
<div className="status-label">{getStatusText()}</div>
|
|
{buffer && <div className="buffer-display">Reading: {buffer}</div>}
|
|
</div>
|
|
</div>
|
|
|
|
{showManualEntry && (
|
|
<div className="manual-entry">
|
|
<input
|
|
type="text"
|
|
value={manualBarcode}
|
|
onChange={(e) => setManualBarcode(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Enter barcode manually"
|
|
className="manual-input"
|
|
disabled={!enabled}
|
|
/>
|
|
<button
|
|
onClick={handleManualSubmit}
|
|
className="manual-submit"
|
|
disabled={!enabled || !manualBarcode.trim()}
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
.barcode-scanner-status {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
background: white;
|
|
border: 2px solid #E5E7EB;
|
|
border-radius: 8px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.barcode-scanner-status.inactive {
|
|
border-color: #E5E7EB;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.barcode-scanner-status.active {
|
|
border-color: #10B981;
|
|
background: #F0FDF4;
|
|
}
|
|
|
|
.barcode-scanner-status.scanning {
|
|
border-color: #3B82F6;
|
|
background: #EFF6FF;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
|
|
.barcode-scanner-status.success {
|
|
border-color: #10B981;
|
|
background: #D1FAE5;
|
|
animation: flash 0.5s ease-in-out;
|
|
}
|
|
|
|
.scanner-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.status-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 8px;
|
|
background: white;
|
|
}
|
|
|
|
.barcode-scanner-status.inactive .status-icon {
|
|
color: #9CA3AF;
|
|
}
|
|
|
|
.barcode-scanner-status.active .status-icon {
|
|
color: #10B981;
|
|
}
|
|
|
|
.barcode-scanner-status.scanning .status-icon {
|
|
color: #3B82F6;
|
|
}
|
|
|
|
.barcode-scanner-status.success .status-icon {
|
|
color: #10B981;
|
|
}
|
|
|
|
.status-text {
|
|
flex: 1;
|
|
}
|
|
|
|
.status-label {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #1F2937;
|
|
}
|
|
|
|
.buffer-display {
|
|
font-size: 12px;
|
|
color: #6B7280;
|
|
font-family: 'Courier New', monospace;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.manual-entry {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.manual-input {
|
|
flex: 1;
|
|
padding: 8px 12px;
|
|
border: 1px solid #D1D5DB;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-family: 'Courier New', monospace;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.manual-input:focus {
|
|
outline: none;
|
|
border-color: #3B82F6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.manual-input:disabled {
|
|
background: #F3F4F6;
|
|
color: #9CA3AF;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.manual-submit {
|
|
padding: 8px 16px;
|
|
background: #3B82F6;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.manual-submit:hover:not(:disabled) {
|
|
background: #2563EB;
|
|
}
|
|
|
|
.manual-submit:disabled {
|
|
background: #D1D5DB;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.8; }
|
|
}
|
|
|
|
@keyframes flash {
|
|
0%, 100% { opacity: 1; }
|
|
25%, 75% { opacity: 0.7; }
|
|
50% { opacity: 0.9; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BarcodeScannerStatus;
|