From 68a96502232106440c89dd95e867f6db37776712 Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Wed, 1 Jul 2026 22:06:22 +0200 Subject: [PATCH] mise a jour --- backend/.env.example | 9 +- backend/package-lock.json | 39 +--- backend/package.json | 2 +- backend/src/controllers/auth.controller.js | 33 ++- backend/src/routes/auth.routes.js | 5 +- backend/src/scripts/createAdmin.js | 2 +- frontend/src/App.jsx | 3 + frontend/src/components/Header.jsx | 7 +- frontend/src/components/ProtectedRoute.jsx | 6 +- frontend/src/components/UserNav.jsx | 6 +- frontend/src/context/AuthContext.jsx | 31 +++ frontend/src/hooks/useHandleAuthError.js | 16 ++ frontend/src/pages/Connexion.jsx | 5 +- frontend/src/pages/Employe.jsx | 229 ++++++++++++++++----- frontend/src/pages/Inscription.jsx | 4 +- frontend/src/pages/MesGains.jsx | 127 +++++++++--- frontend/src/pages/Profile.jsx | 142 ++++++++++--- frontend/src/pages/SaisirCodeUnique.jsx | 140 +++++++++++-- frontend/src/services/admin.js | 14 +- frontend/src/services/auth.js | 25 ++- frontend/src/services/employee.js | 31 +++ frontend/src/services/game.js | 10 +- 22 files changed, 719 insertions(+), 167 deletions(-) create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/hooks/useHandleAuthError.js create mode 100644 frontend/src/services/employee.js diff --git a/backend/.env.example b/backend/.env.example index 97c309f..efb7104 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,4 +9,11 @@ MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_USER=yourmail_adress@gmail.com MAIL_PASS=_______________________ -MAIL_FROM="Thé Tip Top " \ No newline at end of file +MAIL_FROM="Thé Tip Top " + +# SMTP (email verification + password reset) +SMTP_HOST="smtp.gmail.com" +SMTP_PORT="587" +SMTP_USER="engongomo.emma27@gmail.com" +SMTP_PASS="your-gmail-app-password" +SMTP_FROM="BizManager " \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 40a1584..b957f37 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", @@ -108,6 +108,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -603,31 +604,6 @@ "dev": true, "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "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==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -635,7 +611,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1987,6 +1962,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2305,6 +2281,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3171,6 +3148,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5457,9 +5435,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" @@ -5894,6 +5872,7 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" diff --git a/backend/package.json b/backend/package.json index abc8476..30506c4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,7 +20,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", diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 7da0d51..4773d9f 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -304,11 +304,42 @@ const me = async (req, res) => { }); }; +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, resetPassword, - forgotPassword + forgotPassword, + changePassword, }; \ No newline at end of file diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 1c61ae4..7525474 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,5 +1,5 @@ const express = require("express"); -const { register, login, verifyEmail, forgotPassword, resetPassword, me } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, forgotPassword, resetPassword, me, changePassword } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); @@ -155,7 +155,8 @@ router.post("/forgot-password", forgotPassword); * 500: * description: Erreur serveur. */ -router.post("/reset-password/:token",resetPassword); +router.post("/reset-password/:token", resetPassword); +router.patch("/change-password", authMiddleware, changePassword); module.exports = router; \ No newline at end of file diff --git a/backend/src/scripts/createAdmin.js b/backend/src/scripts/createAdmin.js index 5738198..1255a85 100644 --- a/backend/src/scripts/createAdmin.js +++ b/backend/src/scripts/createAdmin.js @@ -23,7 +23,7 @@ async function createAdmin() { }, }) - console.log('✅ Admin créé avec succès') + console.log(' Admin créé avec succès') console.log(' Email :', email) console.log(' Mot de passe :', password) } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7263b3c..477be20 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ 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 Connexion from './pages/Connexion' @@ -21,6 +22,7 @@ import Contact from './pages/Contact.jsx' export default function App() { return ( + } /> } /> @@ -73,6 +75,7 @@ export default function App() { } /> + ) } diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index a9b7291..e3bfc40 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,18 +1,17 @@ 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 = JSON.parse(localStorage.getItem('user') || 'null') + const { user, logout } = useAuth() const isLoggedIn = !!user const handleLogout = () => { - localStorage.removeItem('token') - localStorage.removeItem('user') + logout() setMenuOpen(false) navigate('/connexion') } diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index 968d36b..f78f05c 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -1,10 +1,10 @@ import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' export default function ProtectedRoute({ children, roles }) { - const token = localStorage.getItem('token') - const user = JSON.parse(localStorage.getItem('user') || 'null') + const { user } = useAuth() - if (!token || !user) { + if (!user) { return } diff --git a/frontend/src/components/UserNav.jsx b/frontend/src/components/UserNav.jsx index 1367a3b..6582af3 100644 --- a/frontend/src/components/UserNav.jsx +++ b/frontend/src/components/UserNav.jsx @@ -40,10 +40,10 @@ export default function UserNav({ active }) { {tab.icon} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..552cb66 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext) +} diff --git a/frontend/src/hooks/useHandleAuthError.js b/frontend/src/hooks/useHandleAuthError.js new file mode 100644 index 0000000..be472df --- /dev/null +++ b/frontend/src/hooks/useHandleAuthError.js @@ -0,0 +1,16 @@ +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export function useHandleAuthError() { + const { logout } = useAuth() + const navigate = useNavigate() + + return function handleAuthError(err) { + if (err.status === 401) { + logout() + navigate('/connexion') + return true + } + return false + } +} diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index eb1d567..acf2a7f 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -1,10 +1,12 @@ import { useState } from 'react' 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) @@ -19,8 +21,7 @@ export default function Connexion() { setLoading(true) try { const { token, user } = await loginApi(form) - localStorage.setItem('token', token) - localStorage.setItem('user', JSON.stringify(user)) + login(user, token) if (user.role === 'ADMIN') navigate('/admin') else if (user.role === 'EMPLOYEE') navigate('/employe') else navigate('/mes-gains') diff --git a/frontend/src/pages/Employe.jsx b/frontend/src/pages/Employe.jsx index 7d7ab50..263ec15 100644 --- a/frontend/src/pages/Employe.jsx +++ b/frontend/src/pages/Employe.jsx @@ -1,74 +1,211 @@ import { useState } from 'react' +import { searchTicketApi, deliverClaimApi } from '../services/employee' +import { useHandleAuthError } from '../hooks/useHandleAuthError' + +function formatDate(dateStr) { + return new Date(dateStr).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +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' }, +} export default function Employe() { - const [code, setCode] = useState('') - const [gain, setGain] = useState(null) + 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 = (e) => { + const handleSearch = async (e) => { e.preventDefault() - // TODO: connecter au backend - setGain({ - code, - lot: 'Thé signature (100g)', - client: 'Jean Dupont', - email: 'jean.dupont@email.com', - status: 'PENDING', - }) + 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 = () => { - // TODO: connecter au backend - setGain({ ...gain, status: 'DELIVERED' }) + 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 ( -
+
-

