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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}>© {new Date().getFullYear()} Braids by Mira. All rights reserved.</p>
|
||||
<p className={styles.copyright}>© {new Date().getFullYear()} Braids by Mira. Tous droits réservés.</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user