From 02a5146533384dbd3103b2492eeb58ecd7aa6898 Mon Sep 17 00:00:00 2001 From: dzinesco Date: Sat, 16 Aug 2025 11:54:25 -0600 Subject: [PATCH] feat(domain): create comprehensive business components for ticketing platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/DomainShowcase.tsx | 371 ++++++++++++++++ .../src/components/billing/FeeBreakdown.tsx | 413 ++++++++++++++++++ .../src/components/billing/index.ts | 3 + .../src/components/checkout/OrderSummary.tsx | 308 +++++++++++++ .../src/components/checkout/index.ts | 3 + .../src/components/events/EventCard.tsx | 206 +++++++++ .../src/components/events/index.ts | 3 + .../components/scanning/ScanStatusBadge.tsx | 286 ++++++++++++ .../src/components/scanning/index.ts | 3 + .../src/components/tickets/TicketTypeRow.tsx | 403 +++++++++++++++++ .../src/components/tickets/index.ts | 3 + reactrebuild0825/src/types/business.ts | 229 ++++++++++ reactrebuild0825/src/types/index.ts | 26 ++ 13 files changed, 2257 insertions(+) create mode 100644 reactrebuild0825/src/components/DomainShowcase.tsx create mode 100644 reactrebuild0825/src/components/billing/FeeBreakdown.tsx create mode 100644 reactrebuild0825/src/components/billing/index.ts create mode 100644 reactrebuild0825/src/components/checkout/OrderSummary.tsx create mode 100644 reactrebuild0825/src/components/checkout/index.ts create mode 100644 reactrebuild0825/src/components/events/EventCard.tsx create mode 100644 reactrebuild0825/src/components/events/index.ts create mode 100644 reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx create mode 100644 reactrebuild0825/src/components/scanning/index.ts create mode 100644 reactrebuild0825/src/components/tickets/TicketTypeRow.tsx create mode 100644 reactrebuild0825/src/components/tickets/index.ts create mode 100644 reactrebuild0825/src/types/business.ts create mode 100644 reactrebuild0825/src/types/index.ts diff --git a/reactrebuild0825/src/components/DomainShowcase.tsx b/reactrebuild0825/src/components/DomainShowcase.tsx new file mode 100644 index 0000000..78da906 --- /dev/null +++ b/reactrebuild0825/src/components/DomainShowcase.tsx @@ -0,0 +1,371 @@ +import React, { useState } from 'react'; + +import type { Order, ScanStatus } from '../types/business'; +import { + MOCK_EVENTS, + MOCK_TICKET_TYPES, + DEFAULT_FEE_STRUCTURE +} from '../types/business'; + +import { MOCK_USERS } from '../types/auth'; + +import { EventCard } from './events'; +import { TicketTypeRow } from './tickets'; +import { OrderSummary } from './checkout'; +import { ScanStatusBadge } from './scanning'; +import { FeeBreakdown } from './billing'; +import { Card, CardHeader, CardBody } from './ui/Card'; +import { Button } from './ui/Button'; +import { MainContainer } from './layout'; + +const DomainShowcase: React.FC = () => { + const [currentUser] = useState(MOCK_USERS[1]); // Organizer user + const [scanStatuses, setScanStatuses] = useState([ + { + isValid: true, + status: 'valid', + timestamp: new Date().toISOString(), + ticketInfo: { + eventTitle: 'Autumn Gala & Silent Auction', + ticketTypeName: 'VIP Patron', + customerEmail: 'customer@example.com', + seatNumber: 'A12' + } + }, + { + isValid: false, + status: 'used', + timestamp: new Date(Date.now() - 300000).toISOString(), + errorMessage: 'This ticket was already scanned 5 minutes ago' + }, + { + isValid: false, + status: 'invalid', + errorMessage: 'QR code format is not recognized' + } + ]); + + // Mock order data + const mockOrder: Order = { + id: 'ord-123', + eventId: 'evt-1', + customerEmail: 'customer@example.com', + items: [ + { + ticketTypeId: 'tt-1', + ticketTypeName: 'VIP Patron', + price: 35000, + quantity: 2, + subtotal: 70000 + }, + { + ticketTypeId: 'tt-2', + ticketTypeName: 'General Admission', + price: 15000, + quantity: 1, + subtotal: 15000 + } + ], + subtotal: 85000, + platformFee: 3075, + processingFee: 2495, + tax: 7875, + total: 98445, + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + const handleEventAction = (action: string, eventId: string) => { + // Handle event actions in real application + }; + + const handleTicketAction = (action: string, ticketTypeId: string, value?: unknown) => { + // Handle ticket type actions in real application + }; + + const handlePromoCode = async (code: string) => { + // Apply promo code in real application + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + return { success: false, error: 'Promo code not found' }; + }; + + const simulateScan = () => { + const newStatus: ScanStatus = { + isValid: Math.random() > 0.5, + status: Math.random() > 0.7 ? 'valid' : 'invalid', + timestamp: new Date().toISOString(), + errorMessage: Math.random() > 0.5 ? 'Invalid QR format' : undefined, + ticketInfo: { + eventTitle: 'Contemporary Dance Showcase', + ticketTypeName: 'General Admission', + customerEmail: 'test@example.com' + } + }; + setScanStatuses(prev => [newStatus, ...prev.slice(0, 4)]); + }; + + return ( + +
+
+

+ Domain Components Showcase +

+

+ Professional event ticketing components for upscale venues with glassmorphism design +

+
+ + {/* Event Cards Section */} +
+

Event Cards

+

+ Display event information with role-based actions and glassmorphism styling +

+
+ {MOCK_EVENTS.map((event) => ( + handleEventAction('view', id)} + onEdit={(id) => handleEventAction('edit', id)} + onManage={(id) => handleEventAction('manage', id)} + /> + ))} +
+
+ + {/* Ticket Type Management Section */} +
+

