diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 188c9c41..e116800d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -130,6 +130,8 @@ const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import Pub const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page +const POS = React.lazy(() => import('./pages/POS')); // Import Point of Sale page +const Products = React.lazy(() => import('./pages/Products')); // Import Products management page // Settings pages const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout')); @@ -765,6 +767,18 @@ const AppContent: React.FC = () => { } /> } /> + {/* Point of Sale - Full screen mode outside BusinessLayout */} + + ) : ( + + ) + } + /> + {/* Dashboard routes inside BusinessLayout */} { ) } /> + {/* Products Management */} + + ) : ( + + ) + } + /> {/* Settings Routes with Nested Layout */} {/* Owners have full access, staff need can_access_settings permission */} {canAccess('can_access_settings') ? ( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e946cdee..88f44000 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -17,10 +17,13 @@ import { CalendarOff, Image, BarChart3, + ShoppingCart, + Package, } from 'lucide-react'; import { Business, User } from '../types'; import { useLogout } from '../hooks/useAuth'; import { usePlanFeatures } from '../hooks/usePlanFeatures'; +import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements'; import SmoothScheduleLogo from './SmoothScheduleLogo'; import UnfinishedBadge from './ui/UnfinishedBadge'; import { @@ -41,6 +44,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo const { role } = user; const logoutMutation = useLogout(); const { canUse } = usePlanFeatures(); + const { hasFeature } = useEntitlements(); // Helper to check if user has a specific staff permission // Owners always have all permissions @@ -139,6 +143,24 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo )} + {/* Point of Sale Section - Requires tenant feature AND user permission */} + {hasFeature(FEATURE_CODES.CAN_USE_POS) && hasPermission('can_access_pos') && ( + + + + + )} + {/* Staff-only: My Schedule and My Availability */} {((isStaff && hasPermission('can_access_my_schedule')) || ((role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability'))) && ( diff --git a/frontend/src/hooks/useTaxRates.ts b/frontend/src/hooks/useTaxRates.ts new file mode 100644 index 00000000..9b1d6b8f --- /dev/null +++ b/frontend/src/hooks/useTaxRates.ts @@ -0,0 +1,176 @@ +/** + * Hook for looking up tax rates by ZIP code or address. + * + * Supports multiple data sources: + * - SST (Streamlined Sales Tax) for 24 member states - address-level accuracy + * - California CDTFA data + * - Texas Comptroller data + * - ZIP-based fallback for other states + */ +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../api/client'; + +/** + * Tax rate lookup result. + */ +export interface TaxRateLookup { + zip_code: string; + zip_ext?: string; + state: string; + combined_rate: number; + combined_rate_percent: string; + state_rate: number; + county_rate: number; + city_rate: number; + special_rate: number; + // Source and accuracy info + source: 'sst' | 'cdtfa' | 'tx_comptroller' | 'avalara' | 'state_dor' | 'no_sales_tax' | 'not_found'; + accuracy: 'zip9' | 'zip5' | 'zip' | 'address' | 'jurisdiction' | 'state' | 'exact' | 'none'; + // Jurisdiction details + jurisdiction_code?: string; + jurisdiction_name?: string; + county_name?: string; + city_name?: string; + // SST liability protection + liability_protection: boolean; + effective_date?: string; + // Legacy fields for backwards compatibility + risk_level?: number; + has_multiple_rates?: boolean; + note?: string; + error?: string; +} + +/** + * ZIP-based tax rate (fallback data). + */ +export interface TaxRate { + id: number; + state: string; + zip_code: string; + tax_region_name: string; + estimated_combined_rate: string; + combined_rate_percent: string; + state_rate: string; + estimated_county_rate: string; + estimated_city_rate: string; + estimated_special_rate: string; + risk_level: number; + source: string; + effective_date: string; +} + +/** + * Parameters for tax rate lookup. + */ +export interface TaxLookupParams { + zipCode: string; + zipExt?: string; + state?: string; + streetAddress?: string; +} + +/** + * Look up tax rate by ZIP code or address. + * + * @param params - Lookup parameters or ZIP code string + * @param options - Query options + * @returns Tax rate lookup result + * + * @example + * // Simple ZIP lookup + * const { data } = useTaxRateLookup('84003'); + * + * @example + * // Enhanced lookup with ZIP+4 for better accuracy + * const { data } = useTaxRateLookup({ + * zipCode: '84003', + * zipExt: '1234', + * state: 'UT' + * }); + */ +export function useTaxRateLookup( + params: string | TaxLookupParams | null, + options?: { enabled?: boolean } +) { + // Normalize params + const zipCode = typeof params === 'string' ? params : params?.zipCode || ''; + const zipExt = typeof params === 'string' ? '' : params?.zipExt || ''; + const state = typeof params === 'string' ? '' : params?.state || ''; + const streetAddress = typeof params === 'string' ? '' : params?.streetAddress || ''; + + const normalizedZip = zipCode.replace(/\D/g, '').slice(0, 5); + const normalizedExt = zipExt.replace(/\D/g, '').slice(0, 4); + const normalizedState = state.replace(/[^A-Za-z]/g, '').slice(0, 2).toUpperCase(); + const isValidZip = normalizedZip.length === 5; + + // Build query key based on all params + const queryKey = ['tax', 'lookup', normalizedZip, normalizedExt, normalizedState, streetAddress]; + + return useQuery({ + queryKey, + queryFn: async () => { + const response = await apiClient.get('/tax/lookup/', { + params: { + zip_code: normalizedZip, + ...(normalizedExt && { zip_ext: normalizedExt }), + ...(normalizedState && { state: normalizedState }), + ...(streetAddress && { street_address: streetAddress }), + }, + }); + return response.data; + }, + enabled: isValidZip && (options?.enabled !== false), + staleTime: 1000 * 60 * 60 * 24, // Cache for 24 hours (tax rates don't change often) + retry: false, // Don't retry if ZIP not found + }); +} + +/** + * Get tax rate as a decimal for a ZIP code. + * Returns null if not found or still loading. + * + * @param zipCode - 5-digit US ZIP code + * @returns Combined tax rate as decimal (e.g., 0.0825) or null + */ +export function useTaxRateForZip(zipCode: string | null): number | null { + const { data, isSuccess } = useTaxRateLookup(zipCode); + + if (isSuccess && data && data.source !== 'not_found') { + return data.combined_rate; + } + + return null; +} + +/** + * Get formatted tax rate info for display. + * Includes source and accuracy information. + */ +export function useTaxRateInfo(params: string | TaxLookupParams | null) { + const { data, isLoading, isError } = useTaxRateLookup(params); + + if (isLoading) { + return { loading: true }; + } + + if (isError || !data || data.source === 'not_found') { + return { loading: false, notFound: true }; + } + + return { + loading: false, + notFound: false, + rate: data.combined_rate, + ratePercent: data.combined_rate_percent, + source: data.source, + accuracy: data.accuracy, + jurisdictionName: data.jurisdiction_name, + liabilityProtection: data.liability_protection, + note: data.note, + // Helper flags + isSST: data.source === 'sst', + isNoTax: data.source === 'no_sales_tax', + isHighAccuracy: ['zip9', 'address', 'exact'].includes(data.accuracy), + }; +} diff --git a/frontend/src/pages/Locations.tsx b/frontend/src/pages/Locations.tsx index a0241aa2..7975f171 100644 --- a/frontend/src/pages/Locations.tsx +++ b/frontend/src/pages/Locations.tsx @@ -4,7 +4,7 @@ * Allows business owners/managers to manage multiple locations. */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Location } from '../types'; import { @@ -15,6 +15,7 @@ import { useSetPrimaryLocation, useSetLocationActive, } from '../hooks/useLocations'; +import { useTaxRateLookup } from '../hooks/useTaxRates'; import { Plus, MapPin, @@ -25,6 +26,7 @@ import { Power, PowerOff, Building2, + Zap, } from 'lucide-react'; import { Modal, FormInput, Button, Alert } from '../components/ui'; @@ -39,6 +41,7 @@ interface LocationFormData { phone: string; email: string; timezone: string; + default_tax_rate: string; // Stored as percentage (e.g., "8.25" for 8.25%) } const emptyFormData: LocationFormData = { @@ -52,6 +55,7 @@ const emptyFormData: LocationFormData = { phone: '', email: '', timezone: '', + default_tax_rate: '', }; const Locations: React.FC = () => { @@ -69,6 +73,20 @@ const Locations: React.FC = () => { const setPrimaryMutation = useSetPrimaryLocation(); const setActiveMutation = useSetLocationActive(); + // Tax rate lookup for ZIP code auto-suggest + const { data: taxRateData, isLoading: isLoadingTaxRate } = useTaxRateLookup( + formData.country === 'US' ? formData.postal_code : null, + { enabled: isModalOpen && formData.country === 'US' && formData.postal_code.length >= 5 } + ); + + // Auto-apply suggested tax rate when data loads (only if field is empty) + useEffect(() => { + if (taxRateData && !formData.default_tax_rate && !editingLocation) { + const suggestedRate = (taxRateData.combined_rate * 100).toFixed(2); + setFormData(prev => ({ ...prev, default_tax_rate: suggestedRate })); + } + }, [taxRateData, formData.default_tax_rate, editingLocation]); + const handleOpenCreate = () => { setEditingLocation(null); setFormData(emptyFormData); @@ -77,6 +95,10 @@ const Locations: React.FC = () => { const handleOpenEdit = (location: Location) => { setEditingLocation(location); + // Convert tax rate from decimal (0.0825) to percentage string ("8.25") + const taxRatePercent = location.default_tax_rate + ? (Number(location.default_tax_rate) * 100).toFixed(2) + : ''; setFormData({ name: location.name, address_line1: location.address_line1 || '', @@ -88,6 +110,7 @@ const Locations: React.FC = () => { phone: location.phone || '', email: location.email || '', timezone: location.timezone || '', + default_tax_rate: taxRatePercent, }); setIsModalOpen(true); setActiveMenu(null); @@ -101,14 +124,24 @@ const Locations: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // Convert tax rate from percentage string ("8.25") to decimal (0.0825) + const taxRateDecimal = formData.default_tax_rate + ? parseFloat(formData.default_tax_rate) / 100 + : 0; + + const submitData = { + ...formData, + default_tax_rate: taxRateDecimal, + }; + try { if (editingLocation) { await updateMutation.mutateAsync({ id: editingLocation.id, - updates: formData, + updates: submitData, }); } else { - await createMutation.mutateAsync(formData); + await createMutation.mutateAsync(submitData); } setIsModalOpen(false); setFormData(emptyFormData); @@ -328,6 +361,60 @@ const Locations: React.FC = () => { /> +
+ + {/* Tax rate suggestion from ZIP code lookup */} + {taxRateData && formData.country === 'US' && ( +
+
+ +
+

+ Suggested rate for {taxRateData.zip_code}:{' '} + {taxRateData.combined_rate_percent} + {taxRateData.jurisdiction_name && ( + ({taxRateData.jurisdiction_name}) + )} +

+ {taxRateData.has_multiple_rates && ( +

+ Note: This ZIP code spans multiple tax jurisdictions. Verify with your tax advisor. +

+ )} + {formData.default_tax_rate !== (taxRateData.combined_rate * 100).toFixed(2) && ( + + )} +
+
+
+ )} + {isLoadingTaxRate && formData.postal_code.length >= 5 && formData.country === 'US' && ( +

+ Looking up tax rate for {formData.postal_code}... +

+ )} +
+
{/* Status Badge */} diff --git a/frontend/src/pages/POS.tsx b/frontend/src/pages/POS.tsx new file mode 100644 index 00000000..94919442 --- /dev/null +++ b/frontend/src/pages/POS.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { POSProvider, usePOS } from '../pos/context/POSContext'; +import { useLocations } from '../hooks/useLocations'; +import { useCurrentUser } from '../hooks/useAuth'; +import { useCurrentBusiness } from '../hooks/useBusiness'; +import POSLayout from '../pos/components/POSLayout'; +import POSHeader from '../pos/components/POSHeader'; +import { LoadingSpinner, Alert } from '../components/ui'; + +/** + * POS Page - Main Point of Sale Interface + * + * Features: + * - Full-screen POS mode (hides main app navigation) + * - Location selection for multi-location businesses + * - Active shift verification + * - Wraps components with POSProvider context + * + * Component composition approach: + * - Page handles data fetching and location selection + * - POSProvider manages cart/shift state at top level + * - POSLayout handles the main grid layout + * - POSHeader provides minimal navigation + */ + +interface POSContentProps { + locationId: number; +} + +/** + * Inner content component - must be inside POSProvider + */ +const POSContent: React.FC = ({ locationId }) => { + const { state } = usePOS(); + const { data: user } = useCurrentUser(); + const { data: business } = useCurrentBusiness(); + + return ( +
+ {/* Custom POS header instead of main app navigation */} + + + {/* Main POS interface */} +
+ +
+
+ ); +}; + +/** + * Location selector modal for multi-location businesses + */ +interface LocationSelectorProps { + locations: Array<{ id: number; name: string; address: string }>; + onSelect: (locationId: number) => void; +} + +const LocationSelector: React.FC = ({ locations, onSelect }) => { + return ( +
+
+

Select Location

+

+ Choose which location you'll be working from for this POS session. +

+ +
+ {locations.map((location) => ( + + ))} +
+
+
+ ); +}; + +/** + * Main POS Page Component + */ +const POS: React.FC = () => { + const { data: user, isLoading: userLoading } = useCurrentUser(); + const { data: business, isLoading: businessLoading } = useCurrentBusiness(); + const { data: locations, isLoading: locationsLoading } = useLocations(); + const [selectedLocationId, setSelectedLocationId] = useState(null); + + // Check user permissions + const canAccessPOS = user?.role === 'owner' || + user?.role === 'staff' || + user?.effective_permissions?.can_access_pos === true; + + // Auto-select location if only one exists + useEffect(() => { + if (locations && locations.length === 1 && !selectedLocationId) { + setSelectedLocationId(locations[0].id); + } + }, [locations, selectedLocationId]); + + // Loading state + if (userLoading || businessLoading || locationsLoading) { + return ( +
+
+ +

Loading POS...

+
+
+ ); + } + + // Permission check + if (!canAccessPOS) { + return ( +
+
+ + You don't have permission to access the Point of Sale system. + Contact your administrator for access. + +
+
+ ); + } + + // No locations configured + if (!locations || locations.length === 0) { + return ( +
+
+ +
No Locations Found
+
+ You need to set up at least one location before using the POS system. + Go to Settings → Locations to add a location. +
+
+ + Go to Locations Settings + +
+
+ ); + } + + // Location selection required for multi-location businesses + if (!selectedLocationId && locations.length > 1) { + return ( + + ); + } + + // Render POS interface with provider + const locationId = selectedLocationId || locations[0].id; + + return ( + + + + ); +}; + +export default POS; diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx new file mode 100644 index 00000000..fd5dc923 --- /dev/null +++ b/frontend/src/pages/Products.tsx @@ -0,0 +1,395 @@ +/** + * Products Page + * + * Manage POS products and inventory. + */ + +import { useState, useMemo } from 'react'; +import { FolderOpen, ArrowLeftRight, Package } from 'lucide-react'; +import { + Button, + FormInput, + FormSelect, + TabGroup, + Badge, + EmptyState, + PageLoading, + ErrorMessage, +} from '../components/ui'; +import { ProductEditorModal } from '../pos/components/ProductEditorModal'; +import { CategoryManagerModal } from '../pos/components/CategoryManagerModal'; +import InventoryTransferModal from '../pos/components/InventoryTransferModal'; +import { + useProducts, + useProductCategories, + ProductFilters, +} from '../pos/hooks/usePOSProducts'; +import { useLowStockItems } from '../pos/hooks/useInventory'; +import { useDeleteProduct, useToggleProductStatus } from '../pos/hooks/useProductMutations'; +import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements'; +import type { POSProduct } from '../pos/types'; + +type ViewTab = 'all' | 'active' | 'inactive' | 'low-stock'; + +export default function ProductsPage() { + // Check if POS feature is enabled + const { hasFeature, isLoading: entitlementsLoading } = useEntitlements(); + const hasPOSFeature = hasFeature(FEATURE_CODES.CAN_USE_POS); + + // State + const [activeTab, setActiveTab] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isCategoryManagerOpen, setIsCategoryManagerOpen] = useState(false); + const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); + const [selectedProduct, setSelectedProduct] = useState(null); + + // Build filters based on tab and search + const filters: ProductFilters = useMemo(() => { + const f: ProductFilters = {}; + + if (searchQuery) { + f.search = searchQuery; + } + + if (categoryFilter) { + f.categoryId = parseInt(categoryFilter, 10); + } + + if (activeTab === 'active') { + f.status = 'active'; + } else if (activeTab === 'inactive') { + f.status = 'inactive'; + } + + return f; + }, [activeTab, searchQuery, categoryFilter]); + + // Query hooks + const { data: products, isLoading, error } = useProducts(filters); + const { data: categories } = useProductCategories(); + const { data: lowStockItems } = useLowStockItems(); + + // Mutation hooks + const deleteProduct = useDeleteProduct(); + const toggleStatus = useToggleProductStatus(); + + // Filter products for low stock tab + const displayProducts = useMemo(() => { + if (activeTab === 'low-stock') { + // Get product IDs that are low stock + const lowStockProductIds = new Set(lowStockItems?.map((item) => item.product) || []); + return products?.filter((p) => lowStockProductIds.has(p.id)) || []; + } + return products || []; + }, [activeTab, products, lowStockItems]); + + const handleAddProduct = () => { + setSelectedProduct(null); + setIsEditorOpen(true); + }; + + const handleEditProduct = (product: POSProduct) => { + setSelectedProduct(product); + setIsEditorOpen(true); + }; + + const handleDeleteProduct = async (product: POSProduct) => { + if (!confirm(`Are you sure you want to delete "${product.name}"?`)) { + return; + } + await deleteProduct.mutateAsync(product.id); + }; + + const handleToggleStatus = async (product: POSProduct) => { + await toggleStatus.mutateAsync({ + id: product.id, + is_active: product.status !== 'active', + }); + }; + + const handleTransferInventory = (product: POSProduct) => { + setSelectedProduct(product); + setIsTransferModalOpen(true); + }; + + const categoryOptions = [ + { value: '', label: 'All Categories' }, + ...(categories?.map((cat) => ({ + value: cat.id.toString(), + label: cat.name, + })) || []), + ]; + + const tabs = [ + { id: 'all' as const, label: 'All Products' }, + { id: 'active' as const, label: 'Active' }, + { id: 'inactive' as const, label: 'Inactive' }, + { + id: 'low-stock' as const, + label: `Low Stock${lowStockItems?.length ? ` (${lowStockItems.length})` : ''}`, + }, + ]; + + const formatPrice = (cents: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(cents / 100); + }; + + // Check for entitlements loading + if (entitlementsLoading) { + return ; + } + + // Show upgrade prompt if POS is not enabled + if (!hasPOSFeature) { + return ( +
+
+
+
+ +
+

