feat: add Book page with the full mock-data booking flow

This commit is contained in:
belviskhoremk
2026-08-03 17:56:01 +00:00
parent 358b5121f0
commit c733a5afa9
5 changed files with 220 additions and 3 deletions

View File

@@ -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(<App initialEntries={['/book']} />)
expect(screen.getByTestId('page-book')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /book an appointment/i })).toBeInTheDocument()
})
it('renders the rules placeholder at /rules', () => {

View File

@@ -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() {
<Route element={<Layout />}>
<Route path="/" element={<div data-testid="page-home" />} />
<Route path="/shop" element={<Shop />} />
<Route path="/book" element={<div data-testid="page-book" />} />
<Route path="/book" element={<Book />} />
<Route path="/rules" element={<div data-testid="page-rules" />} />
</Route>
</Routes>

24
src/pages/Book.module.css Normal file
View File

@@ -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;
}

51
src/pages/Book.test.tsx Normal file
View File

@@ -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(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/book" element={<Book />} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})

141
src/pages/Book.tsx Normal file
View File

@@ -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 (
<div className={styles.page}>
<h1>You're booked!</h1>
<p>
{selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}.
Mira will see you then.
</p>
</div>
)
}
return (
<div className={styles.page}>
<h1>Book an Appointment</h1>
{booking.state.step === 'service' && (
<div className={styles.services}>
{services.map((service) => (
<ServiceCard key={service.id} service={service} onSelect={booking.selectService} />
))}
</div>
)}
{(booking.state.step === 'date' || booking.state.step === 'slot') && (
<>
<div className={styles.field}>
<label htmlFor="booking-date">Choose a date</label>
<input
id="booking-date"
type="date"
value={dateInput}
onChange={(e) => {
setDateInput(e.target.value)
booking.selectDate(e.target.value)
}}
/>
</div>
{dateInput && availableSlots.length === 0 && (
<div>
<p>No times available on this date.</p>
{nextAvailable && (
<button
type="button"
onClick={() => {
setDateInput(nextAvailable)
booking.selectDate(nextAvailable)
}}
>
Try {nextAvailable} instead
</button>
)}
</div>
)}
{availableSlots.length > 0 && (
<div className={styles.slots}>
{availableSlots.map((slot) => (
<button key={slot.time} type="button" onClick={() => booking.selectSlot(slot)}>
{slot.time}
</button>
))}
</div>
)}
</>
)}
{booking.state.step === 'contact' && (
<form
onSubmit={(e) => {
e.preventDefault()
booking.submit()
}}
>
<div className={styles.field}>
<label htmlFor="contact-name">Name</label>
<input
id="contact-name"
value={booking.state.contact.name}
onChange={(e) => booking.updateContact({ name: e.target.value })}
/>
{booking.errors.name && <span className={styles.error}>{booking.errors.name}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-email">Email</label>
<input
id="contact-email"
value={booking.state.contact.email}
onChange={(e) => booking.updateContact({ email: e.target.value })}
/>
{booking.errors.email && <span className={styles.error}>{booking.errors.email}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-phone">Phone</label>
<input
id="contact-phone"
value={booking.state.contact.phone}
onChange={(e) => booking.updateContact({ phone: e.target.value })}
/>
{booking.errors.phone && <span className={styles.error}>{booking.errors.phone}</span>}
</div>
<div className={styles.field}>
<label htmlFor="contact-notes">Notes</label>
<textarea
id="contact-notes"
value={booking.state.contact.notes}
onChange={(e) => booking.updateContact({ notes: e.target.value })}
/>
</div>
<button type="submit">Confirm Booking</button>
</form>
)}
</div>
)
}
export default Book