feat: translate public site to French

Translates all user-visible English text on the public-facing pages
(Home, Shop, Book, Rules, Nav, Footer, InquiryModal, StepProgress,
ServiceCard, ProductCard) plus product and service data to French,
matching the tone/register of the already-French rules.ts. Category
enum values, IDs, route paths, and admin pages are left untouched.
Updates matching test assertions to the new French copy.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-12 18:51:26 +02:00
parent 3504790216
commit b84db38096
23 changed files with 133 additions and 125 deletions

View File

@@ -5,21 +5,21 @@ import App from './App'
describe('App routing', () => {
it('renders the Home page at /', async () => {
render(<App initialEntries={['/']} />)
expect(await screen.findByText(/your hair, your crown/i)).toBeInTheDocument()
expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
})
it('renders the Shop page at /shop', () => {
render(<App initialEntries={['/shop']} />)
expect(screen.getByRole('heading', { name: /shop/i })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /boutique/i })).toBeInTheDocument()
})
it('renders the Book page at /book', () => {
render(<App initialEntries={['/book']} />)
expect(screen.getByRole('heading', { name: /book an appointment/i })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /réserver un rendez-vous/i })).toBeInTheDocument()
})
it('renders the Rules page at /rules', () => {
render(<App initialEntries={['/rules']} />)
expect(screen.getByRole('heading', { name: /salon rules/i })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /règlements/i })).toBeInTheDocument()
})
})

View File

@@ -7,7 +7,7 @@ describe('Footer', () => {
it('renders contact info and quick links', () => {
render(<MemoryRouter><Footer /></MemoryRouter>)
expect(screen.getAllByText(/braids by mira/i).length).toBeGreaterThan(0)
expect(screen.getByRole('link', { name: /shop/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /rules/i })).toHaveAttribute('href', '/rules')
expect(screen.getByRole('link', { name: /boutique/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /règlements/i })).toHaveAttribute('href', '/rules')
})
})

View File

@@ -7,27 +7,27 @@ function Footer() {
<div className={styles.inner}>
<div className={styles.brand}>
<p className={styles.wordmark}>Braids by Mira</p>
<p className={styles.tagline}>Your hair, your crown</p>
<p className={styles.tagline}>Vos cheveux, votre couronne</p>
</div>
<div className={styles.column}>
<p className={styles.columnTitle}>Contact</p>
<a href="mailto:hello@braidsbymira.com">hello@braidsbymira.com</a>
<p>Tue–Sat, 9am–6pm</p>
<p>Mar–Sam, 9h à 18h</p>
</div>
<div className={styles.column}>
<p className={styles.columnTitle}>Navigate</p>
<p className={styles.columnTitle}>Navigation</p>
<ul className={styles.links}>
<li><Link to="/">Home</Link></li>
<li><Link to="/shop">Shop</Link></li>
<li><Link to="/book">Book</Link></li>
<li><Link to="/rules">Rules</Link></li>
<li><Link to="/">Accueil</Link></li>
<li><Link to="/shop">Boutique</Link></li>
<li><Link to="/book">Réserver</Link></li>
<li><Link to="/rules">Règlements</Link></li>
</ul>
</div>
</div>
<p className={styles.copyright}>&copy; {new Date().getFullYear()} Braids by Mira. All rights reserved.</p>
<p className={styles.copyright}>&copy; {new Date().getFullYear()} Braids by Mira. Tous droits réservés.</p>
</footer>
)
}

View File

