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:
13
index.html
13
index.html
@@ -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
5
public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://braidsbymira.vercel.app/sitemap.xml
|
||||
15
public/sitemap.xml
Normal file
15
public/sitemap.xml
Normal 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
19
src/hooks/usePageTitle.ts
Normal 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])
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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('')
|
||||
|
||||
Reference in New Issue
Block a user