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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet">
|
||||||
<title>Braids by Mira</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 StepProgress from '../components/StepProgress'
|
||||||
import { fetchServices, type Service } from '../data/services'
|
import { fetchServices, type Service } from '../data/services'
|
||||||
import type { TimeSlot } from '../data/availability'
|
import type { TimeSlot } from '../data/availability'
|
||||||
|
import usePageTitle from '../hooks/usePageTitle'
|
||||||
import styles from './Book.module.css'
|
import styles from './Book.module.css'
|
||||||
|
|
||||||
function Book() {
|
function Book() {
|
||||||
|
usePageTitle('Réserver', 'Réservez votre rendez-vous en ligne chez Braids by Mira.')
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId
|
const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId
|
||||||
const booking = useBooking(preselectedServiceId)
|
const booking = useBooking(preselectedServiceId)
|
||||||
@@ -185,6 +187,121 @@ function Book() {
|
|||||||
onChange={(e) => booking.updateContact({ notes: e.target.value })}
|
onChange={(e) => booking.updateContact({ notes: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<button type="submit" disabled={booking.submitting}>Confirmer la réservation</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { fetchServices, type Service } from '../data/services'
|
|||||||
import { fetchProducts, type Product } from '../data/products'
|
import { fetchProducts, type Product } from '../data/products'
|
||||||
import { fetchTestimonials, type Testimonial } from '../data/testimonials'
|
import { fetchTestimonials, type Testimonial } from '../data/testimonials'
|
||||||
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
|
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
|
||||||
|
import usePageTitle from '../hooks/usePageTitle'
|
||||||
import styles from './Home.module.css'
|
import styles from './Home.module.css'
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
|
usePageTitle('Accueil', 'Salon de tresses et extensions. Réservez votre rendez-vous en ligne.')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { scrollYProgress } = useScroll()
|
const { scrollYProgress } = useScroll()
|
||||||
const prefersReducedMotion = useReducedMotion()
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import ScrollReveal from '../components/ScrollReveal'
|
import ScrollReveal from '../components/ScrollReveal'
|
||||||
import RuleIcon from '../components/RuleIcon'
|
import RuleIcon from '../components/RuleIcon'
|
||||||
import { fetchRules, type RuleSection } from '../data/rules'
|
import { fetchRules, type RuleSection } from '../data/rules'
|
||||||
|
import usePageTitle from '../hooks/usePageTitle'
|
||||||
import styles from './Rules.module.css'
|
import styles from './Rules.module.css'
|
||||||
|
|
||||||
function splitIntoPoints(body: string): string[] {
|
function splitIntoPoints(body: string): string[] {
|
||||||
@@ -13,6 +14,7 @@ function splitIntoPoints(body: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Rules() {
|
function Rules() {
|
||||||
|
usePageTitle('Règlements', 'Règlements et politiques du salon à lire avant de réserver.')
|
||||||
const [rules, setRules] = useState<RuleSection[]>([])
|
const [rules, setRules] = useState<RuleSection[]>([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { motion, useReducedMotion } from 'framer-motion'
|
|||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
import InquiryModal from '../components/InquiryModal'
|
import InquiryModal from '../components/InquiryModal'
|
||||||
import { fetchProducts, getProductsByCategory, type Product } from '../data/products'
|
import { fetchProducts, getProductsByCategory, type Product } from '../data/products'
|
||||||
|
import usePageTitle from '../hooks/usePageTitle'
|
||||||
import styles from './Shop.module.css'
|
import styles from './Shop.module.css'
|
||||||
|
|
||||||
const CATEGORIES: (Product['category'] | 'all')[] = ['all', 'hair-care', 'extensions', 'tools', 'accessories']
|
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() {
|
function Shop() {
|
||||||
|
usePageTitle('Boutique', 'Produits capillaires, extensions, outils et accessoires.')
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|||||||
Reference in New Issue
Block a user