Ticket Type Management

+

+ Manage ticket types with inline editing and inventory tracking +

+ + {/* Card Layout */} +
+

Card Layout (Mobile-Friendly)

+
+ {MOCK_TICKET_TYPES.map((ticketType) => ( + handleTicketAction('edit', tt.id)} + onDelete={(id) => handleTicketAction('delete', id)} + onToggleStatus={(id, status) => handleTicketAction('toggle-status', id, status)} + onQuantityUpdate={(id, quantity) => handleTicketAction('quantity-update', id, quantity)} + onPriceUpdate={(id, price) => handleTicketAction('price-update', id, price)} + /> + ))} +
+
+ + {/* Table Layout */} +
+

Table Layout (Desktop)

+ + +
+ + + + + + + + + + + + + + + {MOCK_TICKET_TYPES.map((ticketType) => ( + handleTicketAction('edit', tt.id)} + onDelete={(id) => handleTicketAction('delete', id)} + onToggleStatus={(id, status) => handleTicketAction('toggle-status', id, status)} + onQuantityUpdate={(id, quantity) => handleTicketAction('quantity-update', id, quantity)} + onPriceUpdate={(id, price) => handleTicketAction('price-update', id, price)} + /> + ))} + +
Ticket TypePriceQuantitySoldAvailableSales RateRevenueActions
+
+
+
+
+
+ + {/* Order Summary & Fee Breakdown Section */} +
+

Checkout Experience

+

+ Professional order summary with transparent fee breakdown +

+
+ {/* Order Summary */} +
+

Order Summary

+ { /* Remove promo code */ }} + /> + +

Compact Layout

+ +
+ + {/* Fee Breakdown */} +
+

Fee Breakdown

+ + +

Table Layout

+ +
+
+
+ + {/* Scanning Interface Section */} +
+

QR Scanning Interface

+

+ Real-time ticket validation with status indicators and animations +

+ +
+ +
+ +
+ {scanStatuses.map((status, index) => ( + + +

+ Scan Result #{scanStatuses.length - index} +

+
+ + + +
+ ))} +
+ + {/* Different Sizes */} +
+

Badge Sizes

+
+ + + +
+
+
+ + {/* Usage Examples */} +
+

Usage Examples

+ + +

Integration Code

