merge: integrate Emmanuel changes into main
This commit is contained in:
@@ -7,7 +7,10 @@ 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'
|
||||
import MesGains from './pages/MesGains'
|
||||
import Employe from './pages/Employe'
|
||||
@@ -32,10 +35,13 @@ 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>} />
|
||||
<Route path="/notre-concept" element={<Layout><NotreConcept /></Layout>} />
|
||||
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
||||
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
||||
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
||||
|
||||
BIN
frontend/src/assets/img/banner-accueil.webp
Normal file
BIN
frontend/src/assets/img/banner-accueil.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/src/assets/img/banner-the-tip-top.webp
Normal file
BIN
frontend/src/assets/img/banner-the-tip-top.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 MiB |
BIN
frontend/src/assets/img/img-contact.webp
Normal file
BIN
frontend/src/assets/img/img-contact.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
BIN
frontend/src/assets/img/logo.webp
Normal file
BIN
frontend/src/assets/img/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
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>
|
||||
)
|
||||
}
|
||||
8
frontend/src/config/captcha.js
Normal file
8
frontend/src/config/captcha.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
|
||||
|
||||
// Sans clé (tests, dev sans .env), aucun widget n'est affiché et aucun jeton
|
||||
// n'est exigé côté front ; le backend fait de même sans TURNSTILE_SECRET_KEY.
|
||||
// navigator.webdriver : le pré-rendu Playwright (scripts/prerender.js) ne doit
|
||||
// pas figer l'iframe Cloudflare dans le HTML statique.
|
||||
export const CAPTCHA_ENABLED =
|
||||
Boolean(TURNSTILE_SITE_KEY) && !(typeof navigator !== 'undefined' && navigator.webdriver)
|
||||
10
frontend/src/config/roles.js
Normal file
10
frontend/src/config/roles.js
Normal 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'
|
||||
}
|
||||
26
frontend/src/hooks/useGameDates.js
Normal file
26
frontend/src/hooks/useGameDates.js
Normal 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])
|
||||
}
|
||||
52
frontend/src/hooks/usePageMeta.js
Normal file
52
frontend/src/hooks/usePageMeta.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
// Domaine de production, utilisé pour construire des URLs absolues
|
||||
// (canonical, og:url) à partir du chemin de la route courante.
|
||||
const SITE_URL = 'https://www.dsp5-archi-o24a-4-5-g4.fr'
|
||||
|
||||
// Change la valeur d'un attribut sur le premier élément trouvé par le
|
||||
// sélecteur, et retourne de quoi la restaurer plus tard. N'écrit rien si
|
||||
// l'élément est absent du <head> (les balises de base vivent dans
|
||||
// index.html ; ce hook ne fait que les mettre à jour, jamais les créer).
|
||||
function setAttr(selector, attr, value) {
|
||||
if (value == null) return null
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) return null
|
||||
const previous = el.getAttribute(attr)
|
||||
el.setAttribute(attr, value)
|
||||
return () => el.setAttribute(attr, previous ?? '')
|
||||
}
|
||||
|
||||
// Permet à une page de définir son propre <title>, sa meta description, son
|
||||
// URL canonique et ses balises Open Graph/Twitter le temps qu'elle est
|
||||
// affichée. La SPA n'a qu'un seul index.html : sans ce hook, toutes les
|
||||
// routes partagent les mêmes balises. Les valeurs précédentes sont
|
||||
// restaurées au démontage, donc quitter la page revient à l'état d'avant
|
||||
// (celui d'index.html, ou celui d'une page parente si elles s'enchaînent).
|
||||
export default function usePageMeta({ title, description, image } = {}) {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title
|
||||
if (title) document.title = title
|
||||
|
||||
const url = `${SITE_URL}${pathname}`
|
||||
const restores = [
|
||||
setAttr('meta[name="description"]', 'content', description),
|
||||
setAttr('link[rel="canonical"]', 'href', url),
|
||||
setAttr('meta[property="og:title"]', 'content', title),
|
||||
setAttr('meta[property="og:description"]', 'content', description),
|
||||
setAttr('meta[property="og:url"]', 'content', url),
|
||||
setAttr('meta[property="og:image"]', 'content', image),
|
||||
setAttr('meta[name="twitter:title"]', 'content', title),
|
||||
setAttr('meta[name="twitter:description"]', 'content', description),
|
||||
setAttr('meta[name="twitter:image"]', 'content', image),
|
||||
].filter(Boolean)
|
||||
|
||||
return () => {
|
||||
document.title = previousTitle
|
||||
restores.forEach((restore) => restore())
|
||||
}
|
||||
}, [title, description, image, pathname])
|
||||
}
|
||||
@@ -1 +1,54 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Animation d'entrée de la bannière d'accueil : l'illustration glisse
|
||||
légèrement depuis la droite en grossissant jusqu'à sa taille finale, et
|
||||
les cartes de gains apparaissent juste après avec un léger décalage
|
||||
(delay défini au cas par cas via [animation-delay:...]). */
|
||||
@keyframes banner-image-enter {
|
||||
0% { opacity: 0; transform: translateX(70px) scale(0.9); }
|
||||
100% { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
@keyframes banner-badge-enter {
|
||||
0% { opacity: 0; transform: translateY(16px) scale(0.9); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Flottement continu des cartes de la bannière, une fois qu'elles sont
|
||||
apparues (deux variantes légèrement différentes pour éviter un
|
||||
mouvement trop synchronisé entre les deux cartes). Appliqué sur un
|
||||
conteneur séparé de celui qui gère l'inclinaison au survol (tilt), pour
|
||||
que les deux animations du `transform` ne se marchent pas dessus. */
|
||||
@keyframes card-float-a {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-10px) rotate(1.5deg); }
|
||||
}
|
||||
@keyframes card-float-b {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-7px) rotate(-1.5deg); }
|
||||
}
|
||||
|
||||
/* Dérive douce des particules de thé générées en JS (position, taille,
|
||||
durée et délai tirés au sort côté composant, amplitude reçue ici via la
|
||||
variable CSS --tea-drift posée en style inline par particule). */
|
||||
@keyframes tea-drift {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(calc(-1 * var(--tea-drift, 12px))) rotate(8deg); }
|
||||
}
|
||||
|
||||
.animate-banner-image { animation: banner-image-enter 900ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-banner-badge { animation: banner-badge-enter 600ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-card-float-a { animation: card-float-a 4.5s ease-in-out infinite; }
|
||||
.animate-card-float-b { animation: card-float-b 5.5s ease-in-out infinite; }
|
||||
.animate-tea-drift { animation: tea-drift 6s ease-in-out infinite; }
|
||||
|
||||
/* Respecte les préférences d'accessibilité : pas d'animation pour les
|
||||
personnes qui ont désactivé les mouvements dans leur OS. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-banner-image,
|
||||
.animate-banner-badge,
|
||||
.animate-card-float-a,
|
||||
.animate-card-float-b,
|
||||
.animate-tea-drift {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Accessibilite() {
|
||||
usePageMeta({
|
||||
title: 'Déclaration d’accessibilité | Thé Tip Top',
|
||||
description: "État de conformité RGAA du site Thé Tip Top et procédure pour signaler un problème d'accessibilité rencontré sur le site.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
@@ -31,7 +38,7 @@ export default function Accessibilite() {
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
||||
part afin que nous puissions y remédier :<br />
|
||||
<strong>Email :</strong> contact@thetiptop.fr
|
||||
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,30 +1,221 @@
|
||||
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 = [
|
||||
{ label: 'Infuseur à thé', top: '26%', right: '26%', delay: 700, variant: 'a' },
|
||||
{ label: 'Boîte 100 g détox ou infusion', top: '22%', right: '1%', delay: 850, variant: 'b' },
|
||||
{ label: 'Boîte 100 g thé signature', top: '48%', right: '30%', delay: 1000, variant: 'a' },
|
||||
{ label: 'Coffret découverte 39 €', top: '78%', right: '2%', delay: 1150, variant: 'b' },
|
||||
{ label: 'Coffret découverte 69 €', top: '84%', right: '24%', delay: 1300, variant: 'a' },
|
||||
]
|
||||
|
||||
function handleCardTilt(e) {
|
||||
const el = e.currentTarget
|
||||
const rect = el.getBoundingClientRect()
|
||||
const px = (e.clientX - rect.left) / rect.width - 0.5
|
||||
const py = (e.clientY - rect.top) / rect.height - 0.5
|
||||
el.style.transform = `perspective(700px) rotateX(${(-py * 12).toFixed(2)}deg) rotateY(${(px * 12).toFixed(2)}deg) scale(1.04)`
|
||||
}
|
||||
function resetCardTilt(e) {
|
||||
e.currentTarget.style.transform = ''
|
||||
}
|
||||
|
||||
const TEA_EMOJI = ['🍃', '🍵', '🫖', '☕', '🥄', '✨']
|
||||
|
||||
function createTeaParticles(count) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
emoji: TEA_EMOJI[i % TEA_EMOJI.length],
|
||||
left: Math.round(Math.random() * 100),
|
||||
top: Math.round(Math.random() * 100),
|
||||
size: Math.round(20 + Math.random() * 26),
|
||||
duration: +(4 + Math.random() * 4).toFixed(1),
|
||||
delay: +(Math.random() * 4).toFixed(1),
|
||||
opacity: +(0.18 + Math.random() * 0.22).toFixed(2),
|
||||
drift: Math.round(12 + Math.random() * 20),
|
||||
}))
|
||||
}
|
||||
|
||||
// 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, 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 ?',
|
||||
answer: (
|
||||
<>
|
||||
Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter :
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>Un infuseur à thé</li>
|
||||
<li>Une boîte de 100 g de thé détox ou d'infusion</li>
|
||||
<li>Une boîte de 100 g de thé signature</li>
|
||||
<li>Un coffret découverte d'une valeur de 39 €</li>
|
||||
<li>Un coffret découverte d'une valeur de 69 €</li>
|
||||
</ul>
|
||||
Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.
|
||||
</>
|
||||
),
|
||||
plainAnswer: "Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : un infuseur à thé, une boîte de 100 g de thé détox ou d'infusion, une boîte de 100 g de thé signature, un coffret découverte d'une valeur de 39 €, ou un coffret découverte d'une valeur de 69 €. Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.",
|
||||
},
|
||||
{
|
||||
question: 'Jusqu\'à quand puis-je jouer ?',
|
||||
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 ?',
|
||||
answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.",
|
||||
},
|
||||
{
|
||||
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 ${formatDay(lastClaimDay)}.`,
|
||||
},
|
||||
]
|
||||
|
||||
const buildFaqJsonLd = (faqs) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
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 ${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), [])
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
|
||||
Bienvenue au jeu-concours Thé Tip Top
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
|
||||
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
|
||||
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
|
||||
plus pour découvrir votre lot.
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
|
||||
|
||||
<section className="relative bg-[#006B6B] overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 relative py-10 lg:py-0 lg:h-[680px] xl:h-[780px]">
|
||||
|
||||
<div
|
||||
className="hidden lg:block absolute right-[-10%] top-1/2 -translate-y-1/2 w-[75%] h-[75%] rounded-full bg-white/10 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden z-0" aria-hidden="true">
|
||||
{teaParticles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="absolute animate-tea-drift select-none"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
top: `${p.top}%`,
|
||||
fontSize: `${p.size}px`,
|
||||
opacity: p.opacity,
|
||||
animationDuration: `${p.duration}s`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
'--tea-drift': `${p.drift}px`,
|
||||
}}
|
||||
>
|
||||
{p.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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 {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{' '}
|
||||
<span className="text-amber-300">100 % Gagnant</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="relative -mx-4 sm:mx-0 lg:mx-0 mb-6 lg:mb-0 lg:contents">
|
||||
<div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden">
|
||||
<img
|
||||
src={bannerImg}
|
||||
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={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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="static lg:absolute lg:left-0 lg:top-1/2 lg:-translate-y-1/2 max-w-full lg:max-w-md text-lg sm:text-2xl lg:text-3xl font-semibold text-white leading-snug [text-wrap:balance] mb-6 lg:mb-0 bg-white/10 backdrop-blur-sm border-l-4 border-amber-300 rounded-2xl px-4 py-3 sm:px-6 sm:py-4 shadow-lg">
|
||||
À l'occasion de l'ouverture de notre{' '}
|
||||
<span className="text-amber-300">10<sup>e</sup> boutique</span> à Nice : chaque code
|
||||
distribué correspond à un lot réel, attribué à l'avance.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||
|
||||
<div className="hidden lg:contents">
|
||||
|
||||
<div className="shrink-0 lg:absolute lg:right-[1%] lg:top-[45%] animate-banner-badge [animation-delay:500ms]">
|
||||
<div className="animate-card-float-a">
|
||||
<div
|
||||
onMouseMove={handleCardTilt}
|
||||
onMouseLeave={resetCardTilt}
|
||||
className="bg-white rounded-2xl shadow-xl px-5 sm:px-7 py-4 sm:py-5 text-center transition-transform duration-200 ease-out will-change-transform cursor-default"
|
||||
>
|
||||
<p className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-gray-900 leading-none">
|
||||
360 <span className="text-[#006B6B]">€</span>
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap mt-1.5">de thé à gagner</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gains.map((gain) => (
|
||||
<div
|
||||
key={gain.label}
|
||||
className="shrink-0 lg:absolute animate-banner-badge"
|
||||
style={{ top: gain.top, right: gain.right, animationDelay: `${gain.delay}ms` }}
|
||||
>
|
||||
<div className={gain.variant === 'a' ? 'animate-card-float-a' : 'animate-card-float-b'}>
|
||||
<div
|
||||
onMouseMove={handleCardTilt}
|
||||
onMouseLeave={resetCardTilt}
|
||||
className="bg-white rounded-2xl shadow-xl px-3.5 py-2.5 text-left transition-transform duration-200 ease-out will-change-transform cursor-default"
|
||||
>
|
||||
<span className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap">{gain.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="static lg:absolute lg:left-0 lg:bottom-24 xl:bottom-28 flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<Link
|
||||
to="/saisir-code-unique"
|
||||
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
||||
className="bg-white hover:bg-gray-100 text-[#006B6B] font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base text-center"
|
||||
>
|
||||
Tester mon code
|
||||
</Link>
|
||||
<Link
|
||||
to="/regles-jeu"
|
||||
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#005555] hover:text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
||||
className="border-2 border-white text-white hover:bg-white hover:text-[#006B6B] font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base text-center"
|
||||
>
|
||||
Découvrir les règles
|
||||
</Link>
|
||||
@@ -32,22 +223,47 @@ export default function Accueil() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comment participer */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||
<section className="max-w-5xl mx-auto px-4 pt-6 sm:px-6 pb-12 sm:pb-20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||
Comment participer ?
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-3 gap-4 list-none">
|
||||
{[
|
||||
{ num: '1', text: 'Achat de 49 € minimum' },
|
||||
{ num: '2', text: 'Recevez votre code unique' },
|
||||
{ num: '3', text: 'Saisissez le code sur le site' },
|
||||
{ num: '4', text: 'Découvrez votre lot' },
|
||||
{ num: '1', text: "Achat de 49 € dans notre boutique de Nice, code sur le ticket" },
|
||||
{ num: '2', text: 'Créez votre compte et saisissez le code' },
|
||||
{ num: '3', text: 'Découvrez et retirez votre lot en boutique' },
|
||||
].map((step) => (
|
||||
<div key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
||||
<li key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
|
||||
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="max-w-3xl mx-auto px-4 sm:px-6 pb-16 sm:pb-24">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||
Questions fréquentes
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq) => (
|
||||
<details key={faq.question} className="group bg-white rounded-2xl shadow p-4 sm:p-6">
|
||||
<summary className="flex items-center justify-between gap-3 cursor-pointer list-none font-semibold text-gray-800 text-sm sm:text-base">
|
||||
{faq.question}
|
||||
<svg
|
||||
className="w-4 h-4 text-[#006B6B] flex-shrink-0 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="text-gray-600 text-sm sm:text-base mt-3 leading-relaxed">
|
||||
{faq.answer}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||
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 (
|
||||
@@ -39,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',
|
||||
@@ -63,24 +71,25 @@ export default function Admin() {
|
||||
const [settingsError, setSettingsError] = useState('')
|
||||
const [settingsOk, setSettingsOk] = useState('')
|
||||
|
||||
// Remise de lot
|
||||
const [ticketCode, setTicketCode] = useState('')
|
||||
const [ticketResult, setTicketResult] = useState(null)
|
||||
const [ticketSearching, setTicketSearching] = useState(false)
|
||||
const [ticketDelivering, setTicketDelivering] = useState(false)
|
||||
const [ticketSearchErr, setTicketSearchErr] = useState('')
|
||||
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
||||
const [ticketSuccess, setTicketSuccess] = useState(false)
|
||||
|
||||
// Contacts emailing
|
||||
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||
const [emailingError, setEmailingError] = useState('')
|
||||
|
||||
// Création d'un compte employé
|
||||
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)
|
||||
})
|
||||
@@ -100,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 {
|
||||
@@ -145,10 +154,7 @@ export default function Admin() {
|
||||
const csv = [header, ...rows]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||
.join('\n')
|
||||
// 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 blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
@@ -157,43 +163,20 @@ export default function Admin() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleTicketSearch = async (e) => {
|
||||
const handleCreateEmployee = async (e) => {
|
||||
e.preventDefault()
|
||||
setTicketResult(null)
|
||||
setTicketSearchErr('')
|
||||
setTicketDeliverErr('')
|
||||
setTicketSuccess(false)
|
||||
setTicketSearching(true)
|
||||
setEmployeeError('')
|
||||
setEmployeeOk('')
|
||||
setEmployeeSaving(true)
|
||||
try {
|
||||
const data = await searchTicketApi(ticketCode.trim())
|
||||
setTicketResult(data)
|
||||
const data = await createEmployeeApi(employeeForm)
|
||||
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)) setTicketSearchErr(err.message)
|
||||
if (!handleAuthError(err)) setEmployeeError(err.message)
|
||||
} finally {
|
||||
setTicketSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTicketDeliver = async () => {
|
||||
if (!ticketResult?.claim?.id) return
|
||||
setTicketDeliverErr('')
|
||||
setTicketDelivering(true)
|
||||
try {
|
||||
await deliverClaimApi(ticketResult.claim.id)
|
||||
setTicketResult((prev) => ({
|
||||
...prev,
|
||||
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
||||
}))
|
||||
setTicketSuccess(true)
|
||||
setStats((prev) => prev ? {
|
||||
...prev,
|
||||
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
|
||||
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
|
||||
} : prev)
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
|
||||
} finally {
|
||||
setTicketDelivering(false)
|
||||
setEmployeeSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,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">
|
||||
@@ -267,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>
|
||||
@@ -418,95 +421,66 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Remise de lot ── */}
|
||||
{/* ── Création d'un compte employé ── */}
|
||||
<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">Remise de lot</h2>
|
||||
<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={handleTicketSearch} className="flex gap-2 mb-4">
|
||||
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
||||
<input
|
||||
type="text"
|
||||
id="admin-ticket-code"
|
||||
value={ticketCode}
|
||||
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
||||
placeholder="Code ticket (10 caractères)"
|
||||
maxLength={10}
|
||||
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"
|
||||
disabled={ticketSearching || ticketCode.trim().length === 0}
|
||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
|
||||
>
|
||||
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{ticketSearchErr && (
|
||||
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
|
||||
)}
|
||||
|
||||
{ticketResult && (
|
||||
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
||||
{ticketSuccess && (
|
||||
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
||||
<p className="text-gray-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 ${
|
||||
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
|
||||
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
|
||||
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
|
||||
: 'À remettre'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
|
||||
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
|
||||
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
|
||||
|
||||
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
|
||||
<button
|
||||
onClick={handleTicketDeliver}
|
||||
disabled={ticketDelivering}
|
||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
|
||||
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Rechercher un autre code
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-firstname"
|
||||
value={employeeForm.firstname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
|
||||
required
|
||||
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-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-lastname"
|
||||
value={employeeForm.lastname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
|
||||
required
|
||||
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-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="employee-email"
|
||||
value={employeeForm.email}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
|
||||
required
|
||||
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
|
||||
type="submit"
|
||||
disabled={employeeSaving}
|
||||
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"
|
||||
>
|
||||
{employeeSaving ? 'Création…' : 'Créer le compte'}
|
||||
</button>
|
||||
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
|
||||
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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.
|
||||
@@ -8,46 +9,40 @@ 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)
|
||||
|
||||
const errorParam = searchParams.get('error')
|
||||
const token = searchParams.get('token')
|
||||
const encodedUser = searchParams.get('user')
|
||||
|
||||
let error = ''
|
||||
let user = null
|
||||
|
||||
if (errorParam) {
|
||||
error = errorParam
|
||||
} else if (!token || !encodedUser) {
|
||||
error = 'Réponse de connexion incomplète.'
|
||||
} else {
|
||||
try {
|
||||
// atob renvoie des octets : on les relit en UTF-8.
|
||||
const bytes = Uint8Array.from(
|
||||
atob(encodedUser),
|
||||
(c) => c.charCodeAt(0)
|
||||
)
|
||||
user = JSON.parse(new TextDecoder().decode(bytes))
|
||||
} catch {
|
||||
error = 'Réponse de connexion illisible.'
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
if (handled.current || !user) 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])
|
||||
login(user, token)
|
||||
navigate(homeFor(user), { replace: true })
|
||||
}, [user, token, login, navigate])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Cgu() {
|
||||
usePageMeta({
|
||||
title: 'Conditions générales d’utilisation | Thé Tip Top',
|
||||
description: "CGU du site Thé Tip Top : conditions d'accès au jeu-concours, inscription, participation et gestion de votre compte.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
@@ -30,13 +38,23 @@ export default function Cgu() {
|
||||
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
||||
les modalités et conditions dans lesquelles la société Thé Tip Top met à
|
||||
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
||||
accessible à l'adresse https://www.thetiptop-concours.fr.
|
||||
accessible à l'adresse https://www.dsp5-archi-o24a-4-5-g4.fr.
|
||||
</p>
|
||||
<p>
|
||||
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
||||
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
||||
ni à utiliser ses services.
|
||||
</p>
|
||||
<p>
|
||||
Le règlement complet du jeu-concours, déposé auprès de Maître Arnaud Rick,
|
||||
Commissaire de Justice, est{' '}
|
||||
<a
|
||||
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||
className="text-[#006B6B] hover:underline font-medium"
|
||||
>
|
||||
téléchargeable au format PDF
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -50,10 +68,11 @@ export default function Cgu() {
|
||||
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
||||
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
||||
</p>
|
||||
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
|
||||
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
|
||||
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
|
||||
autre zone géographique.
|
||||
<p><strong>Conditions géographiques —</strong> La participation au jeu-concours
|
||||
« 100% Gagnant » est réservée aux personnes majeures résidant en France
|
||||
métropolitaine. Seuls les tickets de caisse ou factures d'un montant supérieur à
|
||||
49 € émis dans la boutique Thé Tip Top de Nice, à l'occasion de son ouverture,
|
||||
donnent droit à un code de participation.
|
||||
</p>
|
||||
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
||||
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
||||
@@ -77,16 +96,16 @@ export default function Cgu() {
|
||||
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
||||
<p>
|
||||
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
||||
aux clients de la société Thé Tip Top de participer à un jeu-concours à
|
||||
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
|
||||
proposés incluent :
|
||||
aux clients de la société Thé Tip Top de participer à un jeu-concours réservé à
|
||||
la boutique de Nice, à l'occasion de son ouverture. Les services proposés
|
||||
incluent :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 mt-2">
|
||||
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
|
||||
<li>La création d'un espace personnel pour chaque participant</li>
|
||||
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €</li>
|
||||
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 € émis dans la boutique de Nice</li>
|
||||
<li>La consultation de l'historique des participations et gains obtenus</li>
|
||||
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
|
||||
<li>La réclamation des lots gagnés dans la boutique de Nice</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -101,12 +120,19 @@ export default function Cgu() {
|
||||
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
||||
correspondant selon la répartition préétablie :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>60 % des tickets : infuseur à thé</li>
|
||||
<li>20 % des tickets : boîte de 100 g de thé détox ou d'infusion</li>
|
||||
<li>10 % des tickets : boîte de 100 g de thé signature</li>
|
||||
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
|
||||
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
|
||||
<ul className="space-y-2 mt-2">
|
||||
{[
|
||||
{ pct: '60 %', text: 'infuseur à thé' },
|
||||
{ pct: '20 %', text: "boîte de 100 g de thé détox ou d'infusion" },
|
||||
{ pct: '10 %', text: 'boîte de 100 g de thé signature' },
|
||||
{ pct: '6 %', text: "coffret découverte d'une valeur de 39 €" },
|
||||
{ pct: '4 %', text: "coffret découverte d'une valeur de 69 €" },
|
||||
].map((lot) => (
|
||||
<li key={lot.pct} className="flex gap-2">
|
||||
<span className="font-semibold text-[#006B6B] flex-shrink-0">{lot.pct}</span>
|
||||
<span className="min-w-0 flex-1">des tickets : {lot.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
||||
@@ -116,8 +142,9 @@ export default function Cgu() {
|
||||
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
||||
en boutique, sur présentation du compte utilisateur validé et de la confirmation
|
||||
de gain.
|
||||
exclusivement dans la boutique Thé Tip Top de Nice, sur présentation du compte
|
||||
utilisateur validé et de la confirmation de gain. Aucun site marchand n'étant
|
||||
ouvert, aucune remise ni expédition à distance n'est possible.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
||||
@@ -207,11 +234,16 @@ export default function Cgu() {
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
||||
<p>
|
||||
L'utilisateur peut demander la suppression de son compte à tout moment en
|
||||
contactant directement l'équipe via contact@thetiptop.fr. La suppression entraîne
|
||||
la perte définitive des participations et gains non encore réclamés. Les données
|
||||
personnelles sont supprimées conformément aux dispositions de la Politique de
|
||||
Confidentialité, sous réserve des obligations légales de conservation.
|
||||
La suppression du compte n'est pas disponible en libre-service. L'utilisateur peut
|
||||
la demander à tout moment en contactant directement l'équipe via
|
||||
thetiptop.gp4@gmail.com, notamment depuis la rubrique dédiée de son{' '}
|
||||
<Link to="/profile#suppression-compte" className="text-[#006B6B] hover:underline font-medium">
|
||||
espace personnel
|
||||
</Link>
|
||||
. La suppression entraîne la perte définitive des participations et gains non
|
||||
encore réclamés. Les données personnelles sont supprimées conformément aux
|
||||
dispositions de la Politique de Confidentialité, sous réserve des obligations
|
||||
légales de conservation.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Confidentialite() {
|
||||
usePageMeta({
|
||||
title: 'Politique de confidentialité | Thé Tip Top',
|
||||
description: "Comment Thé Tip Top collecte et protège vos données personnelles dans le cadre du jeu-concours, et comment exercer vos droits RGPD.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
@@ -19,7 +26,7 @@ export default function Confidentialite() {
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le responsable du traitement des données collectées via le site du jeu-concours
|
||||
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
|
||||
« 100% Gagnant » est la société Thé Tip Top.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -89,7 +96,7 @@ export default function Confidentialite() {
|
||||
</h2>
|
||||
<p className="text-gray-700 text-sm sm:text-base">
|
||||
Pour toute question concernant vos données personnelles :<br />
|
||||
<strong>Email :</strong> contact@thetiptop.fr
|
||||
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { loginApi } from '../services/auth'
|
||||
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({
|
||||
title: 'Connexion | Thé Tip Top',
|
||||
description: "Connectez-vous à votre espace participant Thé Tip Top pour saisir votre code et suivre vos gains au jeu-concours 100 % Gagnant.",
|
||||
})
|
||||
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { login } = useAuth()
|
||||
@@ -12,29 +21,34 @@ export default function Connexion() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [captchaToken, setCaptchaToken] = useState('')
|
||||
const captchaRef = useRef()
|
||||
|
||||
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 API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = `${API_URL}/api/auth/google`
|
||||
}
|
||||
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||
// redirections vers Google/Facebook puis retour sur /auth/callback.
|
||||
const handleOAuthLogin = (provider) => {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||
window.location.href = `${apiUrl}/api/auth/${provider}`
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { token, user } = await loginApi(form)
|
||||
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.
|
||||
captchaRef.current?.reset()
|
||||
setCaptchaToken('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -107,15 +121,21 @@ const handleGoogleLogin = () => {
|
||||
</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>
|
||||
|
||||
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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 ? 'Connexion...' : 'Se connecter'}
|
||||
@@ -131,7 +151,7 @@ const handleGoogleLogin = () => {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
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">
|
||||
@@ -144,9 +164,8 @@ const handleGoogleLogin = () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => handleOAuthLogin('facebook')}
|
||||
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 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||
|
||||
@@ -1,45 +1,167 @@
|
||||
import imgContact from '../assets/img/img-contact.jpg'
|
||||
import imgContact from '../assets/img/img-contact.webp'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Contact() {
|
||||
usePageMeta({
|
||||
title: 'Contact | Thé Tip Top',
|
||||
description:
|
||||
"Une question sur le jeu-concours, votre participation ou votre lot ? Contactez l'équipe Thé Tip Top par email, téléphone ou via le formulaire en ligne.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
||||
{/* 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>
|
||||
{/* Informations + image */}
|
||||
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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é Tip Top 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é Tip Top
|
||||
<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 h-64 sm:h-80 md:h-full md:min-h-0">
|
||||
<img
|
||||
src={imgContact}
|
||||
alt="Une personne savourant un thé"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Formulaire */}
|
||||
<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">
|
||||
|
||||
<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]"
|
||||
/>
|
||||
</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]"
|
||||
/>
|
||||
</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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,15 +2,37 @@ import { useState, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||
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]
|
||||
|
||||
// Le jeu est réservé aux personnes majeures.
|
||||
function hasAtLeast18Years(birthdate) {
|
||||
const birth = new Date(birthdate)
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const monthDiff = today.getMonth() - birth.getMonth()
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--
|
||||
}
|
||||
return age >= 18
|
||||
}
|
||||
|
||||
// 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() {
|
||||
usePageMeta({
|
||||
title: 'Créer un compte | Thé Tip Top',
|
||||
description: "Inscrivez-vous en quelques instants pour participer au jeu-concours 100 % Gagnant Thé Tip Top et saisir le code de votre ticket de caisse.",
|
||||
})
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
@@ -22,6 +44,7 @@ export default function Inscription() {
|
||||
lastName: '',
|
||||
firstName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
})
|
||||
@@ -32,6 +55,8 @@ export default function Inscription() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [captchaToken, setCaptchaToken] = useState('')
|
||||
const captchaRef = useRef()
|
||||
|
||||
// Code à 4 chiffres
|
||||
const [digits, setDigits] = useState(['', '', '', ''])
|
||||
@@ -41,15 +66,35 @@ 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'
|
||||
// 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 || ''
|
||||
window.location.href = `${apiUrl}/api/auth/${provider}`
|
||||
}
|
||||
|
||||
// Étape 1 — Inscription
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!hasAtLeast18Years(form.birthdate)) {
|
||||
setError('Vous devez avoir au moins 18 ans pour vous inscrire.')
|
||||
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({
|
||||
@@ -57,14 +102,19 @@ export default function Inscription() {
|
||||
firstname: form.firstName,
|
||||
lastname: form.lastName,
|
||||
password: form.password,
|
||||
confirmPassword: form.confirmPassword,
|
||||
birthdate: form.birthdate,
|
||||
gender: form.gender,
|
||||
marketingOptIn,
|
||||
captchaToken,
|
||||
})
|
||||
setEmail(form.email)
|
||||
setStep('verify')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
// Le jeton a été consommé par le backend : on en redemande un.
|
||||
captchaRef.current?.reset()
|
||||
setCaptchaToken('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -158,7 +208,7 @@ export default function Inscription() {
|
||||
Le code expire dans 15 minutes.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
|
||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
|
||||
className="text-[#006B6B] hover:underline"
|
||||
>
|
||||
Recommencer
|
||||
@@ -202,9 +252,10 @@ export default function Inscription() {
|
||||
</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}
|
||||
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance <span className="text-gray-800">*</span></label>
|
||||
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} required 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]" />
|
||||
<p className="text-xs text-gray-500 mt-1">Le jeu-concours est réservé aux personnes majeures.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -222,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">
|
||||
@@ -230,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">
|
||||
@@ -255,7 +318,9 @@ export default function Inscription() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={loading || !acceptedTerms}
|
||||
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||
|
||||
<button type="submit" disabled={loading || !acceptedTerms || (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 ? 'Inscription…' : "S'inscrire"}
|
||||
</button>
|
||||
@@ -268,7 +333,7 @@ export default function Inscription() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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">
|
||||
<button type="button" onClick={() => handleOAuthSignup('google')} 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>
|
||||
@@ -277,7 +342,7 @@ export default function Inscription() {
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" onClick={() => handleOAuthSignup('facebook')} 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 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" />
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function MentionsLegales() {
|
||||
usePageMeta({
|
||||
title: 'Mentions légales | Thé Tip Top',
|
||||
description: "Mentions légales du site Thé Tip Top : éditeur, hébergeur, propriété intellectuelle et informations relatives au jeu-concours 100 % Gagnant.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
@@ -36,12 +43,12 @@ export default function MentionsLegales() {
|
||||
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
||||
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
||||
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
||||
<p><strong>Email :</strong> contact@thetiptop.fr</p>
|
||||
<p><strong>Email :</strong> thetiptop.gp4@gmail.com</p>
|
||||
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
||||
<p className="pt-2">
|
||||
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
||||
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
||||
à l'adresse contact@thetiptop.fr.
|
||||
à l'adresse thetiptop.gp4@gmail.com.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function MesGains() {
|
||||
}, [handleAuthError])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||
<UserNav active="gains" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
117
frontend/src/pages/MotDePasseOublie.jsx
Normal file
117
frontend/src/pages/MotDePasseOublie.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
frontend/src/pages/NotreConcept.jsx
Normal file
145
frontend/src/pages/NotreConcept.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
import bannerImg from '../assets/img/banner-the-tip-top.webp'
|
||||
|
||||
export default function NotreConcept() {
|
||||
usePageMeta({
|
||||
title: 'Notre concept : un jeu-concours sans perdant | Thé Tip Top',
|
||||
description: "Pourquoi un jeu 100 % gagnant ? Découvrez le concept, la répartition des lots et les garanties de loyauté du jeu-concours Thé Tip Top.",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-20">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Le concept 100 % Gagnant : un jeu-concours sans perdant
|
||||
</h1>
|
||||
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">
|
||||
La plupart des jeux-concours font gagner une personne et laissent les autres repartir
|
||||
les mains vides. Nous avons choisi l'inverse : chaque code distribué correspond à
|
||||
un lot réel, attribué à l'avance. Personne ne joue pour rien.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
to="/accueil"
|
||||
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Voir le jeu-concours
|
||||
</Link>
|
||||
<Link
|
||||
to="/regles-jeu"
|
||||
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#006B6B] hover:text-white font-semibold px-6 sm:px-8 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Consulter le règlement
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Visuel principal */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||
<div className="rounded-2xl shadow overflow-hidden">
|
||||
<img
|
||||
src={bannerImg}
|
||||
alt="Infuseur à thé Thé Tip Top, lot garanti du jeu-concours 100 % Gagnant"
|
||||
className="w-full h-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pourquoi un jeu sans perdant */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Pourquoi un jeu sans perdant
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||
Un jeu classique récompense la chance. Celui-ci récompense la visite. Nous ouvrons une
|
||||
nouvelle adresse et nous voulions que chaque personne qui pousse la porte reparte avec
|
||||
quelque chose à goûter, pas avec un ticket de loterie. C'est aussi la raison pour
|
||||
laquelle le lot se retire sur place : le jeu est un prétexte à une rencontre, pas à
|
||||
une transaction de plus.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comment la loyauté du jeu est garantie */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Comment la loyauté du jeu est garantie
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||
L'intégralité des codes et leur lot associé sont générés avant l'ouverture du jeu, puis
|
||||
déposés auprès de Maître Arnaud Rick, commissaire de justice. Aucun lot ne peut être
|
||||
modifié en cours d'opération. Le règlement complet est{' '}
|
||||
<a
|
||||
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||
className="text-[#006B6B] font-semibold underline hover:no-underline"
|
||||
>
|
||||
consultable et téléchargeable
|
||||
</a>{' '}
|
||||
à tout moment. Le tirage au sort du lot principal est effectué sous le contrôle du même
|
||||
commissaire de justice, et le nombre de participations n'augmente pas les chances de le
|
||||
remporter.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* La répartition des lots */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
La répartition des lots, annoncée à l'avance
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||
Six codes sur dix donnent un infuseur, deux sur dix une boîte de 100 g, un sur dix une
|
||||
boîte d'un mélange signature, et le solde un coffret découverte. Cette répartition est
|
||||
publiée dès le premier jour et ne varie pas. Vous savez donc, avant même de jouer, ce
|
||||
que vous pouvez obtenir et dans quelle proportion.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Un jeu conçu pour ne rien gaspiller */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Un jeu conçu pour ne rien gaspiller
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||
Le lot le plus distribué est un infuseur réutilisable, choisi pour remplacer durablement
|
||||
les sachets jetables plutôt que pour faire nombre. La participation est entièrement
|
||||
numérique, sans bulletin papier, et nos supports imprimés se limitent au strict
|
||||
nécessaire d'un point de vente.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||
<div className="bg-[#006B6B] rounded-2xl p-6 sm:p-10 text-center text-white">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
|
||||
Une question sur le concept du jeu ?
|
||||
</h2>
|
||||
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||
Notre équipe reste à votre écoute pour tout ce qui concerne le jeu-concours et vos
|
||||
lots.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
to="/contact"
|
||||
className="bg-white text-[#006B6B] px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white border border-white text-sm sm:text-base"
|
||||
>
|
||||
Contactez-nous
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
frontend/src/pages/NouveauMotDePasse.jsx
Normal file
139
frontend/src/pages/NouveauMotDePasse.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
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,
|
||||
forgotPasswordApi,
|
||||
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') || '{}')
|
||||
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const { hash } = useLocation()
|
||||
|
||||
const deleteSectionRef = useRef(null)
|
||||
|
||||
// Permet à un lien externe (ex. les CGU) de renvoyer directement sur cette
|
||||
// section via /profile#suppression-compte, plutôt que d'obliger l'utilisateur
|
||||
// à chercher l'information au milieu de la page.
|
||||
useEffect(() => {
|
||||
if (hash === '#suppression-compte') {
|
||||
deleteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [hash])
|
||||
|
||||
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
||||
@@ -34,7 +54,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)
|
||||
@@ -54,8 +74,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
|
||||
}
|
||||
|
||||
@@ -81,7 +101,7 @@ export default function Profile() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||
<UserNav active="profile" />
|
||||
|
||||
{/* Avatar */}
|
||||
@@ -180,6 +200,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]"
|
||||
/>
|
||||
@@ -192,6 +214,9 @@ export default function Profile() {
|
||||
<EyeIcon visible={showPw[key]} />
|
||||
</button>
|
||||
</div>
|
||||
{name === 'newPassword' && (
|
||||
<PasswordRules id="profile-password-rules" password={pwForm.newPassword} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -205,6 +230,30 @@ export default function Profile() {
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="border-t border-gray-100 my-6" />
|
||||
|
||||
{/* Section suppression du compte */}
|
||||
<div ref={deleteSectionRef} id="suppression-compte">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Supprimer mon compte</h2>
|
||||
|
||||
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Pour des raisons de sécurité, la suppression de compte n'est pas disponible en
|
||||
libre-service. Envoyez-nous un email depuis votre adresse enregistrée
|
||||
({user.email || '—'}) et notre équipe supprimera votre compte, vos participations et
|
||||
vos gains non retirés.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="mailto:thetiptop.gp4@gmail.com?subject=Suppression%20de%20mon%20compte"
|
||||
className="mt-3 inline-flex items-center justify-center bg-[#006B6B] hover:bg-[#005959] text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Demander la suppression par email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function ReglesJeu() {
|
||||
const { user } = useAuth()
|
||||
const isLoggedIn = !!user
|
||||
|
||||
usePageMeta({
|
||||
title: 'Règles du jeu-concours 100 % Gagnant | Thé Tip Top',
|
||||
description: "Comment participer au jeu-concours Thé Tip Top : obtenir un code, créer un compte, découvrir votre lot et tenter de remporter le grand gagnant.",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
|
||||
@@ -15,7 +21,7 @@ export default function ReglesJeu() {
|
||||
Règles du jeu
|
||||
</h1>
|
||||
<p className="text-base sm:text-xl text-gray-600">
|
||||
Participez au jeu-concours ThéTipTop et découvrez immédiatement
|
||||
Jeu-concours réservé à notre boutique de Nice : participez et découvrez immédiatement
|
||||
le lot que vous avez gagné.
|
||||
</p>
|
||||
</div>
|
||||
@@ -31,7 +37,7 @@ export default function ReglesJeu() {
|
||||
{
|
||||
num: '1',
|
||||
title: 'Obtenez un code',
|
||||
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.",
|
||||
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ dans notre boutique de Nice pour recevoir un code unique à 10 chiffres.",
|
||||
},
|
||||
{
|
||||
num: '2',
|
||||
@@ -59,17 +65,39 @@ export default function ReglesJeu() {
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
||||
Que pouvez-vous gagner ?
|
||||
</h2>
|
||||
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-6 sm:mb-8">
|
||||
Tous les billets sont gagnants ! Les récompenses peuvent aller
|
||||
d'un infuseur à thé à un coffret découverte d'une valeur de 69€,
|
||||
en passant par des thés détox et des thés signatures.
|
||||
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-4 sm:mb-6">
|
||||
Tous les billets sont gagnants ! Selon votre ticket, vous remportez :
|
||||
</p>
|
||||
<ul className="text-gray-700 text-sm sm:text-base leading-relaxed space-y-2 max-w-md mx-auto mb-6 sm:mb-8">
|
||||
{[
|
||||
{ pct: '60 %', text: 'un infuseur à thé' },
|
||||
{ pct: '20 %', text: "une boîte de 100 g de thé détox ou d'infusion" },
|
||||
{ pct: '10 %', text: 'une boîte de 100 g de thé signature' },
|
||||
{ pct: '6 %', text: "un coffret découverte d'une valeur de 39 €" },
|
||||
{ pct: '4 %', text: "un coffret découverte d'une valeur de 69 €" },
|
||||
].map((lot) => (
|
||||
<li key={lot.pct} className="flex gap-2">
|
||||
<span className="font-semibold text-[#006B6B] flex-shrink-0">{lot.pct}</span>
|
||||
<span className="min-w-0 flex-1">des tickets : {lot.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
||||
<p className="text-gray-700 text-sm sm:text-base">
|
||||
Le participant tiré au sort remportera une année de thé d'une valeur de 360€ !
|
||||
Un tirage au sort, effectué sous le contrôle de Maître Arnaud Rick, Commissaire de
|
||||
Justice, désignera le gagnant d'une année de thé d'une valeur de 360€ !
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-6 sm:mt-8">
|
||||
<a
|
||||
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||
className="inline-flex items-center justify-center gap-2 border border-[#006B6B] text-[#006B6B] hover:bg-[#006B6B] hover:text-white font-semibold px-6 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Télécharger le règlement (PDF)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -80,7 +108,7 @@ export default function ReglesJeu() {
|
||||
Ne manquez pas votre chance !
|
||||
</h2>
|
||||
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||
Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne.
|
||||
Inscrivez-vous, entrez votre code de ticket et retirez votre lot dans notre boutique de Nice.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||
{!isLoggedIn && (
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function SaisirCodeUnique() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||
<UserNav active="code" />
|
||||
|
||||
<div className="mb-8">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth} from '../context/AuthContext'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Navigation principale',
|
||||
links: [
|
||||
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
||||
{ label: 'Notre concept', to: '/notre-concept', desc: 'Pourquoi un jeu-concours sans perdant' },
|
||||
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
||||
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
||||
],
|
||||
@@ -44,6 +46,11 @@ const sections = [
|
||||
]
|
||||
|
||||
export default function Sitemap() {
|
||||
usePageMeta({
|
||||
title: 'Plan du site | Thé Tip Top',
|
||||
description: "Retrouvez toutes les pages du site Thé Tip Top : jeu-concours, compte participant, informations légales et espaces professionnels.",
|
||||
})
|
||||
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'ADMIN'
|
||||
const visibleSections = sections.filter(
|
||||
|
||||
@@ -59,3 +59,38 @@ export async function setGameSettingsApi(startDate) {
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
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 }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ function apiError(message, status) {
|
||||
return err
|
||||
}
|
||||
|
||||
export async function loginApi({ email, password }) {
|
||||
export async function loginApi({ email, password, captchaToken }) {
|
||||
const res = await fetch(`${BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, captchaToken: captchaToken || undefined }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
||||
@@ -23,9 +23,11 @@ export async function registerApi({
|
||||
firstname,
|
||||
lastname,
|
||||
password,
|
||||
confirmPassword,
|
||||
birthdate,
|
||||
gender,
|
||||
marketingOptIn,
|
||||
captchaToken,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/register`, {
|
||||
method: 'POST',
|
||||
@@ -37,13 +39,21 @@ export async function registerApi({
|
||||
firstname,
|
||||
lastname,
|
||||
password,
|
||||
confirmPassword,
|
||||
birthdate: birthdate || undefined,
|
||||
gender: gender || undefined,
|
||||
marketingOptIn: marketingOptIn ?? undefined,
|
||||
captchaToken: captchaToken || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
||||
if (!res.ok) {
|
||||
// Le backend renvoie un message générique ("Données invalides") avec le
|
||||
// détail par champ dans `errors` : on préfère afficher ce détail quand il
|
||||
// existe (ex. l'âge minimum requis pour la date de naissance).
|
||||
const fieldMessage = Object.values(data.errors || {})[0]?.[0]
|
||||
throw apiError(fieldMessage || data.message || "Erreur lors de l'inscription.", res.status)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -58,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)
|
||||
@@ -101,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`, {
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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()
|
||||
|
||||
41
frontend/src/test/fonctionnel/Accueil.test.jsx
Normal file
41
frontend/src/test/fonctionnel/Accueil.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -42,7 +42,8 @@ describe('Connexion', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||
|
||||
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
|
||||
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' })
|
||||
// captchaToken vide : aucun widget Turnstile en test (voir vite.config.js).
|
||||
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp', captchaToken: '' })
|
||||
})
|
||||
|
||||
it('connecte un client et redirige vers /mes-gains', async () => {
|
||||
|
||||
56
frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx
Normal file
56
frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx
Normal 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!' })
|
||||
)
|
||||
})
|
||||
})
|
||||
97
frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx
Normal file
97
frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
51
frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx
Normal file
51
frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
74
frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx
Normal file
74
frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
37
frontend/src/test/unit/gameDates.test.js
Normal file
37
frontend/src/test/unit/gameDates.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
80
frontend/src/utils/gameDates.js
Normal file
80
frontend/src/utils/gameDates.js
Normal 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}`
|
||||
}
|
||||
14
frontend/src/utils/passwordRules.js
Normal file
14
frontend/src/utils/passwordRules.js
Normal 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.'
|
||||
Reference in New Issue
Block a user