feat: add Book page with the full mock-data booking flow
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
24
src/pages/Book.module.css
Normal 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
51
src/pages/Book.test.tsx
Normal 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
141
src/pages/Book.tsx
Normal 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
|
||||
Reference in New Issue
Block a user