+
+ +
+{`import { EventCard, TicketTypeRow, OrderSummary, ScanStatusBadge, FeeBreakdown } from '../components';
+
+// Event listing page
+
+
+// Ticket management
+
+
+// Checkout process
+
+
+// QR scanning
+
+
+// Admin fee transparency
+`}
+              
+
+
+
+
+
+ ); +}; + +export default DomainShowcase; \ No newline at end of file diff --git a/reactrebuild0825/src/components/billing/FeeBreakdown.tsx b/reactrebuild0825/src/components/billing/FeeBreakdown.tsx new file mode 100644 index 0000000..8c050d6 --- /dev/null +++ b/reactrebuild0825/src/components/billing/FeeBreakdown.tsx @@ -0,0 +1,413 @@ +import React, { useState } from 'react'; +import { FeeStructure, Order, DEFAULT_FEE_STRUCTURE } from '../../types/business'; +import { Card, CardHeader, CardBody } from '../ui/Card'; +import { Button } from '../ui/Button'; +import { Badge } from '../ui/Badge'; + +export interface FeeBreakdownProps { + order: Order; + feeStructure?: FeeStructure; + layout?: 'compact' | 'detailed' | 'table'; + showTooltips?: boolean; + showCalculations?: boolean; + printFriendly?: boolean; + className?: string; +} + +export interface TooltipProps { + content: string; + children: React.ReactNode; +} + +const Tooltip: React.FC = ({ content, children }) => { + const [isVisible, setIsVisible] = useState(false); + + return ( +
+
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} + tabIndex={0} + className="cursor-help" + > + {children} +
+ {isVisible && ( +
+ {content} +
+
+ )} +
+ ); +}; + +const FeeBreakdown: React.FC = ({ + order, + feeStructure = DEFAULT_FEE_STRUCTURE, + layout = 'detailed', + showTooltips = true, + showCalculations = false, + printFriendly = false, + className = '' +}) => { + const [isExpanded, setIsExpanded] = useState(layout === 'detailed'); + + // Format currency helper + const formatCurrency = (amountInCents: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amountInCents / 100); + }; + + // Format percentage helper + const formatPercentage = (rate: number) => { + return `${(rate * 100).toFixed(2)}%`; + }; + + // Calculate fee breakdown details + const calculateFeeDetails = () => { + const subtotal = order.subtotal; + + // Platform fee breakdown + const platformFeeVariable = Math.round(subtotal * feeStructure.platformFeeRate); + const platformFeeFixed = feeStructure.platformFeeFixed || 0; + const platformFeeRaw = platformFeeVariable + platformFeeFixed; + const platformFee = Math.max( + feeStructure.minPlatformFee || 0, + Math.min(feeStructure.maxPlatformFee || Infinity, platformFeeRaw) + ); + + // Processing fee breakdown + const processingFeeVariable = Math.round(subtotal * feeStructure.processingFeeRate); + const processingFeeFixed = feeStructure.processingFeeFixed || 0; + const processingFee = processingFeeVariable + processingFeeFixed; + + // Tax calculation + const taxableAmount = subtotal + platformFee + processingFee; + const tax = Math.round(taxableAmount * feeStructure.taxRate); + + return { + platformFee: { + total: platformFee, + variable: platformFeeVariable, + fixed: platformFeeFixed, + rate: feeStructure.platformFeeRate, + wasCapped: platformFeeRaw !== platformFee + }, + processingFee: { + total: processingFee, + variable: processingFeeVariable, + fixed: processingFeeFixed, + rate: feeStructure.processingFeeRate + }, + tax: { + total: tax, + rate: feeStructure.taxRate, + taxableAmount + } + }; + }; + + const feeDetails = calculateFeeDetails(); + + // Compliance information + const getComplianceInfo = () => { + return { + platformFee: "Service fee for platform usage, event management tools, and customer support.", + processingFee: "Credit card processing fee charged by payment processor for secure transaction handling.", + tax: "Local sales tax as required by applicable tax authorities. Tax-exempt organizations may qualify for reduced rates." + }; + }; + + const compliance = getComplianceInfo(); + + // Compact layout for mobile/sidebars + if (layout === 'compact') { + return ( +
+ + + {isExpanded && ( +
+
+ Platform fee + {formatCurrency(order.platformFee)} +
+
+ Processing fee + {formatCurrency(order.processingFee)} +
+
+ Tax + {formatCurrency(order.tax)} +
+
+ )} +
+ ); + } + + // Table layout for admin/detailed views + if (layout === 'table') { + return ( +
+ + + + + + + {showCalculations && ( + + )} + + + + + + + + {showCalculations && ( + + )} + + + + + + {showCalculations && ( + + )} + + + + + + {showCalculations && ( + + )} + + + + + + {showCalculations && ( + + )} + + +
+ Fee Type + + Rate + + Amount + + Calculation +
+
+ Platform Fee + {showTooltips && ( + + + + + + )} +
+
+ {formatPercentage(feeDetails.platformFee.rate)} + {formatCurrency(feeDetails.platformFee.fixed)} + + {formatCurrency(order.platformFee)} + + {formatCurrency(order.subtotal)} × {formatPercentage(feeDetails.platformFee.rate)} + {formatCurrency(feeDetails.platformFee.fixed)} + {feeDetails.platformFee.wasCapped && ( + Capped + )} +
+
+ Processing Fee + {showTooltips && ( + + + + + + )} +
+
+ {formatPercentage(feeDetails.processingFee.rate)} + {formatCurrency(feeDetails.processingFee.fixed)} + + {formatCurrency(order.processingFee)} + + {formatCurrency(order.subtotal)} × {formatPercentage(feeDetails.processingFee.rate)} + {formatCurrency(feeDetails.processingFee.fixed)} +
+
+ Tax + {showTooltips && ( + + + + + + )} +
+
+ {formatPercentage(feeDetails.tax.rate)} + + {formatCurrency(order.tax)} + + {formatCurrency(feeDetails.tax.taxableAmount)} × {formatPercentage(feeDetails.tax.rate)} +
+ Total Fees & Taxes + + {formatCurrency(order.platformFee + order.processingFee + order.tax)} +
+
+ ); + } + + // Detailed card layout (default) + return ( + + +
+

Fee Breakdown

