2606 lines
79 KiB
Markdown
2606 lines
79 KiB
Markdown
# Braids by Mira — Phase 1 (Public Site) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the public-facing Braids by Mira site — Home, Shop (product showcase), Book (mock-data booking flow), and Rules pages — in the "Elegant Editorial" visual style with scroll-driven motion, on top of the existing Vite + React 19 + TypeScript scaffold.
|
||
|
||
**Architecture:** A single-page React app with client-side routing (`react-router-dom`) rendering four routed pages inside a shared `Layout` (Nav + Footer). All content (products, services, availability, rules copy) comes from typed local mock-data modules under `src/data/`, shaped so a future backend can replace them without touching component code. Motion (scroll reveals, hero rotation, hover tilt) is built with `framer-motion` and respects `prefers-reduced-motion`.
|
||
|
||
**Tech Stack:** Vite, React 19, TypeScript, react-router-dom v7, framer-motion, Vitest + React Testing Library + jsdom, plain CSS (CSS variables + CSS Modules, no utility framework).
|
||
|
||
## Global Constraints
|
||
|
||
- Palette: cream `#F7F3EC` (primary bg), `#EFE7D8` (secondary bg), charcoal `#3A332E` (ink), antique gold `#B08D57` (accent) — from the spec's Visual System section.
|
||
- Type: Georgia/serif for labels/nav/body; a script webfont ("Great Vibes") for headline moments only.
|
||
- No cart/checkout — Shop uses an "Ask about this" inquiry modal instead, per spec.
|
||
- No real backend/network calls anywhere in Phase 1 — all data is local mock data; booking/inquiry "submission" just transitions UI state to a confirmation screen.
|
||
- All motion must respect `prefers-reduced-motion` (instant fades instead of parallax/rotation/stagger).
|
||
- Every component/hook task includes tests written before implementation (Vitest + React Testing Library).
|
||
|
||
---
|
||
|
||
### Task 1: Tooling setup — Vitest, routing, motion, and CSS tokens
|
||
|
||
**Files:**
|
||
- Modify: `package.json` (new deps)
|
||
- Create: `vite.config.ts` (add `test` config)
|
||
- Create: `src/test/setup.ts`
|
||
- Create: `src/styles/tokens.css`
|
||
- Modify: `index.html` (title, Google Fonts link for "Great Vibes")
|
||
- Modify: `src/index.css` (replace default theme vars with tokens import + reset)
|
||
- Delete: `src/App.css`, `src/assets/react.svg`, `src/assets/vite.svg`, `src/assets/hero.png`
|
||
- Modify: `src/App.tsx` (temporary minimal router shell, replaced page-by-page in later tasks)
|
||
- Test: `src/App.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: CSS variables available globally — `--color-cream`, `--color-cream-alt`, `--color-charcoal`, `--color-gold`, `--font-serif`, `--font-script` (defined in `src/styles/tokens.css`, imported once from `src/index.css`).
|
||
- Produces: `App.tsx` exports default `App` component that renders a `BrowserRouter` with routes `/`, `/shop`, `/book`, `/rules`, each currently rendering a placeholder `<div>` with a unique text marker (`data-testid="page-home"` etc.) — later tasks swap each placeholder for the real page component.
|
||
|
||
- [ ] **Step 1: Install dependencies**
|
||
|
||
```bash
|
||
npm install react-router-dom framer-motion
|
||
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
|
||
```
|
||
|
||
- [ ] **Step 2: Add Vitest config to `vite.config.ts`**
|
||
|
||
```ts
|
||
/// <reference types="vitest/config" />
|
||
import { defineConfig } from 'vite'
|
||
import react from '@vitejs/plugin-react'
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
environment: 'jsdom',
|
||
globals: true,
|
||
setupFiles: ['./src/test/setup.ts'],
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Create the test setup file**
|
||
|
||
`src/test/setup.ts`:
|
||
|
||
```ts
|
||
import '@testing-library/jest-dom/vitest'
|
||
```
|
||
|
||
- [ ] **Step 4: Add a `test` script to `package.json`**
|
||
|
||
Add under `"scripts"`: `"test": "vitest run"`
|
||
|
||
- [ ] **Step 5: Create the design token stylesheet**
|
||
|
||
`src/styles/tokens.css`:
|
||
|
||
```css
|
||
:root {
|
||
--color-cream: #F7F3EC;
|
||
--color-cream-alt: #EFE7D8;
|
||
--color-charcoal: #3A332E;
|
||
--color-gold: #B08D57;
|
||
|
||
--font-serif: Georgia, 'Times New Roman', serif;
|
||
--font-script: 'Great Vibes', cursive;
|
||
|
||
--space-sm: 0.5rem;
|
||
--space-md: 1rem;
|
||
--space-lg: 2rem;
|
||
--space-xl: 4rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Replace `src/index.css` with the reset + token import**
|
||
|
||
```css
|
||
@import './styles/tokens.css';
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html, body {
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
background: var(--color-cream);
|
||
color: var(--color-charcoal);
|
||
font-family: var(--font-serif);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
a {
|
||
color: inherit;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Delete unused boilerplate files**
|
||
|
||
```bash
|
||
rm src/App.css src/assets/react.svg src/assets/vite.svg src/assets/hero.png
|
||
```
|
||
|
||
- [ ] **Step 8: Add the Google Fonts link to `index.html`** and update the title
|
||
|
||
In `<head>`, replace the existing `<title>` and add before it:
|
||
|
||
```html
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet">
|
||
<title>Braids by Mira</title>
|
||
```
|
||
|
||
- [ ] **Step 9: Write the failing router shell test**
|
||
|
||
`src/App.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { MemoryRouter } from 'react-router-dom'
|
||
import { describe, it, expect } from 'vitest'
|
||
import App from './App'
|
||
|
||
describe('App routing', () => {
|
||
it('renders the home placeholder at /', () => {
|
||
render(<App initialEntries={['/']} />)
|
||
expect(screen.getByTestId('page-home')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the shop placeholder at /shop', () => {
|
||
render(<App initialEntries={['/shop']} />)
|
||
expect(screen.getByTestId('page-shop')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the book placeholder at /book', () => {
|
||
render(<App initialEntries={['/book']} />)
|
||
expect(screen.getByTestId('page-book')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the rules placeholder at /rules', () => {
|
||
render(<App initialEntries={['/rules']} />)
|
||
expect(screen.getByTestId('page-rules')).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
Note: to make `App` accept `initialEntries` for testing, `App.tsx` will take an optional prop and use `MemoryRouter` in tests vs `BrowserRouter` in production — see Step 11.
|
||
|
||
- [ ] **Step 10: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/App.test.tsx`
|
||
Expected: FAIL (`App` doesn't exist yet / no matching testid)
|
||
|
||
- [ ] **Step 11: Write the minimal router shell**
|
||
|
||
`src/App.tsx`:
|
||
|
||
```tsx
|
||
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
|
||
|
||
interface AppProps {
|
||
initialEntries?: string[]
|
||
}
|
||
|
||
function AppRoutes() {
|
||
return (
|
||
<Routes>
|
||
<Route path="/" element={<div data-testid="page-home" />} />
|
||
<Route path="/shop" element={<div data-testid="page-shop" />} />
|
||
<Route path="/book" element={<div data-testid="page-book" />} />
|
||
<Route path="/rules" element={<div data-testid="page-rules" />} />
|
||
</Routes>
|
||
)
|
||
}
|
||
|
||
function App({ initialEntries }: AppProps) {
|
||
if (initialEntries) {
|
||
return (
|
||
<MemoryRouter initialEntries={initialEntries}>
|
||
<AppRoutes />
|
||
</MemoryRouter>
|
||
)
|
||
}
|
||
return (
|
||
<BrowserRouter>
|
||
<AppRoutes />
|
||
</BrowserRouter>
|
||
)
|
||
}
|
||
|
||
export default App
|
||
```
|
||
|
||
- [ ] **Step 12: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/App.test.tsx`
|
||
Expected: PASS (4 tests)
|
||
|
||
- [ ] **Step 13: Verify the app still builds and runs**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds with no TypeScript errors
|
||
|
||
- [ ] **Step 14: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: set up Vitest, routing, and design tokens"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Mock data modules — products, services, availability, rules
|
||
|
||
**Files:**
|
||
- Create: `src/data/products.ts`
|
||
- Create: `src/data/services.ts`
|
||
- Create: `src/data/availability.ts`
|
||
- Create: `src/data/rules.ts`
|
||
- Test: `src/data/products.test.ts`
|
||
- Test: `src/data/availability.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Product` type + `products: Product[]` + `getProductsByCategory(category: Product['category'] | 'all'): Product[]` from `src/data/products.ts`
|
||
- Produces: `Service` type + `services: Service[]` from `src/data/services.ts`
|
||
- Produces: `TimeSlot` type + `getSlotsForDate(date: string): TimeSlot[]` + `getNextAvailableDate(fromDate: string): string | null` from `src/data/availability.ts`
|
||
- Produces: `RuleSection` type + `rules: RuleSection[]` from `src/data/rules.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests for products data**
|
||
|
||
`src/data/products.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import { products, getProductsByCategory } from './products'
|
||
|
||
describe('products data', () => {
|
||
it('has at least 6 products with required fields', () => {
|
||
expect(products.length).toBeGreaterThanOrEqual(6)
|
||
for (const p of products) {
|
||
expect(p.id).toBeTruthy()
|
||
expect(p.name).toBeTruthy()
|
||
expect(p.price).toBeGreaterThan(0)
|
||
expect(typeof p.inStock).toBe('boolean')
|
||
}
|
||
})
|
||
|
||
it('filters by category', () => {
|
||
const hairCare = getProductsByCategory('hair-care')
|
||
expect(hairCare.length).toBeGreaterThan(0)
|
||
expect(hairCare.every((p) => p.category === 'hair-care')).toBe(true)
|
||
})
|
||
|
||
it('returns all products for "all"', () => {
|
||
expect(getProductsByCategory('all')).toHaveLength(products.length)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/data/products.test.ts`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement products data**
|
||
|
||
`src/data/products.ts`:
|
||
|
||
```ts
|
||
export interface Product {
|
||
id: string
|
||
name: string
|
||
category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
|
||
price: number
|
||
image: string
|
||
description: string
|
||
inStock: boolean
|
||
}
|
||
|
||
export const products: Product[] = [
|
||
{ id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care', price: 12, image: '/products/edge-control.jpg', description: 'Long-lasting hold with a natural shine, no flaking.', inStock: true },
|
||
{ id: 'braid-oil', name: 'Nourishing Braid Oil', category: 'hair-care', price: 16, image: '/products/braid-oil.jpg', description: 'Lightweight scalp oil that soothes and adds shine between washes.', inStock: true },
|
||
{ id: 'leave-in', name: 'Detangling Leave-In', category: 'hair-care', price: 14, image: '/products/leave-in.jpg', description: 'Slip-rich leave-in conditioner for gentle detangling.', inStock: true },
|
||
{ id: 'kanekalon-black', name: 'Kanekalon Braiding Hair — Jet Black', category: 'extensions', price: 8, image: '/products/kanekalon-black.jpg', description: 'Premium synthetic braiding hair, soft texture, low shedding.', inStock: true },
|
||
{ id: 'kanekalon-brown', name: 'Kanekalon Braiding Hair — Chestnut', category: 'extensions', price: 8, image: '/products/kanekalon-brown.jpg', description: 'Warm brown tone, blends seamlessly for natural styles.', inStock: false },
|
||
{ id: 'locs-extension', name: 'Faux Locs Extensions', category: 'extensions', price: 22, image: '/products/locs-extension.jpg', description: 'Pre-looped faux locs for a natural, lightweight install.', inStock: true },
|
||
{ id: 'rat-tail-comb', name: 'Steel Rat-Tail Comb', category: 'tools', price: 6, image: '/products/rat-tail-comb.jpg', description: 'Precision parting comb with a fine metal tail.', inStock: true },
|
||
{ id: 'edge-brush', name: 'Mini Edge Brush', category: 'tools', price: 5, image: '/products/edge-brush.jpg', description: 'Dual-sided brush for laying edges and smoothing baby hairs.', inStock: true },
|
||
{ id: 'gold-cuffs', name: 'Gold Hair Cuffs (Set of 6)', category: 'accessories', price: 10, image: '/products/gold-cuffs.jpg', description: 'Adjustable cuffs to accessorize braids and locs.', inStock: true },
|
||
{ id: 'satin-bonnet', name: 'Satin Sleep Bonnet', category: 'accessories', price: 9, image: '/products/satin-bonnet.jpg', description: 'Protects braids and locs overnight, reduces frizz.', inStock: true },
|
||
]
|
||
|
||
export function getProductsByCategory(category: Product['category'] | 'all'): Product[] {
|
||
if (category === 'all') return products
|
||
return products.filter((p) => p.category === category)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/data/products.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Implement services data (no test file — simple static data, covered indirectly by Home/Book tests in later tasks)**
|
||
|
||
`src/data/services.ts`:
|
||
|
||
```ts
|
||
export interface Service {
|
||
id: string
|
||
name: string
|
||
durationMinutes: number
|
||
priceFrom: number
|
||
description: string
|
||
image: string
|
||
}
|
||
|
||
export const services: Service[] = [
|
||
{ id: 'knotless', name: 'Knotless Braids', durationMinutes: 240, priceFrom: 150, description: 'Tension-free braids with a natural, seamless root.', image: '/styles/knotless.jpg' },
|
||
{ id: 'boho', name: 'Boho Braids', durationMinutes: 270, priceFrom: 180, description: 'Knotless braids with curly pieces woven in for a soft, romantic look.', image: '/styles/boho.jpg' },
|
||
{ id: 'locs', name: 'Faux Locs', durationMinutes: 300, priceFrom: 200, description: 'Lightweight, natural-looking locs installed with care.', image: '/styles/locs.jpg' },
|
||
{ id: 'cornrows', name: 'Cornrows', durationMinutes: 120, priceFrom: 90, description: 'Classic sleek cornrows, straight-back or with a custom design.', image: '/styles/cornrows.jpg' },
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 6: Write failing tests for availability data**
|
||
|
||
`src/data/availability.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import { getSlotsForDate, getNextAvailableDate } from './availability'
|
||
|
||
describe('availability data', () => {
|
||
it('returns a list of time slots for a given date', () => {
|
||
const slots = getSlotsForDate('2026-08-10')
|
||
expect(slots.length).toBeGreaterThan(0)
|
||
for (const s of slots) {
|
||
expect(s.date).toBe('2026-08-10')
|
||
expect(typeof s.time).toBe('string')
|
||
expect(typeof s.available).toBe('boolean')
|
||
}
|
||
})
|
||
|
||
it('is deterministic for the same date', () => {
|
||
expect(getSlotsForDate('2026-08-10')).toEqual(getSlotsForDate('2026-08-10'))
|
||
})
|
||
|
||
it('finds the next available date within 14 days when none is available immediately', () => {
|
||
// Sundays (day 0) are fully booked in the mock generator
|
||
const next = getNextAvailableDate('2026-08-09') // a Sunday
|
||
expect(next).not.toBeNull()
|
||
expect(next).not.toBe('2026-08-09')
|
||
})
|
||
|
||
it('returns null if no available date is found within the search window', () => {
|
||
expect(getNextAvailableDate('1999-01-01', 0)).toBeNull()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 7: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/data/availability.test.ts`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 8: Implement availability data**
|
||
|
||
`src/data/availability.ts`:
|
||
|
||
```ts
|
||
export interface TimeSlot {
|
||
date: string // ISO date, e.g. "2026-08-10"
|
||
time: string // e.g. "10:00 AM"
|
||
available: boolean
|
||
}
|
||
|
||
const ALL_TIMES = ['9:00 AM', '11:00 AM', '1:00 PM', '3:00 PM', '5:00 PM']
|
||
|
||
function seedFromDate(date: string): number {
|
||
let seed = 0
|
||
for (let i = 0; i < date.length; i++) seed += date.charCodeAt(i)
|
||
return seed
|
||
}
|
||
|
||
export function getSlotsForDate(date: string): TimeSlot[] {
|
||
const day = new Date(`${date}T00:00:00`).getDay()
|
||
if (day === 0) {
|
||
// Sundays: closed, no slots at all
|
||
return []
|
||
}
|
||
const seed = seedFromDate(date)
|
||
return ALL_TIMES.map((time, i) => ({
|
||
date,
|
||
time,
|
||
available: (seed + i) % 3 !== 0,
|
||
}))
|
||
}
|
||
|
||
export function getNextAvailableDate(fromDate: string, searchWindowDays = 14): string | null {
|
||
const start = new Date(`${fromDate}T00:00:00`)
|
||
for (let i = 1; i <= searchWindowDays; i++) {
|
||
const candidate = new Date(start)
|
||
candidate.setDate(start.getDate() + i)
|
||
const iso = candidate.toISOString().slice(0, 10)
|
||
if (getSlotsForDate(iso).some((s) => s.available)) {
|
||
return iso
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/data/availability.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 10: Implement rules data (no dedicated test — content is exercised by the Rules page test in Task 9)**
|
||
|
||
`src/data/rules.ts`:
|
||
|
||
```ts
|
||
export interface RuleSection {
|
||
id: string
|
||
title: string
|
||
body: string
|
||
}
|
||
|
||
export const rules: RuleSection[] = [
|
||
{ id: 'deposit', title: 'Deposit', body: 'A non-refundable deposit of $30 is required to secure your appointment and will be deducted from your final total. Appointments are not confirmed until the deposit is received.' },
|
||
{ id: 'cancellation', title: 'Cancellation & Lateness', body: 'Please give at least 48 hours notice to reschedule. Arrivals more than 15 minutes late may need to be rescheduled and will forfeit their deposit, as it affects every client booked after you.' },
|
||
{ id: 'guests', title: 'Guests & Kids', body: 'To keep the space calm and on schedule, please come to your appointment alone unless the guest is also being serviced. Children who are not being serviced should not accompany you.' },
|
||
{ id: 'hair-prep', title: 'Hair Prep', body: 'Arrive with hair freshly washed, blow-dried or stretched, and fully detangled unless a wash service has been booked. Extra prep time may be added to your total if hair arrives unprepped.' },
|
||
{ id: 'health-safety', title: 'Health & Safety', body: 'Please reschedule if you are unwell. Let Mira know in advance of any scalp sensitivities, allergies, or conditions that may affect your service.' },
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 11: Run the full data test suite**
|
||
|
||
Run: `npx vitest run src/data`
|
||
Expected: all PASS
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add src/data
|
||
git commit -m "feat: add mock data for products, services, availability, and rules"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `ScrollReveal` component
|
||
|
||
**Files:**
|
||
- Create: `src/components/ScrollReveal.tsx`
|
||
- Test: `src/components/ScrollReveal.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing from earlier tasks
|
||
- Produces: `ScrollReveal` component — `<ScrollReveal delay?: number>{children}</ScrollReveal>` — wraps children in a `framer-motion` `motion.div` that fades + rises into view on scroll, and renders children plainly (no animation props) when `prefers-reduced-motion: reduce` is set. Used by Home/Shop/Book/Rules sections in later tasks.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/components/ScrollReveal.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||
import ScrollReveal from './ScrollReveal'
|
||
|
||
function mockMatchMedia(reduced: boolean) {
|
||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||
matches: reduced && query.includes('prefers-reduced-motion'),
|
||
media: query,
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}))
|
||
}
|
||
|
||
describe('ScrollReveal', () => {
|
||
afterEach(() => vi.restoreAllMocks())
|
||
|
||
it('renders its children', () => {
|
||
mockMatchMedia(false)
|
||
render(<ScrollReveal><p>Hello</p></ScrollReveal>)
|
||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders children unanimated when reduced motion is preferred', () => {
|
||
mockMatchMedia(true)
|
||
render(<ScrollReveal><p data-testid="content">Hello</p></ScrollReveal>)
|
||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/ScrollReveal.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `ScrollReveal`**
|
||
|
||
`src/components/ScrollReveal.tsx`:
|
||
|
||
```tsx
|
||
import type { ReactNode } from 'react'
|
||
import { motion, useReducedMotion } from 'framer-motion'
|
||
|
||
interface ScrollRevealProps {
|
||
children: ReactNode
|
||
delay?: number
|
||
}
|
||
|
||
function ScrollReveal({ children, delay = 0 }: ScrollRevealProps) {
|
||
const prefersReducedMotion = useReducedMotion()
|
||
|
||
if (prefersReducedMotion) {
|
||
return <>{children}</>
|
||
}
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 24 }}
|
||
whileInView={{ opacity: 1, y: 0 }}
|
||
viewport={{ once: true, amount: 0.3 }}
|
||
transition={{ duration: 0.6, delay, ease: 'easeOut' }}
|
||
>
|
||
{children}
|
||
</motion.div>
|
||
)
|
||
}
|
||
|
||
export default ScrollReveal
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/ScrollReveal.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/ScrollReveal.tsx src/components/ScrollReveal.test.tsx
|
||
git commit -m "feat: add ScrollReveal motion wrapper"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `Nav` and `Footer` components
|
||
|
||
**Files:**
|
||
- Create: `src/components/Nav.tsx`, `src/components/Nav.module.css`
|
||
- Create: `src/components/Footer.tsx`, `src/components/Footer.module.css`
|
||
- Test: `src/components/Nav.test.tsx`
|
||
- Test: `src/components/Footer.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Nav` component (no props) — renders the logo/wordmark and links to `/`, `/shop`, `/book`, `/rules`; adds a `condensed` class to its root once `window.scrollY > 40`.
|
||
- Produces: `Footer` component (no props) — renders contact info, hours, and quick links to the four routes.
|
||
- Both must be rendered inside a `MemoryRouter` (or `BrowserRouter`) in tests and in `Layout` (Task 5) since they use `Link`.
|
||
|
||
- [ ] **Step 1: Write the failing Nav test**
|
||
|
||
`src/components/Nav.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent } from '@testing-library/react'
|
||
import { MemoryRouter } from 'react-router-dom'
|
||
import { describe, it, expect } from 'vitest'
|
||
import Nav from './Nav'
|
||
|
||
describe('Nav', () => {
|
||
it('renders links to all four routes', () => {
|
||
render(<MemoryRouter><Nav /></MemoryRouter>)
|
||
expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/')
|
||
expect(screen.getByRole('link', { name: /shop/i })).toHaveAttribute('href', '/shop')
|
||
expect(screen.getByRole('link', { name: /book/i })).toHaveAttribute('href', '/book')
|
||
expect(screen.getByRole('link', { name: /rules/i })).toHaveAttribute('href', '/rules')
|
||
})
|
||
|
||
it('adds a condensed class after scrolling', () => {
|
||
render(<MemoryRouter><Nav /></MemoryRouter>)
|
||
const nav = screen.getByRole('navigation')
|
||
expect(nav.className).not.toMatch(/condensed/)
|
||
Object.defineProperty(window, 'scrollY', { value: 80, configurable: true })
|
||
fireEvent.scroll(window)
|
||
expect(nav.className).toMatch(/condensed/)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/Nav.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `Nav`**
|
||
|
||
`src/components/Nav.module.css`:
|
||
|
||
```css
|
||
.nav {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: var(--space-md) var(--space-lg);
|
||
font-family: var(--font-serif);
|
||
letter-spacing: 2px;
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
background: var(--color-cream);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
transition: padding 0.3s ease, box-shadow 0.3s ease;
|
||
}
|
||
|
||
.nav.condensed {
|
||
padding: var(--space-sm) var(--space-lg);
|
||
box-shadow: 0 2px 12px rgba(58, 51, 46, 0.08);
|
||
}
|
||
|
||
.links {
|
||
display: flex;
|
||
gap: var(--space-lg);
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.links a {
|
||
text-decoration: none;
|
||
color: var(--color-charcoal);
|
||
}
|
||
```
|
||
|
||
`src/components/Nav.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import styles from './Nav.module.css'
|
||
|
||
function Nav() {
|
||
const [condensed, setCondensed] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const onScroll = () => setCondensed(window.scrollY > 40)
|
||
window.addEventListener('scroll', onScroll)
|
||
return () => window.removeEventListener('scroll', onScroll)
|
||
}, [])
|
||
|
||
return (
|
||
<nav className={`${styles.nav} ${condensed ? styles.condensed : ''}`}>
|
||
<span>Braids by Mira</span>
|
||
<ul className={styles.links}>
|
||
<li><Link to="/">Home</Link></li>
|
||
<li><Link to="/shop">Shop</Link></li>
|
||
<li><Link to="/book">Book</Link></li>
|
||
<li><Link to="/rules">Rules</Link></li>
|
||
</ul>
|
||
</nav>
|
||
)
|
||
}
|
||
|
||
export default Nav
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/Nav.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing Footer test**
|
||
|
||
`src/components/Footer.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { MemoryRouter } from 'react-router-dom'
|
||
import { describe, it, expect } from 'vitest'
|
||
import Footer from './Footer'
|
||
|
||
describe('Footer', () => {
|
||
it('renders contact info and quick links', () => {
|
||
render(<MemoryRouter><Footer /></MemoryRouter>)
|
||
expect(screen.getByText(/braids by mira/i)).toBeInTheDocument()
|
||
expect(screen.getByRole('link', { name: /shop/i })).toHaveAttribute('href', '/shop')
|
||
expect(screen.getByRole('link', { name: /rules/i })).toHaveAttribute('href', '/rules')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/Footer.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 7: Implement `Footer`**
|
||
|
||
`src/components/Footer.module.css`:
|
||
|
||
```css
|
||
.footer {
|
||
background: var(--color-charcoal);
|
||
color: var(--color-cream);
|
||
padding: var(--space-xl) var(--space-lg);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
gap: var(--space-lg);
|
||
font-family: var(--font-serif);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.footer a {
|
||
color: var(--color-cream);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.links {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
list-style: none;
|
||
padding: 0;
|
||
}
|
||
```
|
||
|
||
`src/components/Footer.tsx`:
|
||
|
||
```tsx
|
||
import { Link } from 'react-router-dom'
|
||
import styles from './Footer.module.css'
|
||
|
||
function Footer() {
|
||
return (
|
||
<footer className={styles.footer}>
|
||
<div>
|
||
<p>Braids by Mira</p>
|
||
<p>hello@braidsbymira.com</p>
|
||
<p>Tue–Sat, 9am–6pm</p>
|
||
</div>
|
||
<ul className={styles.links}>
|
||
<li><Link to="/">Home</Link></li>
|
||
<li><Link to="/shop">Shop</Link></li>
|
||
<li><Link to="/book">Book</Link></li>
|
||
<li><Link to="/rules">Rules</Link></li>
|
||
</ul>
|
||
</footer>
|
||
)
|
||
}
|
||
|
||
export default Footer
|
||
```
|
||
|
||
- [ ] **Step 8: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/Footer.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/components/Nav.tsx src/components/Nav.module.css src/components/Nav.test.tsx \
|
||
src/components/Footer.tsx src/components/Footer.module.css src/components/Footer.test.tsx
|
||
git commit -m "feat: add Nav and Footer components"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `Layout` component and wire it into `App.tsx`
|
||
|
||
**Files:**
|
||
- Create: `src/components/Layout.tsx`
|
||
- Modify: `src/App.tsx`
|
||
- Modify: `src/App.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Nav` and `Footer` from Task 4
|
||
- Produces: `Layout` component — renders `<Nav />`, `<Outlet />` (from `react-router-dom`), then `<Footer />` — used as the parent route element in `App.tsx`.
|
||
|
||
- [ ] **Step 1: Update the App routing test for the layout wrapper**
|
||
|
||
Modify `src/App.test.tsx` to assert Nav/Footer render on every route:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, it, expect } from 'vitest'
|
||
import App from './App'
|
||
|
||
describe('App routing', () => {
|
||
it('renders the home placeholder at / with nav and footer', () => {
|
||
render(<App initialEntries={['/']} />)
|
||
expect(screen.getByTestId('page-home')).toBeInTheDocument()
|
||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||
expect(screen.getByText(/hello@braidsbymira.com/)).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the shop placeholder at /shop', () => {
|
||
render(<App initialEntries={['/shop']} />)
|
||
expect(screen.getByTestId('page-shop')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the book placeholder at /book', () => {
|
||
render(<App initialEntries={['/book']} />)
|
||
expect(screen.getByTestId('page-book')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the rules placeholder at /rules', () => {
|
||
render(<App initialEntries={['/rules']} />)
|
||
expect(screen.getByTestId('page-rules')).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/App.test.tsx`
|
||
Expected: FAIL (no `navigation` role / no footer text yet — placeholders render bare)
|
||
|
||
- [ ] **Step 3: Implement `Layout`**
|
||
|
||
`src/components/Layout.tsx`:
|
||
|
||
```tsx
|
||
import { Outlet } from 'react-router-dom'
|
||
import Nav from './Nav'
|
||
import Footer from './Footer'
|
||
|
||
function Layout() {
|
||
return (
|
||
<>
|
||
<Nav />
|
||
<Outlet />
|
||
<Footer />
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default Layout
|
||
```
|
||
|
||
- [ ] **Step 4: Wire `Layout` into `App.tsx`**
|
||
|
||
`src/App.tsx`:
|
||
|
||
```tsx
|
||
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
|
||
import Layout from './components/Layout'
|
||
|
||
interface AppProps {
|
||
initialEntries?: string[]
|
||
}
|
||
|
||
function AppRoutes() {
|
||
return (
|
||
<Routes>
|
||
<Route element={<Layout />}>
|
||
<Route path="/" element={<div data-testid="page-home" />} />
|
||
<Route path="/shop" element={<div data-testid="page-shop" />} />
|
||
<Route path="/book" element={<div data-testid="page-book" />} />
|
||
<Route path="/rules" element={<div data-testid="page-rules" />} />
|
||
</Route>
|
||
</Routes>
|
||
)
|
||
}
|
||
|
||
function App({ initialEntries }: AppProps) {
|
||
if (initialEntries) {
|
||
return (
|
||
<MemoryRouter initialEntries={initialEntries}>
|
||
<AppRoutes />
|
||
</MemoryRouter>
|
||
)
|
||
}
|
||
return (
|
||
<BrowserRouter>
|
||
<AppRoutes />
|
||
</BrowserRouter>
|
||
)
|
||
}
|
||
|
||
export default App
|
||
```
|
||
|
||
- [ ] **Step 5: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/App.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/App.tsx src/App.test.tsx src/components/Layout.tsx
|
||
git commit -m "feat: add shared Layout wrapping Nav and Footer around all routes"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `Hero3D` component
|
||
|
||
**Files:**
|
||
- Create: `src/components/Hero3D.tsx`, `src/components/Hero3D.module.css`
|
||
- Test: `src/components/Hero3D.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Hero3D` component (no props) — renders a `data-testid="hero-3d"` container with a rotating crown/braid motif built from CSS 3D transforms, whose rotation is driven by scroll position via `framer-motion`'s `useScroll`/`useTransform`. When `prefers-reduced-motion: reduce`, renders the motif statically (no scroll-linked transform) with class `static`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/components/Hero3D.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||
import Hero3D from './Hero3D'
|
||
|
||
function mockMatchMedia(reduced: boolean) {
|
||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||
matches: reduced && query.includes('prefers-reduced-motion'),
|
||
media: query,
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
}))
|
||
}
|
||
|
||
describe('Hero3D', () => {
|
||
afterEach(() => vi.restoreAllMocks())
|
||
|
||
it('renders the motif container', () => {
|
||
mockMatchMedia(false)
|
||
render(<Hero3D />)
|
||
expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders statically when reduced motion is preferred', () => {
|
||
mockMatchMedia(true)
|
||
render(<Hero3D />)
|
||
expect(screen.getByTestId('hero-3d').className).toMatch(/static/)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/Hero3D.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `Hero3D`**
|
||
|
||
`src/components/Hero3D.module.css`:
|
||
|
||
```css
|
||
.wrapper {
|
||
perspective: 900px;
|
||
width: 220px;
|
||
height: 220px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.motif {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 30% 30%, #e6c98f, var(--color-gold));
|
||
transform-style: preserve-3d;
|
||
}
|
||
|
||
.static {
|
||
composes: motif;
|
||
}
|
||
```
|
||
|
||
`src/components/Hero3D.tsx`:
|
||
|
||
```tsx
|
||
import { motion, useReducedMotion, useScroll, useTransform } from 'framer-motion'
|
||
import styles from './Hero3D.module.css'
|
||
|
||
function Hero3D() {
|
||
const prefersReducedMotion = useReducedMotion()
|
||
const { scrollY } = useScroll()
|
||
const rotateY = useTransform(scrollY, [0, 600], [0, 180])
|
||
const rotateX = useTransform(scrollY, [0, 600], [0, 20])
|
||
|
||
if (prefersReducedMotion) {
|
||
return (
|
||
<div className={styles.wrapper} data-testid="hero-3d">
|
||
<div className={`${styles.motif} static`} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={styles.wrapper} data-testid="hero-3d">
|
||
<motion.div className={styles.motif} style={{ rotateY, rotateX }} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Hero3D
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/Hero3D.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/Hero3D.tsx src/components/Hero3D.module.css src/components/Hero3D.test.tsx
|
||
git commit -m "feat: add Hero3D scroll-driven rotating motif"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `ProductCard` and `ServiceCard` components
|
||
|
||
**Files:**
|
||
- Create: `src/components/ProductCard.tsx`, `src/components/ProductCard.module.css`
|
||
- Create: `src/components/ServiceCard.tsx`, `src/components/ServiceCard.module.css`
|
||
- Test: `src/components/ProductCard.test.tsx`
|
||
- Test: `src/components/ServiceCard.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Product` type from `src/data/products.ts`, `Service` type from `src/data/services.ts`
|
||
- Produces: `ProductCard` — props `{ product: Product; onInquire: (product: Product) => void }`; renders name, price (formatted `$NN`), description, and an "Ask about this" button that is `disabled` when `!product.inStock` and calls `onInquire(product)` on click.
|
||
- Produces: `ServiceCard` — props `{ service: Service; onSelect?: (serviceId: string) => void }`; renders name, duration (formatted `Xh` or `Xh Ym`), "From $NN", and calls `onSelect(service.id)` on click if provided.
|
||
|
||
- [ ] **Step 1: Write the failing ProductCard test**
|
||
|
||
`src/components/ProductCard.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import ProductCard from './ProductCard'
|
||
import type { Product } from '../data/products'
|
||
|
||
const product: Product = {
|
||
id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care',
|
||
price: 12, image: '/x.jpg', description: 'Hold with shine.', inStock: true,
|
||
}
|
||
|
||
describe('ProductCard', () => {
|
||
it('renders product details and calls onInquire when clicked', async () => {
|
||
const onInquire = vi.fn()
|
||
render(<ProductCard product={product} onInquire={onInquire} />)
|
||
expect(screen.getByText('Silk Edge Control')).toBeInTheDocument()
|
||
expect(screen.getByText('$12')).toBeInTheDocument()
|
||
await userEvent.click(screen.getByRole('button', { name: /ask about this/i }))
|
||
expect(onInquire).toHaveBeenCalledWith(product)
|
||
})
|
||
|
||
it('disables the inquire button when out of stock', () => {
|
||
const onInquire = vi.fn()
|
||
render(<ProductCard product={{ ...product, inStock: false }} onInquire={onInquire} />)
|
||
expect(screen.getByRole('button', { name: /ask about this/i })).toBeDisabled()
|
||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/ProductCard.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `ProductCard`**
|
||
|
||
`src/components/ProductCard.module.css`:
|
||
|
||
```css
|
||
.card {
|
||
background: var(--color-cream-alt);
|
||
border-radius: 4px;
|
||
padding: var(--space-md);
|
||
transition: transform 0.25s ease;
|
||
}
|
||
|
||
.card:hover {
|
||
transform: translateY(-6px) rotateX(2deg);
|
||
}
|
||
|
||
.badge {
|
||
color: #a0453a;
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.button {
|
||
background: var(--color-charcoal);
|
||
color: var(--color-cream);
|
||
border: none;
|
||
padding: 0.6rem 1.2rem;
|
||
font-size: 0.75rem;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.button:disabled {
|
||
background: #b7ada3;
|
||
cursor: not-allowed;
|
||
}
|
||
```
|
||
|
||
`src/components/ProductCard.tsx`:
|
||
|
||
```tsx
|
||
import type { Product } from '../data/products'
|
||
import styles from './ProductCard.module.css'
|
||
|
||
interface ProductCardProps {
|
||
product: Product
|
||
onInquire: (product: Product) => void
|
||
}
|
||
|
||
function ProductCard({ product, onInquire }: ProductCardProps) {
|
||
return (
|
||
<div className={styles.card}>
|
||
<h3>{product.name}</h3>
|
||
<p>${product.price}</p>
|
||
<p>{product.description}</p>
|
||
{!product.inStock && <p className={styles.badge}>Currently unavailable</p>}
|
||
<button
|
||
type="button"
|
||
className={styles.button}
|
||
disabled={!product.inStock}
|
||
onClick={() => onInquire(product)}
|
||
>
|
||
Ask about this
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ProductCard
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/ProductCard.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing ServiceCard test**
|
||
|
||
`src/components/ServiceCard.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import ServiceCard from './ServiceCard'
|
||
import type { Service } from '../data/services'
|
||
|
||
const service: Service = {
|
||
id: 'knotless', name: 'Knotless Braids', durationMinutes: 240,
|
||
priceFrom: 150, description: 'Tension-free braids.', image: '/x.jpg',
|
||
}
|
||
|
||
describe('ServiceCard', () => {
|
||
it('renders service details', () => {
|
||
render(<ServiceCard service={service} />)
|
||
expect(screen.getByText('Knotless Braids')).toBeInTheDocument()
|
||
expect(screen.getByText(/from \$150/i)).toBeInTheDocument()
|
||
expect(screen.getByText('4h')).toBeInTheDocument()
|
||
})
|
||
|
||
it('calls onSelect with the service id when clicked', async () => {
|
||
const onSelect = vi.fn()
|
||
render(<ServiceCard service={service} onSelect={onSelect} />)
|
||
await userEvent.click(screen.getByRole('button'))
|
||
expect(onSelect).toHaveBeenCalledWith('knotless')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/ServiceCard.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 7: Implement `ServiceCard`**
|
||
|
||
`src/components/ServiceCard.module.css`:
|
||
|
||
```css
|
||
.card {
|
||
background: var(--color-cream-alt);
|
||
border-radius: 4px;
|
||
padding: var(--space-md);
|
||
min-width: 160px;
|
||
text-align: center;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: transform 0.25s ease;
|
||
font-family: var(--font-serif);
|
||
}
|
||
|
||
.card:hover {
|
||
transform: translateY(-6px) rotateX(2deg);
|
||
}
|
||
```
|
||
|
||
`src/components/ServiceCard.tsx`:
|
||
|
||
```tsx
|
||
import type { Service } from '../data/services'
|
||
import styles from './ServiceCard.module.css'
|
||
|
||
interface ServiceCardProps {
|
||
service: Service
|
||
onSelect?: (serviceId: string) => void
|
||
}
|
||
|
||
function formatDuration(minutes: number): string {
|
||
const hours = Math.floor(minutes / 60)
|
||
const rest = minutes % 60
|
||
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
|
||
}
|
||
|
||
function ServiceCard({ service, onSelect }: ServiceCardProps) {
|
||
return (
|
||
<button type="button" className={styles.card} onClick={() => onSelect?.(service.id)}>
|
||
<h3>{service.name}</h3>
|
||
<p>{formatDuration(service.durationMinutes)}</p>
|
||
<p>From ${service.priceFrom}</p>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export default ServiceCard
|
||
```
|
||
|
||
- [ ] **Step 8: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/ServiceCard.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/components/ProductCard.tsx src/components/ProductCard.module.css src/components/ProductCard.test.tsx \
|
||
src/components/ServiceCard.tsx src/components/ServiceCard.module.css src/components/ServiceCard.test.tsx
|
||
git commit -m "feat: add ProductCard and ServiceCard components"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: `InquiryModal` component
|
||
|
||
**Files:**
|
||
- Create: `src/components/InquiryModal.tsx`, `src/components/InquiryModal.module.css`
|
||
- Test: `src/components/InquiryModal.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Product` type from `src/data/products.ts`
|
||
- Produces: `InquiryModal` — props `{ product: Product; onClose: () => void }`. Renders a form (name, email, notes) prefilled with the product name in a heading. Validates `name` (non-empty) and `email` (contains `@`) on submit; shows inline error text and does not advance if invalid. On valid submit, replaces the form with a confirmation message ("Thanks — Mira will get back to you about {product.name} soon."). A "Close" button (present in both form and confirmation states) calls `onClose`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/components/InquiryModal.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import InquiryModal from './InquiryModal'
|
||
import type { Product } from '../data/products'
|
||
|
||
const product: Product = {
|
||
id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care',
|
||
price: 12, image: '/x.jpg', description: 'Hold with shine.', inStock: true,
|
||
}
|
||
|
||
describe('InquiryModal', () => {
|
||
it('shows a validation error when submitting without a name or valid email', async () => {
|
||
render(<InquiryModal product={product} onClose={vi.fn()} />)
|
||
await userEvent.click(screen.getByRole('button', { name: /send inquiry/i }))
|
||
expect(screen.getByText(/name is required/i)).toBeInTheDocument()
|
||
expect(screen.getByText(/valid email is required/i)).toBeInTheDocument()
|
||
})
|
||
|
||
it('shows a confirmation after a valid submit', async () => {
|
||
render(<InquiryModal product={product} onClose={vi.fn()} />)
|
||
await userEvent.type(screen.getByLabelText(/name/i), 'Jane Doe')
|
||
await userEvent.type(screen.getByLabelText(/email/i), 'jane@example.com')
|
||
await userEvent.click(screen.getByRole('button', { name: /send inquiry/i }))
|
||
expect(screen.getByText(/silk edge control/i)).toBeInTheDocument()
|
||
expect(screen.getByText(/mira will get back to you/i)).toBeInTheDocument()
|
||
})
|
||
|
||
it('calls onClose when Close is clicked', async () => {
|
||
const onClose = vi.fn()
|
||
render(<InquiryModal product={product} onClose={onClose} />)
|
||
await userEvent.click(screen.getByRole('button', { name: /close/i }))
|
||
expect(onClose).toHaveBeenCalled()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/InquiryModal.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `InquiryModal`**
|
||
|
||
`src/components/InquiryModal.module.css`:
|
||
|
||
```css
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(58, 51, 46, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 20;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--color-cream);
|
||
padding: var(--space-lg);
|
||
border-radius: 4px;
|
||
width: min(420px, 90vw);
|
||
font-family: var(--font-serif);
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
margin-bottom: var(--space-md);
|
||
}
|
||
|
||
.error {
|
||
color: #a0453a;
|
||
font-size: 0.8rem;
|
||
}
|
||
```
|
||
|
||
`src/components/InquiryModal.tsx`:
|
||
|
||
```tsx
|
||
import { useState, type FormEvent } from 'react'
|
||
import type { Product } from '../data/products'
|
||
import styles from './InquiryModal.module.css'
|
||
|
||
interface InquiryModalProps {
|
||
product: Product
|
||
onClose: () => void
|
||
}
|
||
|
||
interface Errors {
|
||
name?: string
|
||
email?: string
|
||
}
|
||
|
||
function isValidEmail(email: string): boolean {
|
||
return /\S+@\S+\.\S+/.test(email)
|
||
}
|
||
|
||
function InquiryModal({ product, onClose }: InquiryModalProps) {
|
||
const [name, setName] = useState('')
|
||
const [email, setEmail] = useState('')
|
||
const [notes, setNotes] = useState('')
|
||
const [errors, setErrors] = useState<Errors>({})
|
||
const [submitted, setSubmitted] = useState(false)
|
||
|
||
function handleSubmit(e: FormEvent) {
|
||
e.preventDefault()
|
||
const nextErrors: Errors = {}
|
||
if (!name.trim()) nextErrors.name = 'Name is required.'
|
||
if (!isValidEmail(email)) nextErrors.email = 'A valid email is required.'
|
||
setErrors(nextErrors)
|
||
if (Object.keys(nextErrors).length === 0) {
|
||
setSubmitted(true)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.overlay}>
|
||
<div className={styles.modal}>
|
||
{submitted ? (
|
||
<>
|
||
<h3>{product.name}</h3>
|
||
<p>Thanks — Mira will get back to you about {product.name} soon.</p>
|
||
<button type="button" onClick={onClose}>Close</button>
|
||
</>
|
||
) : (
|
||
<form onSubmit={handleSubmit}>
|
||
<h3>Ask about {product.name}</h3>
|
||
<div className={styles.field}>
|
||
<label htmlFor="inquiry-name">Name</label>
|
||
<input id="inquiry-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||
{errors.name && <span className={styles.error}>{errors.name}</span>}
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label htmlFor="inquiry-email">Email</label>
|
||
<input id="inquiry-email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||
{errors.email && <span className={styles.error}>{errors.email}</span>}
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label htmlFor="inquiry-notes">Notes</label>
|
||
<textarea id="inquiry-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||
</div>
|
||
<button type="submit">Send Inquiry</button>
|
||
<button type="button" onClick={onClose}>Close</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default InquiryModal
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/InquiryModal.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/InquiryModal.tsx src/components/InquiryModal.module.css src/components/InquiryModal.test.tsx
|
||
git commit -m "feat: add InquiryModal for product inquiries"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: `Shop` page
|
||
|
||
**Files:**
|
||
- Create: `src/pages/Shop.tsx`, `src/pages/Shop.module.css`
|
||
- Modify: `src/App.tsx` (replace `/shop` placeholder)
|
||
- Modify: `src/App.test.tsx` (update the `/shop` assertion)
|
||
- Test: `src/pages/Shop.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `products`, `getProductsByCategory` from `src/data/products.ts`; `ProductCard` from Task 7; `InquiryModal` from Task 8
|
||
- Produces: `Shop` page component (default export, no props) — renders a category filter (`all`, `hair-care`, `extensions`, `tools`, `accessories`) as buttons, a grid of `ProductCard`s for the selected category, and opens `InquiryModal` when a card's inquire button is clicked.
|
||
|
||
- [ ] **Step 1: Write the failing Shop page test**
|
||
|
||
`src/pages/Shop.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import userEvent from '@testing-library/user-event'
|
||
import { describe, it, expect } from 'vitest'
|
||
import Shop from './Shop'
|
||
import { products } from '../data/products'
|
||
|
||
describe('Shop page', () => {
|
||
it('renders all products by default', () => {
|
||
render(<Shop />)
|
||
for (const p of products) {
|
||
expect(screen.getByText(p.name)).toBeInTheDocument()
|
||
}
|
||
})
|
||
|
||
it('filters products by category', async () => {
|
||
render(<Shop />)
|
||
await userEvent.click(screen.getByRole('button', { name: /^tools$/i }))
|
||
expect(screen.getByText('Steel Rat-Tail Comb')).toBeInTheDocument()
|
||
expect(screen.queryByText('Silk Edge Control')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('opens the inquiry modal when a product is inquired about', async () => {
|
||
render(<Shop />)
|
||
const buttons = screen.getAllByRole('button', { name: /ask about this/i })
|
||
await userEvent.click(buttons[0])
|
||
expect(screen.getByRole('button', { name: /send inquiry/i })).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/pages/Shop.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement the `Shop` page**
|
||
|
||
`src/pages/Shop.module.css`:
|
||
|
||
```css
|
||
.page {
|
||
padding: var(--space-xl) var(--space-lg);
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: var(--space-sm);
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
|
||
.filters button {
|
||
background: none;
|
||
border: 1px solid var(--color-gold);
|
||
padding: 0.5rem 1rem;
|
||
cursor: pointer;
|
||
text-transform: capitalize;
|
||
}
|
||
|
||
.filters button.active {
|
||
background: var(--color-charcoal);
|
||
color: var(--color-cream);
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: var(--space-lg);
|
||
}
|
||
```
|
||
|
||
`src/pages/Shop.tsx`:
|
||
|
||
```tsx
|
||
import { useState } from 'react'
|
||
import ProductCard from '../components/ProductCard'
|
||
import InquiryModal from '../components/InquiryModal'
|
||
import { getProductsByCategory, type Product } from '../data/products'
|
||
import styles from './Shop.module.css'
|
||
|
||
const CATEGORIES: (Product['category'] | 'all')[] = ['all', 'hair-care', 'extensions', 'tools', 'accessories']
|
||
|
||
function Shop() {
|
||
const [category, setCategory] = useState<Product['category'] | 'all'>('all')
|
||
const [inquiring, setInquiring] = useState<Product | null>(null)
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<h1>Shop</h1>
|
||
<div className={styles.filters}>
|
||
{CATEGORIES.map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
className={c === category ? 'active' : ''}
|
||
onClick={() => setCategory(c)}
|
||
>
|
||
{c.replace('-', ' ')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className={styles.grid}>
|
||
{getProductsByCategory(category).map((product) => (
|
||
<ProductCard key={product.id} product={product} onInquire={setInquiring} />
|
||
))}
|
||
</div>
|
||
{inquiring && <InquiryModal product={inquiring} onClose={() => setInquiring(null)} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Shop
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/pages/Shop.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Wire `Shop` into `App.tsx`**
|
||
|
||
In `src/App.tsx`, import `Shop` from `./pages/Shop` and replace `<Route path="/shop" element={<div data-testid="page-shop" />} />` with `<Route path="/shop" element={<Shop />} />`.
|
||
|
||
Update the corresponding assertion in `src/App.test.tsx`:
|
||
|
||
```tsx
|
||
it('renders the Shop page at /shop', () => {
|
||
render(<App initialEntries={['/shop']} />)
|
||
expect(screen.getByRole('heading', { name: /shop/i })).toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full test suite to verify everything still passes**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Shop.tsx src/pages/Shop.module.css src/pages/Shop.test.tsx src/App.tsx src/App.test.tsx
|
||
git commit -m "feat: add Shop page with category filter and product inquiries"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: `useBooking` hook
|
||
|
||
**Files:**
|
||
- Create: `src/hooks/useBooking.ts`
|
||
- Test: `src/hooks/useBooking.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `services` from `src/data/services.ts`; `getSlotsForDate`, `TimeSlot` from `src/data/availability.ts`
|
||
- Produces:
|
||
|
||
```ts
|
||
interface BookingContact {
|
||
name: string
|
||
phone: string
|
||
email: string
|
||
notes: string
|
||
}
|
||
|
||
interface BookingState {
|
||
step: 'service' | 'date' | 'slot' | 'contact' | 'confirmed'
|
||
serviceId: string | null
|
||
date: string | null
|
||
slot: TimeSlot | null
|
||
contact: BookingContact
|
||
}
|
||
|
||
interface UseBookingResult {
|
||
state: BookingState
|
||
errors: Partial<Record<keyof BookingContact, string>>
|
||
selectService: (serviceId: string) => void
|
||
selectDate: (date: string) => void
|
||
selectSlot: (slot: TimeSlot) => void
|
||
updateContact: (patch: Partial<BookingContact>) => void
|
||
submit: () => boolean
|
||
reset: () => void
|
||
}
|
||
|
||
function useBooking(initialServiceId?: string): UseBookingResult
|
||
```
|
||
|
||
`selectService` advances `step` to `'date'`; `selectDate` advances to `'slot'`; `selectSlot` advances to `'contact'`; `submit()` validates `contact` (name required, email must contain `@`, phone required) — on success sets `step` to `'confirmed'` and returns `true`; on failure populates `errors` and returns `false` without changing `step`. `reset()` returns to the initial state (service unselected unless `initialServiceId` was given).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/hooks/useBooking.test.ts`:
|
||
|
||
```ts
|
||
import { renderHook, act } from '@testing-library/react'
|
||
import { describe, it, expect } from 'vitest'
|
||
import useBooking from './useBooking'
|
||
|
||
describe('useBooking', () => {
|
||
it('starts at the service step, or date step if preselected', () => {
|
||
const { result: fresh } = renderHook(() => useBooking())
|
||
expect(fresh.current.state.step).toBe('service')
|
||
|
||
const { result: preselected } = renderHook(() => useBooking('knotless'))
|
||
expect(preselected.current.state.step).toBe('date')
|
||
expect(preselected.current.state.serviceId).toBe('knotless')
|
||
})
|
||
|
||
it('advances through service -> date -> slot -> contact', () => {
|
||
const { result } = renderHook(() => useBooking())
|
||
act(() => result.current.selectService('cornrows'))
|
||
expect(result.current.state.step).toBe('date')
|
||
|
||
act(() => result.current.selectDate('2026-08-11'))
|
||
expect(result.current.state.step).toBe('slot')
|
||
|
||
act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
|
||
expect(result.current.state.step).toBe('contact')
|
||
})
|
||
|
||
it('rejects submit with invalid contact info and reports errors', () => {
|
||
const { result } = renderHook(() => useBooking('cornrows'))
|
||
act(() => result.current.selectDate('2026-08-11'))
|
||
act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
|
||
|
||
let success = true
|
||
act(() => { success = result.current.submit() })
|
||
expect(success).toBe(false)
|
||
expect(result.current.state.step).toBe('contact')
|
||
expect(result.current.errors.name).toBeTruthy()
|
||
expect(result.current.errors.email).toBeTruthy()
|
||
expect(result.current.errors.phone).toBeTruthy()
|
||
})
|
||
|
||
it('confirms booking with valid contact info', () => {
|
||
const { result } = renderHook(() => useBooking('cornrows'))
|
||
act(() => result.current.selectDate('2026-08-11'))
|
||
act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
|
||
act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))
|
||
|
||
let success = false
|
||
act(() => { success = result.current.submit() })
|
||
expect(success).toBe(true)
|
||
expect(result.current.state.step).toBe('confirmed')
|
||
})
|
||
|
||
it('resets to the initial state', () => {
|
||
const { result } = renderHook(() => useBooking())
|
||
act(() => result.current.selectService('locs'))
|
||
act(() => result.current.reset())
|
||
expect(result.current.state.step).toBe('service')
|
||
expect(result.current.state.serviceId).toBeNull()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/hooks/useBooking.test.ts`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `useBooking`**
|
||
|
||
`src/hooks/useBooking.ts`:
|
||
|
||
```ts
|
||
import { useState, useCallback } from 'react'
|
||
import type { TimeSlot } from '../data/availability'
|
||
|
||
export interface BookingContact {
|
||
name: string
|
||
phone: string
|
||
email: string
|
||
notes: string
|
||
}
|
||
|
||
export type BookingStep = 'service' | 'date' | 'slot' | 'contact' | 'confirmed'
|
||
|
||
export interface BookingState {
|
||
step: BookingStep
|
||
serviceId: string | null
|
||
date: string | null
|
||
slot: TimeSlot | null
|
||
contact: BookingContact
|
||
}
|
||
|
||
type BookingErrors = Partial<Record<keyof BookingContact, string>>
|
||
|
||
function initialState(initialServiceId?: string): BookingState {
|
||
return {
|
||
step: initialServiceId ? 'date' : 'service',
|
||
serviceId: initialServiceId ?? null,
|
||
date: null,
|
||
slot: null,
|
||
contact: { name: '', phone: '', email: '', notes: '' },
|
||
}
|
||
}
|
||
|
||
function isValidEmail(email: string): boolean {
|
||
return /\S+@\S+\.\S+/.test(email)
|
||
}
|
||
|
||
export default function useBooking(initialServiceId?: string) {
|
||
const [state, setState] = useState<BookingState>(() => initialState(initialServiceId))
|
||
const [errors, setErrors] = useState<BookingErrors>({})
|
||
|
||
const selectService = useCallback((serviceId: string) => {
|
||
setState((s) => ({ ...s, serviceId, step: 'date' }))
|
||
}, [])
|
||
|
||
const selectDate = useCallback((date: string) => {
|
||
setState((s) => ({ ...s, date, step: 'slot' }))
|
||
}, [])
|
||
|
||
const selectSlot = useCallback((slot: TimeSlot) => {
|
||
setState((s) => ({ ...s, slot, step: 'contact' }))
|
||
}, [])
|
||
|
||
const updateContact = useCallback((patch: Partial<BookingContact>) => {
|
||
setState((s) => ({ ...s, contact: { ...s.contact, ...patch } }))
|
||
}, [])
|
||
|
||
const submit = useCallback((): boolean => {
|
||
let result = false
|
||
setState((s) => {
|
||
const nextErrors: BookingErrors = {}
|
||
if (!s.contact.name.trim()) nextErrors.name = 'Name is required.'
|
||
if (!isValidEmail(s.contact.email)) nextErrors.email = 'A valid email is required.'
|
||
if (!s.contact.phone.trim()) nextErrors.phone = 'Phone is required.'
|
||
setErrors(nextErrors)
|
||
if (Object.keys(nextErrors).length > 0) {
|
||
result = false
|
||
return s
|
||
}
|
||
result = true
|
||
return { ...s, step: 'confirmed' }
|
||
})
|
||
return result
|
||
}, [])
|
||
|
||
const reset = useCallback(() => {
|
||
setState(initialState())
|
||
setErrors({})
|
||
}, [])
|
||
|
||
return { state, errors, selectService, selectDate, selectSlot, updateContact, submit, reset }
|
||
}
|
||
```
|
||
|
||
Note: because `setState`'s updater runs synchronously within React's batching in the test environment, `result` reflects the outcome correctly when read immediately after the `act()` call in tests above.
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/hooks/useBooking.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/hooks/useBooking.ts src/hooks/useBooking.test.ts
|
||
git commit -m "feat: add useBooking hook for the booking flow state machine"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: `Book` page
|
||
|
||
**Files:**
|
||
- Create: `src/pages/Book.tsx`, `src/pages/Book.module.css`
|
||
- Modify: `src/App.tsx` (replace `/book` placeholder)
|
||
- Modify: `src/App.test.tsx` (update the `/book` assertion)
|
||
- Test: `src/pages/Book.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `useBooking` from Task 10; `services` from `src/data/services.ts`; `getSlotsForDate`, `getNextAvailableDate` from `src/data/availability.ts`; `ServiceCard` from Task 7; `useLocation` from `react-router-dom` (reads `location.state?.serviceId` to preselect)
|
||
- Produces: `Book` page component (default export, no props) — renders the step-by-step flow described in the spec: service picker → date input → slot buttons (with an empty-state + "next available date" suggestion when none are available) → contact form → confirmation.
|
||
|
||
- [ ] **Step 1: Write the failing Book page test**
|
||
|
||
`src/pages/Book.test.tsx`:
|
||
|
||
```tsx
|
||
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(
|
||
<MemoryRouter initialEntries={initialEntries}>
|
||
<Routes>
|
||
<Route path="/book" element={<Book />} />
|
||
</Routes>
|
||
</MemoryRouter>,
|
||
)
|
||
}
|
||
|
||
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()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/pages/Book.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement the `Book` page**
|
||
|
||
`src/pages/Book.module.css`:
|
||
|
||
```css
|
||
.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;
|
||
}
|
||
```
|
||
|
||
`src/pages/Book.tsx`:
|
||
|
||
```tsx
|
||
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 (
|
||
<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>
|
||
|
||
{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}
|
||
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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Book
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/pages/Book.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Wire `Book` into `App.tsx`**
|
||
|
||
In `src/App.tsx`, import `Book` from `./pages/Book` and replace the `/book` placeholder route with `<Route path="/book" element={<Book />} />`.
|
||
|
||
Update `src/App.test.tsx`'s `/book` assertion:
|
||
|
||
```tsx
|
||
it('renders the Book page at /book', () => {
|
||
render(<App initialEntries={['/book']} />)
|
||
expect(screen.getByRole('heading', { name: /book an appointment/i })).toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full test suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Book.tsx src/pages/Book.module.css src/pages/Book.test.tsx src/App.tsx src/App.test.tsx
|
||
git commit -m "feat: add Book page with the full mock-data booking flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: `Rules` page
|
||
|
||
**Files:**
|
||
- Create: `src/pages/Rules.tsx`, `src/pages/Rules.module.css`
|
||
- Modify: `src/App.tsx` (replace `/rules` placeholder)
|
||
- Modify: `src/App.test.tsx` (update the `/rules` assertion)
|
||
- Test: `src/pages/Rules.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `rules` from `src/data/rules.ts`
|
||
- Produces: `Rules` page component (default export, no props) — renders each `RuleSection` as a titled block, plus a closing note and a `Link` to `/book`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/pages/Rules.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { MemoryRouter } from 'react-router-dom'
|
||
import { describe, it, expect } from 'vitest'
|
||
import Rules from './Rules'
|
||
import { rules } from '../data/rules'
|
||
|
||
describe('Rules page', () => {
|
||
it('renders every rule section title and body', () => {
|
||
render(<MemoryRouter><Rules /></MemoryRouter>)
|
||
for (const rule of rules) {
|
||
expect(screen.getByText(rule.title)).toBeInTheDocument()
|
||
expect(screen.getByText(rule.body)).toBeInTheDocument()
|
||
}
|
||
})
|
||
|
||
it('links back to booking', () => {
|
||
render(<MemoryRouter><Rules /></MemoryRouter>)
|
||
expect(screen.getByRole('link', { name: /book/i })).toHaveAttribute('href', '/book')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/pages/Rules.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement the `Rules` page**
|
||
|
||
`src/pages/Rules.module.css`:
|
||
|
||
```css
|
||
.page {
|
||
padding: var(--space-xl) var(--space-lg);
|
||
max-width: 680px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
|
||
.section h2 {
|
||
color: var(--color-gold);
|
||
font-size: 1rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.cta {
|
||
text-align: center;
|
||
margin-top: var(--space-xl);
|
||
}
|
||
```
|
||
|
||
`src/pages/Rules.tsx`:
|
||
|
||
```tsx
|
||
import { Link } from 'react-router-dom'
|
||
import { rules } from '../data/rules'
|
||
import styles from './Rules.module.css'
|
||
|
||
function Rules() {
|
||
return (
|
||
<div className={styles.page}>
|
||
<h1>Salon Rules & Policies</h1>
|
||
<p>Please read through before booking your appointment.</p>
|
||
{rules.map((rule) => (
|
||
<div key={rule.id} className={styles.section}>
|
||
<h2>{rule.title}</h2>
|
||
<p>{rule.body}</p>
|
||
</div>
|
||
))}
|
||
<div className={styles.cta}>
|
||
<p>By booking, you agree to these terms.</p>
|
||
<Link to="/book">Book Now</Link>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Rules
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/pages/Rules.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Wire `Rules` into `App.tsx`**
|
||
|
||
In `src/App.tsx`, import `Rules` from `./pages/Rules` and replace the `/rules` placeholder route with `<Route path="/rules" element={<Rules />} />`.
|
||
|
||
Update `src/App.test.tsx`'s `/rules` assertion:
|
||
|
||
```tsx
|
||
it('renders the Rules page at /rules', () => {
|
||
render(<App initialEntries={['/rules']} />)
|
||
expect(screen.getByRole('heading', { name: /salon rules/i })).toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full test suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Rules.tsx src/pages/Rules.module.css src/pages/Rules.test.tsx src/App.tsx src/App.test.tsx
|
||
git commit -m "feat: add Rules page"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: `Home` page
|
||
|
||
**Files:**
|
||
- Create: `src/pages/Home.tsx`, `src/pages/Home.module.css`
|
||
- Modify: `src/App.tsx` (replace `/` placeholder)
|
||
- Modify: `src/App.test.tsx` (update the `/` assertion)
|
||
- Test: `src/pages/Home.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Hero3D` (Task 6), `ScrollReveal` (Task 3), `ServiceCard` (Task 7), `ProductCard`-free "shop highlight" links, `services` (`src/data/services.ts`), `products` (`src/data/products.ts`), `useNavigate`/`Link` from `react-router-dom`
|
||
- Produces: `Home` page component (default export, no props) — assembles all seven sections from the spec's Home layout. Clicking a Signature Style card navigates to `/book` with `state={{ serviceId }}`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/pages/Home.test.tsx`:
|
||
|
||
```tsx
|
||
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 Home from './Home'
|
||
import Book from './Book'
|
||
import { services } from '../data/services'
|
||
|
||
function renderHome() {
|
||
return render(
|
||
<MemoryRouter initialEntries={['/']}>
|
||
<Routes>
|
||
<Route path="/" element={<Home />} />
|
||
<Route path="/book" element={<Book />} />
|
||
</Routes>
|
||
</MemoryRouter>,
|
||
)
|
||
}
|
||
|
||
describe('Home page', () => {
|
||
it('renders the hero headline and all four signature styles', () => {
|
||
renderHome()
|
||
expect(screen.getByText(/your hair, your crown/i)).toBeInTheDocument()
|
||
for (const service of services) {
|
||
expect(screen.getByText(service.name)).toBeInTheDocument()
|
||
}
|
||
})
|
||
|
||
it('renders shop highlights and a link to the rules page', () => {
|
||
renderHome()
|
||
expect(screen.getByRole('link', { name: /shop all/i })).toHaveAttribute('href', '/shop')
|
||
expect(screen.getByRole('link', { name: /read the rules|salon rules/i })).toHaveAttribute('href', '/rules')
|
||
})
|
||
|
||
it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => {
|
||
renderHome()
|
||
await userEvent.click(screen.getByRole('button', { name: services[0].name }))
|
||
expect(screen.getByLabelText(/choose a date/i)).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails**
|
||
|
||
Run: `npx vitest run src/pages/Home.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement the `Home` page**
|
||
|
||
`src/pages/Home.module.css`:
|
||
|
||
```css
|
||
.hero {
|
||
text-align: center;
|
||
padding: var(--space-xl) var(--space-lg);
|
||
}
|
||
|
||
.headline {
|
||
font-family: var(--font-script);
|
||
font-size: 3rem;
|
||
margin: var(--space-md) 0 0;
|
||
}
|
||
|
||
.tagline {
|
||
font-size: 0.75rem;
|
||
letter-spacing: 3px;
|
||
text-transform: uppercase;
|
||
color: #6b6259;
|
||
}
|
||
|
||
.ctas {
|
||
margin-top: var(--space-lg);
|
||
display: flex;
|
||
gap: var(--space-md);
|
||
justify-content: center;
|
||
}
|
||
|
||
.section {
|
||
padding: var(--space-xl) var(--space-lg);
|
||
text-align: center;
|
||
}
|
||
|
||
.altSection {
|
||
composes: section;
|
||
background: var(--color-cream-alt);
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
gap: var(--space-md);
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
margin-top: var(--space-lg);
|
||
}
|
||
|
||
.banner {
|
||
background: var(--color-charcoal);
|
||
color: var(--color-cream);
|
||
padding: var(--space-xl) var(--space-lg);
|
||
text-align: center;
|
||
}
|
||
|
||
.banner a {
|
||
color: var(--color-gold);
|
||
}
|
||
```
|
||
|
||
`src/pages/Home.tsx`:
|
||
|
||
```tsx
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
import Hero3D from '../components/Hero3D'
|
||
import ScrollReveal from '../components/ScrollReveal'
|
||
import ServiceCard from '../components/ServiceCard'
|
||
import { services } from '../data/services'
|
||
import { products } from '../data/products'
|
||
import styles from './Home.module.css'
|
||
|
||
const TESTIMONIALS = [
|
||
{ quote: 'Mira made me feel like royalty in her chair. My braids lasted 8 weeks!', author: 'Amara T.' },
|
||
{ quote: 'The most gentle, precise braider I have ever been to.', author: 'Keisha R.' },
|
||
{ quote: 'Booking was so easy and the results speak for themselves.', author: 'Nadia F.' },
|
||
]
|
||
|
||
function Home() {
|
||
const navigate = useNavigate()
|
||
|
||
return (
|
||
<div>
|
||
<section className={styles.hero}>
|
||
<Hero3D />
|
||
<h1 className={styles.headline}>Your hair, your crown</h1>
|
||
<p className={styles.tagline}>Protective Styles by Mira</p>
|
||
<div className={styles.ctas}>
|
||
<Link to="/book">Book Now</Link>
|
||
<Link to="/shop">Shop Products</Link>
|
||
</div>
|
||
</section>
|
||
|
||
<ScrollReveal>
|
||
<section className={styles.altSection}>
|
||
<h2>About Mira</h2>
|
||
<p>
|
||
With over a decade behind the chair, Mira treats every install as an art form —
|
||
gentle on the scalp, precise at the root, and finished with a crown you'll want
|
||
to show off.
|
||
</p>
|
||
</section>
|
||
</ScrollReveal>
|
||
|
||
<ScrollReveal>
|
||
<section className={styles.section}>
|
||
<h2>Signature Styles</h2>
|
||
<div className={styles.row}>
|
||
{services.map((service) => (
|
||
<ServiceCard
|
||
key={service.id}
|
||
service={service}
|
||
onSelect={(serviceId) => navigate('/book', { state: { serviceId } })}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</ScrollReveal>
|
||
|
||
<ScrollReveal>
|
||
<section className={styles.altSection}>
|
||
<h2>Shop Highlights</h2>
|
||
<div className={styles.row}>
|
||
{products.filter((p) => p.inStock).slice(0, 3).map((product) => (
|
||
<div key={product.id}>
|
||
<p>{product.name}</p>
|
||
<p>${product.price}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p><Link to="/shop">Shop All</Link></p>
|
||
</section>
|
||
</ScrollReveal>
|
||
|
||
<ScrollReveal>
|
||
<section className={styles.section}>
|
||
<h2>Client Love</h2>
|
||
<div className={styles.row}>
|
||
{TESTIMONIALS.map((t) => (
|
||
<blockquote key={t.author}>
|
||
<p>“{t.quote}”</p>
|
||
<cite>{t.author}</cite>
|
||
</blockquote>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</ScrollReveal>
|
||
|
||
<section className={styles.banner}>
|
||
<h2>Before You Book</h2>
|
||
<p>
|
||
Please take a moment to read the <Link to="/rules">salon rules</Link> before booking your appointment.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Home
|
||
```
|
||
|
||
- [ ] **Step 4: Run to verify it passes**
|
||
|
||
Run: `npx vitest run src/pages/Home.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Wire `Home` into `App.tsx`**
|
||
|
||
In `src/App.tsx`, import `Home` from `./pages/Home` and replace the `/` placeholder route with `<Route path="/" element={<Home />} />`.
|
||
|
||
Update `src/App.test.tsx`'s `/` assertion:
|
||
|
||
```tsx
|
||
it('renders the Home page at /', () => {
|
||
render(<App initialEntries={['/']} />)
|
||
expect(screen.getByText(/your hair, your crown/i)).toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full test suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Home.tsx src/pages/Home.module.css src/pages/Home.test.tsx src/App.tsx src/App.test.tsx
|
||
git commit -m "feat: add Home landing page assembling all sections"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Final polish, responsive check, and manual verification
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Home.module.css`, `src/pages/Shop.module.css`, `src/pages/Book.module.css`, `src/pages/Rules.module.css`, `src/components/Nav.module.css`, `src/components/Footer.module.css` (responsive breakpoints)
|
||
- No new test files — this task is verification + small CSS adjustments, not new behavior
|
||
|
||
- [ ] **Step 1: Add a mobile breakpoint to `Nav.module.css`**
|
||
|
||
Append to `src/components/Nav.module.css`:
|
||
|
||
```css
|
||
@media (max-width: 640px) {
|
||
.nav {
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
}
|
||
.links {
|
||
gap: var(--space-md);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add a mobile breakpoint to `Home.module.css`**
|
||
|
||
Append to `src/pages/Home.module.css`:
|
||
|
||
```css
|
||
@media (max-width: 640px) {
|
||
.headline {
|
||
font-size: 2.2rem;
|
||
}
|
||
.row {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add a mobile breakpoint to `Shop.module.css`**
|
||
|
||
Append to `src/pages/Shop.module.css`:
|
||
|
||
```css
|
||
@media (max-width: 640px) {
|
||
.grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the full test suite one final time**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all tests across `src/` PASS
|
||
|
||
- [ ] **Step 5: Type-check and build**
|
||
|
||
Run: `npm run build`
|
||
Expected: `tsc -b` and `vite build` both succeed with no errors
|
||
|
||
- [ ] **Step 6: Manual verification in the browser**
|
||
|
||
Run: `npm run dev`
|
||
|
||
Open the printed local URL and check, for each page:
|
||
- `/` — hero renders with the rotating motif, script headline, scroll reveals trigger as you scroll, Signature Style cards navigate to `/book` with the service preselected
|
||
- `/shop` — category filters work, "Ask about this" opens the inquiry modal, submitting with valid info shows the confirmation, out-of-stock products show the disabled state
|
||
- `/book` — full flow from service → date → slot → contact → confirmation works; picking a Sunday shows the empty state with a working "try next available date" button
|
||
- `/rules` — all five policy sections render, "Book Now" link goes to `/book`
|
||
- Resize the browser to a narrow (mobile) width and confirm nav, hero, and grids reflow sensibly
|
||
- In your OS/browser accessibility settings, enable "reduce motion" and reload — confirm the hero motif and scroll reveals no longer animate but all content still renders
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "polish: add responsive breakpoints for nav, home, and shop"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- **Spec coverage:** Tech stack (Task 1), visual system/tokens (Task 1), Nav/Footer/Layout (Tasks 4-5), Hero3D motion (Task 6), Home's 7 sections (Task 13), Shop with inquiry-not-cart (Tasks 7-9), Book's full mock-data flow with deep-link preselection and empty-state handling (Tasks 10-11), Rules page (Task 12), reduced-motion handling (Tasks 3 and 6), responsive layout (Task 14), Vitest/RTL testing throughout. All spec sections are covered.
|
||
- **Type consistency:** `Product`, `Service`, `TimeSlot`, `RuleSection`, `BookingState`/`BookingContact` are defined once each (Tasks 2 and 10) and reused by name in every later task without renaming.
|
||
- **No placeholders:** every step includes literal file contents and exact commands.
|