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()
|
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']} />)
|
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', () => {
|
it('renders the rules placeholder at /rules', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Shop from './pages/Shop'
|
import Shop from './pages/Shop'
|
||||||
|
import Book from './pages/Book'
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
initialEntries?: string[]
|
initialEntries?: string[]
|
||||||
@@ -12,7 +13,7 @@ function AppRoutes() {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<div data-testid="page-home" />} />
|
<Route path="/" element={<div data-testid="page-home" />} />
|
||||||
<Route path="/shop" element={<Shop />} />
|
<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 path="/rules" element={<div data-testid="page-rules" />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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