diff --git a/src/App.test.tsx b/src/App.test.tsx
index 6762ac8..2dab327 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -15,9 +15,9 @@ describe('App routing', () => {
expect(screen.getByRole('heading', { name: /shop/i })).toBeInTheDocument()
})
- it('renders the book placeholder at /book', () => {
+ it('renders the Book page at /book', () => {
render()
- expect(screen.getByTestId('page-book')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /book an appointment/i })).toBeInTheDocument()
})
it('renders the rules placeholder at /rules', () => {
diff --git a/src/App.tsx b/src/App.tsx
index 2db3b25..18240fa 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,7 @@
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Shop from './pages/Shop'
+import Book from './pages/Book'
interface AppProps {
initialEntries?: string[]
@@ -12,7 +13,7 @@ function AppRoutes() {
}>
} />
} />
- } />
+ } />
} />
diff --git a/src/pages/Book.module.css b/src/pages/Book.module.css
new file mode 100644
index 0000000..4ff70ea
--- /dev/null
+++ b/src/pages/Book.module.css
@@ -0,0 +1,24 @@
+.page {
+ padding: var(--space-xl) var(--space-lg);
+ max-width: 640px;
+ margin: 0 auto;
+}
+
+.services, .slots {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-md);
+ margin-top: var(--space-md);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin-bottom: var(--space-md);
+}
+
+.error {
+ color: #a0453a;
+ font-size: 0.8rem;
+}
diff --git a/src/pages/Book.test.tsx b/src/pages/Book.test.tsx
new file mode 100644
index 0000000..67bd182
--- /dev/null
+++ b/src/pages/Book.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Routes, Route } from 'react-router-dom'
+import { describe, it, expect } from 'vitest'
+import Book from './Book'
+
+function renderBook(initialEntries: { pathname: string; state?: unknown }[]) {
+ return render(
+
+
+ } />
+
+ ,
+ )
+}
+
+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(screen.getByLabelText(/choose a 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()
+ })
+
+ 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')
+ const slotButton = (await screen.findAllByRole('button', { name: /am|pm/i }))[0]
+ await userEvent.click(slotButton)
+
+ await userEvent.type(screen.getByLabelText(/^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 }))
+
+ expect(screen.getByText(/you're booked/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()
+ })
+})
diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx
new file mode 100644
index 0000000..2ad0a35
--- /dev/null
+++ b/src/pages/Book.tsx
@@ -0,0 +1,141 @@
+import { useLocation } from 'react-router-dom'
+import { useMemo, useState } from 'react'
+import useBooking from '../hooks/useBooking'
+import ServiceCard from '../components/ServiceCard'
+import { services } from '../data/services'
+import { getSlotsForDate, getNextAvailableDate } from '../data/availability'
+import styles from './Book.module.css'
+
+function Book() {
+ const location = useLocation()
+ const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId
+ const booking = useBooking(preselectedServiceId)
+ const [dateInput, setDateInput] = useState('')
+
+ const slots = useMemo(() => (dateInput ? getSlotsForDate(dateInput) : []), [dateInput])
+ const availableSlots = slots.filter((s) => s.available)
+ const nextAvailable = useMemo(
+ () => (dateInput && availableSlots.length === 0 ? getNextAvailableDate(dateInput) : null),
+ [dateInput, availableSlots.length],
+ )
+
+ const selectedService = services.find((s) => s.id === booking.state.serviceId)
+
+ if (booking.state.step === 'confirmed') {
+ return (
+
+
You're booked!
+
+ {selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}.
+ Mira will see you then.
+
+
+ )
+ }
+
+ return (
+
+
Book an Appointment
+
+ {booking.state.step === 'service' && (
+
+ {services.map((service) => (
+
+ ))}
+
+ )}
+
+ {(booking.state.step === 'date' || booking.state.step === 'slot') && (
+ <>
+
+
+ {
+ setDateInput(e.target.value)
+ booking.selectDate(e.target.value)
+ }}
+ />
+
+
+ {dateInput && availableSlots.length === 0 && (
+
+
No times available on this date.
+ {nextAvailable && (
+
+ )}
+
+ )}
+
+ {availableSlots.length > 0 && (
+
+ {availableSlots.map((slot) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {booking.state.step === 'contact' && (
+
+ )}
+
+ )
+}
+
+export default Book