@@ -12,24 +12,24 @@ const product: Product = {
describe('InquiryModal', () => {
it('shows a validation error when submitting without a name or valid email', async () => {
render(<InquiryModal product={product} onClose={vi.fn()} />)
await userEvent.click(screen.getByRole('button', { name: /send inquiry/i }))
expect(screen.getByText(/name is required/i)).toBeInTheDocument()
expect(screen.getByText(/valid email is required/i)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /envoyer la demande/i }))
expect(screen.getByText(/nom est requis/i)).toBeInTheDocument()
expect(screen.getByText(/adresse courriel valide est requise/i)).toBeInTheDocument()
})
it('shows a confirmation after a valid submit', async () => {
render(<InquiryModal product={product} onClose={vi.fn()} />)
await userEvent.type(screen.getByLabelText(/name/i), 'Jane Doe')
await userEvent.type(screen.getByLabelText(/email/i), 'jane@example.com')
await userEvent.click(screen.getByRole('button', { name: /send inquiry/i }))
await userEvent.type(screen.getByLabelText(/nom/i), 'Jane Doe')
await userEvent.type(screen.getByLabelText(/courriel/i), 'jane@example.com')
await userEvent.click(screen.getByRole('button', { name: /envoyer la demande/i }))
expect(screen.getByText(/silk edge control/i)).toBeInTheDocument()
expect(screen.getByText(/mira will get back to you/i)).toBeInTheDocument()
expect(screen.getByText(/mira vous recontactera/i)).toBeInTheDocument()
})
it('calls onClose when Close is clicked', async () => {
const onClose = vi.fn()
render(<InquiryModal product={product} onClose={onClose} />)
await userEvent.click(screen.getByRole('button', { name: /close/i }))
await userEvent.click(screen.getByRole('button', { name: /fermer/i }))
expect(onClose).toHaveBeenCalled()
})
@@ -42,7 +42,7 @@ describe('InquiryModal', () => {
it('exposes an accessible dialog labelled by its heading', () => {
render(<InquiryModal product={product} onClose={vi.fn()} />)
const dialog = screen.getByRole('dialog', { name: /ask about silk edge control/i })
const dialog = screen.getByRole('dialog', { name: /une question sur silk edge control/i })
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})

View File

@@ -38,8 +38,8 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
function handleSubmit(e: FormEvent) {
e.preventDefault()
const nextErrors: Errors = {}
if (!name.trim()) nextErrors.name = 'Name is required.'
if (!isValidEmail(email)) nextErrors.email = 'A valid email is required.'
if (!name.trim()) nextErrors.name = 'Le nom est requis.'
if (!isValidEmail(email)) nextErrors.email = 'Une adresse courriel valide est requise.'
setErrors(nextErrors)
if (Object.keys(nextErrors).length === 0) {
setSubmitted(true)
@@ -61,14 +61,14 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
>
{submitted ? (
<>
<p>Thanks — Mira will get back to you about {product.name} soon.</p>
<button type="button" onClick={onClose}>Close</button>
<p>Merci — Mira vous recontactera bientôt au sujet de {product.name}.</p>
<button type="button" onClick={onClose}>Fermer</button>
</>
) : (
<form onSubmit={handleSubmit}>
<h3 id={HEADING_ID}>Ask about {product.name}</h3>
<h3 id={HEADING_ID}>Une question sur {product.name}</h3>
<div className={styles.field}>
<label htmlFor="inquiry-name">Name</label>
<label htmlFor="inquiry-name">Nom</label>
<input
id="inquiry-name"
ref={nameInputRef}
@@ -78,7 +78,7 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
{errors.name && <span className={styles.error}>{errors.name}</span>}
</div>
<div className={styles.field}>
<label htmlFor="inquiry-email">Email</label>
<label htmlFor="inquiry-email">Courriel</label>
<input id="inquiry-email" value={email} onChange={(e) => setEmail(e.target.value)} />
{errors.email && <span className={styles.error}>{errors.email}</span>}
</div>
@@ -86,8 +86,8 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
<label htmlFor="inquiry-notes">Notes</label>
<textarea id="inquiry-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<button type="submit">Send Inquiry</button>
<button type="button" onClick={onClose}>Close</button>
<button type="submit">Envoyer la demande</button>
<button type="button" onClick={onClose}>Fermer</button>
</form>
)}
</div>

View File

@@ -6,10 +6,10 @@ import Nav from './Nav'
describe('Nav', () => {
it('renders links to all four routes', () => {
render(<MemoryRouter><Nav /></MemoryRouter>)
expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: /shop/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /book/i })).toHaveAttribute('href', '/book')
expect(screen.getByRole('link', { name: /rules/i })).toHaveAttribute('href', '/rules')
expect(screen.getByRole('link', { name: /accueil/i })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: /boutique/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /réserver/i })).toHaveAttribute('href', '/book')
expect(screen.getByRole('link', { name: /règlements/i })).toHaveAttribute('href', '/rules')
})
it('adds a condensed class after scrolling', () => {

View File

@@ -15,10 +15,10 @@ function Nav() {
<nav className={`${styles.nav} ${condensed ? styles.condensed : ''}`}>
<span>Braids by Mira</span>
<ul className={styles.links}>
<li><Link to="/">Home</Link></li>
<li><Link to="/shop">Shop</Link></li>
<li><Link to="/book">Book</Link></li>
<li><Link to="/rules">Rules</Link></li>
<li><Link to="/">Accueil</Link></li>
<li><Link to="/shop">Boutique</Link></li>
<li><Link to="/book">Réserver</Link></li>
<li><Link to="/rules">Règlements</Link></li>
</ul>
</nav>
)

View File

@@ -14,16 +14,16 @@ describe('ProductCard', () => {
const onInquire = vi.fn()
render(<ProductCard product={product} onInquire={onInquire} />)
expect(screen.getByText('Silk Edge Control')).toBeInTheDocument()
expect(screen.getByText('$12')).toBeInTheDocument()
expect(screen.getByText('12 $')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Silk Edge Control' })).toHaveAttribute('src', '/x.jpg')
await userEvent.click(screen.getByRole('button', { name: /ask about this/i }))
await userEvent.click(screen.getByRole('button', { name: /poser une question/i }))
expect(onInquire).toHaveBeenCalledWith(product)
})
it('disables the inquire button when out of stock', () => {
const onInquire = vi.fn()
render(<ProductCard product={{ ...product, inStock: false }} onInquire={onInquire} />)
expect(screen.getByRole('button', { name: /ask about this/i })).toBeDisabled()
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /poser une question/i })).toBeDisabled()
expect(screen.getByText(/indisponible/i)).toBeInTheDocument()
})
})