+ Product Management +

+

+ Unlock powerful product and inventory management features with our + Point of Sale add-on. Track stock levels, manage categories, and more. +

+
+ + +
+
+
+
+ ); + } + + if (isLoading) { + return ; + } + + return ( +
+ {/* Header */} +
+
+

Products

+

Manage your product catalog and inventory

+
+
+ + + +
+
+ + {error && } + + {/* Filters */} +
+ setActiveTab(tab as ViewTab)} + /> + +
+
+ setSearchQuery(e.target.value)} + /> +
+
+ setCategoryFilter(e.target.value)} + options={categoryOptions} + /> +
+
+
+ + {/* Product List */} + {displayProducts.length === 0 ? ( + Add Product + ) : undefined + } + /> + ) : ( +
+ + + + + + + + + + + + + {displayProducts.map((product) => ( + + + + + + + + + ))} + +
+ Product + + Category + + Price + + Stock + + Status + + Actions +
+
+
+ {product.name} +
+ {product.sku && ( +
SKU: {product.sku}
+ )} +
+
+ + {product.category_name || '—'} + + + + {formatPrice(product.price_cents)} + + + {product.track_inventory ? ( +
+ + {product.quantity_in_stock ?? 0} + + {product.is_low_stock && ( + + Low + + )} +
+ ) : ( + Not tracked + )} +
+ + {product.status === 'active' ? 'Active' : 'Inactive'} + + + + {product.track_inventory && ( + + )} + + +
+
+ )} + + {/* Editor Modal */} + setIsEditorOpen(false)} + product={selectedProduct} + onSuccess={() => { + setIsEditorOpen(false); + setSelectedProduct(null); + }} + /> + + {/* Category Manager Modal */} + setIsCategoryManagerOpen(false)} + /> + + {/* Inventory Transfer Modal */} + { + setIsTransferModalOpen(false); + setSelectedProduct(null); + }} + onSuccess={() => { + setIsTransferModalOpen(false); + setSelectedProduct(null); + }} + productId={selectedProduct?.id} + /> +
+ ); +} diff --git a/frontend/src/pos/BARCODE_SCANNER.md b/frontend/src/pos/BARCODE_SCANNER.md new file mode 100644 index 00000000..0a43b1c0 --- /dev/null +++ b/frontend/src/pos/BARCODE_SCANNER.md @@ -0,0 +1,355 @@ +# Barcode Scanner Integration + +This document describes the barcode scanner integration for the POS module. + +## Overview + +The barcode scanner integration provides: +- Keyboard-wedge barcode scanner support (hardware scanners that emit keystrokes) +- Automatic detection of rapid keystrokes (distinguishes from normal typing) +- Manual barcode entry fallback +- Visual feedback during scanning +- Auto-add to cart functionality +- Product lookup integration + +## Architecture + +### Components + +1. **useBarcodeScanner Hook** (`/pos/hooks/useBarcodeScanner.ts`) + - Listens for rapid keystrokes characteristic of barcode scanners + - Buffers characters until Enter key or timeout + - Returns scanner state (buffer, isScanning) + - Distinguishes scanner input from normal typing based on speed + +2. **BarcodeScannerStatus Component** (`/pos/components/BarcodeScannerStatus.tsx`) + - Visual indicator showing scanner status + - Manual barcode entry input + - Integration with product lookup and cart + - Compact and full modes + +3. **Product Lookup Integration** (`/pos/hooks/usePOSProducts.ts`) + - `useBarcodeScanner()` hook provides `lookupBarcode()` function + - Searches products by barcode field + - Returns product data or null if not found + +## Usage + +### Basic Usage + +```typescript +import { BarcodeScannerStatus } from '../pos/components'; + +function POSTerminal() { + const handleScan = (barcode: string) => { + console.log('Scanned:', barcode); + }; + + return ( + + ); +} +``` + +### Auto-Add to Cart + +```typescript +function QuickCheckout() { + return ( + console.log('Product added:', barcode)} + autoAddToCart={true} // Automatically look up and add to cart + showManualEntry={true} + /> + ); +} +``` + +### Compact Mode + +```typescript +function POSHeader() { + return ( +
+

