mise a jour
This commit is contained in:
@@ -4,16 +4,10 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
|||||||
|
|
||||||
JWT_SECRET="your_secret_key"
|
JWT_SECRET="your_secret_key"
|
||||||
|
|
||||||
APP_URL=http://localhost:5000
|
APP_URL="http://localhost:5000"
|
||||||
MAIL_HOST=smtp.gmail.com
|
MAIL_HOST="smtp.gmail.com"
|
||||||
MAIL_PORT=587
|
MAIL_PORT="587"
|
||||||
MAIL_USER=yourmail_adress@gmail.com
|
MAIL_USER="engongomo.emma27@gmail.com"
|
||||||
MAIL_PASS=_______________________
|
MAIL_PASS="rczo zqps hsnm msdm"
|
||||||
MAIL_FROM="Thé Tip Top <yourmail_adress@gmail.com>"
|
MAIL_FROM="Thé Tip Top <engongomo.emma27@gmail.com>"
|
||||||
|
|
||||||
# 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 <engongomo.emma27@gmail.com>"
|
|
||||||
@@ -34,8 +34,8 @@ const register = async (req, res) => {
|
|||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
const emailVerifyToken = crypto.randomBytes(32).toString("hex");
|
const emailVerifyToken = String(Math.floor(1000 + Math.random() * 9000));
|
||||||
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 60); // 1h
|
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 15); // 15 min
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -62,23 +62,25 @@ const register = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const verifyUrl = `${process.env.APP_URL}/api/auth/verify-email/${emailVerifyToken}`;
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Validation de votre compte Thé Tip Top",
|
subject: "Votre code de validation Thé Tip Top",
|
||||||
html: `
|
html: `
|
||||||
<h2>Bienvenue ${firstname}</h2>
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
<p>Merci pour votre inscription.</p>
|
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||||
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p>
|
<p>Merci pour votre inscription sur Thé Tip Top.</p>
|
||||||
<a href="${verifyUrl}">Valider mon compte</a>
|
<p>Voici votre code de validation à 4 chiffres :</p>
|
||||||
<p>Ce lien expire dans 1 heure.</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({
|
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,
|
user,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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({
|
return res.status(403).json({
|
||||||
message: "Veuillez valider votre adresse email avant de vous connecter.",
|
message: "Veuillez valider votre adresse email avant de vous connecter.",
|
||||||
});
|
});
|
||||||
@@ -298,12 +300,106 @@ 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) => {
|
const me = async (req, res) => {
|
||||||
return res.json({
|
return res.json({
|
||||||
user: req.user,
|
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) => {
|
const changePassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
@@ -339,7 +435,9 @@ module.exports = {
|
|||||||
login,
|
login,
|
||||||
me,
|
me,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
|
verifyCode,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
deleteAccount,
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { register, login, verifyEmail, forgotPassword, resetPassword, me, changePassword } = 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 authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||||
@@ -157,6 +157,8 @@ router.post("/forgot-password", forgotPassword);
|
|||||||
*/
|
*/
|
||||||
router.post("/reset-password/:token", resetPassword);
|
router.post("/reset-password/:token", resetPassword);
|
||||||
|
|
||||||
|
router.post("/verify-code", verifyCode);
|
||||||
router.patch("/change-password", authMiddleware, changePassword);
|
router.patch("/change-password", authMiddleware, changePassword);
|
||||||
|
router.delete("/account", authMiddleware, deleteAccount);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -6,8 +6,13 @@ async function createAdmin() {
|
|||||||
const password = 'Admin@TipTop2026!'
|
const password = 'Admin@TipTop2026!'
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email } })
|
const existing = await prisma.user.findUnique({ where: { email } })
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log('Admin déjà existant :', email)
|
await prisma.user.update({
|
||||||
|
where: { email },
|
||||||
|
data: { isEmailVerified: true, role: 'ADMIN' },
|
||||||
|
})
|
||||||
|
console.log('Admin mis à jour :', email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +25,13 @@ async function createAdmin() {
|
|||||||
email,
|
email,
|
||||||
password: hashed,
|
password: hashed,
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
|
isEmailVerified: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(' Admin créé avec succès')
|
console.log('Admin créé avec succès')
|
||||||
console.log(' Email :', email)
|
console.log(' Email :', email)
|
||||||
console.log(' Mot de passe :', password)
|
console.log(' Mot de passe:', password)
|
||||||
}
|
}
|
||||||
|
|
||||||
createAdmin()
|
createAdmin()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import { AuthProvider } from './context/AuthContext.jsx'
|
import { AuthProvider } from './context/AuthContext.jsx'
|
||||||
import Layout from './components/Layout.jsx'
|
import Layout from './components/Layout.jsx'
|
||||||
import ProtectedRoute from './components/ProtectedRoute.jsx'
|
import ProtectedRoute from './components/ProtectedRoute.jsx'
|
||||||
|
import CookieBanner from './components/CookieBanner.jsx'
|
||||||
import Connexion from './pages/Connexion'
|
import Connexion from './pages/Connexion'
|
||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
@@ -24,7 +25,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/connexion" replace />} />
|
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<CookieBanner />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
41
frontend/src/components/CookieBanner.jsx
Normal file
41
frontend/src/components/CookieBanner.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
@@ -5,12 +6,12 @@ export function useHandleAuthError() {
|
|||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return function handleAuthError(err) {
|
return useCallback((err) => {
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
logout()
|
logout()
|
||||||
navigate('/connexion')
|
navigate('/connexion')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}, [logout, navigate])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi } from '../services/admin'
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi } from '../services/admin'
|
||||||
|
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||||
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
function StatCard({ label, value, sub }) {
|
function StatCard({ label, value, sub }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow p-4 text-center">
|
<div className="bg-white rounded-xl shadow p-4 text-center">
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-green-700">{value ?? '—'}</p>
|
<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>
|
<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>}
|
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function Admin() {
|
||||||
|
const handleAuthError = useHandleAuthError()
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [winner, setWinner] = useState(undefined)
|
const [winner, setWinner] = useState(undefined)
|
||||||
@@ -19,6 +29,16 @@ export default function Admin() {
|
|||||||
const [drawError, setDrawError] = useState('')
|
const [drawError, setDrawError] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||||
@@ -27,24 +47,66 @@ export default function Admin() {
|
|||||||
setUsers(u.users)
|
setUsers(u.users)
|
||||||
setWinner(w)
|
setWinner(w)
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e.message))
|
.catch((e) => {
|
||||||
|
if (!handleAuthError(e)) setError(e.message)
|
||||||
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDraw = async () => {
|
const handleDraw = async () => {
|
||||||
if (!window.confirm('Lancer le tirage au sort ? Cette action est irréversible.')) return
|
setShowConfirm(false)
|
||||||
setDrawing(true)
|
setDrawing(true)
|
||||||
setDrawError('')
|
setDrawError('')
|
||||||
try {
|
try {
|
||||||
const data = await drawWinnerApi()
|
const data = await drawWinnerApi()
|
||||||
setWinner(data)
|
setWinner(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDrawError(e.message)
|
if (!handleAuthError(e)) setDrawError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
setDrawing(false)
|
setDrawing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
if (loading) return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<p className="text-gray-400">Chargement...</p>
|
<p className="text-gray-400">Chargement...</p>
|
||||||
@@ -57,6 +119,8 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const winnerUser = winner?.winner?.user
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
@@ -98,7 +162,7 @@ export default function Admin() {
|
|||||||
<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 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-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-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-green-700 font-semibold text-xs sm:text-sm">{lot._count.participations}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -107,31 +171,147 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tirage au sort */}
|
{/* ── Remise de lot ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<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-3">Grand tirage au sort</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
||||||
|
|
||||||
{winner?.winner ? (
|
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<input
|
||||||
<p className="text-green-700 font-semibold mb-1">🏆 Gagnant tiré au sort</p>
|
type="text"
|
||||||
<p className="text-gray-800 font-medium">
|
value={ticketCode}
|
||||||
{winner.winner.user.firstname} {winner.winner.user.lastname}
|
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
||||||
</p>
|
placeholder="Code ticket (10 caractères)"
|
||||||
<p className="text-gray-500 text-sm">{winner.winner.user.email}</p>
|
maxLength={10}
|
||||||
<p className="text-gray-400 text-xs mt-1">
|
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]"
|
||||||
Lot : {winner.winner.prize}
|
/>
|
||||||
|
<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>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
/* 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
|
<button
|
||||||
onClick={handleDraw}
|
onClick={() => setShowConfirm(true)}
|
||||||
disabled={drawing}
|
disabled={drawing || (stats?.users ?? 0) === 0}
|
||||||
className="bg-green-700 hover:bg-green-800 disabled:opacity-60 text-white font-semibold px-5 py-2.5 rounded-lg transition-colors text-sm"
|
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"
|
||||||
>
|
>
|
||||||
{drawing ? 'Tirage en cours...' : 'Lancer le tirage'}
|
<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>
|
</button>
|
||||||
{drawError && <p className="text-red-500 text-xs">{drawError}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +344,7 @@ export default function Admin() {
|
|||||||
{user.firstname} {user.lastname}
|
{user.firstname} {user.lastname}
|
||||||
</td>
|
</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-gray-500 text-xs">{user.email}</td>
|
||||||
<td className="py-3 px-4 sm:px-0 text-right text-green-700 font-semibold text-xs sm:text-sm">
|
<td className="py-3 px-4 sm:px-0 text-right text-[#006B6B] font-semibold text-xs sm:text-sm">
|
||||||
{user._count.participations}
|
{user._count.participations}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -175,6 +355,51 @@ export default function Admin() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { registerApi } from '../services/auth'
|
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Inscription() {
|
export default function Inscription() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
|
// Étape : 'form' | 'verify'
|
||||||
|
const [step, setStep] = useState('form')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' })
|
const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' })
|
||||||
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [successName, setSuccessName] = useState(null)
|
|
||||||
const [countdown, setCountdown] = useState(5)
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Code à 4 chiffres
|
||||||
if (successName === null) return
|
const [digits, setDigits] = useState(['', '', '', ''])
|
||||||
if (countdown === 0) {
|
const [codeError, setCodeError] = useState('')
|
||||||
navigate('/connexion', { state: { registered: true } })
|
const [codeLoading, setCodeLoading] = useState(false)
|
||||||
return
|
const inputRefs = [useRef(), useRef(), useRef(), useRef()]
|
||||||
}
|
|
||||||
const t = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [successName, countdown, navigate])
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
const { name, value } = e.target
|
|
||||||
setForm({ ...form, [name]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Étape 1 — Inscription
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -38,7 +36,8 @@ export default function Inscription() {
|
|||||||
lastname: form.lastName,
|
lastname: form.lastName,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
})
|
})
|
||||||
setSuccessName(form.firstName)
|
setEmail(form.email)
|
||||||
|
setStep('verify')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,9 +45,106 @@ export default function Inscription() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// É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 (
|
return (
|
||||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
<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>
|
||||||
|
|
||||||
|
{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"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={1}
|
||||||
|
value={d}
|
||||||
|
onChange={(e) => handleDigit(i, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleDigitKeyDown(i, e)}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{codeLoading ? 'Vérification…' : 'Valider le code'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">S'inscrire</h1>
|
<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>
|
<p className="text-gray-500 mt-1 text-sm">Créer votre compte</p>
|
||||||
@@ -62,70 +158,30 @@ export default function Inscription() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">
|
<label className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
|
||||||
Adresse email <span className="text-gray-800">*</span>
|
<input type="email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
|
||||||
</label>
|
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]" />
|
||||||
<input
|
|
||||||
type="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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">
|
<label className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
|
||||||
Nom <span className="text-gray-800">*</span>
|
<input type="text" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
|
||||||
</label>
|
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]" />
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">
|
<label className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
|
||||||
Prénom <span className="text-gray-800">*</span>
|
<input type="text" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
|
||||||
</label>
|
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]" />
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-800 mb-1">
|
<label className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
Mot de passe <span className="text-gray-800">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input type={showPassword ? 'text' : 'password'} name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
type={showPassword ? 'text' : 'password'}
|
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]" />
|
||||||
name="password"
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
value={form.password}
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||||
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 le mot de passe"
|
|
||||||
>
|
|
||||||
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
|
<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" />}
|
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,31 +190,19 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
|
||||||
type="checkbox"
|
className="mt-0.5 w-4 h-4 accent-[#006B6B] flex-shrink-0" />
|
||||||
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">
|
<span className="text-xs text-gray-500 leading-relaxed">
|
||||||
J'accepte les{' '}
|
J'accepte les{' '}
|
||||||
<Link to="/mentions-legales" className="text-[#006B6B] hover:underline font-medium">
|
<Link to="/mentions-legales" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
|
||||||
conditions d'utilisation
|
|
||||||
</Link>{' '}
|
|
||||||
et la{' '}
|
et la{' '}
|
||||||
<Link to="/confidentialite" className="text-[#006B6B] hover:underline font-medium">
|
<Link to="/confidentialite" className="text-[#006B6B] hover:underline font-medium">politique de confidentialité</Link>
|
||||||
politique de confidentialité
|
|
||||||
</Link>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button type="submit" disabled={loading || !acceptedTerms}
|
||||||
type="submit"
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
disabled={loading || !acceptedTerms}
|
{loading ? 'Inscription…' : "S'inscrire"}
|
||||||
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -183,28 +227,6 @@ export default function Inscription() {
|
|||||||
Vous avez déjà un compte ?{' '}
|
Vous avez déjà un compte ?{' '}
|
||||||
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">Se connecter</Link>
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">Se connecter</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Modal succès */}
|
|
||||||
{successName !== null && (
|
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-6">
|
|
||||||
<div className="bg-white rounded-2xl p-8 w-full max-w-sm text-center shadow-xl">
|
|
||||||
<div className="flex justify-center mb-5">
|
|
||||||
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="w-9 h-9 text-white" fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
|
||||||
Bienvenue {successName} !!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 text-sm leading-relaxed">
|
|
||||||
Votre compte a été créé avec succès. Un email de validation vous a été envoyé. Vous serez redirigé vers la connexion dans{' '}
|
|
||||||
<span className="font-semibold text-[#006B6B]">{countdown}</span> secondes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import UserNav from '../components/UserNav'
|
import UserNav from '../components/UserNav'
|
||||||
import { changePasswordApi } from '../services/auth'
|
import { changePasswordApi, deleteAccountApi } from '../services/auth'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||||
const handleAuthError = useHandleAuthError()
|
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 [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function SaisirCodeUnique() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-sm mx-auto">
|
||||||
<UserNav active="code" />
|
<UserNav active="code" />
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ export async function registerApi({ email, firstname, lastname, password }) {
|
|||||||
return data
|
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 }) {
|
export async function changePasswordApi({ currentPassword, newPassword }) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/change-password`, {
|
const res = await fetch(`${BASE}/change-password`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user