merge: integrate Google OAuth and latest application fixes

This commit is contained in:
2026-08-20 23:01:39 +00:00
50 changed files with 2817 additions and 931 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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&apos;email de notification n&apos;a pas pu être envoyé au gagnant. Merci de le contacter directement à l&apos;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>

View 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>
)
}

View File

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

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;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>
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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é.'))