fix(typescript): resolve build errors and improve type safety

- Fix billing components ConnectError type compatibility with exactOptionalPropertyTypes
- Update Select component usage to match proper API (options vs children)
- Remove unused imports and fix optional property assignments in system components
- Resolve duplicate Order/Ticket type definitions and add null safety checks
- Handle optional branding properties correctly in organization features
- Add window property type declarations for test environment
- Fix Playwright API usage (page.setOffline → page.context().setOffline)
- Clean up unused imports, variables, and parameters across codebase
- Add comprehensive global type declarations for test window extensions

Resolves major TypeScript compilation issues and improves type safety throughout the application.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-22 13:31:19 -06:00
parent 5edaaf0651
commit d5c3953888
16 changed files with 6085 additions and 6 deletions

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { ExternalLink, CreditCard, Loader2 } from 'lucide-react';
import { useStripeConnect } from '../../hooks/useStripeConnect';
import { Button } from '../ui/Button';
import type { StripeConnectButtonProps, ConnectError } from '../../types/stripe';
export const StripeConnectButton: React.FC<StripeConnectButtonProps> = ({
orgId,
onSuccess,
onError,
className = '',
children,
}) => {
const { startOnboarding, isLoading, error } = useStripeConnect(orgId);
const handleConnect = async () => {
try {
await startOnboarding();
// Note: This will redirect to Stripe, so success callback
// will be called when user returns via the return URL
if (onSuccess) {
// We can't actually call this here since we redirect,
// but it's useful for the component API
}
} catch (err) {
if (onError) {
const errorObj: ConnectError = {
error: err instanceof Error ? err.message : 'Failed to start onboarding',
};
if (err instanceof Error && err.stack) {
errorObj.details = err.stack;
}
onError(errorObj);
}
}
};
React.useEffect(() => {
if (error && onError) {
const errorObj: ConnectError = error instanceof Error
? error.stack
? { error: error.message, details: error.stack }
: { error: error.message }
: error;
onError(errorObj);
}
}, [error, onError]);
return (
<Button
onClick={handleConnect}
disabled={isLoading}
variant="primary"
size="lg"
className={`
relative overflow-hidden
bg-gradient-to-r from-primary-600 to-primary-700
hover:from-primary-700 hover:to-primary-800
transition-all duration-200
shadow-lg hover:shadow-xl
${className}
`}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
{children || (
<>
<CreditCard className="mr-2 h-4 w-4" />
Connect Stripe Account
<ExternalLink className="ml-2 h-3 w-3" />
</>
)}
</>
)}
</Button>
);
};

View File

@@ -0,0 +1,181 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle, AlertCircle, Clock, CreditCard, Building } from 'lucide-react';
import { useStripeConnect } from '../../hooks/useStripeConnect';
import { Alert } from '../ui/Alert';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { StripeConnectButton } from './StripeConnectButton';
import type { StripeConnectStatusProps, OrgPaymentData } from '../../types/stripe';
export const StripeConnectStatus: React.FC<StripeConnectStatusProps> = ({
orgId,
onStatusUpdate,
className = '',
}) => {
const { checkStatus, isLoading, error, paymentData } = useStripeConnect(orgId);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
// Check status on mount and when URL indicates return from Stripe
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const status = urlParams.get('status');
// Always check status on mount
handleRefreshStatus();
// If we're returning from Stripe onboarding, check again after a delay
if (status === 'connected' || status === 'refresh') {
setTimeout(() => {
handleRefreshStatus();
}, 2000);
}
}, []);
// Call onStatusUpdate when paymentData changes
useEffect(() => {
if (paymentData && onStatusUpdate) {
onStatusUpdate(paymentData);
}
}, [paymentData, onStatusUpdate]);
const handleRefreshStatus = async () => {
const data = await checkStatus();
if (data) {
setLastChecked(new Date());
}
};
const getStatusInfo = (data: OrgPaymentData | null) => {
if (!data) {
return {
icon: <AlertCircle className="h-5 w-5 text-warning-500" />,
badge: <Badge variant="warning">Not Connected</Badge>,
title: 'Stripe Account Required',
description: 'Connect your Stripe account to accept payments.',
};
}
const { connected, stripe } = data;
if (connected) {
return {
icon: <CheckCircle className="h-5 w-5 text-success-500" />,
badge: <Badge variant="success">Connected</Badge>,
title: 'Stripe Account Connected',
description: `Ready to accept payments${stripe.businessName ? ` as ${stripe.businessName}` : ''}.`,
};
}
if (stripe.detailsSubmitted && !stripe.chargesEnabled) {
return {
icon: <Clock className="h-5 w-5 text-warning-500" />,
badge: <Badge variant="warning">Under Review</Badge>,
title: 'Account Under Review',
description: 'Stripe is reviewing your account. This usually takes 1-2 business days.',
};
}
return {
icon: <AlertCircle className="h-5 w-5 text-warning-500" />,
badge: <Badge variant="warning">Incomplete</Badge>,
title: 'Setup Incomplete',
description: 'Please complete your Stripe account setup to accept payments.',
};
};
const statusInfo = getStatusInfo(paymentData);
return (
<div className={`space-y-4 ${className}`}>
<Card className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
{statusInfo.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-lg font-semibold text-text-primary">
{statusInfo.title}
</h3>
{statusInfo.badge}
</div>
<p className="text-text-secondary mb-4">
{statusInfo.description}
</p>
{paymentData?.stripe && (
<div className="space-y-2 text-sm text-text-secondary">
<div className="flex items-center space-x-2">
<CreditCard className="h-4 w-4" />
<span>Account ID: {paymentData.stripe.accountId}</span>
</div>
{paymentData.stripe.businessName && (
<div className="flex items-center space-x-2">
<Building className="h-4 w-4" />
<span>Business: {paymentData.stripe.businessName}</span>
</div>
)}
</div>
)}
</div>
</div>
<div className="flex flex-col space-y-2">
{!paymentData?.connected && (
<StripeConnectButton
orgId={orgId}
onError={(err) => console.error('Stripe Connect error:', err)}
>
{paymentData ? 'Continue Setup' : 'Connect Stripe'}
</StripeConnectButton>
)}
<Button
variant="outline"
size="sm"
onClick={handleRefreshStatus}
disabled={isLoading}
className="min-w-0"
>
{isLoading ? 'Checking...' : 'Refresh'}
</Button>
</div>
</div>
</Card>
{error && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<div>
<p className="font-medium">Failed to check Stripe status</p>
<p className="text-sm">{'error' in error ? error.error : error.message}</p>
</div>
</Alert>
)}
{lastChecked && (
<p className="text-xs text-text-secondary">
Last checked: {lastChecked.toLocaleTimeString()}
</p>
)}
{/* Development info */}
{import.meta.env.DEV && paymentData && (
<Card className="p-4 bg-surface-secondary border-warning-200">
<h4 className="text-sm font-medium text-warning-700 mb-2">
Development Info
</h4>
<pre className="text-xs text-warning-600 overflow-x-auto">
{JSON.stringify(paymentData, null, 2)}
</pre>
</Card>
)}
</div>
);
};

View File