View File

@@ -11,16 +11,16 @@ function ProductCard({ product, onInquire }: ProductCardProps) {
<div className={styles.card}>
<img src={product.image} alt={product.name} className={styles.image} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<p>{product.price} $</p>
<p>{product.description}</p>
{!product.inStock && <p className={styles.badge}>Currently unavailable</p>}
{!product.inStock && <p className={styles.badge}>Actuellement indisponible</p>}
<button
type="button"
className={styles.button}
disabled={!product.inStock}
onClick={() => onInquire(product)}
>
Ask about this
Poser une question
</button>
</div>
)

View File

@@ -13,7 +13,7 @@ describe('ServiceCard', () => {
it('renders service details', () => {
render(<ServiceCard service={service} />)
expect(screen.getByText('Knotless Braids')).toBeInTheDocument()
expect(screen.getByText(/from \$150/i)).toBeInTheDocument()
expect(screen.getByText(/à partir de 150 \$/i)).toBeInTheDocument()
expect(screen.getByText('4h')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Knotless Braids' })).toHaveAttribute('src', '/x.jpg')
})

View File

@@ -18,7 +18,7 @@ function ServiceCard({ service, onSelect }: ServiceCardProps) {
<img src={service.image} alt={service.name} className={styles.image} />
<h3>{service.name}</h3>
<p>{formatDuration(service.durationMinutes)}</p>
<p>From ${service.priceFrom}</p>
<p>À partir de {service.priceFrom} $</p>
</button>
)
}

View File

@@ -3,9 +3,9 @@ import styles from './StepProgress.module.css'
const STEPS = [
{ key: 'service', label: 'Service' },
{ key: 'date', label: 'Date & Time' },
{ key: 'date', label: 'Date et heure' },
{ key: 'contact', label: 'Contact' },
{ key: 'confirmed', label: 'Confirmed' },
{ key: 'confirmed', label: 'Confirmé' },
] as const
interface StepProgressProps {

View File

@@ -9,16 +9,16 @@ export interface Product {
}
export const products: Product[] = [
{ id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care', price: 12, image: '/products/edge-control.jpg', description: 'Long-lasting hold with a natural shine, no flaking.', inStock: true },
{ id: 'braid-oil', name: 'Nourishing Braid Oil', category: 'hair-care', price: 16, image: '/products/braid-oil.jpg', description: 'Lightweight scalp oil that soothes and adds shine between washes.', inStock: true },
{ id: 'leave-in', name: 'Detangling Leave-In', category: 'hair-care', price: 14, image: '/products/leave-in.jpg', description: 'Slip-rich leave-in conditioner for gentle detangling.', inStock: true },
{ id: 'kanekalon-black', name: 'Kanekalon Braiding Hair — Jet Black', category: 'extensions', price: 8, image: '/products/kanekalon-black.jpg', description: 'Premium synthetic braiding hair, soft texture, low shedding.', inStock: true },
{ id: 'kanekalon-brown', name: 'Kanekalon Braiding Hair — Chestnut', category: 'extensions', price: 8, image: '/products/kanekalon-brown.jpg', description: 'Warm brown tone, blends seamlessly for natural styles.', inStock: false },
{ id: 'locs-extension', name: 'Faux Locs Extensions', category: 'extensions', price: 22, image: '/products/locs-extension.jpg', description: 'Pre-looped faux locs for a natural, lightweight install.', inStock: true },
{ id: 'rat-tail-comb', name: 'Steel Rat-Tail Comb', category: 'tools', price: 6, image: '/products/rat-tail-comb.jpg', description: 'Precision parting comb with a fine metal tail.', inStock: true },
{ id: 'edge-brush', name: 'Mini Edge Brush', category: 'tools', price: 5, image: '/products/edge-brush.jpg', description: 'Dual-sided brush for laying edges and smoothing baby hairs.', inStock: true },
{ id: 'gold-cuffs', name: 'Gold Hair Cuffs (Set of 6)', category: 'accessories', price: 10, image: '/products/gold-cuffs.jpg', description: 'Adjustable cuffs to accessorize braids and locs.', inStock: true },
{ id: 'satin-bonnet', name: 'Satin Sleep Bonnet', category: 'accessories', price: 9, image: '/products/satin-bonnet.jpg', description: 'Protects braids and locs overnight, reduces frizz.', inStock: true },
{ id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '/products/edge-control.jpg', description: 'Tenue longue durée avec un éclat naturel, sans effet blanc.', inStock: true },
{ id: 'braid-oil', name: 'Huile Nourrissante pour Tresses', category: 'hair-care', price: 16, image: '/products/braid-oil.jpg', description: "Huile légère pour le cuir chevelu qui apaise et ajoute de l'éclat entre les lavages.", inStock: true },
{ id: 'leave-in', name: 'Soin Démêlant Sans Rinçage', category: 'hair-care', price: 14, image: '/products/leave-in.jpg', description: "Après-shampooing sans rinçage riche en glisse pour un démêlage tout en douceur.", inStock: true },
{ id: 'kanekalon-black', name: 'Fibre à Tresser Kanekalon — Noir Jais', category: 'extensions', price: 8, image: '/products/kanekalon-black.jpg', description: 'Fibre synthétique haut de gamme pour tresses, texture douce, faible chute.', inStock: true },
{ id: 'kanekalon-brown', name: 'Fibre à Tresser Kanekalon — Châtain', category: 'extensions', price: 8, image: '/products/kanekalon-brown.jpg', description: 'Teinte brune chaude qui se fond parfaitement pour un rendu naturel.', inStock: false },
{ id: 'locs-extension', name: 'Extensions de Faux Locs', category: 'extensions', price: 22, image: '/products/locs-extension.jpg', description: 'Faux locs pré-bouclées pour une pose naturelle et légère.', inStock: true },
{ id: 'rat-tail-comb', name: 'Peigne à Tige en Acier', category: 'tools', price: 6, image: '/products/rat-tail-comb.jpg', description: 'Peigne de précision pour les raies, avec une fine tige métallique.', inStock: true },
{ id: 'edge-brush', name: 'Mini Brosse à Contours', category: 'tools', price: 5, image: '/products/edge-brush.jpg', description: 'Brosse double face pour lisser les contours et discipliner les cheveux follets.', inStock: true },
{ id: 'gold-cuffs', name: 'Anneaux Dorés pour Cheveux (Lot de 6)', category: 'accessories', price: 10, image: '/products/gold-cuffs.jpg', description: 'Anneaux ajustables pour accessoiriser tresses et locs.', inStock: true },
{ id: 'satin-bonnet', name: 'Bonnet de Nuit en Satin', category: 'accessories', price: 9, image: '/products/satin-bonnet.jpg', description: 'Protège tresses et locs pendant la nuit et réduit les frisottis.', inStock: true },
]
export function getProductsByCategory(category: Product['category'] | 'all'): Product[] {

View File

@@ -8,8 +8,8 @@ export interface Service {
}
export const services: Service[] = [
{ id: 'knotless', name: 'Knotless Braids', durationMinutes: 240, priceFrom: 150, description: 'Tension-free braids with a natural, seamless root.', image: '/styles/knotless.jpg' },
{ id: 'boho', name: 'Boho Braids', durationMinutes: 270, priceFrom: 180, description: 'Knotless braids with curly pieces woven in for a soft, romantic look.', image: '/styles/boho.jpg' },
{ id: 'locs', name: 'Faux Locs', durationMinutes: 300, priceFrom: 200, description: 'Lightweight, natural-looking locs installed with care.', image: '/styles/locs.jpg' },
{ id: 'cornrows', name: 'Cornrows', durationMinutes: 120, priceFrom: 90, description: 'Classic sleek cornrows, straight-back or with a custom design.', image: '/styles/cornrows.jpg' },
{ id: 'knotless', name: 'Tresses sans nœuds', durationMinutes: 240, priceFrom: 150, description: 'Tresses sans tension avec une racine naturelle et invisible.', image: '/styles/knotless.jpg' },
{ id: 'boho', name: 'Tresses Boho', durationMinutes: 270, priceFrom: 180, description: 'Tresses sans nœuds agrémentées de mèches bouclées pour un look doux et romantique.', image: '/styles/boho.jpg' },
{ id: 'locs', name: 'Faux Locs', durationMinutes: 300, priceFrom: 200, description: 'Locs légères à l\'allure naturelle, installées avec soin.', image: '/styles/locs.jpg' },
{ id: 'cornrows', name: 'Nattes Collées', durationMinutes: 120, priceFrom: 90, description: 'Nattes collées classiques et nettes, droites vers l\'arrière ou avec un motif personnalisé.', image: '/styles/cornrows.jpg' },
]

View File

@@ -53,9 +53,9 @@ export default function useBooking(initialServiceId?: string) {
const submit = useCallback((): boolean => {
const nextErrors: BookingErrors = {}
if (!state.contact.name.trim()) nextErrors.name = 'Name is required.'
if (!isValidEmail(state.contact.email)) nextErrors.email = 'A valid email is required.'
if (!state.contact.phone.trim()) nextErrors.phone = 'Phone is required.'
if (!state.contact.name.trim()) nextErrors.name = 'Le nom est requis.'
if (!isValidEmail(state.contact.email)) nextErrors.email = 'Une adresse courriel valide est requise.'
if (!state.contact.phone.trim()) nextErrors.phone = 'Le téléphone est requis.'
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
return false

View File

@@ -17,35 +17,35 @@ function renderBook(initialEntries: { pathname: string; state?: unknown }[]) {
describe('Book page', () => {
it('starts on the service step and advances to date after selecting a service', async () => {
renderBook([{ pathname: '/book' }])
expect(screen.getByText(/knotless braids/i)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /knotless braids/i }))
expect(await screen.findByLabelText(/choose a date/i)).toBeInTheDocument()
expect(screen.getByText(/tresses sans nœuds/i)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /tresses sans nœuds/i }))
expect(await screen.findByLabelText(/choisissez une date/i)).toBeInTheDocument()
})
it('preselects the service from route state and skips to the date step', () => {
renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }])
expect(screen.getByLabelText(/choose a date/i)).toBeInTheDocument()
expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument()
})
it('walks through date -> slot -> contact -> confirmation', async () => {
renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }])
await userEvent.type(screen.getByLabelText(/choose a date/i), '2026-08-11')
await userEvent.type(screen.getByLabelText(/choisissez une date/i), '2026-08-11')
const slotButton = (await screen.findAllByRole('button', { name: /am|pm/i }))[0]
await userEvent.click(slotButton)
await userEvent.type(await screen.findByLabelText(/^name$/i), 'Jane Doe')
await userEvent.type(screen.getByLabelText(/^email$/i), 'jane@example.com')
await userEvent.type(screen.getByLabelText(/^phone$/i), '5551234567')
await userEvent.click(screen.getByRole('button', { name: /confirm/i }))
await userEvent.type(await screen.findByLabelText(/^nom$/i), 'Jane Doe')
await userEvent.type(screen.getByLabelText(/^courriel$/i), 'jane@example.com')
await userEvent.type(screen.getByLabelText(/^téléphone$/i), '5551234567')
await userEvent.click(screen.getByRole('button', { name: /confirmer/i }))
expect(await screen.findByText(/mira will see you then/i)).toBeInTheDocument()
expect(await screen.findByText(/mira vous attend/i)).toBeInTheDocument()
})
it('shows a friendly empty state with the next available date when a chosen date has none', async () => {
renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }])
// Sundays are fully closed in the mock availability generator
await userEvent.type(screen.getByLabelText(/choose a date/i), '2026-08-09')
expect(await screen.findByText(/no times available/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /try .* instead/i })).toBeInTheDocument()
await userEvent.type(screen.getByLabelText(/choisissez une date/i), '2026-08-09')
expect(await screen.findByText(/aucun créneau disponible/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /essayez .* à la place/i })).toBeInTheDocument()
})
})

