- 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>
286 lines
9.4 KiB
TypeScript
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; |