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:
35
src/components/StepProgress.module.css
Normal file
35
src/components/StepProgress.module.css
Normal 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);
|
||||
}
|
||||
27
src/components/StepProgress.test.tsx
Normal file
27
src/components/StepProgress.test.tsx
Normal 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/)
|
||||
})
|
||||
})
|
||||
35
src/components/StepProgress.tsx
Normal file
35
src/components/StepProgress.tsx
Normal 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
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user