Ajouter la page concept, améliorer la bannière, ajouter pages contact et concept au menu et ajouter la condition d'inscription pour plus de 18 ans

This commit is contained in:
Alexis SANOGO
2026-08-29 01:44:20 +02:00
parent fc9bcbf8da
commit a1ab2df975
22 changed files with 495 additions and 66 deletions

25
frontend/.gitignore vendored
View File

@@ -1,25 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <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/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/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/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/mentions-legales</loc></url>

View File

@@ -8,6 +8,7 @@ import Inscription from './pages/Inscription'
import AuthCallback from './pages/AuthCallback' import AuthCallback from './pages/AuthCallback'
import DefinirMotDePasse from './pages/DefinirMotDePasse' import DefinirMotDePasse from './pages/DefinirMotDePasse'
import Accueil from './pages/Accueil' import Accueil from './pages/Accueil'
import NotreConcept from './pages/NotreConcept'
import ReglesJeu from './pages/ReglesJeu' import ReglesJeu from './pages/ReglesJeu'
import MesGains from './pages/MesGains' import MesGains from './pages/MesGains'
import Employe from './pages/Employe' import Employe from './pages/Employe'
@@ -36,6 +37,7 @@ export default function App() {
{/* Pages publiques */} {/* Pages publiques */}
<Route path="/accueil" element={<Layout><Accueil /></Layout>} /> <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="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} /> <Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} /> <Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

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

@@ -58,6 +58,16 @@ export default function Footer() {
</FooterLink> </FooterLink>
</li> </li>
<li>
<FooterLink
to="/notre-concept"
pathname={pathname}
className="hover:text-white transition"
>
Notre concept
</FooterLink>
</li>
<li> <li>
<FooterLink <FooterLink
to="/regles-jeu" to="/regles-jeu"

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom' 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' import { useAuth } from '../context/AuthContext'
export default function Header() { export default function Header() {
@@ -19,7 +19,9 @@ export default function Header() {
const navLinks = [ const navLinks = [
{ to: '/accueil', label: 'Accueil' }, { to: '/accueil', label: 'Accueil' },
{ to: '/notre-concept', label: 'Notre concept' },
{ to: '/regles-jeu', label: 'Règles du jeu' }, { to: '/regles-jeu', label: 'Règles du jeu' },
{ to: '/contact', label: 'Contact' },
] ]
const initials = user const initials = user

View File

@@ -1 +1,54 @@
@import "tailwindcss"; @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,6 +1,53 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import usePageMeta from '../hooks/usePageMeta' import usePageMeta from '../hooks/usePageMeta'
import bannerImg from '../assets/img/banner-the-tip-top.webp' import bannerImg from '../assets/img/banner-accueil.webp'
// Tous les lots possibles hors grand lot (déjà mis en avant dans sa propre
// carte). Chaque lot devient sa propre carte flottante, étalée en quinconce
// le long de la partie droite de l'illustration plutôt qu'empilée en liste.
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' },
]
// Légère inclinaison 3D des cartes de la bannière au passage de la souris,
// façon « tilt card ». Fonctions déclarées hors du composant : elles ne
// dépendent d'aucun state, inutile de les recréer à chaque rendu.
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 = ''
}
// Petites animations de thé générées en JS : position, taille, durée et
// délai tirés au sort une seule fois (useMemo) pour parsemer toute la
// bannière de feuilles/tasses/théières/cuillères qui dérivent doucement,
// sans jamais se superposer deux fois de la même façon. Taille et opacité
// relevées pour qu'elles restent bien visibles sur le fond teal.
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),
}))
}
const faqs = [ const faqs = [
{ {
@@ -57,41 +104,160 @@ export default function Accueil() {
description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.", description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.",
}) })
// Tirées au sort une seule fois au montage : la bannière ne doit pas
// réordonner ses particules de thé à chaque re-render.
const teaParticles = useMemo(() => createTeaParticles(30), [])
return ( return (
<div className="bg-gray-50 min-h-screen"> <div className="bg-gray-50 min-h-screen">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} /> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
{/* Bannière */} {/* Bannière : fond teal plein cadre, illustration très grande et animée à l'entrée,
<section className="w-full"> textes et cartes de gains répartis en plusieurs blocs pour occuper toute la zone
plutôt qu'une seule colonne dense. La composition « poster » en absolute (position:
static → absolute) ne s'active qu'à partir de lg (1024px) : en dessous — mobile ET
tablette —, un écran trop étroit ferait chevaucher le titre et l'illustration de façon
illisible (constaté sur iPad portrait), donc tout reste en empilement normal, dans
l'ordre du DOM, jusqu'à avoir vraiment la place. */}
<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]">
{/* Halo doux derrière l'illustration (uniquement pertinent à partir de lg, où
l'illustration passe en fond de la composition) */}
<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"
/>
{/* Particules de thé générées en JS (position/taille/durée tirées au sort dans
teaParticles), parsemées sur toute la bannière derrière le contenu. */}
<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>
{/* Titre */}
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
Du 02 au 31 octobre 2026
</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>
{/* Illustration : très grande à partir de lg, débordant du cadre en haut/bas (rogné
proprement par le overflow-hidden de la section) et glissant à l'entrée. En
dessous de lg, recadrée (object-cover) sur le personnage pour ne pas gaspiller
l'espace transparent intégré au visuel d'origine. */}
<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="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice."
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 <img
src={bannerImg} src={bannerImg}
alt="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice." alt="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice."
width={2048} width={2048}
height={1152} height={1152}
className="w-full h-full object-cover object-center" 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"
/> />
</section> </div>
{/* Hero */} {/* Sous-titre : mis en évidence dans une carte « verre dépoli » distincte du fond
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24"> teal uni (bordure d'accent + fond translucide flouté), pour qu'il se détache
<div className="text-center max-w-3xl mx-auto"> clairement plutôt que de se fondre dans le reste du texte blanc de la bannière. */}
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight"> <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">
Bienvenue au jeu-concours Thé Tip Top Nice À l'occasion de l'ouverture de notre{' '}
</h1> <span className="text-amber-300">10<sup>e</sup> boutique</span> à Nice : chaque code
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10"> distribué correspond à un lot réel, attribué à l'avance.
Exclusivement réservé à la boutique Thé Tip Top de Nice. Créez votre compte, saisissez le code de votre ticket et découvrez immédiatement votre lot.
</p> </p>
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
{/* Cartes de gains : le grand lot (agrandi) + tous les autres lots possibles, en
cartes flottantes autour de l'illustration à partir de lg uniquement — masquées
sur mobile/tablette, où elles surchargeaient l'écran en plus du reste du contenu.
Chaque carte flotte doucement en continu (animate-card-float-*) et s'incline
légèrement au passage de la souris (onMouseMove → handleCardTilt), deux animations
portées par des conteneurs séparés pour ne pas se marcher dessus. */}
<div className="hidden lg:contents">
{/* Grand lot : décalé plus bas et plus à droite que les autres cartes du haut
(elle touchait « Boîte détox ou infusion »), dans le grand espace libre entre
cette carte et « Coffret découverte 39 € », pour amorcer un arc de cercle autour
de l'illustration plutôt qu'un empilement dans le même coin. */}
<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>
{/* Les autres lots : une carte par lot, éclatées en quinconce (position en %
définie dans le tableau `gains`) plutôt qu'empilées dans une seule colonne.
top/right en style inline : ils ne s'appliquent qu'à partir de lg (seul
breakpoint où la carte passe en absolute), sans effet en dessous.
whitespace-nowrap plutôt qu'un max-w fixe : une carte plus étroite que sa
ligne de texte forçait un retour à la ligne qui laissait du blanc (ex. « Boîte
100 g détox ou » / « infusion »). En nowrap, chaque carte se limite exactement
à la largeur de son texte, sur une seule ligne, sans espace perdu. */}
{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>
{/* CTA, remonté par rapport au bord bas de la bannière */}
<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 <Link
to="/saisir-code-unique" 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 Tester mon code
</Link> </Link>
<Link <Link
to="/regles-jeu" 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 Découvrir les règles
</Link> </Link>
@@ -100,7 +266,7 @@ export default function Accueil() {
</section> </section>
{/* Comment participer */} {/* 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"> <h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
Comment participer ? Comment participer ?
</h2> </h2>

View File

@@ -1,3 +1,5 @@
import { Link } from 'react-router-dom'
export default function Cgu() { export default function Cgu() {
return ( return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16"> <main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
@@ -226,11 +228,16 @@ export default function Cgu() {
<div> <div>
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3> <h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
<p> <p>
L'utilisateur peut demander la suppression de son compte à tout moment en La suppression du compte n'est pas disponible en libre-service. L'utilisateur peut
contactant directement l'équipe via thetiptop.gp4@gmail.com. La suppression entraîne la demander à tout moment en contactant directement l'équipe via
la perte définitive des participations et gains non encore réclamés. Les données thetiptop.gp4@gmail.com, notamment depuis la rubrique dédiée de son{' '}
personnelles sont supprimées conformément aux dispositions de la Politique de <Link to="/profile#suppression-compte" className="text-[#006B6B] hover:underline font-medium">
Confidentialité, sous réserve des obligations légales de conservation. 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> </p>
</div> </div>
<div> <div>

View File

@@ -1,4 +1,4 @@
import imgContact from '../assets/img/img-contact.jpg' import imgContact from '../assets/img/img-contact.webp'
export default function Contact() { export default function Contact() {
return ( return (
@@ -58,8 +58,14 @@ export default function Contact() {
</div> </div>
</section> </section>
{/* Image */} {/* Image : hauteur fixe en empilement mobile (h-64/h-80), puis à partir de md, où
<section className="rounded-2xl shadow overflow-hidden min-h-80"> les deux blocs deviennent des colonnes de grille, h-full + min-h-0 pour que
l'image s'étire exactement à la hauteur de la colonne texte (déterminée par son
propre contenu) au lieu d'imposer la sienne via son ratio d'aspect — min-h-0
annule le min-height: auto par défaut des enfants de grille qui, sinon,
empêcherait l'image de se réduire en dessous de sa hauteur intrinsèque.
object-cover recadre l'excédent. */}
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-full md:min-h-0">
<img <img
src={imgContact} src={imgContact}
alt="Une personne savourant un thé" alt="Une personne savourant un thé"

View File

@@ -6,6 +6,18 @@ import { useAuth } from '../context/AuthContext'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur. // Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0] 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 // Valeurs alignées sur le tableau de bord admin, qui compte exactement
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre". // "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
const GENRES = ['Femme', 'Homme', 'Autre'] const GENRES = ['Femme', 'Homme', 'Autre']
@@ -51,6 +63,12 @@ export default function Inscription() {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
if (!hasAtLeast18Years(form.birthdate)) {
setError('Vous devez avoir au moins 18 ans pour vous inscrire.')
return
}
setLoading(true) setLoading(true)
try { try {
await registerApi({ await registerApi({
@@ -203,9 +221,10 @@ export default function Inscription() {
</div> </div>
<div> <div>
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label> <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} max={AUJOURD_HUI} <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]" /> 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>
<div> <div>

View File

@@ -66,7 +66,7 @@ export default function MesGains() {
}, [handleAuthError]) }, [handleAuthError])
return ( 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" /> <UserNav active="gains" />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

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

@@ -1,13 +1,25 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import UserNav from '../components/UserNav' import UserNav from '../components/UserNav'
import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth' import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
import { useHandleAuthError } from '../hooks/useHandleAuthError' import { useHandleAuthError } from '../hooks/useHandleAuthError'
export default function Profile() { export default function Profile() {
const user = JSON.parse(localStorage.getItem('user') || '{}') const user = JSON.parse(localStorage.getItem('user') || '{}')
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase() const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
const handleAuthError = useHandleAuthError() 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 [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false }) const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
@@ -82,7 +94,7 @@ export default function Profile() {
) )
return ( 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" /> <UserNav active="profile" />
{/* Avatar */} {/* Avatar */}
@@ -206,6 +218,30 @@ export default function Profile() {
</form> </form>
)} )}
</div> </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> </div>
) )
} }

View File

@@ -44,7 +44,7 @@ export default function SaisirCodeUnique() {
} }
return ( 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" /> <UserNav active="code" />
<div className="mb-8"> <div className="mb-8">

View File

@@ -6,6 +6,7 @@ const sections = [
title: 'Navigation principale', title: 'Navigation principale',
links: [ links: [
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" }, { 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: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' }, { label: 'Contact', to: '/contact', desc: 'Nous contacter' },
], ],

View File

@@ -43,7 +43,13 @@ export async function registerApi({
}), }),
}) })
const data = await res.json() 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 return data
} }