firt commit

This commit is contained in:
Kaby_Kun 2025-06-04 15:13:40 +02:00
commit c2e63830e1
71 changed files with 9613 additions and 0 deletions

36
src/App.tsx Executable file
View file

@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Hand } from 'lucide-react';
import Navbar from './components/Navbar';
import HomePage from './pages/HomePage';
import AdminPage from './pages/AdminPage';
import MatchDetailPage from './pages/MatchDetailPage';
import StatusPage from './pages/StatusPage';
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-900 text-white">
<Navbar />
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/match/:id" element={<MatchDetailPage />} />
<Route path="/status" element={<StatusPage />} />
</Routes>
</main>
<footer className="bg-gray-800 py-6 mt-12">
<div className="container mx-auto px-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Hand size={24} className="text-blue-500" />
<span className="font-bold">Handball Tickets</span>
</div>
<p className="text-gray-400">&copy; 2025 All rights reserved</p>
</div>
</footer>
</div>
</BrowserRouter>
);
}
export default App;

View file

0
src/components/FileUpload.tsx Executable file
View file

0
src/components/Footer.tsx Executable file
View file

0
src/components/Logo.tsx Executable file
View file

0
src/components/MatchCard.tsx Executable file
View file

0
src/components/MatchList.tsx Executable file
View file

34
src/components/Navbar.tsx Executable file
View file

@ -0,0 +1,34 @@
import { Link } from 'react-router-dom';
import { Hand } from 'lucide-react';
function Navbar() {
return (
<nav className="bg-gray-800 shadow-lg">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<Link to="/" className="flex items-center gap-2">
<Hand size={32} className="text-blue-500" />
<span className="text-xl font-bold">Handball Tickets</span>
</Link>
<div className="flex items-center gap-6">
<Link
to="/"
className="text-gray-300 hover:text-white transition-colors"
>
Matches
</Link>
<Link
to="/admin"
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition-colors"
>
Admin
</Link>
</div>
</div>
</div>
</nav>
);
}
export default Navbar;

View file

0
src/components/TicketForm.tsx Executable file
View file

View file

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View file

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View file

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "../../lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

0
src/context/AuthContext.tsx Executable file
View file

24
src/index.css Executable file
View file

@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #1a1a1a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

62
src/main.tsx Executable file
View file

@ -0,0 +1,62 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
// Add error boundary
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false,
error: null
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('React error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="text-center p-8">
<h1 className="text-2xl font-bold text-red-500 mb-4">Something went wrong</h1>
<p className="text-gray-400 mb-4">{this.state.error?.message}</p>
<button
onClick={() => window.location.reload()}
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
// Add debugging
console.log('Starting application...');
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);

0
src/pages/AdminMatchForm.tsx Executable file
View file

555
src/pages/AdminPage.tsx Executable file
View file

