From c733a5afa9a8f2dd7a81715c2be74aa59fe1b81f Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Mon, 3 Aug 2026 17:56:01 +0000 Subject: [PATCH] feat: add Book page with the full mock-data booking flow --- src/App.test.tsx | 4 +- src/App.tsx | 3 +- src/pages/Book.module.css | 24 +++++++ src/pages/Book.test.tsx | 51 ++++++++++++++ src/pages/Book.tsx | 141 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 src/pages/Book.module.css create mode 100644 src/pages/Book.test.tsx create mode 100644 src/pages/Book.tsx 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' && ( +
{ + e.preventDefault() + booking.submit() + }} + > +
+ + booking.updateContact({ name: e.target.value })} + /> + {booking.errors.name && {booking.errors.name}} +
+
+ + booking.updateContact({ email: e.target.value })} + /> + {booking.errors.email && {booking.errors.email}} +
+
+ + booking.updateContact({ phone: e.target.value })} + /> + {booking.errors.phone && {booking.errors.phone}} +
+
+ +