feat: add step progress indicator and animated transitions to Book

Adds StepProgress component showing 4 labeled booking steps (Service,
Date & Time, Contact, Confirmed) and wires it into Book.tsx alongside
framer-motion AnimatePresence/motion.div step transitions.

Deviates from literal brief in two ways, both to avoid known issues:
- Guards the transition with useReducedMotion() (duration: 0 when
  prefers-reduced-motion is set), matching the fix pattern already
  applied to Home.tsx and Shop.tsx after prior review round-trips.
- Drops AnimatePresence's mode="wait" in favor of the default (sync)
  mode: with mode="wait", the incoming step's DOM doesn't mount until
  the outgoing step's exit animation finishes, which broke existing
  synchronous Testing Library queries in Book.test.tsx (real 250ms
  exit animations in jsdom, no fake timers). Sync mode mounts the new
  step immediately while both fade, preserving test synchronicity.

Also updates Book.test.tsx's confirmation assertion since the old
"You're booked!" heading is gone (page now always shows the shared
"Book an Appointment" heading and StepProgress; only the body swaps).
This commit is contained in:
belviskhoremk
2026-08-03 23:11:29 +00:00
parent 3f82e759cb
commit 801c4c028f
5 changed files with 211 additions and 105 deletions

View File

@@ -0,0 +1,35 @@
.steps {
display: flex;
justify-content: center;
gap: var(--space-lg);
list-style: none;
padding: 0;
margin: 0 0 var(--space-lg);
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
opacity: 0.4;
transition: opacity 0.3s ease;
}
.step.active {
opacity: 1;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-gold);
}
.label {
font-size: 0.7rem;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--color-charcoal);
}

View File

@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import StepProgress from './StepProgress'
describe('StepProgress', () => {
it('marks steps up to and including the current one as active', () => {
render(<StepProgress currentStep="slot" />)
const items = screen.getAllByRole('listitem')
expect(items[0].className).toMatch(/active/) // Service
expect(items[1].className).toMatch(/active/) // Date & Time (slot maps here)
expect(items[2].className).not.toMatch(/active/) // Contact
expect(items[3].className).not.toMatch(/active/) // Confirmed
})
it('marks all steps active when confirmed', () => {
render(<StepProgress currentStep="confirmed" />)
const items = screen.getAllByRole('listitem')
items.forEach((item) => expect(item.className).toMatch(/active/))
})
it('marks only the first step active when on the service step', () => {
render(<StepProgress currentStep="service" />)
const items = screen.getAllByRole('listitem')
expect(items[0].className).toMatch(/active/)
expect(items[1].className).not.toMatch(/active/)
})
})

View File

@@ -0,0 +1,35 @@
import type { BookingStep } from '../hooks/useBooking'
import styles from './StepProgress.module.css'
const STEPS = [
{ key: 'service', label: 'Service' },
{ key: 'date', label: 'Date & Time' },
{ key: 'contact', label: 'Contact' },
{ key: 'confirmed', label: 'Confirmed' },
] as const
interface StepProgressProps {
currentStep: BookingStep
}
function stepIndex(step: BookingStep): number {
if (step === 'slot') return 1
return STEPS.findIndex((s) => s.key === step)
}
function StepProgress({ currentStep }: StepProgressProps) {
const activeIndex = stepIndex(currentStep)
return (
<ol className={styles.steps}>
{STEPS.map((step, index) => (
<li key={step.key} className={`${styles.step} ${index <= activeIndex ? styles.active : ''}`}>
<span className={styles.dot} />
<span className={styles.label}>{step.label}</span>
</li>
))}
</ol>
)
}
export default StepProgress

View File

@@ -38,7 +38,7 @@ describe('Book page', () => {
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()
expect(screen.getByText(/mira will see you then/i)).toBeInTheDocument()
})
it('shows a friendly empty state with the next available date when a chosen date has none', async () => {

View File

@@ -1,7 +1,9 @@
import { useLocation } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import useBooking from '../hooks/useBooking'
import ServiceCard from '../components/ServiceCard'
import StepProgress from '../components/StepProgress'
import { services } from '../data/services'
import { getSlotsForDate, getNextAvailableDate } from '../data/availability'
import styles from './Book.module.css'
@@ -11,6 +13,7 @@ function Book() {
const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId
const booking = useBooking(preselectedServiceId)
const [dateInput, setDateInput] = useState('')
const prefersReducedMotion = useReducedMotion()
const slots = useMemo(() => (dateInput ? getSlotsForDate(dateInput) : []), [dateInput])
const availableSlots = slots.filter((s) => s.available)
@@ -21,120 +24,126 @@ function Book() {
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>
<StepProgress currentStep={booking.state.step} />
{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}
min={new Date().toISOString().slice(0, 10)}
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>
<AnimatePresence>
<motion.div
key={booking.state.step}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.25 }}
>
{booking.state.step === 'confirmed' && (
<p>
{selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}.
Mira will see you then.
</p>
)}
{availableSlots.length > 0 && (
<div className={styles.slots}>
{availableSlots.map((slot) => (
<button key={slot.time} type="button" onClick={() => booking.selectSlot(slot)}>
{slot.time}
</button>
{booking.state.step === 'service' && (
<div className={styles.services}>
{services.map((service) => (
<ServiceCard key={service.id} service={service} onSelect={booking.selectService} />
))}
</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>
)}
{(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}
min={new Date().toISOString().slice(0, 10)}
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>
)}
</motion.div>
</AnimatePresence>
</div>
)
}