firt commit
This commit is contained in:
commit
c2e63830e1
71 changed files with 9613 additions and 0 deletions
28
eslint.config.js
Executable file
28
eslint.config.js
Executable 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
15
index.html
Executable 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
4961
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
package.json
Executable file
45
package.json
Executable 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
6
postcss.config.js
Executable file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
0
public/favicon.ico
Executable file
0
public/favicon.ico
Executable file
5
public/handball.svg
Normal file
5
public/handball.svg
Normal 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 |
BIN
public/uploads/1749039793559-EST22.pdf
Normal file
BIN
public/uploads/1749039793559-EST22.pdf
Normal file
Binary file not shown.
BIN
public/uploads/1749039793561-OUEST312.pdf
Normal file
BIN
public/uploads/1749039793561-OUEST312.pdf
Normal file
Binary file not shown.
BIN
public/uploads/1749039793561-SUD410.pdf
Normal file
BIN
public/uploads/1749039793561-SUD410.pdf
Normal file
Binary file not shown.
BIN
public/uploads/1749041684938-EST_22.pdf
Normal file
BIN
public/uploads/1749041684938-EST_22.pdf
Normal file
Binary file not shown.
BIN
public/uploads/1749041684940-OUEST_312.pdf
Normal file
BIN
public/uploads/1749041684940-OUEST_312.pdf
Normal file
Binary file not shown.
BIN
public/uploads/1749041684941-SUD_410.pdf
Normal file
BIN
public/uploads/1749041684941-SUD_410.pdf
Normal file
Binary file not shown.
293
server/controllers/adminController.js
Executable file
293
server/controllers/adminController.js
Executable 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)
|
63
server/controllers/authController.js
Normal file
63
server/controllers/authController.js
Normal 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' });
|
||||
}
|
||||
};
|
30
server/controllers/matchController.js
Executable file
30
server/controllers/matchController.js
Executable 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 });
|
||||
}
|
||||
};
|
156
server/controllers/ticketController.js
Executable file
156
server/controllers/ticketController.js
Executable 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
56
server/index.js
Executable 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
0
server/middleware/auth.js
Executable file
0
server/middleware/fileUpload.js
Executable file
0
server/middleware/fileUpload.js
Executable file
0
server/models/booking.js
Executable file
0
server/models/booking.js
Executable file
0
server/models/match.js
Executable file
0
server/models/match.js
Executable file
0
server/models/ticket.js
Executable file
0
server/models/ticket.js
Executable file
1798
server/package-lock.json
generated
Normal 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
28
server/package.json
Normal 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
32
server/routes/admin.js
Executable 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
12
server/routes/matches.js
Executable 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
82
server/routes/tickets.js
Executable 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
14
server/scripts/resetDb.js
Normal 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
29
server/utils/database.js
Executable 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
34
server/utils/email.js
Executable 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;
|
||||
}
|
||||
}
|
47
server/utils/emailService.js
Normal file
47
server/utils/emailService.js
Normal 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
0
server/utils/fileStorage.js
Executable file
231
server/utils/initDatabase.js
Executable file
231
server/utils/initDatabase.js
Executable 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
74
server/utils/pdf.js
Normal 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);
|
||||
});
|
||||
}
|
66
server/utils/pdfHandler.js
Normal file
66
server/utils/pdfHandler.js
Normal 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
36
src/App.tsx
Executable 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">© 2025 All rights reserved</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
0
src/components/CountdownTimer.tsx
Executable file
0
src/components/CountdownTimer.tsx
Executable file
0
src/components/FileUpload.tsx
Executable file
0
src/components/FileUpload.tsx
Executable file
0
src/components/Footer.tsx
Executable file
0
src/components/Footer.tsx
Executable file
0
src/components/Logo.tsx
Executable file
0
src/components/Logo.tsx
Executable file
0
src/components/MatchCard.tsx
Executable file
0
src/components/MatchCard.tsx
Executable file
0
src/components/MatchList.tsx
Executable file
0
src/components/MatchList.tsx
Executable file
34
src/components/Navbar.tsx
Executable file
34
src/components/Navbar.tsx
Executable 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;
|
0
src/components/SeatSelector.tsx
Executable file
0
src/components/SeatSelector.tsx
Executable file
0
src/components/TicketForm.tsx
Executable file
0
src/components/TicketForm.tsx
Executable file
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 }
|
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 }
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal 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
0
src/context/AuthContext.tsx
Executable file
24
src/index.css
Executable file
24
src/index.css
Executable 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
6
src/lib/utils.ts
Normal 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
62
src/main.tsx
Executable 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
0
src/pages/AdminMatchForm.tsx
Executable file
555
src/pages/AdminPage.tsx
Executable file
555
src/pages/AdminPage.tsx
Executable 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
120
src/pages/HomePage.tsx
Executable 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
242
src/pages/MatchDetailPage.tsx
Executable 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
55
src/pages/StatusPage.tsx
Executable 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
69
src/services/api.ts
Executable 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
0
src/services/database.ts
Executable file
53
src/types/index.ts
Executable file
53
src/types/index.ts
Executable 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
0
src/utils/formatters.ts
Executable file
0
src/utils/validation.ts
Executable file
0
src/utils/validation.ts
Executable file
1
src/vite-env.d.ts
vendored
Executable file
1
src/vite-env.d.ts
vendored
Executable file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
11
tailwind.config.js
Executable file
11
tailwind.config.js
Executable 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
24
tsconfig.app.json
Executable 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
7
tsconfig.json
Executable file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
tsconfig.node.json
Executable file
22
tsconfig.node.json
Executable 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
15
vite.config.js
Normal 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
26
vite.config.ts
Executable 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
|
||||
}
|
||||
}
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue