feat: add SEO basics (meta tags, robots.txt, sitemap, per-page titles)

lang=fr, meta description, Open Graph/Twitter tags, and a lightweight
usePageTitle hook wired into each public page.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-25 10:42:52 +02:00
parent cca472d72c
commit efce78ea93
8 changed files with 174 additions and 1 deletions

View File

@@ -1,13 +1,24 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet">
<title>Braids by Mira</title>
<meta name="description" content="Braids by Mira — salon de tresses et extensions. Réservez votre rendez-vous en ligne." />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Braids by Mira" />
<meta property="og:title" content="Braids by Mira" />
<meta property="og:description" content="Salon de tresses et extensions. Réservez votre rendez-vous en ligne." />
<meta property="og:url" content="https://braidsbymira.vercel.app/" />
<meta property="og:image" content="https://braidsbymira.vercel.app/favicon.svg" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Braids by Mira" />
<meta name="twitter:description" content="Salon de tresses et extensions. Réservez votre rendez-vous en ligne." />
</head>
<body>
<div id="root"></div>

5
public/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
User-agent: *
Disallow: /admin
Allow: /
Sitemap: https://braidsbymira.vercel.app/sitemap.xml

15
public/sitemap.xml Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://braidsbymira.vercel.app/</loc>
</url>
<url>
<loc>https://braidsbymira.vercel.app/shop</loc>
</url>
<url>
<loc>https://braidsbymira.vercel.app/book</loc>
</url>
<url>
<loc>https://braidsbymira.vercel.app/rules</loc>
</url>
</urlset>

19
src/hooks/usePageTitle.ts Normal file
View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react'
export default function usePageTitle(title: string, description?: string) {
useEffect(() => {
document.title = `${title} — Braids by Mira`
if (!description) return
let meta = document.querySelector('meta[name="description"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'description')
document.head.appendChild(meta)
}
const previous = meta.getAttribute('content')
meta.setAttribute('content', description)
return () => {
if (previous) meta.setAttribute('content', previous)
}
}, [title, description])
}

View File

