changement

This commit is contained in:
Emma781227
2026-09-14 22:30:40 +02:00
parent 8a6355cde1
commit 2201446ee6
54 changed files with 2495 additions and 139 deletions

View File

@@ -7,6 +7,8 @@ import Connexion from './pages/Connexion'
import Inscription from './pages/Inscription'
import AuthCallback from './pages/AuthCallback'
import DefinirMotDePasse from './pages/DefinirMotDePasse'
import MotDePasseOublie from './pages/MotDePasseOublie'
import NouveauMotDePasse from './pages/NouveauMotDePasse'
import Accueil from './pages/Accueil'
import NotreConcept from './pages/NotreConcept'
import ReglesJeu from './pages/ReglesJeu'
@@ -33,7 +35,9 @@ export default function App() {
<Route path="/connexion" element={<Connexion />} />
<Route path="/inscription" element={<Inscription />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/mot-de-passe-oublie" element={<MotDePasseOublie />} />
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
<Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} />
{/* Pages publiques */}
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />

View File

@@ -0,0 +1,21 @@
import { PASSWORD_RULES } from '../utils/passwordRules'
// Règles du mot de passe, cochées au fil de la saisie. À relier au champ via
// aria-describedby={id}.
export default function PasswordRules({ id, password }) {
return (
<ul id={id} className="mt-2 space-y-1">
{PASSWORD_RULES.map((rule) => {
const ok = rule.test(password)
return (
<li key={rule.id} data-rule={rule.id} data-valid={ok}
className={`flex items-center gap-2 text-xs ${ok ? 'text-green-700' : 'text-gray-500'}`}>
<span aria-hidden="true">{ok ? '✓' : '○'}</span>
{rule.label}
<span className="sr-only">{ok ? ' : respecté' : ' : non respecté'}</span>
</li>
)
})}
</ul>
)
}

View File

@@ -1,5 +1,6 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles'
export default function ProtectedRoute({ children, roles }) {
const { user } = useAuth()
@@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) {
return <Navigate to="/connexion" replace />
}
// Mot de passe provisoire : aucune page protégée tant qu'il n'est pas
// remplacé (le backend refuse de toute façon les appels à l'API).
if (user.mustChangePassword) {
return <Navigate to={INITIAL_PASSWORD_PATH} replace />
}
if (roles && !roles.includes(user.role)) {
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
return <Navigate to={redirects[user.role] || '/connexion'} replace />
return <Navigate to={homeFor(user)} replace />
}
return children

View File

@@ -0,0 +1,288 @@
import { useEffect, useRef, useState } from 'react'
import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin'
const STATUS_BADGES = {
DELIVERED: { label: 'Lot remis', className: 'bg-green-100 text-green-700' },
PENDING: { label: 'En attente de retrait', className: 'bg-amber-100 text-amber-700' },
CANCELLED: { label: 'Expiré', className: 'bg-gray-200 text-gray-700' },
NOT_PLAYED: { label: 'Pas encore joué', className: 'bg-gray-100 text-gray-600' },
}
const INPUT_CLASS =
'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]'
const BUTTON_CLASS =
'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'
function formatDateTime(dateStr) {
return new Date(dateStr).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
const fullName = (person) => `${person.firstname} ${person.lastname}`
// Traçabilité des remises de lots : liste des employés, lots remis par chacun,
// et recherche inverse par numéro de ticket.
// `refreshKey` change quand un employé est créé, pour recharger la liste.
export default function EmployeeDeliveries({ refreshKey, onAuthError }) {
const [employees, setEmployees] = useState(null)
const [employeesError, setEmployeesError] = useState('')
const [filter, setFilter] = useState('')
const [selected, setSelected] = useState(null)
const [deliveries, setDeliveries] = useState(null)
const [deliveriesLoading, setDeliveriesLoading] = useState(false)
const [deliveriesError, setDeliveriesError] = useState('')
const detailRef = useRef(null)
const [ticketCode, setTicketCode] = useState('')
const [ticketResult, setTicketResult] = useState(null)
const [ticketLoading, setTicketLoading] = useState(false)
const [ticketError, setTicketError] = useState('')
useEffect(() => {
getEmployeesApi()
.then((data) => {
setEmployees(data.employees)
setEmployeesError('')
})
.catch((err) => {
if (!onAuthError(err)) setEmployeesError(err.message)
})
}, [refreshKey, onAuthError])
const openEmployee = async (employee) => {
setSelected(employee)
setDeliveries(null)
setDeliveriesError('')
setDeliveriesLoading(true)
try {
const data = await getEmployeeDeliveriesApi(employee.id)
setDeliveries(data.deliveries)
} catch (err) {
if (!onAuthError(err)) setDeliveriesError(err.message)
} finally {
setDeliveriesLoading(false)
detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
const handleTicketSearch = async (e) => {
e.preventDefault()
setTicketError('')
setTicketResult(null)
setTicketLoading(true)
try {
setTicketResult(await getDeliveryByTicketCodeApi(ticketCode))
} catch (err) {
if (!onAuthError(err)) setTicketError(err.message)
} finally {
setTicketLoading(false)
}
}
const needle = filter.trim().toLowerCase()
const visibleEmployees = (employees ?? []).filter((employee) =>
!needle || `${fullName(employee)} ${employee.email}`.toLowerCase().includes(needle)
)
return (
<div className="bg-white rounded-xl shadow p-4 sm:p-6 space-y-6">
<div>
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Employés et remises des lots</h2>
<p className="text-xs sm:text-sm text-gray-500">
Retrouvez quel employé a remis un lot et à quelle date, par numéro de ticket ou par employé.
</p>
</div>
{/* ── Recherche par numéro de ticket ── */}
<div>
<form onSubmit={handleTicketSearch} className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[200px]">
<label htmlFor="delivery-ticket-code" className="block text-xs font-medium text-gray-700 mb-1">
Numéro du ticket
</label>
<input
type="text"
id="delivery-ticket-code"
value={ticketCode}
onChange={(e) => setTicketCode(e.target.value)}
required
placeholder="Ex. A1B2C3D4E5"
className={`${INPUT_CLASS} uppercase placeholder:normal-case`}
/>
</div>
<button type="submit" disabled={ticketLoading || !ticketCode.trim()} className={BUTTON_CLASS}>
{ticketLoading ? 'Recherche…' : 'Rechercher'}
</button>
</form>
{ticketError && <p className="text-red-500 text-xs sm:text-sm mt-3">{ticketError}</p>}
{ticketResult && (
<div className="mt-4 p-4 rounded-lg border border-gray-200 bg-gray-50 text-sm space-y-1.5" data-testid="ticket-result">
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="font-mono font-semibold text-gray-800">{ticketResult.ticketCode}</p>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_BADGES[ticketResult.status]?.className}`}>
{STATUS_BADGES[ticketResult.status]?.label ?? ticketResult.status}
</span>
</div>
<p className="text-gray-600">Lot : <span className="font-medium text-gray-800">{ticketResult.lot.name}</span></p>
{ticketResult.client && (
<p className="text-gray-600">
Gagné par : <span className="font-medium text-gray-800">{fullName(ticketResult.client)}</span>{' '}
<span className="text-gray-500">({ticketResult.client.email})</span>
{ticketResult.playedAt && <> le {formatDateTime(ticketResult.playedAt)}</>}
</p>
)}
{ticketResult.deliveredBy ? (
<p className="text-gray-600">
Remis par :{' '}
<button
type="button"
onClick={() => openEmployee(ticketResult.deliveredBy)}
className="font-semibold text-[#006B6B] hover:underline"
>
{fullName(ticketResult.deliveredBy)}
</button>{' '}
le <span className="font-medium text-gray-800">{formatDateTime(ticketResult.deliveredAt)}</span>
</p>
) : (
ticketResult.status !== 'NOT_PLAYED' && (
<p className="text-gray-500">Ce lot n'a pas encore été remis.</p>
)
)}
</div>
)}
</div>
{/* ── Liste des employés ── */}
<div className="pt-6 border-t border-gray-100">
<div className="flex items-end justify-between flex-wrap gap-3 mb-3">
<h3 className="text-sm font-semibold text-gray-800">
Comptes employés
{employees && <span className="text-gray-500 font-normal"> - {employees.length}</span>}
</h3>
<div className="w-full sm:w-64">
<label htmlFor="employee-filter" className="sr-only">Rechercher un employé</label>
<input
type="search"
id="employee-filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Rechercher un employé…"
className={INPUT_CLASS}
/>
</div>
</div>
{employeesError && <p className="text-red-500 text-sm">{employeesError}</p>}
{!employees && !employeesError && <p className="text-gray-500 text-sm">Chargement…</p>}
{employees && (
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[520px]">
<thead>
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
<th className="pb-2 px-4 sm:px-0">Nom</th>
<th className="pb-2 px-4 sm:px-0">Email</th>
<th className="pb-2 px-4 sm:px-0">Créé le</th>
<th className="pb-2 px-4 sm:px-0 text-center">Lots remis</th>
</tr>
</thead>
<tbody>
{visibleEmployees.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
{employees.length === 0 ? "Aucun compte employé pour l'instant" : 'Aucun employé ne correspond à la recherche'}
</td>
</tr>
)}
{visibleEmployees.map((employee) => (
<tr key={employee.id} className={`border-b last:border-0 ${selected?.id === employee.id ? 'bg-[#006B6B]/5' : ''}`}>
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm">
<button
type="button"
onClick={() => openEmployee(employee)}
className="font-semibold text-[#006B6B] hover:underline text-left"
>
{fullName(employee)}
</button>
{employee.mustChangePassword && (
<span className="block text-[11px] text-amber-700">Première connexion en attente</span>
)}
</td>
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{employee.email}</td>
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">
{new Date(employee.createdAt).toLocaleDateString('fr-FR')}
</td>
<td className="py-3 px-4 sm:px-0 text-center font-semibold text-gray-800">{employee.deliveredCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* ── Lots remis par l'employé sélectionné ── */}
{selected && (
<div ref={detailRef} className="pt-6 border-t border-gray-100">
<div className="flex items-center justify-between flex-wrap gap-2 mb-3">
<h3 className="text-sm font-semibold text-gray-800">
Lots remis par {fullName(selected)}
{deliveries && <span className="text-gray-500 font-normal"> — {deliveries.length}</span>}
</h3>
<button
type="button"
onClick={() => setSelected(null)}
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-3 py-1.5 rounded-lg transition-colors text-xs"
>
Fermer
</button>
</div>
{deliveriesLoading && <p className="text-gray-500 text-sm">Chargement…</p>}
{deliveriesError && <p className="text-red-500 text-sm">{deliveriesError}</p>}
{deliveries && (
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[560px]">
<thead>
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
<th className="pb-2 px-4 sm:px-0">Ticket</th>
<th className="pb-2 px-4 sm:px-0">Lot</th>
<th className="pb-2 px-4 sm:px-0">Client</th>
<th className="pb-2 px-4 sm:px-0 text-right">Remis le</th>
</tr>
</thead>
<tbody>
{deliveries.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
Cet employé n'a encore remis aucun lot
</td>
</tr>
)}
{deliveries.map((delivery) => (
<tr key={delivery.claimId} className="border-b last:border-0">
<td className="py-3 px-4 sm:px-0 font-mono text-xs text-gray-800">{delivery.ticketCode}</td>
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm text-gray-800">{delivery.lot.name}</td>
<td className="py-3 px-4 sm:px-0 text-xs text-gray-500">
{fullName(delivery.client)}
<span className="block">{delivery.client.email}</span>
</td>
<td className="py-3 px-4 sm:px-0 text-right text-xs text-gray-500 whitespace-nowrap">
{formatDateTime(delivery.deliveredAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,10 @@
// Page d'accueil de chaque rôle après connexion.
export const HOME_BY_ROLE = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
// Page imposée tant qu'un mot de passe provisoire n'a pas été remplacé.
export const INITIAL_PASSWORD_PATH = '/nouveau-mot-de-passe'
export function homeFor(user) {
if (user?.mustChangePassword) return INITIAL_PASSWORD_PATH
return HOME_BY_ROLE[user?.role] || '/connexion'
}

View File

@@ -0,0 +1,26 @@
import { useEffect, useMemo, useState } from 'react'
import { getGameWindowApi } from '../services/game'
import { toGameDates } from '../utils/gameDates'
// Dates du jeu-concours pour l'affichage public. Rend immédiatement les dates
// par défaut (2 → 31 octobre 2026), puis celles fixées par l'admin dès que
// l'API répond. Rechargées à chaque montage : une date modifiée dans le
// tableau de bord apparaît dès le retour sur la page.
export default function useGameDates() {
const [gameWindow, setGameWindow] = useState(null)
useEffect(() => {
let active = true
getGameWindowApi()
.then((data) => {
if (active) setGameWindow(data)
})
// API injoignable : les dates par défaut restent affichées.
.catch(() => {})
return () => {
active = false
}
}, [])
return useMemo(() => toGameDates(gameWindow), [gameWindow])
}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import usePageMeta from '../hooks/usePageMeta'
import useGameDates from '../hooks/useGameDates'
import { formatDay, formatRange } from '../utils/gameDates'
import bannerImg from '../assets/img/banner-accueil.webp'
const gains = [
@@ -38,10 +40,11 @@ function createTeaParticles(count) {
}))
}
const faqs = [
// Les dates viennent du tableau de bord admin (voir useGameDates).
const buildFaqs = ({ start, lastPlayDay, lastClaimDay }) => [
{
question: 'Comment obtenir un code ?',
answer: "Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, entre le 2 et le 31 octobre 2026 : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.",
answer: `Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, du ${formatRange(start, lastPlayDay)} : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.`,
},
{
question: 'Quels sont les lots à gagner ?',
@@ -62,7 +65,7 @@ const faqs = [
},
{
question: 'Jusqu\'à quand puis-je jouer ?',
answer: 'La saisie des codes est ouverte du 2 au 31 octobre 2026 inclus. Les lots sont ensuite à retirer jusqu\'au 30 novembre 2026.',
answer: `La saisie des codes est ouverte du ${formatRange(start, lastPlayDay)} inclus. Les lots sont ensuite à retirer jusqu'au ${formatDay(lastClaimDay)}.`,
},
{
question: 'Que faire si mon code n\'est pas reconnu ?',
@@ -70,11 +73,11 @@ const faqs = [
},
{
question: 'Où retirer mon lot ?',
answer: 'Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu\'au 30 novembre 2026.',
answer: `Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu'au ${formatDay(lastClaimDay)}.`,
},
]
const faqJsonLd = {
const buildFaqJsonLd = (faqs) => ({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
@@ -85,12 +88,18 @@ const faqJsonLd = {
text: faq.plainAnswer || faq.answer,
},
})),
}
})
export default function Accueil() {
const gameDates = useGameDates()
const period = formatRange(gameDates.start, gameDates.lastPlayDay)
const faqs = useMemo(() => buildFaqs(gameDates), [gameDates])
const faqJsonLd = useMemo(() => buildFaqJsonLd(faqs), [faqs])
const bannerAlt = `Jeu-concours 100 % Gagnant Thé Tip Top, du ${period} : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.`
usePageMeta({
title: 'Jeu-concours 100 % Gagnant du 2 au 31 octobre | Thé Tip Top',
description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.",
title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | Thé Tip Top`,
description: `Du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })}, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.`,
})
const teaParticles = useMemo(() => createTeaParticles(30), [])
@@ -130,7 +139,7 @@ export default function Accueil() {
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
Du 02 au 31 octobre 2026
Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })}
</p>
<h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]">
Jeu-concours{' '}
@@ -142,13 +151,13 @@ export default function Accueil() {
<div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden">
<img
src={bannerImg}
alt="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice."
alt={bannerAlt}
className="absolute inset-y-0 right-0 w-[210%] sm:w-[130%] max-w-none h-full object-cover object-right animate-banner-image"
/>
</div>
<img
src={bannerImg}
alt="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice."
alt={bannerAlt}
width={2048}
height={1152}
className="hidden lg:block lg:absolute lg:right-[-2%] lg:top-1/2 lg:-translate-y-1/2 lg:h-[145%] xl:h-[160%] lg:w-auto lg:max-w-none object-contain drop-shadow-2xl animate-banner-image"

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
function StatCard({ label, value, sub }) {
return (
@@ -38,6 +40,13 @@ const PHASE_TITLES = {
CLOSED: 'Jeu-concours terminé',
}
// Valeur attendue par <input type="datetime-local"> : heure locale, sans fuseau.
function toDateTimeLocalValue(dateStr) {
const date = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
@@ -68,15 +77,19 @@ export default function Admin() {
const [emailingError, setEmailingError] = useState('')
// Création d'un compte employé
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' })
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' })
const [employeeSaving, setEmployeeSaving] = useState(false)
const [employeeError, setEmployeeError] = useState('')
const [employeeOk, setEmployeeOk] = useState('')
// Incrémenté à chaque création pour recharger la liste des employés.
const [employeesVersion, setEmployeesVersion] = useState(0)
useEffect(() => {
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
.then(([s, u, w]) => {
setStats(s)
// Le champ part de la date enregistrée, celle affichée sur l'accueil.
if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate))
setUsers(u.users)
setWinner(w)
})
@@ -96,8 +109,8 @@ export default function Admin() {
// 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('')
setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`)
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
} catch (err) {
if (!handleAuthError(err)) setSettingsError(err.message)
} finally {
@@ -157,8 +170,9 @@ export default function Admin() {
setEmployeeSaving(true)
try {
const data = await createEmployeeApi(employeeForm)
setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`)
setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' })
setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`)
setEmployeeForm({ firstname: '', lastname: '', email: '' })
setEmployeesVersion((v) => v + 1)
} catch (err) {
if (!handleAuthError(err)) setEmployeeError(err.message)
} finally {
@@ -180,6 +194,15 @@ export default function Admin() {
const winnerUser = winner?.winner?.user
// Aperçu en direct des dates publiques pour la date saisie.
const startInputDate = startDateInput ? new Date(startDateInput) : null
const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime())
? gameDatesFromStart(startInputDate)
: null
const savedStart = stats?.gameWindow?.startDate
const startPreviewUnsaved = Boolean(startPreview) &&
(!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput)
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
<div className="max-w-5xl mx-auto space-y-6">
@@ -236,6 +259,17 @@ export default function Admin() {
</button>
</form>
{startPreview && (
<p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview">
Affiché sur la page d'accueil :{' '}
<span className="font-semibold">
Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })}
</span>
, retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span>
{startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>}
</p>
)}
<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>
@@ -392,6 +426,7 @@ export default function Admin() {
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
<p className="text-xs sm:text-sm text-gray-500 mb-4">
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion.
</p>
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
@@ -428,19 +463,6 @@ export default function Admin() {
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div>
<label htmlFor="employee-password" className="block text-xs font-medium text-gray-700 mb-1">Mot de passe</label>
<input
type="password"
id="employee-password"
value={employeeForm.password}
onChange={(e) => setEmployeeForm({ ...employeeForm, password: e.target.value })}
required
minLength={8}
placeholder="8 caractères minimum"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
<button
@@ -456,6 +478,9 @@ export default function Admin() {
</form>
</div>
{/* ── Employés et remises des lots ── */}
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
{/* ── Tirage au sort ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { homeFor } from '../config/roles'
// 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.
@@ -40,10 +41,7 @@ export default function AuthCallback() {
handled.current = true
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 })
navigate(homeFor(user), { replace: true })
}, [user, token, login, navigate])
if (error) {

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
import { homeFor } from '../config/roles'
export default function Connexion() {
usePageMeta({
@@ -39,9 +40,9 @@ export default function Connexion() {
try {
const { token, user } = await loginApi({ ...form, captchaToken })
login(user, token)
if (user.role === 'ADMIN') navigate('/admin')
else if (user.role === 'EMPLOYEE') navigate('/employe')
else navigate('/mes-gains')
// Un employé qui se connecte avec son mot de passe provisoire est
// envoyé directement sur le choix de son nouveau mot de passe.
navigate(homeFor(user))
} catch (err) {
setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
@@ -119,9 +120,13 @@ export default function Connexion() {
</button>
</div>
<div className="text-right mt-1">
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
<Link
to="/mot-de-passe-oublie"
state={{ email: form.email }}
className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline"
>
Mot de passe oublié ?
</button>
</Link>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { resetPasswordApi } from '../services/auth'
import PasswordRules from '../components/PasswordRules'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
// 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,
@@ -21,8 +23,8 @@ export default function DefinirMotDePasse() {
e.preventDefault()
setError('')
if (form.password.length < 8) {
setError('Le mot de passe doit contenir au moins 8 caractères.')
if (!isPasswordValid(form.password)) {
setError(PASSWORD_RULES_ERROR)
return
}
if (form.password !== form.confirmPassword) {
@@ -58,15 +60,22 @@ export default function DefinirMotDePasse() {
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>
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
{/* Texte neutre : la page sert au mot de passe oublié comme au
premier mot de passe d'un compte Google. */}
<p className="text-gray-500 mt-1 text-sm">
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
Choisissez le mot de passe que vous utiliserez pour vous connecter avec votre adresse email.
</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">
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
{error}
{/invalide ou expiré/i.test(error) && (
<Link to="/mot-de-passe-oublie" className="block mt-2 font-medium text-[#006B6B] hover:underline">
Demander un nouveau lien
</Link>
)}
</div>
)}
@@ -82,10 +91,12 @@ export default function DefinirMotDePasse() {
value={form.password}
onChange={handleChange}
required
minLength={8}
placeholder="8 caractères minimum"
autoComplete="new-password"
aria-describedby="password-rules"
placeholder="Votre nouveau mot de passe"
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]"
/>
<PasswordRules id="password-rules" password={form.password} />
</div>
<div>

View File

@@ -5,6 +5,8 @@ import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
import PasswordRules from '../components/PasswordRules'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
@@ -42,6 +44,7 @@ export default function Inscription() {
lastName: '',
firstName: '',
password: '',
confirmPassword: '',
birthdate: '',
gender: '',
})
@@ -63,6 +66,9 @@ export default function Inscription() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Signalé dès que la confirmation est saisie, sans attendre l'envoi.
const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword
// Même route que la connexion : le provider crée le compte à la première venue.
const handleOAuthSignup = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || ''
@@ -79,6 +85,16 @@ export default function Inscription() {
return
}
if (!isPasswordValid(form.password)) {
setError(PASSWORD_RULES_ERROR)
return
}
if (form.password !== form.confirmPassword) {
setError('Les deux mots de passe ne correspondent pas.')
return
}
setLoading(true)
try {
await registerApi({
@@ -86,6 +102,7 @@ export default function Inscription() {
firstname: form.firstName,
lastname: form.lastName,
password: form.password,
confirmPassword: form.confirmPassword,
birthdate: form.birthdate,
gender: form.gender,
marketingOptIn,
@@ -256,6 +273,7 @@ export default function Inscription() {
<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"
autoComplete="new-password" aria-describedby="reg-password-rules"
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-500 hover:text-gray-600" aria-label="Afficher/masquer">
@@ -264,6 +282,17 @@ export default function Inscription() {
</div>
</button>
</div>
<PasswordRules id="reg-password-rules" password={form.password} />
</div>
<div>
<label htmlFor="reg-confirm-password" 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="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau"
autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined}
className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} />
{passwordMismatch && (
<p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p>
)}
</div>
<label className="flex items-start gap-3 cursor-pointer">

View File

@@ -0,0 +1,117 @@
import { useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { forgotPasswordApi } from '../services/auth'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
// Demande d'un lien de réinitialisation. La réponse est volontairement la même
// que le compte existe ou non, pour ne pas révéler quels emails sont inscrits.
export default function MotDePasseOublie() {
usePageMeta({
title: 'Mot de passe oublié | Thé Tip Top',
description: 'Recevez par email un lien pour choisir un nouveau mot de passe pour votre compte Thé Tip Top.',
})
// L'email déjà saisi sur la page de connexion est repris.
const { state } = useLocation()
const [email, setEmail] = useState(state?.email ?? '')
const [sent, setSent] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await forgotPasswordApi({ email, captchaToken })
setSent(data.message)
} catch (err) {
setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally {
setLoading(false)
}
}
if (sent) {
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-4">Vérifiez vos emails</h1>
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm" role="status">
{sent}
</div>
<p className="text-sm text-gray-500 mb-6">
Le lien est valable 30 minutes. Pensez à regarder dans vos courriers indésirables.
</p>
<button
type="button"
onClick={() => { setSent(''); setCaptchaToken('') }}
className="text-sm text-gray-500 hover:text-[#006B6B] hover:underline mb-3"
>
Je n'ai rien reçu, renvoyer un lien
</button>
<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 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 oublié</h1>
<p className="text-gray-500 mt-2 text-sm">
Saisissez l'adresse email de votre compte : nous vous enverrons un lien pour choisir un nouveau mot de passe.
</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" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="forgot-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="forgot-email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="Votre email"
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>
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button
type="submit"
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Envoi…' : 'Recevoir le lien'}
</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

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { setInitialPasswordApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
import { homeFor } from '../config/roles'
import PasswordRules from '../components/PasswordRules'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
// Première connexion d'un employé créé par un admin : le mot de passe reçu
// par email est provisoire, il doit être remplacé avant tout accès.
export default function NouveauMotDePasse() {
const navigate = useNavigate()
const { user, login, logout } = useAuth()
const [form, setForm] = useState({ password: '', confirmPassword: '' })
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (!user) return <Navigate to="/connexion" replace />
if (!user.mustChangePassword) return <Navigate to={homeFor(user)} replace />
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!isPasswordValid(form.password)) {
setError(PASSWORD_RULES_ERROR)
return
}
if (form.password !== form.confirmPassword) {
setError('Les deux mots de passe ne correspondent pas.')
return
}
setLoading(true)
try {
await setInitialPasswordApi(form)
const updatedUser = { ...user, mustChangePassword: false }
login(updatedUser, localStorage.getItem('token'))
navigate(homeFor(updatedUser), { replace: true })
} catch (err) {
if (err.status === 401) {
logout()
navigate('/connexion', { replace: true })
return
}
setError(err.message)
setLoading(false)
}
}
const handleLogout = () => {
logout()
navigate('/connexion', { replace: true })
}
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]">Nouveau mot de passe</h1>
<p className="text-gray-500 mt-2 text-sm">
Bienvenue {user.firstname} ! Vous vous êtes connecté avec un mot de passe provisoire.
Choisissez votre propre mot de passe pour accéder à votre espace.
</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" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="new-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="new-password"
name="password"
value={form.password}
onChange={handleChange}
required
autoComplete="new-password"
aria-describedby="new-password-rules"
placeholder="Votre nouveau mot de passe"
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]"
/>
<PasswordRules id="new-password-rules" password={form.password} />
</div>
<div>
<label htmlFor="confirm-new-password" 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="confirm-new-password"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required
autoComplete="new-password"
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">
<button type="button" onClick={handleLogout} className="text-gray-700 font-medium hover:underline">
Se déconnecter
</button>
</p>
</div>
)
}

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import UserNav from '../components/UserNav'
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
import { changePasswordApi, meApi, sendPasswordSetupLinkApi } from '../services/auth'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
import PasswordRules from '../components/PasswordRules'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
export default function Profile() {
const user = JSON.parse(localStorage.getItem('user') || '{}')
@@ -47,7 +49,7 @@ export default function Profile() {
setLinkError('')
setLinkSending(true)
try {
await forgotPasswordApi(user.email)
await sendPasswordSetupLinkApi()
setLinkSent(true)
} catch (e) {
if (!handleAuthError(e)) setLinkError(e.message)
@@ -67,8 +69,8 @@ export default function Profile() {
setPwError('Les nouveaux mots de passe ne correspondent pas.')
return
}
if (pwForm.newPassword.length < 8) {
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
if (!isPasswordValid(pwForm.newPassword)) {
setPwError(PASSWORD_RULES_ERROR)
return
}
@@ -193,6 +195,8 @@ export default function Profile() {
value={pwForm[name]}
onChange={handlePwChange}
required
autoComplete={name === 'currentPassword' ? 'current-password' : 'new-password'}
aria-describedby={name === 'newPassword' ? 'profile-password-rules' : undefined}
placeholder="••••••••"
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]"
/>
@@ -205,6 +209,9 @@ export default function Profile() {
<EyeIcon visible={showPw[key]} />
</button>
</div>
{name === 'newPassword' && (
<PasswordRules id="profile-password-rules" password={pwForm.newPassword} />
)}
</div>
))}

View File

@@ -61,11 +61,34 @@ export async function getWinnerApi() {
return data
}
export async function createEmployeeApi({ firstname, lastname, email, password }) {
export async function getEmployeesApi() {
const res = await fetch(`${BASE}/employees`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des employés.', res.status)
return data
}
export async function getEmployeeDeliveriesApi(employeeId) {
const res = await fetch(`${BASE}/employees/${employeeId}/deliveries`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des remises.', res.status)
return data
}
export async function getDeliveryByTicketCodeApi(code) {
const res = await fetch(`${BASE}/deliveries/${encodeURIComponent(code.trim())}`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche du ticket.', res.status)
return data
}
// Pas de mot de passe : le serveur en génère un provisoire et l'envoie par
// email à l'employé.
export async function createEmployeeApi({ firstname, lastname, email }) {
const res = await fetch(`${BASE}/employees`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ firstname, lastname, email, password }),
body: JSON.stringify({ firstname, lastname, email }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)

View File

@@ -23,6 +23,7 @@ export async function registerApi({
firstname,
lastname,
password,
confirmPassword,
birthdate,
gender,
marketingOptIn,
@@ -38,6 +39,7 @@ export async function registerApi({
firstname,
lastname,
password,
confirmPassword,
birthdate: birthdate || undefined,
gender: gender || undefined,
marketingOptIn: marketingOptIn ?? undefined,
@@ -66,11 +68,23 @@ export async function verifyCodeApi({ email, code }) {
return data
}
export async function forgotPasswordApi(email) {
export async function forgotPasswordApi({ email, captchaToken }) {
const res = await fetch(`${BASE}/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
body: JSON.stringify({ email, captchaToken: captchaToken || undefined }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
return data
}
// Utilisateur connecté : lien envoyé à l'adresse de son compte.
export async function sendPasswordSetupLinkApi() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/password-setup-link`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
@@ -109,6 +123,21 @@ export async function deleteAccountApi() {
return data
}
export async function setInitialPasswordApi({ password, confirmPassword }) {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/initial-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password, confirmPassword }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de l'enregistrement du mot de passe.", res.status)
return data
}
export async function changePasswordApi({ currentPassword, newPassword }) {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/change-password`, {

View File

@@ -8,6 +8,14 @@ function apiError(message, status) {
return err
}
// Public : dates du jeu-concours fixées par l'admin.
export async function getGameWindowApi() {
const res = await fetch(`${BASE}/window`)
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la lecture des dates du jeu.', res.status)
return data
}
export async function myGainsApi() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/my-gains`, {

View File

@@ -2,6 +2,9 @@ import '@testing-library/jest-dom'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
// jsdom n'implémente pas le défilement, présent dans tous les navigateurs.
Element.prototype.scrollIntoView ??= function scrollIntoView() {}
afterEach(() => {
cleanup()
localStorage.clear()

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import Accueil from '../../pages/Accueil'
import { getGameWindowApi } from '../../services/game'
import { renderWithProviders } from '../renderWithProviders'
vi.mock('../../services/game', () => ({
getGameWindowApi: vi.fn(),
}))
describe('Accueil - dates du jeu-concours', () => {
beforeEach(() => vi.clearAllMocks())
it("affiche les dates fixées dans le tableau de bord admin", async () => {
getGameWindowApi.mockResolvedValue({
configured: true,
phase: 'SCHEDULED',
startDate: '2026-11-14T23:00:00.000Z',
participationDeadline: '2026-12-14T23:00:00.000Z',
claimDeadline: '2027-01-13T23:00:00.000Z',
})
renderWithProviders(<Accueil />, { route: '/accueil' })
expect(await screen.findByText('Du 15 novembre au 14 décembre 2026')).toBeInTheDocument()
expect(screen.getByText(/ouverte du 15 novembre au 14 décembre 2026 inclus/)).toBeInTheDocument()
expect(screen.getByText(/retirer jusqu'au 13 janvier 2027/)).toBeInTheDocument()
expect(document.title).toBe('Jeu-concours 100 % Gagnant du 15 novembre au 14 décembre | Thé Tip Top')
})
it("affiche les dates par défaut (2 → 31 octobre 2026) si l'API ne répond pas", async () => {
getGameWindowApi.mockRejectedValue(new Error('réseau'))
renderWithProviders(<Accueil />, { route: '/accueil' })
expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument()
expect(screen.getByText(/retirer jusqu'au 30 novembre 2026/)).toBeInTheDocument()
await vi.waitFor(() => expect(getGameWindowApi).toHaveBeenCalled())
expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DefinirMotDePasse from '../../pages/DefinirMotDePasse'
import { resetPasswordApi } from '../../services/auth'
import { renderWithProviders } from '../renderWithProviders'
vi.mock('../../services/auth', () => ({
resetPasswordApi: vi.fn(),
}))
async function remplir(password, confirmPassword = password) {
await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password)
await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword)
await userEvent.click(screen.getByRole('button', { name: /enregistrer/i }))
}
describe('DefinirMotDePasse', () => {
beforeEach(() => vi.clearAllMocks())
it('affiche les règles du mot de passe', () => {
const { container } = renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
expect(container.querySelector('[data-rule="length"]')).toBeInTheDocument()
expect(container.querySelector('[data-rule="special"]')).toBeInTheDocument()
})
it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => {
renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
await remplir('MonMotDePasse1')
expect(screen.getByText(/dont un caractère spécial/i)).toBeInTheDocument()
expect(resetPasswordApi).not.toHaveBeenCalled()
})
it('propose de demander un nouveau lien quand celui-ci a expiré', async () => {
resetPasswordApi.mockRejectedValue(new Error('Lien de réinitialisation invalide ou expiré.'))
renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
await remplir('MonMotDePasse1!')
expect(await screen.findByRole('link', { name: /demander un nouveau lien/i })).toHaveAttribute('href', '/mot-de-passe-oublie')
})
it('envoie un mot de passe conforme', async () => {
resetPasswordApi.mockResolvedValue({ message: 'ok' })
renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
await remplir('MonMotDePasse1!')
expect(resetPasswordApi).toHaveBeenCalledWith(
expect.objectContaining({ password: 'MonMotDePasse1!', confirmPassword: 'MonMotDePasse1!' })
)
})
})

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EmployeeDeliveries from '../../components/admin/EmployeeDeliveries'
import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin'
vi.mock('../../services/admin', () => ({
getEmployeesApi: vi.fn(),
getEmployeeDeliveriesApi: vi.fn(),
getDeliveryByTicketCodeApi: vi.fn(),
}))
const MARC = { id: 10, firstname: 'Marc', lastname: 'Durand', email: 'marc@example.com', createdAt: '2026-09-01T00:00:00Z', mustChangePassword: false, deliveredCount: 1 }
const SOPHIE = { id: 11, firstname: 'Sophie', lastname: 'Bernard', email: 'sophie@example.com', createdAt: '2026-09-02T00:00:00Z', mustChangePassword: true, deliveredCount: 0 }
function renderComponent() {
return render(<EmployeeDeliveries refreshKey={0} onAuthError={() => false} />)
}
describe('EmployeeDeliveries', () => {
beforeEach(() => {
vi.clearAllMocks()
getEmployeesApi.mockResolvedValue({ employees: [MARC, SOPHIE] })
})
it('liste les employés et filtre par nom', async () => {
renderComponent()
expect(await screen.findByRole('button', { name: 'Marc Durand' })).toBeInTheDocument()
expect(screen.getByText('Première connexion en attente')).toBeInTheDocument()
await userEvent.type(screen.getByLabelText(/rechercher un employé/i), 'sophie')
expect(screen.queryByRole('button', { name: 'Marc Durand' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sophie Bernard' })).toBeInTheDocument()
})
it("affiche les lots remis quand on clique sur le nom d'un employé", async () => {
getEmployeeDeliveriesApi.mockResolvedValue({
employee: MARC,
deliveries: [{
claimId: 1,
ticketCode: 'A1B2C3D4E5',
lot: { name: 'Infuseur à thé', value: 39 },
client: { id: 5, firstname: 'Chloé', lastname: 'Martin', email: 'chloe@example.com' },
deliveredAt: '2026-09-10T14:30:00Z',
}],
})
renderComponent()
await userEvent.click(await screen.findByRole('button', { name: 'Marc Durand' }))
expect(getEmployeeDeliveriesApi).toHaveBeenCalledWith(10)
expect(await screen.findByText('A1B2C3D4E5')).toBeInTheDocument()
expect(screen.getByText(/lots remis par marc durand/i)).toBeInTheDocument()
expect(screen.getByText('Infuseur à thé')).toBeInTheDocument()
})
it("retrouve l'employé et la date de remise à partir du numéro de ticket", async () => {
getDeliveryByTicketCodeApi.mockResolvedValue({
ticketCode: 'A1B2C3D4E5',
lot: { name: 'Infuseur à thé', value: 39 },
status: 'DELIVERED',
playedAt: '2026-09-01T10:00:00Z',
client: { id: 5, firstname: 'Chloé', lastname: 'Martin', email: 'chloe@example.com' },
deliveredBy: { id: 10, firstname: 'Marc', lastname: 'Durand', email: 'marc@example.com' },
deliveredAt: '2026-09-10T14:30:00Z',
})
getEmployeeDeliveriesApi.mockResolvedValue({ employee: MARC, deliveries: [] })
renderComponent()
await userEvent.type(screen.getByLabelText(/numéro du ticket/i), 'a1b2c3d4e5')
await userEvent.click(screen.getByRole('button', { name: /^rechercher$/i }))
const resultat = await screen.findByTestId('ticket-result')
expect(getDeliveryByTicketCodeApi).toHaveBeenCalledWith('a1b2c3d4e5')
expect(within(resultat).getByText('Lot remis')).toBeInTheDocument()
expect(within(resultat).getByText(/remis par/i)).toHaveTextContent(new Date('2026-09-10T14:30:00Z').toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
}))
// Le nom de l'employé mène à la liste de ses remises.
await userEvent.click(within(resultat).getByRole('button', { name: 'Marc Durand' }))
expect(getEmployeeDeliveriesApi).toHaveBeenCalledWith(10)
expect(await screen.findByText(/n'a encore remis aucun lot/i)).toBeInTheDocument()
})
it("affiche l'erreur quand le ticket n'existe pas", async () => {
getDeliveryByTicketCodeApi.mockRejectedValue(new Error('Aucun ticket ne correspond à ce numéro.'))
renderComponent()
await userEvent.type(screen.getByLabelText(/numéro du ticket/i), 'INCONNU')
await userEvent.click(screen.getByRole('button', { name: /^rechercher$/i }))
expect(await screen.findByText('Aucun ticket ne correspond à ce numéro.')).toBeInTheDocument()
})
})

View File

@@ -10,13 +10,21 @@ vi.mock('../../services/auth', () => ({
verifyCodeApi: vi.fn(),
}))
// Remplit et soumet le formulaire d'inscription (étape 1).
async function remplirEtSoumettre() {
// Remplit les champs obligatoires (la date de naissance l'est depuis la règle
// des 18 ans), sans soumettre.
async function remplirChampsObligatoires({ password = 'MotDePasse123!', confirmPassword = password } = {}) {
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.type(screen.getByLabelText(/date de naissance/i), '1990-05-14')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), password)
await userEvent.type(screen.getByLabelText(/confirmer le mot de passe/i), confirmPassword)
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
}
// Remplit et soumet le formulaire d'inscription (étape 1).
async function remplirEtSoumettre(options) {
await remplirChampsObligatoires(options)
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
}
@@ -41,6 +49,50 @@ describe('Inscription', () => {
expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument()
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument()
expect(screen.getByLabelText(/confirmer le mot de passe/i)).toBeInTheDocument()
})
it('coche les règles du mot de passe au fil de la saisie', async () => {
const { container } = renderWithProviders(<Inscription />, { route: '/inscription' })
const regle = (id) => container.querySelector(`[data-rule="${id}"]`)
expect(regle('length')).toHaveAttribute('data-valid', 'false')
expect(regle('special')).toHaveAttribute('data-valid', 'false')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'abcdefgh')
expect(regle('length')).toHaveAttribute('data-valid', 'true')
expect(regle('special')).toHaveAttribute('data-valid', 'false')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), '!')
expect(regle('special')).toHaveAttribute('data-valid', 'true')
})
it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => {
renderWithProviders(<Inscription />, { route: '/inscription' })
await remplirEtSoumettre({ password: 'MotDePasse123' })
expect(screen.getByText(/au moins 8 caractères, dont un caractère spécial/i)).toBeInTheDocument()
expect(registerApi).not.toHaveBeenCalled()
})
it("refuse un mot de passe trop court sans appeler l'API", async () => {
renderWithProviders(<Inscription />, { route: '/inscription' })
await remplirEtSoumettre({ password: 'Ab1!xyz' })
expect(screen.getByText(/au moins 8 caractères, dont un caractère spécial/i)).toBeInTheDocument()
expect(registerApi).not.toHaveBeenCalled()
})
it("signale une confirmation différente et bloque l'inscription", async () => {
renderWithProviders(<Inscription />, { route: '/inscription' })
await remplirChampsObligatoires({ password: 'MotDePasse123!', confirmPassword: 'MotDePasse124!' })
expect(screen.getByLabelText(/confirmer le mot de passe/i)).toHaveAttribute('aria-invalid', 'true')
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
expect(screen.getAllByText(/ne correspondent pas/i).length).toBeGreaterThan(0)
expect(registerApi).not.toHaveBeenCalled()
})
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
@@ -64,9 +116,11 @@ describe('Inscription', () => {
firstname: 'Jean',
lastname: 'Dupont',
password: 'MotDePasse123!',
birthdate: '',
confirmPassword: 'MotDePasse123!',
birthdate: '1990-05-14',
gender: '',
marketingOptIn: false,
captchaToken: '',
})
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
@@ -83,6 +137,7 @@ describe('Inscription', () => {
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.type(screen.getByLabelText(/confirmer le 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 }))
@@ -99,13 +154,8 @@ describe('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 }))
await remplirEtSoumettre()
expect(registerApi).toHaveBeenCalledWith(
expect.objectContaining({ marketingOptIn: true })

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MotDePasseOublie from '../../pages/MotDePasseOublie'
import Connexion from '../../pages/Connexion'
import { forgotPasswordApi } from '../../services/auth'
import { renderWithProviders } from '../renderWithProviders'
vi.mock('../../services/auth', () => ({
forgotPasswordApi: vi.fn(),
loginApi: vi.fn(),
}))
describe('MotDePasseOublie', () => {
beforeEach(() => vi.clearAllMocks())
it("envoie la demande et affiche la confirmation neutre", async () => {
forgotPasswordApi.mockResolvedValue({ message: 'Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé.' })
renderWithProviders(<MotDePasseOublie />, { route: '/mot-de-passe-oublie' })
await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com')
await userEvent.click(screen.getByRole('button', { name: /recevoir le lien/i }))
expect(forgotPasswordApi).toHaveBeenCalledWith({ email: 'lea@example.com', captchaToken: '' })
expect(await screen.findByRole('status')).toHaveTextContent(/si un compte existe/i)
expect(screen.getByRole('link', { name: /retour à la connexion/i })).toHaveAttribute('href', '/connexion')
})
it("affiche l'erreur renvoyée par l'API", async () => {
forgotPasswordApi.mockRejectedValue(new Error('Trop de tentatives, réessayez dans quelques minutes.'))
renderWithProviders(<MotDePasseOublie />, { route: '/mot-de-passe-oublie' })
await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com')
await userEvent.click(screen.getByRole('button', { name: /recevoir le lien/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/trop de tentatives/i)
})
it("le lien de la page de connexion y mène en reprenant l'email saisi", async () => {
renderWithProviders(<Connexion />, {
route: '/connexion',
routes: { '/mot-de-passe-oublie': <MotDePasseOublie /> },
})
await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com')
await userEvent.click(screen.getByRole('link', { name: /mot de passe oublié/i }))
expect(await screen.findByRole('heading', { name: /mot de passe oublié/i })).toBeInTheDocument()
expect(screen.getByLabelText(/adresse email/i)).toHaveValue('lea@example.com')
})
})

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import NouveauMotDePasse from '../../pages/NouveauMotDePasse'
import { setInitialPasswordApi } from '../../services/auth'
import { renderWithProviders } from '../renderWithProviders'
vi.mock('../../services/auth', () => ({
setInitialPasswordApi: vi.fn(),
}))
const EMPLOYE = { id: 3, firstname: 'Marc', email: 'marc@example.com', role: 'EMPLOYEE', mustChangePassword: true }
async function remplir(password, confirmPassword) {
await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password)
await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword)
await userEvent.click(screen.getByRole('button', { name: /enregistrer/i }))
}
describe('NouveauMotDePasse', () => {
beforeEach(() => vi.clearAllMocks())
it("refuse deux mots de passe différents sans appeler l'API", async () => {
renderWithProviders(<NouveauMotDePasse />, { route: '/nouveau-mot-de-passe', user: EMPLOYE })
await remplir('NouveauMdp123!', 'AutreMdp456!')
expect(screen.getByRole('alert')).toHaveTextContent(/ne correspondent pas/i)
expect(setInitialPasswordApi).not.toHaveBeenCalled()
})
it("enregistre le mot de passe puis ouvre l'espace employé", async () => {
setInitialPasswordApi.mockResolvedValue({ message: 'ok' })
renderWithProviders(<NouveauMotDePasse />, {
route: '/nouveau-mot-de-passe',
user: EMPLOYE,
routes: { '/employe': <p>Page Employé</p> },
})
await remplir('NouveauMdp123!', 'NouveauMdp123!')
expect(await screen.findByText('Page Employé')).toBeInTheDocument()
expect(setInitialPasswordApi).toHaveBeenCalledWith({ password: 'NouveauMdp123!', confirmPassword: 'NouveauMdp123!' })
expect(JSON.parse(localStorage.getItem('user')).mustChangePassword).toBe(false)
})
it("affiche l'erreur renvoyée par l'API", async () => {
setInitialPasswordApi.mockRejectedValue(new Error('Choisissez un mot de passe différent du mot de passe provisoire.'))
renderWithProviders(<NouveauMotDePasse />, { route: '/nouveau-mot-de-passe', user: EMPLOYE })
await remplir('Provisoire12!', 'Provisoire12!')
expect(await screen.findByText(/différent du mot de passe provisoire/i)).toBeInTheDocument()
})
it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => {
renderWithProviders(<NouveauMotDePasse />, { route: '/nouveau-mot-de-passe', user: EMPLOYE })
await remplir('NouveauMdp123', 'NouveauMdp123')
expect(screen.getByRole('alert')).toHaveTextContent(/caractère spécial/i)
expect(setInitialPasswordApi).not.toHaveBeenCalled()
})
it("renvoie vers son espace un utilisateur qui n'a pas de mot de passe provisoire", () => {
renderWithProviders(<NouveauMotDePasse />, {
route: '/nouveau-mot-de-passe',
user: { ...EMPLOYE, mustChangePassword: false },
routes: { '/employe': <p>Page Employé</p> },
})
expect(screen.getByText('Page Employé')).toBeInTheDocument()
})
})

View File

@@ -77,4 +77,20 @@ describe('ProtectedRoute', () => {
expect(screen.getByText('Page Employé')).toBeInTheDocument()
})
it('impose le choix du mot de passe tant que le provisoire n\'est pas remplacé', () => {
renderWithProviders(
<ProtectedRoute roles={['EMPLOYEE']}>
<p>Espace employé</p>
</ProtectedRoute>,
{
route: '/employe',
user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE', mustChangePassword: true },
routes: { '/nouveau-mot-de-passe': <p>Page Nouveau mot de passe</p> },
}
)
expect(screen.getByText('Page Nouveau mot de passe')).toBeInTheDocument()
expect(screen.queryByText('Espace employé')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { toGameDates, gameDatesFromStart, formatDay, formatRange } from '../../utils/gameDates'
describe('gameDates', () => {
it('retombe sur le 2 → 31 octobre 2026 quand le jeu n\'est pas configuré', () => {
for (const gameWindow of [null, { configured: false, startDate: null }]) {
const { start, lastPlayDay, lastClaimDay } = toGameDates(gameWindow)
expect(formatRange(start, lastPlayDay, { padDay: true })).toBe('02 au 31 octobre 2026')
expect(formatDay(lastClaimDay)).toBe('30 novembre 2026')
}
})
it("utilise les échéances renvoyées par l'API", () => {
const dates = toGameDates({
configured: true,
startDate: '2026-11-14T23:00:00.000Z', // 15 novembre 00:00 à Paris
participationDeadline: '2026-12-14T23:00:00.000Z',
claimDeadline: '2027-01-13T23:00:00.000Z',
})
expect(formatRange(dates.start, dates.lastPlayDay)).toBe('15 novembre au 14 décembre 2026')
expect(formatDay(dates.lastClaimDay)).toBe('13 janvier 2027')
})
it("calcule l'aperçu admin à partir de la seule date de début, en heure de Paris", () => {
const dates = gameDatesFromStart(new Date('2026-10-02T00:00:00+02:00'))
expect(formatRange(dates.start, dates.lastPlayDay)).toBe('2 au 31 octobre 2026')
expect(formatDay(dates.lastClaimDay)).toBe('30 novembre 2026')
})
it('formate une période à cheval sur deux années', () => {
// Début à 10 h : l'échéance tombe le 19 janvier à 10 h, dernier jour jouable.
const dates = gameDatesFromStart(new Date('2026-12-20T10:00:00+01:00'))
expect(formatRange(dates.start, dates.lastPlayDay)).toBe('20 décembre 2026 au 19 janvier 2027')
expect(formatRange(dates.start, dates.lastPlayDay, { withYear: false })).toBe('20 décembre au 19 janvier')
})
})

View File

@@ -0,0 +1,80 @@
// Dates du jeu-concours affichées au public. Elles viennent de l'API (date de
// début fixée par l'admin) ; en attendant la réponse, ou si le jeu n'est pas
// configuré, on affiche les dates prévues par le règlement : 2 → 31 octobre 2026.
const TIME_ZONE = 'Europe/Paris'
const DAY_MS = 24 * 60 * 60 * 1000
// Mêmes durées que backend/src/utils/gameWindow.js.
const GAME_DURATION_DAYS = 30
const CLAIM_EXTENSION_DAYS = 30
export const DEFAULT_START_DATE = '2026-10-02T00:00:00+02:00'
function windowFromStart(startDate) {
const start = new Date(startDate)
return {
start,
participationDeadline: new Date(start.getTime() + GAME_DURATION_DAYS * DAY_MS),
claimDeadline: new Date(start.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS),
}
}
// Les échéances sont des instants exclusifs (début + 30 j) : le dernier jour
// affiché est celui qui précède immédiatement l'échéance.
function lastDayBefore(deadline) {
return new Date(deadline.getTime() - 1)
}
export function toGameDates(gameWindow) {
const source = gameWindow?.configured && gameWindow.startDate
? {
start: new Date(gameWindow.startDate),
participationDeadline: new Date(gameWindow.participationDeadline),
claimDeadline: new Date(gameWindow.claimDeadline),
}
: windowFromStart(DEFAULT_START_DATE)
return {
start: source.start,
lastPlayDay: lastDayBefore(source.participationDeadline),
lastClaimDay: lastDayBefore(source.claimDeadline),
}
}
// Même calcul à partir d'une date de début seule (aperçu dans l'admin).
export function gameDatesFromStart(startDate) {
return toGameDates({ configured: true, startDate, ...windowFromStart(startDate) })
}
function parts(date) {
const values = {}
new Intl.DateTimeFormat('fr-FR', { timeZone: TIME_ZONE, day: 'numeric', month: 'long', year: 'numeric' })
.formatToParts(date)
.forEach(({ type, value }) => { values[type] = value })
return values
}
// « 30 novembre 2026 »
export function formatDay(date) {
const { day, month, year } = parts(date)
return `${day} ${month} ${year}`
}
// « 2 au 31 octobre 2026 », « 15 octobre au 13 novembre 2026 »,
// « 20 décembre 2026 au 18 janvier 2027 ». Sans « du » : l'appelant choisit
// la majuscule. `padDay` écrit « 02 », `withYear: false` omet l'année.
export function formatRange(start, end, { padDay = false, withYear = true } = {}) {
const a = parts(start)
const b = parts(end)
const startDay = padDay ? a.day.padStart(2, '0') : a.day
const year = withYear ? ` ${b.year}` : ''
if (a.year !== b.year) {
return `${startDay} ${a.month}${withYear ? ` ${a.year}` : ''} au ${b.day} ${b.month}${year}`
}
if (a.month !== b.month) {
return `${startDay} ${a.month} au ${b.day} ${b.month}${year}`
}
return `${startDay} au ${b.day} ${b.month}${year}`
}

View File

@@ -0,0 +1,14 @@
// Règle de mot de passe, identique au backend
// (backend/src/validations/password.validation.js) : plus de 7 caractères et
// au moins un caractère spécial (ni lettre, accentuée comprise, ni chiffre,
// ni espace).
export const PASSWORD_RULES = [
{ id: 'length', label: '8 caractères minimum', test: (password) => password.length >= 8 },
{ id: 'special', label: 'Au moins un caractère spécial (!, @, #, ?, …)', test: (password) => /[^\p{L}\p{N}\s]/u.test(password) },
]
export function isPasswordValid(password) {
return PASSWORD_RULES.every((rule) => rule.test(password))
}
export const PASSWORD_RULES_ERROR = 'Le mot de passe doit contenir au moins 8 caractères, dont un caractère spécial.'