merge: integrate Emmanuel changes into main

This commit is contained in:
2026-09-17 18:36:09 +00:00
108 changed files with 4335 additions and 515 deletions

1
frontend/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -1,9 +1,10 @@
FROM node:22-alpine AS build
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npx playwright install --with-deps chromium
COPY . .
@@ -13,6 +14,9 @@ ARG VITE_GA_ID
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_GA_ID=${VITE_GA_ID}
ARG VITE_TURNSTILE_SITE_KEY
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
RUN npm run build

View File

@@ -8,6 +8,7 @@ export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
ignores: ['scripts/**'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
@@ -18,4 +19,13 @@ export default defineConfig([
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
// Scripts Node exécutés à la compilation (ex. prerender.js), jamais
// dans le navigateur : globals Node, pas de règles React.
files: ['scripts/**/*.js'],
extends: [js.configs.recommended],
languageOptions: {
globals: globals.node,
},
},
])

View File

@@ -5,7 +5,43 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
<link rel="canonical" href="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
<title>Jeu concours - Thé Tip Top</title>
<!-- Open Graph : valeurs par défaut, mises à jour par page via le hook usePageMeta -->
<meta property="og:site_name" content="Thé Tip Top" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:title" content="Jeu concours - Thé Tip Top" />
<meta property="og:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
<meta property="og:url" content="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
<meta property="og:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Jeu concours - Thé Tip Top" />
<meta name="twitter:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
<meta name="twitter:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
<!-- Schema.org : identité du site uniquement, aucune adresse (non communiquée) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"name": "Thé Tip Top",
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/",
"email": "thetiptop.gp4@gmail.com"
},
{
"@type": "WebSite",
"name": "Thé Tip Top",
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/"
}
]
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -6,6 +6,12 @@ server {
index index.html;
location / {
try_files $uri $uri/ /index.html;
# $uri/index.html (et non $uri/) : sert directement le fichier des
# pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans
# passer par la redirection 301 vers l'URL à slash final que nginx
# déclenche pour une résolution de type répertoire ($uri/). Les
# canonical/sitemap pointent vers les URLs sans slash : elles
# doivent répondre 200 directement, pas via un redirect.
try_files $uri $uri/index.html /index.html;
}
}

View File

@@ -8,6 +8,7 @@
"name": "ttt-front",
"version": "0.0.0",
"dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
@@ -27,6 +28,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12",
"vitest": "^4.1.10"
@@ -802,6 +804,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@marsidev/react-turnstile": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz",
"integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==",
"peerDependencies": {
"react": "^17.0.2 || ^18.0.0 || ^19.0",
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -3419,6 +3430,33 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node scripts/prerender.js",
"prerender": "node scripts/prerender.js",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
@@ -13,6 +14,7 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
@@ -32,6 +34,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12",
"vitest": "^4.1.10"

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

View File

@@ -3,10 +3,13 @@ User-agent: *
# Pages publiques autorisées
Allow: /
Allow: /accueil
Allow: /notre-concept
Allow: /regles-jeu
Allow: /contact
Allow: /mentions-legales
Allow: /confidentialite
Allow: /accessibilite
Allow: /cgu
Allow: /sitemap
Allow: /inscription
Allow: /connexion
@@ -20,4 +23,4 @@ Disallow: /saisir-code-unique
Disallow: /perdu
# Sitemap
Sitemap: https://thetiptop.fr/sitemap
Sitemap: https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap.xml

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/accueil</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/notre-concept</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/regles-jeu</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/contact</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/mentions-legales</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/cgu</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/confidentialite</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/accessibilite</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/inscription</loc></url>
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/connexion</loc></url>
</urlset>

View File

@@ -0,0 +1,54 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { preview } from 'vite'
import { chromium } from 'playwright'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const distDir = join(root, 'dist')
// Pages publiques, indexables (voir public/robots.txt) : ce sont les seules
// qui ont besoin d'un HTML pré-rendu pour le SEO et les aperçus sociaux. Les
// pages privées (profil, mes gains, admin...) restent en rendu 100% client,
// servies par le index.html générique via le fallback SPA de nginx.
const ROUTES = [
'/accueil',
'/notre-concept',
'/regles-jeu',
'/contact',
'/mentions-legales',
'/confidentialite',
'/accessibilite',
'/cgu',
'/sitemap',
'/inscription',
'/connexion',
]
async function main() {
const server = await preview({ root, preview: { port: 4173, strictPort: false } })
const base = server.resolvedUrls.local[0].replace(/\/$/, '')
const browser = await chromium.launch()
const page = await browser.newPage()
for (const route of ROUTES) {
await page.goto(`${base}${route}`, { waitUntil: 'networkidle' })
await page.waitForSelector('h1')
const html = await page.content()
const outDir = join(distDir, route.replace(/^\//, ''))
mkdirSync(outDir, { recursive: true })
writeFileSync(join(outDir, 'index.html'), `<!doctype html>\n${html}`)
console.log(`✓ pré-rendu ${route} -> dist${route}/index.html`)
}
await browser.close()
await server.close()
}
main().catch((error) => {
console.error('Erreur de pré-rendu :', error)
process.exit(1)
})

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View 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('')}
/>
)
}