View File

@@ -26,7 +26,7 @@ function Book() {
return (
<div className={styles.page}>
<h1>Book an Appointment</h1>
<h1>Réserver un rendez-vous</h1>
<StepProgress currentStep={booking.state.step} />
<AnimatePresence mode="wait">
@@ -39,8 +39,8 @@ function Book() {
>
{booking.state.step === 'confirmed' && (
<p>
{selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}.
Mira will see you then.
{selectedService?.name} le {booking.state.date} à {booking.state.slot?.time}.
Mira vous attend.
</p>
)}
@@ -55,7 +55,7 @@ function Book() {
{(booking.state.step === 'date' || booking.state.step === 'slot') && (
<>
<div className={styles.field}>
<label htmlFor="booking-date">Choose a date</label>
<label htmlFor="booking-date">Choisissez une date</label>
<input
id="booking-date"
type="date"
@@ -70,7 +70,7 @@ function Book() {
{dateInput && availableSlots.length === 0 && (
<div>
<p>No times available on this date.</p>
<p>Aucun créneau disponible à cette date.</p>
{nextAvailable && (
<button
type="button"
@@ -79,7 +79,7 @@ function Book() {
booking.selectDate(nextAvailable)
}}
>
Try {nextAvailable} instead
Essayez le {nextAvailable} à la place
</button>
)}
</div>
@@ -105,7 +105,7 @@ function Book() {
}}
>
<div className={styles.field}>
<label htmlFor="contact-name">Name</label>
<label htmlFor="contact-name">Nom</label>
<input
id="contact-name"
value={booking.state.contact.name}
@@ -114,7 +114,7 @@ function Book() {
{booking.errors.name && <span className={styles.error}>{booking.errors.name}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-email">Email</label>
<label htmlFor="contact-email">Courriel</label>
<input
id="contact-email"
value={booking.state.contact.email}
@@ -123,7 +123,7 @@ function Book() {
{booking.errors.email && <span className={styles.error}>{booking.errors.email}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-phone">Phone</label>
<label htmlFor="contact-phone">Téléphone</label>
<input
id="contact-phone"
value={booking.state.contact.phone}
@@ -139,7 +139,7 @@ function Book() {
onChange={(e) => booking.updateContact({ notes: e.target.value })}
/>
</div>
<button type="submit">Confirm Booking</button>
<button type="submit">Confirmer la réservation</button>
</form>
)}
</motion.div>

View File

@@ -20,7 +20,7 @@ function renderHome() {
describe('Home page', () => {
it('renders the hero headline and all four signature styles', () => {
renderHome()
expect(screen.getByText(/your hair, your crown/i)).toBeInTheDocument()
expect(screen.getByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
for (const service of services) {
expect(screen.getByText(service.name)).toBeInTheDocument()
}
@@ -28,14 +28,14 @@ describe('Home page', () => {
it('renders shop highlights and a link to the rules page', () => {
renderHome()
expect(screen.getByRole('link', { name: /shop all/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /read the rules|salon rules/i })).toHaveAttribute('href', '/rules')
expect(screen.getByRole('link', { name: /voir toute la boutique/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /règlements du salon/i })).toHaveAttribute('href', '/rules')
})
it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => {
renderHome()
await userEvent.click(screen.getByRole('button', { name: new RegExp(services[0].name) }))
expect(screen.getByLabelText(/choose a date/i)).toBeInTheDocument()
expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument()
})
it('renders the About Mira portrait', () => {

View File

@@ -9,9 +9,9 @@ import { products } from '../data/products'
import styles from './Home.module.css'
const TESTIMONIALS = [
{ quote: 'Mira made me feel like royalty in her chair. My braids lasted 8 weeks!', author: 'Amara T.', avatar: '/testimonials/amara.jpg' },
{ quote: 'The most gentle, precise braider I have ever been to.', author: 'Keisha R.', avatar: '/testimonials/keisha.jpg' },
{ quote: 'Booking was so easy and the results speak for themselves.', author: 'Nadia F.', avatar: '/testimonials/nadia.jpg' },
{ quote: "Mira m'a fait sentir comme une reine sur son fauteuil. Mes tresses ont tenu 8 semaines !", author: 'Amara T.', avatar: '/testimonials/amara.jpg' },
{ quote: "La tresseuse la plus douce et la plus précise que j'aie jamais consultée.", author: 'Keisha R.', avatar: '/testimonials/keisha.jpg' },
{ quote: 'La réservation était si simple, et le résultat parle de lui-même.', author: 'Nadia F.', avatar: '/testimonials/nadia.jpg' },
]
function Home() {
@@ -24,11 +24,11 @@ function Home() {
<div>
<section className={styles.hero}>
<Hero3DScene />
<h1 className={styles.headline}>Your hair, your crown</h1>
<p className={styles.tagline}>Protective Styles by Mira</p>
<h1 className={styles.headline}>Vos cheveux, votre couronne</h1>
<p className={styles.tagline}>Coiffures protectrices par Mira</p>
<div className={styles.ctas}>
<Link to="/book">Book Now</Link>
<Link to="/shop">Shop Products</Link>
<Link to="/book">Réserver maintenant</Link>
<Link to="/shop">Voir les produits</Link>
</div>
</section>
@@ -37,16 +37,16 @@ function Home() {
<div className={styles.aboutGrid}>
<motion.img
src="/about/mira-portrait.jpg"
alt="Mira, owner and stylist at Braids by Mira"
alt="Mira, propriétaire et coiffeuse chez Braids by Mira"
className={styles.portrait}
style={{ y: portraitY }}
/>
<div>
<h2>About Mira</h2>
<h2>À propos de Mira</h2>
<p>
With over a decade behind the chair, Mira treats every install as an art form —
gentle on the scalp, precise at the root, and finished with a crown you'll want
to show off.
Avec plus d'une décennie d'expérience derrière le fauteuil, Mira aborde chaque
installation comme une œuvre d'art — douce pour le cuir chevelu, précise à la
racine, et achevée pour une couronne que vous aurez envie d'afficher.
</p>
</div>
</div>
@@ -55,7 +55,7 @@ function Home() {
<ScrollReveal>
<section className={styles.section}>
<h2>Signature Styles</h2>
<h2>Styles Signature</h2>
<div className={styles.row}>
{services.map((service) => (
<ServiceCard
@@ -70,19 +70,19 @@ function Home() {
<ScrollReveal>
<section className={styles.altSection}>
<h2>Shop Highlights</h2>
<h2>Sélection boutique</h2>
<div className={styles.row}>
{products.filter((p) => p.inStock).slice(0, 3).map((product) => (
<ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
))}
</div>
<p><Link to="/shop">Shop All</Link></p>
<p><Link to="/shop">Voir toute la boutique</Link></p>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.section}>
<h2>Client Love</h2>
<h2>Avis de nos clientes</h2>
<div className={styles.row}>
{TESTIMONIALS.map((t) => (
<blockquote key={t.author} className={styles.testimonial}>
@@ -96,9 +96,9 @@ function Home() {
</ScrollReveal>
<section className={styles.banner}>
<h2>Before You Book</h2>
<h2>Avant de réserver</h2>
<p>
Please take a moment to read the <Link to="/rules">salon rules</Link> before booking your appointment.
Merci de prendre un instant pour lire les <Link to="/rules">règlements du salon</Link> avant de réserver votre rendez-vous.
</p>
</section>
</div>

View File

@@ -15,6 +15,6 @@ describe('Rules page', () => {
it('links back to booking', () => {
render(<MemoryRouter><Rules /></MemoryRouter>)
expect(screen.getByRole('link', { name: /book/i })).toHaveAttribute('href', '/book')
expect(screen.getByRole('link', { name: /réserver/i })).toHaveAttribute('href', '/book')
})
})

View File

@@ -7,8 +7,8 @@ import styles from './Rules.module.css'
function Rules() {
return (
<div className={styles.page}>
<h1>Salon Rules & Policies</h1>
<p>Please read through before booking your appointment.</p>
<h1>Règlements et politiques du salon</h1>
<p>Merci de les lire avant de réserver votre rendez-vous.</p>
{rules.map((rule, index) => (
<ScrollReveal key={rule.id} delay={index * 0.08}>
<div className={styles.section}>
@@ -21,8 +21,8 @@ function Rules() {
</ScrollReveal>
))}
<div className={styles.cta}>
<p>By booking, you agree to these terms.</p>
<Link to="/book">Book Now</Link>
<p>En réservant, vous acceptez ces conditions.</p>
<Link to="/book">Réserver maintenant</Link>
</div>
</div>
)

View File

@@ -14,15 +14,15 @@ describe('Shop page', () => {
it('filters products by category', async () => {
render(<Shop />)
await userEvent.click(screen.getByRole('button', { name: /^tools$/i }))
expect(screen.getByText('Steel Rat-Tail Comb')).toBeInTheDocument()
expect(screen.queryByText('Silk Edge Control')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /^outils$/i }))
expect(screen.getByText('Peigne à Tige en Acier')).toBeInTheDocument()
expect(screen.queryByText('Gel Contour Soyeux')).not.toBeInTheDocument()
})
it('opens the inquiry modal when a product is inquired about', async () => {
render(<Shop />)
const buttons = screen.getAllByRole('button', { name: /ask about this/i })
const buttons = screen.getAllByRole('button', { name: /poser une question/i })
await userEvent.click(buttons[0])
expect(screen.getByRole('button', { name: /send inquiry/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /envoyer la demande/i })).toBeInTheDocument()
})
})

View File

@@ -7,6 +7,14 @@ import styles from './Shop.module.css'
const CATEGORIES: (Product['category'] | 'all')[] = ['all', 'hair-care', 'extensions', 'tools', 'accessories']
const CATEGORY_LABELS: Record<Product['category'] | 'all', string> = {
all: 'Tout',
'hair-care': 'Soins capillaires',
extensions: 'Extensions',
tools: 'Outils',
accessories: 'Accessoires',
}
function Shop() {
const [category, setCategory] = useState<Product['category'] | 'all'>('all')
const [inquiring, setInquiring] = useState<Product | null>(null)
@@ -14,7 +22,7 @@ function Shop() {
return (
<div className={styles.page}>
<h1>Shop</h1>
<h1>Boutique</h1>
<div className={styles.filters}>
{CATEGORIES.map((c) => (
<button
@@ -30,7 +38,7 @@ function Shop() {
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 350, damping: 30 }}
/>
)}
<span className={styles.filterLabel}>{c.replace('-', ' ')}</span>
<span className={styles.filterLabel}>{CATEGORY_LABELS[c]}</span>
</button>
))}
</div>