1464 lines
52 KiB
Markdown
1464 lines
52 KiB
Markdown
# Frontend Visual Overhaul 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:** Replace the flat, image-less Phase 1 UI with a real WebGL 3D hero, generated product/style/portrait photography, and tighter, more animated layouts across all four existing pages (Home, Shop, Book, Rules).
|
||
|
||
**Architecture:** Additive changes on top of the existing React 19 + TypeScript + Vite + framer-motion + react-router-dom stack. A new `@react-three/fiber` scene replaces the current CSS-only `Hero3D` on Home. Product/service images are generated once as static files and wired into the already-existing (but previously unused) `image` fields on `Product`/`Service`. No new routes, no new data shapes, no backend.
|
||
|
||
**Tech Stack:** Adds `three`, `@react-three/fiber`, `@react-three/drei` for the 3D hero. Everything else reuses the existing Vitest + React Testing Library + framer-motion toolchain.
|
||
|
||
## Global Constraints
|
||
|
||
- Palette stays cream `#F7F3EC` / `#EFE7D8`, charcoal `#3A332E`, gold `#B08D57` — no new colors, no rebrand.
|
||
- Typography stays Georgia/serif for body/labels, the existing script font for headline moments — no new fonts.
|
||
- 3D treatment is scoped to the Home hero only — not product cards, not other pages (explicit user decision).
|
||
- All motion continues to respect `prefers-reduced-motion` (existing `useReducedMotion` pattern from `framer-motion`), including the new 3D scene, which falls back to a static element and skips mounting the WebGL canvas entirely for those users.
|
||
- No backend/network calls — imagery is static files under `public/`, generated once and committed.
|
||
- `three`/`@react-three/fiber`/`@react-three/drei` must only be imported from the Home hero component so they don't bloat the Shop/Book/Rules bundles (Vite's existing route-based code splitting handles this automatically as long as the import stays local to that one component).
|
||
|
||
---
|
||
|
||
### Task 1: Install 3D dependencies
|
||
|
||
**Files:**
|
||
- Modify: `package.json`, `package-lock.json`
|
||
|
||
**Interfaces:**
|
||
- Produces: `three`, `@react-three/fiber`, `@react-three/drei` available as npm dependencies for Task 3.
|
||
|
||
- [ ] **Step 1: Install the packages**
|
||
|
||
```bash
|
||
npm install three @react-three/fiber @react-three/drei
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the install didn't break the existing build**
|
||
|
||
Run: `npm run build`
|
||
Expected: succeeds with zero TypeScript errors (these packages ship their own types, no `@types/three` needed for the API surface used in this plan).
|
||
|
||
- [ ] **Step 3: Verify the existing test suite is unaffected**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all existing tests still pass (44/44 as of the last Phase 1 merge).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json
|
||
git commit -m "chore: add three, @react-three/fiber, @react-three/drei for the 3D hero"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Generate product, style, portrait, and testimonial imagery
|
||
|
||
**Files:**
|
||
- Create: `public/products/edge-control.jpg`
|
||
- Create: `public/products/braid-oil.jpg`
|
||
- Create: `public/products/leave-in.jpg`
|
||
- Create: `public/products/kanekalon-black.jpg`
|
||
- Create: `public/products/kanekalon-brown.jpg`
|
||
- Create: `public/products/locs-extension.jpg`
|
||
- Create: `public/products/rat-tail-comb.jpg`
|
||
- Create: `public/products/edge-brush.jpg`
|
||
- Create: `public/products/gold-cuffs.jpg`
|
||
- Create: `public/products/satin-bonnet.jpg`
|
||
- Create: `public/styles/knotless.jpg`
|
||
- Create: `public/styles/boho.jpg`
|
||
- Create: `public/styles/locs.jpg`
|
||
- Create: `public/styles/cornrows.jpg`
|
||
- Create: `public/about/mira-portrait.jpg`
|
||
- Create: `public/testimonials/amara.jpg`
|
||
- Create: `public/testimonials/keisha.jpg`
|
||
- Create: `public/testimonials/nadia.jpg`
|
||
|
||
**Interfaces:**
|
||
- Produces: static image files at the exact paths above. These paths already match the `image` fields hardcoded in `src/data/products.ts` and `src/data/services.ts` from Phase 1 (e.g. `products.ts` already has `image: '/products/edge-control.jpg'`) — **do not rename files or change the data files**, the filenames here are load-bearing for Tasks 4 and 5.
|
||
|
||
This task has no code to write and no tests to run — it's content generation. Use whatever AI image-generation tool is available in your environment (search your available tools for an image generation capability before starting). Generate each image individually or in batches if your tool supports batch generation, then save/move each result to its exact destination path listed above.
|
||
|
||
- [ ] **Step 1: Generate the 10 product images**
|
||
|
||
Use these prompts (adapt wording to your tool's requirements, but preserve the subject, framing, and palette guidance in each):
|
||
|
||
1. `public/products/edge-control.jpg` — "Product photography of a small round jar of hair edge control gel, cream and gold label, soft studio lighting, on a plain warm cream (#F7F3EC) background, minimalist beauty product shot"
|
||
2. `public/products/braid-oil.jpg` — "Product photography of a small amber glass dropper bottle labeled as a hair oil, soft studio lighting, on a plain warm cream background, minimalist beauty product shot"
|
||
3. `public/products/leave-in.jpg` — "Product photography of a white pump bottle of leave-in hair conditioner, cream and gold label, soft studio lighting, on a plain warm cream background, minimalist beauty product shot"
|
||
4. `public/products/kanekalon-black.jpg` — "Product photography of a neatly coiled bundle of jet black synthetic braiding hair extensions, soft studio lighting, on a plain warm cream background"
|
||
5. `public/products/kanekalon-brown.jpg` — "Product photography of a neatly coiled bundle of warm chestnut brown synthetic braiding hair extensions, soft studio lighting, on a plain warm cream background"
|
||
6. `public/products/locs-extension.jpg` — "Product photography of pre-looped faux locs hair extensions arranged neatly, dark brown, soft studio lighting, on a plain warm cream background"
|
||
7. `public/products/rat-tail-comb.jpg` — "Product photography of a steel rat-tail hair parting comb, minimalist, soft studio lighting, on a plain warm cream background"
|
||
8. `public/products/edge-brush.jpg` — "Product photography of a small dual-sided hair edge brush, dark handle, soft studio lighting, on a plain warm cream background"
|
||
9. `public/products/gold-cuffs.jpg` — "Product photography of a set of six small gold adjustable hair cuffs for braids, soft studio lighting, on a plain warm cream background"
|
||
10. `public/products/satin-bonnet.jpg` — "Product photography of a folded charcoal-brown satin hair sleep bonnet, soft studio lighting, on a plain warm cream background"
|
||
|
||
- [ ] **Step 2: Generate the 4 signature-style images**
|
||
|
||
1. `public/styles/knotless.jpg` — "Editorial beauty portrait of a woman with long knotless box braids, warm natural lighting, neutral cream studio background, elegant and calm expression"
|
||
2. `public/styles/boho.jpg` — "Editorial beauty portrait of a woman with boho knotless braids featuring loose curly pieces woven in, warm natural lighting, neutral cream studio background"
|
||
3. `public/styles/locs.jpg` — "Editorial beauty portrait of a woman with natural-looking faux locs, warm natural lighting, neutral cream studio background"
|
||
4. `public/styles/cornrows.jpg` — "Editorial beauty portrait of a woman with sleek straight-back cornrows, warm natural lighting, neutral cream studio background"
|
||
|
||
- [ ] **Step 3: Generate the About Mira portrait**
|
||
|
||
`public/about/mira-portrait.jpg` — "Warm editorial portrait of a confident Black hairstylist in her salon, soft natural window light, neutral warm tones, three-quarter view, professional and approachable"
|
||
|
||
- [ ] **Step 4: Generate the 3 testimonial avatars**
|
||
|
||
1. `public/testimonials/amara.jpg` — "Small circular-crop friendly headshot portrait of a smiling Black woman, warm natural lighting, neutral cream background"
|
||
2. `public/testimonials/keisha.jpg` — "Small circular-crop friendly headshot portrait of a smiling Black woman, different from a previous portrait, warm natural lighting, neutral cream background"
|
||
3. `public/testimonials/nadia.jpg` — "Small circular-crop friendly headshot portrait of a smiling Black woman, different from previous portraits, warm natural lighting, neutral cream background"
|
||
|
||
- [ ] **Step 5: Verify every file exists and is a valid image**
|
||
|
||
```bash
|
||
for f in public/products/edge-control.jpg public/products/braid-oil.jpg public/products/leave-in.jpg \
|
||
public/products/kanekalon-black.jpg public/products/kanekalon-brown.jpg public/products/locs-extension.jpg \
|
||
public/products/rat-tail-comb.jpg public/products/edge-brush.jpg public/products/gold-cuffs.jpg \
|
||
public/products/satin-bonnet.jpg public/styles/knotless.jpg public/styles/boho.jpg public/styles/locs.jpg \
|
||
public/styles/cornrows.jpg public/about/mira-portrait.jpg public/testimonials/amara.jpg \
|
||
public/testimonials/keisha.jpg public/testimonials/nadia.jpg; do
|
||
file "$f"
|
||
done
|
||
```
|
||
|
||
Expected: every line reports a valid JPEG (or PNG — if your tool outputs PNG, save with a `.jpg` extension is fine as long as `<img>` tags render it correctly, but prefer actually generating/converting to JPEG for smaller file size; either way every file must exist and be non-zero bytes).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add public/products public/styles public/about public/testimonials
|
||
git commit -m "content: add generated product, style, portrait, and testimonial imagery"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `Hero3DScene` — WebGL 3D hero component
|
||
|
||
**Files:**
|
||
- Create: `src/components/Hero3DScene.tsx`, `src/components/Hero3DScene.module.css`
|
||
- Test: `src/components/Hero3DScene.test.tsx`
|
||
- Modify: `src/pages/Home.tsx` (swap `Hero3D` for `Hero3DScene`)
|
||
- Modify: `src/pages/Home.test.tsx` (if it references the old testid — check and update)
|
||
- Delete: `src/components/Hero3D.tsx`, `src/components/Hero3D.module.css`, `src/components/Hero3D.test.tsx` (fully replaced by `Hero3DScene`)
|
||
|
||
**Interfaces:**
|
||
- Produces: `Hero3DScene` component (no props) — renders a `data-testid="hero-3d-scene"` container. Under normal motion, renders an `@react-three/fiber` `<Canvas>` containing a rotating procedural braided-crown mesh that also tilts with scroll position. Under `prefers-reduced-motion: reduce`, renders a static gradient-circle fallback (same visual fallback the old `Hero3D` used) with class `static`, and does **not** mount the `<Canvas>` at all.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/components/Hero3DScene.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||
|
||
vi.mock('@react-three/fiber', () => ({
|
||
Canvas: ({ children }: { children?: React.ReactNode }) => <div data-testid="r3f-canvas">{children}</div>,
|
||
useFrame: () => {},
|
||
}))
|
||
|
||
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('Hero3DScene', () => {
|
||
afterEach(() => {
|
||
vi.restoreAllMocks()
|
||
})
|
||
|
||
it('renders the static fallback and no canvas when reduced motion is preferred', async () => {
|
||
vi.resetModules()
|
||
mockMatchMedia(true)
|
||
const { default: Hero3DScene } = await import('./Hero3DScene')
|
||
render(<Hero3DScene />)
|
||
const wrapper = screen.getByTestId('hero-3d-scene')
|
||
expect(wrapper.className).toMatch(/static/)
|
||
expect(screen.queryByTestId('r3f-canvas')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('renders the WebGL canvas when motion is allowed', async () => {
|
||
vi.resetModules()
|
||
mockMatchMedia(false)
|
||
const { default: Hero3DScene } = await import('./Hero3DScene')
|
||
render(<Hero3DScene />)
|
||
expect(screen.getByTestId('hero-3d-scene')).toBeInTheDocument()
|
||
expect(screen.getByTestId('r3f-canvas')).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
Note: this uses `vi.resetModules()` + dynamic `import()` inside each test, mirroring the pattern already established in this codebase for `Hero3D.test.tsx` — framer-motion's `useReducedMotion()` caches its result in a module-level singleton on first call within a file, so a static top-level import would make the second test see the first test's cached value. Mocking `@react-three/fiber`'s `Canvas`/`useFrame` avoids needing a real WebGL context in the jsdom test environment.
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/Hero3DScene.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `Hero3DScene`**
|
||
|
||
`src/components/Hero3DScene.module.css`:
|
||
|
||
```css
|
||
.wrapper {
|
||
width: 280px;
|
||
height: 280px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.wrapper canvas {
|
||
outline: none;
|
||
}
|
||
|
||
.fallbackMotif {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 30% 30%, #e6c98f, var(--color-gold));
|
||
}
|
||
```
|
||
|
||
`src/components/Hero3DScene.tsx`:
|
||
|
||
```tsx
|
||
import { useMemo, useRef } from 'react'
|
||
import { Canvas, useFrame } from '@react-three/fiber'
|
||
import * as THREE from 'three'
|
||
import { useReducedMotion, useScroll, useTransform, type MotionValue } from 'framer-motion'
|
||
import styles from './Hero3DScene.module.css'
|
||
|
||
const GOLD = '#B08D57'
|
||
const STRAND_COUNT = 6
|
||
|
||
interface BraidStrandProps {
|
||
radius: number
|
||
angleOffset: number
|
||
}
|
||
|
||
function BraidStrand({ radius, angleOffset }: BraidStrandProps) {
|
||
const geometry = useMemo(() => {
|
||
const points: THREE.Vector3[] = []
|
||
const segments = 20
|
||
for (let i = 0; i <= segments; i++) {
|
||
const t = i / segments
|
||
const angle = angleOffset + t * Math.PI * 0.6
|
||
const r = radius * (1 - t * 0.3)
|
||
points.push(new THREE.Vector3(Math.cos(angle) * r, t * 1.6 - 0.8, Math.sin(angle) * r))
|
||
}
|
||
const curve = new THREE.CatmullRomCurve3(points)
|
||
return new THREE.TubeGeometry(curve, 40, 0.05, 8, false)
|
||
}, [radius, angleOffset])
|
||
|
||
return (
|
||
<mesh geometry={geometry}>
|
||
<meshStandardMaterial color={GOLD} metalness={0.6} roughness={0.35} />
|
||
</mesh>
|
||
)
|
||
}
|
||
|
||
interface BraidedCrownProps {
|
||
scrollTilt: MotionValue<number>
|
||
}
|
||
|
||
function BraidedCrown({ scrollTilt }: BraidedCrownProps) {
|
||
const groupRef = useRef<THREE.Group>(null)
|
||
|
||
useFrame((_state, delta) => {
|
||
if (!groupRef.current) return
|
||
groupRef.current.rotation.y += delta * 0.25
|
||
groupRef.current.rotation.x = scrollTilt.get()
|
||
})
|
||
|
||
const strands = useMemo(
|
||
() =>
|
||
Array.from({ length: STRAND_COUNT }, (_, i) => ({
|
||
radius: 1.1,
|
||
angleOffset: (i / STRAND_COUNT) * Math.PI * 2,
|
||
})),
|
||
[],
|
||
)
|
||
|
||
return (
|
||
<group ref={groupRef}>
|
||
{strands.map((s, i) => (
|
||
<BraidStrand key={i} radius={s.radius} angleOffset={s.angleOffset} />
|
||
))}
|
||
</group>
|
||
)
|
||
}
|
||
|
||
function Hero3DScene() {
|
||
const prefersReducedMotion = useReducedMotion()
|
||
const { scrollY } = useScroll()
|
||
const scrollTilt = useTransform(scrollY, [0, 600], [0, 0.35])
|
||
|
||
if (prefersReducedMotion) {
|
||
return (
|
||
<div className={`${styles.wrapper} static`} data-testid="hero-3d-scene">
|
||
<div className={styles.fallbackMotif} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={styles.wrapper} data-testid="hero-3d-scene" aria-hidden="true">
|
||
<Canvas camera={{ position: [0, 0, 4], fov: 45 }}>
|
||
<ambientLight intensity={0.4} />
|
||
<directionalLight position={[2, 3, 4]} intensity={1.1} />
|
||
<directionalLight position={[-2, -1, -3]} intensity={0.3} />
|
||
<BraidedCrown scrollTilt={scrollTilt} />
|
||
</Canvas>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Hero3DScene
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/Hero3DScene.test.tsx`
|
||
Expected: PASS (2 tests)
|
||
|
||
- [ ] **Step 5: Delete the old `Hero3D` component**
|
||
|
||
```bash
|
||
rm src/components/Hero3D.tsx src/components/Hero3D.module.css src/components/Hero3D.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 6: Wire `Hero3DScene` into `Home.tsx`**
|
||
|
||
In `src/pages/Home.tsx`, change:
|
||
|
||
```tsx
|
||
import Hero3D from '../components/Hero3D'
|
||
```
|
||
|
||
to:
|
||
|
||
```tsx
|
||
import Hero3DScene from '../components/Hero3DScene'
|
||
```
|
||
|
||
and change `<Hero3D />` to `<Hero3DScene />` in the hero section.
|
||
|
||
- [ ] **Step 7: Check and fix `Home.test.tsx` for any reference to the old component**
|
||
|
||
Run: `grep -n "Hero3D" src/pages/Home.test.tsx`
|
||
|
||
If it finds nothing, no change needed. If it finds a reference (e.g. a testid check), update it to `hero-3d-scene`.
|
||
|
||
- [ ] **Step 8: Run the full suite and build**
|
||
|
||
Run: `npx vitest run && npm run build`
|
||
Expected: all tests pass, build succeeds with zero TypeScript errors.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: replace CSS hero motif with a real WebGL 3D braided-crown scene"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `ProductCard` renders its image
|
||
|
||
**Files:**
|
||
- Modify: `src/components/ProductCard.tsx`
|
||
- Modify: `src/components/ProductCard.module.css`
|
||
- Modify: `src/components/ProductCard.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Product.image` (already exists on the type from Phase 1, e.g. `/products/edge-control.jpg`, now backed by real files from Task 2)
|
||
- No signature changes — `ProductCard`'s props (`{ product, onInquire }`) are unchanged; this task only adds rendering of the existing `image` field.
|
||
|
||
- [ ] **Step 1: Update the test to assert the image renders**
|
||
|
||
Modify `src/components/ProductCard.test.tsx`, adding this assertion inside the first test (after the existing `$12` check):
|
||
|
||
```tsx
|
||
expect(screen.getByRole('img', { name: 'Silk Edge Control' })).toHaveAttribute('src', '/x.jpg')
|
||
```
|
||
|
||
The full first test becomes:
|
||
|
||
```tsx
|
||
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()
|
||
expect(screen.getByRole('img', { name: 'Silk Edge Control' })).toHaveAttribute('src', '/x.jpg')
|
||
await userEvent.click(screen.getByRole('button', { name: /ask about this/i }))
|
||
expect(onInquire).toHaveBeenCalledWith(product)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/ProductCard.test.tsx`
|
||
Expected: FAIL (no image role found)
|
||
|
||
- [ ] **Step 3: Add the image to `ProductCard`**
|
||
|
||
In `src/components/ProductCard.tsx`, add an `<img>` as the first child of the card, right after the opening `<div className={styles.card}>`:
|
||
|
||
```tsx
|
||
<img src={product.image} alt={product.name} className={styles.image} />
|
||
```
|
||
|
||
Full updated render:
|
||
|
||
```tsx
|
||
return (
|
||
<div className={styles.card}>
|
||
<img src={product.image} alt={product.name} className={styles.image} />
|
||
<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>
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Add image styling to `ProductCard.module.css`**
|
||
|
||
Add this rule (keep all existing rules unchanged):
|
||
|
||
```css
|
||
.image {
|
||
width: 100%;
|
||
aspect-ratio: 4 / 3;
|
||
object-fit: cover;
|
||
border-radius: 3px;
|
||
margin-bottom: var(--space-sm);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.card:hover .image {
|
||
transform: scale(1.05);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/ProductCard.test.tsx`
|
||
Expected: PASS (2 tests)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/ProductCard.tsx src/components/ProductCard.module.css src/components/ProductCard.test.tsx
|
||
git commit -m "feat: render product images with hover-zoom in ProductCard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `ServiceCard` renders its image
|
||
|
||
**Files:**
|
||
- Modify: `src/components/ServiceCard.tsx`
|
||
- Modify: `src/components/ServiceCard.module.css`
|
||
- Modify: `src/components/ServiceCard.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Service.image` (already exists on the type, e.g. `/styles/knotless.jpg`, now backed by real files from Task 2)
|
||
- No signature changes — `ServiceCard`'s props (`{ service, onSelect? }`) are unchanged.
|
||
|
||
- [ ] **Step 1: Update the test to assert the image renders**
|
||
|
||
Modify `src/components/ServiceCard.test.tsx`'s first test:
|
||
|
||
```tsx
|
||
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()
|
||
expect(screen.getByRole('img', { name: 'Knotless Braids' })).toHaveAttribute('src', '/x.jpg')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/ServiceCard.test.tsx`
|
||
Expected: FAIL (no image role found)
|
||
|
||
- [ ] **Step 3: Add the image to `ServiceCard`**
|
||
|
||
`ServiceCard` renders as a `<button>`, so the image goes inside it:
|
||
|
||
```tsx
|
||
return (
|
||
<button type="button" className={styles.card} onClick={() => onSelect?.(service.id)}>
|
||
<img src={service.image} alt={service.name} className={styles.image} />
|
||
<h3>{service.name}</h3>
|
||
<p>{formatDuration(service.durationMinutes)}</p>
|
||
<p>From ${service.priceFrom}</p>
|
||
</button>
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Add image styling to `ServiceCard.module.css`**
|
||
|
||
```css
|
||
.image {
|
||
width: 100%;
|
||
aspect-ratio: 4 / 3;
|
||
object-fit: cover;
|
||
border-radius: 3px;
|
||
margin-bottom: var(--space-sm);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.card:hover .image {
|
||
transform: scale(1.05);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/ServiceCard.test.tsx`
|
||
Expected: PASS (2 tests)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/ServiceCard.tsx src/components/ServiceCard.module.css src/components/ServiceCard.test.tsx
|
||
git commit -m "feat: render service images with hover-zoom in ServiceCard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Home page polish — spacing, portrait, real Shop Highlights, testimonial avatars
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Home.tsx`
|
||
- Modify: `src/pages/Home.module.css`
|
||
- Modify: `src/pages/Home.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Hero3DScene` (Task 3), `ProductCard` (Task 4), `ServiceCard` (Task 5 — already used), `products`/`services` data (unchanged)
|
||
- No new exports — this task only changes `Home`'s internal rendering.
|
||
|
||
- [ ] **Step 1: Update `Home.test.tsx` for the new Shop Highlights markup**
|
||
|
||
The current Home test suite (from Phase 1) doesn't assert on Shop Highlights' internal structure beyond the "Shop All" link, so no test changes are needed there. Add one new test to `src/pages/Home.test.tsx` verifying the About section's portrait image renders:
|
||
|
||
```tsx
|
||
it('renders the About Mira portrait', () => {
|
||
renderHome()
|
||
expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
Add this as a new `it()` block inside the existing `describe('Home page', ...)` block, alongside the existing tests.
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/pages/Home.test.tsx`
|
||
Expected: FAIL (no image with accessible name matching "mira")
|
||
|
||
- [ ] **Step 3: Add testimonial avatar data and update `Home.tsx`**
|
||
|
||
Replace the whole `TESTIMONIALS` constant and the JSX with the version below. Full replacement of `src/pages/Home.tsx`:
|
||
|
||
```tsx
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||
import Hero3DScene from '../components/Hero3DScene'
|
||
import ScrollReveal from '../components/ScrollReveal'
|
||
import ServiceCard from '../components/ServiceCard'
|
||
import ProductCard from '../components/ProductCard'
|
||
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.', avatar: '/testimonials/amara.jpg' },
|
||
{ quote: 'The most gentle, precise braider I have ever been to.', author: 'Keisha R.', avatar: '/testimonials/keisha.jpg' },
|
||
{ quote: 'Booking was so easy and the results speak for themselves.', author: 'Nadia F.', avatar: '/testimonials/nadia.jpg' },
|
||
]
|
||
|
||
function Home() {
|
||
const navigate = useNavigate()
|
||
const { scrollYProgress } = useScroll()
|
||
const portraitY = useTransform(scrollYProgress, [0, 1], [0, -40])
|
||
|
||
return (
|
||
<div>
|
||
<section className={styles.hero}>
|
||
<Hero3DScene />
|
||
<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}>
|
||
<div className={styles.aboutGrid}>
|
||
<motion.img
|
||
src="/about/mira-portrait.jpg"
|
||
alt="Mira, owner and stylist at Braids by Mira"
|
||
className={styles.portrait}
|
||
style={{ y: portraitY }}
|
||
/>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</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) => (
|
||
<ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
|
||
))}
|
||
</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} className={styles.testimonial}>
|
||
<img src={t.avatar} alt={t.author} className={styles.avatar} />
|
||
<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: Update `Home.module.css`**
|
||
|
||
Add these rules (keep all existing rules, including the media query at the bottom, unchanged — add these before the media query):
|
||
|
||
```css
|
||
.aboutGrid {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-xl);
|
||
flex-wrap: wrap;
|
||
text-align: left;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.aboutGrid h2,
|
||
.aboutGrid p {
|
||
text-align: left;
|
||
}
|
||
|
||
.portrait {
|
||
width: 220px;
|
||
height: 220px;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.testimonial {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
max-width: 260px;
|
||
}
|
||
|
||
.avatar {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
margin-bottom: var(--space-sm);
|
||
}
|
||
```
|
||
|
||
Also tighten the dead-whitespace gaps: change `.section` and `.altSection`'s base rule (via `.section`, since `.altSection` composes it) from `padding: var(--space-xl) var(--space-lg);` to `padding: var(--space-lg) var(--space-lg);` — this reduces the top/bottom padding on every content section without touching the hero or the dark banner (which have their own separate rules).
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/pages/Home.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Run the full suite and build**
|
||
|
||
Run: `npx vitest run && npm run build`
|
||
Expected: all tests pass, build succeeds.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Home.tsx src/pages/Home.module.css src/pages/Home.test.tsx
|
||
git commit -m "feat: add portrait with parallax, real product cards, and testimonial avatars to Home"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Shop — sliding active-filter indicator and tighter grid
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Shop.tsx`
|
||
- Modify: `src/pages/Shop.module.css`
|
||
- Modify: `src/pages/Shop.test.tsx` (only if the active-class assertion needs updating — check first)
|
||
|
||
**Interfaces:**
|
||
- No prop/type changes — `Shop` remains a route page component with no props.
|
||
|
||
- [ ] **Step 1: Check whether `Shop.test.tsx` asserts on the `active` class directly**
|
||
|
||
Run: `grep -n "active" src/pages/Shop.test.tsx`
|
||
|
||
If nothing is found, no test change is needed for this step (the existing tests check filtered product visibility, not the class name, so they'll keep passing). If something is found, note it — you'll need to keep that assertion working with the new markup below (the button element still exists with the same test ids/roles, only its internal content changes).
|
||
|
||
- [ ] **Step 2: Replace the filter button rendering in `Shop.tsx`**
|
||
|
||
Replace the `<div className={styles.filters}>...</div>` block with:
|
||
|
||
```tsx
|
||
<div className={styles.filters}>
|
||
{CATEGORIES.map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
className={styles.filterButton}
|
||
onClick={() => setCategory(c)}
|
||
>
|
||
{c === category && (
|
||
<motion.span
|
||
layoutId="active-filter-pill"
|
||
className={styles.activePill}
|
||
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||
/>
|
||
)}
|
||
<span className={styles.filterLabel}>{c.replace('-', ' ')}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
```
|
||
|
||
Add the import at the top of the file:
|
||
|
||
```tsx
|
||
import { motion } from 'framer-motion'
|
||
```
|
||
|
||
Full updated `src/pages/Shop.tsx`:
|
||
|
||
```tsx
|
||
import { useState } from 'react'
|
||
import { motion } from 'framer-motion'
|
||
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={styles.filterButton}
|
||
onClick={() => setCategory(c)}
|
||
>
|
||
{c === category && (
|
||
<motion.span
|
||
layoutId="active-filter-pill"
|
||
className={styles.activePill}
|
||
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||
/>
|
||
)}
|
||
<span className={styles.filterLabel}>{c.replace('-', ' ')}</span>
|
||
</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
|
||
```
|
||
|
||
Note: the previous `styles.active` class (from the earlier Phase 1 fix) is replaced by this layered pill approach — the button no longer gets a class swap; instead an absolutely-positioned `motion.span` slides between buttons using framer-motion's shared `layoutId`.
|
||
|
||
- [ ] **Step 3: Update `Shop.module.css`**
|
||
|
||
Replace the `.filters button` and `.filters button.active` rules with:
|
||
|
||
```css
|
||
.filterButton {
|
||
position: relative;
|
||
background: none;
|
||
border: 1px solid var(--color-gold);
|
||
padding: 0.5rem 1rem;
|
||
cursor: pointer;
|
||
text-transform: capitalize;
|
||
overflow: hidden;
|
||
color: var(--color-charcoal);
|
||
}
|
||
|
||
.activePill {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: var(--color-charcoal);
|
||
z-index: 0;
|
||
}
|
||
|
||
.filterLabel {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.filterButton:has(.activePill) .filterLabel {
|
||
color: var(--color-cream);
|
||
}
|
||
```
|
||
|
||
Also tighten grid spacing — change `.grid`'s `gap: var(--space-lg);` to `gap: var(--space-md);`.
|
||
|
||
- [ ] **Step 4: Run the full suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all tests pass (filter/inquiry behavior tests still pass since the button elements and their click handlers are unchanged, only their internal markup differs).
|
||
|
||
- [ ] **Step 5: Run the build**
|
||
|
||
Run: `npm run build`
|
||
Expected: succeeds with zero TypeScript errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/pages/Shop.tsx src/pages/Shop.module.css
|
||
git commit -m "feat: animate Shop's active category filter with a sliding pill indicator"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Book — step progress indicator and animated step transitions
|
||
|
||
**Files:**
|
||
- Create: `src/components/StepProgress.tsx`, `src/components/StepProgress.module.css`
|
||
- Test: `src/components/StepProgress.test.tsx`
|
||
- Modify: `src/pages/Book.tsx`
|
||
- Modify: `src/pages/Book.module.css`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BookingStep` type from `src/hooks/useBooking.ts` (`'service' | 'date' | 'slot' | 'contact' | 'confirmed'`)
|
||
- Produces: `StepProgress` component — props `{ currentStep: BookingStep }` — renders an ordered list of 4 labeled steps (Service, Date & Time, Contact, Confirmed), marking every step up to and including the current one as visually active. `'date'` and `'slot'` both map to the "Date & Time" step.
|
||
|
||
- [ ] **Step 1: Write the failing test for `StepProgress`**
|
||
|
||
`src/components/StepProgress.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, it, expect } from 'vitest'
|
||
import StepProgress from './StepProgress'
|
||
|
||
describe('StepProgress', () => {
|
||
it('marks steps up to and including the current one as active', () => {
|
||
render(<StepProgress currentStep="slot" />)
|
||
const items = screen.getAllByRole('listitem')
|
||
expect(items[0].className).toMatch(/active/) // Service
|
||
expect(items[1].className).toMatch(/active/) // Date & Time (slot maps here)
|
||
expect(items[2].className).not.toMatch(/active/) // Contact
|
||
expect(items[3].className).not.toMatch(/active/) // Confirmed
|
||
})
|
||
|
||
it('marks all steps active when confirmed', () => {
|
||
render(<StepProgress currentStep="confirmed" />)
|
||
const items = screen.getAllByRole('listitem')
|
||
items.forEach((item) => expect(item.className).toMatch(/active/))
|
||
})
|
||
|
||
it('marks only the first step active when on the service step', () => {
|
||
render(<StepProgress currentStep="service" />)
|
||
const items = screen.getAllByRole('listitem')
|
||
expect(items[0].className).toMatch(/active/)
|
||
expect(items[1].className).not.toMatch(/active/)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/StepProgress.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `StepProgress`**
|
||
|
||
`src/components/StepProgress.module.css`:
|
||
|
||
```css
|
||
.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);
|
||
}
|
||
```
|
||
|
||
`src/components/StepProgress.tsx`:
|
||
|
||
```tsx
|
||
import type { BookingStep } from '../hooks/useBooking'
|
||
import styles from './StepProgress.module.css'
|
||
|
||
const STEPS = [
|
||
{ key: 'service', label: 'Service' },
|
||
{ key: 'date', label: 'Date & Time' },
|
||
{ key: 'contact', label: 'Contact' },
|
||
{ key: 'confirmed', label: 'Confirmed' },
|
||
] as const
|
||
|
||
interface StepProgressProps {
|
||
currentStep: BookingStep
|
||
}
|
||
|
||
function stepIndex(step: BookingStep): number {
|
||
if (step === 'slot') return 1
|
||
return STEPS.findIndex((s) => s.key === step)
|
||
}
|
||
|
||
function StepProgress({ currentStep }: StepProgressProps) {
|
||
const activeIndex = stepIndex(currentStep)
|
||
|
||
return (
|
||
<ol className={styles.steps}>
|
||
{STEPS.map((step, index) => (
|
||
<li key={step.key} className={`${styles.step} ${index <= activeIndex ? styles.active : ''}`}>
|
||
<span className={styles.dot} />
|
||
<span className={styles.label}>{step.label}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)
|
||
}
|
||
|
||
export default StepProgress
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/StepProgress.test.tsx`
|
||
Expected: PASS (3 tests)
|
||
|
||
- [ ] **Step 5: Wire `StepProgress` into `Book.tsx` and animate step transitions**
|
||
|
||
Replace the entire contents of `src/pages/Book.tsx` with:
|
||
|
||
```tsx
|
||
import { useLocation } from 'react-router-dom'
|
||
import { useMemo, useState } from 'react'
|
||
import { AnimatePresence, motion } 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'
|
||
|
||
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)
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<h1>Book an Appointment</h1>
|
||
<StepProgress currentStep={booking.state.step} />
|
||
|
||
<AnimatePresence mode="wait">
|
||
<motion.div
|
||
key={booking.state.step}
|
||
initial={{ opacity: 0, x: 12 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -12 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
{booking.state.step === 'confirmed' && (
|
||
<p>
|
||
{selectedService?.name} on {booking.state.date} at {booking.state.slot?.time}.
|
||
Mira will see you then.
|
||
</p>
|
||
)}
|
||
|
||
{booking.state.step === 'service' && (
|
||
<div className={styles.services}>
|
||
{services.map((service) => (
|
||
<ServiceCard key={service.id} service={service} onSelect={booking.selectService} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{(booking.state.step === 'date' || booking.state.step === 'slot') && (
|
||
<>
|
||
<div className={styles.field}>
|
||
<label htmlFor="booking-date">Choose a date</label>
|
||
<input
|
||
id="booking-date"
|
||
type="date"
|
||
value={dateInput}
|
||
min={new Date().toISOString().slice(0, 10)}
|
||
onChange={(e) => {
|
||
setDateInput(e.target.value)
|
||
booking.selectDate(e.target.value)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{dateInput && availableSlots.length === 0 && (
|
||
<div>
|
||
<p>No times available on this date.</p>
|
||
{nextAvailable && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDateInput(nextAvailable)
|
||
booking.selectDate(nextAvailable)
|
||
}}
|
||
>
|
||
Try {nextAvailable} instead
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{availableSlots.length > 0 && (
|
||
<div className={styles.slots}>
|
||
{availableSlots.map((slot) => (
|
||
<button key={slot.time} type="button" onClick={() => booking.selectSlot(slot)}>
|
||
{slot.time}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{booking.state.step === 'contact' && (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault()
|
||
booking.submit()
|
||
}}
|
||
>
|
||
<div className={styles.field}>
|
||
<label htmlFor="contact-name">Name</label>
|
||
<input
|
||
id="contact-name"
|
||
value={booking.state.contact.name}
|
||
onChange={(e) => booking.updateContact({ name: e.target.value })}
|
||
/>
|
||
{booking.errors.name && <span className={styles.error}>{booking.errors.name}</span>}
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label htmlFor="contact-email">Email</label>
|
||
<input
|
||
id="contact-email"
|
||
value={booking.state.contact.email}
|
||
onChange={(e) => booking.updateContact({ email: e.target.value })}
|
||
/>
|
||
{booking.errors.email && <span className={styles.error}>{booking.errors.email}</span>}
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label htmlFor="contact-phone">Phone</label>
|
||
<input
|
||
id="contact-phone"
|
||
value={booking.state.contact.phone}
|
||
onChange={(e) => booking.updateContact({ phone: e.target.value })}
|
||
/>
|
||
{booking.errors.phone && <span className={styles.error}>{booking.errors.phone}</span>}
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label htmlFor="contact-notes">Notes</label>
|
||
<textarea
|
||
id="contact-notes"
|
||
value={booking.state.contact.notes}
|
||
onChange={(e) => booking.updateContact({ notes: e.target.value })}
|
||
/>
|
||
</div>
|
||
<button type="submit">Confirm Booking</button>
|
||
</form>
|
||
)}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Book
|
||
```
|
||
|
||
Important behavioral note: the confirmation heading `<h1>You're booked!</h1>` from the old early-return branch is gone because the page now always renders the shared `<h1>Book an Appointment</h1>` plus `<StepProgress>` at the top, with only the body content swapping per step (including the confirmed step). **This changes what `Book.test.tsx`'s confirmation assertion should look for.** Check `src/pages/Book.test.tsx` for `screen.getByText(/you're booked/i)` — if present, update it to match the new confirmation copy, which is now just the paragraph text, e.g. change the assertion to check for the same paragraph text without expecting a `"You're booked!"` heading. Concretely, if the test currently does:
|
||
|
||
```tsx
|
||
expect(screen.getByText(/you're booked/i)).toBeInTheDocument()
|
||
```
|
||
|
||
change it to:
|
||
|
||
```tsx
|
||
expect(screen.getByText(/mira will see you then/i)).toBeInTheDocument()
|
||
```
|
||
|
||
- [ ] **Step 6: Add `StepProgress` spacing to `Book.module.css`**
|
||
|
||
No new rules strictly required (StepProgress brings its own margin), but double-check `.page`'s existing `max-width: 640px; margin: 0 auto;` still reads well with the progress indicator — no change needed unless visual inspection in Step 8 says otherwise.
|
||
|
||
- [ ] **Step 7: Run the full suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all tests pass, including the updated confirmation assertion in `Book.test.tsx`.
|
||
|
||
- [ ] **Step 8: Run the build**
|
||
|
||
Run: `npm run build`
|
||
Expected: succeeds with zero TypeScript errors.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/components/StepProgress.tsx src/components/StepProgress.module.css src/components/StepProgress.test.tsx \
|
||
src/pages/Book.tsx src/pages/Book.test.tsx
|
||
git commit -m "feat: add step progress indicator and animated transitions to Book"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Rules — icons, stagger, and tighter typography rhythm
|
||
|
||
**Files:**
|
||
- Create: `src/components/RuleIcon.tsx`
|
||
- Test: `src/components/RuleIcon.test.tsx`
|
||
- Modify: `src/pages/Rules.tsx`
|
||
- Modify: `src/pages/Rules.module.css`
|
||
|
||
**Interfaces:**
|
||
- Produces: `RuleIcon` component — props `{ ruleId: string }` — renders a small inline SVG icon. Falls back to a default icon for an unrecognized `ruleId` (never throws).
|
||
|
||
- [ ] **Step 1: Write the failing test for `RuleIcon`**
|
||
|
||
`src/components/RuleIcon.test.tsx`:
|
||
|
||
```tsx
|
||
import { render } from '@testing-library/react'
|
||
import { describe, it, expect } from 'vitest'
|
||
import RuleIcon from './RuleIcon'
|
||
|
||
describe('RuleIcon', () => {
|
||
it('renders an svg for a known rule id', () => {
|
||
const { container } = render(<RuleIcon ruleId="deposit" />)
|
||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||
})
|
||
|
||
it('falls back to a default icon for an unknown rule id without throwing', () => {
|
||
const { container } = render(<RuleIcon ruleId="not-a-real-rule" />)
|
||
expect(container.querySelector('svg path')).toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `npx vitest run src/components/RuleIcon.test.tsx`
|
||
Expected: FAIL (module doesn't exist)
|
||
|
||
- [ ] **Step 3: Implement `RuleIcon`**
|
||
|
||
`src/components/RuleIcon.tsx`:
|
||
|
||
```tsx
|
||
interface RuleIconProps {
|
||
ruleId: string
|
||
}
|
||
|
||
const ICON_PATHS: Record<string, string> = {
|
||
deposit: 'M4 7h16M4 7a2 2 0 00-2 2v8a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2M4 7l2-4h12l2 4M9 13h1v3H9zM14 13h1v3h-1z',
|
||
cancellation: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||
guests: 'M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m5-8.13a4 4 0 110 8 4 4 0 010-8zm6 4a4 4 0 10-3.2-6.4M5.2 5.6A4 4 0 108.4 12',
|
||
'hair-prep': 'M12 3c-2 3-5 5-5 9a5 5 0 0010 0c0-4-3-6-5-9zM12 12v9',
|
||
'health-safety': 'M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z',
|
||
}
|
||
|
||
function RuleIcon({ ruleId }: RuleIconProps) {
|
||
const d = ICON_PATHS[ruleId] ?? ICON_PATHS.deposit
|
||
|
||
return (
|
||
<svg
|
||
width="28"
|
||
height="28"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d={d} />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
export default RuleIcon
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `npx vitest run src/components/RuleIcon.test.tsx`
|
||
Expected: PASS (2 tests)
|
||
|
||
- [ ] **Step 5: Wire `RuleIcon` into `Rules.tsx` with stagger**
|
||
|
||
Replace `src/pages/Rules.tsx` entirely:
|
||
|
||
```tsx
|
||
import { Link } from 'react-router-dom'
|
||
import ScrollReveal from '../components/ScrollReveal'
|
||
import RuleIcon from '../components/RuleIcon'
|
||
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, index) => (
|
||
<ScrollReveal key={rule.id} delay={index * 0.08}>
|
||
<div className={styles.section}>
|
||
<RuleIcon ruleId={rule.id} />
|
||
<div>
|
||
<h2>{rule.title}</h2>
|
||
<p>{rule.body}</p>
|
||
</div>
|
||
</div>
|
||
</ScrollReveal>
|
||
))}
|
||
<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 6: Update `Rules.module.css` for the icon layout**
|
||
|
||
Replace the `.section` rule (find it in the file — the existing rule sets `margin-bottom: var(--space-lg);`) with:
|
||
|
||
```css
|
||
.section {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--space-md);
|
||
margin-bottom: var(--space-md);
|
||
color: var(--color-gold);
|
||
}
|
||
|
||
.section h2 {
|
||
color: var(--color-gold);
|
||
font-size: 1rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin: 0 0 0.35rem;
|
||
}
|
||
|
||
.section p {
|
||
color: var(--color-charcoal);
|
||
margin: 0;
|
||
}
|
||
```
|
||
|
||
Remove the old standalone `.section h2` rule if it duplicates the one above (check the file — Phase 1's `Rules.module.css` had `.section h2` as a separate rule; consolidate into the block above, don't leave two conflicting `.section h2` rules).
|
||
|
||
- [ ] **Step 7: Run the full suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all tests pass (existing `Rules.test.tsx` checks title/body text and the `/book` link, both still present in the new markup).
|
||
|
||
- [ ] **Step 8: Run the build**
|
||
|
||
Run: `npm run build`
|
||
Expected: succeeds with zero TypeScript errors.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/components/RuleIcon.tsx src/components/RuleIcon.test.tsx src/pages/Rules.tsx src/pages/Rules.module.css
|
||
git commit -m "feat: add per-rule icons and staggered scroll-in to Rules page"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Final verification and manual browser check
|
||
|
||
**Files:**
|
||
- No new files — this task is verification only.
|
||
|
||
- [ ] **Step 1: Run the full automated test suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: every test file passes, including all changes from Tasks 3–9.
|
||
|
||
- [ ] **Step 2: Type-check and build**
|
||
|
||
Run: `npm run build`
|
||
Expected: `tsc -b && vite build` succeeds with zero errors.
|
||
|
||
- [ ] **Step 3: Lint**
|
||
|
||
Run: `npm run lint`
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 4: Manual verification in the browser**
|
||
|
||
Run: `npm run dev`, then open the printed local URL (or drive it with Playwright browser tools if available) and check:
|
||
|
||
- `/` — the hero now shows a real rotating 3D braided-crown scene (not a flat circle); scroll down and confirm the About portrait, Signature Style images, and Shop Highlight product images all render; confirm testimonial avatars render; confirm the dead-whitespace gaps from before are visibly smaller
|
||
- `/shop` — product images render in the grid; clicking a category filter animates a sliding pill behind the active button instead of an instant color swap
|
||
- `/book` — the 4-step progress indicator appears at the top and updates as you move through service → date → slot → contact → confirmation; each step transition fades/slides instead of snapping instantly; service picker cards show style images
|
||
- `/rules` — each rule section shows a small icon next to its title; sections animate in with a slight stagger as you scroll
|
||
- Resize to a narrow (375px) viewport and confirm nothing overflows horizontally, especially the new About portrait layout on Home and the step progress indicator on Book
|
||
- Enable "reduce motion" (OS-level or via injecting a `matchMedia` override) and reload `/` — confirm the hero falls back to the static gradient circle with no WebGL canvas mounted, and Rules sections no longer stagger-animate
|
||
|
||
- [ ] **Step 5: Report any visual issues found**
|
||
|
||
If manual verification finds a layout or visual bug, fix it directly in the relevant page/component file, re-run the full suite and build, and note the fix. Do not leave known visual bugs unaddressed at the end of this task.
|
||
|
||
- [ ] **Step 6: Commit any fixes from Step 5**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "polish: fix visual issues found during manual verification"
|
||
```
|
||
|
||
(Skip this commit if Step 5 found nothing to fix.)
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- **Spec coverage:** 3D hero (Task 3), imagery generation (Task 2) and wiring (Tasks 4, 5, 6), Home spacing/portrait/highlights/testimonials (Task 6), Shop sliding filter + grid spacing (Task 7), Book step progress + animated transitions (Task 8), Rules icons + stagger + typography (Task 9), performance/a11y (canvas `aria-hidden`, route-scoped R3F imports, reduced-motion fallback — Task 3; image `alt` text — Tasks 4–6), final verification (Task 10). All spec sections are covered.
|
||
- **Type consistency:** `Hero3DScene` (Task 3), `ProductCard`/`ServiceCard` (Tasks 4–5), `StepProgress`/`BookingStep` (Task 8), `RuleIcon` (Task 9) are each defined once and referenced by the same name/signature in every later task that uses them.
|
||
- **No placeholders:** every step has literal file contents, exact prompts (Task 2), and exact commands.
|