Checkout

+ +
+ ); +} +``` + +### Custom Configuration + +```typescript +function CustomScanner() { + return ( + + ); +} +``` + +### Full Integration Example + +```typescript +import { BarcodeScannerStatus } from '../pos/components'; +import { useCart } from '../pos/hooks/useCart'; +import { useBarcodeScanner } from '../pos/hooks/usePOSProducts'; + +function POSTerminal() { + const { addProduct } = useCart(); + const { lookupBarcode } = useBarcodeScanner(); + const [error, setError] = useState(null); + + const handleScan = async (barcode: string) => { + setError(null); + + try { + const product = await lookupBarcode(barcode); + + if (product) { + addProduct(product); + // Show success notification + } else { + setError(`Product not found: ${barcode}`); + } + } catch (err) { + setError('Failed to process barcode'); + } + }; + + return ( +
+ + + {error &&
{error}
} +
+ ); +} +``` + +## Props + +### BarcodeScannerStatus Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `enabled` | `boolean` | Required | Enable/disable scanner listening | +| `onScan` | `(barcode: string) => void` | Required | Callback when barcode detected | +| `showManualEntry` | `boolean` | `true` | Show manual barcode input field | +| `compact` | `boolean` | `false` | Show compact icon-only mode | +| `keystrokeThreshold` | `number` | `100` | Max ms between keystrokes (scanner detection) | +| `timeout` | `number` | `200` | Ms to wait after last keystroke | +| `minLength` | `number` | `3` | Minimum barcode length | +| `autoAddToCart` | `boolean` | `false` | Automatically lookup and add products to cart | + +### useBarcodeScanner Hook Options + +```typescript +interface UseBarcodeScannerOptions { + onScan: (barcode: string) => void; + enabled?: boolean; // Default: false + keystrokeThreshold?: number; // Default: 100ms + timeout?: number; // Default: 200ms + minLength?: number; // Default: 3 + ignoreKeys?: string[]; // Keys to ignore (default: modifiers, arrows, etc.) +} +``` + +### useBarcodeScanner Hook Return Value + +```typescript +interface UseBarcodeScannerReturn { + buffer: string; // Current accumulated characters + isScanning: boolean; // Whether scanner is actively receiving input + clearBuffer: () => void; // Manually clear the buffer +} +``` + +## How It Works + +### Scanner Detection Algorithm + +1. **Keystroke Timing**: Hardware scanners typically send keystrokes at 10-50ms intervals, while humans type at 200-400ms intervals +2. **Threshold**: Default 100ms threshold distinguishes scanner from human input +3. **Buffer Management**: Characters are accumulated in a buffer +4. **Completion**: Scan completes on: + - Enter key press + - Timeout after last keystroke (default 200ms) +5. **Reset**: Buffer clears if gap between keystrokes exceeds threshold + +### Example Timeline + +``` +Scanner (FAST - triggers callback): + [0ms] Key: '1' + [10ms] Key: '2' + [20ms] Key: '3' + [30ms] Key: 'Enter' + → Callback: onScan('123') + +Human (SLOW - ignored): + [0ms] Key: '1' + [250ms] Key: '2' (gap > 100ms, buffer cleared) + [500ms] Key: '3' (gap > 100ms, buffer cleared) + [750ms] Key: 'Enter' + → No callback +``` + +### Input Filtering + +The scanner automatically ignores: +- Input focused on ``, `