+ + Total: {formatCurrency(order.platformFee + order.processingFee + order.tax)} + +
+
+ + + {/* Platform Fee */} +
+
+
+ Platform Fee + {showTooltips && ( + + + + + + )} +
+ {formatCurrency(order.platformFee)} +
+
+ Rate: {formatPercentage(feeDetails.platformFee.rate)} + {formatCurrency(feeDetails.platformFee.fixed)} + {feeDetails.platformFee.wasCapped && ( + Fee Capped + )} +
+ {showCalculations && ( +
+ Calculation: {formatCurrency(order.subtotal)} × {formatPercentage(feeDetails.platformFee.rate)} + {formatCurrency(feeDetails.platformFee.fixed)} = {formatCurrency(feeDetails.platformFee.total)} +
+ )} +
+ + {/* Processing Fee */} +
+
+
+ Processing Fee + {showTooltips && ( + + + + + + )} +
+ {formatCurrency(order.processingFee)} +
+
+ Rate: {formatPercentage(feeDetails.processingFee.rate)} + {formatCurrency(feeDetails.processingFee.fixed)} +
+ {showCalculations && ( +
+ Calculation: {formatCurrency(order.subtotal)} × {formatPercentage(feeDetails.processingFee.rate)} + {formatCurrency(feeDetails.processingFee.fixed)} = {formatCurrency(feeDetails.processingFee.total)} +
+ )} +
+ + {/* Tax */} +
+
+
+ Tax + {showTooltips && ( + + + + + + )} +
+ {formatCurrency(order.tax)} +
+
+ Rate: {formatPercentage(feeDetails.tax.rate)} on taxable amount +
+ {showCalculations && ( +
+ Taxable amount: {formatCurrency(feeDetails.tax.taxableAmount)} × {formatPercentage(feeDetails.tax.rate)} = {formatCurrency(feeDetails.tax.total)} +
+ )} +
+ + {/* Controls */} +
+ + + {printFriendly && ( + + )} +
+
+
+ ); +}; + +export default FeeBreakdown; \ No newline at end of file diff --git a/reactrebuild0825/src/components/billing/index.ts b/reactrebuild0825/src/components/billing/index.ts new file mode 100644 index 0000000..929abed --- /dev/null +++ b/reactrebuild0825/src/components/billing/index.ts @@ -0,0 +1,3 @@ +// Billing-related Components +export { default as FeeBreakdown } from './FeeBreakdown'; +export type { FeeBreakdownProps } from './FeeBreakdown'; \ No newline at end of file diff --git a/reactrebuild0825/src/components/checkout/OrderSummary.tsx b/reactrebuild0825/src/components/checkout/OrderSummary.tsx new file mode 100644 index 0000000..ec67029 --- /dev/null +++ b/reactrebuild0825/src/components/checkout/OrderSummary.tsx @@ -0,0 +1,308 @@ +import React, { useState } from 'react'; +import { Order, FeeStructure, PromoCode, DEFAULT_FEE_STRUCTURE } from '../../types/business'; +import { Card, CardHeader, CardBody, CardFooter } from '../ui/Card'; +import { Button } from '../ui/Button'; +import { Input } from '../ui/Input'; +import { Badge } from '../ui/Badge'; +import { Alert } from '../ui/Alert'; + +export interface OrderSummaryProps { + order: Order; + feeStructure?: FeeStructure; + onPromoCodeApply?: (code: string) => Promise<{ success: boolean; promoCode?: PromoCode; error?: string }>; + onPromoCodeRemove?: () => void; + layout?: 'compact' | 'detailed'; + showPromoCode?: boolean; + className?: string; +} + +const OrderSummary: React.FC = ({ + order, + feeStructure = DEFAULT_FEE_STRUCTURE, + onPromoCodeApply, + onPromoCodeRemove, + layout = 'detailed', + showPromoCode = true, + className = '' +}) => { + const [promoCodeInput, setPromoCodeInput] = useState(''); + const [isApplyingPromo, setIsApplyingPromo] = useState(false); + const [promoError, setPromoError] = useState(null); + const [showFeeBreakdown, setShowFeeBreakdown] = useState(false); + + // Format currency helper + const formatCurrency = (amountInCents: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amountInCents / 100); + }; + + // Handle promo code application + const handleApplyPromo = async () => { + if (!promoCodeInput.trim() || !onPromoCodeApply) return; + + setIsApplyingPromo(true); + setPromoError(null); + + try { + const result = await onPromoCodeApply(promoCodeInput.trim().toUpperCase()); + if (result.success) { + setPromoCodeInput(''); + } else { + setPromoError(result.error || 'Invalid promo code'); + } + } catch (error) { + setPromoError('Failed to apply promo code. Please try again.'); + } finally { + setIsApplyingPromo(false); + } + }; + + // Handle promo code removal + const handleRemovePromo = () => { + onPromoCodeRemove?.(); + setPromoError(null); + }; + + // Fee breakdown calculation is handled by FeeBreakdown component + + if (layout === 'compact') { + return ( + + + {/* Order Items Summary */} +
+ {order.items.map((item, index) => ( +
+ + {item.quantity}x {item.ticketTypeName} + + + {formatCurrency(item.subtotal)} + +
+ ))} +
+ + {/* Promo Code */} + {order.promoCode && ( +
+ Promo: {order.promoCode} + + -{formatCurrency(order.discount || 0)} + +
+ )} + + {/* Total */} +
+ Total + + {formatCurrency(order.total)} + +
+ + {/* Fee Breakdown Toggle */} + + + {showFeeBreakdown && ( +
+
+ Subtotal + {formatCurrency(order.subtotal)} +
+
+ Platform fee + {formatCurrency(order.platformFee)} +
+
+ Processing fee + {formatCurrency(order.processingFee)} +
+
+ Tax + {formatCurrency(order.tax)} +
+
+ )} +
+
+ ); + } + + return ( + + +

Order Summary

+
+ + + {/* Order Items */} +
+ {order.items.map((item, index) => ( +
+
+
{item.ticketTypeName}
+
+ {formatCurrency(item.price)} × {item.quantity} +
+
+
+ {formatCurrency(item.subtotal)} +
+
+ ))} +
+ + {/* Subtotal */} +
+ Subtotal + + {formatCurrency(order.subtotal)} + +
+ + {/* Promo Code Section */} + {showPromoCode && ( +
+ {order.promoCode ? ( +
+
+ PROMO + {order.promoCode} +
+
+ + -{formatCurrency(order.discount || 0)} + + +
+
+ ) : ( +
+
+ setPromoCodeInput(e.target.value.toUpperCase())} + className="flex-1" + onKeyDown={(e) => e.key === 'Enter' && handleApplyPromo()} + /> + +
+ {promoError && ( + + {promoError} + + )} +
+ )} +
+ )} + + {/* Fee Breakdown */} +
+
+ + + {formatCurrency(order.platformFee + order.processingFee + order.tax)} + +
+ + {showFeeBreakdown && ( +
+
+
+ Platform fee + +
+ {formatCurrency(order.platformFee)} +
+
+
+ Processing fee + +
+ {formatCurrency(order.processingFee)} +
+
+
+ Tax + +
+ {formatCurrency(order.tax)} +
+
+ )} +
+
+ + +
+ Total + + {formatCurrency(order.total)} + +
+
+
+ ); +}; + +export default OrderSummary; \ No newline at end of file diff --git a/reactrebuild0825/src/components/checkout/index.ts b/reactrebuild0825/src/components/checkout/index.ts new file mode 100644 index 0000000..fed516b --- /dev/null +++ b/reactrebuild0825/src/components/checkout/index.ts @@ -0,0 +1,3 @@ +// Checkout-related Components +export { default as OrderSummary } from './OrderSummary'; +export type { OrderSummaryProps } from './OrderSummary'; \ No newline at end of file diff --git a/reactrebuild0825/src/components/events/EventCard.tsx b/reactrebuild0825/src/components/events/EventCard.tsx new file mode 100644 index 0000000..66aa1f8 --- /dev/null +++ b/reactrebuild0825/src/components/events/EventCard.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { Event, EventStats } from '../../types/business'; +import { User } from '../../types/auth'; +import { Card, CardBody, CardFooter } from '../ui/Card'; +import { Button } from '../ui/Button'; +import { Badge } from '../ui/Badge'; + +export interface EventCardProps { + event: Event; + stats?: EventStats; + currentUser?: User; + onView?: (eventId: string) => void; + onEdit?: (eventId: string) => void; + onManage?: (eventId: string) => void; + className?: string; +} + +const EventCard: React.FC = ({ + event, + stats, + currentUser, + onView, + onEdit, + onManage, + className = '' +}) => { + // Calculate derived stats if not provided + const salesRate = stats?.salesRate ?? ((event.ticketsSold / event.totalCapacity) * 100); + const formattedRevenue = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(event.revenue / 100); + + // Format date + const eventDate = new Date(event.date); + const formattedDate = eventDate.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + const formattedTime = eventDate.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + // Determine status styling + const getStatusBadge = () => { + switch (event.status) { + case 'draft': + return Draft; + case 'published': + return salesRate >= 95 ? + Sold Out : + On Sale; + case 'cancelled': + return Cancelled; + case 'completed': + return Completed; + default: + return null; + } + }; + + // Check user permissions + const canEdit = currentUser?.role === 'admin' || currentUser?.role === 'organizer'; + const canManage = currentUser?.role === 'admin' || currentUser?.role === 'organizer'; + + return ( + + {/* Event Image */} + {event.image && ( +
+ {event.title} +
+
+ {getStatusBadge()} +
+
+ )} + + + {/* Event Details */} +
+

+ {event.title} +

+

+ {event.description} +

+ + {/* Date and Venue */} +
+
+ + + + {formattedDate} at {formattedTime} +
+
+ + + + + {event.venue} +
+
+
+ + {/* Sales Metrics */} +
+
+
Tickets Sold
+
+ {event.ticketsSold.toLocaleString()} / {event.totalCapacity.toLocaleString()} +
+
+ {salesRate.toFixed(1)}% sold +
+
+
+
Revenue
+
+ {formattedRevenue} +
+
+ {stats?.averageOrderValue ? + `Avg: $${(stats.averageOrderValue / 100).toFixed(0)}` : + 'Total gross' + } +
+
+
+ + {/* Progress Bar */} +
+
+
+
+
+ + + + {/* View Button - Always visible for published events */} + {event.status === 'published' && ( + + )} + + {/* Edit Button - For organizers/admins */} + {canEdit && ( + + )} + + {/* Manage Button - For organizers/admins */} + {canManage && ( + + )} + + + ); +}; + +export default EventCard; \ No newline at end of file diff --git a/reactrebuild0825/src/components/events/index.ts b/reactrebuild0825/src/components/events/index.ts new file mode 100644 index 0000000..3db8b83 --- /dev/null +++ b/reactrebuild0825/src/components/events/index.ts @@ -0,0 +1,3 @@ +// Event-related Components +export { default as EventCard } from './EventCard'; +export type { EventCardProps } from './EventCard'; \ No newline at end of file diff --git a/reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx b/reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx new file mode 100644 index 0000000..bf1e3a7 --- /dev/null +++ b/reactrebuild0825/src/components/scanning/ScanStatusBadge.tsx @@ -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 = ({ + 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: ( + + + + ), + label: 'Valid', + bgColor: 'bg-success/10', + borderColor: 'border-success/20', + textColor: 'text-success' + }; + case 'used': + return { + variant: 'warning' as const, + icon: ( + + + + ), + label: 'Used', + bgColor: 'bg-warning/10', + borderColor: 'border-warning/20', + textColor: 'text-warning' + }; + case 'expired': + return { + variant: 'secondary' as const, + icon: ( + + + + ), + label: 'Expired', + bgColor: 'bg-secondary/10', + borderColor: 'border-secondary/20', + textColor: 'text-secondary' + }; + case 'invalid': + return { + variant: 'destructive' as const, + icon: ( + + + + ), + label: 'Invalid', + bgColor: 'bg-destructive/10', + borderColor: 'border-destructive/20', + textColor: 'text-destructive' + }; + case 'not_found': + return { + variant: 'destructive' as const, + icon: ( + + + + ), + label: 'Not Found', + bgColor: 'bg-destructive/10', + borderColor: 'border-destructive/20', + textColor: 'text-destructive' + }; + default: + return { + variant: 'secondary' as const, + icon: ( + + + + ), + 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 ( +
+ {/* Screen reader announcement */} +
+ {announceText} +
+ + {/* Status Badge with Animation */} +
+
+ {/* Status Icon */} +
+ {config.icon} +
+ + {/* Status Label */} + + {config.label} + + + {/* Animation pulse effect */} + {isAnimating && scanStatus.status === 'valid' && ( +
+ )} +
+ + {/* Timestamp */} + {showTimestamp && scanStatus.timestamp && ( + + {formatTimestamp(scanStatus.timestamp)} + + )} +
+ + {/* Error Message */} + {scanStatus.errorMessage && ( +
+ {scanStatus.errorMessage} +
+ )} + + {/* Ticket Information */} + {showTicketInfo && scanStatus.ticketInfo && ( +
+
+ {scanStatus.ticketInfo.eventTitle} +
+
+ {scanStatus.ticketInfo.ticketTypeName} +
+
+ {scanStatus.ticketInfo.customerEmail} +
+ {scanStatus.ticketInfo.seatNumber && ( +
+ Seat: {scanStatus.ticketInfo.seatNumber} +
+ )} +
+ )} + + {/* Success/Error animations */} + +
+ ); +}; + +export default ScanStatusBadge; \ No newline at end of file diff --git a/reactrebuild0825/src/components/scanning/index.ts b/reactrebuild0825/src/components/scanning/index.ts new file mode 100644 index 0000000..16f9ce9 --- /dev/null +++ b/reactrebuild0825/src/components/scanning/index.ts @@ -0,0 +1,3 @@ +// Scanning-related Components +export { default as ScanStatusBadge } from './ScanStatusBadge'; +export type { ScanStatusBadgeProps } from './ScanStatusBadge'; \ No newline at end of file diff --git a/reactrebuild0825/src/components/tickets/TicketTypeRow.tsx b/reactrebuild0825/src/components/tickets/TicketTypeRow.tsx new file mode 100644 index 0000000..83c9592 --- /dev/null +++ b/reactrebuild0825/src/components/tickets/TicketTypeRow.tsx @@ -0,0 +1,403 @@ +import React, { useState } from 'react'; +import { TicketType, TicketTypeStats } from '../../types/business'; +import { User } from '../../types/auth'; +import { Button } from '../ui/Button'; +import { Badge } from '../ui/Badge'; +import { Input } from '../ui/Input'; + +export interface TicketTypeRowProps { + ticketType: TicketType; + stats?: TicketTypeStats; + currentUser?: User; + layout?: 'table' | 'card'; + onEdit?: (ticketType: TicketType) => void; + onDelete?: (ticketTypeId: string) => void; + onToggleStatus?: (ticketTypeId: string, newStatus: TicketType['status']) => void; + onQuantityUpdate?: (ticketTypeId: string, newQuantity: number) => void; + onPriceUpdate?: (ticketTypeId: string, newPrice: number) => void; + className?: string; +} + +const TicketTypeRow: React.FC = ({ + ticketType, + stats, + currentUser, + layout = 'table', + onEdit, + onDelete, + onToggleStatus, + onQuantityUpdate, + onPriceUpdate, + className = '' +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editPrice, setEditPrice] = useState(ticketType.price); + const [editQuantity, setEditQuantity] = useState(ticketType.quantity); + + // Calculate derived stats + const salesRate = stats?.salesRate ?? ((ticketType.sold / ticketType.quantity) * 100); + const available = ticketType.quantity - ticketType.sold; + const revenue = stats?.revenue ?? (ticketType.sold * ticketType.price); + + // Format price + const formattedPrice = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(ticketType.price / 100); + + const formattedRevenue = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(revenue / 100); + + // Check permissions + const canEdit = currentUser?.role === 'admin' || currentUser?.role === 'organizer'; + + // Get status badge + const getStatusBadge = () => { + switch (ticketType.status) { + case 'active': + return available <= 0 ? + Sold Out : + Active; + case 'paused': + return Paused; + case 'sold_out': + return Sold Out; + default: + return Unknown; + } + }; + + // Handle inline editing + const handleSaveEdit = () => { + if (editPrice !== ticketType.price) { + onPriceUpdate?.(ticketType.id, editPrice); + } + if (editQuantity !== ticketType.quantity) { + onQuantityUpdate?.(ticketType.id, editQuantity); + } + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setEditPrice(ticketType.price); + setEditQuantity(ticketType.quantity); + setIsEditing(false); + }; + + // Toggle status helper + const handleToggleStatus = () => { + const newStatus = ticketType.status === 'active' ? 'paused' : 'active'; + onToggleStatus?.(ticketType.id, newStatus); + }; + + if (layout === 'card') { + return ( +
+
+
+
+

