merge: integrate Emmanuel changes into main
This commit is contained in:
19
frontend/src/components/Captcha.jsx
Normal file
19
frontend/src/components/Captcha.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from '../config/captcha'
|
||||
|
||||
// `ref` expose reset() : un jeton Turnstile n'est valable qu'une fois, il faut
|
||||
// en redemander un après chaque envoi refusé.
|
||||
export default function Captcha({ ref, onToken }) {
|
||||
if (!CAPTCHA_ENABLED) return null
|
||||
|
||||
return (
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
options={{ language: 'fr', size: 'flexible' }}
|
||||
onSuccess={onToken}
|
||||
onExpire={() => onToken('')}
|
||||
onError={() => onToken('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function Footer() {
|
||||
<footer className="bg-[#006B6B] text-white">
|
||||
|
||||
{/* Partie principale */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8">
|
||||
|
||||
{/* Logo */}
|
||||
<div>
|
||||
@@ -57,6 +57,16 @@ export default function Footer() {
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/notre-concept"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Notre concept
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/regles-jeu"
|
||||
@@ -179,7 +189,6 @@ export default function Footer() {
|
||||
>
|
||||
01 84 80 20 34
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="mailto:contact@thetiptop.fr"
|
||||
className="inline-block mt-3 font-medium hover:text-teal-300"
|
||||
@@ -198,9 +207,23 @@ export default function Footer() {
|
||||
|
||||
{/* Barre basse */}
|
||||
<div className="border-t border-teal-800">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 text-center">
|
||||
<p className="text-teal-100 text-xs leading-relaxed">
|
||||
Jeu avec obligation d'achat. Participation réservée aux personnes majeures résidant en
|
||||
France métropolitaine, sur présentation d'un ticket de caisse supérieur à 49 € émis
|
||||
dans la boutique Thé Tip Top de Nice. Le nombre de participations n'augmente pas les
|
||||
chances de remporter le gros lot.{' '}
|
||||
<a
|
||||
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||
className="underline hover:text-white"
|
||||
>
|
||||
Télécharger le règlement (PDF)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
|
||||
|
||||
<p className="text-teal-200 text-sm text-center md:text-left">
|
||||
<p className="text-teal-100 text-sm text-center md:text-left">
|
||||
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
||||
<br className="md:hidden" />
|
||||
<span className="md:before:content-['_—_']">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import logo from '../assets/img/logo.png'
|
||||
import logo from '../assets/img/logo.webp'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Header() {
|
||||
@@ -19,7 +19,9 @@ export default function Header() {
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/accueil', label: 'Accueil' },
|
||||
{ to: '/notre-concept', label: 'Notre concept' },
|
||||
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
||||
{ to: '/contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
const initials = user
|
||||
|
||||
21
frontend/src/components/PasswordRules.jsx
Normal file
21
frontend/src/components/PasswordRules.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
288
frontend/src/components/admin/EmployeeDeliveries.jsx
Normal file
288
frontend/src/components/admin/EmployeeDeliveries.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user