Espace Employé

-

Vérifier et remettre un lot

-
+ {/* En-tête */} +
+

Espace Employé

+

Recherchez un code ticket pour remettre le lot au client.

+
+ + {/* Formulaire de recherche */} + 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 text-sm" + 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]" />
- {gain && ( -
-
-
-

{gain.lot}

-

{gain.code}

-
- - {gain.status === 'DELIVERED' ? 'Remis' : 'À remettre'} - -
-
-

Client : {gain.client}

-

Email : {gain.email}

-
- {gain.status === 'PENDING' && ( - - )} + {/* Erreur de recherche */} + {searchError && ( +
+ {searchError}
)} + + {/* Résultat */} + {result && ( +
+ + {/* Bandeau succès remise */} + {success && ( +
+ + + +

Lot remis avec succès — espace client mis à jour.

+
+ )} + + {/* En-tête carte : lot + statut */} +
+
+

{result.lot.name}

+

{result.lot.description}

+ {result.lot.value && ( +

Valeur : {result.lot.value} €

+ )} +
+ + {statusCfg.label} + +
+ + {/* Infos client */} +
+

Client

+
+
+ {(result.client.firstname?.[0] ?? '?').toUpperCase()}{(result.client.lastname?.[0] ?? '?').toUpperCase()} +
+
+

+ {result.client.firstname} {result.client.lastname} +

+

{result.client.email}

+
+
+
+ + {/* Infos ticket */} +
+

Ticket

+

+ Code :{' '} + {result.ticket.code} +

+

+ Utilisé le : {formatDate(result.ticket.usedAt)} +

+ {result.claim.claimedAt && ( +

+ Remis le : {formatDate(result.claim.claimedAt)} +

+ )} +
+ + {/* Actions */} +
+ {deliverError && ( +
+ {deliverError} +
+ )} + + {result.claim.status === 'PENDING' && !success && ( + + )} + + {result.claim.status === 'CANCELLED' && ( +
+ Ce gain est expiré et ne peut plus être remis. +
+ )} + + +
+ +
+ )} +
) diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index d959977..9f7521e 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -15,7 +15,7 @@ export default function Inscription() { useEffect(() => { if (successName === null) return if (countdown === 0) { - navigate('/saisir-code-unique') + navigate('/connexion', { state: { registered: true } }) return } const t = setTimeout(() => setCountdown((c) => c - 1), 1000) @@ -199,7 +199,7 @@ export default function Inscription() { Bienvenue {successName} !!

- Votre compte a été créé avec succès. Vous serez redirigé vers l'étape suivante dans{' '} + Votre compte a été créé avec succès. Un email de validation vous a été envoyé. Vous serez redirigé vers la connexion dans{' '} {countdown} secondes.

diff --git a/frontend/src/pages/MesGains.jsx b/frontend/src/pages/MesGains.jsx index 5646002..134f347 100644 --- a/frontend/src/pages/MesGains.jsx +++ b/frontend/src/pages/MesGains.jsx @@ -1,32 +1,76 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { myGainsApi } from '../services/game' import UserNav from '../components/UserNav' +import { useHandleAuthError } from '../hooks/useHandleAuthError' function formatDate(dateStr) { - return new Date(dateStr).toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) + 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() { - const [gains, setGains] = useState([]) + const handleAuthError = useHandleAuthError() + const [gains, setGains] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState('') + const [error, setError] = useState('') + const [refreshing, setRefreshing] = useState(false) - useEffect(() => { - myGainsApi() - .then((data) => setGains(data.gains)) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)) - }, []) + 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 (
- {loading &&

Chargement...

} +
+

Historique de mes gains

+ +
+ + {loading &&

Chargement…

} {error && (
@@ -35,23 +79,50 @@ export default function MesGains() { )} {!loading && !error && gains.length === 0 && ( -

Aucun gain pour le moment.

+
+

Aucun gain pour le moment.

+

Saisissez un code pour participer !

+
)}
- {gains.map((gain, index) => ( -
-
-
- {index < gains.length - 1 &&
} + {gains.map((gain, index) => { + const statusCfg = CLAIM_STATUS[gain.claimStatus] ?? CLAIM_STATUS.PENDING + return ( +
+
+
+ {index < gains.length - 1 &&
} +
+
+
+

{gain.lot}

+ + {statusCfg.label} + +
+

{gain.description}

+ {gain.value && ( +

Valeur : {gain.value} €

+ )} +
+

Gagné le {formatDate(gain.wonAt)}

+ {timeAgo(gain.wonAt) && ( + + {timeAgo(gain.wonAt)} + + )} +
+

+ Code utilisé :{' '} + {gain.ticketCode} +

+
-
-

{gain.lot}

-

{gain.description}

-

Gain obtenu le {formatDate(gain.wonAt)}

-
-
- ))} + ) + })}
) diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index aa80be0..da50573 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,54 +1,150 @@ +import { useState } from 'react' import UserNav from '../components/UserNav' +import { changePasswordApi } from '../services/auth' +import { useHandleAuthError } from '../hooks/useHandleAuthError' export default function Profile() { const user = JSON.parse(localStorage.getItem('user') || '{}') const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase() + const handleAuthError = useHandleAuthError() - const handleSubmit = (e) => { + 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() - // TODO: connecter au backend pour update profil - alert('Modification du profil à venir') + 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 }) => ( + + {visible + ? <> + : + } + + ) + return ( -
+
+ {/* Avatar */}
{initials}
-
+ {/* Infos du compte (lecture seule) */} +
- - + +
+ {user.email || '—'} +
- -
+ +
{user.lastname || '—'}
- -
+ +
{user.firstname || '—'}
- - +
+ + {/* Séparateur */} +
+ + {/* Section changement de mot de passe */} +
+

Changer mon mot de passe

+ + {pwSuccess && ( +
+ + + + Mot de passe modifié avec succès. +
+ )} + + {pwError && ( +
+ {pwError} +
+ )} + +
+ {[ + { 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 }) => ( +
+ +
+ + +
+
+ ))} + + +
+
) } diff --git a/frontend/src/pages/SaisirCodeUnique.jsx b/frontend/src/pages/SaisirCodeUnique.jsx index b7c0ee3..ffc11b1 100644 --- a/frontend/src/pages/SaisirCodeUnique.jsx +++ b/frontend/src/pages/SaisirCodeUnique.jsx @@ -2,12 +2,24 @@ 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 [code, setCode] = useState('') - const [loading, setLoading] = useState(false) - const [gain, setGain] = useState(null) + 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() @@ -15,16 +27,27 @@ export default function SaisirCodeUnique() { try { const data = await participateApi(code.trim().toUpperCase()) setGain(data.gain) + setClaim(data.claim) + setWonAt(new Date()) } catch (err) { - navigate('/perdu', { state: { message: err.message } }) + if (!handleAuthError(err)) { + navigate('/perdu', { state: { message: err.message } }) + } } finally { setLoading(false) } } + const handleClose = () => { + setGain(null) + setClaim(null) + setWonAt(null) + setShowDetail(false) + setCode('') + } + return (
-
@@ -42,7 +65,6 @@ export default function SaisirCodeUnique() { 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]" /> -
)} + + {/* ── Modal détail du lot ── */} + {gain && showDetail && ( +
+
+ + {/* En-tête coloré */} +
+ +
+
+ + + +
+
+

Votre lot

+

{gain.name}

+
+
+
+ + {/* Corps */} +
+ + {/* Description */} +

{gain.description}

+ + {/* Valeur */} + {gain.value && ( +
+ Valeur estimée + {gain.value} € +
+ )} + + {/* Code utilisé */} +
+ Code utilisé + {code} +
+ + {/* Date & heure */} + {wonAt && ( +
+ Obtenu le + {formatDateTime(wonAt)} +
+ )} + + {/* Statut */} +
+ Statut + + En attente de remise + +
+ + {/* Info remise */} +
+ + + +

+ Présentez ce code en boutique Thé Tip Top pour récupérer votre lot. +

+
+
+ + {/* Actions */} +
+ + +
+ +
+
+ )}
) } diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 609412b..09f2da3 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -5,24 +5,30 @@ function authHeaders() { } } +function apiError(message, status) { + const err = new Error(message) + err.status = status + return err +} + export async function getStatsApi() { const res = await fetch('/api/admin/stats', { headers: authHeaders() }) const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur stats.') + if (!res.ok) throw apiError(data.message || 'Erreur stats.', res.status) return data } export async function getUsersApi() { const res = await fetch('/api/admin/users', { headers: authHeaders() }) const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur utilisateurs.') + if (!res.ok) throw apiError(data.message || 'Erreur utilisateurs.', res.status) return data } export async function drawWinnerApi() { const res = await fetch('/api/admin/draw', { method: 'POST', headers: authHeaders() }) const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur tirage.') + if (!res.ok) throw apiError(data.message || 'Erreur tirage.', res.status) return data } @@ -30,6 +36,6 @@ export async function getWinnerApi() { const res = await fetch('/api/admin/draw', { headers: authHeaders() }) if (res.status === 404) return null const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur gagnant.') + if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status) return data } diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index b251768..2858198 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -1,5 +1,11 @@ const BASE = '/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', @@ -7,7 +13,7 @@ export async function loginApi({ email, password }) { body: JSON.stringify({ email, password }), }) const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur de connexion.') + if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status) return data } @@ -18,6 +24,21 @@ export async function registerApi({ email, firstname, lastname, password }) { body: JSON.stringify({ email, firstname, lastname, password }), }) const data = await res.json() - if (!res.ok) throw new Error(data.message || "Erreur lors de l'inscription.") + if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", 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 } diff --git a/frontend/src/services/employee.js b/frontend/src/services/employee.js new file mode 100644 index 0000000..a127fb0 --- /dev/null +++ b/frontend/src/services/employee.js @@ -0,0 +1,31 @@ +const BASE = '/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 +} diff --git a/frontend/src/services/game.js b/frontend/src/services/game.js index c3fe267..1312a56 100644 --- a/frontend/src/services/game.js +++ b/frontend/src/services/game.js @@ -1,10 +1,16 @@ +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('/api/game/my-gains', { headers: { Authorization: `Bearer ${token}` }, }) const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur lors de la récupération des gains.') + if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération des gains.', res.status) return data } @@ -19,6 +25,6 @@ export async function participateApi(code) { body: JSON.stringify({ code }), }) const data = await res.json() - if (!res.ok) throw new Error(data.message || 'Erreur lors de la participation.') + if (!res.ok) throw apiError(data.message || 'Erreur lors de la participation.', res.status) return data }