Mise à jour : dernières modifs
This commit is contained in:
21
.env.
Normal file
21
.env.
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# === Super Sunday PROD Environment ===
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Database
|
||||||
|
POSTGRES_DB=supersunday
|
||||||
|
POSTGRES_USER=supersunday
|
||||||
|
POSTGRES_PASSWORD=Sup3rSund@y2025!
|
||||||
|
DATABASE_URL=postgres://supersunday:Sup3rSund@y2025!@db:5432/supersunday
|
||||||
|
|
||||||
|
# API / Auth
|
||||||
|
JWT_SECRET=Sup3rSundayUltraSecretKey_2025
|
||||||
|
CORS_ORIGIN=http://localhost
|
||||||
|
|
||||||
|
# Admin credentials
|
||||||
|
ADMIN_EMAIL=admin@padel24play.com
|
||||||
|
ADMIN_PASSWORD=4575SataMGF+-
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
APP_NAME="Super Sunday Padel Championship"
|
||||||
|
CLUB_NAME="Les Églantiers, Woluwe-Saint-Pierre"
|
||||||
21
.env.prod
Normal file
21
.env.prod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# === Super Sunday PROD Environment ===
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Database
|
||||||
|
POSTGRES_DB=supersunday
|
||||||
|
POSTGRES_USER=supersunday
|
||||||
|
POSTGRES_PASSWORD=Sup3rSund@y2025!
|
||||||
|
DATABASE_URL=postgres://supersunday:Sup3rSund@y2025!@db:5432/supersunday
|
||||||
|
|
||||||
|
# API / Auth
|
||||||
|
JWT_SECRET=Sup3rSundayUltraSecretKey_2025
|
||||||
|
CORS_ORIGIN=http://localhost
|
||||||
|
|
||||||
|
# Admin credentials
|
||||||
|
ADMIN_EMAIL=admin@supersunday.app
|
||||||
|
ADMIN_PASSWORD=ChangeMeNow!42
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
APP_NAME="Super Sunday Padel Championship"
|
||||||
|
CLUB_NAME="Les Églantiers, Woluwe-Saint-Pierre"
|
||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
*.out
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# OS / IDE
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environnements
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Coverage / tests
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Build / dist
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# DB / data temporaire
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-journal
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
44
README.md
44
README.md
@@ -1,14 +1,38 @@
|
|||||||
# SuperSunday — V1 (prod, port 8080)
|
# Super Sunday Padel — Full PROD bundle
|
||||||
|
|
||||||
|
Stack: **Postgres 16 + Node/Express API + Nginx static frontend**
|
||||||
|
Features: Players, Teams, Tournaments, Enrollments, Matches, Results, **Americano generator**, JWT Auth, basic admin SPA.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
## Démarrage
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x start.sh
|
cp .env.example .env
|
||||||
./start.sh
|
docker compose up -d --build
|
||||||
# http://localhost:8080
|
# Open http://localhost:8080
|
||||||
|
# Admin at http://localhost:8080/admin (login with ADMIN_EMAIL / ADMIN_PASSWORD from .env)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Fonctionnalités
|
## Push to Gitea (example)
|
||||||
- Liste d'événements
|
```bash
|
||||||
- Inscription + téléchargement iCal
|
git init
|
||||||
- Admin: consultation des inscriptions
|
git add .
|
||||||
- Seeds réalistes (8 éditions winter + live démo + futur)
|
git commit -m "Super Sunday full prod v1"
|
||||||
|
git remote add origin <URL_DE_TON_GITEA>
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## API (highlights)
|
||||||
|
- `POST /api/auth/login` -> { token }
|
||||||
|
- `GET /api/health`
|
||||||
|
- Players: `GET/POST/PUT/DELETE /api/players`
|
||||||
|
- Teams: `GET/POST/PUT/DELETE /api/teams`
|
||||||
|
- Tournaments: `GET/POST/PUT/DELETE /api/tournaments`
|
||||||
|
- Enrollments: `GET/POST/DELETE /api/enrollments`
|
||||||
|
- Matches: `GET/POST/PUT /api/matches`, `POST /api/matches/generate` (Americano)
|
||||||
|
- Scores: `POST /api/matches/:id/score`
|
||||||
|
|
||||||
|
Protected routes require `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Change `JWT_SECRET` and admin credentials before going live.
|
||||||
|
- SQL seed creates minimal schema + demo tournament.
|
||||||
12
README_PATCH.txt
Normal file
12
README_PATCH.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
Patch: Typography + titles facelift (hero titles, section titles, kicker labels).
|
||||||
|
Files:
|
||||||
|
- frontend/public/assets/style.titles.patch.css
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
1. Copy style.titles.patch.css into frontend/public/assets/
|
||||||
|
2. In your HTML <head>, include:
|
||||||
|
<link rel="stylesheet" href="/assets/style.titles.patch.css" />
|
||||||
|
3. Apply classes .hero-title, .section-title, .kicker, .btn-outline in your HTML.
|
||||||
|
|
||||||
|
Rebuild web:
|
||||||
|
docker compose build web && docker compose up -d
|
||||||
25
apply_patch.sh
Executable file
25
apply_patch.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Projet courant
|
||||||
|
PROJECT_DIR="$(pwd)"
|
||||||
|
|
||||||
|
# On cherche le dernier patch .zip téléchargé
|
||||||
|
PATCH=$(ls -t ~/Downloads/supersunday_patch_*.zip | head -n 1)
|
||||||
|
|
||||||
|
if [ ! -f "$PATCH" ]; then
|
||||||
|
echo "❌ Aucun patch trouvé dans ~/Downloads"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Application du patch: $PATCH ==="
|
||||||
|
unzip -o "$PATCH" -d "$PROJECT_DIR"
|
||||||
|
|
||||||
|
echo "=== Rebuild docker ==="
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Suppression du patch après application
|
||||||
|
rm -f "$PATCH"
|
||||||
|
echo "🗑️ Patch $PATCH supprimé"
|
||||||
|
|
||||||
|
echo "✅ Patch appliqué avec succès !"
|
||||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* .npmrc* ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["node", "server.js"]
|
||||||
1200
backend/package-lock.json
generated
Normal file
1200
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/package.json
Normal file
18
backend/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "supersunday-api",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"pg": "^8.11.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
277
backend/server.js
Normal file
277
backend/server.js
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import pkg from 'pg';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const { Pool } = pkg;
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://supersunday:supersunday@db:5432/supersunday';
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret';
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@supersunday.local';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') || '*' }));
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
app.use(morgan('tiny'));
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: DATABASE_URL });
|
||||||
|
async function q(text, params=[]) { const { rows } = await pool.query(text, params); return rows; }
|
||||||
|
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) return res.status(401).json({ error: 'missing token' });
|
||||||
|
try { req.user = jwt.verify(token, JWT_SECRET); next(); }
|
||||||
|
catch { res.status(401).json({ error: 'invalid token' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health + Auth */
|
||||||
|
app.get('/api/health', async (req, res) => {
|
||||||
|
try { const { rows } = await pool.query('SELECT 1 as ok'); res.json({ status: 'ok', db: rows[0].ok === 1 }); }
|
||||||
|
catch (e) { res.status(500).json({ status: 'error', error: String(e) }); }
|
||||||
|
});
|
||||||
|
app.post('/api/auth/login', (req, res) => {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
|
||||||
|
const token = jwt.sign({ email, role: 'admin' }, JWT_SECRET, { expiresIn: '12h' });
|
||||||
|
return res.json({ token });
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'invalid credentials' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Players */
|
||||||
|
app.get('/api/players', async (req, res) => {
|
||||||
|
res.json(await q('SELECT * FROM players ORDER BY id DESC'));
|
||||||
|
});
|
||||||
|
app.get('/api/players/:id', async (req, res) => {
|
||||||
|
const rows = await q('SELECT * FROM players WHERE id=$1', [+req.params.id]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.post('/api/players', auth, async (req, res) => {
|
||||||
|
const { first_name, last_name, email=null, ranking=0 } = req.body||{};
|
||||||
|
if (!first_name || !last_name) return res.status(400).json({ error: 'first_name & last_name required' });
|
||||||
|
const rows = await q('INSERT INTO players(first_name,last_name,email,ranking) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||||
|
[first_name, last_name, email, ranking]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
});
|
||||||
|
app.put('/api/players/:id', auth, async (req, res) => {
|
||||||
|
const { first_name, last_name, email, ranking } = req.body||{};
|
||||||
|
const rows = await q('UPDATE players SET first_name=$1,last_name=$2,email=$3,ranking=$4 WHERE id=$5 RETURNING *',
|
||||||
|
[first_name, last_name, email, ranking, +req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.delete('/api/players/:id', auth, async (req, res) => {
|
||||||
|
await q('DELETE FROM players WHERE id=$1', [+req.params.id]); res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Tournaments */
|
||||||
|
app.get('/api/tournaments', async (req, res) => {
|
||||||
|
res.json(await q('SELECT * FROM tournaments ORDER BY start_date DESC, id DESC'));
|
||||||
|
});
|
||||||
|
app.get('/api/tournaments/upcoming', async (req, res) => {
|
||||||
|
res.json(await q('SELECT * FROM tournaments WHERE start_date >= CURRENT_DATE ORDER BY start_date ASC LIMIT 10'));
|
||||||
|
});
|
||||||
|
app.get('/api/tournaments/:id', async (req, res) => {
|
||||||
|
const id = +req.params.id;
|
||||||
|
const [t] = await q('SELECT * FROM tournaments WHERE id=$1', [id]);
|
||||||
|
if (!t) return res.status(404).json({ error: 'tournament not found' });
|
||||||
|
const [{ enrollment_count }] = await q('SELECT COUNT(*)::int AS enrollment_count FROM enrollments WHERE tournament_id=$1', [id]);
|
||||||
|
const [{ team_count }] = await q('SELECT COUNT(*)::int AS team_count FROM teams WHERE tournament_id=$1', [id]);
|
||||||
|
const [{ match_count }] = await q('SELECT COUNT(*)::int AS match_count FROM matches WHERE tournament_id=$1', [id]);
|
||||||
|
const nextRows = await q(`SELECT * FROM matches WHERE tournament_id=$1 AND status IN ('scheduled','playing') AND scheduled_at IS NOT NULL ORDER BY scheduled_at ASC LIMIT 1`, [id]);
|
||||||
|
res.json({ ...t, enrollment_count, team_count, match_count, next_match: nextRows[0] || null });
|
||||||
|
});
|
||||||
|
app.get('/api/tournaments/:id/summary', async (req, res) => {
|
||||||
|
const id = +req.params.id;
|
||||||
|
const [t] = await q('SELECT * FROM tournaments WHERE id=$1', [id]);
|
||||||
|
if (!t) return res.status(404).json({ error: 'tournament not found' });
|
||||||
|
const enroll = await q(`
|
||||||
|
SELECT p.id, p.first_name, p.last_name, p.ranking
|
||||||
|
FROM enrollments e JOIN players p ON p.id=e.player_id
|
||||||
|
WHERE e.tournament_id=$1 ORDER BY p.ranking ASC NULLS LAST, p.last_name ASC
|
||||||
|
`, [id]);
|
||||||
|
const matches = await q('SELECT * FROM matches WHERE tournament_id=$1 ORDER BY scheduled_at NULLS LAST, id ASC', [id]);
|
||||||
|
res.json({ tournament: t, enrollments: enroll, matches });
|
||||||
|
});
|
||||||
|
app.post('/api/tournaments', auth, async (req, res) => {
|
||||||
|
const { name, location=null, start_date, end_date } = req.body||{};
|
||||||
|
if (!name || !start_date || !end_date) return res.status(400).json({ error: 'name, start_date, end_date required' });
|
||||||
|
const rows = await q('INSERT INTO tournaments(name,location,start_date,end_date) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||||
|
[name, location, start_date, end_date]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
});
|
||||||
|
app.put('/api/tournaments/:id', auth, async (req, res) => {
|
||||||
|
const { name, location, start_date, end_date } = req.body||{};
|
||||||
|
const rows = await q('UPDATE tournaments SET name=$1,location=$2,start_date=$3,end_date=$4 WHERE id=$5 RETURNING *',
|
||||||
|
[name, location, start_date, end_date, +req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.delete('/api/tournaments/:id', auth, async (req, res) => {
|
||||||
|
await q('DELETE FROM tournaments WHERE id=$1', [+req.params.id]); res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Enrollments */
|
||||||
|
app.get('/api/enrollments', async (req, res) => {
|
||||||
|
const tid = req.query.tournament_id ? +req.query.tournament_id : null;
|
||||||
|
if (tid) {
|
||||||
|
return res.json(await q(`
|
||||||
|
SELECT e.player_id, p.first_name, p.last_name, p.ranking
|
||||||
|
FROM enrollments e JOIN players p ON p.id=e.player_id
|
||||||
|
WHERE e.tournament_id=$1 ORDER BY p.ranking ASC, p.last_name ASC
|
||||||
|
`, [tid]));
|
||||||
|
}
|
||||||
|
res.json(await q('SELECT * FROM enrollments ORDER BY tournament_id, player_id'));
|
||||||
|
});
|
||||||
|
app.post('/api/enrollments', auth, async (req, res) => {
|
||||||
|
const { player_id, tournament_id } = req.body||{};
|
||||||
|
if (!player_id || !tournament_id) return res.status(400).json({ error: 'player_id & tournament_id required' });
|
||||||
|
await q('INSERT INTO enrollments(player_id,tournament_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [player_id, tournament_id]);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
});
|
||||||
|
app.post('/api/enrollments/bulk', auth, async (req, res) => {
|
||||||
|
const { player_ids = [], tournament_id } = req.body||{};
|
||||||
|
if (!tournament_id || !Array.isArray(player_ids)) return res.status(400).json({ error: 'player_ids[] & tournament_id required' });
|
||||||
|
for (const pid of player_ids) { await q('INSERT INTO enrollments(player_id,tournament_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [pid, tournament_id]); }
|
||||||
|
res.json({ ok: true, count: player_ids.length });
|
||||||
|
});
|
||||||
|
app.delete('/api/enrollments', auth, async (req, res) => {
|
||||||
|
const { player_id, tournament_id } = req.body||{};
|
||||||
|
await q('DELETE FROM enrollments WHERE player_id=$1 AND tournament_id=$2', [player_id, tournament_id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Teams */
|
||||||
|
app.get('/api/teams', async (req, res) => {
|
||||||
|
const tid = req.query.tournament_id ? +req.query.tournament_id : null;
|
||||||
|
if (tid) {
|
||||||
|
return res.json(await q(`
|
||||||
|
SELECT t.*,
|
||||||
|
p1.first_name AS p1_first, p1.last_name AS p1_last,
|
||||||
|
p2.first_name AS p2_first, p2.last_name AS p2_last
|
||||||
|
FROM teams t
|
||||||
|
JOIN players p1 ON p1.id=t.player1_id
|
||||||
|
JOIN players p2 ON p2.id=t.player2_id
|
||||||
|
WHERE t.tournament_id=$1 ORDER BY t.id DESC
|
||||||
|
`, [tid]));
|
||||||
|
}
|
||||||
|
res.json(await q('SELECT * FROM teams ORDER BY id DESC'));
|
||||||
|
});
|
||||||
|
app.get('/api/teams/:id', async (req, res) => {
|
||||||
|
const rows = await q('SELECT * FROM teams WHERE id=$1', [+req.params.id]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.post('/api/teams', auth, async (req, res) => {
|
||||||
|
const { player1_id, player2_id, tournament_id=null } = req.body||{};
|
||||||
|
if (!player1_id || !player2_id) return res.status(400).json({ error: 'player1_id & player2_id required' });
|
||||||
|
const rows = await q('INSERT INTO teams(player1_id,player2_id,tournament_id) VALUES ($1,$2,$3) RETURNING *',
|
||||||
|
[Math.min(player1_id, player2_id), Math.max(player1_id, player2_id), tournament_id]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
});
|
||||||
|
app.post('/api/teams/auto', auth, async (req, res) => {
|
||||||
|
const { tournament_id } = req.body||{};
|
||||||
|
if (!tournament_id) return res.status(400).json({ error: 'tournament_id required' });
|
||||||
|
const players = await q(`
|
||||||
|
SELECT p.* FROM enrollments e JOIN players p ON p.id=e.player_id
|
||||||
|
WHERE e.tournament_id=$1 ORDER BY p.ranking ASC NULLS LAST
|
||||||
|
`, [tournament_id]);
|
||||||
|
const created = [];
|
||||||
|
for (let i=0;i<players.length;i+=2) {
|
||||||
|
if (i+1 >= players.length) break;
|
||||||
|
const a = players[i].id, b = players[i+1].id;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'INSERT INTO teams(player1_id,player2_id,tournament_id) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING *',
|
||||||
|
[Math.min(a,b), Math.max(a,b), tournament_id]
|
||||||
|
);
|
||||||
|
if (rows[0]) created.push(rows[0]);
|
||||||
|
}
|
||||||
|
res.json({ created_count: created.length, created });
|
||||||
|
});
|
||||||
|
app.put('/api/teams/:id', auth, async (req, res) => {
|
||||||
|
const { player1_id, player2_id, tournament_id } = req.body||{};
|
||||||
|
const rows = await q('UPDATE teams SET player1_id=$1,player2_id=$2,tournament_id=$3 WHERE id=$4 RETURNING *',
|
||||||
|
[Math.min(player1_id, player2_id), Math.max(player1_id, player2_id), tournament_id, +req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.delete('/api/teams/:id', auth, async (req, res) => {
|
||||||
|
await q('DELETE FROM teams WHERE id=$1', [+req.params.id]); res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Matches */
|
||||||
|
app.get('/api/matches', async (req, res) => {
|
||||||
|
const tid = req.query.tournament_id ? +req.query.tournament_id : null;
|
||||||
|
if (tid) return res.json(await q('SELECT * FROM matches WHERE tournament_id=$1 ORDER BY scheduled_at NULLS LAST, id DESC', [tid]));
|
||||||
|
res.json(await q('SELECT * FROM matches ORDER BY scheduled_at NULLS LAST, id DESC'));
|
||||||
|
});
|
||||||
|
app.get('/api/matches/:id', async (req, res) => {
|
||||||
|
const rows = await q('SELECT * FROM matches WHERE id=$1', [+req.params.id]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.post('/api/matches', auth, async (req, res) => {
|
||||||
|
const { tournament_id, court=null, round=null, scheduled_at=null, team_a_id=null, team_b_id=null } = req.body||{};
|
||||||
|
if (!tournament_id) return res.status(400).json({ error: 'tournament_id required' });
|
||||||
|
const rows = await q('INSERT INTO matches(tournament_id,court,round,scheduled_at,team_a_id,team_b_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
|
||||||
|
[tournament_id, court, round, scheduled_at, team_a_id, team_b_id]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
});
|
||||||
|
app.put('/api/matches/:id', auth, async (req, res) => {
|
||||||
|
const { court, round, scheduled_at, team_a_id, team_b_id, status } = req.body||{};
|
||||||
|
const rows = await q('UPDATE matches SET court=$1, round=$2, scheduled_at=$3, team_a_id=$4, team_b_id=$5, status=$6 WHERE id=$7 RETURNING *',
|
||||||
|
[court, round, scheduled_at, team_a_id, team_b_id, status, +req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.post('/api/matches/:id/score', auth, async (req, res) => {
|
||||||
|
const { score_a, score_b, status='finished' } = req.body||{};
|
||||||
|
const rows = await q('UPDATE matches SET score_a=$1, score_b=$2, status=$3 WHERE id=$4 RETURNING *',
|
||||||
|
[score_a, score_b, status, +req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
});
|
||||||
|
app.post('/api/matches/generate', auth, async (req, res) => {
|
||||||
|
const { tournament_id, courts=['Court 1'], start_at=null, interval_minutes=20 } = req.body||{};
|
||||||
|
if (!tournament_id) return res.status(400).json({ error: 'tournament_id required' });
|
||||||
|
const teams = await q('SELECT id FROM teams WHERE tournament_id=$1 ORDER BY id ASC', [tournament_id]);
|
||||||
|
if (teams.length < 2) return res.status(400).json({ error: 'not enough teams' });
|
||||||
|
const now = start_at ? new Date(start_at) : new Date(Date.now() + 5*60*1000);
|
||||||
|
let t = new Date(now); let courtIdx = 0; const created = [];
|
||||||
|
for (let i=0;i<teams.length;i+=2) {
|
||||||
|
if (i+1 >= teams.length) break;
|
||||||
|
const a = teams[i].id, b = teams[i+1].id;
|
||||||
|
const court = courts[courtIdx % courts.length]; courtIdx++;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'INSERT INTO matches(tournament_id, court, round, scheduled_at, team_a_id, team_b_id, status) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *',
|
||||||
|
[tournament_id, court, 'Americano', t.toISOString(), a, b, 'scheduled']
|
||||||
|
);
|
||||||
|
created.push(rows[0]); t = new Date(t.getTime() + interval_minutes*60*1000);
|
||||||
|
}
|
||||||
|
res.json({ created });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Leaderboard (simple): sums wins/losses by team in a tournament */
|
||||||
|
app.get('/api/leaderboard', async (req, res) => {
|
||||||
|
const tid = req.query.tournament_id ? +req.query.tournament_id : null;
|
||||||
|
if (!tid) return res.status(400).json({ error: 'tournament_id required' });
|
||||||
|
const rows = await q(`
|
||||||
|
SELECT team_id, SUM(w) AS wins, SUM(l) AS losses
|
||||||
|
FROM (
|
||||||
|
SELECT team_a_id AS team_id, CASE WHEN score_a>score_b THEN 1 ELSE 0 END AS w, CASE WHEN score_a<score_b THEN 1 ELSE 0 END AS l
|
||||||
|
FROM matches WHERE tournament_id=$1 AND status='finished' AND team_a_id IS NOT NULL AND team_b_id IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT team_b_id AS team_id, CASE WHEN score_b>score_a THEN 1 ELSE 0 END AS w, CASE WHEN score_b<score_a THEN 1 ELSE 0 END AS l
|
||||||
|
FROM matches WHERE tournament_id=$1 AND status='finished' AND team_a_id IS NOT NULL AND team_b_id IS NOT NULL
|
||||||
|
) x
|
||||||
|
GROUP BY team_id
|
||||||
|
ORDER BY wins DESC, losses ASC
|
||||||
|
`, [tid]);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`API listening on :${PORT}`));
|
||||||
BIN
bakcgrounp24p.jpg
Normal file
BIN
bakcgrounp24p.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
190
data/db.json
190
data/db.json
@@ -1,190 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"siteName": "Padel24Play — V1 (8080)",
|
|
||||||
"currency": "EUR",
|
|
||||||
"priceEur": 48,
|
|
||||||
"refundWindowHours": 48
|
|
||||||
},
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"email": "admin@supersunday.com",
|
|
||||||
"password": "password123",
|
|
||||||
"role": "admin",
|
|
||||||
"name": "Admin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"email": "player@supersunday.com",
|
|
||||||
"password": "password123",
|
|
||||||
"role": "user",
|
|
||||||
"name": "Player"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"id": "ss-winter-1",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #1",
|
|
||||||
"date": "2025-10-05T08:00:00",
|
|
||||||
"end": "2025-10-05T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-2",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #2",
|
|
||||||
"date": "2025-10-19T08:00:00",
|
|
||||||
"end": "2025-10-19T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-3",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #3",
|
|
||||||
"date": "2025-11-02T08:00:00",
|
|
||||||
"end": "2025-11-02T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-4",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #4",
|
|
||||||
"date": "2025-11-16T08:00:00",
|
|
||||||
"end": "2025-11-16T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-5",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #5",
|
|
||||||
"date": "2025-11-30T08:00:00",
|
|
||||||
"end": "2025-11-30T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-6",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #6",
|
|
||||||
"date": "2025-12-14T08:00:00",
|
|
||||||
"end": "2025-12-14T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-7",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #7",
|
|
||||||
"date": "2025-12-28T08:00:00",
|
|
||||||
"end": "2025-12-28T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-winter-8",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Winter Edition #8",
|
|
||||||
"date": "2026-01-11T08:00:00",
|
|
||||||
"end": "2026-01-11T11:00:00",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-live-now",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — LIVE (démo)",
|
|
||||||
"date": "2025-08-18T18:33:23",
|
|
||||||
"end": "2025-08-18T21:33:23",
|
|
||||||
"location": "TC Églantiers"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ss-futur-1",
|
|
||||||
"kind": "Americano",
|
|
||||||
"name": "SuperSunday — Inscription ouverte",
|
|
||||||
"date": "2025-08-28T19:03:23",
|
|
||||||
"end": "2025-08-28T22:03:23",
|
|
||||||
"location": "Sport City Woluwe"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"registrations": [
|
|
||||||
{
|
|
||||||
"id": "reg_seed_1",
|
|
||||||
"createdAt": "2025-08-18T19:03:23.597Z",
|
|
||||||
"eventId": "ss-live-now",
|
|
||||||
"player": {
|
|
||||||
"name": "SmashQueen",
|
|
||||||
"email": "smashqueen@demo.local",
|
|
||||||
"phone": "+32471000000",
|
|
||||||
"level": "Avancé"
|
|
||||||
},
|
|
||||||
"payment": "card",
|
|
||||||
"status": "confirmé",
|
|
||||||
"paymentStatus": "paid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "reg_seed_2",
|
|
||||||
"createdAt": "2025-08-18T19:03:23.600Z",
|
|
||||||
"eventId": "ss-winter-1",
|
|
||||||
"player": {
|
|
||||||
"name": "PadelKing42",
|
|
||||||
"email": "padelking42@demo.local",
|
|
||||||
"phone": "+32471000001",
|
|
||||||
"level": "Intermédiaire"
|
|
||||||
},
|
|
||||||
"payment": "paypal",
|
|
||||||
"status": "en_attente",
|
|
||||||
"paymentStatus": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "reg_seed_3",
|
|
||||||
"createdAt": "2025-08-18T19:03:23.600Z",
|
|
||||||
"eventId": "ss-live-now",
|
|
||||||
"player": {
|
|
||||||
"name": "MrVibora",
|
|
||||||
"email": "mrvibora@demo.local",
|
|
||||||
"phone": "+32471000002",
|
|
||||||
"level": "Débutant"
|
|
||||||
},
|
|
||||||
"payment": "onspot",
|
|
||||||
"status": "confirmé",
|
|
||||||
"paymentStatus": "paid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "reg_seed_4",
|
|
||||||
"createdAt": "2025-08-18T19:03:23.600Z",
|
|
||||||
"eventId": "ss-winter-1",
|
|
||||||
"player": {
|
|
||||||
"name": "LaBandeja",
|
|
||||||
"email": "labandeja@demo.local",
|
|
||||||
"phone": "+32471000003",
|
|
||||||
"level": "Avancé"
|
|
||||||
},
|
|
||||||
"payment": "card",
|
|
||||||
"status": "en_attente",
|
|
||||||
"paymentStatus": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "reg_seed_5",
|
|
||||||
"createdAt": "2025-08-18T19:03:23.600Z",
|
|
||||||
"eventId": "ss-live-now",
|
|
||||||
"player": {
|
|
||||||
"name": "MissChiquita",
|
|
||||||
"email": "misschiquita@demo.local",
|
|
||||||
"phone": "+32471000004",
|
|
||||||
"level": "Intermédiaire"
|
|
||||||
},
|
|
||||||
"payment": "paypal",
|
|
||||||
"status": "confirmé",
|
|
||||||
"paymentStatus": "paid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "reg_seed_6",
|
|
||||||
"createdAt": "2025-08-18T19:03:23.600Z",
|
|
||||||
"eventId": "ss-winter-1",
|
|
||||||
"player": {
|
|
||||||
"name": "VoléeMagique",
|
|
||||||
"email": "volemagique@demo.local",
|
|
||||||
"phone": "+32471000005",
|
|
||||||
"level": "Débutant"
|
|
||||||
},
|
|
||||||
"payment": "onspot",
|
|
||||||
"status": "en_attente",
|
|
||||||
"paymentStatus": "pending"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
68
db/init/001_schema_and_seed.sql
Normal file
68
db/init/001_schema_and_seed.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
ranking INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tournaments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS enrollments (
|
||||||
|
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
tournament_id INTEGER NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (player_id, tournament_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
player1_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
player2_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
tournament_id INTEGER REFERENCES tournaments(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(player1_id, player2_id, tournament_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS matches (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tournament_id INTEGER NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
|
||||||
|
court TEXT,
|
||||||
|
round TEXT,
|
||||||
|
scheduled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
team_a_id INTEGER REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
|
team_b_id INTEGER REFERENCES teams(id) ON DELETE SET NULL,
|
||||||
|
score_a INTEGER DEFAULT 0,
|
||||||
|
score_b INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'scheduled' -- scheduled|playing|finished
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Demo data
|
||||||
|
INSERT INTO players (first_name, last_name, email, ranking)
|
||||||
|
VALUES
|
||||||
|
('Flore', 'Van den Broeck', 'flore@example.com', 200),
|
||||||
|
('Karim', 'Hassan', 'karim@example.com', 210),
|
||||||
|
('Félicie', 'Hassan', 'felicie@example.com', 350),
|
||||||
|
('Balthazar', 'Hassan', 'balthazar@example.com', 400)
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO tournaments (name, location, start_date, end_date)
|
||||||
|
SELECT 'Super Sunday - Demo', 'Les Églantiers, WSP', CURRENT_DATE, CURRENT_DATE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM tournaments WHERE name='Super Sunday - Demo');
|
||||||
|
|
||||||
|
-- auto-enroll demo players
|
||||||
|
DO $$
|
||||||
|
DECLARE t_id INT; p INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO t_id FROM tournaments WHERE name='Super Sunday - Demo';
|
||||||
|
FOR p IN SELECT id FROM players LOOP
|
||||||
|
INSERT INTO enrollments(player_id, tournament_id) VALUES (p, t_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-supersunday}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-supersunday}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-supersunday}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-supersunday} -d ${POSTGRES_DB:-supersunday}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./frontend
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
3
frontend/Dockerfile
Normal file
3
frontend/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY public /usr/share/nginx/html
|
||||||
67
frontend/index.html
Normal file
67
frontend/index.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Super Sunday — Admin</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Admin — Super Sunday</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Accueil</a>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section id="loginSection">
|
||||||
|
<h2>Connexion</h2>
|
||||||
|
<input id="email" placeholder="Email" />
|
||||||
|
<input id="password" type="password" placeholder="Mot de passe" />
|
||||||
|
<button id="loginBtn">Se connecter</button>
|
||||||
|
<p id="loginStatus"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="adminSection" style="display:none;">
|
||||||
|
<h2>Actions rapides</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Nouveau tournoi</h3>
|
||||||
|
<input id="t_name" placeholder="Nom" />
|
||||||
|
<input id="t_loc" placeholder="Lieu" />
|
||||||
|
<input id="t_sd" type="date" />
|
||||||
|
<input id="t_ed" type="date" />
|
||||||
|
<button onclick="createTournament()">Créer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Générer Americano</h3>
|
||||||
|
<input id="g_tid" placeholder="Tournament ID" />
|
||||||
|
<input id="g_courts" placeholder="Courts (ex: Court 1,Court 2)" />
|
||||||
|
<input id="g_start" type="datetime-local" />
|
||||||
|
<input id="g_int" type="number" value="20" />
|
||||||
|
<button onclick="generateAmericano()">Générer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Score Match</h3>
|
||||||
|
<input id="m_id" placeholder="Match ID" />
|
||||||
|
<input id="m_a" type="number" placeholder="Score A" />
|
||||||
|
<input id="m_b" type="number" placeholder="Score B" />
|
||||||
|
<button onclick="scoreMatch()">Valider</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt">Tournaments</h2>
|
||||||
|
<pre id="adminTournaments"></pre>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<pre id="adminMatches"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/api.js"></script>
|
||||||
|
<script src="/assets/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
frontend/nginx/default.conf
Normal file
21
frontend/nginx/default.conf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location = /health { return 200 'ok'; add_header Content-Type text/plain; }
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/public/admin/index.html
Normal file
74
frontend/public/admin/index.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Super Sunday — Admin</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Admin — Super Sunday</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Accueil</a>
|
||||||
|
<a href="/events">Événements</a>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section id="loginSection" class="card">
|
||||||
|
<h2>Connexion</h2>
|
||||||
|
<input id="email" placeholder="Email" />
|
||||||
|
<input id="password" type="password" placeholder="Mot de passe" />
|
||||||
|
<button id="loginBtn" class="btn">Se connecter</button>
|
||||||
|
<p id="loginStatus" class="muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="adminSection" style="display:none;">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Nouveau tournoi</h3>
|
||||||
|
<input id="t_name" placeholder="Nom" />
|
||||||
|
<input id="t_loc" placeholder="Lieu" />
|
||||||
|
<input id="t_sd" type="date" />
|
||||||
|
<input id="t_ed" type="date" />
|
||||||
|
<button onclick="createTournament()" class="btn">Créer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Générer Americano</h3>
|
||||||
|
<input id="g_tid" placeholder="Tournament ID" />
|
||||||
|
<input id="g_courts" placeholder="Courts (ex: Court 1,Court 2)" />
|
||||||
|
<input id="g_start" type="datetime-local" />
|
||||||
|
<input id="g_int" type="number" value="20" />
|
||||||
|
<button onclick="generateAmericano()" class="btn">Générer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Score Match</h3>
|
||||||
|
<input id="m_id" placeholder="Match ID" />
|
||||||
|
<input id="m_a" type="number" placeholder="Score A" />
|
||||||
|
<input id="m_b" type="number" placeholder="Score B" />
|
||||||
|
<button onclick="scoreMatch()" class="btn">Valider</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt">Tournaments</h2>
|
||||||
|
<pre id="adminTournaments"></pre>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<pre id="adminMatches"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bubbles -->
|
||||||
|
<div class="bg-bubble b1"></div>
|
||||||
|
<div class="bg-bubble b2"></div>
|
||||||
|
<div class="bg-bubble b3"></div>
|
||||||
|
<div class="bg-bubble b4"></div>
|
||||||
|
<div class="bg-bubble b5"></div>
|
||||||
|
|
||||||
|
<script src="/assets/api.js"></script>
|
||||||
|
<script src="/assets/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
frontend/public/assets/admin.js
Normal file
74
frontend/public/assets/admin.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const loginBtn = document.getElementById('loginBtn');
|
||||||
|
const loginSection = document.getElementById('loginSection');
|
||||||
|
const adminSection = document.getElementById('adminSection');
|
||||||
|
const loginStatus = document.getElementById('loginStatus');
|
||||||
|
|
||||||
|
async function refreshLists(){
|
||||||
|
const ts = await SSAPI.get('/tournaments').catch(()=>[]);
|
||||||
|
const ms = await SSAPI.get('/matches').catch(()=>[]);
|
||||||
|
document.getElementById('adminTournaments').textContent = JSON.stringify(ts,null,2);
|
||||||
|
document.getElementById('adminMatches').textContent = JSON.stringify(ms,null,2);
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBtn.onclick = async () => {
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
try {
|
||||||
|
const { token } = await SSAPI.post('/auth/login', { email, password });
|
||||||
|
SSAPI.setToken(token);
|
||||||
|
loginStatus.textContent = 'Connecté ✓';
|
||||||
|
loginSection.style.display = 'none';
|
||||||
|
adminSection.style.display = 'block';
|
||||||
|
await refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
loginStatus.textContent = 'Erreur: ' + e.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.createTournament = async () => {
|
||||||
|
const name = document.getElementById('t_name').value;
|
||||||
|
const location = document.getElementById('t_loc').value;
|
||||||
|
const start_date = document.getElementById('t_sd').value;
|
||||||
|
const end_date = document.getElementById('t_ed').value;
|
||||||
|
await SSAPI.post('/tournaments', { name, location, start_date, end_date });
|
||||||
|
await refreshLists();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.generateAmericano = async () => {
|
||||||
|
const tournament_id = Number(document.getElementById('g_tid').value);
|
||||||
|
const courts = document.getElementById('g_courts').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||||
|
const start_at = document.getElementById('g_start').value || null;
|
||||||
|
const interval_minutes = Number(document.getElementById('g_int').value || 20);
|
||||||
|
const res = await SSAPI.post('/matches/generate', { tournament_id, courts, start_at, interval_minutes });
|
||||||
|
alert('Matches créés: ' + res.created.length);
|
||||||
|
await refreshLists();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.scoreMatch = async () => {
|
||||||
|
const id = Number(document.getElementById('m_id').value);
|
||||||
|
const score_a = Number(document.getElementById('m_a').value);
|
||||||
|
const score_b = Number(document.getElementById('m_b').value);
|
||||||
|
await SSAPI.post(`/matches/${id}/score`, { score_a, score_b });
|
||||||
|
await refreshLists();
|
||||||
|
};
|
||||||
|
|
||||||
|
// New: bulk enroll & auto teams
|
||||||
|
window.bulkEnroll = async () => {
|
||||||
|
const tournament_id = Number(document.getElementById('b_tid').value);
|
||||||
|
const ids = document.getElementById('b_ids').value.split(',').map(s=>Number(s.trim())).filter(Boolean);
|
||||||
|
await SSAPI.post('/enrollments/bulk', { tournament_id, player_ids: ids });
|
||||||
|
await refreshLists();
|
||||||
|
};
|
||||||
|
window.autoTeams = async () => {
|
||||||
|
const tournament_id = Number(document.getElementById('b_tid').value);
|
||||||
|
const res = await SSAPI.post('/teams/auto', { tournament_id });
|
||||||
|
alert('Équipes créées: ' + (res.created_count || 0));
|
||||||
|
await refreshLists();
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto-show admin if token present
|
||||||
|
if (SSAPI.getToken()) {
|
||||||
|
loginSection.style.display = 'none';
|
||||||
|
adminSection.style.display = 'block';
|
||||||
|
refreshLists();
|
||||||
|
}
|
||||||
12
frontend/public/assets/api.js
Normal file
12
frontend/public/assets/api.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const api = {
|
||||||
|
base: '',
|
||||||
|
token: null,
|
||||||
|
setToken(t){ this.token = t; localStorage.setItem('ss_token', t); },
|
||||||
|
getToken(){ return this.token || localStorage.getItem('ss_token'); },
|
||||||
|
headers(){ const h = { 'Content-Type':'application/json' }; const t=this.getToken(); if(t) h['Authorization']='Bearer '+t; return h; },
|
||||||
|
async get(path){ const r = await fetch('/api'+path, { headers: this.headers() }); if(!r.ok) throw new Error(await r.text()); return r.json(); },
|
||||||
|
async post(path, body){ const r = await fetch('/api'+path, { method:'POST', headers:this.headers(), body: JSON.stringify(body) }); if(!r.ok) throw new Error(await r.text()); return r.json(); },
|
||||||
|
async put(path, body){ const r = await fetch('/api'+path, { method:'PUT', headers:this.headers(), body: JSON.stringify(body) }); if(!r.ok) throw new Error(await r.text()); return r.json(); },
|
||||||
|
async del(path, body){ const r = await fetch('/api'+path, { method:'DELETE', headers:this.headers(), body: JSON.stringify(body) }); if(!r.ok) throw new Error(await r.text()); return r.json(); }
|
||||||
|
};
|
||||||
|
window.SSAPI = api;
|
||||||
11
frontend/public/assets/events.js
Normal file
11
frontend/public/assets/events.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
(async function(){
|
||||||
|
const items = await SSAPI.get('/tournaments/upcoming').catch(()=>[]);
|
||||||
|
const el = document.getElementById('list');
|
||||||
|
el.innerHTML = items.map(t=>`
|
||||||
|
<a class="card" href="/tournament/?id=${t.id}" style="display:block;text-decoration:none;color:inherit">
|
||||||
|
<strong>${t.name}</strong><br>
|
||||||
|
${t.location || ''}<br>
|
||||||
|
${t.start_date} → ${t.end_date}
|
||||||
|
</a>
|
||||||
|
`).join('') || '<p class="muted">Aucun événement à venir</p>';
|
||||||
|
})();
|
||||||
BIN
frontend/public/assets/image/backgroundp24p.jpg
Normal file
BIN
frontend/public/assets/image/backgroundp24p.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
23
frontend/public/assets/main.js
Normal file
23
frontend/public/assets/main.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
(async function(){
|
||||||
|
const tournaments = await SSAPI.get('/tournaments').catch(()=>[]);
|
||||||
|
const matches = await SSAPI.get('/matches').catch(()=>[]);
|
||||||
|
|
||||||
|
const tWrap = document.getElementById('tournaments');
|
||||||
|
tWrap.innerHTML = tournaments.map(t=>`
|
||||||
|
<div class="card">
|
||||||
|
<strong>${t.name}</strong><br>
|
||||||
|
${t.location || ''}<br>
|
||||||
|
${t.start_date} → ${t.end_date}
|
||||||
|
</div>
|
||||||
|
`).join('') || '<p class="muted">Aucun tournoi</p>';
|
||||||
|
|
||||||
|
const mWrap = document.getElementById('matches');
|
||||||
|
mWrap.innerHTML = matches.map(m=>`
|
||||||
|
<div class="card">
|
||||||
|
<div><strong>${m.round || 'Match'}</strong> — ${m.court || 'Court ?'}</div>
|
||||||
|
<div>Teams: ${m.team_a_id || '?'} vs ${m.team_b_id || '?'}</div>
|
||||||
|
<div>Score: ${m.score_a} - ${m.score_b} (${m.status})</div>
|
||||||
|
<div>${m.scheduled_at ? new Date(m.scheduled_at).toLocaleString() : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<p class="muted">Aucun match</p>';
|
||||||
|
})();
|
||||||
141
frontend/public/assets/style.css
Normal file
141
frontend/public/assets/style.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/* === Super Sunday — Global sporty theme + hero background + subtle bubbles === */
|
||||||
|
|
||||||
|
/* Palette */
|
||||||
|
:root {
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f2f5fa;
|
||||||
|
--ink: #101827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #e5e9f2;
|
||||||
|
|
||||||
|
--accent-start: #1ea7ff;
|
||||||
|
--accent-end: #27d980;
|
||||||
|
--accent: #1890ff;
|
||||||
|
|
||||||
|
--radius: 16px;
|
||||||
|
--shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
--shadow-hover: 0 10px 28px rgba(0,0,0,.12);
|
||||||
|
--trans: 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{
|
||||||
|
margin:0;
|
||||||
|
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
|
||||||
|
color:var(--ink);
|
||||||
|
line-height:1.5;
|
||||||
|
|
||||||
|
/* Hero background image */
|
||||||
|
background-image: url("/assets/images/backgroundp24p.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center top;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dark overlay to ensure readability */
|
||||||
|
body::before{
|
||||||
|
content:"";
|
||||||
|
position:fixed; inset:0;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.30), rgba(255,255,255,.60));
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
z-index:-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Header / Nav ===== */
|
||||||
|
header{
|
||||||
|
position: sticky; top:0; z-index:40;
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
backdrop-filter: blur(8px) saturate(120%);
|
||||||
|
}
|
||||||
|
header h1{margin:0; font-size:20px; font-weight:700; color:var(--ink)}
|
||||||
|
nav a{
|
||||||
|
margin-left:16px; padding:8px 14px;
|
||||||
|
color:var(--ink); text-decoration:none; border-radius:12px;
|
||||||
|
transition: background var(--trans), transform var(--trans);
|
||||||
|
}
|
||||||
|
nav a:hover{ background: linear-gradient(90deg, rgba(30,167,255,.15), rgba(39,217,128,.15)); transform:translateY(-1px) }
|
||||||
|
|
||||||
|
/* ===== Layout ===== */
|
||||||
|
.container{ max-width:1100px; margin:24px auto; padding:0 16px }
|
||||||
|
.grid{ display:grid; gap:20px; grid-template-columns: repeat(auto-fit,minmax(280px,1fr)) }
|
||||||
|
|
||||||
|
/* ===== Cards ===== */
|
||||||
|
.card{
|
||||||
|
background: var(--surface);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: transform var(--trans), box-shadow var(--trans), border-color var(--trans);
|
||||||
|
}
|
||||||
|
.card:hover{ transform: translateY(-2px); box-shadow: var(--shadow-hover); border-color:#dbe5f2 }
|
||||||
|
a.card{ display:block; color:inherit; text-decoration:none }
|
||||||
|
.card.accent{
|
||||||
|
background: linear-gradient(135deg, rgba(30,167,255,.08), rgba(39,217,128,.08)), var(--surface);
|
||||||
|
border-color: rgba(30,167,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titles / text */
|
||||||
|
h1,h2,h3{ line-height:1.2 }
|
||||||
|
h2{ margin: 20px 0 12px; font-size:22px; font-weight:600 }
|
||||||
|
.muted{ color: var(--muted) }
|
||||||
|
.mt{ margin-top:24px }
|
||||||
|
|
||||||
|
/* Buttons & inputs */
|
||||||
|
.btn{
|
||||||
|
display:inline-block; padding:10px 18px; border-radius:12px; border:none;
|
||||||
|
background: linear-gradient(90deg, var(--accent-start), var(--accent-end));
|
||||||
|
color:#fff; font-weight:600; text-decoration:none;
|
||||||
|
box-shadow: 0 8px 20px rgba(24,144,255,.25);
|
||||||
|
transition: transform var(--trans), box-shadow var(--trans);
|
||||||
|
}
|
||||||
|
.btn:hover{ transform: translateY(-1px); box-shadow: 0 12px 28px rgba(24,144,255,.35) }
|
||||||
|
|
||||||
|
input,select{
|
||||||
|
width:100%; padding:10px 12px; border-radius:12px; border:1px solid var(--border);
|
||||||
|
background: var(--surface-2); color: var(--ink); outline:none;
|
||||||
|
transition: border-color var(--trans), box-shadow var(--trans);
|
||||||
|
}
|
||||||
|
input:focus,select:focus{ border-color:#b8d8ff; box-shadow: 0 0 0 3px rgba(24,144,255,.20) }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty{
|
||||||
|
padding: 20px; text-align:center; color:var(--muted);
|
||||||
|
background: var(--surface-2); border:1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific existing sections */
|
||||||
|
#tournaments .card, #matches .card{ margin-bottom:12px }
|
||||||
|
|
||||||
|
/* ===== Animated bubbles (tennis/padel ball ghosts) ===== */
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translateY(0) scale(1); opacity:.7; }
|
||||||
|
50% { transform: translateY(-36px) scale(1.05); opacity:.4; }
|
||||||
|
100% { transform: translateY(0) scale(1); opacity:.7; }
|
||||||
|
}
|
||||||
|
.bg-bubble{
|
||||||
|
position: fixed; z-index: -1; pointer-events: none; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,.75), rgba(255,255,255,0));
|
||||||
|
filter: blur(0.2px);
|
||||||
|
animation: float 9s ease-in-out infinite;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
.bg-bubble.b1{ width:90px; height:90px; left:15%; top:72%; animation-duration:8s }
|
||||||
|
.bg-bubble.b2{ width:70px; height:70px; left:68%; top:64%; animation-duration:10s }
|
||||||
|
.bg-bubble.b3{ width:115px; height:115px; left:48%; top:78%; animation-duration:12s }
|
||||||
|
.bg-bubble.b4{ width:60px; height:60px; left:82%; top:20%; animation-duration:11s }
|
||||||
|
.bg-bubble.b5{ width:80px; height:80px; left:6%; top:18%; animation-duration:9s }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
.bg-bubble{ animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer (if needed) */
|
||||||
|
footer{ margin:40px 0 0; padding:24px 16px; text-align:center; color:var(--muted);
|
||||||
|
border-top:1px solid var(--border); background: rgba(255,255,255,.75); backdrop-filter: blur(6px) }
|
||||||
55
frontend/public/assets/style.titles.patch.css
Normal file
55
frontend/public/assets/style.titles.patch.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* ==== TITLES UPGRADE (Sporty & Bold) ==== */
|
||||||
|
:root{
|
||||||
|
--display-font: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
--title-size-xl: clamp(28px, 4.5vw, 48px);
|
||||||
|
--title-size-lg: clamp(22px, 3.2vw, 32px);
|
||||||
|
--title-weight: 800;
|
||||||
|
--title-gradient: linear-gradient(90deg, var(--accent-start), var(--accent-end));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title{
|
||||||
|
font-family: var(--display-font);
|
||||||
|
font-size: var(--title-size-xl);
|
||||||
|
font-weight: var(--title-weight);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
background: var(--title-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
text-shadow: 0 6px 24px rgba(24,144,255,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title{
|
||||||
|
position: relative;
|
||||||
|
font-size: var(--title-size-lg);
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 22px 0 14px;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
.section-title::before{
|
||||||
|
content:"";
|
||||||
|
position:absolute; left:0; top: 8px; bottom: 8px;
|
||||||
|
width:6px; border-radius: 6px;
|
||||||
|
background: var(--title-gradient);
|
||||||
|
box-shadow: 0 8px 18px rgba(24,144,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker{
|
||||||
|
display:inline-block;
|
||||||
|
font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase;
|
||||||
|
color:#0a3a6b;
|
||||||
|
background: rgba(24,144,255,.10);
|
||||||
|
border: 1px solid rgba(24,144,255,.25);
|
||||||
|
padding:4px 8px; border-radius:999px; margin-bottom:10px;
|
||||||
|
}
|
||||||
|
.cta-row{ display:flex; gap:12px; flex-wrap:wrap; margin-top:12px }
|
||||||
|
.btn-outline{
|
||||||
|
display:inline-block; padding:10px 16px; border-radius:12px;
|
||||||
|
border:1px solid #cfe3ff; color:#0f437c; text-decoration:none;
|
||||||
|
background: rgba(255,255,255,.6);
|
||||||
|
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease;
|
||||||
|
}
|
||||||
|
.btn-outline:hover{ transform: translateY(-1px); background: rgba(255,255,255,.85); border-color: #a3cdfd }
|
||||||
39
frontend/public/assets/tournament.js
Normal file
39
frontend/public/assets/tournament.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
(function(){
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const id = Number(params.get('id') || '0');
|
||||||
|
if(!id){
|
||||||
|
document.getElementById('info').innerHTML = '<p>Tournoi introuvable (id manquant)</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
async function load(){
|
||||||
|
const t = await SSAPI.get('/tournaments/'+id).catch(()=>null);
|
||||||
|
if(!t){ document.getElementById('info').innerHTML = '<p>Tournoi introuvable</p>'; return; }
|
||||||
|
document.getElementById('info').innerHTML = `
|
||||||
|
<h2>${t.name}</h2>
|
||||||
|
<div><strong>Lieu:</strong> ${t.location || '—'}</div>
|
||||||
|
<div><strong>Dates:</strong> ${t.start_date} → ${t.end_date}</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><strong>Inscrits</strong><br>${t.enrollment_count}</div>
|
||||||
|
<div class="card"><strong>Équipes</strong><br>${t.team_count}</div>
|
||||||
|
<div class="card"><strong>Matches</strong><br>${t.match_count}</div>
|
||||||
|
</div>
|
||||||
|
${t.next_match ? `<div class="card mt"><strong>Prochain match</strong><br>${t.next_match.round || ''} — ${t.next_match.court || ''}<br>${t.next_match.scheduled_at ? new Date(t.next_match.scheduled_at).toLocaleString() : ''}</div>` : ''}
|
||||||
|
`;
|
||||||
|
const parts = await SSAPI.get('/enrollments?tournament_id='+id).catch(()=>[]);
|
||||||
|
document.getElementById('participants').innerHTML = parts.map(p=>`
|
||||||
|
<div class="card">
|
||||||
|
${p.first_name} ${p.last_name} <span class="muted">(#${p.ranking || '—'})</span>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<p class="muted">Aucun inscrit</p>';
|
||||||
|
const matches = await SSAPI.get('/matches?tournament_id='+id).catch(()=>[]);
|
||||||
|
document.getElementById('matchlist').innerHTML = matches.map(m=>`
|
||||||
|
<div class="card">
|
||||||
|
<div><strong>${m.round || 'Match'}</strong> — ${m.court || 'Court ?'}</div>
|
||||||
|
<div>Teams: ${m.team_a_id || '?'} vs ${m.team_b_id || '?'}</div>
|
||||||
|
<div>Score: ${m.score_a} - ${m.score_b} (${m.status})</div>
|
||||||
|
<div>${m.scheduled_at ? new Date(m.scheduled_at).toLocaleString() : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<p class="muted">Aucun match</p>';
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
})();
|
||||||
24
frontend/public/events/index.html
Normal file
24
frontend/public/events/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Événements — Super Sunday</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Événements à venir</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Accueil</a>
|
||||||
|
<a href="/events">Événements</a>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<div id="list"></div>
|
||||||
|
</main>
|
||||||
|
<script src="/assets/api.js"></script>
|
||||||
|
<script src="/assets/events.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
frontend/public/index.html
Normal file
40
frontend/public/index.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Super Sunday — Accueil</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Super Sunday — Padel Championship</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Accueil</a>
|
||||||
|
<a href="/events">Événements</a>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section>
|
||||||
|
<h2>Tournois</h2>
|
||||||
|
<div id="tournaments"></div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<div id="matches" class="empty">Aucun match</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bubbles -->
|
||||||
|
<div class="bg-bubble b1"></div>
|
||||||
|
<div class="bg-bubble b2"></div>
|
||||||
|
<div class="bg-bubble b3"></div>
|
||||||
|
<div class="bg-bubble b4"></div>
|
||||||
|
<div class="bg-bubble b5"></div>
|
||||||
|
|
||||||
|
<script src="/assets/api.js"></script>
|
||||||
|
<script src="/assets/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
frontend/public/tournament/index.html
Normal file
41
frontend/public/tournament/index.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Détail tournoi — Super Sunday</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Détail du tournoi</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Accueil</a>
|
||||||
|
<a href="/events">Événements</a>
|
||||||
|
<a href="/admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section id="info" class="card"></section>
|
||||||
|
<section>
|
||||||
|
<h2>Participants</h2>
|
||||||
|
<div id="participants"></div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<div id="matchlist"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bubbles -->
|
||||||
|
<div class="bg-bubble b1"></div>
|
||||||
|
<div class="bg-bubble b2"></div>
|
||||||
|
<div class="bg-bubble b3"></div>
|
||||||
|
<div class="bg-bubble b4"></div>
|
||||||
|
<div class="bg-bubble b5"></div>
|
||||||
|
|
||||||
|
<script src="/assets/api.js"></script>
|
||||||
|
<script src="/assets/tournament.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "supersunday_prod_1.5",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<nav class="nav"><div class="inner">
|
|
||||||
<img src="/assets/img/logo.svg" width="22" height="22" alt="logo"/>
|
|
||||||
<div class="brand"><span class="badge">P24P</span><span class="title">Padel24Play</span></div>
|
|
||||||
<a href="/">Accueil</a><a href="/events.html">Événements</a><a href="/admin.html">Admin</a>
|
|
||||||
<div class="spacer"></div><a href="/reglement.html">Règlement</a></div></nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<h1>🛠️ Admin — Inscriptions</h1>
|
|
||||||
<div class="card">
|
|
||||||
<div class="row"><input id="ev" placeholder="eventId (ex: ss-futur-1)" value="ss-futur-1"/><button class="btn" id="loadRegs">Charger</button></div>
|
|
||||||
<table id="regs"><thead><tr><th>Joueur</th><th>Email</th><th>Paiement</th><th>Statut</th></tr></thead><tbody></tbody></table>
|
|
||||||
<p class="notice">Version prod de base — paiements temps réel & effets à venir.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script src="/assets/js/app.js"></script>
|
|
||||||
<script>
|
|
||||||
document.getElementById('loadRegs').onclick = async ()=>{
|
|
||||||
const evId = document.getElementById('ev').value.trim();
|
|
||||||
const list = await api('/api/registrations?eventId='+encodeURIComponent(evId));
|
|
||||||
const tbody = document.querySelector('#regs tbody'); tbody.innerHTML='';
|
|
||||||
list.forEach(r=>{
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.innerHTML = `<td>${r.player?.name||'-'}</td><td>${r.player?.email||'-'}</td><td>${r.payment} (${r.paymentStatus||'-'})</td><td>${r.status}</td>`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
:root{ color-scheme:dark; --bg:#0a0f1f; --card:rgba(255,255,255,.06); --line:rgba(255,255,255,.12); --txt:#eaf2ff; --acc:#27b0ff; --good:#22c55e; --bad:#ef4444 }
|
|
||||||
*{ box-sizing:border-box }
|
|
||||||
html,body{ margin:0; padding:0; background:radial-gradient(1000px 600px at 10% -10%, #122040 0%, transparent 60%), var(--bg); color:var(--txt); font-family: ui-sans-serif,system-ui,Segoe UI,Roboto,Helvetica,Arial }
|
|
||||||
a{ color:#9ad1ff; text-decoration:none }
|
|
||||||
.container{ max-width:1100px; margin:0 auto; padding:24px }
|
|
||||||
.nav{ position:sticky; top:0; z-index:10; backdrop-filter: blur(10px); background: rgba(6,12,24,.6); border-bottom:1px solid var(--line) }
|
|
||||||
.nav .inner{ display:flex; gap:16px; align-items:center; padding:12px 20px }
|
|
||||||
.brand{ display:flex; gap:10px; align-items:center; font-weight:900; letter-spacing:.3px }
|
|
||||||
.badge{ background:linear-gradient(135deg,#0ea5e9,#2563eb); padding:6px 10px; border-radius:10px; font-size:12px; font-weight:800; color:white }
|
|
||||||
.spacer{ flex:1 }
|
|
||||||
.btn{ background:linear-gradient(135deg,#0ea5e9,#2563eb); color:white; padding:10px 14px; border:0; border-radius:12px; cursor:pointer; font-weight:700; box-shadow:0 10px 24px rgba(37,99,235,.25) }
|
|
||||||
.row{ display:flex; gap:14px; flex-wrap:wrap }
|
|
||||||
.card{ background:var(--card); border:1px solid var(--line); border-radius:16px; padding:16px }
|
|
||||||
h1{ font-size:28px; margin:18px 0 }
|
|
||||||
table{ width:100%; border-collapse: collapse }
|
|
||||||
th,td{ border-top:1px solid var(--line); padding:10px 8px; text-align:left }
|
|
||||||
small{ opacity:.8 }
|
|
||||||
.hero{ display:grid; grid-template-columns: 1.2fr .8fr; gap:22px; align-items:center }
|
|
||||||
@media (max-width:900px){ .hero{ grid-template-columns:1fr } }
|
|
||||||
.pill{ border:1px solid var(--line); border-radius:999px; padding:6px 10px; display:inline-flex; gap:6px; align-items:center }
|
|
||||||
input,select{ background:#0f1b36; color:var(--txt); border:1px solid var(--line); border-radius:10px; padding:10px 12px; width:100% }
|
|
||||||
label{ font-size:13px; opacity:.9 }
|
|
||||||
.grid{ display:grid; gap:12px }
|
|
||||||
.grid-2{ grid-template-columns: 1fr 1fr }
|
|
||||||
.grid-3{ grid-template-columns: repeat(3, 1fr) }
|
|
||||||
footer{ margin-top:40px; opacity:.7; font-size:13px; border-top:1px solid var(--line); padding-top:14px }
|
|
||||||
.notice{ padding:10px 12px; border:1px dashed var(--line); border-radius:12px; background: rgba(255,255,255,.04) }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
||||||
<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#0ea5e9"/><stop offset="1" stop-color="#2563eb"/></linearGradient></defs>
|
|
||||||
<circle cx="32" cy="32" r="30" fill="url(#g)"/>
|
|
||||||
<circle cx="32" cy="32" r="18" fill="none" stroke="white" stroke-width="4" stroke-dasharray="4 6"/>
|
|
||||||
<rect x="20" y="26" width="24" height="12" rx="6" fill="white" opacity="0.9"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 449 B |
@@ -1,12 +0,0 @@
|
|||||||
async function api(path, opts={}){
|
|
||||||
const res = await fetch(path, { headers:{ 'content-type':'application/json' }, ...opts });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const ct = res.headers.get('content-type')||'';
|
|
||||||
return ct.includes('application/json')? res.json() : res.text();
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', async ()=>{
|
|
||||||
try {
|
|
||||||
const cfg = await api('/api/config');
|
|
||||||
const el = document.querySelector('.title'); if (el) el.textContent = cfg.siteName || 'Padel24Play';
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<nav class="nav"><div class="inner">
|
|
||||||
<img src="/assets/img/logo.svg" width="22" height="22" alt="logo"/>
|
|
||||||
<div class="brand"><span class="badge">P24P</span><span class="title">Padel24Play</span></div>
|
|
||||||
<a href="/">Accueil</a><a href="/events.html">Événements</a><a href="/admin.html">Admin</a>
|
|
||||||
<div class="spacer"></div><a href="/reglement.html">Règlement</a></div></nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<h1>📅 Événements</h1>
|
|
||||||
<div class="card">
|
|
||||||
<table id="eventsTable"><thead><tr><th>Nom</th><th>Type</th><th>Date</th><th>Lieu</th><th></th></tr></thead><tbody></tbody></table>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script src="/assets/js/app.js"></script>
|
|
||||||
<script>
|
|
||||||
(async()=>{ const rows=document.querySelector('#eventsTable tbody'); const list = await api('/api/events');
|
|
||||||
list.forEach(ev=>{ const tr=document.createElement('tr'); tr.innerHTML = `<td>${ev.name}</td><td>${ev.kind||'-'}</td><td>${new Date(ev.date).toLocaleString()}</td><td>${ev.location}</td>
|
|
||||||
<td><a class="btn" href="/register.html?eventId=${encodeURIComponent(ev.id)}">S'inscrire</a></td>`; rows.appendChild(tr); });
|
|
||||||
})();</script>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<nav class="nav"><div class="inner">
|
|
||||||
<img src="/assets/img/logo.svg" width="22" height="22" alt="logo"/>
|
|
||||||
<div class="brand"><span class="badge">P24P</span><span class="title">Padel24Play</span></div>
|
|
||||||
<a href="/">Accueil</a><a href="/events.html">Événements</a><a href="/admin.html">Admin</a>
|
|
||||||
<div class="spacer"></div><a href="/reglement.html">Règlement</a></div></nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<section class="hero">
|
|
||||||
<div>
|
|
||||||
<h1>🎉 SuperSunday — V1 (prod)</h1>
|
|
||||||
<p>Base propre prête à tester (port 8080) — événements, inscriptions, iCal.</p>
|
|
||||||
<div class="row" style="margin-top:16px">
|
|
||||||
<a class="btn" href="/events.html">Voir les événements</a>
|
|
||||||
<a class="btn" href="/admin.html">Admin</a>
|
|
||||||
</div>
|
|
||||||
<p class="notice" style="margin-top:12px">Version sans effets “waouw” pour rester simple. On les ajoutera en brique.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Ce qui est dedans</h3>
|
|
||||||
<ul>
|
|
||||||
<li>8 éditions Winter (tous les 14 jours dès dim. 5 oct. 2025, 08:00–11:00)</li>
|
|
||||||
<li>Un live démo (pour les inscriptions) et un futur ouvert</li>
|
|
||||||
<li>Formulaire d’inscription + iCal</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script src="/assets/js/app.js"></script>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<nav class="nav"><div class="inner">
|
|
||||||
<img src="/assets/img/logo.svg" width="22" height="22" alt="logo"/>
|
|
||||||
<div class="brand"><span class="badge">P24P</span><span class="title">Padel24Play</span></div>
|
|
||||||
<a href="/">Accueil</a><a href="/events.html">Événements</a><a href="/admin.html">Admin</a>
|
|
||||||
<div class="spacer"></div><a href="/reglement.html">Règlement</a></div></nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<h1>📝 Inscription</h1>
|
|
||||||
<div class="card">
|
|
||||||
<form id="form" class="grid grid-2">
|
|
||||||
<div><label>Nom complet</label><input name="name" required/></div>
|
|
||||||
<div><label>Email</label><input name="email" type="email" required/></div>
|
|
||||||
<div><label>Téléphone</label><input name="phone" required/></div>
|
|
||||||
<div><label>Niveau</label><select name="level"><option>Débutant</option><option>Intermédiaire</option><option>Avancé</option></select></div>
|
|
||||||
<div><label>Paiement</label><select name="payment"><option value="card">Stripe</option><option value="paypal">PayPal</option><option value="onspot">Sur place</option></select></div>
|
|
||||||
<div><button class="btn">Valider</button></div>
|
|
||||||
</form>
|
|
||||||
<p id="msg" class="notice"></p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script src="/assets/js/app.js"></script>
|
|
||||||
<script>
|
|
||||||
const params = new URLSearchParams(location.search); const eventId = params.get('eventId')||'ss-futur-1';
|
|
||||||
document.getElementById('form').addEventListener('submit', async (e)=>{
|
|
||||||
e.preventDefault();
|
|
||||||
const f = new FormData(e.target);
|
|
||||||
const reg = { eventId, player:{ name:f.get('name'), email:f.get('email'), phone:f.get('phone'), level:f.get('level') }, payment:f.get('payment') };
|
|
||||||
const created = await fetch('/api/registrations', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(reg) }).then(r=>r.json());
|
|
||||||
document.getElementById('msg').innerHTML = "Inscription créée ✔️ — "+
|
|
||||||
"<a class='btn' href='/api/ical/"+created.id+"'>Télécharger iCal</a>";
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<nav class="nav"><div class="inner">
|
|
||||||
<img src="/assets/img/logo.svg" width="22" height="22" alt="logo"/>
|
|
||||||
<div class="brand"><span class="badge">P24P</span><span class="title">Padel24Play</span></div>
|
|
||||||
<a href="/">Accueil</a><a href="/events.html">Événements</a><a href="/admin.html">Admin</a>
|
|
||||||
<div class="spacer"></div><a href="/reglement.html">Règlement</a></div></nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<h1>📜 Règlement (extrait)</h1>
|
|
||||||
<div class="card"><p>Americano, fair‑play, BYE équitables, remboursement jusqu’à J‑2 (48h), iCal après inscription.</p></div>
|
|
||||||
</main>
|
|
||||||
19
run_local.sh
Executable file
19
run_local.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
echo "=== Super Sunday — lancement local (env=.env.prod) ==="
|
||||||
|
|
||||||
|
if [ ! -f ".env.prod" ]; then
|
||||||
|
echo "[erreur] .env.prod introuvable dans $(pwd)"
|
||||||
|
echo "Crée-le ou copie-le depuis ton zip."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose --env-file .env.prod up -d --build
|
||||||
|
sleep 5
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Frontend : http://localhost:8080"
|
||||||
|
echo "Admin : http://localhost:8080/admin"
|
||||||
|
echo "Logs API : docker compose logs -f api"
|
||||||
|
echo "Stop : docker compose down"
|
||||||
146
server.mjs
146
server.mjs
@@ -1,146 +0,0 @@
|
|||||||
import http from 'http';
|
|
||||||
import { readFile, writeFile, stat } from 'fs/promises';
|
|
||||||
import { createReadStream } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import url from 'url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
||||||
const PORT = process.env.PORT || 8080;
|
|
||||||
const DATA_DIR = path.join(__dirname, 'data');
|
|
||||||
const DB_PATH = path.join(DATA_DIR, 'db.json');
|
|
||||||
|
|
||||||
const mime = { '.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.ico':'image/x-icon','.webp':'image/webp' };
|
|
||||||
|
|
||||||
function send(res, code, body, headers={}){ res.writeHead(code, { 'content-type':'text/plain; charset=utf-8', ...headers }); res.end(body); }
|
|
||||||
|
|
||||||
async function ensureDb(){
|
|
||||||
try { await stat(DB_PATH); }
|
|
||||||
catch {
|
|
||||||
const base = new Date(Date.UTC(2025, 9, 5, 8, 0, 0)); // 5 Oct 2025 08:00 UTC
|
|
||||||
const events = [];
|
|
||||||
for (let i=0;i<8;i++){
|
|
||||||
const start = new Date(base.getTime() + i*14*24*3600*1000);
|
|
||||||
const end = new Date(start.getTime() + 3*3600*1000);
|
|
||||||
events.push({ id:`ss-winter-${i+1}`, kind:"Americano", name:`SuperSunday — Winter Edition #${i+1}`, date:start.toISOString().slice(0,19), end:end.toISOString().slice(0,19), location:"Sport City Woluwe" });
|
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
events.push(
|
|
||||||
{ id:"ss-live-now", kind:"Americano", name:"SuperSunday — LIVE (démo)", date: new Date(now.getTime()-30*60*1000).toISOString().slice(0,19), end: new Date(now.getTime()+2.5*3600*1000).toISOString().slice(0,19), location:"TC Églantiers" },
|
|
||||||
{ id:"ss-futur-1", kind:"Americano", name:"SuperSunday — Inscription ouverte", date: new Date(now.getTime()+10*24*3600*1000).toISOString().slice(0,19), end: new Date(now.getTime()+10*24*3600*1000+3*3600*1000).toISOString().slice(0,19), location:"Sport City Woluwe" }
|
|
||||||
);
|
|
||||||
const names = ["SmashQueen","PadelKing42","MrVibora","LaBandeja","MissChiquita","VoléeMagique"];
|
|
||||||
const regs = names.map((n,i)=>({
|
|
||||||
id:`reg_seed_${i+1}`, createdAt:new Date().toISOString(), eventId: i%2===0? "ss-live-now" : "ss-winter-1",
|
|
||||||
player:{ name:n, email:`${n.replace(/[^a-z0-9]/ig,'').toLowerCase()}@demo.local`, phone:`+3247${(1000000+i).toString().padStart(7,'0')}`, level: i%3===0? "Avancé": (i%3===1? "Intermédiaire":"Débutant") },
|
|
||||||
payment:i%3===0? "card": (i%3===1? "paypal":"onspot"), status: i%2===0? "confirmé":"en_attente", paymentStatus: i%2===0? "paid":"pending"
|
|
||||||
}));
|
|
||||||
await writeFile(DB_PATH, JSON.stringify({
|
|
||||||
config:{ siteName:"Padel24Play — V1 (8080)", currency:"EUR", priceEur:48, refundWindowHours:48 },
|
|
||||||
users:[
|
|
||||||
{ email:"admin@supersunday.com", password:"password123", role:"admin", name:"Admin"},
|
|
||||||
{ email:"player@supersunday.com", password:"password123", role:"user", name:"Player"}
|
|
||||||
],
|
|
||||||
events, registrations: regs
|
|
||||||
}, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function loadDb(){ await ensureDb(); return JSON.parse(await readFile(DB_PATH,'utf-8')); }
|
|
||||||
async function saveDb(d){ await writeFile(DB_PATH, JSON.stringify(d,null,2)); }
|
|
||||||
|
|
||||||
function sendJson(res, obj){ send(res, 200, JSON.stringify(obj), {'content-type':'application/json'}); }
|
|
||||||
|
|
||||||
async function parseBody(req){
|
|
||||||
return new Promise((resolve,reject)=>{
|
|
||||||
let data=''; req.on('data',c=> data+=c); req.on('end', ()=>{
|
|
||||||
try{
|
|
||||||
const ct = req.headers['content-type']||'';
|
|
||||||
if (!data) return resolve({});
|
|
||||||
if (ct.includes('application/json')) return resolve(JSON.parse(data));
|
|
||||||
if (ct.includes('application/x-www-form-urlencoded')){
|
|
||||||
const out={}; data.split('&').forEach(kv=>{ const [k,v] = kv.split('='); out[decodeURIComponent(k)] = decodeURIComponent((v||'').replace(/\\+/g,' ')); });
|
|
||||||
return resolve(out);
|
|
||||||
}
|
|
||||||
resolve({ raw:data });
|
|
||||||
}catch(e){ reject(e); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function serveStatic(req,res){
|
|
||||||
const publicDir = path.join(__dirname, 'public');
|
|
||||||
let pathname = url.parse(req.url).pathname || '/';
|
|
||||||
if (pathname === '/') pathname = '/index.html';
|
|
||||||
const filePath = path.join(publicDir, pathname);
|
|
||||||
try {
|
|
||||||
const st = await stat(filePath);
|
|
||||||
if (!st.isDirectory()){
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
|
||||||
res.writeHead(200, {'content-type': mime[ext] || 'application/octet-stream'});
|
|
||||||
createReadStream(filePath).pipe(res);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}catch{}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoToV(s){ return s.replace(/[-:]/g,'').replace('T','')+'Z'; }
|
|
||||||
|
|
||||||
async function handleApi(req,res){
|
|
||||||
const { pathname, query } = url.parse(req.url, true);
|
|
||||||
|
|
||||||
if (pathname === '/api/config' && req.method==='GET'){
|
|
||||||
const d = await loadDb(); return sendJson(res, d.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/api/events' && req.method==='GET'){
|
|
||||||
const d = await loadDb(); return sendJson(res, d.events);
|
|
||||||
}
|
|
||||||
if (pathname === '/api/registrations' && req.method==='GET'){
|
|
||||||
const d = await loadDb(); const list = query.eventId ? d.registrations.filter(r=> r.eventId===query.eventId) : d.registrations;
|
|
||||||
return sendJson(res, list);
|
|
||||||
}
|
|
||||||
if (pathname === '/api/registrations' && req.method==='POST'){
|
|
||||||
const d = await loadDb(); const body = await parseBody(req);
|
|
||||||
const id = 'reg_'+Math.random().toString(36).slice(2,10);
|
|
||||||
const rec = { id, createdAt:new Date().toISOString(), status:'en_attente', paymentStatus:'pending', ...body };
|
|
||||||
d.registrations.push(rec); await saveDb(d);
|
|
||||||
return sendJson(res, rec);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith('/api/ical/') && req.method==='GET'){
|
|
||||||
const regId = pathname.split('/').pop();
|
|
||||||
const d = await loadDb(); const reg = d.registrations.find(r=> r.id===regId);
|
|
||||||
if (!reg) return send(res,404,'Not found');
|
|
||||||
const ev = d.events.find(e=> e.id===reg.eventId);
|
|
||||||
if (!ev) return send(res,404,'Event not found');
|
|
||||||
const ics = [
|
|
||||||
'BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//P24P//SuperSunday//FR',
|
|
||||||
'BEGIN:VEVENT',
|
|
||||||
`UID:${reg.id}@p24p`,
|
|
||||||
`DTSTAMP:${isoToV(ev.date)}`,
|
|
||||||
`DTSTART:${isoToV(ev.date)}`,
|
|
||||||
`DTEND:${isoToV(ev.end)}`,
|
|
||||||
`SUMMARY:${ev.name}`,
|
|
||||||
`LOCATION:${ev.location}`,
|
|
||||||
`DESCRIPTION:Inscription ${reg.player?.name||''}`,
|
|
||||||
'END:VEVENT','END:VCALENDAR'
|
|
||||||
].join('\\r\\n');
|
|
||||||
return send(res,200,ics,{'content-type':'text/calendar; charset=utf-8','content-disposition':`attachment; filename="${reg.id}.ics"`});
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (req,res)=>{
|
|
||||||
try{
|
|
||||||
if (req.url.startsWith('/api/')){
|
|
||||||
const ok = await handleApi(req,res);
|
|
||||||
if (ok===false) return send(res,404,'API not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ok = await serveStatic(req,res);
|
|
||||||
if (!ok) send(res,404,'Not found');
|
|
||||||
}catch(e){ console.error(e); send(res,500,'Server error'); }
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, ()=> console.log(`✅ SuperSunday V1 (prod) http://localhost:${PORT}`));
|
|
||||||
5
start.sh
5
start.sh
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
echo "🚀 SuperSunday V1 (prod) on :8080"
|
|
||||||
export PORT=8080
|
|
||||||
export ENABLE_EFFECTS=0
|
|
||||||
node server.mjs
|
|
||||||
25
sync.sh
25
sync.sh
@@ -1,25 +0,0 @@
|
|||||||
{\rtf1\ansi\ansicpg1252\cocoartf2822
|
|
||||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .AppleSystemUIFontMonospaced-Regular;}
|
|
||||||
{\colortbl;\red255\green255\blue255;\red135\green5\blue129;\red0\green0\blue0;\red181\green0\blue19;
|
|
||||||
\red50\green91\blue97;\red13\green100\blue1;\red151\green0\blue126;}
|
|
||||||
{\*\expandedcolortbl;;\cssrgb\c60784\c13725\c57647;\csgray\c0;\cssrgb\c76863\c10196\c8627;
|
|
||||||
\cssrgb\c24706\c43137\c45490;\cssrgb\c0\c45490\c0;\cssrgb\c66667\c5098\c56863;}
|
|
||||||
\paperw11900\paperh16840\margl1440\margr1440\vieww11520\viewh8400\viewkind0
|
|
||||||
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
|
|
||||||
|
|
||||||
\f0\fs26 \cf2 #!/bin/bash\cf3 \
|
|
||||||
set -e\
|
|
||||||
MSG=\cf4 "\cf5 $\{1:-chore: quick sync\}\cf4 "\cf3 \
|
|
||||||
\
|
|
||||||
\cf6 # S\'92assure qu\'92on est dans un repo\cf3 \
|
|
||||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || \{ echo \cf4 "Pas un d\'e9p\'f4t Git"\cf3 ; exit 1; \}\
|
|
||||||
\
|
|
||||||
\cf6 # Ajoute, commit et push\cf3 \
|
|
||||||
git add -A\
|
|
||||||
\cf7 if\cf3 ! git diff --cached --quiet; \cf7 then\cf3 \
|
|
||||||
git commit -m \cf4 "\cf5 $MSG\cf4 "\cf3 \
|
|
||||||
\cf7 else\cf3 \
|
|
||||||
echo \cf4 "Rien \'e0 committer."\cf3 \
|
|
||||||
\cf7 fi\cf3 \
|
|
||||||
git push\
|
|
||||||
echo \cf4 "\uc0\u9989 Sync OK"}
|
|
||||||
16
sync2.sh
16
sync2.sh
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
MSG="${1:-chore: quick sync}"
|
|
||||||
|
|
||||||
# S’assure qu’on est dans un repo
|
|
||||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "Pas un dépôt Git"; exit 1; }
|
|
||||||
|
|
||||||
# Ajoute, commit et push
|
|
||||||
git add -A
|
|
||||||
if ! git diff --cached --quiet; then
|
|
||||||
git commit -m "$MSG"
|
|
||||||
else
|
|
||||||
echo "Rien à committer."
|
|
||||||
fi
|
|
||||||
git push
|
|
||||||
echo "✅ Sync OK"
|
|
||||||
Reference in New Issue
Block a user