From 1aa5b76e3be0b43df7e602c53408b20b1f596b37 Mon Sep 17 00:00:00 2001 From: poduck Date: Sat, 27 Dec 2025 11:31:19 -0500 Subject: [PATCH] Add Point of Sale system and tax rate lookup integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.tsx | 25 + frontend/src/components/Sidebar.tsx | 22 + frontend/src/hooks/useTaxRates.ts | 176 ++ frontend/src/pages/Locations.tsx | 96 +- frontend/src/pages/POS.tsx | 185 ++ frontend/src/pages/Products.tsx | 395 +++++ frontend/src/pos/BARCODE_SCANNER.md | 355 ++++ frontend/src/pos/README.md | 528 ++++++ .../src/pos/__tests__/ESCPOSBuilder.test.ts | 619 +++++++ .../src/pos/__tests__/POSContext.test.tsx | 696 ++++++++ frontend/src/pos/__tests__/utils.test.ts | 550 ++++++ .../BarcodeScannerStatus.example.tsx | 220 +++ .../pos/components/BarcodeScannerStatus.tsx | 462 +++++ frontend/src/pos/components/CartItem.tsx | 157 ++ frontend/src/pos/components/CartPanel.tsx | 261 +++ .../src/pos/components/CashDrawerPanel.tsx | 191 +++ .../src/pos/components/CashPaymentPanel.tsx | 291 ++++ .../pos/components/CategoryManagerModal.tsx | 341 ++++ frontend/src/pos/components/CategoryTabs.tsx | 102 ++ .../src/pos/components/CloseShiftModal.tsx | 349 ++++ .../pos/components/CustomerSelect.example.tsx | 159 ++ .../src/pos/components/CustomerSelect.tsx | 380 +++++ frontend/src/pos/components/DiscountModal.tsx | 320 ++++ .../pos/components/GiftCardPaymentPanel.tsx | 277 +++ .../pos/components/GiftCardPurchaseModal.tsx | 356 ++++ .../pos/components/InventoryTransferModal.tsx | 271 +++ frontend/src/pos/components/NumPad.tsx | 346 ++++ frontend/src/pos/components/OpenItemModal.tsx | 280 +++ .../src/pos/components/OpenShiftModal.tsx | 198 +++ .../src/pos/components/OrderDetailModal.tsx | 470 +++++ .../src/pos/components/OrderHistoryPanel.tsx | 245 +++ frontend/src/pos/components/PAYMENT_FLOW.md | 632 +++++++ frontend/src/pos/components/POSHeader.tsx | 165 ++ frontend/src/pos/components/POSLayout.tsx | 562 ++++++ frontend/src/pos/components/PaymentModal.tsx | 589 +++++++ .../pos/components/PrinterConnectionPanel.tsx | 250 +++ frontend/src/pos/components/PrinterStatus.tsx | 91 + .../src/pos/components/ProductEditorModal.tsx | 470 +++++ frontend/src/pos/components/ProductGrid.tsx | 194 +++ frontend/src/pos/components/QuickSearch.tsx | 111 ++ .../src/pos/components/ReceiptPreview.tsx | 291 ++++ frontend/src/pos/components/ShiftSummary.tsx | 201 +++ frontend/src/pos/components/TipSelector.tsx | 269 +++ .../__tests__/BarcodeScannerStatus.test.tsx | 290 ++++ .../components/__tests__/CartItem.test.tsx | 480 ++++++ .../components/__tests__/CartPanel.test.tsx | 638 +++++++ .../__tests__/CashDrawerPanel.test.tsx | 205 +++ .../__tests__/CashPaymentPanel.test.tsx | 630 +++++++ .../__tests__/CategoryManagerModal.test.tsx | 1017 +++++++++++ .../__tests__/CategoryTabs.test.tsx | 241 +++ .../__tests__/CloseShiftModal.test.tsx | 389 +++++ .../__tests__/CustomerSelect.test.tsx | 288 ++++ .../__tests__/DiscountModal.test.tsx | 525 ++++++ .../__tests__/GiftCardPaymentPanel.test.tsx | 539 ++++++ .../__tests__/GiftCardPurchaseModal.test.tsx | 551 ++++++ .../__tests__/InventoryTransferModal.test.tsx | 453 +++++ .../__tests__/OpenItemModal.test.tsx | 331 ++++ .../__tests__/OpenShiftModal.test.tsx | 315 ++++ .../__tests__/OrderDetailModal.test.tsx | 598 +++++++ .../__tests__/OrderHistoryPanel.test.tsx | 376 ++++ .../components/__tests__/POSHeader.test.tsx | 288 ++++ .../components/__tests__/POSLayout.test.tsx | 1472 ++++++++++++++++ .../__tests__/PaymentModal.test.tsx | 1297 ++++++++++++++ .../__tests__/PrinterConnectionPanel.test.tsx | 759 +++++++++ .../__tests__/PrinterStatus.test.tsx | 401 +++++ .../__tests__/ProductEditorModal.test.tsx | 969 +++++++++++ .../components/__tests__/ProductGrid.test.tsx | 540 ++++++ .../components/__tests__/QuickSearch.test.tsx | 346 ++++ .../__tests__/ReceiptPreview.test.tsx | 854 ++++++++++ .../__tests__/ShiftSummary.test.tsx | 143 ++ .../components/__tests__/TipSelector.test.tsx | 288 ++++ frontend/src/pos/components/index.ts | 24 + frontend/src/pos/context/POSContext.tsx | 586 +++++++ .../pos/context/__tests__/POSContext.test.tsx | 595 +++++++ frontend/src/pos/hardware/ESCPOSBuilder.ts | 522 ++++++ .../pos/hardware/GiftCardReceiptBuilder.ts | 420 +++++ frontend/src/pos/hardware/ReceiptBuilder.ts | 433 +++++ .../src/pos/hardware/ShiftReportBuilder.ts | 402 +++++ .../__tests__/GiftCardReceiptBuilder.test.ts | 877 ++++++++++ .../hardware/__tests__/ReceiptBuilder.test.ts | 1036 +++++++++++ .../__tests__/ShiftReportBuilder.test.ts | 868 ++++++++++ frontend/src/pos/hardware/constants.ts | 349 ++++ frontend/src/pos/hardware/index.ts | 101 ++ .../hooks/__tests__/useBarcodeScanner.test.ts | 362 ++++ .../src/pos/hooks/__tests__/useCart.test.ts | 683 ++++++++ .../pos/hooks/__tests__/useCashDrawer.test.ts | 714 ++++++++ .../pos/hooks/__tests__/useGiftCards.test.ts | 557 ++++++ .../pos/hooks/__tests__/useInventory.test.tsx | 1258 ++++++++++++++ .../src/pos/hooks/__tests__/useOrders.test.ts | 506 ++++++ .../hooks/__tests__/usePOSProducts.test.ts | 907 ++++++++++ .../pos/hooks/__tests__/usePayment.test.ts | 857 ++++++++++ .../__tests__/useProductMutations.test.ts | 814 +++++++++ .../hooks/__tests__/useThermalPrinter.test.ts | 873 ++++++++++ frontend/src/pos/hooks/useBarcodeScanner.ts | 229 +++ frontend/src/pos/hooks/useCart.ts | 297 ++++ frontend/src/pos/hooks/useCashDrawer.ts | 368 ++++ frontend/src/pos/hooks/useGiftCards.ts | 107 ++ frontend/src/pos/hooks/useInventory.ts | 298 ++++ frontend/src/pos/hooks/useOrders.ts | 147 ++ frontend/src/pos/hooks/usePOSProducts.ts | 298 ++++ frontend/src/pos/hooks/usePayment.ts | 217 +++ frontend/src/pos/hooks/useProductMutations.ts | 266 +++ frontend/src/pos/hooks/useThermalPrinter.ts | 377 ++++ frontend/src/pos/types.ts | 552 ++++++ frontend/src/pos/utils.ts | 444 +++++ smoothschedule/config/urls.py | 4 + .../smoothschedule/commerce/pos/__init__.py | 2 + .../smoothschedule/commerce/pos/admin.py | 192 +++ .../smoothschedule/commerce/pos/apps.py | 15 + .../commerce/pos/migrations/0001_initial.py | 303 ++++ .../commerce/pos/migrations/__init__.py | 0 .../smoothschedule/commerce/pos/models.py | 1017 +++++++++++ .../commerce/pos/permissions.py | 107 ++ .../commerce/pos/serializers.py | 1033 +++++++++++ .../smoothschedule/commerce/pos/services.py | 1516 +++++++++++++++++ .../smoothschedule/commerce/pos/signals.py | 265 +++ .../smoothschedule/commerce/pos/tasks.py | 628 +++++++ .../commerce/pos/tests/__init__.py | 1 + .../commerce/pos/tests/test_models.py | 727 ++++++++ .../commerce/pos/tests/test_permissions.py | 363 ++++ .../commerce/pos/tests/test_serializers.py | 640 +++++++ .../commerce/pos/tests/test_services.py | 768 +++++++++ .../commerce/pos/tests/test_signals.py | 369 ++++ .../commerce/pos/tests/test_tasks.py | 144 ++ .../commerce/pos/tests/test_views.py | 920 ++++++++++ .../smoothschedule/commerce/pos/urls.py | 33 + .../smoothschedule/commerce/pos/views.py | 1512 ++++++++++++++++ .../smoothschedule/commerce/tax/__init__.py | 1 + .../smoothschedule/commerce/tax/admin.py | 29 + .../smoothschedule/commerce/tax/apps.py | 8 + .../commerce/tax/docs/STATE_TAX_SOURCES.md | 208 +++ .../commerce/tax/management/__init__.py | 0 .../tax/management/commands/__init__.py | 0 .../commands/import_all_tax_rates.py | 148 ++ .../commands/import_arizona_rates.py | 264 +++ .../commands/import_avalara_tax_rates.py | 452 +++++ .../commands/import_california_rates.py | 380 +++++ .../commands/import_florida_rates.py | 344 ++++ .../commands/import_illinois_rates.py | 288 ++++ .../commands/import_newyork_rates.py | 308 ++++ .../management/commands/import_sst_rates.py | 550 ++++++ .../commands/import_static_state_rates.py | 172 ++ .../management/commands/import_texas_rates.py | 368 ++++ .../commerce/tax/migrations/0001_initial.py | 56 + ...02_taxboundary_taxjurisdiction_and_more.py | 112 ++ .../commerce/tax/migrations/__init__.py | 0 .../smoothschedule/commerce/tax/models.py | 701 ++++++++ .../commerce/tax/serializers.py | 160 ++ .../smoothschedule/commerce/tax/tasks.py | 247 +++ .../commerce/tax/tests/__init__.py | 0 .../commerce/tax/tests/test_models.py | 99 ++ .../smoothschedule/commerce/tax/urls.py | 31 + .../smoothschedule/commerce/tax/views.py | 382 +++++ .../migrations/0048_add_location_tax_rate.py | 18 + .../scheduling/schedule/models.py | 6 + .../scheduling/schedule/serializers.py | 2 +- 156 files changed, 61604 insertions(+), 4 deletions(-) create mode 100644 frontend/src/hooks/useTaxRates.ts create mode 100644 frontend/src/pages/POS.tsx create mode 100644 frontend/src/pages/Products.tsx create mode 100644 frontend/src/pos/BARCODE_SCANNER.md create mode 100644 frontend/src/pos/README.md create mode 100644 frontend/src/pos/__tests__/ESCPOSBuilder.test.ts create mode 100644 frontend/src/pos/__tests__/POSContext.test.tsx create mode 100644 frontend/src/pos/__tests__/utils.test.ts create mode 100644 frontend/src/pos/components/BarcodeScannerStatus.example.tsx create mode 100644 frontend/src/pos/components/BarcodeScannerStatus.tsx create mode 100644 frontend/src/pos/components/CartItem.tsx create mode 100644 frontend/src/pos/components/CartPanel.tsx create mode 100644 frontend/src/pos/components/CashDrawerPanel.tsx create mode 100644 frontend/src/pos/components/CashPaymentPanel.tsx create mode 100644 frontend/src/pos/components/CategoryManagerModal.tsx create mode 100644 frontend/src/pos/components/CategoryTabs.tsx create mode 100644 frontend/src/pos/components/CloseShiftModal.tsx create mode 100644 frontend/src/pos/components/CustomerSelect.example.tsx create mode 100644 frontend/src/pos/components/CustomerSelect.tsx create mode 100644 frontend/src/pos/components/DiscountModal.tsx create mode 100644 frontend/src/pos/components/GiftCardPaymentPanel.tsx create mode 100644 frontend/src/pos/components/GiftCardPurchaseModal.tsx create mode 100644 frontend/src/pos/components/InventoryTransferModal.tsx create mode 100644 frontend/src/pos/components/NumPad.tsx create mode 100644 frontend/src/pos/components/OpenItemModal.tsx create mode 100644 frontend/src/pos/components/OpenShiftModal.tsx create mode 100644 frontend/src/pos/components/OrderDetailModal.tsx create mode 100644 frontend/src/pos/components/OrderHistoryPanel.tsx create mode 100644 frontend/src/pos/components/PAYMENT_FLOW.md create mode 100644 frontend/src/pos/components/POSHeader.tsx create mode 100644 frontend/src/pos/components/POSLayout.tsx create mode 100644 frontend/src/pos/components/PaymentModal.tsx create mode 100644 frontend/src/pos/components/PrinterConnectionPanel.tsx create mode 100644 frontend/src/pos/components/PrinterStatus.tsx create mode 100644 frontend/src/pos/components/ProductEditorModal.tsx create mode 100644 frontend/src/pos/components/ProductGrid.tsx create mode 100644 frontend/src/pos/components/QuickSearch.tsx create mode 100644 frontend/src/pos/components/ReceiptPreview.tsx create mode 100644 frontend/src/pos/components/ShiftSummary.tsx create mode 100644 frontend/src/pos/components/TipSelector.tsx create mode 100644 frontend/src/pos/components/__tests__/BarcodeScannerStatus.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CartItem.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CartPanel.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CashDrawerPanel.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CashPaymentPanel.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CategoryManagerModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CategoryTabs.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CloseShiftModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/CustomerSelect.test.tsx create mode 100644 frontend/src/pos/components/__tests__/DiscountModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/GiftCardPaymentPanel.test.tsx create mode 100644 frontend/src/pos/components/__tests__/GiftCardPurchaseModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/InventoryTransferModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/OpenItemModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/OpenShiftModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/OrderDetailModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/OrderHistoryPanel.test.tsx create mode 100644 frontend/src/pos/components/__tests__/POSHeader.test.tsx create mode 100644 frontend/src/pos/components/__tests__/POSLayout.test.tsx create mode 100644 frontend/src/pos/components/__tests__/PaymentModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/PrinterConnectionPanel.test.tsx create mode 100644 frontend/src/pos/components/__tests__/PrinterStatus.test.tsx create mode 100644 frontend/src/pos/components/__tests__/ProductEditorModal.test.tsx create mode 100644 frontend/src/pos/components/__tests__/ProductGrid.test.tsx create mode 100644 frontend/src/pos/components/__tests__/QuickSearch.test.tsx create mode 100644 frontend/src/pos/components/__tests__/ReceiptPreview.test.tsx create mode 100644 frontend/src/pos/components/__tests__/ShiftSummary.test.tsx create mode 100644 frontend/src/pos/components/__tests__/TipSelector.test.tsx create mode 100644 frontend/src/pos/components/index.ts create mode 100644 frontend/src/pos/context/POSContext.tsx create mode 100644 frontend/src/pos/context/__tests__/POSContext.test.tsx create mode 100644 frontend/src/pos/hardware/ESCPOSBuilder.ts create mode 100644 frontend/src/pos/hardware/GiftCardReceiptBuilder.ts create mode 100644 frontend/src/pos/hardware/ReceiptBuilder.ts create mode 100644 frontend/src/pos/hardware/ShiftReportBuilder.ts create mode 100644 frontend/src/pos/hardware/__tests__/GiftCardReceiptBuilder.test.ts create mode 100644 frontend/src/pos/hardware/__tests__/ReceiptBuilder.test.ts create mode 100644 frontend/src/pos/hardware/__tests__/ShiftReportBuilder.test.ts create mode 100644 frontend/src/pos/hardware/constants.ts create mode 100644 frontend/src/pos/hardware/index.ts create mode 100644 frontend/src/pos/hooks/__tests__/useBarcodeScanner.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/useCart.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/useCashDrawer.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/useGiftCards.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/useInventory.test.tsx create mode 100644 frontend/src/pos/hooks/__tests__/useOrders.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/usePOSProducts.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/usePayment.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/useProductMutations.test.ts create mode 100644 frontend/src/pos/hooks/__tests__/useThermalPrinter.test.ts create mode 100644 frontend/src/pos/hooks/useBarcodeScanner.ts create mode 100644 frontend/src/pos/hooks/useCart.ts create mode 100644 frontend/src/pos/hooks/useCashDrawer.ts create mode 100644 frontend/src/pos/hooks/useGiftCards.ts create mode 100644 frontend/src/pos/hooks/useInventory.ts create mode 100644 frontend/src/pos/hooks/useOrders.ts create mode 100644 frontend/src/pos/hooks/usePOSProducts.ts create mode 100644 frontend/src/pos/hooks/usePayment.ts create mode 100644 frontend/src/pos/hooks/useProductMutations.ts create mode 100644 frontend/src/pos/hooks/useThermalPrinter.ts create mode 100644 frontend/src/pos/types.ts create mode 100644 frontend/src/pos/utils.ts create mode 100644 smoothschedule/smoothschedule/commerce/pos/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/admin.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/apps.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/migrations/0001_initial.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/migrations/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/models.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/permissions.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/serializers.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/services.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/signals.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tasks.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_models.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_permissions.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_serializers.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_services.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_signals.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_tasks.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/tests/test_views.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/urls.py create mode 100644 smoothschedule/smoothschedule/commerce/pos/views.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/admin.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/apps.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/docs/STATE_TAX_SOURCES.md create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_all_tax_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_arizona_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_avalara_tax_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_california_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_florida_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_illinois_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_newyork_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_sst_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_static_state_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/management/commands/import_texas_rates.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/migrations/0001_initial.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/migrations/0002_taxboundary_taxjurisdiction_and_more.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/migrations/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/models.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/serializers.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/tasks.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/tests/__init__.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/tests/test_models.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/urls.py create mode 100644 smoothschedule/smoothschedule/commerce/tax/views.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0048_add_location_tax_rate.py 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 ``, `