Files
blackcanyontickets/reactrebuild0825/src/features/scanner/ManualEntryModal.tsx
dzinesco aa81eb5adb feat: add advanced analytics and territory management system
- Add comprehensive analytics components with export functionality
- Implement territory management with manager performance tracking
- Add seatmap components for venue layout management
- Create customer management features with modal interface
- Add advanced hooks for dashboard flags and territory data
- Implement seat selection and venue management utilities
- Add type definitions for ticketing and seatmap systems

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 09:25:10 -06:00

356 lines
11 KiB
TypeScript

/**
* Manual Entry Modal for Ticket Scanner
* Optimized for gate staff with gloves and stylus input
*/
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X,
Delete,
Check,
Loader2,
AlertCircle,
Hash,
Eye,
EyeOff
} from 'lucide-react';
import { Button } from '../../components/ui/Button';
import { Card } from '../../components/ui/Card';
import { formatBackupCode, QRValidator } from '../../lib/qr-validator';
export interface ManualEntryProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (code: string) => Promise<void>;
isLoading: boolean;
lastError?: string;
}
const KEYPAD_BUTTONS = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['clear', '0', 'delete']
] as const;
const LETTER_BUTTONS = [
['A', 'B', 'C'],
['D', 'E', 'F']
] as const;
export function ManualEntryModal({
isOpen,
onClose,
onSubmit,
isLoading,
lastError
}: ManualEntryProps): JSX.Element {
const [code, setCode] = useState('');
const [showLetters, setShowLetters] = useState(false);
const [validator] = useState(() => new QRValidator());
// Reset state when modal opens/closes
useEffect(() => {
if (isOpen) {
setCode('');
setShowLetters(false);
}
}, [isOpen]);
const handleKeyPress = useCallback((key: string) => {
if (isLoading) {return;}
switch (key) {
case 'clear':
setCode('');
break;
case 'delete':
setCode(prev => prev.slice(0, -1));
break;
default:
if (code.length < 8) {
setCode(prev => prev + key);
}
break;
}
}, [code.length, isLoading]);
const handleSubmit = useCallback(async () => {
if (code.length === 0 || isLoading) {return;}
const validation = validator.validateBackupCode(code);
if (!validation.valid) {
// Don't submit invalid codes - let user fix them
return;
}
try {
await onSubmit(validation.normalizedCode!);
} catch (error) {
// Error handling is done in parent component
console.error('Manual entry submission error:', error);
}
}, [code, isLoading, onSubmit, validator]);
const isCodeValid = validator.validateBackupCode(code).valid;
const formattedCode = formatBackupCode(code.padEnd(8, '_'));
// Keyboard event handling
useEffect(() => {
if (!isOpen) {return;}
const handleKeyDown = (event: KeyboardEvent) => {
if (isLoading) {return;}
const {key} = event;
// Numbers
if (/^[0-9]$/.test(key)) {
event.preventDefault();
handleKeyPress(key);
}
// Letters (A-F for hex codes)
if (/^[A-Fa-f]$/.test(key)) {
event.preventDefault();
handleKeyPress(key.toUpperCase());
}
// Backspace
if (key === 'Backspace') {
event.preventDefault();
handleKeyPress('delete');
}
// Escape
if (key === 'Escape') {
event.preventDefault();
onClose();
}
// Enter
if (key === 'Enter' && isCodeValid) {
event.preventDefault();
handleSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyPress, handleSubmit, isCodeValid, isLoading, onClose]);
if (!isOpen) {return <></>;}
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black bg-opacity-75 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md mx-auto"
>
<Card className="bg-glass-bg backdrop-blur-lg border-glass-border p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<Hash className="h-6 w-6 text-primary-500" />
<h2 className="text-lg font-semibold text-primary-text">
Manual Entry
</h2>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-2"
disabled={isLoading}
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Instructions */}
<div className="mb-6">
<p className="text-sm text-secondary-text mb-2">
Enter the last 8 characters from the ticket code
</p>
<div className="text-xs text-tertiary-text space-y-1">
<p> Found at bottom of physical tickets</p>
<p> Use when QR code is damaged or unreadable</p>
<p> Numbers and letters A-F only</p>
</div>
</div>
{/* Code Display */}
<div className="mb-6">
<div className="relative">
<div className={`
w-full p-4 text-center font-mono text-2xl tracking-wider
bg-input-bg border border-input-border rounded-lg
${isCodeValid ? 'border-success-500 bg-success-50' : ''}
${code.length > 0 && !isCodeValid ? 'border-warning-500 bg-warning-50' : ''}
`}>
{formattedCode}
</div>
{/* Validation indicator */}
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{code.length === 8 && isCodeValid && (
<Check className="h-5 w-5 text-success-500" />
)}
{code.length > 0 && code.length < 8 && (
<span className="text-xs text-tertiary-text">
{8 - code.length} more
</span>
)}
</div>
</div>
{/* Error Display */}
{lastError && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-2 p-3 bg-error-50 border border-error-200 rounded-lg flex items-center space-x-2"
>
<AlertCircle className="h-4 w-4 text-error-500 flex-shrink-0" />
<p className="text-sm text-error-600">{lastError}</p>
</motion.div>
)}
</div>
{/* Letter/Number Toggle */}
<div className="mb-4 flex items-center justify-center">
<Button
onClick={() => setShowLetters(!showLetters)}
variant="outline"
size="sm"
className="flex items-center space-x-2"
disabled={isLoading}
>
{showLetters ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span>{showLetters ? 'Hide' : 'Show'} Letters A-F</span>
</Button>
</div>
{/* Keypad */}
<div className="space-y-3">
{/* Number Keys */}
{KEYPAD_BUTTONS.map((row, rowIndex) => (
<div key={rowIndex} className="grid grid-cols-3 gap-3">
{row.map((key) => {
let buttonContent: React.ReactNode;
let variant: 'primary' | 'outline' | 'secondary' = 'outline';
let disabled = isLoading;
switch (key) {
case 'clear':
buttonContent = 'Clear';
variant = 'secondary';
disabled = disabled || code.length === 0;
break;
case 'delete':
buttonContent = <Delete className="h-5 w-5" />;
variant = 'secondary';
disabled = disabled || code.length === 0;
break;
default:
buttonContent = key;
disabled = disabled || code.length >= 8;
break;
}
return (
<Button
key={key}
onClick={() => handleKeyPress(key)}
variant={variant}
disabled={disabled}
className="h-14 text-lg font-semibold touch-manipulation"
>
{buttonContent}
</Button>
);
})}
</div>
))}
{/* Letter Keys (A-F) */}
{showLetters && (
<div className="space-y-3 border-t border-glass-border pt-3">
{LETTER_BUTTONS.map((row, rowIndex) => (
<div key={`letters-${rowIndex}`} className="grid grid-cols-3 gap-3">
{row.map((key) => (
<Button
key={key}
onClick={() => handleKeyPress(key)}
variant="outline"
disabled={isLoading || code.length >= 8}
className="h-14 text-lg font-semibold touch-manipulation"
>
{key}
</Button>
))}
</div>
))}
</div>
)}
</div>
{/* Submit Button */}
<div className="mt-6 flex space-x-3">
<Button
onClick={onClose}
variant="outline"
disabled={isLoading}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="primary"
disabled={!isCodeValid || isLoading}
className="flex-1 flex items-center justify-center space-x-2"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Checking...</span>
</>
) : (
<>
<Check className="h-4 w-4" />
<span>Submit</span>
</>
)}
</Button>
</div>
{/* Footer Help */}
<div className="mt-4 text-center">
<p className="text-xs text-tertiary-text">
Press Enter to submit Esc to cancel
</p>
</div>
</Card>
</motion.div>
</motion.div>
</AnimatePresence>
);
}