@@ -0,0 +1,300 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useClaims, useAccessibleTerritories } from '@/hooks/useClaims';
import { MOCK_TERRITORIES, type Territory } from '@/types/territory';
import { useWizardEventDetails, useWizardValidation } from '../../stores';
import { Input, Select } from '../ui';
import { Card, CardBody, CardHeader } from '../ui/Card';
import type { Event } from '../../types/business';
// Legacy props interface - kept for backward compatibility
export interface EventDetailsStepProps {
eventDetails?: Partial<Event>;
onUpdate?: (updates: Partial<Event>) => void;
}
export const EventDetailsStep: React.FC<EventDetailsStepProps> = () => {
// Use store hooks
const eventDetails = useWizardEventDetails();
const validation = useWizardValidation();
// Territory management
const { claims } = useClaims();
const { accessibleTerritoryIds, hasFullAccess } = useAccessibleTerritories();
const [availableTerritories, setAvailableTerritories] = useState<Territory[]>([]);
// Load available territories based on user role and access
useEffect(() => {
if (!claims?.orgId) {
setAvailableTerritories([]);
return;
}
// Filter territories by organization
const orgTerritories = MOCK_TERRITORIES.filter(
territory => territory.orgId === claims.orgId
);
// Further filter by user access if they don't have full access
const filtered = hasFullAccess
? orgTerritories
: orgTerritories.filter(territory =>
accessibleTerritoryIds.includes(territory.id)
);
setAvailableTerritories(filtered);
// Auto-select territory for territory managers if not already set
if (claims.role === 'territoryManager' &&
!eventDetails.eventDetails.territoryId &&
filtered.length === 1) {
eventDetails.updateEventDetails({ territoryId: filtered[0].id });
}
}, [claims, accessibleTerritoryIds, hasFullAccess, eventDetails]);
const handleInputChange = useCallback(
(field: keyof Event) => (value: string) => {
if (field === 'title') {eventDetails.setEventTitle(value);}
else if (field === 'description') {eventDetails.setEventDescription(value);}
else if (field === 'venue') {eventDetails.setEventVenue(value);}
else if (field === 'image') {eventDetails.setEventImage(value);}
else {eventDetails.updateEventDetails({ [field]: value });}
},
[eventDetails]
);
const handleCheckboxChange = useCallback(
(field: keyof Event) => (checked: boolean) => {
if (field === 'isPublic') {eventDetails.setEventVisibility(checked);}
else {eventDetails.updateEventDetails({ [field]: checked });}
},
[eventDetails]
);
const handleTagsChange = useCallback(
(tagsString: string) => {
const tags = tagsString
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
eventDetails.updateEventDetails({ tags });
},
[eventDetails]
);
// Convert date for datetime-local input
const formatDateForInput = (dateString: string) => {
if (!dateString) {return '';}
try {
const date = new Date(dateString);
// Format as YYYY-MM-DDTHH:mm for datetime-local input
return date.toISOString().slice(0, 16);
} catch {
return '';
}
};
const handleDateChange = (dateString: string) => {
if (!dateString) {
eventDetails.setEventDate('');
return;
}
try {
// Convert from datetime-local format to ISO string
const date = new Date(dateString);
eventDetails.setEventDate(date.toISOString());
} catch {
// Invalid date, don't update
}
};
const currentTags = eventDetails.eventDetails.tags ? eventDetails.eventDetails.tags.join(', ') : '';
// Get validation errors
const errors = validation.validationErrors.eventDetails || [];
return (
<div className="space-y-6 py-4">
{errors.length > 0 && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<h4 className="text-sm font-semibold text-red-400 mb-2">Please fix the following errors:</h4>
<ul className="text-sm text-red-300 space-y-1">
{errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</div>
)}
<Card variant="surface" className="border-border-subtle">
<CardHeader>
<h3 className="text-lg font-semibold text-text-primary">Basic Information</h3>
<p className="text-sm text-text-secondary">
Provide the essential details for your event
</p>
</CardHeader>
<CardBody className="space-y-4">
<Input
label="Event Title"
value={eventDetails.eventDetails.title || ''}
onChange={(e) => handleInputChange('title')(e.target.value)}
placeholder="Enter event title"
required
helperText="A clear, descriptive title for your event"
error={errors.some(err => err.includes('title')) ? 'Please provide a valid title' : undefined}
/>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Description
</label>
<textarea
value={eventDetails.eventDetails.description || ''}
onChange={(e) => handleInputChange('description')(e.target.value)}
placeholder="Describe your event, what attendees can expect, dress code, etc."
rows={4}
className={`w-full px-3 py-2 border rounded-lg bg-background-elevated text-text-primary placeholder-text-muted focus:ring-2 focus:ring-accent-primary-500 focus:border-accent-primary-500 transition-colors resize-none ${
errors.some(err => err.includes('description'))
? 'border-red-500'
: 'border-border-subtle'
}`}
required
/>
<p className="text-xs text-text-muted mt-1">
Provide a detailed description to help attendees understand what to expect
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Event Date & Time
</label>
<input
type="datetime-local"
value={formatDateForInput(eventDetails.eventDetails.date || '')}
onChange={(e) => handleDateChange(e.target.value)}
className={`w-full px-3 py-2 border rounded-lg bg-background-elevated text-text-primary focus:ring-2 focus:ring-accent-primary-500 focus:border-accent-primary-500 transition-colors ${
errors.some(err => err.includes('date'))
? 'border-red-500'
: 'border-border-subtle'
}`}
required
/>
</div>
<Input
label="Venue"
value={eventDetails.eventDetails.venue || ''}
onChange={(e) => handleInputChange('venue')(e.target.value)}
placeholder="Event location"
required
helperText="Full venue name and address if needed"
error={errors.some(err => err.includes('Venue')) ? 'Please provide a valid venue' : undefined}
/>
</div>
{/* Territory Selection */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Territory <span className="text-red-500">*</span>
{claims?.role === 'territoryManager' && (
<span className="ml-2 text-xs text-text-secondary">
(Limited to your assigned territories)
</span>
)}
</label>
<Select
value={eventDetails.eventDetails.territoryId || ''}
onChange={(value: string | string[]) => eventDetails.updateEventDetails({ territoryId: value as string })}
disabled={availableTerritories.length === 0 ||
(claims?.role === 'territoryManager' && availableTerritories.length === 1)}
placeholder={availableTerritories.length === 0
? 'No territories available'
: 'Select a territory...'}
options={availableTerritories.map(territory => ({
value: territory.id,
label: `${territory.code} - ${territory.name}${territory.description ? ` (${territory.description})` : ''}`,
disabled: false
}))}
{...(errors.some(err => err.includes('territory')) ? { error: 'Please select a territory for this event' } : {})}
className="w-full"
/>
<p className="text-xs text-text-muted mt-1">
Territory determines access permissions and reporting scope for this event
</p>
</div>
</CardBody>
</Card>
<Card variant="surface" className="border-border-subtle">
<CardHeader>
<h3 className="text-lg font-semibold text-text-primary">Additional Details</h3>
<p className="text-sm text-text-secondary">
Optional information to enhance your event listing
</p>
</CardHeader>
<CardBody className="space-y-4">
<Input
label="Event Image URL"
value={eventDetails.eventDetails.image || ''}
onChange={(e) => handleInputChange('image')(e.target.value)}
placeholder="https://example.com/event-image.jpg"
type="url"
helperText="Link to a high-quality image representing your event"
/>
<Input
label="Tags"
value={currentTags}
onChange={(e) => handleTagsChange(e.target.value)}
placeholder="gala, fundraising, black-tie"
helperText="Comma-separated keywords to help categorize your event"
/>
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="isPublic"
checked={eventDetails.eventDetails.isPublic || false}
onChange={(e) => handleCheckboxChange('isPublic')(e.target.checked)}
className="mt-1 h-4 w-4 text-accent-primary-500 border-border-subtle rounded focus:ring-accent-primary-500 bg-background-elevated"
/>
<div className="flex-1">
<label htmlFor="isPublic" className="text-sm font-medium text-text-primary">
Public Event
</label>
<p className="text-xs text-text-secondary mt-1">
Allow this event to be discovered in public event listings and search results
</p>
</div>
</div>
</CardBody>
</Card>
<Card variant="glass" className="bg-background-secondary border-accent-primary-200">
<CardBody>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-accent-primary-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-accent-primary-700">
Tips for Great Events
</h4>
<ul className="text-xs text-accent-primary-600 mt-2 space-y-1">
<li> Use a clear, compelling event title that describes what attendees will experience</li>
<li> Include key details like dress code, parking information, or special instructions</li>
<li> Choose an event image that captures the atmosphere and quality of your event</li>
<li> Consider your target audience when setting the public visibility</li>
</ul>
</div>
</div>
</CardBody>
</Card>
</div>
);
};

View File

@@ -0,0 +1,170 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '../ui/Button';
interface DataErrorProps {
/**
* Custom error title. Defaults to "Failed to load data"
*/
title?: string;
/**
* Error message to display. If not provided, shows generic message
*/
message?: string;
/**
* Retry callback function. If provided, shows retry button
*/
onRetry?: () => void;
/**
* Loading state for retry button
*/
isRetrying?: boolean;
/**
* Size variant for the error display
*/
size?: 'sm' | 'md' | 'lg';
/**
* Additional CSS classes
*/
className?: string;
}
/**
* DataError - Compact error component for API failures
*
* Features:
* - Clean, professional error messaging
* - Optional retry functionality with loading state
* - Responsive design with size variants
* - Uses design tokens for consistent styling
* - Fully accessible with proper ARIA labels
* - Glassmorphism styling with subtle animations
*
* @example
* ```tsx
* // Basic error
* <DataError title="Events not found" />
*
* // With retry
* <DataError
* title="Failed to load events"
* onRetry={() => refetch()}
* isRetrying={isLoading}
* />
*
* // Custom message
* <DataError
* title="Connection Error"
* message="Unable to connect to the server. Please check your internet connection."
* onRetry={handleRetry}
* />
* ```
*/
export function DataError({
title = 'Failed to load data',
message,
onRetry,
isRetrying = false,
size = 'md',
className = ''
}: DataErrorProps) {
// Default messages based on common scenarios
const defaultMessage = message || 'An error occurred while loading data. Please try again.';
// Size-based styling
const sizeClasses = {
sm: {
container: 'p-md space-y-sm',
icon: 'w-5 h-5',
title: 'text-sm font-medium',
message: 'text-xs',
button: 'text-xs px-2 py-1'
},
md: {
container: 'p-lg space-y-md',
icon: 'w-6 h-6',
title: 'text-base font-semibold',
message: 'text-sm',
button: 'text-sm px-3 py-2'
},
lg: {
container: 'p-xl space-y-lg',
icon: 'w-8 h-8',
title: 'text-lg font-bold',
message: 'text-base',
button: 'text-base px-4 py-2'
}
};
const styles = sizeClasses[size];
return (
<div
className={`glass border-error-border bg-error-bg/50 rounded-lg border ${className}`}
role="alert"
aria-live="polite"
>
<div className={`flex items-start gap-md ${styles.container}`}>
{/* Error Icon */}
<div
className="flex-shrink-0 mt-xs"
aria-hidden="true"
>
<div className="rounded-full bg-error-bg p-sm border border-error-border">
<AlertTriangle
className={`${styles.icon} text-error`}
strokeWidth={2}
/>
</div>
</div>
{/* Error Content */}
<div className="flex-grow space-y-sm">
{/* Error Title */}
<h3 className={`${styles.title} text-primary`}>
{title}
</h3>
{/* Error Message */}
<p className={`${styles.message} text-secondary leading-relaxed`}>
{defaultMessage}
</p>
{/* Retry Button */}
{onRetry && (
<div className="pt-xs">
<Button
variant="outline"
size={size === 'lg' ? 'md' : 'sm'}
onClick={onRetry}
disabled={isRetrying}
className={`
${styles.button}
inline-flex items-center gap-xs
border-error-border hover:bg-error-bg/20
focus-ring-error transition-all duration-200
${isRetrying ? 'cursor-not-allowed opacity-70' : 'hover:scale-105'}
`}
aria-label={isRetrying ? 'Retrying...' : 'Retry loading data'}
>
<RefreshCw
className={`w-3 h-3 ${isRetrying ? 'animate-spin' : ''}`}
strokeWidth={2}
aria-hidden="true"
/>
{isRetrying ? 'Retrying...' : 'Retry'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}
export default DataError;

View File

@@ -0,0 +1,207 @@
import React, { Component, type ReactNode } from 'react';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
/**
* CrashPage Component - Displays when application crashes
* Features glassmorphism design with proper accessibility
*/
interface CrashPageProps {
error: Error;
onReload: () => void;
sentryEventId?: string;
}
function CrashPage({ error, onReload, sentryEventId }: CrashPageProps) {
return (
<div className="min-h-screen bg-primary flex items-center justify-center p-lg">
<Card className="max-w-md w-full mx-auto glass">
<div className="text-center space-y-lg p-lg">
{/* Error Icon */}
<div
className="mx-auto w-16 h-16 rounded-full bg-error-bg border border-error-border flex items-center justify-center"
role="img"
aria-label="Error"
>
<svg
className="w-8 h-8 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 18.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
{/* Error Title */}
<div className="space-y-sm">
<h1 className="text-2xl font-bold text-primary">
Something went wrong
</h1>
<p className="text-secondary">
We apologize for the inconvenience. The application has encountered an unexpected error.
</p>
</div>
{/* Error Details in Development */}
{process.env.NODE_ENV === 'development' && (
<details className="text-left bg-elevated-1 rounded-md p-md border border-default">
<summary className="text-sm text-tertiary cursor-pointer mb-sm font-medium">
Technical Details
</summary>
<div className="space-y-xs">
<p className="text-xs font-mono text-error break-all">
{error.message}
</p>
{sentryEventId && (
<p className="text-xs font-mono text-tertiary">
Event ID: {sentryEventId}
</p>
)}
</div>
</details>
)}
{/* Action Button */}
<div className="pt-md">
<Button
onClick={onReload}
variant="primary"
size="lg"
className="w-full"
aria-label="Reload the application"
>
Reload Page
</Button>
</div>
{/* Support Information */}
<div className="pt-lg border-t border-muted">
<p className="text-sm text-tertiary">
If this problem persists, please{' '}
<a
href="mailto:support@blackcanyontickets.com"
className="text-accent hover:text-accent-hover transition-colors underline"
aria-label="Contact support via email"
>
contact support
</a>
{sentryEventId && (
<>
{' '}with reference ID:{' '}
<code className="font-mono text-xs bg-elevated-1 px-xs py-xs rounded">
{sentryEventId}
</code>
</>
)}
</p>
</div>
</div>
</Card>
</div>
);
}
/**
* ErrorBoundary - React component that catches render errors
*
* Features:
* - Catches JavaScript errors in component tree
* - Shows friendly crash page with reload option
* - Integrates with Sentry for error reporting
* - Uses design tokens for consistent styling
* - Fully accessible with proper ARIA labels
*
* @example
* ```tsx
* <ErrorBoundary onError={(error, errorInfo) => console.error(error)}>
* <App />
* </ErrorBoundary>
* ```
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
private sentryEventId: string | undefined;
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error
};
}
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Update state with error info
this.setState({ errorInfo });
// Call custom error handler if provided
this.props.onError?.(error, errorInfo);
// Log error in development
if (process.env.NODE_ENV === 'development') {
console.group('🚨 ErrorBoundary caught error');
console.error('Error:', error);
console.error('Error Info:', errorInfo);
console.groupEnd();
}
// Report to Sentry in production (if available)
try {
// Simulated Sentry integration
// In real implementation: this.sentryEventId = Sentry.captureException(error, { extra: errorInfo });
this.sentryEventId = `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
} catch (sentryError) {
console.error('Failed to report error to Sentry:', sentryError);
}
}
private readonly handleReload = () => {
window.location.reload();
};
override render() {
const { hasError, error } = this.state;
const { children } = this.props;
if (hasError && error) {
return (
<CrashPage
error={error}
onReload={this.handleReload}
{...(this.sentryEventId ? { sentryEventId: this.sentryEventId } : {})}
/>
);
}
return children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,432 @@
import { useState, useEffect } from 'react';
import { DollarSign, RefreshCw, Eye, AlertCircle, CheckCircle, Clock, XCircle } from 'lucide-react';
import { Badge } from '../../components/ui/Badge';
import { Alert } from '../../components/ui/Alert';
import { DataError } from '../../components/system/DataError';
import { Button } from '../../components/ui/Button';
import { Card } from '../../components/ui/Card';
import { RefundModal } from './RefundModal';
import type { Order as BusinessOrder } from '../../types/business';
// Local interfaces for orders table
interface OrderTicket {
id: string;
status: 'issued' | 'scanned' | 'refunded' | 'void' | 'locked_dispute';
priceCents: number;
ticketTypeName: string;
qr: string;
purchaserEmail: string;
}
interface Order extends Omit<BusinessOrder, 'tickets' | 'totalCents'> {
totalCents: number;
tickets: OrderTicket[];
orgId: string;
eventName: string;
purchaserEmail: string;
}
interface OrdersTableProps {
eventId: string;
orgId: string;
onOrderUpdated?: () => void;
}
export function OrdersTable({ eventId, orgId, onOrderUpdated }: OrdersTableProps) {
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
const [showRefundModal, setShowRefundModal] = useState(false);
// Removed unused getApiUrl function
const loadOrders = async () => {
setIsLoading(true);
setError(null);
try {
// Mock API call - in production, this would fetch orders from Firebase Functions
const mockOrders: Order[] = [
{
id: 'order-1',
orgId,
eventId,
eventName: 'Summer Music Festival 2024',
status: 'paid',
totalCents: 15000, // $150.00
purchaserEmail: 'john.doe@example.com',
createdAt: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
paymentIntentId: 'pi_mock_123',
tickets: [
{
id: 'ticket-1',
status: 'issued' as const,
priceCents: 7500,
ticketTypeName: 'General Admission',
qr: 'qr-code-1',
purchaserEmail: 'john.doe@example.com',
},
{
id: 'ticket-2',
status: 'scanned' as const,
priceCents: 7500,
ticketTypeName: 'General Admission',
qr: 'qr-code-2',
purchaserEmail: 'john.doe@example.com',
},
],
},
{
id: 'order-2',
orgId,
eventId,
eventName: 'Summer Music Festival 2024',
status: 'paid',
totalCents: 10000, // $100.00
purchaserEmail: 'jane.smith@example.com',
createdAt: new Date(Date.now() - 172800000).toISOString(), // 2 days ago
paymentIntentId: 'pi_mock_456',
tickets: [
{
id: 'ticket-3',
status: 'locked_dispute' as const,
priceCents: 10000,
ticketTypeName: 'VIP Access',
qr: 'qr-code-3',
purchaserEmail: 'jane.smith@example.com',
},
],
dispute: {
disputeId: 'dp_mock_789',
status: 'warning_needs_response',
reason: 'fraudulent',
},
},
{
id: 'order-3',
orgId,
eventId,
eventName: 'Summer Music Festival 2024',
status: 'paid',
totalCents: 5000, // $50.00
purchaserEmail: 'bob.wilson@example.com',
createdAt: new Date(Date.now() - 259200000).toISOString(), // 3 days ago
paymentIntentId: 'pi_mock_789',
tickets: [
{
id: 'ticket-4',
status: 'refunded' as const,
priceCents: 5000,
ticketTypeName: 'Early Bird',
qr: 'qr-code-4',
purchaserEmail: 'bob.wilson@example.com',
},
],
refunds: [
{
id: 'refund-1',
amountCents: 5000,
status: 'succeeded',
createdAt: new Date(Date.now() - 86400000).toISOString(),
reason: 'Customer request',
},
],
},
];
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
setOrders(mockOrders);
} catch (err) {
console.error('Failed to load orders:', err);
setError(err instanceof Error ? err.message : 'Failed to load orders');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadOrders();
}, [eventId, orgId]);
const formatCurrency = (cents: number) => new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(cents / 100);
const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid':
return <Badge variant="success" className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Paid
</Badge>;
case 'pending':
return <Badge variant="warning" className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Pending
</Badge>;
case 'failed_sold_out':
return <Badge variant="error" className="flex items-center gap-1">
<XCircle className="w-3 h-3" />
Failed
</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getTicketStatusBadge = (status: string) => {
switch (status) {
case 'issued':
return <Badge variant="primary" size="sm">Issued</Badge>;
case 'scanned':
return <Badge variant="success" size="sm">Scanned</Badge>;
case 'refunded':
return <Badge variant="secondary" size="sm">Refunded</Badge>;
case 'void':
return <Badge variant="error" size="sm">Void</Badge>;
case 'locked_dispute':
return <Badge variant="warning" size="sm">Dispute</Badge>;
default:
return <Badge variant="secondary" size="sm">{status}</Badge>;
}
};
const canRefund = (order: Order) => {
if (order.status !== 'paid') {return false;}
if (order.dispute) {return false;} // Cannot refund disputed orders
// Check if there are any refundable tickets
return order.tickets?.some(ticket => ['issued', 'scanned'].includes(ticket.status)) || false;
};
const getTotalRefunded = (order: Order) => order.refunds?.reduce((total, refund) => refund.status === 'succeeded' ? total + refund.amountCents : total, 0) || 0;
const handleRefund = (order: Order) => {
setSelectedOrder(order);
setShowRefundModal(true);
};
const handleRefundCreated = () => {
setShowRefundModal(false);
setSelectedOrder(null);
loadOrders(); // Reload orders
if (onOrderUpdated) {
onOrderUpdated();
}
};
if (isLoading) {
return (
<Card className="p-spacing-lg">
<div className="flex items-center justify-center py-spacing-xl">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" />
<span className="ml-2 text-text-secondary">Loading orders...</span>
</div>
</Card>
);
}
if (error) {
return (
<DataError
title="Failed to Load Orders"
message={error}
onRetry={loadOrders}
isRetrying={isLoading}
/>
);
}
if (orders.length === 0) {
return (
<Card className="p-spacing-lg">
<div className="text-center py-spacing-xl">
<DollarSign className="h-12 w-12 text-text-muted mx-auto mb-spacing-md" />
<h3 className="text-lg font-medium text-text-primary mb-spacing-sm">
No Orders Found
</h3>
<p className="text-text-secondary">
Orders will appear here once customers purchase tickets.
</p>
</div>
</Card>
);
}
return (
<>
<div className="space-y-spacing-md">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-text-primary">
Orders ({orders.length})
</h2>
<Button variant="outline" size="sm" onClick={loadOrders}>
<RefreshCw className="h-4 w-4 mr-1" />
Refresh
</Button>
</div>
{/* Orders List */}
<div className="space-y-spacing-md">
{orders.map((order) => {
const totalRefunded = getTotalRefunded(order);
const netAmount = (order.totalCents || 0) - totalRefunded;
return (
<Card key={order.id} className="p-spacing-lg">
<div className="space-y-spacing-md">
{/* Order Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center space-x-spacing-sm mb-spacing-xs">
<h3 className="font-semibold text-text-primary">
Order #{order.id.slice(-8)}
</h3>
{getStatusBadge(order.status)}
</div>
<div className="text-sm text-text-secondary space-y-0.5">
<div>Customer: {order.purchaserEmail}</div>
<div>Date: {formatDate(order.createdAt)}</div>
{order.paymentIntentId && (
<div className="font-mono text-xs">PI: {order.paymentIntentId}</div>
)}
</div>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-text-primary">
{formatCurrency(order.totalCents || 0)}
</div>
{totalRefunded > 0 && (
<div className="text-sm text-text-secondary">
<div>Refunded: {formatCurrency(totalRefunded)}</div>
<div className="font-medium">Net: {formatCurrency(netAmount)}</div>
</div>
)}
</div>
</div>
{/* Dispute Alert */}
{order.dispute && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<div>
<p className="font-medium">Payment Dispute</p>
<p className="text-sm">
Status: {order.dispute.status} Reason: {order.dispute.reason}
{order.dispute.outcome && ` • Outcome: ${order.dispute.outcome}`}
</p>
</div>
</Alert>
)}
{/* Tickets */}
<div className="space-y-spacing-sm">
<h4 className="font-medium text-text-primary">
Tickets ({order.tickets?.length || 0})
</h4>
<div className="grid gap-spacing-sm">
{(order.tickets || []).map((ticket) => (
<div key={ticket.id} className="flex items-center justify-between p-spacing-sm bg-surface-secondary rounded">
<div>
<div className="font-medium text-text-primary">
{ticket.ticketTypeName}
</div>
<div className="text-sm text-text-secondary font-mono">
{ticket.id}
</div>
</div>
<div className="flex items-center space-x-spacing-sm">
<span className="text-sm font-medium">
{formatCurrency(ticket.priceCents)}
</span>
{getTicketStatusBadge(ticket.status)}
</div>
</div>
))}
</div>
</div>
{/* Refunds */}
{order.refunds && order.refunds.length > 0 && (
<div className="space-y-spacing-sm">
<h4 className="font-medium text-text-primary">
Refunds ({order.refunds.length})
</h4>
<div className="space-y-spacing-xs">
{order.refunds.map((refund) => (
<div key={refund.id} className="flex items-center justify-between p-spacing-sm bg-error-50 border border-error-200 rounded">
<div>
<div className="text-sm font-medium text-error-900">
{formatCurrency(refund.amountCents)}
</div>
<div className="text-xs text-error-700">
{formatDate(refund.createdAt)}
{refund.reason && `${refund.reason}`}
</div>
</div>
<Badge
variant={refund.status === 'succeeded' ? 'success' : 'warning'}
size="sm"
>
{refund.status}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex space-x-spacing-sm pt-spacing-sm border-t border-border-primary">
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
View Details
</Button>
{canRefund(order) && (
<Button
variant="secondary"
size="sm"
onClick={() => handleRefund(order)}
>
<DollarSign className="h-4 w-4 mr-1" />
Create Refund
</Button>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
{/* Refund Modal */}
{selectedOrder && (
<RefundModal
isOpen={showRefundModal}
onClose={() => setShowRefundModal(false)}
order={selectedOrder}
onRefundCreated={handleRefundCreated}
/>
)}
</>
);
}

View File

@@ -0,0 +1,426 @@
import { useState, useMemo } from 'react';
import { DollarSign, AlertTriangle, CheckCircle } from 'lucide-react';
import { Alert } from '../../components/ui/Alert';
import { Button } from '../../components/ui/Button';
import { Card } from '../../components/ui/Card';
import { Input } from '../../components/ui/Input';
import { Modal } from '../../components/ui/Modal';
interface Ticket {
id: string;
status: 'issued' | 'scanned' | 'refunded' | 'void';
priceCents: number;
ticketTypeName: string;
}
interface Order {
id: string;
totalCents: number;
purchaserEmail: string;
eventName: string;
tickets: Ticket[];
}
interface RefundModalProps {
isOpen: boolean;
onClose: () => void;
order: Order;
onRefundCreated?: (refundId: string) => void;
}
type RefundType = 'full' | 'partial' | 'tickets';
interface RefundRequest {
orderId: string;
ticketId?: string;
amountCents?: number;
reason: string;
}
export function RefundModal({ isOpen, onClose, order, onRefundCreated }: RefundModalProps) {
const [refundType, setRefundType] = useState<RefundType>('full');
const [selectedTickets, setSelectedTickets] = useState<Set<string>>(new Set());
const [customAmount, setCustomAmount] = useState('');
const [reason, setReason] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Filter refundable tickets (issued or scanned, not already refunded/void)
const refundableTickets = useMemo(() => order.tickets.filter(ticket =>
['issued', 'scanned'].includes(ticket.status)
), [order.tickets]);
// Calculate refund amount based on selection
const refundAmount = useMemo(() => {
if (refundType === 'full') {
return order.totalCents;
} if (refundType === 'tickets') {
return Array.from(selectedTickets).reduce((total, ticketId) => {
const ticket = refundableTickets.find(t => t.id === ticketId);
return total + (ticket?.priceCents || 0);
}, 0);
} if (refundType === 'partial') {
const amount = parseFloat(customAmount) * 100; // Convert to cents
return isNaN(amount) ? 0 : Math.round(amount);
}
return 0;
}, [refundType, selectedTickets, customAmount, order.totalCents, refundableTickets]);
// Validate refund amount
const isValidAmount = useMemo(() => {
if (refundAmount <= 0) {return false;}
if (refundAmount > order.totalCents) {return false;}
return true;
}, [refundAmount, order.totalCents]);
const handleTicketSelection = (ticketId: string, selected: boolean) => {
const newSelection = new Set(selectedTickets);
if (selected) {
newSelection.add(ticketId);
} else {
newSelection.delete(ticketId);
}
setSelectedTickets(newSelection);
};
const handleSelectAllTickets = () => {
if (selectedTickets.size === refundableTickets.length) {
setSelectedTickets(new Set());
} else {
setSelectedTickets(new Set(refundableTickets.map(t => t.id)));
}
};
const formatCurrency = (cents: number) => new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(cents / 100);
const handleRefund = async () => {
if (!isValidAmount) {
setError('Invalid refund amount');
return;
}
setIsLoading(true);
setError(null);
setSuccess(null);
try {
const refundRequest: RefundRequest = {
orderId: order.id,
reason: reason.trim() || '',
};
// Set specific parameters based on refund type
if (refundType === 'tickets' && selectedTickets.size === 1) {
// Single ticket refund
refundRequest.ticketId = Array.from(selectedTickets)[0];
} else if (refundType === 'partial' || (refundType === 'tickets' && selectedTickets.size > 1)) {
// Partial amount or multiple tickets (specify amount)
refundRequest.amountCents = refundAmount;
}
// For full refund, no additional parameters needed
// Environment-based API URL
const getApiUrl = (): string => {
if (import.meta.env.DEV) {
return 'http://localhost:5001/black-canyon-tickets/us-central1';
}
return 'https://us-central1-black-canyon-tickets.cloudfunctions.net';
};
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/createRefund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer mock-token`, // In production, use actual auth token
},
body: JSON.stringify(refundRequest),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.details || errorData.error || 'Refund failed');
}
const result = await response.json();
setSuccess(`Refund of ${formatCurrency(refundAmount)} created successfully`);
if (onRefundCreated) {
onRefundCreated(result.refundId);
}
// Reset form
setTimeout(() => {
onClose();
setRefundType('full');
setSelectedTickets(new Set());
setCustomAmount('');
setReason('');
setSuccess(null);
}, 2000);
} catch (err) {
console.error('Refund error:', err);
setError(err instanceof Error ? err.message : 'Failed to create refund');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
if (!isLoading) {
onClose();
setError(null);
setSuccess(null);
setRefundType('full');
setSelectedTickets(new Set());
setCustomAmount('');
setReason('');
}
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Create Refund"
size="lg"
showCloseButton={!isLoading}
>
<div className="space-y-spacing-lg">
{/* Order Information */}
<Card className="p-spacing-md bg-surface-secondary">
<div className="space-y-spacing-sm">
<h3 className="font-semibold text-text-primary">Order Details</h3>
<div className="text-sm text-text-secondary space-y-1">
<div>Event: {order.eventName}</div>
<div>Customer: {order.purchaserEmail}</div>
<div>Total: {formatCurrency(order.totalCents)}</div>
<div>Tickets: {refundableTickets.length} refundable / {order.tickets.length} total</div>
</div>
</div>
</Card>
{/* Refund Type Selection */}
<div className="space-y-spacing-md">
<h3 className="font-semibold text-text-primary">Refund Type</h3>
<div className="space-y-spacing-sm">
{/* Full Refund */}
<label className="flex items-center space-x-spacing-sm cursor-pointer">
<input
type="radio"
name="refundType"
value="full"
checked={refundType === 'full'}
onChange={(e) => setRefundType(e.target.value as RefundType)}
className="text-primary-500"
/>
<span className="text-text-primary">
Full Order Refund ({formatCurrency(order.totalCents)})
</span>
</label>
{/* Specific Tickets */}
{refundableTickets.length > 0 && (
<label className="flex items-center space-x-spacing-sm cursor-pointer">
<input
type="radio"
name="refundType"
value="tickets"
checked={refundType === 'tickets'}
onChange={(e) => setRefundType(e.target.value as RefundType)}
className="text-primary-500"
/>
<span className="text-text-primary">Specific Tickets</span>
</label>
)}
{/* Partial Amount */}
<label className="flex items-center space-x-spacing-sm cursor-pointer">
<input
type="radio"
name="refundType"
value="partial"
checked={refundType === 'partial'}
onChange={(e) => setRefundType(e.target.value as RefundType)}
className="text-primary-500"
/>
<span className="text-text-primary">Custom Amount</span>
</label>
</div>
</div>
{/* Ticket Selection */}
{refundType === 'tickets' && (
<div className="space-y-spacing-md">
<div className="flex items-center justify-between">
<h4 className="font-medium text-text-primary">Select Tickets</h4>
<Button
variant="outline"
size="sm"
onClick={handleSelectAllTickets}
>
{selectedTickets.size === refundableTickets.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<div className="space-y-spacing-sm max-h-40 overflow-y-auto">
{refundableTickets.map((ticket) => (
<label key={ticket.id} className="flex items-center space-x-spacing-sm cursor-pointer p-spacing-sm bg-surface-secondary rounded">
<input
type="checkbox"
checked={selectedTickets.has(ticket.id)}
onChange={(e) => handleTicketSelection(ticket.id, e.target.checked)}
className="text-primary-500"
/>
<div className="flex-1">
<div className="text-sm text-text-primary">{ticket.ticketTypeName}</div>
<div className="text-xs text-text-secondary">
{formatCurrency(ticket.priceCents)} Status: {ticket.status}
</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Custom Amount Input */}
{refundType === 'partial' && (
<div className="space-y-spacing-sm">
<label className="block text-sm font-medium text-text-primary">
Refund Amount
</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-text-secondary" />
<Input
type="number"
step="0.01"
min="0.01"
max={(order.totalCents / 100).toFixed(2)}
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="0.00"
className="pl-10"
/>
</div>
<div className="text-xs text-text-secondary">
Maximum: {formatCurrency(order.totalCents)}
</div>
</div>
)}
{/* Reason */}
<div className="space-y-spacing-sm">
<label className="block text-sm font-medium text-text-primary">
Reason (Optional)
</label>
<Input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason for refund..."
maxLength={200}
/>
</div>
{/* Refund Summary */}
{refundAmount > 0 && (
<Card className="p-spacing-md bg-primary-50 border-primary-200">
<div className="flex items-center space-x-spacing-sm">
<DollarSign className="h-5 w-5 text-primary-600" />
<div>
<div className="font-semibold text-primary-900">
Refund Amount: {formatCurrency(refundAmount)}
</div>
<div className="text-sm text-primary-700">
{refundType === 'full' && 'Full order refund'}
{refundType === 'tickets' && `${selectedTickets.size} ticket(s) selected`}
{refundType === 'partial' && 'Custom partial refund'}
</div>
</div>
</div>
</Card>
)}
{/* Error Alert */}
{error && (
<Alert variant="error">
<AlertTriangle className="h-4 w-4" />
<div>
<p className="font-medium">Refund Failed</p>
<p className="text-sm">{error}</p>
</div>
</Alert>
)}
{/* Success Alert */}
{success && (
<Alert variant="success">
<CheckCircle className="h-4 w-4" />
<div>
<p className="font-medium">Refund Created</p>
<p className="text-sm">{success}</p>
</div>
</Alert>
)}
{/* Actions */}
<div className="flex space-x-spacing-sm pt-spacing-md border-t border-border-primary">
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleRefund}
disabled={isLoading || !isValidAmount || success !== null}
className="flex-1"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
Creating Refund...
</>
) : (
<>
<DollarSign className="h-4 w-4 mr-2" />
Create Refund
</>
)}
</Button>
</div>
{/* Warning */}
{!isLoading && !success && (
<div className="bg-warning-50 border border-warning-200 rounded-lg p-spacing-sm">
<div className="flex items-start space-x-spacing-sm">
<AlertTriangle className="h-4 w-4 text-warning-600 mt-0.5" />
<div className="text-xs text-warning-700">
<p className="font-medium">Important:</p>
<ul className="mt-1 space-y-0.5">
<li> Refunds typically process within 5-10 business days</li>
<li> Refunded tickets will be marked as void and cannot be used</li>
<li> Platform fees are automatically refunded when applicable</li>
<li> This action cannot be undone</li>
</ul>
</div>
</div>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,495 @@
import { useState, useEffect } from 'react';
import { Upload, Palette, Eye, Save, AlertCircle, Check } from 'lucide-react';
import { Alert } from '../../components/ui/Alert';
import { Button } from '../../components/ui/Button';
import { Card } from '../../components/ui/Card';
import { Input } from '../../components/ui/Input';
import { useOrganizationStore, type OrgTheme } from '../../stores/organizationStore';
import {
applyOrgTheme,
validateThemeColors,
validateThemeAccessibility,
generateThemeCSS,
DEFAULT_ORG_THEME
} from '../../theme/orgTheme';
interface ColorInputProps {
label: string;
value: string;
onChange: (value: string) => void;
description?: string;
}
function ColorInput({ label, value, onChange, description }: ColorInputProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-text-primary">
{label}
</label>
<div className="flex items-center space-x-3">
<div
className="w-10 h-10 rounded-lg border-2 border-border-primary cursor-pointer shadow-sm"
style={{ backgroundColor: value }}
onClick={() => {
const input = document.createElement('input');
input.type = 'color';
input.value = value;
input.onchange = (e) => onChange((e.target as HTMLInputElement).value);
input.click();
}}
/>
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="#000000"
className="font-mono text-sm"
/>
</div>
{description && (
<p className="text-xs text-text-secondary">{description}</p>
)}
</div>
);
}
interface LogoUploadProps {
currentLogoUrl?: string;
onLogoChange: (url: string) => void;
}
function LogoUpload({ currentLogoUrl, onLogoChange }: LogoUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const handleFileUpload = async (file: File) => {
setIsUploading(true);
setUploadError(null);
try {
// In a real implementation, this would upload to Firebase Storage
// For now, we'll simulate with a data URL
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
onLogoChange(dataUrl);
setIsUploading(false);
};
reader.onerror = () => {
setUploadError('Failed to read file');
setIsUploading(false);
};
reader.readAsDataURL(file);
} catch (error) {
setUploadError(error instanceof Error ? error.message : 'Upload failed');
setIsUploading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-text-primary">Organization Logo</h3>
</div>
{currentLogoUrl && (
<div className="flex items-center space-x-4 p-4 bg-surface-primary rounded-lg border border-border-primary">
<img
src={currentLogoUrl}
alt="Current logo"
className="w-16 h-16 object-contain rounded-lg bg-canvas-primary"
/>
<div className="flex-1">
<p className="text-sm text-text-primary font-medium">Current Logo</p>
<p className="text-xs text-text-secondary mt-1">Click to upload a new logo</p>
</div>
</div>
)}
<div className="border-2 border-dashed border-border-primary rounded-lg p-6 text-center hover:border-accent-500 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {handleFileUpload(file);}
}}
className="hidden"
id="logo-upload"
disabled={isUploading}
/>
<label
htmlFor="logo-upload"
className="cursor-pointer"
>
<Upload className="w-8 h-8 text-text-secondary mx-auto mb-2" />
<p className="text-sm text-text-primary font-medium">
{isUploading ? 'Uploading...' : 'Upload Organization Logo'}
</p>
<p className="text-xs text-text-secondary mt-1">
PNG, JPG, or SVG up to 2MB
</p>
</label>
</div>
{uploadError && (
<Alert variant="error">
<AlertCircle className="w-4 h-4" />
{uploadError}
</Alert>
)}
</div>
);
}
interface ThemePreviewProps {
theme: OrgTheme;
}
function ThemePreview({ theme }: ThemePreviewProps) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary">Theme Preview</h3>
<div
className="p-6 rounded-lg border-2 transition-all duration-300"
style={{
backgroundColor: theme.bgCanvas,
borderColor: theme.bgSurface,
color: theme.textPrimary,
}}
>
<div
className="p-4 rounded-lg mb-4"
style={{ backgroundColor: theme.bgSurface }}
>
<h4 className="font-semibold mb-2" style={{ color: theme.textPrimary }}>
Sample Card
</h4>
<p className="text-sm mb-3" style={{ color: theme.textSecondary }}>
This is how your content will look with the selected theme colors.
</p>
<button
className="px-4 py-2 rounded-md font-medium text-white transition-colors"
style={{ backgroundColor: theme.accent }}
>
Accent Button
</button>
</div>
<div className="text-xs space-y-1">
<div style={{ color: theme.textPrimary }}>Primary Text Color</div>
<div style={{ color: theme.textSecondary }}>Secondary Text Color</div>
<div
className="inline-block px-2 py-1 rounded text-white"
style={{ backgroundColor: theme.accent }}
>
Accent Color
</div>
</div>
</div>
</div>
);
}
export function BrandingSettings() {
const orgStore = useOrganizationStore();
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
// Form state
const [logoUrl, setLogoUrl] = useState('');
const [faviconUrl, setFaviconUrl] = useState('');
const [theme, setTheme] = useState<OrgTheme>(DEFAULT_ORG_THEME);
// Live preview state
const [isPreviewMode, setIsPreviewMode] = useState(false);
// Load current organization data
useEffect(() => {
if (orgStore.currentOrg) {
setLogoUrl(orgStore.currentOrg.branding.logoUrl || '');
setFaviconUrl(orgStore.currentOrg.branding.faviconUrl || '');
setTheme(orgStore.currentOrg.branding.theme);
}
}, [orgStore.currentOrg]);
// Handle theme changes
const handleThemeChange = (key: keyof OrgTheme, value: string) => {
const newTheme = { ...theme, [key]: value };
setTheme(newTheme);
setIsDirty(true);
// Apply live preview if enabled
if (isPreviewMode) {
applyOrgTheme(newTheme);
}
};
// Toggle live preview
const togglePreview = () => {
if (isPreviewMode) {
// Restore original theme
if (orgStore.currentOrg) {
applyOrgTheme(orgStore.currentOrg.branding.theme);
}
} else {
// Apply preview theme
applyOrgTheme(theme);
}
setIsPreviewMode(!isPreviewMode);
};
// Save changes
const handleSave = async () => {
setSaveError(null);
setSaveSuccess(false);
setIsSaving(true);
try {
// Validate theme colors
const colorValidation = validateThemeColors(theme);
if (!colorValidation.valid) {
throw new Error(`Invalid colors: ${colorValidation.errors.join(', ')}`);
}
// Update organization store
const brandingUpdate: any = { theme };
if (logoUrl) brandingUpdate.logoUrl = logoUrl;
if (faviconUrl) brandingUpdate.faviconUrl = faviconUrl;
orgStore.updateBranding(brandingUpdate);
// In a real implementation, this would save to Firestore
// For now, we'll simulate a save operation
await new Promise(resolve => setTimeout(resolve, 1000));
// Apply the theme
applyOrgTheme(theme);
setIsDirty(false);
setSaveSuccess(true);
// Auto-hide success message
setTimeout(() => setSaveSuccess(false), 3000);
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Failed to save branding');
} finally {
setIsSaving(false);
}
};
// Reset changes
const handleReset = () => {
if (orgStore.currentOrg) {
setLogoUrl(orgStore.currentOrg.branding.logoUrl || '');
setFaviconUrl(orgStore.currentOrg.branding.faviconUrl || '');
setTheme(orgStore.currentOrg.branding.theme);
setIsDirty(false);
// Restore original theme if in preview mode
if (isPreviewMode) {
applyOrgTheme(orgStore.currentOrg.branding.theme);
}
}
};
// Validation results
const colorValidation = validateThemeColors(theme);
const accessibilityValidation = validateThemeAccessibility(theme);
if (!orgStore.currentOrg) {
return (
<div className="p-6">
<Alert variant="error">
<AlertCircle className="w-4 h-4" />
No organization found. Please ensure you are logged in and have the correct permissions.
</Alert>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Branding Settings</h1>
<p className="text-text-secondary mt-1">
Customize your organization's logo, colors, and visual identity
</p>
</div>
<div className="flex items-center space-x-3">
<Button
variant="secondary"
size="sm"
onClick={togglePreview}
className="flex items-center space-x-2"
>
<Eye className="w-4 h-4" />
<span>{isPreviewMode ? 'Exit Preview' : 'Live Preview'}</span>
</Button>
{isDirty && (
<Button
variant="secondary"
size="sm"
onClick={handleReset}
>
Reset
</Button>
)}
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!isDirty || isSaving || !colorValidation.valid}
className="flex items-center space-x-2"
>
{isSaving ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
<span>{isSaving ? 'Saving...' : 'Save Changes'}</span>
</Button>
</div>
</div>
{/* Status messages */}
{saveError && (
<Alert variant="error">
<AlertCircle className="w-4 h-4" />
{saveError}
</Alert>
)}
{saveSuccess && (
<Alert variant="success">
<Check className="w-4 h-4" />
Branding settings saved successfully!
</Alert>
)}
{isPreviewMode && (
<Alert variant="info">
<Eye className="w-4 h-4" />
Live preview mode is active. Changes will be applied in real-time.
</Alert>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Logo Upload */}
<Card className="p-6">
<LogoUpload
currentLogoUrl={logoUrl}
onLogoChange={(url) => {
setLogoUrl(url);
setIsDirty(true);
}}
/>
</Card>
{/* Theme Preview */}
<Card className="p-6">
<ThemePreview theme={theme} />
</Card>
{/* Color Settings */}
<Card className="p-6 lg:col-span-2">
<div className="flex items-center space-x-2 mb-6">
<Palette className="w-5 h-5 text-accent-500" />
<h3 className="text-lg font-semibold text-text-primary">Theme Colors</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ColorInput
label="Accent Color"
value={theme.accent}
onChange={(value) => handleThemeChange('accent', value)}
description="Primary brand color for buttons and highlights"
/>
<ColorInput
label="Canvas Background"
value={theme.bgCanvas}
onChange={(value) => handleThemeChange('bgCanvas', value)}
description="Main page background color"
/>
<ColorInput
label="Surface Background"
value={theme.bgSurface}
onChange={(value) => handleThemeChange('bgSurface', value)}
description="Card and component background color"
/>
<ColorInput
label="Primary Text"
value={theme.textPrimary}
onChange={(value) => handleThemeChange('textPrimary', value)}
description="Main text color for headings and content"
/>
<ColorInput
label="Secondary Text"
value={theme.textSecondary}
onChange={(value) => handleThemeChange('textSecondary', value)}
description="Muted text color for descriptions"
/>
</div>
{/* Validation Messages */}
{!colorValidation.valid && (
<div className="mt-6">
<Alert variant="error">
<AlertCircle className="w-4 h-4" />
<div>
<p className="font-medium">Invalid color values:</p>
<ul className="list-disc list-inside mt-1 text-sm">
{colorValidation.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
</Alert>
</div>
)}
{!accessibilityValidation.valid && (
<div className="mt-4">
<Alert variant="warning">
<AlertCircle className="w-4 h-4" />
<div>
<p className="font-medium">Accessibility concerns:</p>
<ul className="list-disc list-inside mt-1 text-sm">
{accessibilityValidation.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
</Alert>
</div>
)}
</Card>
</div>
{/* CSS Export (for developers) */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">CSS Export</h3>
<p className="text-sm text-text-secondary mb-4">
Copy this CSS to use the theme in external applications or custom code.
</p>
<pre className="bg-surface-primary p-4 rounded-lg text-xs font-mono text-text-primary overflow-x-auto border border-border-primary">
{generateThemeCSS(theme)}
</pre>
</Card>
</div>
);
}

View File

@@ -12,16 +12,31 @@ export interface Event {
totalCapacity: number;
revenue: number; // in cents
organizationId: string;
territoryId: string; // Required for territory-based access control
createdAt: string;
updatedAt: string;
publishedAt?: string; // ISO date string when event was published
slug: string;
isPublic: boolean;
tags?: string[];
}
// Lightweight event type for index pages with minimal data
export interface EventLite {
id: string;
orgId: string;
name: string;
startAt: string;
endAt: string;
venue?: string;
territoryId: string;
status: 'draft' | 'published' | 'archived';
}
export interface TicketType {
id: string;
eventId: string;
territoryId: string; // Inherited from event for access control
name: string;
description?: string;
price: number; // in cents
@@ -38,6 +53,7 @@ export interface TicketType {
}
export interface OrderItem {
eventId?: string; // Added for ticket generation
ticketTypeId: string;
ticketTypeName: string;
price: number; // in cents
@@ -57,10 +73,30 @@ export interface Order {
total: number; // in cents
promoCode?: string;
discount?: number; // in cents
status: 'pending' | 'completed' | 'cancelled' | 'refunded';
status: 'pending' | 'completed' | 'cancelled' | 'refunded' | 'paid' | 'failed_sold_out';
createdAt: string;
updatedAt: string;
paymentIntentId?: string;
// Extended fields for orders management
orgId?: string;
eventName?: string;
purchaserEmail?: string;
totalCents?: number; // Alternative to total for compatibility
tickets?: Ticket[];
dispute?: {
disputeId: string;
status: string;
reason: string;
outcome?: string;
};
refunds?: {
id: string;
amountCents: number;
status: string;
createdAt: string;
reason?: string;
}[];
}
export interface ScanEvent {
@@ -76,15 +112,18 @@ export interface ScanEvent {
export interface Ticket {
id: string;
orderId: string;
eventId: string; // Added for territory filtering
ticketTypeId: string;
ticketTypeName: string;
territoryId: string; // Inherited from event/ticket type for access control
customerEmail: string;
qrCode: string;
status: 'valid' | 'used' | 'cancelled' | 'expired';
status: 'valid' | 'used' | 'cancelled' | 'expired' | 'issued' | 'scanned' | 'refunded' | 'void' | 'locked_dispute';
issuedAt: string;
scannedAt?: string;
seatNumber?: string;
holderName?: string;
price: number; // Added for revenue calculations
}
export interface FeeStructure {
@@ -111,6 +150,34 @@ export interface PromoCode {
minOrderAmount?: number; // in cents
}
export interface Customer {
id: string;
email: string;
firstName: string;
lastName: string;
phone?: string;
dateOfBirth?: string;
address?: {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
};
preferences: {
emailMarketing: boolean;
smsMarketing: boolean;
eventTypes: string[];
};
totalSpent: number; // in cents
orderCount: number;
lastOrderDate?: string;
createdAt: string;
updatedAt: string;
tags: string[];
notes?: string;
}
// Computed/derived types for UI
export interface EventStats {
totalRevenue: number;
@@ -149,12 +216,13 @@ export const MOCK_EVENTS: Event[] = [
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',
image: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjUwMCIgdmlld0JveD0iMCAwIDgwMCA1MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNTAwIiBmaWxsPSIjNjM2NmYxIi8+Cjx0ZXh0IHg9IjQwMCIgeT0iMjUwIiBmb250LXNpemU9IjQ4IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+RXZlbnQgSW1hZ2U8L3RleHQ+Cjwvc3ZnPgo=',
status: 'published',
ticketsSold: 156,
totalCapacity: 200,
revenue: 4680000, // $46,800
organizationId: 'org-1',
organizationId: 'org_001', // Updated to match auth types
territoryId: 'territory_001', // WNW territory
createdAt: '2024-09-01T10:00:00Z',
updatedAt: '2024-10-15T14:30:00Z',
slug: 'autumn-gala-2024',
@@ -167,17 +235,37 @@ export const MOCK_EVENTS: Event[] = [
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',
image: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjUwMCIgdmlld0JveD0iMCAwIDgwMCA1MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNTAwIiBmaWxsPSIjZmYwZjhiIi8+Cjx0ZXh0IHg9IjQwMCIgeT0iMjUwIiBmb250LXNpemU9IjQ4IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+RGFuY2UgU2hvd2Nhc2U8L3RleHQ+Cjwvc3ZnPgo=',
status: 'published',
ticketsSold: 87,
totalCapacity: 120,
revenue: 2175000, // $21,750
organizationId: 'org-1',
organizationId: 'org_001', // Updated to match auth types
territoryId: 'territory_002', // SE territory
createdAt: '2024-09-15T12:00:00Z',
updatedAt: '2024-10-20T09:15:00Z',
slug: 'contemporary-dance-showcase',
isPublic: true,
tags: ['dance', 'contemporary', 'arts']
},
{
id: 'evt-3',
title: 'Holiday Wedding Expo',
description: 'Explore premium wedding venues and services for your special day.',
date: '2024-12-14T14:00:00Z',
venue: 'Convention Center Hall A',
image: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjUwMCIgdmlld0JveD0iMCAwIDgwMCA1MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNTAwIiBmaWxsPSIjMGJiNWE2Ii8+Cjx0ZXh0IHg9IjQwMCIgeT0iMjUwIiBmb250LXNpemU9IjQ4IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+V2VkZGluZyBSZWNlcHRpb248L3RleHQ+Cjwvc3ZnPgo=',
status: 'draft',
ticketsSold: 0,
totalCapacity: 300,
revenue: 0,
organizationId: 'org_001',
territoryId: 'territory_003', // NE territory
createdAt: '2024-10-01T09:00:00Z',
updatedAt: '2024-10-25T16:20:00Z',
slug: 'holiday-wedding-expo-2024',
isPublic: true,
tags: ['wedding', 'expo', 'vendors']
}
];
@@ -185,6 +273,7 @@ export const MOCK_TICKET_TYPES: TicketType[] = [
{
id: 'tt-1',
eventId: 'evt-1',
territoryId: 'territory_001', // Inherited from event
name: 'VIP Patron',
description: 'Premium seating, cocktail reception, and auction preview',
price: 35000, // $350
@@ -202,6 +291,7 @@ export const MOCK_TICKET_TYPES: TicketType[] = [
{
id: 'tt-2',
eventId: 'evt-1',
territoryId: 'territory_001', // Inherited from event
name: 'General Admission',
description: 'Includes dinner and dancing',
price: 15000, // $150
@@ -215,6 +305,42 @@ export const MOCK_TICKET_TYPES: TicketType[] = [
updatedAt: '2024-10-15T14:30:00Z',
maxPerCustomer: 8,
isVisible: true
},
{
id: 'tt-3',
eventId: 'evt-2',
territoryId: 'territory_002', // Inherited from event
name: 'Premium Seating',
description: 'Front row seats with complimentary program',
price: 5000, // $50
quantity: 30,
sold: 25,
status: 'active',
salesStart: '2024-09-15T00:00:00Z',
salesEnd: '2024-12-03T18:00:00Z',
sortOrder: 1,
createdAt: '2024-09-15T12:00:00Z',
updatedAt: '2024-10-20T09:15:00Z',
maxPerCustomer: 6,
isVisible: true
},
{
id: 'tt-4',
eventId: 'evt-2',
territoryId: 'territory_002', // Inherited from event
name: 'Standard Admission',
description: 'General seating for the performance',
price: 2500, // $25
quantity: 90,
sold: 62,
status: 'active',
salesStart: '2024-09-15T00:00:00Z',
salesEnd: '2024-12-03T18:00:00Z',
sortOrder: 2,
createdAt: '2024-09-15T12:00:00Z',
updatedAt: '2024-10-20T09:15:00Z',
maxPerCustomer: 10,
isVisible: true
}
];

44
reactrebuild0825/src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
// Global type declarations for test environment
declare global {
interface Window {
scanPrevention?: {
isEnabled: boolean;
cooldownMs: number;
violations: number;
lastScanTime: number;
isInCooldown: () => boolean;
recordViolation: () => void;
resetViolations: () => void;
};
rateLimitMonitor?: {
isEnabled: boolean;
requestCount: number;
windowMs: number;
maxRequests: number;
resetTime: number;
canMakeRequest: () => boolean;
recordRequest: () => void;
getTimeUntilReset: () => number;
reset: () => void;
};
rateLimitUI?: {
isVisible: boolean;
show: () => void;
hide: () => void;
updateCountdown: (seconds: number) => void;
};
qrErrorHandling?: {
isEnabled: boolean;
errorHistory: Array<{ error: string; timestamp: number }>;
addError: (error: string) => void;
getRecentErrors: () => string[];
clearErrors: () => void;
};
}
}
export {};