@ -0,0 +1,555 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Upload, Lock, Trash2, X } from 'lucide-react';
import type { Match } from '../types';
// Add this interface for uploaded files
interface UploadedFile {
file: File;
preview: string;
extractedDirection: string | null;
extractedSeatNumber: number | null;
manualSeatNumber: number | null;
}
function AdminPage() {
const navigate = useNavigate();
const [isAuthenticated, setIsAuthenticated] = useState(() => {
// Check if we're already authenticated from a previous session
return localStorage.getItem('isAdminAuthenticated') === 'true';
});
const [isFirstTime, setIsFirstTime] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [matchData, setMatchData] = useState({
name: '',
date: '',
location: '',
totalSeats: 0,
price: 0,
timeoutDate: '',
});
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [matches, setMatches] = useState<Match[]>([]);
useEffect(() => {
const checkPassword = async () => {
// Only check if we're not already authenticated
if (isAuthenticated) {
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError('');
const response = await fetch('/api/admin/check-password');
if (!response.ok) {
throw new Error('Failed to check password status');
}
const data = await response.json();
setIsFirstTime(data.isFirstTime);
} catch (error) {
console.error('Error checking password:', error);
setError('Failed to check password status. Please try again later.');
} finally {
setIsLoading(false);
}
};
checkPassword();
}, [isAuthenticated]);
useEffect(() => {
const fetchMatches = async () => {
if (!isAuthenticated) return;
try {
const response = await fetch('/api/matches');
if (!response.ok) {
throw new Error('Failed to fetch matches');
}
const data = await response.json();
setMatches(data);
} catch (error) {
console.error('Error fetching matches:', error);
}
};
fetchMatches();
}, [isAuthenticated]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (isFirstTime) {
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
try {
const response = await fetch('/api/admin/set-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: newPassword }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to set password');
}
setIsAuthenticated(true);
localStorage.setItem('isAdminAuthenticated', 'true');
setNewPassword('');
setConfirmPassword('');
} catch (error: any) {
setError(error.message);
}
} else {
try {
const response = await fetch('/api/admin/verify-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Invalid password');
}
setIsAuthenticated(true);
localStorage.setItem('isAdminAuthenticated', 'true');
setPassword('');
} catch (error: any) {
setError(error.message);
}
}
};
const handleLogout = () => {
setIsAuthenticated(false);
localStorage.removeItem('isAdminAuthenticated');
};
const handleRemoveFile = (index: number) => {
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const newFiles: UploadedFile[] = await Promise.all(files.map(async file => {
// Perform filename parsing on the frontend
const extractedInfo = await extractSeatInfoFromFilenameFrontend(file.name);
return {
file,
preview: URL.createObjectURL(file),
extractedDirection: extractedInfo.direction,
extractedSeatNumber: extractedInfo.extractedSeatNumber,
manualSeatNumber: extractedInfo.extractedSeatNumber, // Initialize manual with extracted
};
}));
setUploadedFiles(prev => [...prev, ...newFiles]);
};
// Helper function for frontend filename parsing (matches backend logic)
const extractSeatInfoFromFilenameFrontend = async (filename: string) => {
// Look for 3 to 5 uppercase letters, followed by an underscore, then numbers, ending with .pdf
const regex = /^([A-Z]{3,5})_(\d+)\.pdf$/;
const match = filename.match(regex);
if (match && match[1] && match[2]) {
return {
direction: match[1],
extractedSeatNumber: parseInt(match[2], 10)
};
} else {
return { direction: null, extractedSeatNumber: null };
}
};
const handleManualSeatNumberChange = (index: number, value: string) => {
const numberValue = parseInt(value, 10);
setUploadedFiles(prev =>
prev.map((item, i) =>
i === index ? { ...item, manualSeatNumber: isNaN(numberValue) ? null : numberValue } : item
)
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isAuthenticated) return;
const formData = new FormData();
formData.append('name', matchData.name);
formData.append('date', matchData.date);
formData.append('location', matchData.location);
formData.append('totalSeats', matchData.totalSeats.toString());
formData.append('price', matchData.price.toString());
formData.append('timeoutDate', matchData.timeoutDate);
// Append only the files that haven't been removed
// Also send the manual seat number along with the file
uploadedFiles.forEach(({ file, manualSeatNumber, extractedDirection, extractedSeatNumber }) => {
formData.append('pdfFiles', file);
// Append the relevant seat info for this file
formData.append('seatInfo', JSON.stringify({
originalFilename: file.name,
direction: extractedDirection,
extractedSeatNumber: extractedSeatNumber,
manualSeatNumber: manualSeatNumber,
}));
});
try {
const response = await fetch('/api/admin/matches', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
},
body: formData
});
if (!response.ok) {
throw new Error('Failed to add match');
}
// Clear the form and uploaded files
setMatchData({
name: '',
date: '',
location: '',
totalSeats: 0,
price: 0,
timeoutDate: ''
});
setUploadedFiles([]);
// Refresh matches list
fetchMatches();
} catch (error) {
console.error('Error adding match:', error);
}
};
// Define fetchMatches here so it can be called from handleSubmit and useEffect
const fetchMatches = async () => {
if (!isAuthenticated) return;
try {
const response = await fetch('/api/matches');
if (!response.ok) {
throw new Error('Failed to fetch matches');
}
const data = await response.json();
setMatches(data);
} catch (error) {
console.error('Error fetching matches:', error);
}
};
// Function to handle match deletion
const handleDeleteMatch = async (matchId: number) => {
if (window.confirm('Are you sure you want to delete this match? This action cannot be undone.')) {
try {
const response = await fetch(`/api/admin/matches/${matchId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to delete match');
}
alert('Match deleted successfully!');
// Refetch matches after deletion to update the list
fetchMatches();
} catch (error: any) {
console.error('Error deleting match:', error);
alert(`Failed to delete match: ${error.message}`);
}
}
};
// Cleanup file previews
useEffect(() => {
return () => {
uploadedFiles.forEach(({ preview }) => {
URL.revokeObjectURL(preview);
});
};
}, [uploadedFiles]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="max-w-md mx-auto">
<div className="bg-gray-800 p-8 rounded-lg shadow-lg">
<div className="flex items-center justify-center mb-6">
<Lock className="w-12 h-12 text-blue-500" />
</div>
<h2 className="text-2xl font-bold mb-6 text-center">
{isFirstTime ? 'Set Admin Password' : 'Admin Login'}
</h2>
<form onSubmit={handleLogin} className="space-y-4">
{isFirstTime ? (
<>
<div>
<label className="block mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2"
required
minLength={8}
/>
</div>
<div>
<label className="block mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2"
required
minLength={8}
/>
</div>
</>
) : (
<div>
<label className="block mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2"
required
/>
</div>
)}
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
{isFirstTime ? 'Set Password' : 'Login'}
</button>
</form>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<button
onClick={handleLogout}
className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded"
>
Logout
</button>
</div>
<div className="grid gap-8">
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-bold mb-6">Create New Match</h2>
{error && (
<div className="bg-red-500 text-white p-4 rounded-lg mb-4">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block mb-2">Match Name</label>
<input
type="text"
value={matchData.name}
onChange={(e) => setMatchData({ ...matchData, name: e.target.value })}
className="w-full bg-gray-700 rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block mb-2">Date & Time</label>
<input
type="datetime-local"
value={matchData.date}
onChange={(e) => setMatchData({ ...matchData, date: e.target.value })}
className="w-full bg-gray-700 rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block mb-2">Location</label>
<input
type="text"
value={matchData.location}
onChange={(e) => setMatchData({ ...matchData, location: e.target.value })}
className="w-full bg-gray-700 rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block mb-2">Total Seats</label>
<input
type="number"
value={matchData.totalSeats}
onChange={(e) => setMatchData({ ...matchData, totalSeats: parseInt(e.target.value) || 0 })}
className="w-full bg-gray-700 rounded px-3 py-2"
required
min="1"
/>
</div>
<div>
<label className="block mb-2">Price ()</label>
<input
type="number"
value={matchData.price}
onChange={(e) => setMatchData({ ...matchData, price: parseFloat(e.target.value) || 0 })}
className="w-full bg-gray-700 rounded px-3 py-2"
min="0"
step="0.01"
/>
</div>
<div>
<label className="block mb-2">Booking Deadline</label>
<input
type="datetime-local"
value={matchData.timeoutDate}
onChange={(e) => setMatchData({ ...matchData, timeoutDate: e.target.value })}
className="w-full bg-gray-700 rounded px-3 py-2"
required
/>
</div>
</div>
<div className="mt-6">
<label className="block mb-2">PDF Files</label>
<input
type="file"
accept=".pdf"
multiple
onChange={handleFileChange}
className="w-full bg-gray-700 rounded px-3 py-2"
/>
{uploadedFiles.length > 0 && (
<div className="mt-2 space-y-2">
{uploadedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-gray-700 p-2 rounded">
<div className="flex-1 truncate mr-4">
<span className="text-sm block">{file.file.name}</span>
{file.extractedDirection && file.extractedSeatNumber && (
<span className="text-xs text-gray-400">Extracted: {file.extractedDirection} {file.extractedSeatNumber}</span>
)}
</div>
<div className="flex items-center">
<label className="text-sm mr-2">Seat No:</label>
<input
type="number"
value={file.manualSeatNumber !== null ? file.manualSeatNumber : ''}
onChange={(e) => handleManualSeatNumberChange(index, e.target.value)}
className="w-20 bg-gray-600 rounded px-2 py-1 text-sm"
min="1"
/>
</div>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="text-red-500 hover:text-red-400 ml-4"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className={`mt-8 w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg flex items-center justify-center gap-2 ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
Creating Match...
</>
) : (
<>
<Plus className="w-5 h-5" />
Create Match
</>
)}
</button>
</form>
</div>
{/* Existing Matches List */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-4">Existing Matches</h2>
<div className="bg-gray-800 rounded-lg shadow-lg p-6">
{matches.length === 0 ? (
<p>No matches found.</p>
) : (
<ul className="space-y-4">
{matches.map((match) => (
<li key={match.id} className="border-b border-gray-700 pb-4 last:border-b-0 flex justify-between items-center">
<div>
<h3 className="text-xl font-semibold">{match.name}</h3>
<p className="text-sm text-gray-400">{new Date(match.date).toLocaleString()}</p>
<p className="text-sm text-gray-400">{match.location}</p>
</div>
<button
onClick={() => handleDeleteMatch(match.id)}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Remove
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
export default AdminPage;

120
src/pages/HomePage.tsx Executable file
View file

@ -0,0 +1,120 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Calendar, MapPin, Users, Loader2, Mail } from 'lucide-react';
import { api, type Match } from '../services/api';
function HomePage() {
const [matches, setMatches] = useState<Match[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadMatches = async () => {
try {
setIsLoading(true);
setError(null);
const data = await api.matches.getAll();
setMatches(data);
} catch (error) {
console.error('Error fetching matches:', error);
setError('Failed to load matches. Please try again later.');
} finally {
setIsLoading(false);
}
};
loadMatches();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-red-500 mb-4">Error</h2>
<p className="text-gray-400">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg"
>
Try Again
</button>
</div>
);
}
if (matches.length === 0) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-bold mb-4">No Matches Available</h2>
<p className="text-gray-400 mb-6">There are no upcoming matches at the moment.</p>
</div>
);
}
return (
<div>
<h1 className="text-4xl font-bold mb-8 text-center">Upcoming Handball Matches</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{matches.map((match) => (
<Link
key={match.id}
to={`/match/${match.id}`}
className="bg-gray-800 rounded-lg overflow-hidden hover:bg-gray-700 transition-colors"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-4">{match.name}</h2>
<div className="space-y-3 text-gray-300">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
<span>{new Date(match.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
<span>{match.location}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<span>{match.availableSeats} seats available</span>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
{match.price > 0 && <span className="text-2xl font-bold">{match.price}</span>}
<button className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg">
Book Now
</button>
</div>
</div>
</Link>
))}
</div>
<footer className="mt-12 text-center text-gray-400">
<div className="flex items-center justify-center gap-2">
<Mail className="w-5 h-5" />
<a href="mailto:alexandreyagoubi@gmail.com" className="hover:text-gray-300">
alexandreyagoubi@gmail.com
</a>
</div>
</footer>
</div>
);
}
export default HomePage;

242
src/pages/MatchDetailPage.tsx Executable file
View file

@ -0,0 +1,242 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Mail, Download } from 'lucide-react';
import { Match, Seat } from '../types';
export default function MatchDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [match, setMatch] = useState<Match | null>(null);
const [seats, setSeats] = useState<Seat[]>([]);
const [selectedSeats, setSelectedSeats] = useState<number[]>([]);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
deliveryMethod: 'download' as 'download' | 'email'
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchMatch = async () => {
try {
const response = await fetch(`/api/matches/${id}`);
if (!response.ok) throw new Error('Failed to fetch match');
const data = await response.json();
setMatch(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch match');
} finally {
setLoading(false);
}
};
const fetchSeats = async () => {
try {
const response = await fetch(`/api/tickets/match/${id}/seats`);
if (!response.ok) throw new Error('Failed to fetch seats');
const data = await response.json();
setSeats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch seats');
}
};
fetchMatch();
fetchSeats();
}, [id]);
const handleSeatClick = (seatNumber: number) => {
setSelectedSeats(prev => {
if (prev.includes(seatNumber)) {
return prev.filter(s => s !== seatNumber);
}
return [...prev, seatNumber];
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!match) return;
try {
const response = await fetch('/api/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
matchId: match.id,
...formData,
selectedSeats
}),
});
if (!response.ok) {
throw new Error('Failed to create ticket');
}
const data = await response.json();
if (formData.deliveryMethod === 'download') {
// Create a temporary link element to trigger the download
const link = document.createElement('a');
link.href = data.pdfUrl;
link.download = `ticket-${match.name}-${selectedSeats.join('-')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create ticket');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!match) return <div>Match not found</div>;
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">{match.name}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div className="bg-gray-800 rounded-lg shadow-md p-6 mb-6 text-white">
<h2 className="text-xl font-semibold mb-4">Match Details</h2>
<p><strong>Date:</strong> {new Date(match.date).toLocaleString()}</p>
<p><strong>Location:</strong> {match.location}</p>
<p><strong>Available Seats:</strong> {match.availableSeats}</p>
{match.price > 0 && <p><strong>Price:</strong> ${match.price}</p>}
{match.timeoutDate && (
<p><strong>Booking Deadline:</strong> {new Date(match.timeoutDate).toLocaleString()}</p>
)}
</div>
<div className="bg-gray-800 rounded-lg shadow-md p-6 text-white">
<h2 className="text-xl font-semibold mb-4">Select Seats</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6">
{seats.map((seat) => (
<button
key={seat.seatNumber}
onClick={() => handleSeatClick(seat.seatNumber)}
disabled={seat.status !== 'available'}
className={`p-3 rounded-md text-sm font-semibold transition-colors duration-200 flex items-center justify-center text-center w-full h-[60px] ${
seat.status === 'available'
? selectedSeats.includes(seat.seatNumber)
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-700 hover:bg-gray-600 text-gray-200'
: 'bg-red-800 cursor-not-allowed text-red-200 opacity-70'
}`}
title={seat.bookedBy ? `Booked by ${seat.bookedBy}` : undefined}
>
{seat.direction ? (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-300">{seat.direction}</span>
<span className="text-base">{seat.seatNumber}</span>
</div>
) : (
<span>{`Seat ${seat.seatNumber}`}</span>
)}
</button>
))}
</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg shadow-md p-6 text-white">
<h2 className="text-xl font-semibold mb-4">Book Tickets</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300">Name</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="mt-1 block w-full rounded-md border-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 bg-gray-900 text-white"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300">Email</label>
<input
type="email"
id="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
className="mt-1 block w-full rounded-md border-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 bg-gray-900 text-white"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-300">Phone</label>
<input
type="tel"
id="phone"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
required
className="mt-1 block w-full rounded-md border-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 bg-gray-900 text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">Delivery Method</label>
<div className="mt-2 space-y-2">
<div className="flex items-center">
<input
type="radio"
id="download"
name="deliveryMethod"
value="download"
checked={formData.deliveryMethod === 'download'}
onChange={(e) => setFormData({ ...formData, deliveryMethod: e.target.value as 'download' | 'email' })}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-700 bg-gray-900"
/>
<label htmlFor="download" className="ml-2 block text-sm text-gray-300 flex items-center gap-2">
<Download className="w-4 h-4 text-gray-300" />
Download
</label>
</div>
<div className="flex items-center">
<input
type="radio"
id="email"
name="deliveryMethod"
value="email"
checked={formData.deliveryMethod === 'email'}
onChange={(e) => setFormData({ ...formData, deliveryMethod: e.target.value as 'download' | 'email' })}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-700 bg-gray-900"
/>
<label htmlFor="email" className="ml-2 block text-sm text-gray-300 flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-300" />
Email
</label>
</div>
</div>
</div>
<button
type="submit"
disabled={selectedSeats.length === 0}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-md transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Book {selectedSeats.length} Seat{selectedSeats.length !== 1 ? 's' : ''}
{match.price > 0 && ` - $${(match.price * selectedSeats.length).toFixed(2)}`}
</button>
</form>
</div>
</div>
<footer className="mt-8 text-center text-gray-600">
<p>Contact: <a href="mailto:alexandreyagoubi@gmail.com" className="text-blue-500 hover:underline">alexandreyagoubi@gmail.com</a></p>
</footer>
</div>
);
}

55
src/pages/StatusPage.tsx Executable file
View file

@ -0,0 +1,55 @@
import { useState, useEffect } from 'react';
import type { Ticket } from '../types';
function StatusPage() {
const [tickets, setTickets] = useState<Ticket[]>([]);
useEffect(() => {
const loadTickets = async () => {
// TODO: Replace with API call to fetch tickets by match
setTickets([]);
};
loadTickets();
}, []);
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Booking Status</h1>
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-700">
<th className="px-6 py-3 text-left">Name</th>
<th className="px-6 py-3 text-left">Email</th>
<th className="px-6 py-3 text-left">Phone</th>
<th className="px-6 py-3 text-left">Seat</th>
<th className="px-6 py-3 text-left">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{tickets.map((ticket) => (
<tr key={ticket.id}>
<td className="px-6 py-4">{ticket.customerName}</td>
<td className="px-6 py-4">{ticket.customerEmail}</td>
<td className="px-6 py-4">{ticket.customerPhone}</td>
<td className="px-6 py-4">{ticket.seatNumber}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded ${
ticket.status === 'confirmed' ? 'bg-green-600' :
ticket.status === 'pending' ? 'bg-yellow-600' :
'bg-red-600'
}`}>
{ticket.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default StatusPage;

69
src/services/api.ts Executable file
View file

@ -0,0 +1,69 @@
const API_BASE_URL = '/api';
export interface Match {
id: string;
name: string;
date: string;
location: string;
totalSeats: number;
availableSeats: number;
price: number;
pdfFiles: string[];
timeoutMinutes: number;
timeoutDate: string;
}
export interface Ticket {
id: string;
matchId: string;
seatNumber: number;
customerName: string;
customerEmail: string;
customerPhone: string;
status: 'pending' | 'confirmed' | 'expired';
createdAt: string;
}
export interface BookingFormData {
name: string;
email: string;
phone: string;
seatNumber: number;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || 'An error occurred');
}
return response.json();
}
export const api = {
matches: {
getAll: async (): Promise<Match[]> => {
const response = await fetch(`${API_BASE_URL}/matches`);
return handleResponse<Match[]>(response);
},
getById: async (id: string): Promise<Match> => {
const response = await fetch(`${API_BASE_URL}/matches/${id}`);
return handleResponse<Match>(response);
}
},
tickets: {
create: async (matchId: string, data: BookingFormData): Promise<Ticket> => {
const response = await fetch(`${API_BASE_URL}/tickets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ matchId, ...data }),
});
return handleResponse<Ticket>(response);
},
getStatus: async (id: string): Promise<Ticket> => {
const response = await fetch(`${API_BASE_URL}/tickets/${id}/status`);
return handleResponse<Ticket>(response);
}
}
};

0
src/services/database.ts Executable file
View file

53
src/types/index.ts Executable file
View file

@ -0,0 +1,53 @@
export interface Match {
id: number;
name: string;
date: string;
location: string;
totalSeats: number;
availableSeats: number;
price: number;
timeoutDate: string;
createdAt: string;
pdfFiles?: string[];
}
export interface Seat {
id: number;
matchId: number;
seatNumber: number;
status: 'available' | 'reserved' | 'booked';
ticketId: number | null;
direction: string | null;
extractedSeatNumber: number | null;
uploadedPdfPath: string | null;
bookedBy?: string;
bookedByEmail?: string;
createdAt: string;
}
export interface Ticket {
id: number;
matchId: number;
name: string;
email: string;
phone: string;
seats: number;
status: 'pending' | 'confirmed' | 'cancelled';
pdfFile: string | null;
deliveryMethod: 'download' | 'email';
createdAt: string;
}
export interface BookingFormData {
name: string;
email: string;
phone: string;
seats: number;
deliveryMethod: 'download' | 'email';
}
export interface AdminSettings {
id: string;
password: string;
lastUpdated: Date;
}

0
src/utils/formatters.ts Executable file
View file

0
src/utils/validation.ts Executable file
View file

1
src/vite-env.d.ts vendored Executable file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />