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}} -
-
- -