View File

@@ -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-['_—_']">

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&nbsp;% 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&nbsp;<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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&nbsp;: 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&nbsp;: 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>
)
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { setInitialPasswordApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
import { homeFor } from '../config/roles'
import PasswordRules from '../components/PasswordRules'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
// Première connexion d'un employé créé par un admin : le mot de passe reçu
// par email est provisoire, il doit être remplacé avant tout accès.
export default function NouveauMotDePasse() {
const navigate = useNavigate()
const { user, login, logout } = useAuth()
const [form, setForm] = useState({ password: '', confirmPassword: '' })
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (!user) return <Navigate to="/connexion" replace />
if (!user.mustChangePassword) return <Navigate to={homeFor(user)} replace />
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!isPasswordValid(form.password)) {
setError(PASSWORD_RULES_ERROR)
return
}
if (form.password !== form.confirmPassword) {
setError('Les deux mots de passe ne correspondent pas.')
return
}
setLoading(true)
try {
await setInitialPasswordApi(form)
const updatedUser = { ...user, mustChangePassword: false }
login(updatedUser, localStorage.getItem('token'))
navigate(homeFor(updatedUser), { replace: true })
} catch (err) {
if (err.status === 401) {
logout()
navigate('/connexion', { replace: true })
return
}
setError(err.message)
setLoading(false)
}
}
const handleLogout = () => {
logout()
navigate('/connexion', { replace: true })
}
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
<p className="text-gray-500 mt-2 text-sm">
Bienvenue {user.firstname} ! Vous vous êtes connecté avec un mot de passe provisoire.
Choisissez votre propre mot de passe pour accéder à votre espace.
</p>
</div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-gray-800 mb-1">
Nouveau mot de passe <span className="text-gray-800">*</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
id="new-password"
name="password"
value={form.password}
onChange={handleChange}
required
autoComplete="new-password"
aria-describedby="new-password-rules"
placeholder="Votre nouveau mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<PasswordRules id="new-password-rules" password={form.password} />
</div>
<div>
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-800 mb-1">
Confirmer le mot de passe <span className="text-gray-800">*</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
id="confirm-new-password"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required
autoComplete="new-password"
placeholder="Saisissez-le à nouveau"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
className="w-5 h-5 accent-[#006B6B]"
/>
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
</label>
<button
type="submit"
disabled={loading}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-8">
<button type="button" onClick={handleLogout} className="text-gray-700 font-medium hover:underline">
Se déconnecter
</button>
</p>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,8 +15,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
// Jenkins pourra lire directement ce fichier.
reporters: ['default', 'junit'],
outputFile: {
junit: './test-results/junit.xml',
@@ -28,5 +26,8 @@ export default defineConfig({
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
},
// Le widget Turnstile ne doit pas bloquer les tests jsdom.
env: { VITE_TURNSTILE_SITE_KEY: '' },
},
})