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>
This commit is contained in:
286
reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx
Normal file
286
reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user