@@ -7,9 +7,11 @@ import ServiceCard from '../components/ServiceCard'
import StepProgress from '../components/StepProgress'
import { fetchServices, type Service } from '../data/services'
import type { TimeSlot } from '../data/availability'
import usePageTitle from '../hooks/usePageTitle'
import styles from './Book.module.css'
function Book() {
usePageTitle('Réserver', 'Réservez votre rendez-vous en ligne chez Braids by Mira.')
const location = useLocation()
const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId
const booking = useBooking(preselectedServiceId)
@@ -185,6 +187,121 @@ function Book() {
onChange={(e) => booking.updateContact({ notes: e.target.value })}
/>
</div>
<h2 className={styles.sectionTitle}>Vos cheveux</h2>
<div className={styles.field}>
<label htmlFor="intake-hair-length">Longueur des cheveux</label>
<select
id="intake-hair-length"
value={booking.state.intake.hairLength}
onChange={(e) => booking.updateIntake({ hairLength: e.target.value as typeof booking.state.intake.hairLength })}
>
<option value="">Sélectionnez…</option>
<option value="court">Court</option>
<option value="moyen">Moyen</option>
<option value="long">Long</option>
</select>
{booking.errors.hairLength && <span className={styles.error}>{booking.errors.hairLength}</span>}
</div>
<div className={styles.field}>
<label htmlFor="intake-hair-type">Type de cheveux</label>
<select
id="intake-hair-type"
value={booking.state.intake.hairType}
onChange={(e) => booking.updateIntake({ hairType: e.target.value as typeof booking.state.intake.hairType })}
>
<option value="">Sélectionnez…</option>
<option value="naturel">Naturel</option>
<option value="defrise">Défrisé</option>
<option value="extensions">Extensions déjà en place</option>
<option value="teints">Teint(s)</option>
</select>
{booking.errors.hairType && <span className={styles.error}>{booking.errors.hairType}</span>}
</div>
<div className={styles.field}>
<label htmlFor="intake-current-photo">Photo de vos cheveux actuels</label>
<input
id="intake-current-photo"
type="file"
accept="image/*"
onChange={(e) => booking.updateIntake({ currentHairPhoto: e.target.files?.[0] ?? null })}
/>
<span className={styles.hint}>Optionnel, mais fortement recommandé.</span>
</div>
<div className={styles.field}>
<label htmlFor="intake-scalp">Problèmes ou sensibilités du cuir chevelu</label>
<textarea
id="intake-scalp"
placeholder="Allergies, irritations, cuir chevelu sensible, etc. (optionnel)"
value={booking.state.intake.scalpSensitivities}
onChange={(e) => booking.updateIntake({ scalpSensitivities: e.target.value })}
/>
</div>
<div className={styles.field}>
<label htmlFor="intake-hair-state">Cheveux mouillés/lavés ou secs le jour du rendez-vous ?</label>
<select
id="intake-hair-state"
value={booking.state.intake.hairState}
onChange={(e) => booking.updateIntake({ hairState: e.target.value as typeof booking.state.intake.hairState })}
>
<option value="">Sélectionnez…</option>
<option value="mouilles">Mouillés/lavés</option>
<option value="secs">Secs</option>
</select>
{booking.errors.hairState && <span className={styles.error}>{booking.errors.hairState}</span>}
</div>
<div className={styles.field}>
<label htmlFor="intake-style">Style souhaité</label>
<textarea
id="intake-style"
placeholder="Décrivez le style souhaité (optionnel)"
value={booking.state.intake.desiredStyle}
onChange={(e) => booking.updateIntake({ desiredStyle: e.target.value })}
/>
</div>
<div className={styles.field}>
<label htmlFor="intake-inspiration-photo">Photo d'inspiration</label>
<input
id="intake-inspiration-photo"
type="file"
accept="image/*"
onChange={(e) => booking.updateIntake({ inspirationPhoto: e.target.files?.[0] ?? null })}
/>
<span className={styles.hint}>Optionnel, mais fortement recommandé.</span>
</div>
<div className={styles.field}>
<label htmlFor="intake-extensions">Mèches/extensions fournies par</label>
<select
id="intake-extensions"
value={booking.state.intake.extensionsProvidedBy}
onChange={(e) => booking.updateIntake({ extensionsProvidedBy: e.target.value as typeof booking.state.intake.extensionsProvidedBy })}
>
<option value="">Sélectionnez…</option>
<option value="cliente">La cliente</option>
<option value="salon">Le salon</option>
</select>
{booking.errors.extensionsProvidedBy && <span className={styles.error}>{booking.errors.extensionsProvidedBy}</span>}
</div>
<div className={styles.field}>
<label htmlFor="intake-guests">Nombre d'accompagnants</label>
<input
id="intake-guests"
type="number"
min={0}
value={booking.state.intake.guestCount}
onChange={(e) => booking.updateIntake({ guestCount: Math.max(0, Number(e.target.value)) })}
/>
</div>
<button type="submit" disabled={booking.submitting}>Confirmer la réservation</button>
</form>
)}

View File

@@ -9,9 +9,11 @@ import { fetchServices, type Service } from '../data/services'
import { fetchProducts, type Product } from '../data/products'
import { fetchTestimonials, type Testimonial } from '../data/testimonials'
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
import usePageTitle from '../hooks/usePageTitle'
import styles from './Home.module.css'
function Home() {
usePageTitle('Accueil', 'Salon de tresses et extensions. Réservez votre rendez-vous en ligne.')
const navigate = useNavigate()
const { scrollYProgress } = useScroll()
const prefersReducedMotion = useReducedMotion()

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import ScrollReveal from '../components/ScrollReveal'
import RuleIcon from '../components/RuleIcon'
import { fetchRules, type RuleSection } from '../data/rules'
import usePageTitle from '../hooks/usePageTitle'
import styles from './Rules.module.css'
function splitIntoPoints(body: string): string[] {
@@ -13,6 +14,7 @@ function splitIntoPoints(body: string): string[] {
}
function Rules() {
usePageTitle('Règlements', 'Règlements et politiques du salon à lire avant de réserver.')
const [rules, setRules] = useState<RuleSection[]>([])
const [error, setError] = useState('')

View File

@@ -3,6 +3,7 @@ import { motion, useReducedMotion } from 'framer-motion'
import ProductCard from '../components/ProductCard'
import InquiryModal from '../components/InquiryModal'
import { fetchProducts, getProductsByCategory, type Product } from '../data/products'
import usePageTitle from '../hooks/usePageTitle'
import styles from './Shop.module.css'
const CATEGORIES: (Product['category'] | 'all')[] = ['all', 'hair-care', 'extensions', 'tools', 'accessories']
@@ -16,6 +17,7 @@ const CATEGORY_LABELS: Record<Product['category'] | 'all', string> = {
}
function Shop() {
usePageTitle('Boutique', 'Produits capillaires, extensions, outils et accessoires.')
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')