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

28
eslint.config.js Executable file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

15
index.html Executable file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/handball.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
<title>Handball Tickets</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4961
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

45
package.json Executable file
View file

@ -0,0 +1,45 @@
{
"name": "handball",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
"dev:frontend": "vite",
"dev:backend": "node server/index.js",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"postinstall": "cd server && npm install"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-slot": "^1.2.3",
"@types/node": "^20.11.24",
"autoprefixer": "^10.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.513.0",
"postcss": "^8.4.35",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.8",
"react-router-dom": "^6.22.2",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2"
},
"devDependencies": {
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"vite": "^5.1.4"
}
}

6
postcss.config.js Executable file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
public/favicon.ico Executable file
View file

5
public/handball.svg Normal file
View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#2563eb" stroke="#ffffff" stroke-width="2"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,293 @@
import { query } from '../utils/database.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Helper to calculate timeout date
const calculateTimeoutDate = (matchDate, timeoutMinutes) => {
const date = new Date(matchDate);
date.setMinutes(date.getMinutes() + timeoutMinutes);
return date;
};
// Helper to convert BigInt to Number
const convertBigIntToNumber = (obj) => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'bigint') return Number(obj);
if (Array.isArray(obj)) return obj.map(convertBigIntToNumber);
if (typeof obj === 'object') {
const result = {};
for (const key in obj) {
result[key] = convertBigIntToNumber(obj[key]);
}
return result;
}
return obj;
};
// Helper to extract seat info from filename
const extractSeatInfoFromFilename = (filename) => {
// Look for 3 to 5 uppercase letters, followed by an underscore, then numbers
const regex = /^([A-Z]{3,5})_(\d+)\.pdf$/; // Updated regex to match DIRECTION_NUMBER.pdf format
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 };
}
};
export const addMatch = async (req, res) => {
try {
const { name, date, location, totalSeats, price, timeoutDate } = req.body;
const uploadedFiles = req.files; // Access uploaded files via multer
// Parse the seatInfo array sent as a JSON string(s) from the frontend
const seatInfoArray = req.body.seatInfo ? (Array.isArray(req.body.seatInfo) ? req.body.seatInfo.map(info => JSON.parse(info)) : [JSON.parse(req.body.seatInfo)]) : [];
console.log('Uploaded files:', uploadedFiles); // Log uploaded files
console.log('Received seatInfoArray:', seatInfoArray); // Log received seat info array
// Basic validation
if (!name || !date || !location || !totalSeats || !timeoutDate) {
return res.status(400).json({ message: 'Missing required fields' });
}
// Ensure totalSeats is a number
const parsedTotalSeats = parseInt(totalSeats, 10);
const parsedPrice = parseFloat(price || 0);
if (isNaN(parsedTotalSeats) || parsedTotalSeats < 0 || isNaN(parsedPrice) || parsedPrice < 0) {
return res.status(400).json({ message: 'Invalid number format for seats or price' });
}
// Optional: Validate number of uploaded files vs totalSeats
if (uploadedFiles && uploadedFiles.length > 0 && uploadedFiles.length !== parsedTotalSeats) {
console.warn(`Number of uploaded files (${uploadedFiles.length}) does not match total seats (${parsedTotalSeats}). Seat info may not be fully populated.`);
}
// Start a transaction
await query('START TRANSACTION');
try {
// Insert match
const matchResult = await query(
`INSERT INTO matches (
name, date, location, totalSeats, availableSeats,
price, timeoutDate
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
name,
date,
location,
parsedTotalSeats,
parsedTotalSeats,
parsedPrice,
timeoutDate
]
);
const matchId = matchResult.insertId;
// Create seats for the match and store extracted/manual info
const seatValues = Array.from({ length: parsedTotalSeats }, (_, i) => {
// Ensure we process files up to the number of uploaded files or totalSeats, whichever is smaller
if (i >= uploadedFiles.length && i >= seatInfoArray.length) {
return null; // No file or seat info for this seat index
}
const seatNumberDefault = i + 1; // Sequential seat number as default fallback
const file = uploadedFiles && uploadedFiles[i]; // Corresponding uploaded file
const frontendSeatInfo = seatInfoArray[i]; // Get seat info by index (assuming order matches files)
console.log(`Processing seat ${i + 1}: File`, file ? file.originalname : 'No file', 'Frontend Info:', frontendSeatInfo); // Log processing details
let seatNumberToSave = seatNumberDefault; // Use a new variable name for the final seat number
let uploadedPdfPath = null;
let sourceOfDirection = null; // Keep track of where direction came from
// Determine seatDirection and seatNumberToSave based on available data, prioritizing frontend
const determinedSeatInfo = (() => {
if (frontendSeatInfo) {
return {
direction: frontendSeatInfo.direction,
seatNumber: frontendSeatInfo.manualSeatNumber !== null ? frontendSeatInfo.manualSeatNumber : (frontendSeatInfo.extractedSeatNumber !== null ? frontendSeatInfo.extractedSeatNumber : seatNumberDefault),
source: 'frontend'
};
} else if (file) {
// Fallback to backend extraction if no frontend info
const extractedInfo = extractSeatInfoFromFilename(file.originalname);
return {
direction: extractedInfo.direction,
seatNumber: extractedInfo.extractedSeatNumber !== null ? extractedInfo.extractedSeatNumber : seatNumberDefault,
source: 'backend_extraction'
};
} else {
// Default if no frontend info and no file
return {
direction: null,
seatNumber: seatNumberDefault,
source: 'default'
};
}
})();
const seatDirection = determinedSeatInfo.direction; // Assign determined direction
seatNumberToSave = determinedSeatInfo.seatNumber; // Assign determined seat number
sourceOfDirection = determinedSeatInfo.source; // Assign source
if (file) {
// Construct the path assuming multer saves to /public/uploads
uploadedPdfPath = `/uploads/${file.filename}`; // Use file.filename provided by multer
}
// Log values after PDF path assignment, before returning for insertion
console.log(`Seat ${i + 1} - Values after PDF path assignment: Direction = ${seatDirection}, Seat Number = ${seatNumberToSave}, PDF Path = ${uploadedPdfPath}, Source = ${sourceOfDirection}`);
// Log values right before returning for insertion
console.log(`Seat ${i + 1} - Values before return for insertion: Direction = ${seatDirection}, Seat Number = ${seatNumberToSave}, PDF Path = ${uploadedPdfPath}, Source = ${sourceOfDirection}`);
// Return the values in the order expected by the INSERT query
return [matchId, seatNumberToSave, seatDirection, uploadedPdfPath];
}).filter(value => value !== null); // Filter out null values if totalSeats was higher than uploaded files/seatInfo
const seatPlaceholders = seatValues.map(() => '(?, ?, ?, ?)').join(', ');
if (seatValues.length > 0) {
await query(
`INSERT INTO seats (matchId, seatNumber, direction, uploadedPdfPath) VALUES ${seatPlaceholders}`,
seatValues.flat()
);
} else if (parsedTotalSeats > 0 && (uploadedFiles.length === 0 || seatInfoArray.length === 0)) {
// If totalSeats > 0 but no files uploaded or seatInfo processed, create seats with default info
const defaultSeatValues = Array.from({ length: parsedTotalSeats }, (_, i) => [matchId, i + 1, null, null]);
const defaultPlaceholders = defaultSeatValues.map(() => '(?, ?, ?, ?)').join(', ');
await query(
`INSERT INTO seats (matchId, seatNumber, direction, uploadedPdfPath) VALUES ${defaultPlaceholders}`,
defaultSeatValues.flat()
);
}
// Commit transaction
await query('COMMIT');
// Convert BigInt to Number before sending response
const response = convertBigIntToNumber({
message: 'Match added successfully',
matchId: matchId
});
res.status(201).json(response);
} catch (error) {
// Rollback transaction on error
await query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error adding match:', error);
res.status(500).json({
message: 'Failed to add match',
error: error.message
});
}
};
export const getMatchSeats = async (req, res) => {
try {
const { matchId } = req.params;
const seats = await query(`
SELECT s.*, t.name as bookedBy, t.email as bookedByEmail
FROM seats s
LEFT JOIN tickets t ON s.ticketId = t.id
WHERE s.matchId = ?
ORDER BY s.seatNumber
`, [matchId]);
res.status(200).json(convertBigIntToNumber(seats));
} catch (error) {
console.error('Error fetching seats:', error);
res.status(500).json({
message: 'Failed to fetch seats',
error: error.message
});
}
};
export const deleteMatch = async (req, res) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ message: 'Match ID is required' });
}
// Start a transaction
await query('START TRANSACTION');
try {
// Get file paths of associated PDFs before deleting records
const seatPdfs = await query('SELECT uploadedPdfPath FROM seats WHERE matchId = ? AND uploadedPdfPath IS NOT NULL', [id]);
const ticketPdfs = await query('SELECT pdfFile FROM tickets WHERE matchId = ? AND pdfFile IS NOT NULL', [id]);
// Delete associated seats
await query('DELETE FROM seats WHERE matchId = ?', [id]);
// Delete associated tickets
await query('DELETE FROM tickets WHERE matchId = ?', [id]);
// Delete the match
const result = await query('DELETE FROM matches WHERE id = ?', [id]);
// Commit transaction
await query('COMMIT');
// Delete physical PDF files after successful database deletion
const filesToDelete = [
...seatPdfs.map(row => row.uploadedPdfPath),
...ticketPdfs.map(row => row.pdfFile)
];
const publicDir = path.join(__dirname, '..', '..' , 'public');
for (const filePath of filesToDelete) {
if (filePath) {
const absolutePath = path.join(publicDir, filePath);
fs.unlink(absolutePath, (err) => {
if (err) {
console.error(`Error deleting file ${absolutePath}:`, err);
} else {
console.log(`Deleted file: ${absolutePath}`);
}
});
}
}
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Match not found' });
}
res.status(200).json({ message: 'Match deleted successfully' });
} catch (error) {
// Rollback transaction on error
await query('ROLLBACK');
console.error('Error deleting match:', error);
res.status(500).json({
message: 'Failed to delete match',
error: error.message
});
}
} catch (error) {
console.error('Error processing delete request:', error);
res.status(500).json({ message: 'Failed to delete match', error: error.message });
}
};
// Add other admin controller functions here later (e.g., for status page)

View file

@ -0,0 +1,63 @@
import { query } from '../utils/database.js';
import bcrypt from 'bcryptjs';
export const checkPassword = async (req, res) => {
try {
const [settings] = await query('SELECT id FROM admin_settings WHERE setting_key = ?', ['admin_password']);
res.json({ isFirstTime: !settings });
} catch (error) {
console.error('Error checking password:', error);
res.status(500).json({ message: 'Failed to check password status' });
}
};
export const setPassword = async (req, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ message: 'Password is required' });
}
const hashedPassword = await bcrypt.hash(password, 10);
// Check if a password already exists
const [existingSettings] = await query('SELECT id FROM admin_settings WHERE setting_key = ?', ['admin_password']);
if (existingSettings) {
// Update existing password
await query('UPDATE admin_settings SET setting_value = ? WHERE setting_key = ?', [hashedPassword, 'admin_password']);
} else {
// Insert new password
await query('INSERT INTO admin_settings (setting_key, setting_value) VALUES (?, ?)', ['admin_password', hashedPassword]);
}
res.json({ message: 'Password set successfully' });
} catch (error) {
console.error('Error setting password:', error);
res.status(500).json({ message: 'Failed to set password' });
}
};
export const verifyPassword = async (req, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ message: 'Password is required' });
}
const [settings] = await query('SELECT setting_value FROM admin_settings WHERE setting_key = ?', ['admin_password']);
if (!settings) {
return res.status(404).json({ message: 'No password set' });
}
const isValid = await bcrypt.compare(password, settings.setting_value);
if (!isValid) {
return res.status(401).json({ message: 'Invalid password' });
}
res.json({ message: 'Password verified successfully' });
} catch (error) {
console.error('Error verifying password:', error);
res.status(500).json({ message: 'Failed to verify password' });
}
};

View file

@ -0,0 +1,30 @@
import { query } from '../utils/database.js';
export const getMatches = async (req, res) => {
try {
// Fetch all matches, ordered by date
const matches = await query('SELECT id, name, date, location, totalSeats, availableSeats, price, timeoutDate FROM matches ORDER BY date');
res.status(200).json(matches);
} catch (error) {
console.error('Error fetching matches:', error);
res.status(500).json({ message: 'Failed to fetch matches', error: error.message });
}
};
export const getMatchDetails = async (req, res) => {
try {
const { id } = req.params;
// Fetch details for a specific match
const match = await query('SELECT id, name, date, location, totalSeats, availableSeats, price, timeoutDate FROM matches WHERE id = ?', [id]);
if (match.length === 0) {
return res.status(404).json({ message: 'Match not found' });
}
res.status(200).json(match[0]);
} catch (error) {
console.error('Error fetching match details:', error);
res.status(500).json({ message: 'Failed to fetch match details', error: error.message });
}
};

View file

@ -0,0 +1,156 @@
import { query } from '../utils/database.js';
import { sendEmail } from '../utils/email.js';
import { generateTicketPDF } from '../utils/pdf.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const createTicket = async (req, res) => {
try {
const { matchId, name, email, phone, selectedSeats, deliveryMethod } = req.body;
// Start transaction
await query('START TRANSACTION');
try {
// Check if seats are available
const seats = await query(
'SELECT * FROM seats WHERE matchId = ? AND seatNumber IN (?) AND status = "available"',
[matchId, selectedSeats]
);
if (seats.length !== selectedSeats.length) {
throw new Error('Some selected seats are not available');
}
// Create ticket
const ticketResult = await query(
`INSERT INTO tickets (
matchId, name, email, phone, seats, deliveryMethod
) VALUES (?, ?, ?, ?, ?, ?)`,
[matchId, name, email, phone, selectedSeats.length, deliveryMethod]
);
const ticketId = ticketResult.insertId;
// Update seats status
await query(
'UPDATE seats SET status = "booked", ticketId = ? WHERE matchId = ? AND seatNumber IN (?)',
[ticketId, matchId, selectedSeats]
);
// Update match available seats
await query(
'UPDATE matches SET availableSeats = availableSeats - ? WHERE id = ?',
[selectedSeats.length, matchId]
);
// Generate PDF
const pdfFileName = `${Date.now()}-${name}-${selectedSeats.length}.pdf`;
const pdfPath = path.join(__dirname, '..', '..', 'public', 'uploads', pdfFileName);
const match = await query('SELECT * FROM matches WHERE id = ?', [matchId]);
await generateTicketPDF({
ticketId,
match: match[0],
name,
email,
phone,
seats: seats,
pdfFile: pdfFileName
});
// Update ticket with PDF path
await query(
'UPDATE tickets SET pdfFile = ? WHERE id = ?',
[`/uploads/${pdfFileName}`, ticketId]
);
// Send email if requested
if (deliveryMethod === 'email') {
await sendEmail({
to: email,
subject: `Your tickets for ${match[0].name}`,
text: `Thank you for booking tickets for ${match[0].name}. Your seat numbers are: ${selectedSeats.join(', ')}`,
attachments: [{
filename: pdfFileName,
path: pdfPath
}]
});
}
// Commit transaction
await query('COMMIT');
res.status(201).json({
message: 'Ticket created successfully',
ticketId,
pdfUrl: `/uploads/${pdfFileName}`
});
} catch (error) {
// Rollback transaction on error
await query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error creating ticket:', error);
res.status(500).json({
message: 'Failed to create ticket',
error: error.message
});
}
};
export const getMatchSeats = async (req, res) => {
try {
const { matchId } = req.params;
const seats = await query(`
SELECT s.*, t.name as bookedBy, t.email as bookedByEmail
FROM seats s
LEFT JOIN tickets t ON s.ticketId = t.id
WHERE s.matchId = ?
ORDER BY s.seatNumber
`, [matchId]);
res.status(200).json(seats);
} catch (error) {
console.error('Error fetching seats:', error);
res.status(500).json({
message: 'Failed to fetch seats',
error: error.message
});
}
};
export const getTicketStatus = async (req, res) => {
try {
const ticketId = req.params.ticketId || req;
// If called as a controller (req, res), req.params.ticketId is used
// If called as a service (just ticketId), req is the id
const id = typeof ticketId === 'object' ? ticketId.ticketId : ticketId;
const result = await query('SELECT * FROM tickets WHERE id = ?', [id]);
if (!result || result.length === 0) {
if (res) {
return res.status(404).json({ message: 'Ticket not found' });
} else {
return null;
}
}
if (res) {
res.json(result[0]);
} else {
return result[0];
}
} catch (error) {
console.error('Error getting ticket status:', error);
if (res) {
res.status(500).json({ message: 'Failed to get ticket status', error: error.message });
} else {
throw error;
}
}
};

56
server/index.js Executable file
View file

@ -0,0 +1,56 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import initDatabase from './utils/initDatabase.js';
import matchesRouter from './routes/matches.js';
import ticketsRouter from './routes/tickets.js';
import adminRouter from './routes/admin.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = process.env.PORT || 3000;
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(__dirname, '..', 'public', 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Serve static files from the uploads directory
app.use('/uploads', express.static(path.join(__dirname, '..', 'public', 'uploads')));
// Routes
app.use('/api/matches', matchesRouter);
app.use('/api/tickets', ticketsRouter);
app.use('/api/admin', adminRouter);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({
message: 'Something broke!',
error: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
});
// Initialize database and start server
initDatabase()
.then(() => {
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
})
.catch((error) => {
console.error('Failed to initialize database:', error);
process.exit(1);
});

0
server/middleware/auth.js Executable file
View file

View file

0
server/models/booking.js Executable file
View file

0
server/models/match.js Executable file
View file

0
server/models/ticket.js Executable file
View file

1798
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
server/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "handball-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"reset-db": "node utils/initDatabase.js --reset"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"mariadb": "^3.2.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.10.1",
"pdfkit": "^0.17.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.11",
"@types/nodemailer": "^6.4.14",
"nodemon": "^3.1.10"
}
}

32
server/routes/admin.js Executable file
View file

@ -0,0 +1,32 @@
import express from 'express';
import multer from 'multer';
import { addMatch, deleteMatch } from '../controllers/adminController.js'; // Assuming this controller file/function
import { checkPassword, setPassword, verifyPassword } from '../controllers/authController.js';
const router = express.Router();
// Set up multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './public/uploads/'); // Directory for uploaded files
},
filename: function (req, file, cb) {
cb(null, `${Date.now()}-${file.originalname}`);
}
});
const upload = multer({ storage: storage });
// Admin authentication routes
router.get('/check-password', checkPassword);
router.post('/set-password', setPassword);
router.post('/verify-password', verifyPassword);
// Route to add a new match
router.post('/matches', upload.array('pdfFiles'), addMatch);
// Route to delete a match
router.delete('/matches/:id', deleteMatch);
// Add other admin routes here later (e.g., for status page)
export default router;

12
server/routes/matches.js Executable file
View file

@ -0,0 +1,12 @@
import express from 'express';
import { getMatches, getMatchDetails } from '../controllers/matchController.js'; // Assuming this controller file/functions
const router = express.Router();
// Route to get all matches
router.get('/', getMatches);
// Route to get details for a specific match
router.get('/:id', getMatchDetails);
export default router;

82
server/routes/tickets.js Executable file
View file

@ -0,0 +1,82 @@
import express from 'express';
import { createTicket, getTicketStatus, getMatchSeats } from '../controllers/ticketController.js';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const router = express.Router();
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '../uploads'));
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'));
}
}
});
// Create a new ticket
router.post('/', upload.single('pdfFile'), async (req, res) => {
try {
const { matchId, name, email, phone, seats, deliveryMethod } = req.body;
const pdfFile = req.file;
if (!matchId || !name || !email || !phone || !seats || !deliveryMethod) {
return res.status(400).json({ message: 'Missing required fields' });
}
if (deliveryMethod === 'email' && !email) {
return res.status(400).json({ message: 'Email is required for email delivery' });
}
const ticket = await createTicket({
matchId,
name,
email,
phone,
seats: parseInt(seats),
pdfFile: pdfFile ? pdfFile.filename : null,
deliveryMethod
});
res.status(201).json(ticket);
} catch (error) {
console.error('Error creating ticket:', error);
res.status(500).json({ message: error.message });
}
});
// Get ticket status
router.get('/:ticketId', async (req, res) => {
try {
const ticket = await getTicketStatus(req.params.ticketId);
if (!ticket) {
return res.status(404).json({ message: 'Ticket not found' });
}
res.json(ticket);
} catch (error) {
console.error('Error getting ticket status:', error);
res.status(500).json({ message: error.message });
}
});
// Get seats for a match
router.get('/match/:matchId/seats', getMatchSeats);
export default router;

14
server/scripts/resetDb.js Normal file
View file

@ -0,0 +1,14 @@
import initDatabase from '../utils/initDatabase.js';
async function main() {
try {
console.log('Starting database reset...');
await initDatabase(true);
console.log('Database reset script finished.');
} catch (error) {
console.error('Database reset script failed:', error);
process.exit(1);
}
}
main();

29
server/utils/database.js Executable file
View file

@ -0,0 +1,29 @@
import mariadb from 'mariadb';
import dotenv from 'dotenv';
dotenv.config();
// Create a pool without database selection first
const initialPool = mariadb.createPool({
host: '172.10.1.4', // Remote database host
user: 'handball', // Remote database user
password: 'Gabi2104@', // Remote database password
database: 'handball', // Database name
connectionLimit: 5
});
export const pool = initialPool;
export async function query(sql, params) {
let conn;
try {
conn = await pool.getConnection();
const result = await conn.query(sql, params);
return result;
} catch (error) {
console.error('Database query error:', error);
throw error;
} finally {
if (conn) conn.release();
}
}

34
server/utils/email.js Executable file
View file

@ -0,0 +1,34 @@
import nodemailer from 'nodemailer';
import dotenv from 'dotenv';
dotenv.config();
// Create a transporter using SMTP
const transporter = nodemailer.createTransport({
host: 'mail.pandem.fr',
port: 465,
secure: true, // SSL
auth: {
user: 'datacenter@nazuna.ovh',
pass: '13,{,oCAlLaGENNiamoThFUllERpOrECriENI'
}
});
export async function sendEmail({ to, subject, text, attachments = [] }) {
try {
const mailOptions = {
from: '"HandBall Ticketer" <datacenter@nazuna.ovh>',
to,
subject,
text,
attachments
};
const info = await transporter.sendMail(mailOptions);
console.log('Email sent:', info.messageId);
return info;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
}

View file

@ -0,0 +1,47 @@
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: 'mail.pandem.fr',
port: 465,
secure: true,
auth: {
user: 'datacenter@nazuna.ovh',
pass: '13,{,oCAlLaGENNiamoThFUllERpOrECriENI'
}
});
export const sendTicketEmail = async (ticketData, matchData, pdfPath) => {
try {
const mailOptions = {
from: '"HandBall Tickets" <datacenter@nazuna.ovh>',
to: ticketData.customerEmail,
subject: `Your Ticket for ${matchData.name}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">Your Handball Match Ticket</h2>
<div style="background-color: #1f2937; color: white; padding: 20px; border-radius: 8px;">
<h3>${matchData.name}</h3>
<p><strong>Date:</strong> ${new Date(matchData.date).toLocaleString('fr-FR')}</p>
<p><strong>Location:</strong> ${matchData.location}</p>
<p><strong>Seat Number:</strong> ${ticketData.seatNumber}</p>
<p><strong>Ticket ID:</strong> ${ticketData.id}</p>
</div>
<p style="margin-top: 20px;">Thank you for your purchase! Your ticket is attached to this email.</p>
</div>
`,
attachments: [
{
filename: `ticket-${ticketData.id}.pdf`,
path: pdfPath
}
]
};
const info = await transporter.sendMail(mailOptions);
console.log('Email sent:', info.messageId);
return true;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
};

0
server/utils/fileStorage.js Executable file
View file

231
server/utils/initDatabase.js Executable file
View file

@ -0,0 +1,231 @@
import mariadb from 'mariadb';
// import dotenv from 'dotenv'; // Remove dotenv
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// dotenv.config(); // Remove dotenv config
// Create a pool using hardcoded database credentials (LESS SECURE)
const initialPool = mariadb.createPool({
host: '172.10.1.4',
user: 'handball',
password: 'Gabi2104@',
database: 'handball',
connectionLimit: 5
});
// Ensure required directories exist
const ensureDirectories = () => {
const uploadsDir = path.join(__dirname, '..', '..', 'public', 'uploads');
const pdfDir = path.join(__dirname, '..', '..', 'public', 'pdfs');
// Create uploads directory if it doesn't exist
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
console.log('Created uploads directory');
}
// Create pdfs directory if it doesn't exist
if (!fs.existsSync(pdfDir)) {
fs.mkdirSync(pdfDir, { recursive: true });
console.log('Created pdfs directory');
}
};
// Ensure .env file exists with required variables (This check is no longer strictly necessary but can be kept as a reminder)
const ensureEnvFile = () => {
const envPath = path.join(__dirname, '..' , '.env');
const requiredEnvVars = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
'PORT',
'SMTP_HOST',
'SMTP_PORT',
'SMTP_USER',
'SMTP_PASS'
];
if (!fs.existsSync(envPath)) {
console.warn('Warning: .env file not found. Using hardcoded credentials.'); // Change to warning
// Do not throw error, just warn
} else {
const envContent = fs.readFileSync(envPath, 'utf8');
const missingVars = requiredEnvVars.filter(varName => !envContent.includes(`${varName}=`)); // Check for key=value
if (missingVars.length > 0) {
console.warn('Warning: Missing required environment variables in .env:', missingVars.join(', ')); // Change to warning
}
}
};
// List of all required tables and their creation SQL
const tableDefinitions = {
matches: `
CREATE TABLE matches (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
date DATETIME NOT NULL,
location VARCHAR(255) NOT NULL,
totalSeats INT NOT NULL,
availableSeats INT NOT NULL,
price DECIMAL(10,2) DEFAULT 0,
timeoutDate DATETIME NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`,
tickets: `
CREATE TABLE tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
matchId INT NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20) NOT NULL,
seats INT NOT NULL,
status ENUM('pending', 'confirmed', 'cancelled') DEFAULT 'pending',
pdfFile VARCHAR(255),
deliveryMethod ENUM('download', 'email') DEFAULT 'download',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ticket_match FOREIGN KEY (matchId) REFERENCES matches(id) ON DELETE CASCADE
) /* Added ON DELETE CASCADE */
`,
seats: `
CREATE TABLE seats (
id INT AUTO_INCREMENT PRIMARY KEY,
matchId INT NOT NULL,
seatNumber INT NOT NULL,
status ENUM('available', 'reserved', 'booked') DEFAULT 'available',
ticketId INT NULL,
direction VARCHAR(50),
extractedSeatNumber INT,
uploadedPdfPath VARCHAR(255),
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_seat_match FOREIGN KEY (matchId) REFERENCES matches(id) ON DELETE CASCADE, /* Added ON DELETE CASCADE */
CONSTRAINT fk_seat_ticket FOREIGN KEY (ticketId) REFERENCES tickets(id) ON DELETE SET NULL, /* Added ON DELETE SET NULL */
UNIQUE KEY unique_seat_match (matchId, seatNumber)
)
`,
admin: `
CREATE TABLE admin (
id INT AUTO_INCREMENT PRIMARY KEY,
passwordHash VARCHAR(255) NOT NULL,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) /* No foreign keys */
`,
admin_settings: `
CREATE TABLE admin_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(255) NOT NULL UNIQUE,
setting_value TEXT,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) /* No foreign keys */
`
};
// Check if table exists
const tableExists = async (conn, tableName) => {
try {
const result = await conn.query(
`SELECT COUNT(*) as count FROM information_schema.tables
WHERE table_schema = ? AND table_name = ?`, // Use env var for schema
[process.env.DB_NAME, tableName]
);
return result[0].count > 0;
} catch (error) {
console.error(`Error checking if table ${tableName} exists:`, error);
return false;
}
};
// Create all tables
const createTables = async (conn) => {
try {
// Create tables in correct order to satisfy foreign keys
const orderedTableNames = ['matches', 'tickets', 'seats', 'admin', 'admin_settings'];
for (const tableName of orderedTableNames) {
const createSQL = tableDefinitions[tableName];
if (!createSQL) {
console.error(`Error: Table definition for ${tableName} not found.`);
continue;
}
try {
console.log(`Creating ${tableName} table...`);
await conn.query(createSQL);
console.log(`${tableName} table created successfully`);
} catch (error) {
// If table already exists, that's fine - just log it
if (error.code === 'ER_TABLE_EXISTS_ERROR') {
console.log(`${tableName} table already exists, skipping creation`);
} else {
// For any other error, rethrow it
throw error;
}
}
}
} catch (error) {
console.error('Error creating tables:', error);
throw error;
}
};
// Export the initialPool (Keep this export)
export { initialPool };
async function initDatabase() {
try {
// Check required directories and files
ensureDirectories();
ensureEnvFile(); // Keep the check but it won't stop execution
console.log('Attempting to connect to database server using hardcoded credentials...'); // Updated log
let conn;
try {
// Connect directly to the database using hardcoded credentials
conn = await initialPool.getConnection();
console.log('Connected to database server.');
// Check if the database exists (Keep this logic)
const dbCheck = await conn.query(
`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`,
['handball'] // Use hardcoded DB name here
);
if (dbCheck.length === 0) {
console.log(`Database 'handball' not found. Creating...`); // Updated log
// Create the database
await conn.query(`CREATE DATABASE handball`); // Use hardcoded DB name here
console.log(`Database 'handball' created.`); // Updated log
} else {
console.log(`Database 'handball' already exists.`); // Updated log
}
// Now that the database exists, ensure the connection is using it
// (Initial pool is already configured with hardcoded DB name)
console.log('Attempting to create tables (if they don\'t exist)...');
// Create tables if they don't exist
await createTables(conn);
console.log('Database initialization completed successfully');
} catch (error) {
console.error('Error during database initialization:', error);
throw error;
} finally {
if (conn) conn.release();
// The pool created with hardcoded DB name is needed for the main server
console.log('Database initialization complete, pool kept open for server using hardcoded credentials.'); // Updated log
}
} catch (error) {
console.error('Startup checks failed:', error);
throw error;
}
}
export default initDatabase;

74
server/utils/pdf.js Normal file
View file

@ -0,0 +1,74 @@
import PDFDocument from 'pdfkit';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export async function generateTicketPDF({ ticketId, match, name, email, phone, seats, pdfFile }) {
const doc = new PDFDocument({
size: 'A4',
margin: 50
});
const outputPath = path.join(__dirname, '../uploads', `ticket-${ticketId}.pdf`);
const writeStream = fs.createWriteStream(outputPath);
doc.pipe(writeStream);
// Add logo if exists
const logoPath = path.join(__dirname, '../assets/logo.png');
if (fs.existsSync(logoPath)) {
doc.image(logoPath, 50, 50, { width: 100 });
}
// Add ticket information
doc.fontSize(24).text('Handball Match Ticket', { align: 'center' });
doc.moveDown();
doc.fontSize(16).text(match.name, { align: 'center' });
doc.moveDown();
// Add match details
doc.fontSize(12);
doc.text(`Date: ${new Date(match.date).toLocaleString()}`);
doc.text(`Location: ${match.location}`);
// Add detailed seat information
doc.text('Seats:');
seats.forEach(seat => {
doc.text(` - ${seat.direction || 'Seat'} ${seat.extractedSeatNumber || seat.seatNumber}`);
});
doc.moveDown();
// Add customer details
doc.text('Customer Information:');
doc.text(`Name: ${name}`);
doc.text(`Email: ${email}`);
doc.text(`Phone: ${phone}`);
doc.moveDown();
// Add ticket ID
doc.text(`Ticket ID: ${ticketId}`);
doc.moveDown();
// Add QR code or barcode if needed
// TODO: Add QR code generation
// Add terms and conditions
doc.fontSize(10);
doc.text('Terms and Conditions:', { underline: true });
doc.text('1. This ticket is non-refundable');
doc.text('2. Please arrive at least 30 minutes before the match');
doc.text('3. Present this ticket at the entrance');
// Finalize PDF
doc.end();
return new Promise((resolve, reject) => {
writeStream.on('finish', () => {
resolve(outputPath);
});
writeStream.on('error', reject);
});
}

View file

@ -0,0 +1,66 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Function to extract seat number from filename
export const extractSeatNumber = (filename) => {
// Try to find a number in the filename
const matches = filename.match(/\d+/);
return matches ? parseInt(matches[0], 10) : null;
};
// Function to find the correct PDF for a seat number
export const findPdfForSeat = async (matchId, seatNumber) => {
try {
// Get the match's PDF files from the database
const [match] = await query('SELECT pdfFiles FROM matches WHERE id = ?', [matchId]);
if (!match || !match.pdfFiles) return null;
const pdfFiles = JSON.parse(match.pdfFiles);
// First try to find a PDF with the seat number in the filename
for (const pdfFile of pdfFiles) {
const filename = path.basename(pdfFile);
const pdfSeatNumber = extractSeatNumber(filename);
if (pdfSeatNumber === seatNumber) {
return path.join(__dirname, '..', 'uploads', pdfFile);
}
}
// If no match found, return the first PDF (admin will need to manually match)
if (pdfFiles.length > 0) {
return path.join(__dirname, '..', 'uploads', pdfFiles[0]);
}
return null;
} catch (error) {
console.error('Error finding PDF for seat:', error);
return null;
}
};
// Function to store uploaded PDFs
export const storePdf = async (file, matchId) => {
try {
const uploadDir = path.join(__dirname, '..', 'uploads', matchId);
// Create directory if it doesn't exist
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filename = `${Date.now()}-${file.originalname}`;
const filepath = path.join(uploadDir, filename);
// Move the file to the upload directory
await fs.promises.writeFile(filepath, file.buffer);
return path.join(matchId, filename);
} catch (error) {
console.error('Error storing PDF:', error);
throw error;
}
};

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" />

11
tailwind.config.js Executable file
View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

24
tsconfig.app.json Executable file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Executable file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Executable file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

15
vite.config.js Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
}
}
}
});

26
vite.config.ts Executable file
View file

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false,
}
}
},
optimizeDeps: {
include: ['lucide-react'],
exclude: ['@webcomponents/custom-elements']
},
build: {
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
}
}
});