{ticketType.name}

+ {getStatusBadge()} +
+ {ticketType.description && ( +

{ticketType.description}

+ )} +
+ {canEdit && ( +
+ + +
+ )} +
+ +
+
+
Price
+ {isEditing ? ( + setEditPrice(Math.round(parseFloat(e.target.value || '0') * 100))} + className="h-8 text-sm" + step="0.01" + min="0" + /> + ) : ( +
{formattedPrice}
+ )} +
+
+
Quantity
+ {isEditing ? ( + setEditQuantity(parseInt(e.target.value || '0'))} + className="h-8 text-sm" + min="0" + /> + ) : ( +
{ticketType.quantity}
+ )} +
+
+ +
+
+
Sold
+
{ticketType.sold}
+
+
+
Available
+
{available}
+
+
+
Revenue
+
{formattedRevenue}
+
+
+ + {/* Progress Bar */} +
+
+ Sales Progress + {salesRate.toFixed(1)}% +
+
+
+
+
+ + {isEditing && ( +
+ + +
+ )} + + {!isEditing && canEdit && ( +
+ + +
+ )} +
+ ); + } + + // Table layout + return ( + + {/* Name & Description */} + +
+
+
{ticketType.name}
+ {ticketType.description && ( +
{ticketType.description}
+ )} +
+ {getStatusBadge()} +
+ + + {/* Price */} + + {isEditing ? ( + setEditPrice(Math.round(parseFloat(e.target.value || '0') * 100))} + className="w-24 h-8 text-sm" + step="0.01" + min="0" + /> + ) : ( +
{formattedPrice}
+ )} + + + {/* Quantity */} + + {isEditing ? ( + setEditQuantity(parseInt(e.target.value || '0'))} + className="w-20 h-8 text-sm" + min="0" + /> + ) : ( +
{ticketType.quantity}
+ )} + + + {/* Sold */} + +
{ticketType.sold}
+ + + {/* Available */} + +
{available}
+ + + {/* Sales Rate */} + +
+
+
+
+ {salesRate.toFixed(1)}% +
+ + + {/* Revenue */} + +
{formattedRevenue}
+ + + {/* Actions */} + {canEdit && ( + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + + )} +
+ + )} + + ); +}; + +export default TicketTypeRow; \ No newline at end of file diff --git a/reactrebuild0825/src/components/tickets/index.ts b/reactrebuild0825/src/components/tickets/index.ts new file mode 100644 index 0000000..b149fc7 --- /dev/null +++ b/reactrebuild0825/src/components/tickets/index.ts @@ -0,0 +1,3 @@ +// Ticket-related Components +export { default as TicketTypeRow } from './TicketTypeRow'; +export type { TicketTypeRowProps } from './TicketTypeRow'; \ No newline at end of file diff --git a/reactrebuild0825/src/types/business.ts b/reactrebuild0825/src/types/business.ts new file mode 100644 index 0000000..978ba43 --- /dev/null +++ b/reactrebuild0825/src/types/business.ts @@ -0,0 +1,229 @@ +// Business Logic Types for Black Canyon Tickets Platform + +export interface Event { + id: string; + title: string; + description: string; + date: string; + venue: string; + image?: string; + status: 'draft' | 'published' | 'cancelled' | 'completed'; + ticketsSold: number; + totalCapacity: number; + revenue: number; // in cents + organizationId: string; + createdAt: string; + updatedAt: string; + slug: string; + isPublic: boolean; + tags?: string[]; +} + +export interface TicketType { + id: string; + eventId: string; + name: string; + description?: string; + price: number; // in cents + quantity: number; + sold: number; + status: 'active' | 'paused' | 'sold_out'; + salesStart?: string; + salesEnd?: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + maxPerCustomer?: number; + isVisible: boolean; +} + +export interface OrderItem { + ticketTypeId: string; + ticketTypeName: string; + price: number; // in cents + quantity: number; + subtotal: number; // in cents +} + +export interface Order { + id: string; + eventId: string; + customerEmail: string; + items: OrderItem[]; + subtotal: number; // in cents + platformFee: number; // in cents + processingFee: number; // in cents + tax: number; // in cents + total: number; // in cents + promoCode?: string; + discount?: number; // in cents + status: 'pending' | 'completed' | 'cancelled' | 'refunded'; + createdAt: string; + updatedAt: string; + paymentIntentId?: string; +} + +export interface ScanEvent { + id: string; + ticketId: string; + scannedAt: string; + scannedBy: string; + location?: string; + isValid: boolean; + errorReason?: string; +} + +export interface Ticket { + id: string; + orderId: string; + ticketTypeId: string; + ticketTypeName: string; + customerEmail: string; + qrCode: string; + status: 'valid' | 'used' | 'cancelled' | 'expired'; + issuedAt: string; + scannedAt?: string; + seatNumber?: string; + holderName?: string; +} + +export interface FeeStructure { + platformFeeRate: number; // percentage (e.g., 0.035 for 3.5%) + platformFeeFixed: number; // in cents + processingFeeRate: number; // percentage + processingFeeFixed: number; // in cents + taxRate: number; // percentage + maxPlatformFee?: number; // in cents + minPlatformFee?: number; // in cents +} + +export interface PromoCode { + id: string; + code: string; + discountType: 'percentage' | 'fixed'; + discountValue: number; // percentage (0-100) or cents + maxUses?: number; + usedCount: number; + validFrom: string; + validUntil: string; + eventId?: string; // null for global codes + isActive: boolean; + minOrderAmount?: number; // in cents +} + +// Computed/derived types for UI +export interface EventStats { + totalRevenue: number; + ticketsSold: number; + totalCapacity: number; + salesRate: number; // percentage + averageOrderValue: number; + topSellingTicketType?: string; +} + +export interface TicketTypeStats { + sold: number; + available: number; + revenue: number; + salesRate: number; +} + +export interface ScanStatus { + isValid: boolean; + status: 'valid' | 'invalid' | 'used' | 'expired' | 'not_found'; + timestamp?: string; + errorMessage?: string; + ticketInfo?: { + eventTitle: string; + ticketTypeName: string; + customerEmail: string; + seatNumber?: string; + }; +} + +// Mock data generators for development +export const MOCK_EVENTS: Event[] = [ + { + id: 'evt-1', + title: 'Autumn Gala & Silent Auction', + description: 'An elegant evening of fine dining, dancing, and philanthropy benefiting local arts education.', + date: '2024-11-15T19:00:00Z', + venue: 'Grand Ballroom at The Meridian', + image: 'https://images.unsplash.com/photo-1464047736614-af63643285bf?w=800', + status: 'published', + ticketsSold: 156, + totalCapacity: 200, + revenue: 4680000, // $46,800 + organizationId: 'org-1', + createdAt: '2024-09-01T10:00:00Z', + updatedAt: '2024-10-15T14:30:00Z', + slug: 'autumn-gala-2024', + isPublic: true, + tags: ['gala', 'fundraising', 'black-tie'] + }, + { + id: 'evt-2', + title: 'Contemporary Dance Showcase', + description: 'A mesmerizing evening featuring emerging and established contemporary dance artists.', + date: '2024-12-03T20:00:00Z', + venue: 'Studio Theater at Arts Center', + image: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=800', + status: 'published', + ticketsSold: 87, + totalCapacity: 120, + revenue: 2175000, // $21,750 + organizationId: 'org-1', + createdAt: '2024-09-15T12:00:00Z', + updatedAt: '2024-10-20T09:15:00Z', + slug: 'contemporary-dance-showcase', + isPublic: true, + tags: ['dance', 'contemporary', 'arts'] + } +]; + +export const MOCK_TICKET_TYPES: TicketType[] = [ + { + id: 'tt-1', + eventId: 'evt-1', + name: 'VIP Patron', + description: 'Premium seating, cocktail reception, and auction preview', + price: 35000, // $350 + quantity: 50, + sold: 42, + status: 'active', + salesStart: '2024-09-01T00:00:00Z', + salesEnd: '2024-11-15T17:00:00Z', + sortOrder: 1, + createdAt: '2024-09-01T10:00:00Z', + updatedAt: '2024-10-15T14:30:00Z', + maxPerCustomer: 4, + isVisible: true + }, + { + id: 'tt-2', + eventId: 'evt-1', + name: 'General Admission', + description: 'Includes dinner and dancing', + price: 15000, // $150 + quantity: 150, + sold: 114, + status: 'active', + salesStart: '2024-09-01T00:00:00Z', + salesEnd: '2024-11-15T17:00:00Z', + sortOrder: 2, + createdAt: '2024-09-01T10:00:00Z', + updatedAt: '2024-10-15T14:30:00Z', + maxPerCustomer: 8, + isVisible: true + } +]; + +export const DEFAULT_FEE_STRUCTURE: FeeStructure = { + platformFeeRate: 0.035, // 3.5% + platformFeeFixed: 99, // $0.99 + processingFeeRate: 0.029, // 2.9% + processingFeeFixed: 30, // $0.30 + taxRate: 0.0875, // 8.75% + maxPlatformFee: 1500, // $15.00 + minPlatformFee: 50 // $0.50 +}; \ No newline at end of file diff --git a/reactrebuild0825/src/types/index.ts b/reactrebuild0825/src/types/index.ts new file mode 100644 index 0000000..153d19d --- /dev/null +++ b/reactrebuild0825/src/types/index.ts @@ -0,0 +1,26 @@ +// Type exports for the Black Canyon Tickets React rebuild +export type { + User, + Organization, + UserPreferences, + AuthState, + LoginCredentials, + AuthContextType, +} from './auth'; + +export type { + Event, + TicketType, + OrderItem, + Order, + ScanEvent, + Ticket, + FeeStructure, + PromoCode, + EventStats, + TicketTypeStats, + ScanStatus, +} from './business'; + +export { MOCK_USERS, ROLE_PERMISSIONS } from './auth'; +export { MOCK_EVENTS, MOCK_TICKET_TYPES, DEFAULT_FEE_STRUCTURE } from './business'; \ No newline at end of file