From 801c4c028ffd17ddd9efb3232705764c4f145e28 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Mon, 3 Aug 2026 23:11:29 +0000 Subject: [PATCH] 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). --- src/components/StepProgress.module.css | 35 ++++ src/components/StepProgress.test.tsx | 27 +++ src/components/StepProgress.tsx | 35 ++++ src/pages/Book.test.tsx | 2 +- src/pages/Book.tsx | 217 +++++++++++++------------ 5 files changed, 211 insertions(+), 105 deletions(-) create mode 100644 src/components/StepProgress.module.css create mode 100644 src/components/StepProgress.test.tsx create mode 100644 src/components/StepProgress.tsx diff --git a/src/components/StepProgress.module.css b/src/components/StepProgress.module.css new file mode 100644 index 0000000..519886c --- /dev/null +++ b/src/components/StepProgress.module.css @@ -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); +} diff --git a/src/components/StepProgress.test.tsx b/src/components/StepProgress.test.tsx new file mode 100644 index 0000000..ca1726a --- /dev/null +++ b/src/components/StepProgress.test.tsx @@ -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() + 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() + 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() + const items = screen.getAllByRole('listitem') + expect(items[0].className).toMatch(/active/) + expect(items[1].className).not.toMatch(/active/) + }) +}) diff --git a/src/components/StepProgress.tsx b/src/components/StepProgress.tsx new file mode 100644 index 0000000..ef9a145 --- /dev/null +++ b/src/components/StepProgress.tsx @@ -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 ( +
    + {STEPS.map((step, index) => ( +
  1. + + {step.label} +
  2. + ))} +
+ ) +} + +export default StepProgress diff --git a/src/pages/Book.test.tsx b/src/pages/Book.test.tsx index 67bd182..412bb63 100644 --- a/src/pages/Book.test.tsx +++ b/src/pages/Book.test.tsx @@ -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 () => { diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx index 8312d0a..ecf18d0 100644 --- a/src/pages/Book.tsx +++ b/src/pages/Book.tsx @@ -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 ( -
-

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 && ( - - )} -
+ + + {booking.state.step === 'confirmed' && ( +

+ {selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}. + Mira will see you then. +

)} - {availableSlots.length > 0 && ( -
- {availableSlots.map((slot) => ( - + {booking.state.step === 'service' && ( +
+ {services.map((service) => ( + ))}
)} - - )} - {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}} -
-
- -