Files
blackcanyontickets/reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx
dzinesco 02a5146533 feat(domain): create comprehensive business components for ticketing platform
- Add EventCard with glassmorphism styling and role-based actions
- Add TicketTypeRow with inline editing and inventory tracking
- Add OrderSummary with promo codes and fee breakdown integration
- Add ScanStatusBadge with real-time updates and accessibility
- Add FeeBreakdown with transparent pricing and regulatory compliance
- Create business logic types for events, tickets, orders, scanning
- Implement responsive layouts (card/table) for all screen sizes
- Ensure WCAG AA compliance with proper ARIA labels and screen reader support
- Use design tokens exclusively for consistent theming
- Build comprehensive showcase component demonstrating all features

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-16 11:54:25 -06:00

286 lines
9.4 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { ScanStatus } from '../../types/business';
export interface ScanStatusBadgeProps {
scanStatus: ScanStatus;
showTimestamp?: boolean;
showTicketInfo?: boolean;
animated?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
onStatusChange?: (status: ScanStatus) => void;
}
const ScanStatusBadge: React.FC<ScanStatusBadgeProps> = ({
scanStatus,
showTimestamp = true,
showTicketInfo = false,
animated = true,
size = 'md',
className = '',
onStatusChange
}) => {
const [isAnimating, setIsAnimating] = useState(false);
const [announceText, setAnnounceText] = useState('');
// Trigger animation on status change
useEffect(() => {
if (animated) {
setIsAnimating(true);
const timer = setTimeout(() => setIsAnimating(false), 600);
return () => clearTimeout(timer);
}
}, [scanStatus, animated]);
// Handle accessibility announcements
useEffect(() => {
const getAnnouncementText = () => {
switch (scanStatus.status) {
case 'valid':
return `Valid ticket scanned. ${scanStatus.ticketInfo?.eventTitle || 'Event'} ticket accepted.`;
case 'used':
return `Ticket already used. This ticket was previously scanned.`;
case 'expired':
return `Expired ticket. This ticket is no longer valid.`;
case 'invalid':
return `Invalid ticket. ${scanStatus.errorMessage || 'Please check the QR code.'}`;
case 'not_found':
return `Ticket not found. Please verify the QR code.`;
default:
return 'Ticket status unknown.';
}
};
setAnnounceText(getAnnouncementText());
onStatusChange?.(scanStatus);
}, [scanStatus, onStatusChange]);
// Format timestamp
const formatTimestamp = (timestamp?: string) => {
if (!timestamp) return null;
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
};
// Get status configuration
const getStatusConfig = () => {
switch (scanStatus.status) {
case 'valid':
return {
variant: 'success' as const,
icon: (
<svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
label: 'Valid',
bgColor: 'bg-success/10',
borderColor: 'border-success/20',
textColor: 'text-success'
};
case 'used':
return {
variant: 'warning' as const,
icon: (
<svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
label: 'Used',
bgColor: 'bg-warning/10',
borderColor: 'border-warning/20',
textColor: 'text-warning'
};
case 'expired':
return {
variant: 'secondary' as const,
icon: (
<svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
label: 'Expired',
bgColor: 'bg-secondary/10',
borderColor: 'border-secondary/20',
textColor: 'text-secondary'
};
case 'invalid':
return {
variant: 'destructive' as const,
icon: (
<svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
),
label: 'Invalid',
bgColor: 'bg-destructive/10',
borderColor: 'border-destructive/20',
textColor: 'text-destructive'
};
case 'not_found':
return {
variant: 'destructive' as const,
icon: (
<svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m6 5H3a2 2 0 01-2-2V5a2 2 0 012-2h18a2 2 0 012 2v14a2 2 0 01-2 2z" />
</svg>
),
label: 'Not Found',
bgColor: 'bg-destructive/10',
borderColor: 'border-destructive/20',
textColor: 'text-destructive'
};
default:
return {
variant: 'secondary' as const,
icon: (
<svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
label: 'Unknown',
bgColor: 'bg-secondary/10',
borderColor: 'border-secondary/20',
textColor: 'text-secondary'
};
}
};
const config = getStatusConfig();
// Size configurations
const sizeConfig = {
sm: {
badge: 'text-xs px-2 py-1',
icon: 'w-3 h-3',
text: 'text-xs',
container: 'space-y-1'
},
md: {
badge: 'text-sm px-3 py-1.5',
icon: 'w-4 h-4',
text: 'text-sm',
container: 'space-y-2'
},
lg: {
badge: 'text-base px-4 py-2',
icon: 'w-5 h-5',
text: 'text-base',
container: 'space-y-3'
}
};
const sizeClasses = sizeConfig[size];
return (
<div className={`${sizeClasses.container} ${className}`}>
{/* Screen reader announcement */}
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{announceText}
</div>
{/* Status Badge with Animation */}
<div className="flex items-center gap-2">
<div
className={`
relative flex items-center gap-2 rounded-lg px-3 py-2 transition-all duration-300
${config.bgColor} ${config.borderColor} ${config.textColor}
${isAnimating ? 'animate-pulse scale-105' : ''}
border backdrop-blur-sm
`}
>
{/* Status Icon */}
<div className={`${sizeClasses.icon} flex-shrink-0`}>
{config.icon}
</div>
{/* Status Label */}
<span className={`font-semibold ${sizeClasses.text}`}>
{config.label}
</span>
{/* Animation pulse effect */}
{isAnimating && scanStatus.status === 'valid' && (
<div className="absolute inset-0 rounded-lg bg-success/20 animate-ping" />
)}
</div>
{/* Timestamp */}
{showTimestamp && scanStatus.timestamp && (
<span className={`${sizeClasses.text} text-fg-secondary`}>
{formatTimestamp(scanStatus.timestamp)}
</span>
)}
</div>
{/* Error Message */}
{scanStatus.errorMessage && (
<div className={`${sizeClasses.text} text-destructive bg-destructive/10 rounded px-2 py-1 border border-destructive/20`}>
{scanStatus.errorMessage}
</div>
)}
{/* Ticket Information */}
{showTicketInfo && scanStatus.ticketInfo && (
<div className={`${config.bgColor} ${config.borderColor} border rounded-lg p-3 space-y-1`}>
<div className={`font-medium ${sizeClasses.text} text-fg-primary`}>
{scanStatus.ticketInfo.eventTitle}
</div>
<div className={`${sizeClasses.text} text-fg-secondary`}>
{scanStatus.ticketInfo.ticketTypeName}
</div>
<div className={`${sizeClasses.text} text-fg-secondary`}>
{scanStatus.ticketInfo.customerEmail}
</div>
{scanStatus.ticketInfo.seatNumber && (
<div className={`${sizeClasses.text} text-fg-secondary`}>
Seat: {scanStatus.ticketInfo.seatNumber}
</div>
)}
</div>
)}
{/* Success/Error animations */}
<style>{`
@keyframes successPulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.animate-success-pulse {
animation: successPulse 0.6s ease-in-out;
}
.animate-error-shake {
animation: errorShake 0.5s ease-in-out;
}
`}</style>
</div>
);
};
export default ScanStatusBadge;