diff --git a/backend/.env.example b/backend/.env.example index 2ca9d51..08da568 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,7 +7,6 @@ JWT_SECRET="your_secret_key" APP_URL="http://localhost:5000" MAIL_HOST="smtp.gmail.com" MAIL_PORT="587" -MAIL_USER="engongomo.emma27@gmail.com" -MAIL_PASS="rczo zqps hsnm msdm" -MAIL_FROM="Thé Tip Top " - +MAIL_USER="your_email@gmail.com" +MAIL_PASS="your_app_password" +MAIL_FROM="Thé Tip Top " diff --git a/backend/prisma/migrations/20260721093615_add_game_settings/migration.sql b/backend/prisma/migrations/20260721093615_add_game_settings/migration.sql new file mode 100644 index 0000000..7146bc8 --- /dev/null +++ b/backend/prisma/migrations/20260721093615_add_game_settings/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "GameSettings" ( + "id" SERIAL NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GameSettings_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a2f3543..7ab6470 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -116,3 +116,12 @@ model GrandPrizeWinner { prize String @default("Un an de thé d'une valeur de 360€") drawnAt DateTime @default(now()) } + +// Ligne unique de configuration : date de début du jeu-concours. +// Participation et réclamation des lots sont autorisées pendant 60 jours +// (30 jours de jeu + 30 jours supplémentaires) à compter de startDate. +model GameSettings { + id Int @id @default(autoincrement()) + startDate DateTime @default(now()) + createdAt DateTime @default(now()) +} diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index 206ba20..370608d 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -39,6 +39,14 @@ async function main() { }); console.log("Lots créés avec succès"); + + const existingSettings = await prisma.gameSettings.findFirst(); + if (!existingSettings) { + await prisma.gameSettings.create({ data: {} }); + console.log("Date de début du jeu-concours initialisée à aujourd'hui."); + } else { + console.log("Date de début du jeu-concours déjà définie, inchangée."); + } } main() diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 2260d3c..8651bf0 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -1,8 +1,41 @@ const bcrypt = require("bcrypt"); const prisma = require("../config/prisma"); +const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); + +const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; + +function getAgeBracket(birthdate) { + if (!birthdate) return "unknown"; + + const now = new Date(); + let age = now.getFullYear() - birthdate.getFullYear(); + const hasHadBirthdayThisYear = + now.getMonth() > birthdate.getMonth() || + (now.getMonth() === birthdate.getMonth() && now.getDate() >= birthdate.getDate()); + if (!hasHadBirthdayThisYear) age -= 1; + + if (age < 18) return "-18"; + if (age <= 24) return "18-24"; + if (age <= 34) return "25-34"; + if (age <= 44) return "35-44"; + if (age <= 54) return "45-54"; + if (age <= 64) return "55-64"; + return "65+"; +} + +function computeAgeStats(users) { + const counts = Object.fromEntries([...AGE_BRACKETS, "unknown"].map((b) => [b, 0])); + for (const user of users) { + counts[getAgeBracket(user.birthdate)] += 1; + } + return counts; +} const getStats = async (req, res) => { try { + await expireOverdueClaims(); + const gameWindow = await getGameWindow(); + const [ ticketsTotal, ticketsUsed, @@ -13,6 +46,7 @@ const getStats = async (req, res) => { users, men, women, + clientsWithBirthdate, ] = await Promise.all([ prisma.ticket.count(), prisma.ticket.count({ where: { isUsed: true } }), @@ -23,6 +57,7 @@ const getStats = async (req, res) => { prisma.user.count({ where: { role: "CLIENT" } }), prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }), prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }), + prisma.user.findMany({ where: { role: "CLIENT" }, select: { birthdate: true } }), ]); const lotsStats = await prisma.lot.findMany({ @@ -48,6 +83,8 @@ const getStats = async (req, res) => { claimsDelivered, claimsCancelled, users, + gameWindow, + ageStats: computeAgeStats(clientsWithBirthdate), genderStats: { men, women, diff --git a/backend/src/controllers/employee.controller.js b/backend/src/controllers/employee.controller.js index be501ac..7dff989 100644 --- a/backend/src/controllers/employee.controller.js +++ b/backend/src/controllers/employee.controller.js @@ -1,9 +1,12 @@ const prisma = require("../config/prisma"); +const { expireOverdueClaims } = require("../utils/gameWindow"); const getGainByTicketCode = async (req, res) => { try { const { code } = req.params; + await expireOverdueClaims(); + const ticket = await prisma.ticket.findUnique({ where: { code: code.toUpperCase() }, include: { @@ -66,6 +69,8 @@ const deliverClaim = async (req, res) => { const { id } = req.params; const employeeId = req.user.id; + await expireOverdueClaims(); + const claim = await prisma.claim.findUnique({ where: { id: Number(id) }, }); diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 5477fca..31f3345 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -1,10 +1,18 @@ const prisma = require("../config/prisma"); +const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); const participate = async (req, res) => { try { const { code } = req.body; const userId = req.user.id; + const gameWindow = await getGameWindow(); + if (!gameWindow.isOpen) { + return res.status(410).json({ + message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.", + }); + } + const ticket = await prisma.ticket.findUnique({ where: { code }, include: { @@ -83,6 +91,8 @@ const myGains = async (req, res) => { try { const userId = req.user.id; + await expireOverdueClaims(); + const participations = await prisma.participation.findMany({ where: { userId }, include: { diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js new file mode 100644 index 0000000..eaa34f1 --- /dev/null +++ b/backend/src/utils/gameWindow.js @@ -0,0 +1,51 @@ +const prisma = require("../config/prisma"); + +const GAME_DURATION_DAYS = 30; +const CLAIM_EXTENSION_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +// Fenêtre pendant laquelle un joueur peut à la fois tester son code et +// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter +// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré +// comme non démarré et donc ouvert (comportement de dev par défaut). +async function getGameWindow() { + const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + + if (!settings) { + return { + configured: false, + startDate: null, + participationDeadline: null, + claimDeadline: null, + isOpen: true, + }; + } + + const participationDeadline = new Date(settings.startDate.getTime() + GAME_DURATION_DAYS * DAY_MS); + const claimDeadline = new Date( + settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS + ); + + return { + configured: true, + startDate: settings.startDate, + participationDeadline, + claimDeadline, + isOpen: new Date() <= claimDeadline, + }; +} + +// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre +// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture +// de claims, ce qui suffit pour ce projet. +async function expireOverdueClaims() { + const window = await getGameWindow(); + if (window.isOpen) return; + + await prisma.claim.updateMany({ + where: { status: "PENDING" }, + data: { status: "CANCELLED" }, + }); +} + +module.exports = { getGameWindow, expireOverdueClaims }; diff --git a/frontend/index.html b/frontend/index.html index a9d49dd..ee46fd3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,9 +1,10 @@ - + + Jeu concours - Thé Tip Top diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e04d3ba..afcc658 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,6 @@ import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' import Accueil from './pages/Accueil' import ReglesJeu from './pages/ReglesJeu' -import Participation from './pages/Participation' import MesGains from './pages/MesGains' import Employe from './pages/Employe' import Admin from './pages/Admin' @@ -34,7 +33,6 @@ export default function App() { {/* Pages publiques */} } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 67fe444..4272e81 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi } from '../services/admin' +import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin' import { searchTicketApi, deliverClaimApi } from '../services/employee' import { useHandleAuthError } from '../hooks/useHandleAuthError' @@ -40,6 +40,11 @@ export default function Admin() { const [ticketDeliverErr, setTicketDeliverErr] = useState('') const [ticketSuccess, setTicketSuccess] = useState(false) + // Contacts emailing + const [emailingContacts, setEmailingContacts] = useState(null) + const [emailingLoading, setEmailingLoading] = useState(false) + const [emailingError, setEmailingError] = useState('') + useEffect(() => { Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()]) .then(([s, u, w]) => { @@ -67,6 +72,37 @@ export default function Admin() { } } + const handleLoadEmailingContacts = async () => { + setEmailingLoading(true) + setEmailingError('') + try { + const data = await getEmailingContactsApi() + setEmailingContacts(data.contacts) + } catch (e) { + if (!handleAuthError(e)) setEmailingError(e.message) + } finally { + setEmailingLoading(false) + } + } + + const handleExportEmailingCsv = () => { + if (!emailingContacts?.length) return + const header = ['Prénom', 'Nom', 'Email', 'Inscrit le'] + const rows = emailingContacts.map((c) => [ + c.firstname, c.lastname, c.email, new Date(c.createdAt).toLocaleDateString('fr-FR'), + ]) + const csv = [header, ...rows] + .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';')) + .join('\n') + const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `contacts-emailing-${new Date().toISOString().slice(0, 10)}.csv` + link.click() + URL.revokeObjectURL(url) + } + const handleTicketSearch = async (e) => { e.preventDefault() setTicketResult(null) @@ -127,6 +163,25 @@ export default function Admin() {

Tableau de bord Admin

+ {/* Fenêtre du jeu-concours */} + {stats?.gameWindow?.configured && ( +
+
+
+

+ Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'} +

+

+ Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)} +

+
+ + {stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'} + +
+
+ )} + {/* Stats principales */}
@@ -142,6 +197,39 @@ export default function Admin() {
+ {/* Stats démographiques */} + {stats?.genderStats && stats?.ageStats && ( +
+
+

Genre des participants

+
+ {[ + { label: 'Femmes', value: stats.genderStats.women }, + { label: 'Hommes', value: stats.genderStats.men }, + { label: 'Autre / non renseigné', value: stats.genderStats.other }, + ].map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+ +
+

Âge des participants

+
+ {['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => ( +
+ {bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`} + {stats.ageStats[bracket] ?? 0} +
+ ))} +
+
+
+ )} + {/* Répartition par lot */} {stats?.lotsStats?.length > 0 && (
@@ -171,13 +259,80 @@ export default function Admin() {
)} + {/* ── Contacts emailing ── */} +
+
+

Contacts emailing

+
+ + {emailingContacts?.length > 0 && ( + + )} +
+
+ + {emailingError &&

{emailingError}

} + +

+ Clients ayant accepté d'être contactés à des fins marketing. + {emailingContacts && <> — {emailingContacts.length} contact(s)} +

+ + {emailingContacts && ( +
+ + + + + + + + + + {emailingContacts.length === 0 && ( + + + + )} + {emailingContacts.map((contact) => ( + + + + + + ))} + +
NomEmailInscrit le
+ Aucun contact n'a accepté l'emailing pour le moment +
+ {contact.firstname} {contact.lastname} + {contact.email} + {new Date(contact.createdAt).toLocaleDateString('fr-FR')} +
+
+ )} +
+ {/* ── Remise de lot ── */}

Remise de lot

+ setTicketCode(e.target.value.toUpperCase())} placeholder="Code ticket (10 caractères)" diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index acf2a7f..3a9b1d2 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -55,11 +55,12 @@ export default function Connexion() {
-