merge: integrate Google OAuth and latest application fixes
This commit is contained in:
909
frontend/package-lock.json
generated
909
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import ProtectedRoute from './components/ProtectedRoute.jsx'
|
||||
import CookieBanner from './components/CookieBanner.jsx'
|
||||
import Connexion from './pages/Connexion'
|
||||
import Inscription from './pages/Inscription'
|
||||
import AuthCallback from './pages/AuthCallback'
|
||||
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||
import Accueil from './pages/Accueil'
|
||||
import ReglesJeu from './pages/ReglesJeu'
|
||||
import MesGains from './pages/MesGains'
|
||||
@@ -29,6 +31,8 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
||||
<Route path="/connexion" element={<Connexion />} />
|
||||
<Route path="/inscription" element={<Inscription />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||
|
||||
{/* Pages publiques */}
|
||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function Header() {
|
||||
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 logoContent = <img src={logo} alt="Logo de Thé Tip Top" className="h-7 sm:h-8 md:h-9 lg:h-12 w-auto" />
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
@@ -40,12 +40,12 @@ export default function Header() {
|
||||
)}
|
||||
|
||||
{/* Nav desktop */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<nav className="hidden md:flex items-center gap-4 lg:gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`font-medium transition-colors ${
|
||||
className={`font-medium whitespace-nowrap transition-colors ${
|
||||
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700 hover:text-[#006B6B]'
|
||||
}`}
|
||||
>
|
||||
@@ -59,21 +59,21 @@ export default function Header() {
|
||||
<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>
|
||||
<span className="whitespace-nowrap">{user.firstname}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-gray-400 hover:text-red-500 transition-colors font-medium"
|
||||
className="text-sm text-gray-500 hover:text-red-500 transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
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">
|
||||
<Link to="/connexion" className="bg-[#006B6B] border border-transparent text-white px-4 py-2 rounded-lg hover:bg-[#005a5a] transition-colors text-sm whitespace-nowrap">
|
||||
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">
|
||||
<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 whitespace-nowrap">
|
||||
Inscription
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function UserNav({ active }) {
|
||||
key: 'code',
|
||||
to: '/saisir-code-unique',
|
||||
label: 'Saisir un code',
|
||||
shortLabel: '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" />
|
||||
@@ -16,6 +17,7 @@ export default function UserNav({ active }) {
|
||||
key: 'gains',
|
||||
to: '/mes-gains',
|
||||
label: 'Mes gains',
|
||||
shortLabel: '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" />
|
||||
@@ -26,6 +28,7 @@ export default function UserNav({ active }) {
|
||||
key: 'profile',
|
||||
to: '/profile',
|
||||
label: 'Profil',
|
||||
shortLabel: '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" />
|
||||
@@ -43,12 +46,12 @@ export default function UserNav({ active }) {
|
||||
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'
|
||||
: 'text-gray-500 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>
|
||||
<span className="sm:hidden whitespace-nowrap">{tab.shortLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
@@ -8,11 +8,37 @@ function StatCard({ label, value, sub }) {
|
||||
<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>}
|
||||
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
|
||||
// ne doit pas être confondu avec « retraits uniquement ».
|
||||
const PHASE_BADGES = {
|
||||
NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' },
|
||||
SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' },
|
||||
OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' },
|
||||
CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' },
|
||||
CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const PHASE_PANELS = {
|
||||
NOT_CONFIGURED: 'bg-amber-50 border border-amber-200',
|
||||
SCHEDULED: 'bg-indigo-50 border border-indigo-200',
|
||||
OPEN: 'bg-white',
|
||||
CLAIM_ONLY: 'bg-white',
|
||||
CLOSED: 'bg-red-50 border border-red-200',
|
||||
}
|
||||
|
||||
const PHASE_TITLES = {
|
||||
NOT_CONFIGURED: 'Jeu-concours non lancé',
|
||||
SCHEDULED: 'Jeu-concours programmé',
|
||||
OPEN: 'Jeu-concours en cours',
|
||||
CLAIM_ONLY: 'Participations closes, retraits en cours',
|
||||
CLOSED: 'Jeu-concours terminé',
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
@@ -31,6 +57,12 @@ export default function Admin() {
|
||||
const [error, setError] = useState('')
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
// Lancement du jeu-concours
|
||||
const [startDateInput, setStartDateInput] = useState('')
|
||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||
const [settingsError, setSettingsError] = useState('')
|
||||
const [settingsOk, setSettingsOk] = useState('')
|
||||
|
||||
// Remise de lot
|
||||
const [ticketCode, setTicketCode] = useState('')
|
||||
const [ticketResult, setTicketResult] = useState(null)
|
||||
@@ -56,7 +88,26 @@ export default function Admin() {
|
||||
if (!handleAuthError(e)) setError(e.message)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [handleAuthError])
|
||||
}, [handleAuthError])
|
||||
|
||||
const handleSaveGameSettings = async (e) => {
|
||||
e.preventDefault()
|
||||
setSettingsError('')
|
||||
setSettingsOk('')
|
||||
setSettingsSaving(true)
|
||||
try {
|
||||
// L'input datetime-local renvoie une heure locale sans fuseau : on la
|
||||
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||
setSettingsOk(data.message)
|
||||
setStartDateInput('')
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||
} finally {
|
||||
setSettingsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDraw = async () => {
|
||||
setShowConfirm(false)
|
||||
@@ -94,7 +145,10 @@ export default function Admin() {
|
||||
const csv = [header, ...rows]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||
.join('\n')
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||||
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le
|
||||
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
@@ -145,7 +199,7 @@ export default function Admin() {
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-gray-400">Chargement...</p>
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -164,21 +218,61 @@ export default function Admin() {
|
||||
<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'}`}>
|
||||
{stats?.gameWindow && (
|
||||
<div className={`rounded-xl shadow p-4 sm:p-6 ${PHASE_PANELS[stats.gameWindow.phase] ?? 'bg-white'}`}>
|
||||
<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)}
|
||||
{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
|
||||
</p>
|
||||
{stats.gameWindow.configured ? (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Début : {formatDate(stats.gameWindow.startDate)}
|
||||
<br />
|
||||
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
|
||||
<br />
|
||||
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Choisissez une date de début pour ouvrir le jeu-concours.
|
||||
</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 className={`text-xs font-semibold px-2.5 py-1 rounded-full ${PHASE_BADGES[stats.gameWindow.phase]?.className ?? 'bg-gray-100 text-gray-700'}`}>
|
||||
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="game-start"
|
||||
value={startDateInput}
|
||||
onChange={(e) => setStartDateInput(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={settingsSaving || !startDateInput}
|
||||
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"
|
||||
>
|
||||
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||
</p>
|
||||
|
||||
{settingsError && <p className="text-red-500 text-xs mt-2">{settingsError}</p>}
|
||||
{settingsOk && <p className="text-green-600 text-xs mt-2">{settingsOk}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -302,7 +396,7 @@ export default function Admin() {
|
||||
<tbody>
|
||||
{emailingContacts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
|
||||
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||
Aucun contact n'a accepté l'emailing pour le moment
|
||||
</td>
|
||||
</tr>
|
||||
@@ -337,7 +431,7 @@ export default function Admin() {
|
||||
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]"
|
||||
className="flex-1 min-w-0 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"
|
||||
@@ -366,7 +460,7 @@ export default function Admin() {
|
||||
<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>
|
||||
<p className="text-gray-500 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 ${
|
||||
@@ -436,10 +530,17 @@ export default function Admin() {
|
||||
</div>
|
||||
</div>
|
||||
{winner?.winner?.createdAt && (
|
||||
<p className="text-xs text-gray-400 text-right">
|
||||
<p className="text-xs text-gray-500 text-right">
|
||||
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
{/* Signalé uniquement au retour du tirage : le GET du gagnant ne
|
||||
renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */}
|
||||
{winner?.emailSent === false && (
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Pas encore de tirage */
|
||||
@@ -474,7 +575,7 @@ export default function Admin() {
|
||||
{/* 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>
|
||||
Participants <span className="text-gray-500 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]">
|
||||
@@ -488,7 +589,7 @@ export default function Admin() {
|
||||
<tbody>
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
|
||||
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||
Aucun participant pour le moment
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
76
frontend/src/pages/AuthCallback.jsx
Normal file
76
frontend/src/pages/AuthCallback.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
|
||||
// avec le JWT et le profil encodés dans l'URL.
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
|
||||
const errorParam = searchParams.get('error')
|
||||
const [error, setError] = useState(errorParam || '')
|
||||
|
||||
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
|
||||
const handled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
handled.current = true
|
||||
|
||||
if (errorParam) return
|
||||
|
||||
const token = searchParams.get('token')
|
||||
const encodedUser = searchParams.get('user')
|
||||
|
||||
if (!token || !encodedUser) {
|
||||
setTimeout(() => {
|
||||
setError('Réponse de connexion incomplète.')
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(atob(encodedUser))
|
||||
login(user, token)
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
navigate('/admin', { replace: true })
|
||||
} else if (user.role === 'EMPLOYEE') {
|
||||
navigate('/employe', { replace: true })
|
||||
} else {
|
||||
navigate('/mes-gains', { replace: true })
|
||||
}
|
||||
} catch {
|
||||
setTimeout(() => {
|
||||
setError('Réponse de connexion illisible.')
|
||||
}, 0)
|
||||
}
|
||||
}, [searchParams, errorParam, login, navigate])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/connexion"
|
||||
className="text-sm text-[#006B6B] font-medium hover:underline"
|
||||
>
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
Connexion en cours…
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,12 @@ export default function Connexion() {
|
||||
|
||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||
|
||||
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||
// redirections vers Google puis retour sur /auth/callback.
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = '/api/auth/google'
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -66,7 +72,7 @@ export default function Connexion() {
|
||||
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]"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -83,12 +89,12 @@ export default function Connexion() {
|
||||
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]"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 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"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 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">
|
||||
@@ -99,7 +105,7 @@ export default function Connexion() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right mt-1">
|
||||
<button type="button" className="text-xs text-gray-400 hover:text-[#006B6B] hover:underline">
|
||||
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
|
||||
Mot de passe oublié ?
|
||||
</button>
|
||||
</div>
|
||||
@@ -123,20 +129,27 @@ export default function Connexion() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
</svg>
|
||||
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"
|
||||
disabled
|
||||
title="Connexion Facebook bientôt disponible"
|
||||
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"
|
||||
>
|
||||
<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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||
</svg>
|
||||
Facebook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,152 +3,43 @@ 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="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||
|
||||
<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">
|
||||
{/* 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>
|
||||
<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]"
|
||||
/>
|
||||
<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>
|
||||
<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]"
|
||||
/>
|
||||
<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>
|
||||
<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]"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* Image */}
|
||||
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
|
||||
<img
|
||||
src={imgContact}
|
||||
alt="Une personne savourant un thé"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { resetPasswordApi } from '../services/auth'
|
||||
|
||||
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
||||
// la définition initiale du mot de passe après une inscription Google,
|
||||
// et la réinitialisation classique depuis « mot de passe oublié ».
|
||||
export default function DefinirMotDePasse() {
|
||||
const { token } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (form.password.length < 8) {
|
||||
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
||||
return
|
||||
}
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError('Les deux mots de passe ne correspondent pas.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await resetPasswordApi({ token, ...form })
|
||||
setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.')
|
||||
setTimeout(() => navigate('/connexion'), 2500)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||
Aller à la connexion
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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]">Mot de passe</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">
|
||||
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
|
||||
</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="password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="8 caractères minimum"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-800 mb-1">
|
||||
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={form.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Saisissez-le à nouveau"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={(e) => setShowPassword(e.target.checked)}
|
||||
className="w-5 h-5 accent-[#006B6B]"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-8">
|
||||
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export default function Employe() {
|
||||
required
|
||||
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]"
|
||||
className="flex-1 min-w-0 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"
|
||||
@@ -142,7 +142,7 @@ export default function Employe() {
|
||||
|
||||
{/* 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>
|
||||
<p className="text-xs text-gray-500 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()}
|
||||
@@ -158,7 +158,7 @@ export default function Employe() {
|
||||
|
||||
{/* 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-xs text-gray-500 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>
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
||||
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
||||
|
||||
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
||||
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
||||
const GENRES = ['Femme', 'Homme', 'Autre']
|
||||
|
||||
export default function Inscription() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
@@ -10,8 +17,18 @@ export default function Inscription() {
|
||||
// É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: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
})
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
||||
// Consentement marketing : distinct des CGU et décoché par défaut, il doit
|
||||
// rester un choix libre et non une condition d'inscription.
|
||||
const [marketingOptIn, setMarketingOptIn] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -24,6 +41,11 @@ export default function Inscription() {
|
||||
|
||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||
|
||||
// Même route que la connexion : Google crée le compte à la première venue.
|
||||
const handleGoogleSignup = () => {
|
||||
window.location.href = '/api/auth/google'
|
||||
}
|
||||
|
||||
// Étape 1 — Inscription
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
@@ -35,6 +57,9 @@ export default function Inscription() {
|
||||
firstname: form.firstName,
|
||||
lastname: form.lastName,
|
||||
password: form.password,
|
||||
birthdate: form.birthdate,
|
||||
gender: form.gender,
|
||||
marketingOptIn,
|
||||
})
|
||||
setEmail(form.email)
|
||||
setStep('verify')
|
||||
@@ -129,7 +154,7 @@ export default function Inscription() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
Le code expire dans 15 minutes.{' '}
|
||||
<button
|
||||
type="button"
|
||||
@@ -161,28 +186,45 @@ export default function Inscription() {
|
||||
<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]" />
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 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]" />
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 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]" />
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
|
||||
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
|
||||
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
|
||||
<option value="">Non précisé</option>
|
||||
{GENRES.map((genre) => (
|
||||
<option key={genre} value={genre}>{genre}</option>
|
||||
))}
|
||||
</select>
|
||||
</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]" />
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 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">
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 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>
|
||||
@@ -192,7 +234,7 @@ export default function Inscription() {
|
||||
|
||||
<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" />
|
||||
className="mt-0.5 w-5 h-5 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>{' '}
|
||||
@@ -201,6 +243,18 @@ export default function Inscription() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
|
||||
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
||||
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
|
||||
<span className="text-xs text-gray-500 leading-relaxed">
|
||||
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
|
||||
<span className="block text-gray-500 mt-0.5">
|
||||
Facultatif — vous pouvez vous désinscrire à tout moment.
|
||||
</span>
|
||||
</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"}
|
||||
@@ -214,12 +268,20 @@ export default function Inscription() {
|
||||
</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>
|
||||
<button type="button" onClick={handleGoogleSignup} 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
</svg>
|
||||
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>
|
||||
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||
</svg>
|
||||
Facebook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function MesGains() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-center text-gray-400 text-sm mt-8">Chargement…</p>}
|
||||
{loading && <p className="text-center text-gray-500 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">
|
||||
@@ -93,8 +93,8 @@ export default function MesGains() {
|
||||
|
||||
{!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>
|
||||
<p className="text-gray-500 text-sm">Aucun gain pour le moment.</p>
|
||||
<p className="text-gray-500 text-xs mt-1">Saisissez un code pour participer !</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function MesGains() {
|
||||
</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>
|
||||
<p className="text-gray-500 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>
|
||||
@@ -128,7 +128,7 @@ export default function MesGains() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-[10px] mt-1">
|
||||
<p className="text-gray-500 text-[10px] mt-1">
|
||||
Code utilisé :{' '}
|
||||
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
|
||||
</p>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function NotFound() {
|
||||
Page introuvable
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-400 text-sm sm:text-base max-w-xs mb-8">
|
||||
<p className="text-gray-500 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>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function Perdu() {
|
||||
<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">
|
||||
<svg className="w-10 h-10 text-gray-500" 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>
|
||||
@@ -22,7 +22,7 @@ export default function Perdu() {
|
||||
{message || 'Ce code est invalide ou a déjà été utilisé.'}
|
||||
</p>
|
||||
|
||||
<p className="text-gray-400 text-xs mb-8">
|
||||
<p className="text-gray-500 text-xs mb-8">
|
||||
Vérifiez le code présent sur votre ticket et réessayez.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import UserNav from '../components/UserNav'
|
||||
import { changePasswordApi} from '../services/auth'
|
||||
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
|
||||
export default function Profile() {
|
||||
@@ -14,6 +14,35 @@ export default function Profile() {
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [pwSuccess, setPwSuccess] = useState(false)
|
||||
|
||||
// Un compte créé via Google peut ne pas avoir de mot de passe : le
|
||||
// formulaire de modification n'aurait alors aucun sens. L'information vient
|
||||
// de l'API, car le profil stocké en localStorage ne la contient pas.
|
||||
const [hasPassword, setHasPassword] = useState(null)
|
||||
const [linkSending, setLinkSending] = useState(false)
|
||||
const [linkSent, setLinkSent] = useState(false)
|
||||
const [linkError, setLinkError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
meApi()
|
||||
.then((data) => setHasPassword(data.user.hasPassword))
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setHasPassword(true)
|
||||
})
|
||||
}, [handleAuthError])
|
||||
|
||||
const handleSendSetupLink = async () => {
|
||||
setLinkError('')
|
||||
setLinkSending(true)
|
||||
try {
|
||||
await forgotPasswordApi(user.email)
|
||||
setLinkSent(true)
|
||||
} catch (e) {
|
||||
if (!handleAuthError(e)) setLinkError(e.message)
|
||||
} finally {
|
||||
setLinkSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
|
||||
|
||||
const handlePwSubmit = async (e) => {
|
||||
@@ -65,19 +94,19 @@ export default function Profile() {
|
||||
{/* 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">
|
||||
<label className="block text-xs font-medium text-gray-500 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-500 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>
|
||||
<label className="block text-xs font-medium text-gray-500 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>
|
||||
<label className="block text-xs font-medium text-gray-500 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>
|
||||
@@ -89,9 +118,37 @@ export default function Profile() {
|
||||
|
||||
{/* Section changement de mot de passe */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Changer mon mot de passe</h2>
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">
|
||||
{hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'}
|
||||
</h2>
|
||||
|
||||
{pwSuccess && (
|
||||
{hasPassword === false && (
|
||||
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Votre compte a été créé avec Google et n'a pas encore de mot de passe.
|
||||
Définissez-en un pour pouvoir aussi vous connecter avec votre email.
|
||||
</p>
|
||||
|
||||
{linkSent ? (
|
||||
<p className="mt-3 text-sm text-green-700">
|
||||
Lien envoyé à {user.email}. Consultez votre boîte mail.
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendSetupLink}
|
||||
disabled={linkSending}
|
||||
className="mt-3 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{linkSending ? 'Envoi…' : 'Recevoir un lien par email'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{linkError && <p className="mt-2 text-sm text-red-600">{linkError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPassword !== false && 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" />
|
||||
@@ -100,12 +157,13 @@ export default function Profile() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pwError && (
|
||||
{hasPassword !== false && 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>
|
||||
)}
|
||||
|
||||
{hasPassword !== false && (
|
||||
<form onSubmit={handlePwSubmit} className="space-y-4">
|
||||
{[
|
||||
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
||||
@@ -123,12 +181,12 @@ export default function Profile() {
|
||||
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]"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 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"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
|
||||
aria-label="Afficher/masquer"
|
||||
>
|
||||
<EyeIcon visible={showPw[key]} />
|
||||
@@ -145,6 +203,7 @@ export default function Profile() {
|
||||
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function SaisirCodeUnique() {
|
||||
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]"
|
||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -144,28 +144,28 @@ export default function SaisirCodeUnique() {
|
||||
{/* 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-xs text-gray-500 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="text-xs text-gray-500 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-xs text-gray-500 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 text-gray-500 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>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function Sitemap() {
|
||||
<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>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{link.desc}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -49,3 +49,13 @@ export async function getWinnerApi() {
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
|
||||
return data
|
||||
}
|
||||
export async function setGameSettingsApi(startDate) {
|
||||
const res = await fetch(`${BASE}/game-settings`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ startDate }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -18,11 +18,29 @@ export async function loginApi({ email, password }) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function registerApi({ email, firstname, lastname, password }) {
|
||||
export async function registerApi({
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
password,
|
||||
birthdate,
|
||||
gender,
|
||||
marketingOptIn,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, firstname, lastname, password }),
|
||||
// birthdate et gender sont facultatifs : on omet la clé plutôt que
|
||||
// d'envoyer une chaîne vide, que l'API stockerait telle quelle.
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
password,
|
||||
birthdate: birthdate || undefined,
|
||||
gender: gender || undefined,
|
||||
marketingOptIn: marketingOptIn ?? undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
||||
@@ -40,6 +58,38 @@ export async function verifyCodeApi({ email, code }) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function forgotPasswordApi(email) {
|
||||
const res = await fetch(`${BASE}/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetPasswordApi({ token, password, confirmPassword }) {
|
||||
const res = await fetch(`${BASE}/reset-password/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password, confirmPassword }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function meApi() {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAccountApi() {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/account`, {
|
||||
|
||||
@@ -16,7 +16,7 @@ async function remplirEtSoumettre() {
|
||||
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('checkbox', { name: /conditions d'utilisation/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Inscription', () => {
|
||||
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||
expect(bouton).toBeDisabled()
|
||||
|
||||
await userEvent.click(screen.getByRole('checkbox'))
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
expect(bouton).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -64,11 +64,64 @@ describe('Inscription', () => {
|
||||
firstname: 'Jean',
|
||||
lastname: 'Dupont',
|
||||
password: 'MotDePasse123!',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
marketingOptIn: false,
|
||||
})
|
||||
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("transmet la date de naissance et le genre saisis", async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
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(/date de naissance/i), '1990-05-14')
|
||||
await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme')
|
||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||
|
||||
expect(registerApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ birthdate: '1990-05-14', gender: 'Femme' })
|
||||
)
|
||||
})
|
||||
|
||||
it("transmet le consentement marketing quand il est coché", async () => {
|
||||
registerApi.mockResolvedValue({ message: 'ok' })
|
||||
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i })
|
||||
expect(marketing).not.toBeChecked()
|
||||
|
||||
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(marketing)
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
||||
|
||||
expect(registerApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ marketingOptIn: true })
|
||||
)
|
||||
})
|
||||
|
||||
it("n'exige pas le consentement marketing pour s'inscrire", async () => {
|
||||
renderWithProviders(<Inscription />, { route: '/inscription' })
|
||||
|
||||
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /offres et actualités/i })).not.toBeChecked()
|
||||
expect(bouton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
|
||||
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user