merge: integrate Alexis and Emmanuel latest changes with CI/CD

This commit is contained in:
2026-08-17 14:14:34 +00:00
69 changed files with 6382 additions and 735 deletions

View File

@@ -4,9 +4,9 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="your_secret_key"
APP_URL=http://localhost:5000
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USER=yourmail_adress@gmail.com
MAIL_PASS=_______________________
MAIL_FROM="Thé Tip Top <yourmail_adress@gmail.com>"
APP_URL="http://localhost:5000"
MAIL_HOST="smtp.gmail.com"
MAIL_PORT="587"
MAIL_USER="your_email@gmail.com"
MAIL_PASS="your_app_password"
MAIL_FROM="Thé Tip Top <your_email@gmail.com>"

View File

@@ -14,7 +14,7 @@
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.1",
"nodemailer": "^9.0.3",
"prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
@@ -605,22 +605,22 @@
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -630,9 +630,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -5487,9 +5487,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"

View File

@@ -21,7 +21,7 @@
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.1",
"nodemailer": "^9.0.3",
"prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",

View File

@@ -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")
);

View File

@@ -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())
}

View File

@@ -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()

View File

@@ -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,

View File

@@ -34,8 +34,8 @@ const register = async (req, res) => {
const hashedPassword = await bcrypt.hash(password, 10);
const emailVerifyToken = crypto.randomBytes(32).toString("hex");
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 60); // 1h
const emailVerifyToken = String(Math.floor(1000 + Math.random() * 9000));
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 15); // 15 min
const user = await prisma.user.create({
data: {
@@ -62,23 +62,25 @@ const register = async (req, res) => {
},
});
const verifyUrl = `${process.env.APP_URL}/api/auth/verify-email/${emailVerifyToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Validation de votre compte Thé Tip Top",
subject: "Votre code de validation Thé Tip Top",
html: `
<h2>Bienvenue ${firstname}</h2>
<p>Merci pour votre inscription.</p>
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p>
<a href="${verifyUrl}">Valider mon compte</a>
<p>Ce lien expire dans 1 heure.</p>
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Merci pour votre inscription sur Thé Tip Top.</p>
<p>Voici votre code de validation à 4 chiffres :</p>
<div style="font-size:36px;font-weight:bold;letter-spacing:12px;color:#006B6B;text-align:center;padding:20px 0">
${emailVerifyToken}
</div>
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
</div>
`,
});
return res.status(201).json({
message: "Inscription réussie. Un email de validation a été envoyé.",
message: "Inscription réussie. Un code de validation a été envoyé par email.",
user,
});
} catch (error) {
@@ -148,7 +150,7 @@ const login = async (req, res) => {
});
}
if (!user.isEmailVerified) {
if (!user.isEmailVerified && user.role === "CLIENT") {
return res.status(403).json({
message: "Veuillez valider votre adresse email avant de vous connecter.",
});
@@ -298,17 +300,144 @@ const resetPassword = async (req, res) => {
}
};
const verifyCode = async (req, res) => {
try {
const { email, code } = req.body;
if (!email || !code) {
return res.status(400).json({ message: "Email et code requis." });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(404).json({ message: "Compte introuvable." });
}
if (user.isEmailVerified) {
return res.status(400).json({ message: "Ce compte est déjà vérifié." });
}
if (!user.emailVerifyToken || user.emailVerifyToken !== String(code)) {
return res.status(400).json({ message: "Code invalide." });
}
if (new Date() > user.emailVerifyExpires) {
return res.status(400).json({ message: "Code expiré. Veuillez vous réinscrire." });
}
await prisma.user.update({
where: { id: user.id },
data: { isEmailVerified: true, emailVerifyToken: null, emailVerifyExpires: null },
});
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
return res.json({
message: "Compte vérifié avec succès.",
token,
user: {
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
},
});
} catch (error) {
console.error("Erreur verifyCode:", error);
return res.status(500).json({ message: "Erreur serveur lors de la vérification." });
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
});
};
const deleteAccount = async (req, res) => {
try {
const userId = req.user.id;
await prisma.$transaction(async (tx) => {
// 1. Supprimer le grand gagnant si c'est lui
await tx.grandPrizeWinner.deleteMany({ where: { userId } });
// 2. Récupérer les participations pour supprimer leurs claims
const participations = await tx.participation.findMany({
where: { userId },
select: { id: true },
});
const participationIds = participations.map((p) => p.id);
// 3. Supprimer les claims
if (participationIds.length > 0) {
await tx.claim.deleteMany({ where: { participationId: { in: participationIds } } });
}
// 4. Supprimer les participations
await tx.participation.deleteMany({ where: { userId } });
// 5. Libérer les tickets utilisés par ce user
await tx.ticket.updateMany({
where: { userId },
data: { userId: null, isUsed: false, usedAt: null },
});
// 6. Supprimer l'utilisateur
await tx.user.delete({ where: { id: userId } });
});
return res.json({ message: "Compte supprimé avec succès." });
} catch (error) {
console.error("Erreur deleteAccount:", error);
return res.status(500).json({ message: "Erreur serveur lors de la suppression du compte." });
}
};
const changePassword = async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
const userId = req.user.id;
if (!currentPassword || !newPassword) {
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
}
if (newPassword.length < 8) {
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
}
const hashed = await bcrypt.hash(newPassword, 10);
await prisma.user.update({ where: { id: userId }, data: { password: hashed } });
return res.json({ message: "Mot de passe modifié avec succès." });
} catch (error) {
console.error("Erreur changePassword:", error);
return res.status(500).json({ message: "Erreur serveur lors du changement de mot de passe." });
}
};
module.exports = {
register,
login,
me,
verifyEmail,
verifyCode,
resetPassword,
forgotPassword
forgotPassword,
changePassword,
deleteAccount,
};

View File

@@ -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) },
});

View File

@@ -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: {

View File

@@ -10,7 +10,7 @@ const options = {
},
servers: [
{
url: "http://localhost:5000",
url: process.env.API_PUBLIC_URL || "http://localhost:5000",
},
],
components: {

View File

@@ -1,5 +1,5 @@
const express = require("express");
const { register, login, verifyEmail, forgotPassword, resetPassword, me } = require("../controllers/auth.controller");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
@@ -157,5 +157,8 @@ router.post("/forgot-password", forgotPassword);
*/
router.post("/reset-password/:token", resetPassword);
router.post("/verify-code", verifyCode);
router.patch("/change-password", authMiddleware, changePassword);
router.delete("/account", authMiddleware, deleteAccount);
module.exports = router;

View File

@@ -0,0 +1,39 @@
const bcrypt = require('bcrypt')
const prisma = require('../config/prisma')
async function createAdmin() {
const email = 'admin@thetiptop.fr'
const password = 'Admin@TipTop2026!'
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
await prisma.user.update({
where: { email },
data: { isEmailVerified: true, role: 'ADMIN' },
})
console.log('Admin mis à jour :', email)
return
}
const hashed = await bcrypt.hash(password, 10)
await prisma.user.create({
data: {
firstname: 'Admin',
lastname: 'TipTop',
email,
password: hashed,
role: 'ADMIN',
isEmailVerified: true,
},
})
console.log('Admin créé avec succès')
console.log(' Email :', email)
console.log(' Mot de passe:', password)
}
createAdmin()
.catch((e) => { console.error('Erreur :', e.message); process.exit(1) })
.finally(() => prisma.$disconnect())

View File

@@ -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 };

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
frontend/Design/profil .png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,10 +1,11 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ttt-front</title>
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
<title>Jeu concours - Thé Tip Top</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^19.2.6",
@@ -17,6 +19,9 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -24,7 +29,9 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vitest": "^4.1.10"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,23 @@
User-agent: *
# Pages publiques autorisées
Allow: /
Allow: /accueil
Allow: /regles-jeu
Allow: /contact
Allow: /mentions-legales
Allow: /confidentialite
Allow: /sitemap
Allow: /inscription
Allow: /connexion
# Pages privées interdites aux robots
Disallow: /admin
Disallow: /employe
Disallow: /profile
Disallow: /mes-gains
Disallow: /saisir-code-unique
Disallow: /perdu
# Sitemap
Sitemap: https://thetiptop.fr/sitemap

View File

@@ -1,23 +1,85 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext.jsx'
import Layout from './components/Layout.jsx'
import ProtectedRoute from './components/ProtectedRoute.jsx'
import CookieBanner from './components/CookieBanner.jsx'
import Connexion from './pages/Connexion'
import Inscription from './pages/Inscription'
import Participation from './pages/Participation'
import Accueil from './pages/Accueil'
import ReglesJeu from './pages/ReglesJeu'
import MesGains from './pages/MesGains'
import Employe from './pages/Employe'
import Admin from './pages/Admin'
import Profile from './pages/Profile'
import SaisirCodeUnique from './pages/SaisirCodeUnique.jsx'
import Perdu from './pages/Perdu'
import NotFound from './pages/NotFound'
import Sitemap from './pages/Sitemap'
import MentionsLegales from './pages/MentionsLegales'
import Confidentialite from './pages/Confidentialite'
import Accessibilite from './pages/Accessibilite'
import Cgu from './pages/Cgu'
import Contact from './pages/Contact.jsx'
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/" element={<Navigate to="/connexion" replace />} />
<Route path="/" element={<Navigate to="/accueil" replace />} />
<Route path="/connexion" element={<Connexion />} />
<Route path="/inscription" element={<Inscription />} />
<Route path="/participation" element={<Participation />} />
<Route path="/mes-gains" element={<MesGains />} />
<Route path="/employe" element={<Employe />} />
<Route path="/admin" element={<Admin />} />
{/* Pages publiques */}
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
<Route path="/accessibilite" element={<Layout><Accessibilite /></Layout>} />
<Route path="/cgu" element={<Layout><Cgu /></Layout>} />
<Route path="/contact" element={<Layout><Contact /></Layout>} />
<Route path="/sitemap" element={<Layout><Sitemap /></Layout>} />
{/* Pages CLIENT */}
<Route path="/mes-gains" element={
<ProtectedRoute roles={['CLIENT']}>
<Layout><MesGains /></Layout>
</ProtectedRoute>
} />
<Route path="/saisir-code-unique" element={
<ProtectedRoute roles={['CLIENT']}>
<Layout><SaisirCodeUnique /></Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute roles={['CLIENT']}>
<Layout><Profile /></Layout>
</ProtectedRoute>
} />
<Route path="/perdu" element={
<ProtectedRoute roles={['CLIENT']}>
<Layout><Perdu /></Layout>
</ProtectedRoute>
} />
{/* Page EMPLOYEE */}
<Route path="/employe" element={
<ProtectedRoute roles={['EMPLOYEE']}>
<Layout><Employe /></Layout>
</ProtectedRoute>
} />
{/* Page ADMIN */}
<Route path="/admin" element={
<ProtectedRoute roles={['ADMIN']}>
<Layout><Admin /></Layout>
</ProtectedRoute>
} />
<Route path="*" element={<NotFound />} />
</Routes>
<CookieBanner />
</AuthProvider>
</BrowserRouter>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
export default function CookieBanner() {
const [visible, setVisible] = useState(() => !localStorage.getItem('cookieConsent'))
if (!visible) return null
const handleChoice = (choice) => {
localStorage.setItem('cookieConsent', choice)
setVisible(false)
}
return (
<div className="fixed bottom-0 left-0 right-0 z-50 px-4 py-4 bg-white border-t border-gray-200 shadow-lg">
<div className="max-w-5xl mx-auto flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1">
<p className="text-sm font-semibold text-gray-800 mb-1">Ce site utilise des cookies</p>
<p className="text-xs text-gray-500 leading-relaxed">
Nous utilisons des cookies essentiels au fonctionnement du site. En continuant votre navigation, vous acceptez leur utilisation conformément à notre{' '}
<Link to="/confidentialite" className="text-[#006B6B] hover:underline">politique de confidentialité</Link>.
</p>
</div>
<div className="flex gap-2 flex-shrink-0 w-full sm:w-auto">
<button
onClick={() => handleChoice('refused')}
className="flex-1 sm:flex-none border border-gray-300 text-gray-600 hover:bg-gray-50 text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
>
Refuser
</button>
<button
onClick={() => handleChoice('accepted')}
className="flex-1 sm:flex-none bg-[#006B6B] hover:bg-[#005959] text-white text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
>
Accepter
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function Footer() {
const { pathname } = useLocation()
const { user } = useAuth()
const isLoggedIn = !!user
function FooterLink({ to, children, className=''}) {
return pathname === to ? (
<span className={className}>{children}</span>
) : (
<Link to={to} className={className}>
{children}
</Link>
)
}
return (
<footer className="bg-[#006B6B] text-white">
{/* Partie principale */}
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
{/* Logo */}
<div>
<FooterLink to='/accueil'>
<h3 className="text-xl font-bold mb-3">
Thé Tip Top
</h3>
</FooterLink>
<p className="text-teal-100 text-sm leading-relaxed">
Participez au jeu-concours Thé Tip Top et tentez de gagner
des lots gourmands autour du thé.
</p>
</div>
{/* Navigation */}
<div>
<h3 className="text-lg font-semibold mb-5">
Navigation
</h3>
<ul className="space-y-3 text-teal-100">
<li>
<FooterLink
to="/accueil"
className="hover:text-white transition"
>
Accueil
</FooterLink>
</li>
<li>
<FooterLink
to="/regles-jeu"
className="hover:text-white transition"
>
Règles du jeu
</FooterLink>
</li>
<li>
<FooterLink
to="/saisir-code-unique"
className="hover:text-white transition"
>
Tester mon code
</FooterLink>
</li>
{ !isLoggedIn && (
<li>
<FooterLink
to="/inscription"
className="hover:text-white transition"
>
Créer un compte
</FooterLink>
</li>
)}
</ul>
</div>
{/* Informations légales */}
<div>
<h3 className="text-lg font-semibold mb-5">
Informations
</h3>
<ul className="space-y-3 text-teal-100">
<li>
<FooterLink
to="/mentions-legales"
className="hover:text-white transition"
>
Mentions légales
</FooterLink>
</li>
<li>
<FooterLink
to="/confidentialite"
className="hover:text-white transition"
>
Politique de confidentialité
</FooterLink>
</li>
<li>
<FooterLink
to="/accessibilite"
className="hover:text-white transition"
>
Accessibilité
</FooterLink>
</li>
<li>
<FooterLink
to="/cgu"
className="hover:text-white transition"
>
CGU
</FooterLink>
</li>
<li>
<FooterLink
to="/contact"
className="hover:text-white transition"
>
Contact
</FooterLink>
</li>
<li>
<FooterLink
to="/sitemap"
className="hover:text-white transition"
>
Plan du site
</FooterLink>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="text-lg font-semibold mb-5">
Contact
</h3>
<p className="text-teal-100 text-sm mb-3">
Une question sur le jeu ?
</p>
<a href="tel:+33184802034" className="block mt-2 font-medium hover:text-teal-300">
01 84 80 20 34
</a>
<a href="mailto:contact@thetiptop.fr" className="inline-block mt-3 font-medium hover:text-teal-300">
contact@thetiptop.fr
</a>
<p className="text-teal-100 text-sm mt-2">
18 rue Léon Frot
<br />
75011 Paris, France
</p>
</div>
</div>
{/* Barre basse */}
<div className="border-t border-teal-800">
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
<p className="text-teal-200 text-sm text-center md:text-left">
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
<br className="md:hidden" />
<span className="md:before:content-['_—_']">
Projet étudiant fictif — aucun achat ni réservation réel ne peut être effectué sur ce site.
</span>
</p>
<div className="flex gap-5 text-sm text-teal-200">
<a
href="https://www.facebook.com/profile.php?id=61590449280854"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition"
>
Facebook
</a>
<a
href="https://www.instagram.com/thetiptop.gp4?igsh=MXZ3YzZ0d3dkazBpeg%3D%3D&utm_source=qr"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition"
>
Instagram
</a>
<a
href="https://www.tiktok.com/@thetiptop4"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition"
>
TikTok
</a>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,151 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import logo from '../assets/img/logo.png'
import { useAuth } from '../context/AuthContext'
export default function Header() {
const [menuOpen, setMenuOpen] = useState(false)
const { pathname } = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const isLoggedIn = !!user
const logoContent = <img src={logo} alt="Logo de Thé Tip Top" className="h-10 sm:h-12 w-auto" />
const handleLogout = () => {
logout()
setMenuOpen(false)
navigate('/connexion')
}
const navLinks = [
{ to: '/accueil', label: 'Accueil' },
{ to: '/regles-jeu', label: 'Règles du jeu' },
]
const initials = user
? `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
: null
return (
<header className="bg-white shadow-sm relative z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
{pathname === '/accueil' ? (
<span className="flex items-center flex-shrink-0">
{logoContent}
</span>
) : (
<Link to ={'/accueil'} className='flex items-center flex-shrink-0'>
{logoContent}
</Link>
)}
{/* Nav desktop */}
<nav className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`font-medium transition-colors ${
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700 hover:text-[#006B6B]'
}`}
>
{link.label}
</Link>
))}
{isLoggedIn ? (
<div className="flex items-center gap-3">
<Link to="/profile" className="flex items-center gap-2 text-sm text-gray-700 hover:text-[#006B6B] font-medium transition-colors">
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold">
{initials}
</div>
<span>{user.firstname}</span>
</Link>
<button
onClick={handleLogout}
className="text-sm text-gray-400 hover:text-red-500 transition-colors font-medium"
>
Déconnexion
</button>
</div>
) : (
<div className="flex items-center gap-2">
<Link to="/connexion" className="bg-[#006B6B] text-white px-4 py-2 rounded-lg hover:bg-[#005a5a] transition-colors text-sm">
Connexion
</Link>
<Link to="/inscription" className="border border-[#006B6B] text-[#006B6B] px-4 py-2 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm">
Inscription
</Link>
</div>
)}
</nav>
{/* Hamburger mobile */}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="md:hidden flex flex-col justify-center items-center w-9 h-9 gap-1.5"
aria-label="Ouvrir le menu"
>
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? 'rotate-45 translate-y-2' : ''}`} />
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 ${menuOpen ? 'opacity-0' : ''}`} />
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? '-rotate-45 -translate-y-2' : ''}`} />
</button>
</div>
{/* Menu mobile déroulant */}
{menuOpen && (
<div className="md:hidden bg-white border-t border-gray-100 px-4 pb-4">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
onClick={() => setMenuOpen(false)}
className={`block py-3 font-medium border-b border-gray-50 ${
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700'
}`}
>
{link.label}
</Link>
))}
<div className="flex flex-col gap-2 pt-3">
{isLoggedIn ? (
<>
<Link
to="/mes-gains"
onClick={() => setMenuOpen(false)}
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg text-sm font-medium"
>
Mon espace ({user.firstname})
</Link>
<button
onClick={handleLogout}
className="w-full text-center border border-red-300 text-red-500 px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
>
Déconnexion
</button>
</>
) : (
<>
<Link
to="/connexion"
onClick={() => setMenuOpen(false)}
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg hover:bg-[#005a5a] transition-colors text-sm font-medium"
>
Connexion
</Link>
<Link
to="/inscription"
onClick={() => setMenuOpen(false)}
className="w-full text-center border border-[#006B6B] text-[#006B6B] px-4 py-2.5 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm font-medium"
>
Inscription
</Link>
</>
)}
</div>
</div>
)}
</header>
)
}

View File

@@ -0,0 +1,16 @@
import Header from './Header'
import Footer from './Footer'
export default function Layout({ children }) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function ProtectedRoute({ children, roles }) {
const { user } = useAuth()
if (!user) {
return <Navigate to="/connexion" replace />
}
if (roles && !roles.includes(user.role)) {
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
return <Navigate to={redirects[user.role] || '/connexion'} replace />
}
return children
}

View File

@@ -0,0 +1,56 @@
import { Link } from 'react-router-dom'
export default function UserNav({ active }) {
const tabs = [
{
key: 'code',
to: '/saisir-code-unique',
label: 'Saisir un code',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
),
},
{
key: 'gains',
to: '/mes-gains',
label: 'Mes gains',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12V22H4V12M22 7H2v5h20V7zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z" />
</svg>
),
},
{
key: 'profile',
to: '/profile',
label: 'Profil',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
},
]
return (
<div className="flex border-b border-gray-200 mb-6">
{tabs.map((tab) => (
<Link
key={tab.key}
to={tab.to}
className={`flex-1 flex items-center justify-center gap-1.5 py-3 text-xs sm:text-sm font-medium transition-colors border-b-2 ${
active === tab.key
? 'text-[#006B6B] border-[#006B6B]'
: 'text-gray-400 border-transparent hover:text-gray-600'
}`}
>
{tab.icon}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.split(' ')[0]}</span>
</Link>
))}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, useState } from 'react'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(() =>
JSON.parse(localStorage.getItem('user') || 'null')
)
function login(userData, token) {
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(userData))
setUser(userData)
}
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext)
}

View File

@@ -0,0 +1,17 @@
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function useHandleAuthError() {
const { logout } = useAuth()
const navigate = useNavigate()
return useCallback((err) => {
if (err.status === 401) {
logout()
navigate('/connexion')
return true
}
return false
}, [logout, navigate])
}

View File

@@ -0,0 +1,41 @@
export default function Accessibilite() {
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Déclaration d'accessibilité
</h1>
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
La société Thé Tip Top s'engage à rendre le site de son jeu-concours « 100% Gagnant »
accessible conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.
</p>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
État de conformité
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Le site a été développé en conformité avec les normes d'accessibilité internationales
WCAG 2.1 niveau AA et le Référentiel Général d'Amélioration de l'Accessibilité
(RGAA 4.1) afin de garantir une accessibilité maximale aux utilisateurs en situation
de handicap.
</p>
</section>
<section>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Signaler un problème d'accessibilité
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
part afin que nous puissions y remédier :<br />
<strong>Email :</strong> contact@thetiptop.fr
</p>
</section>
</div>
</main>
)
}

View File

@@ -0,0 +1,57 @@
import { Link } from 'react-router-dom'
export default function Accueil() {
return (
<div className="bg-gray-50 min-h-screen">
{/* Hero */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24">
<div className="text-center max-w-3xl mx-auto">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
Bienvenue au jeu-concours Thé Tip Top
</h1>
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
plus pour découvrir votre lot.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
<Link
to="/saisir-code-unique"
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
>
Tester mon code
</Link>
<Link
to="/regles-jeu"
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#005555] hover:text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
>
Découvrir les règles
</Link>
</div>
</div>
</section>
{/* Comment participer */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
Comment participer ?
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ num: '1', text: 'Achat de 49 € minimum' },
{ num: '2', text: 'Recevez votre code unique' },
{ num: '3', text: 'Saisissez le code sur le site' },
{ num: '4', text: 'Découvrez votre lot' },
].map((step) => (
<div key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
</div>
))}
</div>
</section>
</div>
)
}

View File

@@ -1,71 +1,560 @@
const MOCK_STATS = {
totalParticipants: 1248,
gainsRemis: 342,
gainsEnAttente: 906,
tauxParticipation: '78%',
import { useState, useEffect } from 'react'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
import { searchTicketApi, deliverClaimApi } from '../services/employee'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
function StatCard({ label, value, sub }) {
return (
<div className="bg-white rounded-xl shadow p-4 text-center">
<p className="text-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
<p className="text-xs sm:text-sm text-gray-500 mt-1">{label}</p>
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
</div>
)
}
const MOCK_USERS = [
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@email.com', gains: 2 },
{ id: 2, name: 'Marie Martin', email: 'marie.martin@email.com', gains: 1 },
{ id: 3, name: 'Paul Bernard', email: 'paul.bernard@email.com', gains: 3 },
]
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
export default function Admin() {
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-gray-800 mb-6">Tableau de bord Admin</h1>
const handleAuthError = useHandleAuthError()
const [stats, setStats] = useState(null)
const [users, setUsers] = useState([])
const [winner, setWinner] = useState(undefined)
const [drawing, setDrawing] = useState(false)
const [drawError, setDrawError] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
// Remise de lot
const [ticketCode, setTicketCode] = useState('')
const [ticketResult, setTicketResult] = useState(null)
const [ticketSearching, setTicketSearching] = useState(false)
const [ticketDelivering, setTicketDelivering] = useState(false)
const [ticketSearchErr, setTicketSearchErr] = useState('')
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]) => {
setStats(s)
setUsers(u.users)
setWinner(w)
})
.catch((e) => {
if (!handleAuthError(e)) setError(e.message)
})
.finally(() => setLoading(false))
}, [])
const handleDraw = async () => {
setShowConfirm(false)
setDrawing(true)
setDrawError('')
try {
const data = await drawWinnerApi()
setWinner(data)
} catch (e) {
if (!handleAuthError(e)) setDrawError(e.message)
} finally {
setDrawing(false)
}
}
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)
setTicketSearchErr('')
setTicketDeliverErr('')
setTicketSuccess(false)
setTicketSearching(true)
try {
const data = await searchTicketApi(ticketCode.trim())
setTicketResult(data)
} catch (err) {
if (!handleAuthError(err)) setTicketSearchErr(err.message)
} finally {
setTicketSearching(false)
}
}
const handleTicketDeliver = async () => {
if (!ticketResult?.claim?.id) return
setTicketDeliverErr('')
setTicketDelivering(true)
try {
await deliverClaimApi(ticketResult.claim.id)
setTicketResult((prev) => ({
...prev,
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
}))
setTicketSuccess(true)
setStats((prev) => prev ? {
...prev,
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
} : prev)
} catch (err) {
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
} finally {
setTicketDelivering(false)
}
}
if (loading) return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-400">Chargement...</p>
</div>
)
if (error) return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<p className="text-red-500 text-sm">{error}</p>
</div>
)
const winnerUser = winner?.winner?.user
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
<div className="max-w-5xl mx-auto space-y-6">
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
{/* Fenêtre du jeu-concours */}
{stats?.gameWindow?.configured && (
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<p className="text-sm font-semibold text-gray-800">
Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'}
</p>
<p className="text-xs text-gray-500 mt-1">
Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
</p>
</div>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${stats.gameWindow.isOpen ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
</span>
</div>
</div>
)}
{/* Stats principales */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
<StatCard label="Participants" value={stats?.users} />
<StatCard label="Participations" value={stats?.participations} />
<StatCard label="Gains en attente" value={stats?.claimsPending} />
<StatCard label="Gains remis" value={stats?.claimsDelivered} />
</div>
{/* Stats tickets */}
<div className="grid grid-cols-3 gap-3 sm:gap-4">
<StatCard label="Tickets total" value={stats?.ticketsTotal} />
<StatCard label="Tickets utilisés" value={stats?.ticketsUsed} />
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
</div>
{/* Stats démographiques */}
{stats?.genderStats && stats?.ageStats && (
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Genre des participants</h2>
<div className="space-y-2">
{[
{ label: 'Participants', value: MOCK_STATS.totalParticipants },
{ label: 'Gains remis', value: MOCK_STATS.gainsRemis },
{ label: 'En attente', value: MOCK_STATS.gainsEnAttente },
{ label: 'Taux participation', value: MOCK_STATS.tauxParticipation },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl shadow p-4 text-center">
<p className="text-2xl font-bold text-green-700">{stat.value}</p>
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
{ label: 'Femmes', value: stats.genderStats.women },
{ label: 'Hommes', value: stats.genderStats.men },
{ label: 'Autre / non renseigné', value: stats.genderStats.other },
].map((row) => (
<div key={row.label} className="flex items-center justify-between text-sm">
<span className="text-gray-500">{row.label}</span>
<span className="font-semibold text-gray-800">{row.value}</span>
</div>
))}
</div>
{/* Tirage au sort */}
<div className="bg-white rounded-xl shadow p-6 mb-6">
<h2 className="text-lg font-bold text-gray-800 mb-3">Grand tirage au sort</h2>
<button
onClick={() => alert('TODO: connecter au backend')}
className="bg-green-700 hover:bg-green-800 text-white font-semibold px-6 py-2.5 rounded-lg transition-colors"
>
Lancer le tirage
</button>
</div>
{/* Liste participants */}
<div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-bold text-gray-800 mb-4">Participants</h2>
<table className="w-full text-sm">
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Âge des participants</h2>
<div className="space-y-2">
{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
<div key={bracket} className="flex items-center justify-between text-sm">
<span className="text-gray-500">{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`}</span>
<span className="font-semibold text-gray-800">{stats.ageStats[bracket] ?? 0}</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Répartition par lot */}
{stats?.lotsStats?.length > 0 && (
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Répartition des lots</h2>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[480px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="pb-2">Nom</th>
<th className="pb-2">Email</th>
<th className="pb-2 text-right">Gains</th>
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
<th className="pb-2 px-4 sm:px-0">Lot</th>
<th className="pb-2 px-4 sm:px-0 text-center">%</th>
<th className="pb-2 px-4 sm:px-0 text-center">Tickets</th>
<th className="pb-2 px-4 sm:px-0 text-center">Participations</th>
</tr>
</thead>
<tbody>
{MOCK_USERS.map((user) => (
<tr key={user.id} className="border-b last:border-0">
<td className="py-3 font-medium text-gray-800">{user.name}</td>
<td className="py-3 text-gray-500">{user.email}</td>
<td className="py-3 text-right text-green-700 font-semibold">{user.gains}</td>
{stats.lotsStats.map((lot) => (
<tr key={lot.id} className="border-b last:border-0">
<td className="py-2.5 px-4 sm:px-0 font-medium text-gray-800 text-xs sm:text-sm">{lot.name}</td>
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-500 text-xs sm:text-sm">{lot.percentage}%</td>
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-600 text-xs sm:text-sm">{lot._count.tickets.toLocaleString('fr-FR')}</td>
<td className="py-2.5 px-4 sm:px-0 text-center text-[#006B6B] font-semibold text-xs sm:text-sm">{lot._count.participations}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── Contacts emailing ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<h2 className="text-base sm:text-lg font-bold text-gray-800">Contacts emailing</h2>
<div className="flex gap-2">
<button
onClick={handleLoadEmailingContacts}
disabled={emailingLoading}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
>
{emailingLoading ? 'Chargement…' : emailingContacts ? 'Actualiser' : 'Charger les contacts'}
</button>
{emailingContacts?.length > 0 && (
<button
onClick={handleExportEmailingCsv}
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
>
Exporter en CSV
</button>
)}
</div>
</div>
{emailingError && <p className="text-red-500 text-sm mb-3">{emailingError}</p>}
<p className="text-sm text-gray-500 mb-3">
Clients ayant accepté d'être contactés à des fins marketing.
{emailingContacts && <> — <span className="font-semibold text-gray-800">{emailingContacts.length}</span> contact(s)</>}
</p>
{emailingContacts && (
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[400px]">
<thead>
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
<th className="pb-2 px-4 sm:px-0">Nom</th>
<th className="pb-2 px-4 sm:px-0">Email</th>
<th className="pb-2 px-4 sm:px-0 text-right">Inscrit le</th>
</tr>
</thead>
<tbody>
{emailingContacts.length === 0 && (
<tr>
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
Aucun contact n'a accepté l'emailing pour le moment
</td>
</tr>
)}
{emailingContacts.map((contact) => (
<tr key={contact.email} className="border-b last:border-0">
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
{contact.firstname} {contact.lastname}
</td>
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{contact.email}</td>
<td className="py-3 px-4 sm:px-0 text-right text-gray-500 text-xs">
{new Date(contact.createdAt).toLocaleDateString('fr-FR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* ── Remise de lot ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
<input
type="text"
id="admin-ticket-code"
value={ticketCode}
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
placeholder="Code ticket (10 caractères)"
maxLength={10}
className="flex-1 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
disabled={ticketSearching || ticketCode.trim().length === 0}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
>
{ticketSearching ? 'Recherche…' : 'Rechercher'}
</button>
</form>
{ticketSearchErr && (
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
)}
{ticketResult && (
<div className="border border-gray-100 rounded-xl overflow-hidden">
{ticketSuccess && (
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
</div>
)}
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
<div>
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
<p className="text-gray-400 text-xs mt-0.5">{ticketResult.lot.description}</p>
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
</div>
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
: 'bg-yellow-100 text-yellow-700'
}`}>
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
: 'À remettre'}
</span>
</div>
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
</div>
</div>
<div className="px-4 py-3 space-y-2">
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
<button
onClick={handleTicketDeliver}
disabled={ticketDelivering}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
>
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
</button>
)}
<button
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
>
Rechercher un autre code
</button>
</div>
</div>
)}
</div>
{/* ── Tirage au sort ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
{winnerUser ? (
/* Gagnant déjà tiré */
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-[#006B6B]/5 rounded-xl border border-[#006B6B]/20">
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold flex-shrink-0">
{(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-[#006B6B] font-semibold uppercase tracking-wider mb-0.5">Gagnant du grand tirage</p>
<p className="font-bold text-gray-900 text-base">{winnerUser.firstname} {winnerUser.lastname}</p>
<p className="text-gray-500 text-sm truncate">{winnerUser.email}</p>
</div>
<div className="flex-shrink-0">
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
</div>
</div>
{winner?.winner?.createdAt && (
<p className="text-xs text-gray-400 text-right">
Tirage effectué le {formatDate(winner.winner.createdAt)}
</p>
)}
</div>
) : (
/* Pas encore de tirage */
<div className="space-y-3">
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
<p className="text-sm text-gray-600 mb-1">
<span className="font-semibold text-gray-800">{stats?.participations ?? 0}</span> participations enregistrées
</p>
<p className="text-sm text-gray-600">
<span className="font-semibold text-gray-800">{stats?.users ?? 0}</span> participants éligibles
</p>
</div>
{drawError && (
<p className="text-red-500 text-sm px-1">{drawError}</p>
)}
<button
onClick={() => setShowConfirm(true)}
disabled={drawing || (stats?.users ?? 0) === 0}
className="flex items-center gap-2 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-xl transition-colors text-sm"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
{drawing ? 'Tirage en cours…' : 'Lancer le tirage au sort'}
</button>
</div>
)}
</div>
{/* Liste participants */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
Participants <span className="text-gray-400 font-normal text-sm">({users.length})</span>
</h2>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[400px]">
<thead>
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
<th className="pb-2 px-4 sm:px-0">Nom</th>
<th className="pb-2 px-4 sm:px-0">Email</th>
<th className="pb-2 px-4 sm:px-0 text-right">Participations</th>
</tr>
</thead>
<tbody>
{users.length === 0 && (
<tr>
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
Aucun participant pour le moment
</td>
</tr>
)}
{users.map((user) => (
<tr key={user.id} className="border-b last:border-0">
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
{user.firstname} {user.lastname}
</td>
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{user.email}</td>
<td className="py-3 px-4 sm:px-0 text-right text-[#006B6B] font-semibold text-xs sm:text-sm">
{user._count.participations}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* ── Modal de confirmation du tirage ── */}
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl p-6">
<div className="flex justify-center mb-4">
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center">
<svg className="w-7 h-7 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
</div>
</div>
<h3 className="text-lg font-bold text-gray-900 text-center mb-1">Lancer le tirage au sort ?</h3>
<p className="text-sm text-gray-500 text-center mb-4">
Un gagnant sera sélectionné aléatoirement parmi les <strong>{stats?.users}</strong> participants.
</p>
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-5 flex gap-2 items-start">
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<p className="text-xs text-red-600">Cette action est <strong>irréversible</strong>. Le tirage ne peut être effectué qu'une seule fois.</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowConfirm(false)}
className="flex-1 border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
>
Annuler
</button>
<button
onClick={handleDraw}
className="flex-1 bg-[#006B6B] hover:bg-[#005959] text-white font-semibold py-2.5 rounded-xl transition-colors text-sm"
>
Confirmer le tirage
</button>
</div>
</div>
</div>
)}
</div>
)
}

244
frontend/src/pages/Cgu.jsx Normal file
View File

@@ -0,0 +1,244 @@
export default function Cgu() {
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Conditions générales d'utilisation
</h1>
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
Thé Tip Top, l'agence Furious Ducks et l'ensemble des conditions présentées
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
effectué sur ce site.
</p>
</div>
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
Les présentes Conditions Générales d'Utilisation (CGU) précisent les modalités d'accès et
d'utilisation du site internet du jeu-concours « 100% Gagnant » de la société Thé Tip Top.
</p>
{[
{
title: 'Article 1 – Objet des CGU',
content: (
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
<p>
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
les modalités et conditions dans lesquelles la société Thé Tip Top met à
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
accessible à l'adresse https://www.thetiptop-concours.fr.
</p>
<p>
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
ni à utiliser ses services.
</p>
</div>
),
},
{
title: 'Article 2 – Conditions d\'accès au Site',
content: (
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
<p><strong>Conditions d'âge —</strong> La participation au jeu-concours est strictement
réservée aux personnes physiques majeures, c'est-à-dire âgées d'au moins 18 ans
révolus à la date de leur inscription. Toute inscription d'un mineur est nulle et
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
d'un justificatif d'identité pour vérifier l'âge déclaré.
</p>
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
autre zone géographique.
</p>
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
sous-traitants, ainsi que les membres de leur famille en ligne directe (conjoints,
ascendants, descendants). Est également exclu le personnel de l'étude du
Commissaire de Justice dépositaire du règlement.
</p>
<p><strong>Conditions techniques —</strong> L'accès au Site nécessite une connexion
internet active et un navigateur web à jour. La société Thé Tip Top ne saurait
être tenue responsable des difficultés d'accès liées à l'environnement technique
de l'utilisateur.
</p>
</div>
),
},
{
title: 'Article 3 – Services proposés et fonctionnalités',
content: (
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
<div>
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
<p>
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
aux clients de la société Thé Tip Top de participer à un jeu-concours à
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
proposés incluent :
</p>
<ul className="list-disc list-inside space-y-2 mt-2">
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
<li>La création d'un espace personnel pour chaque participant</li>
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €</li>
<li>La consultation de l'historique des participations et gains obtenus</li>
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">3.2 – Fonctionnalités principales</h3>
<p><strong>S'inscrire —</strong> L'utilisateur peut créer un compte personnel en
renseignant un formulaire d'inscription classique ou en utilisant les
fonctionnalités d'authentification via Google. La création d'un compte est
obligatoire pour participer au jeu-concours.
</p>
<p className="mt-2"><strong>Jouer (participer au jeu-concours) —</strong> Une fois
connecté, l'utilisateur saisit le code à 10 caractères figurant sur son ticket de
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
correspondant selon la répartition préétablie :
</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>60 % des tickets : infuseur à thé</li>
<li>20 % des tickets : boîte de 100 g de thé détox ou d'infusion</li>
<li>10 % des tickets : boîte de 100 g de thé signature</li>
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
</ul>
<p className="mt-2">
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de
son ouverture. Les lots gagnés peuvent être réclamés pendant cette période ainsi
que dans les 30 jours calendaires suivant la date de clôture du jeu-concours.
Passé ce délai, les lots non réclamés sont définitivement perdus.
</p>
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
en boutique, sur présentation du compte utilisateur validé et de la confirmation
de gain.
</p>
<p className="mt-2">
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
participants pour désigner le gagnant d'un an de thé d'une valeur de 360 €. Ce
tirage a lieu sous le contrôle de Maître Arnaud Rick, Commissaire de Justice. Le
nombre de participations n'augmente pas les chances de remporter ce lot.
</p>
</div>
</div>
),
},
{
title: 'Article 4 – Conditions d\'inscription et de création de compte',
content: (
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
<div>
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Processus d'inscription</h3>
<p>
Pour accéder aux services du Site, l'utilisateur doit créer un compte personnel.
L'inscription peut s'effectuer de deux manières :
</p>
<p className="mt-2"><strong>Via le formulaire classique —</strong> L'utilisateur
renseigne les champs obligatoires suivants : nom, prénom, adresse e-mail valide,
mot de passe sécurisé. L'utilisateur reçoit ensuite un e-mail de confirmation
contenant un lien d'activation. Le compte n'est activé qu'après validation de ce
lien.
</p>
<p className="mt-2"><strong>Via l'authentification Google —</strong> L'utilisateur
peut s'inscrire en utilisant son compte Google. Dans ce cas, les données
transmises par la plateforme tierce (nom, prénom, adresse e-mail) sont récupérées
avec le consentement explicite de l'utilisateur.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Unicité du compte</h3>
<p>
Chaque utilisateur ne peut posséder qu'un seul compte. Toute tentative de
création de comptes multiples par un même utilisateur est prohibée et peut
entraîner la disqualification de l'ensemble des participations concernées.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Véracité des informations</h3>
<p>
L'utilisateur s'engage à fournir des informations exactes, complètes et à jour
lors de son inscription et tout au long de l'utilisation du Site. Toute
inscription effectuée avec une fausse identité ou des données inexactes sera
annulée, ainsi que les participations et gains associés.
</p>
</div>
</div>
),
},
{
title: 'Article 5 – Responsabilité de l\'utilisateur',
content: (
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
<p>
L'utilisateur est seul responsable des informations communiquées lors de la
création et de l'utilisation de son compte. À ce titre, il s'engage à :
</p>
<ul className="list-disc list-inside space-y-2">
<li>Fournir des données personnelles exactes, sincères et à jour</li>
<li>Ne pas usurper l'identité d'un tiers</li>
<li>Ne pas créer de compte au nom d'une autre personne sans son autorisation</li>
<li>Signaler sans délai à la société Thé Tip Top toute erreur, inexactitude ou modification de ses informations personnelles via les fonctionnalités de gestion du compte</li>
</ul>
<p>
En cas de préjudice subi par la société Thé Tip Top ou par des tiers en raison de
fausses informations fournies par l'utilisateur, ce dernier en assume l'entière
responsabilité et peut se voir réclamer réparation.
</p>
</div>
),
},
{
title: 'Article 6 – Gestion du compte utilisateur',
content: (
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
<div>
<h3 className="font-semibold text-gray-800 mb-1">6.1 – Modification des informations</h3>
<p>
L'utilisateur peut à tout moment accéder à son espace personnel et modifier ses
informations (nom, prénom, mot de passe).
</p>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
<p>
L'utilisateur peut demander la suppression de son compte à tout moment en
contactant directement l'équipe via contact@thetiptop.fr. La suppression entraîne
la perte définitive des participations et gains non encore réclamés. Les données
personnelles sont supprimées conformément aux dispositions de la Politique de
Confidentialité, sous réserve des obligations légales de conservation.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">6.3 – Sécurité des identifiants</h3>
<p>
L'utilisateur est seul responsable de la confidentialité de ses identifiants de
connexion (adresse e-mail et mot de passe). Il s'engage à ne les communiquer à
personne et à prendre toutes les précautions nécessaires pour en assurer la
protection. En cas de perte, de vol ou d'utilisation frauduleuse de ses
identifiants, l'utilisateur doit en informer immédiatement la société Thé Tip
Top. La société Thé Tip Top ne saurait être tenue responsable des dommages
résultant de l'utilisation des identifiants par un tiers, sauf faute de sa part.
</p>
</div>
</div>
),
},
].map((article) => (
<section key={article.title} className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
{article.title}
</h2>
{article.content}
</section>
))}
</div>
</main>
)
}

View File

@@ -0,0 +1,99 @@
export default function Confidentialite() {
return (
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Politique de confidentialité
</h1>
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
Thé Tip Top accorde une grande importance à la protection de vos données personnelles.
Cette politique explique quelles informations sont collectées, comment elles sont utilisées
et quels sont vos droits.
</p>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 1 – Responsable du traitement
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Le responsable du traitement des données collectées via le site du jeu-concours
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
</p>
</section>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 2 – Données collectées
</h2>
<p className="text-gray-700 mb-3 text-sm sm:text-base">Dans le cadre de votre participation, nous pouvons collecter :</p>
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
<li>Nom et prénom</li>
<li>Adresse email</li>
<li>Informations du compte utilisateur</li>
<li>Code de participation au jeu</li>
<li>Informations liées aux gains obtenus</li>
</ul>
</section>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 3 – Utilisation des données
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Les données collectées sont utilisées uniquement pour : gérer votre participation au
jeu-concours, vérifier les codes, attribuer les lots, vous contacter en cas de gain
et améliorer l'expérience utilisateur.
</p>
</section>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 4 – Conservation des données
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Vos données sont conservées uniquement pendant la durée nécessaire au fonctionnement
du jeu-concours et au respect des obligations légales de Thé Tip Top.
</p>
</section>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 5 – Cookies
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Le site utilise des cookies nécessaires au fonctionnement ainsi que des cookies
statistiques permettant d'améliorer les services proposés. Vous pouvez gérer vos
préférences à tout moment.
</p>
</section>
<section className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 6 – Vos droits
</h2>
<p className="text-gray-700 mb-3 text-sm sm:text-base">Conformément au RGPD, vous disposez des droits suivants :</p>
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
<li>Droit d'accès à vos données</li>
<li>Droit de rectification</li>
<li>Droit de suppression</li>
<li>Droit d'opposition</li>
<li>Droit à la limitation du traitement</li>
</ul>
</section>
<section>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
Article 7 – Contact
</h2>
<p className="text-gray-700 text-sm sm:text-base">
Pour toute question concernant vos données personnelles :<br />
<strong>Email :</strong> contact@thetiptop.fr
</p>
</section>
</div>
</main>
)
}

View File

@@ -1,73 +1,152 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { loginApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
export default function Connexion() {
const navigate = useNavigate()
const location = useLocation()
const { login } = useAuth()
const justRegistered = location.state?.registered === true
const [form, setForm] = useState({ email: '', password: '' })
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault()
// TODO: connecter au backend
alert(`Connexion avec ${form.email}`)
setError('')
setLoading(true)
try {
const { token, user } = await loginApi(form)
login(user, token)
if (user.role === 'ADMIN') navigate('/admin')
else if (user.role === 'EMPLOYEE') navigate('/employe')
else navigate('/mes-gains')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
<p className="text-gray-500 mt-1">Con
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
nexion à votre espace</p>
<div className="mb-8">
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Se connecter</h1>
<p className="text-gray-500 mt-1 text-sm">Connexion à votre espace</p>
</div>
{justRegistered && (
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
Compte créé avec succès ! Connectez-vous.
</div>
)}
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
<label htmlFor="email" className="block text-sm font-medium text-gray-800 mb-1">
Adresse email <span className="text-gray-800">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={form.email}
onChange={handleChange}
required
placeholder="exemple@email.com"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="Votre email"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mot de passe
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
Mot de passe <span className="text-gray-800">*</span>
</label>
<div className="relative">
<input
type="password"
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={form.password}
onChange={handleChange}
required
placeholder="••••••••"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="Votre mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Afficher/masquer le mot de passe"
>
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
{showPassword && (
<div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
)}
</div>
</button>
</div>
<div className="text-right mt-1">
<button type="button" className="text-xs text-gray-400 hover:text-[#006B6B] hover:underline">
Mot de passe oublié ?
</button>
</div>
</div>
<button
type="submit"
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
disabled={loading}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
Se connecter
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-6">
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-sm text-gray-500">Ou</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
G
</div>
Google
</button>
<button
type="button"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
f
</div>
Facebook
</button>
</div>
<p className="text-center text-sm text-gray-500 mt-8">
Pas encore de compte ?{' '}
<Link to="/inscription" className="text-green-700 font-medium hover:underline">
<Link to="/inscription" className="text-gray-700 font-medium hover:underline">
S'inscrire
</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import imgContact from '../assets/img/img-contact.jpg'
export default function Contact() {
return (
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
{/* Informations */}
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Contact
</h1>
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
Une question concernant le jeu-concours, votre participation ou votre lot ?
Notre équipe ThéTipTop est à votre écoute.
</p>
<div className="space-y-5 text-gray-700">
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Adresse
</h2>
<p className="text-sm sm:text-base">
ThéTipTop
<br />
18 rue Léon Frot
<br />
75011 Paris, France
</p>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Email
</h2>
<a
href="mailto:contact@thetiptop.fr"
className="text-sm sm:text-base hover:text-[#006B6B]"
>
contact@thetiptop.fr
</a>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Téléphone
</h2>
<a
href="tel:+33184802034"
className="text-sm sm:text-base hover:text-[#006B6B]"
>
01 84 80 20 34
</a>
</div>
</div>
</section>
{/* Image */}
<section className="rounded-2xl shadow overflow-hidden min-h-80">
<img
src={imgContact}
alt="Une personne savourant un thé"
className="w-full h-full object-cover"
/>
</section>
</div>
{/* Formulaire Emmanuel intégré au design Alexis */}
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10 max-w-4xl mx-auto">
<h2 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Envoyez-nous un message
</h2>
<form className="space-y-4 sm:space-y-5">
<div>
<label
htmlFor="contact-name"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Nom
</label>
<input
type="text"
id="contact-name"
placeholder="Votre nom"
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
/>
</div>
<div>
<label
htmlFor="contact-email"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Email
</label>
<input
type="email"
id="contact-email"
placeholder="Votre email"
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
/>
</div>
<div>
<label
htmlFor="contact-subject"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Sujet
</label>
<input
type="text"
id="contact-subject"
placeholder="Sujet de votre demande"
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
/>
</div>
<div>
<label
htmlFor="contact-message"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Message
</label>
<textarea
id="contact-message"
rows="4"
placeholder="Votre message..."
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B] resize-none"
/>
</div>
<button
type="submit"
className="w-full bg-[#006B6B] hover:bg-[#005858] text-white font-semibold py-2.5 sm:py-3 rounded-xl transition text-sm sm:text-base"
>
Envoyer le message
</button>
</form>
</section>
</div>
</main>
)
}

View File

@@ -1,76 +1,213 @@
import { useState } from 'react'
import { searchTicketApi, deliverClaimApi } from '../services/employee'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
export default function Employe() {
const [code, setCode] = useState('')
const [gain, setGain] = useState(null)
const handleSearch = (e) => {
e.preventDefault()
// TODO: connecter au backend
setGain({
code,
lot: 'Thé signature (100g)',
client: 'Jean Dupont',
email: 'jean.dupont@email.com',
status: 'PENDING',
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const handleDeliver = () => {
// TODO: connecter au backend
setGain({ ...gain, status: 'DELIVERED' })
const STATUS_CONFIG = {
PENDING: { label: 'À remettre', classes: 'bg-yellow-100 text-yellow-800' },
DELIVERED: { label: 'Déjà remis', classes: 'bg-green-100 text-green-800' },
CANCELLED: { label: 'Expiré', classes: 'bg-gray-100 text-gray-500' },
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold text-gray-800 mb-1">Espace Employé</h1>
<p className="text-gray-500 mb-6">Vérifier et remettre un lot</p>
export default function Employe() {
const handleAuthError = useHandleAuthError()
const [code, setCode] = useState('')
const [result, setResult] = useState(null)
const [searching, setSearching] = useState(false)
const [delivering, setDelivering] = useState(false)
const [searchError, setSearchError] = useState('')
const [deliverError, setDeliverError] = useState('')
const [success, setSuccess] = useState(false)
const handleSearch = async (e) => {
e.preventDefault()
setResult(null)
setSuccess(false)
setDeliverError('')
setSearchError('')
setSearching(true)
try {
const data = await searchTicketApi(code.trim())
setResult(data)
} catch (err) {
if (!handleAuthError(err)) setSearchError(err.message)
} finally {
setSearching(false)
}
}
const handleDeliver = async () => {
if (!result?.claim?.id) return
setDeliverError('')
setDelivering(true)
try {
await deliverClaimApi(result.claim.id)
setResult((prev) => ({
...prev,
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
}))
setSuccess(true)
} catch (err) {
if (!handleAuthError(err)) setDeliverError(err.message)
} finally {
setDelivering(false)
}
}
const handleReset = () => {
setCode('')
setResult(null)
setSuccess(false)
setSearchError('')
setDeliverError('')
}
const statusCfg = result ? (STATUS_CONFIG[result.claim?.status] ?? STATUS_CONFIG.PENDING) : null
return (
<div className="min-h-screen bg-gray-50 px-4 py-8 sm:py-12">
<div className="max-w-lg mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Espace Employé</h1>
<p className="text-gray-500 mt-1 text-sm">Recherchez un code ticket pour remettre le lot au client.</p>
</div>
{/* Formulaire de recherche */}
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
<label htmlFor="employee-ticket-code" className="sr-only">Code ticket</label>
<input
type="text"
id="employee-ticket-code"
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => setCode(e.target.value.toUpperCase())}
required
placeholder="Code ticket (TTT-XXXXX)"
className="flex-1 border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 font-mono"
placeholder="Code ticket (10 caractères)"
maxLength={10}
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
className="bg-green-700 hover:bg-green-800 text-white font-semibold px-5 py-2.5 rounded-lg transition-colors"
disabled={searching || code.trim().length === 0}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-lg transition-colors text-sm whitespace-nowrap"
>
Rechercher
{searching ? 'Recherche…' : 'Rechercher'}
</button>
</form>
{gain && (
<div className="bg-white rounded-xl shadow p-6 space-y-3">
<div className="flex justify-between items-start">
<div>
<p className="font-bold text-gray-800 text-lg">{gain.lot}</p>
<p className="text-sm text-gray-500 font-mono">{gain.code}</p>
{/* Erreur de recherche */}
{searchError && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{searchError}
</div>
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${gain.status === 'DELIVERED' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{gain.status === 'DELIVERED' ? 'Remis' : 'À remettre'}
)}
{/* Résultat */}
{result && (
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
{/* Bandeau succès remise */}
{success && (
<div className="bg-green-500 px-5 py-3 flex items-center gap-3">
<svg className="w-5 h-5 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p className="text-white text-sm font-semibold">Lot remis avec succès — espace client mis à jour.</p>
</div>
)}
{/* En-tête carte : lot + statut */}
<div className="px-5 py-4 flex items-start justify-between gap-3 border-b border-gray-100">
<div>
<p className="font-bold text-gray-900 text-base">{result.lot.name}</p>
<p className="text-gray-500 text-xs mt-0.5">{result.lot.description}</p>
{result.lot.value && (
<p className="text-[#006B6B] text-xs font-semibold mt-1">Valeur : {result.lot.value} €</p>
)}
</div>
<span className={`flex-shrink-0 text-xs font-semibold px-3 py-1 rounded-full ${statusCfg.classes}`}>
{statusCfg.label}
</span>
</div>
<div className="border-t pt-3">
<p className="text-sm text-gray-600"><span className="font-medium">Client :</span> {gain.client}</p>
<p className="text-sm text-gray-600"><span className="font-medium">Email :</span> {gain.email}</p>
{/* Infos client */}
<div className="px-5 py-4 space-y-2 border-b border-gray-100">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wider mb-2">Client</p>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
{(result.client.firstname?.[0] ?? '?').toUpperCase()}{(result.client.lastname?.[0] ?? '?').toUpperCase()}
</div>
<div>
<p className="text-sm font-semibold text-gray-800">
{result.client.firstname} {result.client.lastname}
</p>
<p className="text-xs text-gray-500">{result.client.email}</p>
</div>
</div>
</div>
{gain.status === 'PENDING' && (
<button
onClick={handleDeliver}
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2"
>
Confirmer la remise du lot
</button>
{/* Infos ticket */}
<div className="px-5 py-4 space-y-1 border-b border-gray-100">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wider mb-2">Ticket</p>
<p className="text-sm text-gray-700">
<span className="font-medium">Code :</span>{' '}
<span className="font-mono">{result.ticket.code}</span>
</p>
<p className="text-sm text-gray-700">
<span className="font-medium">Utilisé le :</span> {formatDate(result.ticket.usedAt)}
</p>
{result.claim.claimedAt && (
<p className="text-sm text-gray-700">
<span className="font-medium">Remis le :</span> {formatDate(result.claim.claimedAt)}
</p>
)}
</div>
{/* Actions */}
<div className="px-5 py-4 space-y-3">
{deliverError && (
<div className="px-4 py-2 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{deliverError}
</div>
)}
{result.claim.status === 'PENDING' && !success && (
<button
onClick={handleDeliver}
disabled={delivering}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors text-sm"
>
{delivering ? 'Confirmation en cours…' : 'Confirmer la remise du lot'}
</button>
)}
{result.claim.status === 'CANCELLED' && (
<div className="px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-500 text-sm text-center">
Ce gain est expiré et ne peut plus être remis.
</div>
)}
<button
onClick={handleReset}
className="w-full border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
>
Rechercher un autre code
</button>
</div>
</div>
)}
</div>
</div>
)

View File

@@ -1,116 +1,233 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { registerApi, verifyCodeApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
export default function Inscription() {
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
acceptMarketing: false,
})
const navigate = useNavigate()
const { login } = useAuth()
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setForm({ ...form, [name]: type === 'checkbox' ? checked : value })
}
// Étape : 'form' | 'verify'
const [step, setStep] = useState('form')
const [email, setEmail] = useState('')
const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' })
const [acceptedTerms, setAcceptedTerms] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = (e) => {
// Code à 4 chiffres
const [digits, setDigits] = useState(['', '', '', ''])
const [codeError, setCodeError] = useState('')
const [codeLoading, setCodeLoading] = useState(false)
const inputRefs = [useRef(), useRef(), useRef(), useRef()]
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Étape 1 — Inscription
const handleSubmit = async (e) => {
e.preventDefault()
// TODO: connecter au backend
alert(`Inscription de ${form.firstName} ${form.lastName}`)
setError('')
setLoading(true)
try {
await registerApi({
email: form.email,
firstname: form.firstName,
lastname: form.lastName,
password: form.password,
})
setEmail(form.email)
setStep('verify')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
// Étape 2 — Saisie du code
const handleDigit = (index, value) => {
if (!/^\d?$/.test(value)) return
const next = [...digits]
next[index] = value
setDigits(next)
if (value && index < 3) inputRefs[index + 1].current?.focus()
}
const handleDigitKeyDown = (index, e) => {
if (e.key === 'Backspace' && !digits[index] && index > 0) {
inputRefs[index - 1].current?.focus()
}
}
const handleVerify = async (e) => {
e.preventDefault()
const code = digits.join('')
if (code.length !== 4) return
setCodeError('')
setCodeLoading(true)
try {
const data = await verifyCodeApi({ email, code })
login(data.user, data.token)
navigate('/saisir-code-unique')
} catch (err) {
setCodeError(err.message)
setDigits(['', '', '', ''])
inputRefs[0].current?.focus()
} finally {
setCodeLoading(false)
}
}
// ── Étape 2 : Vérification du code ──
if (step === 'verify') {
return (
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
<p className="text-gray-500 mt-1">Créer votre compte</p>
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
<div className="mb-8 text-center">
<div className="w-14 h-14 rounded-full bg-[#006B6B]/10 flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-[#006B6B]" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Vérifiez votre email</h1>
<p className="text-gray-500 text-sm mt-2">
Un code à 4 chiffres a été envoyé à<br />
<span className="font-medium text-gray-700">{email}</span>
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom</label>
{codeError && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm text-center">
{codeError}
</div>
)}
<form onSubmit={handleVerify} className="space-y-6">
<div className="flex justify-center gap-3">
{digits.map((d, i) => (
<input
key={i}
ref={inputRefs[i]}
type="text"
name="firstName"
value={form.firstName}
onChange={handleChange}
required
placeholder="Jean"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
inputMode="numeric"
maxLength={1}
value={d}
onChange={(e) => handleDigit(i, e.target.value)}
onKeyDown={(e) => handleDigitKeyDown(i, e)}
aria-label={`Chiffre ${i + 1} du code de vérification`}
className="w-14 h-14 text-center text-2xl font-bold border-2 border-gray-200 rounded-xl focus:outline-none focus:border-[#006B6B] focus:ring-1 focus:ring-[#006B6B] transition-colors"
/>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom</label>
<input
type="text"
name="lastName"
value={form.lastName}
onChange={handleChange}
required
placeholder="Dupont"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
placeholder="exemple@email.com"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
required
placeholder="••••••••"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
name="acceptMarketing"
checked={form.acceptMarketing}
onChange={handleChange}
className="mt-1 accent-green-700"
/>
<span className="text-sm text-gray-600">
J'accepte de recevoir des communications marketing de Thé Tip Top
</span>
</label>
<button
type="submit"
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
disabled={codeLoading || digits.join('').length !== 4}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
Créer mon compte
{codeLoading ? 'Vérification…' : 'Valider le code'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-6">
Déjà un compte ?{' '}
<Link to="/connexion" className="text-green-700 font-medium hover:underline">
Se connecter
</Link>
<p className="text-center text-xs text-gray-400 mt-6">
Le code expire dans 15 minutes.{' '}
<button
type="button"
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
className="text-[#006B6B] hover:underline"
>
Recommencer
</button>
</p>
</div>
)
}
// ── Étape 1 : Formulaire d'inscription ──
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">S'inscrire</h1>
<p className="text-gray-500 mt-1 text-sm">Créer votre compte</p>
</div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
<div className="relative">
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
<button type="button" onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" aria-label="Afficher/masquer">
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
</div>
</button>
</div>
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
className="mt-0.5 w-4 h-4 accent-[#006B6B] flex-shrink-0" />
<span className="text-xs text-gray-500 leading-relaxed">
J'accepte les{' '}
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
et la{' '}
<Link to="/confidentialite" className="text-[#006B6B] hover:underline font-medium">politique de confidentialité</Link>
</span>
</label>
<button type="submit" disabled={loading || !acceptedTerms}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
{loading ? 'Inscription…' : "S'inscrire"}
</button>
</form>
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-sm text-gray-500">Ou</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
<div className="grid grid-cols-2 gap-3">
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">G</div>
Google
</button>
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">f</div>
Facebook
</button>
</div>
<p className="text-center text-sm text-gray-500 mt-8">
Vous avez déjà un compte ?{' '}
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">Se connecter</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
export default function MentionsLegales() {
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Mentions légales
</h1>
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
Thé Tip Top, l'agence Furious Ducks et l'ensemble des informations légales présentées
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
effectué sur ce site.
</p>
</div>
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
Conformément à la loi n°2004-575 du 21 juin 2004 pour la Confiance dans l'Économie
Numérique (LCEN), les présentes mentions légales précisent l'identité des différents
intervenants dans la conception et le suivi du site internet dédié au jeu-concours
« 100% Gagnant » de la société Thé Tip Top.
</p>
{[
{
title: 'Article 1 – Société éditrice du site',
content: (
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
<p><strong>Dénomination sociale :</strong> Thé Tip Top</p>
<p><strong>Forme juridique :</strong> Société à Responsabilité Limitée (SARL)</p>
<p><strong>Capital social :</strong> 150 000 €</p>
<p><strong>Siège social :</strong> 18 rue Léon Frot, 75011 Paris, France</p>
<p><strong>Représentant légal :</strong> M. Eric Bourdon, Gérant</p>
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
<p><strong>SIRET :</strong> 362 521 879 00054</p>
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
<p><strong>Email :</strong> contact@thetiptop.fr</p>
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
<p className="pt-2">
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
Commissaire de Justice. Il est consultable gratuitement sur simple demande
à l'adresse contact@thetiptop.fr.
</p>
</div>
),
},
{
title: 'Article 2 – Agence de développement',
content: (
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
<p><strong>Dénomination sociale :</strong> Furious Ducks</p>
<p><strong>Statut juridique :</strong> Société par Actions Simplifiée (SAS)</p>
<p><strong>Capital social :</strong> 200 000 €</p>
<p><strong>Siège social :</strong> 13 Avenue Jean Moulin, 93240 Stains</p>
<p><strong>Directeur Général :</strong> M. Guido Brasletti</p>
<p><strong>Effectif :</strong> 45 employés</p>
<p><strong>RCS :</strong> Bobigny 500 234 567</p>
<p><strong>Code APE / NAF :</strong> 6201Z – Développement de logiciels</p>
<p><strong>Année de création :</strong> 2007</p>
<p><strong>Spécialité :</strong> Technologies open source</p>
<p><strong>Téléphone :</strong> 01 35 52 40 49</p>
<p className="pt-2">
L'agence Furious Ducks est responsable de la conception technique du site, de
l'API, de la webApp et de l'infrastructure de déploiement. Cependant, elle n'est
pas responsable des contenus éditoriaux publiés par la société Thé Tip Top.
</p>
</div>
),
},
{
title: 'Article 3 – Hébergeur du site',
content: (
<div className="text-gray-700 space-y-2 text-sm sm:text-base">
<p><strong>Raison sociale :</strong> Vercel Inc.</p>
<p><strong>Adresse :</strong> 440 N Barranca Avenue #4133, Covina, CA 91723, États-Unis</p>
<p><strong>Contact :</strong> privacy@vercel.com</p>
<p><strong>Site internet :</strong> https://www.vercel.com</p>
</div>
),
},
{
title: 'Article 4 – Propriété intellectuelle',
content: (
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
<div>
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Droits de la société Thé Tip Top</h3>
<p>
L'ensemble des éléments constituant le présent site internet (textes, graphiques,
logiciels, photographies, images, sons, vidéos, noms, logos, marques, créations et
œuvres protégeables) est la propriété exclusive de la société Thé Tip Top ou de ses
partenaires, et est protégé par les lois françaises et internationales relatives à
la propriété intellectuelle.
</p>
<p className="mt-2">
La marque « Thé Tip Top », ses logos, son identité visuelle refondue dans le cadre
du présent projet et ses mélanges signatures sont des éléments protégés. Toute
utilisation non autorisée constitue une contrefaçon sanctionnée par les articles
L.713-1 et suivants du Code de la propriété intellectuelle.
</p>
<p className="mt-2">
Toute reproduction, représentation, modification, publication ou adaptation de
tout ou partie des éléments du Site, quel que soit le moyen ou le procédé utilisé,
est strictement interdite sans autorisation écrite préalable de la société Thé Tip
Top, sous peine de poursuites judiciaires.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Droits de l'agence Furious Ducks</h3>
<p>
Les développements techniques réalisés par l'agence Furious Ducks (API, webApp,
infrastructure de déploiement, workflow CI/CD) font l'objet de droits distincts,
définis contractuellement dans le contrat de prestation signé entre les parties.
L'agence Furious Ducks cède à la société Thé Tip Top les droits d'exploitation
nécessaires à l'utilisation du site dans les conditions prévues au contrat, sans
que cela n'implique un transfert de propriété des outils et technologies
propriétaires de l'agence.
</p>
</div>
<div>
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Contenu soumis par les utilisateurs</h3>
<p>
L'utilisateur garantit qu'il dispose de tous les droits nécessaires sur les
informations qu'il communique sur le Site. Il accorde à la société Thé Tip Top
une licence non exclusive d'utilisation de ces données dans le seul cadre de la
fourniture des services du jeu-concours.
</p>
</div>
</div>
),
},
{
title: 'Article 5 – Cookies et traceurs',
content: (
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
<p>
Le site est susceptible d'utiliser des cookies et traceurs afin d'améliorer
l'expérience utilisateur, d'analyser le trafic et de permettre les fonctionnalités
d'authentification via Google.
</p>
<p>
Lors de votre première visite, un bandeau de consentement conforme au RGPD et aux
recommandations de la CNIL (Commission Nationale de l'Informatique et des Libertés)
vous informe de la présence de cookies et recueille votre accord. Vous pouvez à
tout moment modifier vos préférences via le gestionnaire de cookies disponible sur
le Site ou les paramètres de votre navigateur.
</p>
<p>Les cookies utilisés sont les suivants :</p>
<ul className="list-disc list-inside space-y-2">
<li>
<strong>Cookies de session :</strong> nécessaires au bon fonctionnement du site
(authentification, suivi de participation, sécurité). Non soumis à consentement.
</li>
<li>
<strong>Cookies analytiques (Google Analytics 4) :</strong> permettent de mesurer
l'audience du Site (visiteurs uniques, sources de trafic, comportements de
navigation, tunnel de conversion inscription → participation → réclamation de
lot). Soumis à consentement.
</li>
<li>
<strong>Cookies publicitaires et sociaux (Meta Business Suite – Instagram/Facebook) :</strong>{' '}
utilisés dans le cadre des campagnes Meta Ads et du suivi des performances
sociales. Soumis à consentement.
</li>
<li>
<strong>Cookies Google :</strong> liés aux fonctionnalités d'authentification via
compte Google. Soumis aux politiques de confidentialité respectives de ces
plateformes.
</li>
</ul>
</div>
),
},
{
title: 'Article 6 – Responsabilité',
content: (
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
<p>
La société Thé Tip Top s'efforce d'assurer l'exactitude et la mise à jour des
informations diffusées sur le Site. Toutefois, elle ne peut garantir l'exactitude,
la complétude ou l'actualité de l'ensemble des informations publiées et décline
toute responsabilité pour :
</p>
<ul className="list-disc list-inside space-y-2">
<li>Toute inexactitude ou omission portant sur des informations disponibles sur le Site</li>
<li>Tout dommage résultant d'une intrusion frauduleuse d'un tiers ayant entraîné une modification des informations</li>
<li>Tout dommage lié à l'utilisation d'Internet (rupture de service, virus, intrusion extérieure)</li>
<li>Tout dysfonctionnement lié à l'infrastructure technique gérée par l'agence Furious Ducks ou son hébergeur</li>
</ul>
</div>
),
},
{
title: 'Article 7 – Droit applicable',
content: (
<p className="text-gray-700 text-sm sm:text-base">
Le présent Site et ses contenus sont soumis au droit français. En cas de litige
relatif à l'interprétation ou à l'exécution des présentes mentions légales, et à
défaut de résolution amiable, les tribunaux français seront seuls compétents.
</p>
),
},
{
title: 'Article 8 – Accessibilité',
content: (
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Le présent site internet a été développé en conformité avec les normes
d'accessibilité internationales WCAG 2.1 niveau AA et le Référentiel Général
d'Amélioration de l'Accessibilité (RGAA 4.1) afin de garantir une accessibilité
maximale aux utilisateurs en situation de handicap. Une déclaration d'accessibilité
complète est disponible sur la page{' '}
<a href="/accessibilite" className="text-[#006B6B] hover:underline font-medium">
Accessibilité
</a>.
</p>
),
},
{
title: 'Article 9 – Engagement RSE et éco-responsabilité',
content: (
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
<p>
La société Thé Tip Top et l'agence Furious Ducks s'engagent dans une démarche de
Responsabilité Sociale et Environnementale (RSE) pour la conception et
l'exploitation du site du jeu-concours 100% Gagnant. Cet engagement comprend
notamment :
</p>
<ul className="list-disc list-inside space-y-2">
<li>L'utilisation de technologies et d'infrastructures respectueuses de l'environnement</li>
<li>L'optimisation de la consommation d'énergie et de l'empreinte carbone du site</li>
<li>L'hébergement auprès de fournisseurs certifiés pour leurs pratiques durables</li>
<li>La limitation de la collecte de données personnelles aux seules données nécessaires</li>
<li>L'utilisation de technologies open source réduisant les dépendances propriétaires</li>
<li>La sensibilisation des utilisateurs aux bonnes pratiques numériques</li>
</ul>
<p>
Toute question relative à ces engagements peut être adressée à : dpo@thetiptop.fr
</p>
</div>
),
},
].map((article) => (
<section key={article.title} className="mb-6 sm:mb-8">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
{article.title}
</h2>
{article.content}
</section>
))}
</div>
</main>
)
}

View File

@@ -1,34 +1,128 @@
const MOCK_GAINS = [
{ id: 1, code: 'TTT-00123', lot: 'Infusion découverte (30g)', status: 'PENDING' },
{ id: 2, code: 'TTT-00456', lot: 'Thé signature (100g)', status: 'DELIVERED' },
]
import { useState, useEffect, useCallback } from 'react'
import { myGainsApi } from '../services/game'
import UserNav from '../components/UserNav'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
const statusLabel = {
PENDING: { label: 'À récupérer', color: 'bg-yellow-100 text-yellow-800' },
DELIVERED: { label: 'Récupéré', color: 'bg-green-100 text-green-800' },
CANCELLED: { label: 'Annulé', color: 'bg-red-100 text-red-800' },
function formatDate(dateStr) {
const date = new Date(dateStr)
const dateLabel = date.toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
const timeLabel = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
return `${dateLabel} à ${timeLabel}`
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return "À l'instant"
if (minutes < 60) return `Il y a ${minutes} min`
if (hours < 24) return `Il y a ${hours}h`
if (days < 7) return `Il y a ${days}j`
return null
}
const CLAIM_STATUS = {
PENDING: { label: 'En attente de remise', classes: 'bg-yellow-100 text-yellow-700' },
DELIVERED: { label: 'Lot remis', classes: 'bg-green-100 text-green-700' },
CANCELLED: { label: 'Expiré', classes: 'bg-gray-100 text-gray-500' },
}
export default function MesGains() {
return (
<div className="min-h-screen bg-green-50 p-6">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-green-800 mb-2">Mes gains</h1>
<p className="text-gray-500 mb-6">Historique de vos participations</p>
const handleAuthError = useHandleAuthError()
const [gains, setGains] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [refreshing, setRefreshing] = useState(false)
<div className="space-y-4">
{MOCK_GAINS.map((gain) => (
<div key={gain.id} className="bg-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p className="font-semibold text-gray-800">{gain.lot}</p>
<p className="text-sm text-gray-400 font-mono mt-1">{gain.code}</p>
const fetchGains = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
setError('')
try {
const data = await myGainsApi()
setGains(data.gains)
} catch (err) {
if (!handleAuthError(err)) setError(err.message)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [handleAuthError])
useEffect(() => { fetchGains() }, [fetchGains])
return (
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
<UserNav active="gains" />
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700">Historique de mes gains</h2>
<button
onClick={() => fetchGains(true)}
disabled={refreshing}
className="text-xs text-[#006B6B] hover:underline disabled:opacity-50 flex items-center gap-1"
>
<svg className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582M20 20v-5h-.581M4.582 9A8 8 0 0120 15M19.418 15A8 8 0 014 9" />
</svg>
{refreshing ? 'Actualisation…' : 'Actualiser'}
</button>
</div>
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${statusLabel[gain.status].color}`}>
{statusLabel[gain.status].label}
{loading && <p className="text-center text-gray-400 text-sm mt-8">Chargement…</p>}
{error && (
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
)}
{!loading && !error && gains.length === 0 && (
<div className="text-center mt-12">
<p className="text-gray-400 text-sm">Aucun gain pour le moment.</p>
<p className="text-gray-400 text-xs mt-1">Saisissez un code pour participer !</p>
</div>
)}
<div className="relative">
{gains.map((gain, index) => {
const statusCfg = CLAIM_STATUS[gain.claimStatus] ?? CLAIM_STATUS.PENDING
return (
<div key={gain.participationId} className="flex gap-4">
<div className="flex flex-col items-center">
<div className={`w-3 h-3 mt-1.5 rounded-full flex-shrink-0 border-2 ${
gain.claimStatus === 'DELIVERED' ? 'border-[#006B6B] bg-[#006B6B]' : 'border-gray-300'
}`} />
{index < gains.length - 1 && <div className="w-px flex-1 bg-gray-200 my-1" />}
</div>
<div className={`flex-1 pb-6 ${index === 0 ? 'bg-gray-50 rounded-lg px-3 pt-2 -ml-1' : 'pt-1'}`}>
<div className="flex items-start justify-between gap-2 flex-wrap">
<p className="font-semibold text-gray-800 text-sm">{gain.lot}</p>
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap ${statusCfg.classes}`}>
{statusCfg.label}
</span>
</div>
))}
<p className="text-gray-500 text-xs mt-1 leading-relaxed">{gain.description}</p>
{gain.value && (
<p className="text-gray-400 text-xs mt-0.5">Valeur : {gain.value} €</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<p className="text-[#006B6B] text-xs">Gagné le {formatDate(gain.wonAt)}</p>
{timeAgo(gain.wonAt) && (
<span className="text-[10px] font-medium text-white bg-[#006B6B]/70 px-1.5 py-0.5 rounded-full">
{timeAgo(gain.wonAt)}
</span>
)}
</div>
<p className="text-gray-400 text-[10px] mt-1">
Code utilisé :{' '}
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
</p>
</div>
</div>
)
})}
</div>
</div>
)

View File

@@ -0,0 +1,26 @@
import { Link } from 'react-router-dom'
export default function NotFound() {
return (
<div className="min-h-screen bg-white flex flex-col items-center justify-center px-6 text-center">
<p className="text-8xl sm:text-9xl font-bold text-[#006B6B] leading-none">404</p>
<h1 className="text-2xl sm:text-3xl font-bold italic font-serif text-gray-800 mt-4 mb-2">
Page introuvable
</h1>
<p className="text-gray-400 text-sm sm:text-base max-w-xs mb-8">
La page que vous recherchez n'existe pas ou a été déplacée.
</p>
<Link
to="/accueil"
className="bg-[#006B6B] hover:bg-[#005959] text-white font-medium px-8 py-3 rounded-lg transition-colors text-sm sm:text-base"
>
Retour à l'accueil
</Link>
</div>
)
}

View File

@@ -1,61 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
export default function Participation() {
const [code, setCode] = useState('')
const [result, setResult] = useState(null)
const handleSubmit = (e) => {
e.preventDefault()
// TODO: connecter au backend
setResult({
lot: 'Infusion découverte (30g)',
description: 'Un assortiment de nos meilleures infusions',
})
}
return (
<div className="min-h-screen bg-green-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-green-800">Thé Tip Top</h1>
<p className="text-gray-500 mt-1">Participer au jeu-concours</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Code ticket
</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
required
placeholder="Ex: TTT-XXXXX"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500 font-mono tracking-widest"
/>
</div>
<button
type="submit"
className="w-full bg-green-700 hover:bg-green-800 text-white font-semibold py-2.5 rounded-lg transition-colors"
>
Valider mon ticket
</button>
</form>
{result && (
<div className="mt-6 bg-green-100 border border-green-300 rounded-xl p-5 text-center">
<p className="text-green-800 font-bold text-lg">Félicitations !</p>
<p className="text-green-700 font-medium mt-1">{result.lot}</p>
<p className="text-gray-600 text-sm mt-1">{result.description}</p>
<Link to="/mes-gains" className="inline-block mt-4 text-sm text-green-700 font-medium hover:underline">
Voir tous mes gains →
</Link>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useLocation, useNavigate } from 'react-router-dom'
export default function Perdu() {
const navigate = useNavigate()
const location = useLocation()
const message = location.state?.message
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-8 max-w-sm mx-auto text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-2">
Réessayer la prochaine fois
</h1>
<p className="text-gray-500 text-sm mb-2">
{message || 'Ce code est invalide ou a déjà été utilisé.'}
</p>
<p className="text-gray-400 text-xs mb-8">
Vérifiez le code présent sur votre ticket et réessayez.
</p>
<button
onClick={() => navigate('/saisir-code-unique')}
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-lg transition-colors"
>
Réessayer
</button>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import UserNav from '../components/UserNav'
import { changePasswordApi, deleteAccountApi } from '../services/auth'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
import { useAuth } from '../context/AuthContext'
export default function Profile() {
const user = JSON.parse(localStorage.getItem('user') || '{}')
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
const handleAuthError = useHandleAuthError()
const { logout } = useAuth()
const navigate = useNavigate()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
const [pwLoading, setPwLoading] = useState(false)
const [pwError, setPwError] = useState('')
const [pwSuccess, setPwSuccess] = useState(false)
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
const handlePwSubmit = async (e) => {
e.preventDefault()
setPwError('')
setPwSuccess(false)
if (pwForm.newPassword !== pwForm.confirmPassword) {
setPwError('Les nouveaux mots de passe ne correspondent pas.')
return
}
if (pwForm.newPassword.length < 8) {
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
return
}
setPwLoading(true)
try {
await changePasswordApi({ currentPassword: pwForm.currentPassword, newPassword: pwForm.newPassword })
setPwSuccess(true)
setPwForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
} catch (err) {
if (!handleAuthError(err)) setPwError(err.message)
} finally {
setPwLoading(false)
}
}
const EyeIcon = ({ visible }) => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
{visible
? <><path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></>
: <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
}
</svg>
)
return (
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
<UserNav active="profile" />
{/* Avatar */}
<div className="mb-6">
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold">
{initials}
</div>
</div>
{/* Infos du compte (lecture seule) */}
<div className="space-y-4 mb-8">
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Adresse email</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-400 bg-gray-50 cursor-not-allowed select-none">
{user.email || '—'}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Nom</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
{user.lastname || '—'}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Prénom</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
{user.firstname || '—'}
</div>
</div>
</div>
{/* Séparateur */}
<div className="border-t border-gray-100 mb-6" />
{/* Section changement de mot de passe */}
<div>
<h2 className="text-sm font-semibold text-gray-800 mb-4">Changer mon mot de passe</h2>
{pwSuccess && (
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Mot de passe modifié avec succès.
</div>
)}
{pwError && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{pwError}
</div>
)}
<form onSubmit={handlePwSubmit} className="space-y-4">
{[
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
{ name: 'newPassword', label: 'Nouveau mot de passe', key: 'new' },
{ name: 'confirmPassword', label: 'Confirmer le nouveau mot de passe', key: 'confirm' },
].map(({ name, label, key }) => (
<div key={name}>
<label htmlFor={name} className="block text-sm font-medium text-gray-800 mb-1">{label}</label>
<div className="relative">
<input
type={showPw[key] ? 'text' : 'password'}
id={name}
name={name}
value={pwForm[name]}
onChange={handlePwChange}
required
placeholder="••••••••"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="button"
onClick={() => setShowPw((s) => ({ ...s, [key]: !s[key] }))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Afficher/masquer"
>
<EyeIcon visible={showPw[key]} />
</button>
</div>
</div>
))}
<button
type="submit"
disabled={pwLoading}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium py-3 rounded-lg transition-colors text-sm"
>
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function ReglesJeu() {
const { user } = useAuth()
const isLoggedIn = !!user
return (
<div className="bg-gray-50 min-h-screen">
{/* Hero */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-20">
<div className="text-center max-w-3xl mx-auto">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Règles du jeu
</h1>
<p className="text-base sm:text-xl text-gray-600">
Participez au jeu-concours ThéTipTop et découvrez immédiatement
le lot que vous avez gagné.
</p>
</div>
</section>
{/* Comment participer */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] text-center mb-8 sm:mb-10">
Comment participer ?
</h2>
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
{[
{
num: '1',
title: 'Obtenez un code',
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.",
},
{
num: '2',
title: 'Créez votre compte',
text: "Inscrivez-vous sur le site du jeu-concours afin d'accéder à votre espace participant.",
},
{
num: '3',
title: 'Découvrez votre lot',
text: 'Entrez votre code dans votre espace client et découvrez immédiatement votre récompense.',
},
].map((step) => (
<div key={step.num} className="bg-white rounded-2xl shadow p-6 sm:p-8 text-center">
<div className="text-3xl sm:text-4xl font-bold text-[#006B6B] mb-3 sm:mb-4">{step.num}</div>
<h3 className="text-lg sm:text-xl font-semibold mb-2 sm:mb-3">{step.title}</h3>
<p className="text-gray-600 text-sm sm:text-base">{step.text}</p>
</div>
))}
</div>
</section>
{/* Lots */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
Que pouvez-vous gagner ?
</h2>
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-6 sm:mb-8">
Tous les billets sont gagnants ! Les récompenses peuvent aller
d'un infuseur à thé à un coffret découverte d'une valeur de 69€,
en passant par des thés détox et des thés signatures.
</p>
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
<p className="text-gray-700 text-sm sm:text-base">
Le participant tiré au sort remportera une année de thé d'une valeur de 360€ !
</p>
</div>
</div>
</section>
{/* CTA */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
<div className="bg-[#006B6B] rounded-2xl p-6 sm:p-10 text-center text-white">
<h2 className="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
Ne manquez pas votre chance !
</h2>
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
{!isLoggedIn && (
<Link
to="/inscription"
className="bg-white text-[#006B6B] px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white text-sm sm:text-base"
>
Inscrivez-vous
</Link>
)}
<Link
to="/saisir-code-unique"
className="border border-white px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white text-sm sm:text-base"
>
Entrer mon code de ticket
</Link>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { participateApi } from '../services/game'
import UserNav from '../components/UserNav'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
function formatDateTime(date) {
return date.toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
export default function SaisirCodeUnique() {
const navigate = useNavigate()
const handleAuthError = useHandleAuthError()
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [gain, setGain] = useState(null)
const [claim, setClaim] = useState(null)
const [wonAt, setWonAt] = useState(null)
const [showDetail, setShowDetail] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
try {
const data = await participateApi(code.trim().toUpperCase())
setGain(data.gain)
setClaim(data.claim)
setWonAt(new Date())
} catch (err) {
if (!handleAuthError(err)) {
navigate('/perdu', { state: { message: err.message } })
}
} finally {
setLoading(false)
}
}
const handleClose = () => {
setGain(null)
setClaim(null)
setWonAt(null)
setShowDetail(false)
setCode('')
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
<UserNav active="code" />
<div className="mb-8">
<h1 className="text-2xl font-bold italic font-serif text-[#006B6B]">Saisir mon code gagnant</h1>
<p className="text-gray-500 mt-2 text-sm">Saisissez le code présent sur votre ticket</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<label htmlFor="ticket-code" className="sr-only">Code de votre ticket</label>
<input
type="text"
id="ticket-code"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="Votre code"
maxLength={10}
required
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
disabled={loading || code.length !== 10}
className="w-full bg-[#00786F] hover:bg-[#1A6B65] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Validation...' : 'Valider'}
</button>
</form>
{/* ── Overlay Félicitation ── */}
{gain && !showDetail && (
<div className="fixed inset-0 bg-black/60 flex flex-col items-center justify-between z-50 px-6 py-12">
<div className="w-full max-w-sm">
<h2 className="text-4xl font-bold italic font-serif text-white mb-1">Félicitation !</h2>
<p className="text-white/80 text-sm">Vous avez obtenu une récompense</p>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="bg-white/10 rounded-lg px-6 py-3 text-white font-mono tracking-widest text-lg">
{code}
</div>
</div>
<div className="mb-8">
<svg className="w-24 h-24 text-yellow-200" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
</div>
<div className="w-full max-w-sm">
<p className="text-white text-center font-semibold mb-1">{gain.name}</p>
<p className="text-white/70 text-center text-sm mb-6">{gain.description}</p>
<button
onClick={() => setShowDetail(true)}
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-lg transition-colors"
>
Découvrir mon lot
</button>
</div>
</div>
)}
{/* ── Modal détail du lot ── */}
{gain && showDetail && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl overflow-hidden">
{/* En-tête coloré */}
<div className="bg-[#006B6B] px-6 py-5 relative">
<button
onClick={handleClose}
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors"
aria-label="Fermer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
<svg className="w-5 h-5 text-yellow-200" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
</div>
<div>
<p className="text-white/70 text-xs">Votre lot</p>
<p className="text-white font-bold text-base leading-tight">{gain.name}</p>
</div>
</div>
</div>
{/* Corps */}
<div className="px-6 py-5 space-y-4">
{/* Description */}
<p className="text-gray-600 text-sm leading-relaxed">{gain.description}</p>
{/* Valeur */}
{gain.value && (
<div className="flex items-center justify-between py-2 border-b border-gray-100">
<span className="text-xs text-gray-400 uppercase tracking-wider">Valeur estimée</span>
<span className="text-sm font-semibold text-[#006B6B]">{gain.value} €</span>
</div>
)}
{/* Code utilisé */}
<div className="flex items-center justify-between py-2 border-b border-gray-100">
<span className="text-xs text-gray-400 uppercase tracking-wider">Code utilisé</span>
<span className="font-mono text-sm tracking-widest text-gray-700">{code}</span>
</div>
{/* Date & heure */}
{wonAt && (
<div className="flex items-center justify-between py-2 border-b border-gray-100">
<span className="text-xs text-gray-400 uppercase tracking-wider">Obtenu le</span>
<span className="text-sm text-gray-700">{formatDateTime(wonAt)}</span>
</div>
)}
{/* Statut */}
<div className="flex items-center justify-between py-2">
<span className="text-xs text-gray-400 uppercase tracking-wider">Statut</span>
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-yellow-100 text-yellow-700">
En attente de remise
</span>
</div>
{/* Info remise */}
<div className="bg-[#006B6B]/5 rounded-xl px-4 py-3 flex gap-3 items-start">
<svg className="w-4 h-4 text-[#006B6B] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-xs text-[#006B6B] leading-relaxed">
Présentez ce code en boutique Thé Tip Top pour récupérer votre lot.
</p>
</div>
</div>
{/* Actions */}
<div className="px-6 pb-6 space-y-2">
<button
onClick={() => navigate('/mes-gains')}
className="w-full bg-[#006B6B] hover:bg-[#005959] text-white font-medium py-3 rounded-xl transition-colors text-sm"
>
Voir tout mon historique
</button>
<button
onClick={handleClose}
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
>
Retour
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { Link } from 'react-router-dom'
import { useAuth} from '../context/AuthContext'
const sections = [
{
title: 'Navigation principale',
links: [
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
],
},
{
title: 'Compte utilisateur',
links: [
{ label: 'Connexion', to: '/connexion', desc: 'Se connecter à son espace' },
{ label: 'Inscription', to: '/inscription', desc: 'Créer un compte participant' },
{ label: 'Mon profil', to: '/profile', desc: 'Voir et modifier ses informations' },
],
},
{
title: 'Jeu-concours',
links: [
{ label: 'Saisir mon code', to: '/saisir-code-unique', desc: 'Entrer son code ticket' },
{ label: 'Mes gains', to: '/mes-gains', desc: 'Historique de ses participations et lots' },
],
},
{
title: 'Espaces professionnels',
links: [
{ label: 'Espace employé', to: '/employe', desc: 'Vérifier et remettre un lot' },
{ label: 'Tableau de bord admin', to: '/admin', desc: 'Statistiques et gestion du jeu' },
],
},
{
title: 'Informations légales',
links: [
{ label: 'Mentions légales', to: '/mentions-legales', desc: 'Informations légales du site' },
{ label: 'CGU', to: '/cgu', desc: "Conditions générales d'utilisation" },
{ label: 'Politique de confidentialité', to: '/confidentialite', desc: 'Gestion des données personnelles' },
{ label: 'Accessibilité', to: '/accessibilite', desc: 'Déclaration de conformité RGAA' },
],
},
]
export default function Sitemap() {
const { user } = useAuth()
const isAdmin = user?.role === 'ADMIN'
const visibleSections = sections.filter(
(section) => section.title !== 'Espaces professionnels' || isAdmin
)
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-4xl mx-auto">
<div className="mb-8 sm:mb-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-2">
Plan du site
</h1>
<p className="text-gray-500 text-sm sm:text-base">
Retrouvez ici l'ensemble des pages disponibles sur le site Thé Tip Top.
</p>
</div>
<div className="grid sm:grid-cols-2 gap-6">
{visibleSections.map((section) => (
<div key={section.title} className="bg-white rounded-2xl shadow p-5 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-[#006B6B] mb-4 pb-2 border-b border-gray-100">
{section.title}
</h2>
<ul className="space-y-3">
{section.links.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="group flex items-start gap-3"
>
<svg
className="w-4 h-4 mt-0.5 text-[#006B6B] flex-shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<div>
<p className="text-sm font-medium text-gray-800 group-hover:text-[#006B6B] transition-colors">
{link.label}
</p>
<p className="text-xs text-gray-400 mt-0.5">{link.desc}</p>
</div>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,51 @@
const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/admin`
function authHeaders() {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
}
}
function apiError(message, status) {
const err = new Error(message)
err.status = status
return err
}
export async function getStatsApi() {
const res = await fetch(`${BASE}/stats`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur stats.', res.status)
return data
}
export async function getUsersApi() {
const res = await fetch(`${BASE}/users`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur utilisateurs.', res.status)
return data
}
export async function drawWinnerApi() {
const res = await fetch(`${BASE}/draw`, { method: 'POST', headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur tirage.', res.status)
return data
}
export async function getEmailingContactsApi() {
const res = await fetch(`${BASE}/emailing`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur contacts emailing.', res.status)
return data
}
export async function getWinnerApi() {
const res = await fetch(`${BASE}/draw`, { headers: authHeaders() })
if (res.status === 404) return null
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
return data
}

View File

@@ -0,0 +1,67 @@
const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/auth`
function apiError(message, status) {
const err = new Error(message)
err.status = status
return err
}
export async function loginApi({ email, password }) {
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
return data
}
export async function registerApi({ email, firstname, lastname, password }) {
const res = await fetch(`${BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, firstname, lastname, password }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
return data
}
export async function verifyCodeApi({ email, code }) {
const res = await fetch(`${BASE}/verify-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Code invalide.', res.status)
return data
}
export async function deleteAccountApi() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/account`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la suppression.', res.status)
return data
}
export async function changePasswordApi({ currentPassword, newPassword }) {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/change-password`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors du changement de mot de passe.', res.status)
return data
}

View File

@@ -0,0 +1,32 @@
const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/employee`
function authHeaders() {
const token = localStorage.getItem('token')
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
}
function apiError(message, status) {
const err = new Error(message)
err.status = status
return err
}
export async function searchTicketApi(code) {
const res = await fetch(`${BASE}/ticket/${encodeURIComponent(code.toUpperCase())}`, {
headers: authHeaders(),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche.', res.status)
return data
}
export async function deliverClaimApi(claimId) {
const res = await fetch(`${BASE}/claim/${claimId}/deliver`, {
method: 'PATCH',
headers: authHeaders(),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la remise.', res.status)
return data
}

View File

@@ -0,0 +1,34 @@
const API_URL = import.meta.env.VITE_API_URL || ''
const BASE = `${API_URL}/api/game`
function apiError(message, status) {
const err = new Error(message)
err.status = status
return err
}
export async function myGainsApi() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/my-gains`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération des gains.', res.status)
return data
}
export async function participateApi(code) {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/participate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ code }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la participation.', res.status)
return data
}

View File

@@ -0,0 +1,8 @@
import '@testing-library/jest-dom'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
localStorage.clear()
})

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Connexion from '../../pages/Connexion'
import { renderWithProviders } from '../renderWithProviders'
import { loginApi } from '../../services/auth'
// On mocke le service d'API : les tests de composant ne font pas de vraies requêtes HTTP.
vi.mock('../../services/auth', () => ({
loginApi: vi.fn(),
}))
describe('Connexion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('affiche le formulaire de connexion', () => {
renderWithProviders(<Connexion />, { route: '/connexion' })
expect(screen.getByRole('heading', { name: /se connecter/i })).toBeInTheDocument()
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /se connecter/i })).toBeInTheDocument()
})
it('affiche le message de succès après une inscription', () => {
renderWithProviders(<Connexion />, {
route: { pathname: '/connexion', state: { registered: true } },
})
expect(screen.getByText(/compte créé avec succès/i)).toBeInTheDocument()
})
it("affiche l'erreur renvoyée par l'API en cas d'échec", async () => {
loginApi.mockRejectedValue(new Error('Email ou mot de passe incorrect.'))
renderWithProviders(<Connexion />, { route: '/connexion' })
await userEvent.type(screen.getByLabelText(/adresse email/i), 'test@example.com')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'mauvais-mdp')
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' })
})
it('connecte un client et redirige vers /mes-gains', async () => {
loginApi.mockResolvedValue({
token: 'jwt-token',
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
})
renderWithProviders(<Connexion />, {
route: '/connexion',
routes: { '/mes-gains': <p>Page Mes Gains</p> },
})
await userEvent.type(screen.getByLabelText(/adresse email/i), 'client@example.com')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText('Page Mes Gains')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBe('jwt-token')
})
it("connecte un admin et redirige vers /admin", async () => {
loginApi.mockResolvedValue({
token: 'jwt-token',
user: { id: 2, email: 'admin@example.com', role: 'ADMIN' },
})
renderWithProviders(<Connexion />, {
route: '/connexion',
routes: { '/admin': <p>Page Admin</p> },
})
await userEvent.type(screen.getByLabelText(/adresse email/i), 'admin@example.com')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText('Page Admin')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CookieBanner from '../../components/CookieBanner'
import { renderWithProviders } from '../renderWithProviders'
describe('CookieBanner', () => {
it("s'affiche quand aucun choix n'a été fait", () => {
renderWithProviders(<CookieBanner />)
expect(screen.getByText(/ce site utilise des cookies/i)).toBeInTheDocument()
})
it('enregistre le consentement et disparaît quand on accepte', async () => {
renderWithProviders(<CookieBanner />)
await userEvent.click(screen.getByRole('button', { name: /accepter/i }))
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
expect(localStorage.getItem('cookieConsent')).toBe('accepted')
})
it('enregistre le refus et disparaît quand on refuse', async () => {
renderWithProviders(<CookieBanner />)
await userEvent.click(screen.getByRole('button', { name: /refuser/i }))
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
expect(localStorage.getItem('cookieConsent')).toBe('refused')
})
it("ne s'affiche pas si un choix a déjà été fait", () => {
localStorage.setItem('cookieConsent', 'accepted')
renderWithProviders(<CookieBanner />)
expect(screen.queryByText(/ce site utilise des cookies/i)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Inscription from '../../pages/Inscription'
import { renderWithProviders } from '../renderWithProviders'
import { registerApi, verifyCodeApi } from '../../services/auth'
vi.mock('../../services/auth', () => ({
registerApi: vi.fn(),
verifyCodeApi: vi.fn(),
}))
// Remplit et soumet le formulaire d'inscription (étape 1).
async function remplirEtSoumettre() {
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
await userEvent.click(screen.getByRole('checkbox'))
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
}
// Saisit le code à 4 chiffres de l'étape de vérification.
async function saisirCode(code) {
const inputs = screen.getAllByRole('textbox')
for (let i = 0; i < 4; i++) {
await userEvent.type(inputs[i], code[i])
}
}
describe('Inscription', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("affiche le formulaire d'inscription", () => {
renderWithProviders(<Inscription />, { route: '/inscription' })
expect(screen.getByRole('heading', { name: /s'inscrire/i })).toBeInTheDocument()
expect(screen.getByLabelText(/adresse email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument()
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
})
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
renderWithProviders(<Inscription />, { route: '/inscription' })
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
expect(bouton).toBeDisabled()
await userEvent.click(screen.getByRole('checkbox'))
expect(bouton).toBeEnabled()
})
it("passe à l'étape de vérification après une inscription réussie", async () => {
registerApi.mockResolvedValue({ message: 'ok' })
renderWithProviders(<Inscription />, { route: '/inscription' })
await remplirEtSoumettre()
expect(registerApi).toHaveBeenCalledWith({
email: 'jean@example.com',
firstname: 'Jean',
lastname: 'Dupont',
password: 'MotDePasse123!',
})
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
})
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
renderWithProviders(<Inscription />, { route: '/inscription' })
await remplirEtSoumettre()
expect(await screen.findByText('Cet email est déjà utilisé.')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: /vérifiez votre email/i })).not.toBeInTheDocument()
})
it('connecte et redirige vers /saisir-code-unique quand le code est valide', async () => {
registerApi.mockResolvedValue({ message: 'ok' })
verifyCodeApi.mockResolvedValue({
token: 'jwt-token',
user: { id: 1, email: 'jean@example.com', role: 'CLIENT' },
})
renderWithProviders(<Inscription />, {
route: '/inscription',
routes: { '/saisir-code-unique': <p>Page Saisir Code</p> },
})
await remplirEtSoumettre()
await screen.findByRole('heading', { name: /vérifiez votre email/i })
await saisirCode('1234')
await userEvent.click(screen.getByRole('button', { name: /valider le code/i }))
expect(verifyCodeApi).toHaveBeenCalledWith({ email: 'jean@example.com', code: '1234' })
expect(await screen.findByText('Page Saisir Code')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBe('jwt-token')
})
it("affiche l'erreur et vide les champs quand le code est invalide", async () => {
registerApi.mockResolvedValue({ message: 'ok' })
verifyCodeApi.mockRejectedValue(new Error('Code invalide.'))
renderWithProviders(<Inscription />, { route: '/inscription' })
await remplirEtSoumettre()
await screen.findByRole('heading', { name: /vérifiez votre email/i })
await saisirCode('0000')
await userEvent.click(screen.getByRole('button', { name: /valider le code/i }))
expect(await screen.findByText('Code invalide.')).toBeInTheDocument()
for (const input of screen.getAllByRole('textbox')) {
expect(input).toHaveValue('')
}
})
})

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import ProtectedRoute from '../../components/ProtectedRoute'
import { renderWithProviders } from '../renderWithProviders'
describe('ProtectedRoute', () => {
it("redirige vers /connexion si l'utilisateur n'est pas connecté", () => {
renderWithProviders(
<ProtectedRoute>
<p>Contenu protégé</p>
</ProtectedRoute>,
{
route: '/mes-gains',
routes: { '/connexion': <p>Page Connexion</p> },
}
)
expect(screen.getByText('Page Connexion')).toBeInTheDocument()
expect(screen.queryByText('Contenu protégé')).not.toBeInTheDocument()
})
it("affiche le contenu si l'utilisateur est connecté", () => {
renderWithProviders(
<ProtectedRoute>
<p>Contenu protégé</p>
</ProtectedRoute>,
{
route: '/mes-gains',
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
}
)
expect(screen.getByText('Contenu protégé')).toBeInTheDocument()
})
it('affiche le contenu si le rôle est autorisé', () => {
renderWithProviders(
<ProtectedRoute roles={['ADMIN']}>
<p>Espace admin</p>
</ProtectedRoute>,
{
route: '/admin',
user: { id: 2, email: 'admin@example.com', role: 'ADMIN' },
}
)
expect(screen.getByText('Espace admin')).toBeInTheDocument()
})
it("redirige un client vers son espace s'il tente d'accéder à l'admin", () => {
renderWithProviders(
<ProtectedRoute roles={['ADMIN']}>
<p>Espace admin</p>
</ProtectedRoute>,
{
route: '/admin',
user: { id: 1, email: 'client@example.com', role: 'CLIENT' },
routes: { '/mes-gains': <p>Page Mes Gains</p> },
}
)
expect(screen.getByText('Page Mes Gains')).toBeInTheDocument()
expect(screen.queryByText('Espace admin')).not.toBeInTheDocument()
})
it("redirige un employé vers son espace s'il tente d'accéder à l'admin", () => {
renderWithProviders(
<ProtectedRoute roles={['ADMIN']}>
<p>Espace admin</p>
</ProtectedRoute>,
{
route: '/admin',
user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE' },
routes: { '/employe': <p>Page Employé</p> },
}
)
expect(screen.getByText('Page Employé')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SaisirCodeUnique from '../../pages/SaisirCodeUnique'
import Perdu from '../../pages/Perdu'
import { renderWithProviders } from '../renderWithProviders'
import { participateApi } from '../../services/game'
vi.mock('../../services/game', () => ({
participateApi: vi.fn(),
}))
const client = { id: 1, email: 'client@example.com', role: 'CLIENT' }
async function saisirEtValider(code) {
await userEvent.type(screen.getByPlaceholderText(/votre code/i), code)
await userEvent.click(screen.getByRole('button', { name: /valider/i }))
}
describe('SaisirCodeUnique', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('désactive le bouton tant que le code ne fait pas 10 caractères', async () => {
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
const bouton = screen.getByRole('button', { name: /valider/i })
expect(bouton).toBeDisabled()
await userEvent.type(screen.getByPlaceholderText(/votre code/i), 'ABCDE12345')
expect(bouton).toBeEnabled()
})
it('met le code en majuscules et l\'envoie à l\'API', async () => {
participateApi.mockResolvedValue({ gain: { name: 'Thé détox' }, claim: { id: 1 } })
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
await saisirEtValider('abcde12345')
expect(participateApi).toHaveBeenCalledWith('ABCDE12345')
})
it("affiche l'écran Félicitation puis le détail du lot quand le code est gagnant", async () => {
participateApi.mockResolvedValue({
gain: { name: 'Thé signature', description: 'Un thé d\'exception', value: 39 },
claim: { id: 1 },
})
renderWithProviders(<SaisirCodeUnique />, { route: '/saisir-code-unique', user: client })
await saisirEtValider('ABCDE12345')
// Overlay de félicitation
expect(await screen.findByText(/félicitation/i)).toBeInTheDocument()
expect(screen.getByText('Thé signature')).toBeInTheDocument()
// Détail du lot
await userEvent.click(screen.getByRole('button', { name: /découvrir mon lot/i }))
expect(screen.getByText(/valeur estimée/i)).toBeInTheDocument()
expect(screen.getByText('39 €')).toBeInTheDocument()
expect(screen.getByText(/en attente de remise/i)).toBeInTheDocument()
})
it("redirige vers /perdu avec le message de l'API quand le code est invalide", async () => {
participateApi.mockRejectedValue(new Error('Ce code a déjà été utilisé.'))
renderWithProviders(<SaisirCodeUnique />, {
route: '/saisir-code-unique',
user: client,
routes: { '/perdu': <Perdu /> },
})
await saisirEtValider('ABCDE12345')
expect(await screen.findByText(/réessayer la prochaine fois/i)).toBeInTheDocument()
expect(screen.getByText('Ce code a déjà été utilisé.')).toBeInTheDocument()
})
it('déconnecte et redirige vers /connexion si la session a expiré (401)', async () => {
participateApi.mockRejectedValue(
Object.assign(new Error('Session expirée'), { status: 401 })
)
renderWithProviders(<SaisirCodeUnique />, {
route: '/saisir-code-unique',
user: client,
routes: { '/connexion': <p>Page Connexion</p> },
})
await saisirEtValider('ABCDE12345')
expect(await screen.findByText('Page Connexion')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBeNull()
})
})

View File

@@ -0,0 +1,29 @@
import { render } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from '../context/AuthContext'
// Rend un composant avec les mêmes providers que l'appli (AuthContext + Router).
// - `route` : URL initiale (peut être un objet { pathname, state })
// - `user` : utilisateur pré-connecté (écrit dans localStorage avant le rendu,
// comme le ferait une vraie session)
// - `routes`: routes supplémentaires pour vérifier les redirections,
// ex: { '/mes-gains': <p>Page Mes Gains</p> }
export function renderWithProviders(ui, { route = '/', user = null, routes = {} } = {}) {
if (user) {
localStorage.setItem('user', JSON.stringify(user))
localStorage.setItem('token', 'fake-token')
}
return render(
<AuthProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path={typeof route === 'string' ? route : route.pathname} element={ui} />
{Object.entries(routes).map(([path, element]) => (
<Route key={path} path={path} element={element} />
))}
</Routes>
</MemoryRouter>
</AuthProvider>
)
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider, useAuth } from '../../context/AuthContext'
// Petit composant de test pour manipuler le contexte comme le ferait une page.
function Consumer() {
const { user, login, logout } = useAuth()
return (
<div>
<p>{user ? `Connecté : ${user.email}` : 'Déconnecté'}</p>
<button onClick={() => login({ email: 'a@b.fr', role: 'CLIENT' }, 'mon-jwt')}>
se connecter
</button>
<button onClick={logout}>se déconnecter</button>
</div>
)
}
describe('AuthContext', () => {
it("n'a pas d'utilisateur par défaut", () => {
render(
<AuthProvider>
<Consumer />
</AuthProvider>
)
expect(screen.getByText('Déconnecté')).toBeInTheDocument()
})
it('restaure la session depuis le localStorage', () => {
localStorage.setItem('user', JSON.stringify({ email: 'deja@la.fr', role: 'CLIENT' }))
render(
<AuthProvider>
<Consumer />
</AuthProvider>
)
expect(screen.getByText('Connecté : deja@la.fr')).toBeInTheDocument()
})
it('login met à jour le user et écrit token + user dans le localStorage', async () => {
render(
<AuthProvider>
<Consumer />
</AuthProvider>
)
await userEvent.click(screen.getByRole('button', { name: 'se connecter' }))
expect(screen.getByText('Connecté : a@b.fr')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBe('mon-jwt')
expect(JSON.parse(localStorage.getItem('user'))).toEqual({ email: 'a@b.fr', role: 'CLIENT' })
})
it('logout vide le user et le localStorage', async () => {
localStorage.setItem('token', 'mon-jwt')
localStorage.setItem('user', JSON.stringify({ email: 'a@b.fr', role: 'CLIENT' }))
render(
<AuthProvider>
<Consumer />
</AuthProvider>
)
await userEvent.click(screen.getByRole('button', { name: 'se déconnecter' }))
expect(screen.getByText('Déconnecté')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBeNull()
expect(localStorage.getItem('user')).toBeNull()
})
})

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { loginApi, registerApi, verifyCodeApi, changePasswordApi } from '../../services/auth'
// Tests unitaires du service auth : on mocke fetch, aucune vraie requête HTTP.
const mockFetch = vi.fn()
function jsonResponse(body, { ok = true, status = 200 } = {}) {
return Promise.resolve({ ok, status, json: () => Promise.resolve(body) })
}
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch)
mockFetch.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('loginApi', () => {
it('envoie email et mot de passe et renvoie les données', async () => {
const payload = { token: 'jwt', user: { email: 'a@b.fr', role: 'CLIENT' } }
mockFetch.mockReturnValue(jsonResponse(payload))
const data = await loginApi({ email: 'a@b.fr', password: 'secret' })
expect(data).toEqual(payload)
expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'a@b.fr', password: 'secret' }),
})
})
it("lève une erreur avec le message et le status de l'API en cas d'échec", async () => {
mockFetch.mockReturnValue(
jsonResponse({ message: 'Identifiants invalides.' }, { ok: false, status: 401 })
)
await expect(loginApi({ email: 'a@b.fr', password: 'faux' })).rejects.toMatchObject({
message: 'Identifiants invalides.',
status: 401,
})
})
it("utilise un message par défaut si l'API n'en renvoie pas", async () => {
mockFetch.mockReturnValue(jsonResponse({}, { ok: false, status: 500 }))
await expect(loginApi({ email: 'a@b.fr', password: 'x' })).rejects.toThrow(
'Erreur de connexion.'
)
})
})
describe('registerApi', () => {
it("envoie les informations d'inscription", async () => {
mockFetch.mockReturnValue(jsonResponse({ message: 'ok' }))
await registerApi({ email: 'a@b.fr', firstname: 'Jean', lastname: 'Dupont', password: 'secret' })
expect(mockFetch).toHaveBeenCalledWith('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'a@b.fr', firstname: 'Jean', lastname: 'Dupont', password: 'secret' }),
})
})
})
describe('verifyCodeApi', () => {
it('renvoie le token et le user quand le code est valide', async () => {
const payload = { token: 'jwt', user: { email: 'a@b.fr' } }
mockFetch.mockReturnValue(jsonResponse(payload))
const data = await verifyCodeApi({ email: 'a@b.fr', code: '1234' })
expect(data).toEqual(payload)
})
it('lève "Code invalide." par défaut en cas d\'échec', async () => {
mockFetch.mockReturnValue(jsonResponse({}, { ok: false, status: 400 }))
await expect(verifyCodeApi({ email: 'a@b.fr', code: '0000' })).rejects.toThrow('Code invalide.')
})
})
describe('changePasswordApi', () => {
it('envoie le token du localStorage dans le header Authorization', async () => {
localStorage.setItem('token', 'mon-jwt')
mockFetch.mockReturnValue(jsonResponse({ message: 'ok' }))
await changePasswordApi({ currentPassword: 'ancien', newPassword: 'nouveau' })
expect(mockFetch).toHaveBeenCalledWith('/api/auth/change-password', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mon-jwt',
},
body: JSON.stringify({ currentPassword: 'ancien', newPassword: 'nouveau' }),
})
})
})

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { myGainsApi, participateApi } from '../../services/game'
// Tests unitaires du service game : on mocke fetch, aucune vraie requête HTTP.
const mockFetch = vi.fn()
function jsonResponse(body, { ok = true, status = 200 } = {}) {
return Promise.resolve({ ok, status, json: () => Promise.resolve(body) })
}
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch)
mockFetch.mockReset()
localStorage.setItem('token', 'mon-jwt')
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('myGainsApi', () => {
it('appelle /api/game/my-gains avec le token et renvoie les gains', async () => {
const payload = { gains: [{ id: 1, name: 'Thé détox' }] }
mockFetch.mockReturnValue(jsonResponse(payload))
const data = await myGainsApi()
expect(data).toEqual(payload)
expect(mockFetch).toHaveBeenCalledWith('/api/game/my-gains', {
headers: { Authorization: 'Bearer mon-jwt' },
})
})
it("lève une erreur avec le status en cas d'échec (ex: session expirée)", async () => {
mockFetch.mockReturnValue(
jsonResponse({ message: 'Non autorisé.' }, { ok: false, status: 401 })
)
await expect(myGainsApi()).rejects.toMatchObject({ message: 'Non autorisé.', status: 401 })
})
})
describe('participateApi', () => {
it('envoie le code en POST avec le token', async () => {
const payload = { gain: { name: 'Thé signature' }, claim: { id: 1 } }
mockFetch.mockReturnValue(jsonResponse(payload))
const data = await participateApi('ABCDE12345')
expect(data).toEqual(payload)
expect(mockFetch).toHaveBeenCalledWith('/api/game/participate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mon-jwt',
},
body: JSON.stringify({ code: 'ABCDE12345' }),
})
})
it("transmet le message d'erreur de l'API (code déjà utilisé, invalide...)", async () => {
mockFetch.mockReturnValue(
jsonResponse({ message: 'Ce code a déjà été utilisé.' }, { ok: false, status: 400 })
)
await expect(participateApi('ABCDE12345')).rejects.toThrow('Ce code a déjà été utilisé.')
})
})

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { useState } from 'react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useHandleAuthError } from '../../hooks/useHandleAuthError'
import { renderWithProviders } from '../renderWithProviders'
// Composant de test : déclenche le hook avec l'erreur passée en prop.
function TestComponent({ error }) {
const handleAuthError = useHandleAuthError()
const [handled, setHandled] = useState(null)
return (
<div>
<button onClick={() => setHandled(handleAuthError(error))}>déclencher</button>
{handled !== null && <p>géré : {String(handled)}</p>}
</div>
)
}
describe('useHandleAuthError', () => {
it('déconnecte et redirige vers /connexion sur une erreur 401', async () => {
const error = Object.assign(new Error('Session expirée'), { status: 401 })
renderWithProviders(<TestComponent error={error} />, {
route: '/mes-gains',
user: { email: 'a@b.fr', role: 'CLIENT' },
routes: { '/connexion': <p>Page Connexion</p> },
})
await userEvent.click(screen.getByRole('button', { name: 'déclencher' }))
expect(screen.getByText('Page Connexion')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBeNull()
expect(localStorage.getItem('user')).toBeNull()
})
it('ne fait rien et renvoie false pour une autre erreur', async () => {
const error = Object.assign(new Error('Erreur serveur'), { status: 500 })
renderWithProviders(<TestComponent error={error} />, {
route: '/mes-gains',
user: { email: 'a@b.fr', role: 'CLIENT' },
routes: { '/connexion': <p>Page Connexion</p> },
})
await userEvent.click(screen.getByRole('button', { name: 'déclencher' }))
expect(screen.getByText('géré : false')).toBeInTheDocument()
expect(localStorage.getItem('token')).toBe('fake-token')
})
})

View File

@@ -4,4 +4,14 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:5000',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
},
})