optimiser SEO et images, corriger age minimum inscription

This commit is contained in:
Alexis SANOGO
2026-09-04 12:47:19 +02:00
parent a1ab2df975
commit dfa5fb81a3
17 changed files with 174 additions and 93 deletions

12
backend/.gitignore vendored
View File

@@ -4,3 +4,15 @@ node_modules/
coverage/
dist/
test-results/
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

@@ -3,6 +3,7 @@ User-agent: *
# Pages publiques autorisées
Allow: /
Allow: /accueil
Allow: /notre-concept
Allow: /regles-jeu
Allow: /contact
Allow: /mentions-legales

View File

@@ -194,12 +194,6 @@ export default function Footer() {
<a href="mailto:thetiptop.gp4@gmail.com" className="inline-block mt-3 font-medium break-all hover:text-teal-300">
thetiptop.gp4@gmail.com
</a>
<p className="text-teal-100 text-sm mt-2">
18 rue Léon Frot
<br />
75011 Paris, France
</p>
</div>
</div>

View File

@@ -1,29 +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()
// Permet à une page de définir son propre <title>/<meta description> le
// temps qu'elle est affichée. La SPA n'a qu'un seul index.html : sans ce
// hook, toutes les routes partagent le même title/meta. 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 } = {}) {
useEffect(() => {
const previousTitle = document.title
if (title) {
document.title = title
}
if (title) document.title = title
const metaEl = document.querySelector('meta[name="description"]')
const previousDescription = metaEl?.getAttribute('content') ?? null
if (description && metaEl) {
metaEl.setAttribute('content', description)
}
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
if (metaEl && previousDescription !== null) {
metaEl.setAttribute('content', previousDescription)
restores.forEach((restore) => restore())
}
}
}, [title, description])
}, [title, description, image, pathname])
}

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

View File

@@ -3,9 +3,6 @@ import { Link } from 'react-router-dom'
import usePageMeta from '../hooks/usePageMeta'
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' },
@@ -14,9 +11,6 @@ const gains = [
{ 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()
@@ -28,11 +22,6 @@ 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) {
@@ -100,12 +89,10 @@ const faqJsonLd = {
export default function Accueil() {
usePageMeta({
title: 'Jeu-concours 100 % Gagnant | Thé Tip Top — Thés bio et faits main',
title: 'Jeu-concours 100 % Gagnant du 2 au 31 octobre | Thé Tip Top',
description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.",
})
// 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 (
@@ -113,25 +100,14 @@ export default function Accueil() {
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
{/* Bannière : fond teal plein cadre, illustration très grande et animée à l'entrée,
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
@@ -152,7 +128,6 @@ export default function Accueil() {
))}
</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
@@ -163,10 +138,6 @@ export default function Accueil() {
</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
@@ -184,27 +155,14 @@ export default function Accueil() {
/>
</div>
{/* Sous-titre : mis en évidence dans une carte « verre dépoli » distincte du fond
teal uni (bordure d'accent + fond translucide flouté), pour qu'il se détache
clairement plutôt que de se fondre dans le reste du texte blanc de la bannière. */}
<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>
{/* 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
@@ -220,14 +178,6 @@ export default function Accueil() {
</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}
@@ -247,7 +197,6 @@ export default function Accueil() {
))}
</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
to="/saisir-code-unique"
@@ -265,7 +214,6 @@ export default function Accueil() {
</div>
</section>
{/* Comment participer */}
<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 ?
@@ -284,7 +232,6 @@ export default function Accueil() {
</ol>
</section>
{/* Questions fréquentes */}
<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

View File

@@ -1,6 +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">

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>

View File

@@ -2,8 +2,14 @@ import { useState } 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'
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()

View File

@@ -1,6 +1,12 @@
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">
@@ -19,19 +25,6 @@ export default function Contact() {
</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

View File

@@ -2,6 +2,7 @@ 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'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
@@ -23,6 +24,11 @@ function hasAtLeast18Years(birthdate) {
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()

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

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

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import { useAuth} from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
const sections = [
{
@@ -45,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(