- 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>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
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<OrderSummaryProps> = ({
|
||
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<string | null>(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 (
|
||
<Card className={`${className}`} variant="elevated">
|
||
<CardBody className="space-y-3">
|
||
{/* Order Items Summary */}
|
||
<div className="space-y-2">
|
||
{order.items.map((item, index) => (
|
||
<div key={index} className="flex justify-between text-sm">
|
||
<span className="text-fg-secondary">
|
||
{item.quantity}x {item.ticketTypeName}
|
||
</span>
|
||
<span className="font-medium text-fg-primary">
|
||
{formatCurrency(item.subtotal)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Promo Code */}
|
||
{order.promoCode && (
|
||
<div className="flex justify-between text-sm border-t border-border-subtle pt-2">
|
||
<span className="text-success">Promo: {order.promoCode}</span>
|
||
<span className="font-medium text-success">
|
||
-{formatCurrency(order.discount || 0)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Total */}
|
||
<div className="flex justify-between items-center pt-2 border-t border-border-subtle">
|
||
<span className="text-lg font-semibold text-fg-primary">Total</span>
|
||
<span className="text-xl font-bold text-accent-primary">
|
||
{formatCurrency(order.total)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Fee Breakdown Toggle */}
|
||
<button
|
||
onClick={() => setShowFeeBreakdown(!showFeeBreakdown)}
|
||
className="text-xs text-fg-secondary hover:text-fg-primary transition-colors underline"
|
||
>
|
||
{showFeeBreakdown ? 'Hide' : 'Show'} fee breakdown
|
||
</button>
|
||
|
||
{showFeeBreakdown && (
|
||
<div className="space-y-1 text-xs text-fg-secondary border-t border-border-subtle pt-2">
|
||
<div className="flex justify-between">
|
||
<span>Subtotal</span>
|
||
<span>{formatCurrency(order.subtotal)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Platform fee</span>
|
||
<span>{formatCurrency(order.platformFee)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Processing fee</span>
|
||
<span>{formatCurrency(order.processingFee)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Tax</span>
|
||
<span>{formatCurrency(order.tax)}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card className={className} variant="elevated">
|
||
<CardHeader>
|
||
<h3 className="text-lg font-semibold text-fg-primary">Order Summary</h3>
|
||
</CardHeader>
|
||
|
||
<CardBody className="space-y-4">
|
||
{/* Order Items */}
|
||
<div className="space-y-3">
|
||
{order.items.map((item, index) => (
|
||
<div key={index} className="flex items-center justify-between">
|
||
<div className="flex-1">
|
||
<div className="font-medium text-fg-primary">{item.ticketTypeName}</div>
|
||
<div className="text-sm text-fg-secondary">
|
||
{formatCurrency(item.price)} × {item.quantity}
|
||
</div>
|
||
</div>
|
||
<div className="font-semibold text-fg-primary">
|
||
{formatCurrency(item.subtotal)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Subtotal */}
|
||
<div className="flex justify-between items-center pt-3 border-t border-border-subtle">
|
||
<span className="font-medium text-fg-primary">Subtotal</span>
|
||
<span className="font-semibold text-fg-primary">
|
||
{formatCurrency(order.subtotal)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Promo Code Section */}
|
||
{showPromoCode && (
|
||
<div className="space-y-3">
|
||
{order.promoCode ? (
|
||
<div className="flex items-center justify-between p-3 bg-success/10 border border-success/20 rounded-lg">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="success" size="sm">PROMO</Badge>
|
||
<span className="font-medium text-success">{order.promoCode}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold text-success">
|
||
-{formatCurrency(order.discount || 0)}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleRemovePromo}
|
||
className="p-1 h-6 w-6 text-success hover:text-success"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="Enter promo code"
|
||
value={promoCodeInput}
|
||
onChange={(e) => setPromoCodeInput(e.target.value.toUpperCase())}
|
||
className="flex-1"
|
||
onKeyDown={(e) => e.key === 'Enter' && handleApplyPromo()}
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={handleApplyPromo}
|
||
disabled={!promoCodeInput.trim() || isApplyingPromo}
|
||
loading={isApplyingPromo}
|
||
>
|
||
Apply
|
||
</Button>
|
||
</div>
|
||
{promoError && (
|
||
<Alert variant="error" className="text-sm">
|
||
{promoError}
|
||
</Alert>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Fee Breakdown */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<button
|
||
onClick={() => setShowFeeBreakdown(!showFeeBreakdown)}
|
||
className="flex items-center gap-1 text-sm text-fg-secondary hover:text-fg-primary transition-colors"
|
||
>
|
||
<span>Fees & Taxes</span>
|
||
<svg
|
||
className={`w-4 h-4 transition-transform ${showFeeBreakdown ? 'rotate-180' : ''}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<span className="font-medium text-fg-primary">
|
||
{formatCurrency(order.platformFee + order.processingFee + order.tax)}
|
||
</span>
|
||
</div>
|
||
|
||
{showFeeBreakdown && (
|
||
<div className="space-y-2 pl-4 border-l-2 border-border-subtle">
|
||
<div className="flex justify-between text-sm">
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-fg-secondary">Platform fee</span>
|
||
<button
|
||
title={`${(feeStructure.platformFeeRate * 100).toFixed(1)}% + $${(feeStructure.platformFeeFixed / 100).toFixed(2)}`}
|
||
className="text-fg-muted hover:text-fg-secondary"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<span className="text-fg-secondary">{formatCurrency(order.platformFee)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-fg-secondary">Processing fee</span>
|
||
<button
|
||
title={`${(feeStructure.processingFeeRate * 100).toFixed(1)}% + $${(feeStructure.processingFeeFixed / 100).toFixed(2)}`}
|
||
className="text-fg-muted hover:text-fg-secondary"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<span className="text-fg-secondary">{formatCurrency(order.processingFee)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-fg-secondary">Tax</span>
|
||
<button
|
||
title={`${(feeStructure.taxRate * 100).toFixed(2)}% tax rate`}
|
||
className="text-fg-muted hover:text-fg-secondary"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<span className="text-fg-secondary">{formatCurrency(order.tax)}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardBody>
|
||
|
||
<CardFooter className="border-t border-border-subtle">
|
||
<div className="flex justify-between items-center w-full">
|
||
<span className="text-xl font-bold text-fg-primary">Total</span>
|
||
<span className="text-2xl font-bold text-accent-primary">
|
||
{formatCurrency(order.total)}
|
||
</span>
|
||
</div>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default OrderSummary; |