Files
braidsbymira/docs/superpowers/plans/2026-08-13-phase2d-site-content-and-admin-redesign.md

113 KiB
Raw Blame History

Phase 2D: Site Content Management & Admin Panel Redesign 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: Make the homepage hero, About-Mira bio, testimonials, featured products, footer contact info, and salon rules editable from the admin panel, and give the admin panel itself a professional visual redesign.

Architecture: Three new Postgres tables (site_content singleton, testimonials, rules) plus two new columns on products (featured, featured_order), all under the existing public-read/admin-write RLS pattern. New src/data/* fetch functions feed the public Home/Footer/Rules pages. Three new admin pages (Contenu, Témoignages, Règlements) plus an extended AdminProducts give the admin CRUD/reorder control. A new admin design-token system and shared CSS Module (composed into every admin page's own module) replaces the ad-hoc, unstyled admin look.

Tech Stack: React 19 + TypeScript + Vite, react-router-dom v7, Supabase (Postgres/Auth/Storage), Vitest + React Testing Library, pgTAP, CSS Modules.

Spec: docs/superpowers/specs/2026-08-13-phase2d-site-content-and-admin-redesign-design.md

Global Constraints

  • Admin UUID for all new RLS policies: 2b88676b-f901-459a-9147-b914190c4f81 (current admin, per docs/superpowers/plans/2026-08-08-phase2a-backend-foundation-supabase.md Step 2 and the 2026-08-12214640_replace_admin_uuid migration).
  • RLS pattern: for select using (true) for public read; for all using ((select auth.uid()) = '<uuid>'::uuid) with check (same) for admin write — copied verbatim from every existing table's policy in supabase/migrations/20260808000001_initial_schema.sql.
  • Test Supabase client mocking: mock ../lib/supabase (or ../../lib/supabase from admin pages) directly — never vi.importActual, which crashes with "supabaseUrl is required" in a test env with no .env.
  • vi.fn() used with a typed implementation and spread args must have an explicit generic, e.g. vi.fn<(table: string) => {...}>(...) — an inferred zero-arg vi.fn() invoked via (...args: unknown[]) => mock(...args) fails tsc -b with TS2556.
  • Thrown/returned Supabase errors are rendered via err.message after a typeof err === 'object' && 'message' in err guard, never err instanceof Error (a Postgrest error object is not an Error instance).
  • Icon keys for rules stay fixed to the existing 6 in RuleIcon.tsx (deposit, hair-prep, extensions, lateness, guests, payment) — no free-text or upload.
  • Redesign scope is CSS/layout/shared-component-structure only (class names, composed styles, and button className additions) — no page's data-fetching logic changes as part of a redesign-only task.

Task 1: Migration — new tables, RLS, seed data, pgTAP tests

Files:

  • Create: supabase/migrations/20260813000001_site_content_testimonials_rules.sql
  • Modify: supabase/tests/database/rls.test.sql

Interfaces:

  • Produces: tables public.site_content (columns: id text primary key default 'default', hero_headline text not null, hero_tagline text not null, about_bio text not null, about_portrait_image text not null, contact_email text not null, contact_hours text not null), public.testimonials (id uuid primary key default gen_random_uuid(), quote text not null, author text not null, avatar_image text not null, sort_order integer not null), public.rules (id uuid primary key default gen_random_uuid(), title text not null, body text not null, icon_key text not null, sort_order integer not null); public.products gains featured boolean not null default false and featured_order integer (nullable).

  • Step 1: Write the migration

-- Phase 2D: adds site-editable content tables (site_content, testimonials,
-- rules) and featured-product columns, all following the existing
-- public-read/admin-write RLS pattern from the initial schema. Seed values
-- match what the site currently shows via hardcoded frontend constants, so
-- this migration doesn't blank any live content.

create table public.site_content (
  id text primary key default 'default',
  hero_headline text not null,
  hero_tagline text not null,
  about_bio text not null,
  about_portrait_image text not null,
  contact_email text not null,
  contact_hours text not null
);

alter table public.site_content enable row level security;

create policy "site_content_public_read" on public.site_content
  for select using (true);
create policy "site_content_admin_write" on public.site_content
  for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid)
  with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid);

insert into public.site_content (id, hero_headline, hero_tagline, about_bio, about_portrait_image, contact_email, contact_hours)
values (
  'default',
  'Vos cheveux, votre couronne',
  'Coiffures protectrices par Mira',
  'Avec plus d''une décennie d''expérience derrière le fauteuil, Mira aborde chaque installation comme une œuvre d''art — douce pour le cuir chevelu, précise à la racine, et achevée pour une couronne que vous aurez envie d''afficher.',
  'about/mira-portrait.jpg',
  'hello@braidsbymira.com',
  'Mar–Sam, 9h à 18h'
);

create table public.testimonials (
  id uuid primary key default gen_random_uuid(),
  quote text not null,
  author text not null,
  avatar_image text not null,
  sort_order integer not null
);

alter table public.testimonials enable row level security;

create policy "testimonials_public_read" on public.testimonials
  for select using (true);
create policy "testimonials_admin_write" on public.testimonials
  for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid)
  with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid);

insert into public.testimonials (quote, author, avatar_image, sort_order) values
  ('Mira m''a fait sentir comme une reine sur son fauteuil. Mes tresses ont tenu 8 semaines !', 'Amara T.', 'testimonials/amara.jpg', 0),
  ('La tresseuse la plus douce et la plus précise que j''aie jamais consultée.', 'Keisha R.', 'testimonials/keisha.jpg', 1),
  ('La réservation était si simple, et le résultat parle de lui-même.', 'Nadia F.', 'testimonials/nadia.jpg', 2);

create table public.rules (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  body text not null,
  icon_key text not null,
  sort_order integer not null
);

alter table public.rules enable row level security;

create policy "rules_public_read" on public.rules
  for select using (true);
create policy "rules_admin_write" on public.rules
  for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid)
  with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid);

insert into public.rules (title, body, icon_key, sort_order) values
  ('Acompte', 'Un acompte de 30 $ est exigé afin de confirmer toute réservation. Celui-ci n''est pas remboursable, mais transférable à une date ultérieure sur entente, pour un maximum d''une fois. Toute annulation moins de 48 heures avant le rendez-vous entraînera la perte de l''acompte, qui ne sera ni remboursé ni transféré. En cas de non-présentation (« no-show »), l''acompte est automatiquement perdu.', 'deposit', 0),
  ('Préalables cheveux', 'Prière de garder vos cheveux propres, démêlés et secs, prêts à être tressés. Le blowdry peut être facturé si cette règle n''est pas respectée. Pas de bains d''huiles la veille ou le jour du rendez-vous, s''il vous plaît. Merci de toujours préciser, photo à l''appui, vos caractéristiques spécifiques (cheveux courts, locks, etc.).', 'hair-prep', 1),
  ('Mèches et extensions', 'Les mèches sont à déposer 24 à 48h avant la prestation si ce n''est pas moi qui les fournis. Choisissez de préférence des mèches pré-étirées ; l''étirage des mèches vous sera facturé en fonction du nombre de paquets.', 'extensions', 2),
  ('Retard', 'Les retards sans notification préalable ne sont pas autorisés. Un retard de 15 à 20 minutes entraîne des frais supplémentaires, et à partir de 30 minutes, le rendez-vous est automatiquement annulé.', 'lateness', 3),
  ('Accompagnant', 'Vous êtes tenue de vous présenter seule à votre rendez-vous. Merci de ne pas venir accompagnée sans l''avoir signalé au préalable.', 'guests', 4),
  ('Paiement', 'Le paiement peut être fait en espèces ou par virement Interac.', 'payment', 5);

alter table public.products add column featured boolean not null default false;
alter table public.products add column featured_order integer;
  • Step 2: Add pgTAP tests

Open supabase/tests/database/rls.test.sql. Change select plan(53); to select plan(63);.

Insert the following block right before the final select * from finish(); / rollback; lines at the end of the file:

-- site_content: public read, admin-only write. Singleton row seeded by the
-- migration, so no insert test is needed -- only read and update.
select isnt_empty(
  $$ select * from public.site_content where id = 'default' $$,
  'anon can read site_content'
);

select throws_ok(
  $$ update public.site_content set hero_headline = 'hack' where id = 'default' $$,
  '42501',
  null,
  'anon cannot update site_content'
);

set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
  $$ update public.site_content set hero_headline = 'Updated Headline' where id = 'default' $$,
  'admin can update site_content'
);

-- testimonials: public read, admin-only write.
set local role anon;
select isnt_empty(
  $$ select * from public.testimonials $$,
  'anon can read testimonials'
);

select throws_ok(
  $$ insert into public.testimonials (quote, author, avatar_image, sort_order)
     values ('hack', 'Hacker', 'x.jpg', 99) $$,
  '42501',
  null,
  'anon cannot insert into testimonials'
);

set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
  $$ insert into public.testimonials (quote, author, avatar_image, sort_order)
     values ('Great service!', 'Test Client', 'test.jpg', 99) $$,
  'admin can insert into testimonials'
);

-- rules: public read, admin-only write.
set local role anon;
select isnt_empty(
  $$ select * from public.rules $$,
  'anon can read rules'
);

select throws_ok(
  $$ insert into public.rules (title, body, icon_key, sort_order)
     values ('hack', 'hack', 'deposit', 99) $$,
  '42501',
  null,
  'anon cannot insert into rules'
);

set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
  $$ insert into public.rules (title, body, icon_key, sort_order)
     values ('Test Rule', 'Test body.', 'payment', 99) $$,
  'admin can insert into rules'
);

-- products.featured / featured_order: no new RLS policy (existing
-- products_admin_write already covers all columns), but confirm anon still
-- can't set them and admin can.
set local role anon;
select throws_ok(
  $$ update public.products set featured = true where id = 'edge-control' $$,
  '42501',
  null,
  'anon cannot set products.featured'
);

set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
  $$ update public.products set featured = true, featured_order = 0 where id = 'edge-control' $$,
  'admin can set products.featured and featured_order'
);
  • Step 3: Run local verification

Run: npx supabase db reset Expected: all migrations (including the new one) apply cleanly, no errors.

Run: npx supabase test db Expected: Result: PASS, Tests=63, Failed: 0.

  • Step 4: Commit
git add supabase/migrations/20260813000001_site_content_testimonials_rules.sql supabase/tests/database/rls.test.sql
git commit -m "feat: add site_content, testimonials, and rules tables; featured products"

Task 2: Admin design tokens and AdminLayout/AdminLogin redesign

Files:

  • Create: src/pages/admin/AdminApp.module.css
  • Create: src/pages/admin/adminShared.module.css
  • Modify: src/pages/admin/AdminApp.tsx
  • Modify: src/pages/admin/AdminLayout.module.css
  • Modify: src/pages/admin/AdminLogin.module.css

Interfaces:

  • Produces: CSS custom properties --admin-bg, --admin-surface, --admin-border, --admin-text, --admin-text-muted, --admin-primary, --admin-primary-hover, --admin-danger, --admin-radius, --admin-shadow, --admin-font, --admin-space-xs/sm/md/lg/xl, scoped to a .adminRoot wrapper class every later admin task builds pages inside. Produces composable classes page, card, table, form, error, button, buttonSecondary, buttonDanger in adminShared.module.css, which every later admin page's own .module.css composes from (composes: table from './adminShared.module.css').

This task establishes the visual system before any new admin page is built, so every subsequent admin page (Tasks 9-12) is built against the new design from the start rather than needing a second pass.

  • Step 1: Create the token file
/* src/pages/admin/AdminApp.module.css */
.adminRoot {
  --admin-bg: #F4F5F7;
  --admin-surface: #FFFFFF;
  --admin-border: #E1E4E9;
  --admin-text: #1F2430;
  --admin-text-muted: #6B7280;
  --admin-primary: #2A5C8A;
  --admin-primary-hover: #1E4566;
  --admin-danger: #B3261E;
  --admin-radius: 8px;
  --admin-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
  --admin-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --admin-space-xs: 0.25rem;
  --admin-space-sm: 0.5rem;
  --admin-space-md: 1rem;
  --admin-space-lg: 1.5rem;
  --admin-space-xl: 2.5rem;

  min-height: 100vh;
  background: var(--admin-bg);
  color: var(--admin-text);
  font-family: var(--admin-font);
}

Scoped to .adminRoot (not global :root) so these tokens never leak into or collide with the public site's src/styles/tokens.css, which the admin bundle doesn't import and vice versa.

  • Step 2: Create the shared component-style file
/* src/pages/admin/adminShared.module.css */
.page {
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-lg);
}

.card {
  background: var(--admin-surface);
  border: 1px solid var(--admin-border);
  border-radius: var(--admin-radius);
  box-shadow: var(--admin-shadow);
}

.table {
  composes: card;
  width: 100%;
  border-collapse: collapse;
}

.table th {
  text-align: left;
  padding: var(--admin-space-sm) var(--admin-space-md);
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--admin-text-muted);
  border-bottom: 1px solid var(--admin-border);
  background: var(--admin-bg);
}

.table td {
  padding: var(--admin-space-sm) var(--admin-space-md);
  border-bottom: 1px solid var(--admin-border);
  vertical-align: middle;
}

.table tbody tr:last-child td {
  border-bottom: none;
}

.table tbody tr:hover {
  background: var(--admin-bg);
}

.form {
  composes: card;
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-md);
  max-width: 480px;
  padding: var(--admin-space-lg);
}

.form label {
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-xs);
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--admin-text);
}

.form input,
.form select,
.form textarea {
  padding: var(--admin-space-sm);
  border: 1px solid var(--admin-border);
  border-radius: calc(var(--admin-radius) / 2);
  font: inherit;
  color: var(--admin-text);
  background: var(--admin-surface);
}

.form input:focus,
.form select:focus,
.form textarea:focus {
  outline: 2px solid var(--admin-primary);
  outline-offset: 1px;
}

.form textarea {
  min-height: 6rem;
  resize: vertical;
}

.button {
  font: inherit;
  font-weight: 600;
  padding: var(--admin-space-sm) var(--admin-space-md);
  border-radius: calc(var(--admin-radius) / 2);
  border: 1px solid transparent;
  cursor: pointer;
  background: var(--admin-primary);
  color: white;
}

.button:hover {
  background: var(--admin-primary-hover);
}

.button:disabled {
  opacity: 0.6;
  cursor: default;
}

.buttonSecondary {
  composes: button;
  font-weight: 500;
  background: var(--admin-surface);
  color: var(--admin-text);
  border-color: var(--admin-border);
}

.buttonSecondary:hover {
  background: var(--admin-bg);
}

.buttonDanger {
  composes: buttonSecondary;
  color: var(--admin-danger);
}

.buttonDanger:hover {
  background: #FEF2F2;
  border-color: var(--admin-danger);
}

.error {
  color: var(--admin-danger);
  font-size: 0.875rem;
}
  • Step 3: Wrap AdminApp in the token scope

Modify src/pages/admin/AdminApp.tsx:

import { Routes, Route, Navigate } from 'react-router-dom'
import ProtectedRoute from '../../components/ProtectedRoute'
import AdminLogin from './AdminLogin'
import AdminLayout from './AdminLayout'
import AdminProducts from './AdminProducts'
import AdminServices from './AdminServices'
import AdminSchedule from './AdminSchedule'
import AdminBookings from './AdminBookings'
import AdminInquiries from './AdminInquiries'
import styles from './AdminApp.module.css'

function AdminApp() {
  return (
    <div className={styles.adminRoot}>
      <Routes>
        <Route path="login" element={<AdminLogin />} />
        <Route element={<ProtectedRoute />}>
          <Route element={<AdminLayout />}>
            <Route index element={<Navigate to="/admin/products" replace />} />
            <Route path="products" element={<AdminProducts />} />
            <Route path="services" element={<AdminServices />} />
            <Route path="schedule" element={<AdminSchedule />} />
            <Route path="bookings" element={<AdminBookings />} />
            <Route path="inquiries" element={<AdminInquiries />} />
            <Route path="*" element={<Navigate to="/admin/products" replace />} />
          </Route>
        </Route>
      </Routes>
    </div>
  )
}

export default AdminApp

(Task 14 below adds the 3 new routes to this same file.)

  • Step 4: Redesign AdminLayout.module.css

Replace its full contents:

.layout {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 240px;
  flex-shrink: 0;
  background: var(--admin-surface);
  border-right: 1px solid var(--admin-border);
  padding: var(--admin-space-lg) var(--admin-space-md);
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-xl);
}

.brand {
  font-weight: 700;
  font-size: 1.125rem;
  color: var(--admin-text);
  padding: 0 var(--admin-space-sm);
}

.sidebar nav {
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-xs);
}

.navLink {
  color: var(--admin-text-muted);
  text-decoration: none;
  padding: var(--admin-space-sm);
  border-radius: var(--admin-radius);
  font-size: 0.9375rem;
  font-weight: 500;
}

.navLink:hover {
  background: var(--admin-bg);
  color: var(--admin-text);
}

.navLinkActive {
  background: var(--admin-primary);
  color: white;
}

.navLinkActive:hover {
  background: var(--admin-primary-hover);
  color: white;
}

.signOut {
  margin-top: auto;
  background: none;
  border: 1px solid var(--admin-border);
  border-radius: var(--admin-radius);
  color: var(--admin-text-muted);
  padding: var(--admin-space-sm);
  cursor: pointer;
  font: inherit;
}

.signOut:hover {
  border-color: var(--admin-danger);
  color: var(--admin-danger);
}

.content {
  flex: 1;
  padding: var(--admin-space-xl) var(--admin-space-lg);
  max-width: 1100px;
}

No changes needed to AdminLayout.tsx — it already applies styles.navLink and (conditionally) styles.navLinkActive together via `${styles.navLink} ${styles.navLinkActive}`, which continues to work unchanged with the new rules.

  • Step 5: Redesign AdminLogin.module.css

Replace its full contents:

.page {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--admin-bg);
  padding: var(--admin-space-lg);
}

.form {
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-md);
  width: 100%;
  max-width: 340px;
  background: var(--admin-surface);
  border: 1px solid var(--admin-border);
  border-radius: var(--admin-radius);
  box-shadow: var(--admin-shadow);
  padding: var(--admin-space-xl) var(--admin-space-lg);
}

.form h1 {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--admin-text);
  margin: 0 0 var(--admin-space-sm);
}

.field {
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-xs);
}

.field label {
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--admin-text);
}

.field input {
  padding: var(--admin-space-sm);
  border: 1px solid var(--admin-border);
  border-radius: calc(var(--admin-radius) / 2);
  font: inherit;
  background: var(--admin-surface);
  color: var(--admin-text);
}

.field input:focus {
  outline: 2px solid var(--admin-primary);
  outline-offset: 1px;
}

.error {
  color: var(--admin-danger);
  font-size: 0.875rem;
}

.form button {
  background: var(--admin-primary);
  color: white;
  border: none;
  border-radius: calc(var(--admin-radius) / 2);
  padding: var(--admin-space-sm) var(--admin-space-md);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.form button:hover {
  background: var(--admin-primary-hover);
}

.form button:disabled {
  opacity: 0.6;
  cursor: default;
}

No AdminLogin.tsx changes needed — its JSX structure (.page > .form > h1 + .field + .error + button) already matches these selectors.

  • Step 6: Verify

Run: npm run build && npx vitest run src/pages/admin/AdminLogin.test.tsx src/pages/admin/AdminApp.test.tsx Expected: build succeeds, both test files pass unchanged (no assertions reference styling).

  • Step 7: Commit
git add src/pages/admin/AdminApp.module.css src/pages/admin/adminShared.module.css src/pages/admin/AdminApp.tsx src/pages/admin/AdminLayout.module.css src/pages/admin/AdminLogin.module.css
git commit -m "feat: introduce admin design tokens and redesign AdminLayout/AdminLogin"

Task 3: fetchSiteContent data function

Files:

  • Create: src/data/siteContent.ts
  • Test: src/data/siteContent.test.ts

Interfaces:

  • Produces: export interface SiteContent { heroHeadline: string; heroTagline: string; aboutBio: string; aboutPortraitImage: string; contactEmail: string; contactHours: string } and export async function fetchSiteContent(): Promise<SiteContent>.

  • Consumes: resolveImageUrl from ../lib/resolveImageUrl (Phase 2C, unchanged), supabase from ../lib/supabase.

  • Step 1: Write the failing test

// src/data/siteContent.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'

const singleMock = vi.fn()
const eqMock = vi.fn(() => ({ single: singleMock }))
const selectMock = vi.fn(() => ({ eq: eqMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`)

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => resolveImageUrlMock(path),
}))

const ROW = {
  id: 'default',
  hero_headline: 'Vos cheveux, votre couronne',
  hero_tagline: 'Coiffures protectrices par Mira',
  about_bio: 'Bio text.',
  about_portrait_image: 'about/mira-portrait.jpg',
  contact_email: 'hello@braidsbymira.com',
  contact_hours: 'Mar–Sam, 9h à 18h',
}

import { fetchSiteContent, type SiteContent } from './siteContent'

describe('fetchSiteContent', () => {
  beforeEach(() => {
    singleMock.mockReset()
    eqMock.mockClear()
    selectMock.mockClear()
    fromMock.mockClear()
    resolveImageUrlMock.mockClear()
  })

  it('fetches the default row and maps snake_case columns to the SiteContent shape', async () => {
    singleMock.mockResolvedValue({ data: ROW, error: null })

    const content = await fetchSiteContent()

    expect(fromMock).toHaveBeenCalledWith('site_content')
    expect(eqMock).toHaveBeenCalledWith('id', 'default')
    expect(resolveImageUrlMock).toHaveBeenCalledWith('about/mira-portrait.jpg')
    expect(content).toEqual<SiteContent>({
      heroHeadline: 'Vos cheveux, votre couronne',
      heroTagline: 'Coiffures protectrices par Mira',
      aboutBio: 'Bio text.',
      aboutPortraitImage: 'https://cdn.example/about/mira-portrait.jpg',
      contactEmail: 'hello@braidsbymira.com',
      contactHours: 'Mar–Sam, 9h à 18h',
    })
  })

  it('throws when the fetch fails', async () => {
    singleMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
    await expect(fetchSiteContent()).rejects.toEqual({ message: 'network error' })
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/data/siteContent.test.ts Expected: FAIL — ./siteContent module does not exist.

  • Step 3: Implement
// src/data/siteContent.ts
import { supabase } from '../lib/supabase'
import { resolveImageUrl } from '../lib/resolveImageUrl'

export interface SiteContent {
  heroHeadline: string
  heroTagline: string
  aboutBio: string
  aboutPortraitImage: string
  contactEmail: string
  contactHours: string
}

interface SiteContentRow {
  hero_headline: string
  hero_tagline: string
  about_bio: string
  about_portrait_image: string
  contact_email: string
  contact_hours: string
}

export async function fetchSiteContent(): Promise<SiteContent> {
  const { data, error } = await supabase.from('site_content').select('*').eq('id', 'default').single()
  if (error) throw error
  const row = data as SiteContentRow
  return {
    heroHeadline: row.hero_headline,
    heroTagline: row.hero_tagline,
    aboutBio: row.about_bio,
    aboutPortraitImage: resolveImageUrl(row.about_portrait_image),
    contactEmail: row.contact_email,
    contactHours: row.contact_hours,
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/data/siteContent.test.ts Expected: PASS (2 tests).

  • Step 5: Commit
git add src/data/siteContent.ts src/data/siteContent.test.ts
git commit -m "feat: add fetchSiteContent data function"

Task 4: fetchTestimonials data function

Files:

  • Create: src/data/testimonials.ts
  • Test: src/data/testimonials.test.ts

Interfaces:

  • Produces: export interface Testimonial { id: string; quote: string; author: string; avatarImage: string } and export async function fetchTestimonials(): Promise<Testimonial[]>.

  • Step 1: Write the failing test

// src/data/testimonials.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`)

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => resolveImageUrlMock(path),
}))

const ROW = { id: 'a1', quote: 'Great!', author: 'Amara T.', avatar_image: 'testimonials/amara.jpg', sort_order: 0 }

import { fetchTestimonials, type Testimonial } from './testimonials'

describe('fetchTestimonials', () => {
  beforeEach(() => {
    orderMock.mockReset()
    selectMock.mockClear()
    fromMock.mockClear()
    resolveImageUrlMock.mockClear()
  })

  it('fetches testimonials ordered by sort_order and maps to the Testimonial shape', async () => {
    orderMock.mockResolvedValue({ data: [ROW], error: null })

    const testimonials = await fetchTestimonials()

    expect(fromMock).toHaveBeenCalledWith('testimonials')
    expect(orderMock).toHaveBeenCalledWith('sort_order')
    expect(resolveImageUrlMock).toHaveBeenCalledWith('testimonials/amara.jpg')
    expect(testimonials).toEqual<Testimonial[]>([
      { id: 'a1', quote: 'Great!', author: 'Amara T.', avatarImage: 'https://cdn.example/testimonials/amara.jpg' },
    ])
  })

  it('throws when the fetch fails', async () => {
    orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
    await expect(fetchTestimonials()).rejects.toEqual({ message: 'network error' })
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/data/testimonials.test.ts Expected: FAIL — ./testimonials module does not exist.

  • Step 3: Implement
// src/data/testimonials.ts
import { supabase } from '../lib/supabase'
import { resolveImageUrl } from '../lib/resolveImageUrl'

export interface Testimonial {
  id: string
  quote: string
  author: string
  avatarImage: string
}

interface TestimonialRow {
  id: string
  quote: string
  author: string
  avatar_image: string
  sort_order: number
}

export async function fetchTestimonials(): Promise<Testimonial[]> {
  const { data, error } = await supabase.from('testimonials').select('*').order('sort_order')
  if (error) throw error
  return ((data ?? []) as TestimonialRow[]).map((row) => ({
    id: row.id,
    quote: row.quote,
    author: row.author,
    avatarImage: resolveImageUrl(row.avatar_image),
  }))
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/data/testimonials.test.ts Expected: PASS (2 tests).

  • Step 5: Commit
git add src/data/testimonials.ts src/data/testimonials.test.ts
git commit -m "feat: add fetchTestimonials data function"

Task 5: Rewrite rules.ts to fetchRules; rename RuleIcon's prop to iconKey

Files:

  • Modify: src/data/rules.ts
  • Modify: src/components/RuleIcon.tsx
  • Test: src/data/rules.test.ts (new)
  • Modify: src/components/RuleIcon.test.tsx

Interfaces:

  • Produces: export interface RuleSection { id: string; title: string; body: string; iconKey: string } and export async function fetchRules(): Promise<RuleSection[]> (replaces the static export const rules array). RuleIcon takes { iconKey: string } instead of { ruleId: string }.

  • Consumes by later tasks: Task 8 (Rules.tsx) calls fetchRules() and renders <RuleIcon iconKey={rule.iconKey} />; Task 12 (AdminRules) uses the same iconKey values for its dropdown.

  • Step 1: Write the failing data-function test

// src/data/rules.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))

const ROW = { id: 'r1', title: 'Acompte', body: 'Body text.', icon_key: 'deposit', sort_order: 0 }

import { fetchRules, type RuleSection } from './rules'

describe('fetchRules', () => {
  beforeEach(() => {
    orderMock.mockReset()
    selectMock.mockClear()
    fromMock.mockClear()
  })

  it('fetches rules ordered by sort_order and maps to the RuleSection shape', async () => {
    orderMock.mockResolvedValue({ data: [ROW], error: null })

    const rules = await fetchRules()

    expect(fromMock).toHaveBeenCalledWith('rules')
    expect(orderMock).toHaveBeenCalledWith('sort_order')
    expect(rules).toEqual<RuleSection[]>([
      { id: 'r1', title: 'Acompte', body: 'Body text.', iconKey: 'deposit' },
    ])
  })

  it('throws when the fetch fails', async () => {
    orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
    await expect(fetchRules()).rejects.toEqual({ message: 'network error' })
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/data/rules.test.ts Expected: FAIL — fetchRules is not exported (current file only exports a static rules array).

  • Step 3: Rewrite src/data/rules.ts
import { supabase } from '../lib/supabase'

export interface RuleSection {
  id: string
  title: string
  body: string
  iconKey: string
}

interface RuleRow {
  id: string
  title: string
  body: string
  icon_key: string
  sort_order: number
}

export async function fetchRules(): Promise<RuleSection[]> {
  const { data, error } = await supabase.from('rules').select('*').order('sort_order')
  if (error) throw error
  return ((data ?? []) as RuleRow[]).map((row) => ({
    id: row.id,
    title: row.title,
    body: row.body,
    iconKey: row.icon_key,
  }))
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/data/rules.test.ts Expected: PASS (2 tests).

  • Step 5: Update RuleIcon to take iconKey

Modify src/components/RuleIcon.tsx:

interface RuleIconProps {
  iconKey: 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',
  'hair-prep': 'M12 3c-2 3-5 5-5 9a5 5 0 0010 0c0-4-3-6-5-9zM12 12v9',
  extensions: 'M6 4c2 4 2 8 0 12M12 4c2 4 2 8 0 12M18 4c2 4 2 8 0 12',
  lateness: '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',
  payment: 'M3 6h18v12H3zM3 10h18M7 15h4',
}

function RuleIcon({ iconKey }: RuleIconProps) {
  const d = ICON_PATHS[iconKey] ?? 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 6: Update RuleIcon.test.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 icon key', () => {
    const { container } = render(<RuleIcon iconKey="deposit" />)
    expect(container.querySelector('svg')).toBeInTheDocument()
  })

  it('falls back to a default icon for an unknown icon key without throwing', () => {
    const { container } = render(<RuleIcon iconKey="not-a-real-key" />)
    expect(container.querySelector('svg path')).toBeInTheDocument()
  })
})
  • Step 7: Run both test files

Run: npx vitest run src/data/rules.test.ts src/components/RuleIcon.test.tsx Expected: PASS (2 + 2 tests). Note: src/pages/Rules.tsx and src/pages/Rules.test.tsx still reference the old static rules export and ruleId prop at this point in the plan — they are rewritten in Task 9 and will fail to compile/pass until then. This is expected and resolved within this same plan, not left as a dangling break at the end.

  • Step 8: Commit
git add src/data/rules.ts src/data/rules.test.ts src/components/RuleIcon.tsx src/components/RuleIcon.test.tsx
git commit -m "feat: replace static rules array with fetchRules; RuleIcon takes iconKey"

Task 6: Extend products.ts with featured/featuredOrder

Files:

  • Modify: src/data/products.ts
  • Modify: src/data/products.test.ts

Interfaces:

  • Produces: Product gains featured: boolean and featuredOrder: number | null.

  • Consumes by later tasks: Task 7 (Home.tsx) filters/sorts on these two fields; Task 13 (AdminProducts) reads/writes featured/featured_order.

  • Step 1: Update the test's expected shape (write the failing assertion)

Modify src/data/products.test.ts's ROW constant and the toEqual assertion in the first fetchProducts test:

const ROW = {
  id: 'edge-control',
  name: 'Gel Contour Soyeux',
  category: 'hair-care',
  price: 12,
  image: 'products/edge-control.jpg',
  description: 'Tenue longue durée.',
  in_stock: true,
  featured: true,
  featured_order: 0,
}
  it('fetches products ordered by name and maps snake_case columns to the Product shape', async () => {
    orderMock.mockResolvedValue({ data: [ROW], error: null })

    const products = await fetchProducts()

    expect(fromMock).toHaveBeenCalledWith('products')
    expect(orderMock).toHaveBeenCalledWith('name')
    expect(resolveImageUrlMock).toHaveBeenCalledWith('products/edge-control.jpg')
    expect(products).toEqual<Product[]>([
      {
        id: 'edge-control',
        name: 'Gel Contour Soyeux',
        category: 'hair-care',
        price: 12,
        image: 'https://cdn.example/products/edge-control.jpg',
        description: 'Tenue longue durée.',
        inStock: true,
        featured: true,
        featuredOrder: 0,
      },
    ])
  })

Also add featured: false, featuredOrder: null to both entries in the getProductsByCategory describe block's products array (so the type continues to satisfy Product[]):

describe('getProductsByCategory', () => {
  const products: Product[] = [
    { id: 'a', name: 'A', category: 'hair-care', price: 1, image: '', description: '', inStock: true, featured: false, featuredOrder: null },
    { id: 'b', name: 'B', category: 'tools', price: 1, image: '', description: '', inStock: true, featured: false, featuredOrder: null },
  ]
  // ... rest unchanged
  • Step 2: Run test to verify it fails

Run: npx vitest run src/data/products.test.ts Expected: FAIL — actual fetchProducts() result is missing featured/featuredOrder.

  • Step 3: Implement

Modify src/data/products.ts:

import { supabase } from '../lib/supabase'
import { resolveImageUrl } from '../lib/resolveImageUrl'

export interface Product {
  id: string
  name: string
  category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
  price: number
  image: string
  description: string
  inStock: boolean
  featured: boolean
  featuredOrder: number | null
}

interface ProductRow {
  id: string
  name: string
  category: Product['category']
  price: number
  image: string
  description: string
  in_stock: boolean
  featured: boolean
  featured_order: number | null
}

export async function fetchProducts(): Promise<Product[]> {
  const { data, error } = await supabase.from('products').select('*').order('name')
  if (error) throw error
  return ((data ?? []) as ProductRow[]).map((row) => ({
    id: row.id,
    name: row.name,
    category: row.category,
    price: row.price,
    image: resolveImageUrl(row.image),
    description: row.description,
    inStock: row.in_stock,
    featured: row.featured,
    featuredOrder: row.featured_order,
  }))
}

export function getProductsByCategory(products: Product[], category: Product['category'] | 'all'): Product[] {
  if (category === 'all') return products
  return products.filter((p) => p.category === category)
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/data/products.test.ts Expected: PASS (4 tests).

  • Step 5: Commit
git add src/data/products.ts src/data/products.test.ts
git commit -m "feat: extend Product with featured/featuredOrder"

Files:

  • Modify: src/pages/Home.tsx
  • Modify: src/pages/Home.test.tsx

Interfaces:

  • Consumes: fetchSiteContent (Task 3), fetchTestimonials (Task 4), Product.featured/featuredOrder (Task 6).

  • Step 1: Update the failing test

Modify src/pages/Home.test.tsx in full:

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, vi, beforeEach } from 'vitest'

const productsOrderMock = vi.fn()
const servicesOrderMock = vi.fn()
const testimonialsOrderMock = vi.fn()
const siteContentSingleMock = vi.fn()
const siteContentEqMock = vi.fn(() => ({ single: siteContentSingleMock }))
const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } | { eq: typeof siteContentEqMock } }>((table: string) => {
  if (table === 'products') return { select: () => ({ order: productsOrderMock }) }
  if (table === 'services') return { select: () => ({ order: servicesOrderMock }) }
  if (table === 'testimonials') return { select: () => ({ order: testimonialsOrderMock }) }
  if (table === 'site_content') return { select: () => ({ eq: siteContentEqMock }) }
  throw new Error(`unexpected table: ${table}`)
})

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => path,
}))

const PRODUCT_ROWS = [
  { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true, featured: true, featured_order: 0 },
  { id: 'braid-oil', name: 'Huile Nourrissante', category: 'hair-care', price: 16, image: '', description: '', in_stock: true, featured: false, featured_order: null },
]
const SERVICE_ROWS = [
  { id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' },
]
const TESTIMONIAL_ROWS = [
  { id: 't1', quote: 'Fantastique service !', author: 'Amara T.', avatar_image: '', sort_order: 0 },
]
const SITE_CONTENT_ROW = {
  id: 'default',
  hero_headline: 'Vos cheveux, votre couronne',
  hero_tagline: 'Coiffures protectrices par Mira',
  about_bio: 'Bio de test.',
  about_portrait_image: '',
  contact_email: 'hello@braidsbymira.com',
  contact_hours: 'Mar–Sam, 9h à 18h',
}

import Home from './Home'
import Book from './Book'

function renderHome() {
  return render(
    <MemoryRouter initialEntries={['/']}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/book" element={<Book />} />
      </Routes>
    </MemoryRouter>,
  )
}

describe('Home page', () => {
  beforeEach(() => {
    productsOrderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null })
    servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null })
    testimonialsOrderMock.mockReset().mockResolvedValue({ data: TESTIMONIAL_ROWS, error: null })
    siteContentSingleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null })
    fromMock.mockClear()
  })

  it('renders the hero headline and all signature styles', async () => {
    renderHome()
    expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
    for (const service of SERVICE_ROWS) {
      expect(await screen.findByText(service.name)).toBeInTheDocument()
    }
  })

  it('renders only featured products in shop highlights, and a link to the rules page', async () => {
    renderHome()
    expect(await screen.findByText('Gel Contour Soyeux')).toBeInTheDocument()
    expect(screen.queryByText('Huile Nourrissante')).not.toBeInTheDocument()
    expect(screen.getByRole('link', { name: /voir toute la boutique/i })).toHaveAttribute('href', '/shop')
    expect(screen.getByRole('link', { name: /règlements du salon/i })).toHaveAttribute('href', '/rules')
  })

  it('renders testimonials from site content', async () => {
    renderHome()
    expect(await screen.findByText(/fantastique service/i)).toBeInTheDocument()
    expect(screen.getByText('Amara T.')).toBeInTheDocument()
  })

  it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => {
    renderHome()
    const button = await screen.findByRole('button', { name: new RegExp(SERVICE_ROWS[0].name) })
    await userEvent.click(button)
    expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument()
  })

  it('renders the About Mira portrait and bio from site content', async () => {
    renderHome()
    expect(await screen.findByText('Bio de test.')).toBeInTheDocument()
    expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument()
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/Home.test.tsx Expected: FAIL — Home.tsx doesn't yet fetch testimonials/site_content, still shows the hardcoded hero/testimonials/first-3-in-stock-products.

  • Step 3: Implement

Replace src/pages/Home.tsx in full:

import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { motion, useScroll, useTransform, useReducedMotion } from 'framer-motion'
import Hero3DScene from '../components/Hero3DScene'
import ScrollReveal from '../components/ScrollReveal'
import ServiceCard from '../components/ServiceCard'
import ProductCard from '../components/ProductCard'
import { fetchServices, type Service } from '../data/services'
import { fetchProducts, type Product } from '../data/products'
import { fetchTestimonials, type Testimonial } from '../data/testimonials'
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
import styles from './Home.module.css'

function Home() {
  const navigate = useNavigate()
  const { scrollYProgress } = useScroll()
  const prefersReducedMotion = useReducedMotion()
  const portraitY = useTransform(scrollYProgress, [0, 1], prefersReducedMotion ? [0, 0] : [0, -40])

  const [services, setServices] = useState<Service[]>([])
  const [products, setProducts] = useState<Product[]>([])
  const [testimonials, setTestimonials] = useState<Testimonial[]>([])
  const [content, setContent] = useState<SiteContent | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')

  useEffect(() => {
    async function load() {
      try {
        const [servicesResult, productsResult, testimonialsResult, contentResult] = await Promise.all([
          fetchServices(),
          fetchProducts(),
          fetchTestimonials(),
          fetchSiteContent(),
        ])
        setServices(servicesResult)
        setProducts(productsResult)
        setTestimonials(testimonialsResult)
        setContent(contentResult)
      } catch (err) {
        setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
      } finally {
        setLoading(false)
      }
    }
    void load()
  }, [])

  const featuredProducts = products
    .filter((p) => p.featured)
    .sort((a, b) => (a.featuredOrder ?? 0) - (b.featuredOrder ?? 0))

  return (
    <div>
      <section className={styles.hero}>
        <Hero3DScene />
        <h1 className={styles.headline}>{content?.heroHeadline ?? 'Vos cheveux, votre couronne'}</h1>
        <p className={styles.tagline}>{content?.heroTagline ?? 'Coiffures protectrices par Mira'}</p>
        <div className={styles.ctas}>
          <Link to="/book">Réserver maintenant</Link>
          <Link to="/shop">Voir les produits</Link>
        </div>
      </section>

      {loading && <p>Chargement…</p>}
      {error && <p className={styles.error}>{error}</p>}

      <ScrollReveal>
        <section className={styles.altSection}>
          <div className={styles.aboutGrid}>
            <motion.img
              src={content?.aboutPortraitImage}
              alt="Mira, propriétaire et coiffeuse chez Braids by Mira"
              className={styles.portrait}
              style={{ y: portraitY }}
            />
            <div>
              <h2>À propos de Mira</h2>
              <p>{content?.aboutBio}</p>
            </div>
          </div>
        </section>
      </ScrollReveal>

      <ScrollReveal>
        <section className={styles.section}>
          <h2>Styles Signature</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>Sélection boutique</h2>
          <div className={styles.row}>
            {featuredProducts.map((product) => (
              <ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
            ))}
          </div>
          <p><Link to="/shop">Voir toute la boutique</Link></p>
        </section>
      </ScrollReveal>

      <ScrollReveal>
        <section className={styles.section}>
          <h2>Avis de nos clientes</h2>
          <div className={styles.row}>
            {testimonials.map((t) => (
              <blockquote key={t.id} className={styles.testimonial}>
                <img src={t.avatarImage} alt={t.author} className={styles.avatar} />
                <p>&ldquo;{t.quote}&rdquo;</p>
                <cite>{t.author}</cite>
              </blockquote>
            ))}
          </div>
        </section>
      </ScrollReveal>

      <section className={styles.banner}>
        <h2>Avant de réserver</h2>
        <p>
          Merci de prendre un instant pour lire les <Link to="/rules">règlements du salon</Link> avant de réserver votre rendez-vous.
        </p>
      </section>
    </div>
  )
}

export default Home
  • Step 4: Run test to verify it passes

Run: npx vitest run src/pages/Home.test.tsx Expected: PASS (5 tests).

  • Step 5: Commit
git add src/pages/Home.tsx src/pages/Home.test.tsx
git commit -m "feat: wire Home page to live site content, testimonials, and featured products"

Task 8: Wire Footer.tsx to site content

Files:

  • Modify: src/components/Footer.tsx
  • Modify: src/components/Footer.test.tsx

Interfaces:

  • Consumes: fetchSiteContent (Task 3).

  • Step 1: Update the failing test

Replace src/components/Footer.test.tsx in full:

import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const singleMock = vi.fn()
const eqMock = vi.fn(() => ({ single: singleMock }))
const selectMock = vi.fn(() => ({ eq: eqMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => path,
}))

const SITE_CONTENT_ROW = {
  id: 'default',
  hero_headline: 'Vos cheveux, votre couronne',
  hero_tagline: 'Coiffures protectrices par Mira',
  about_bio: '',
  about_portrait_image: '',
  contact_email: 'hello@braidsbymira.com',
  contact_hours: 'Mar–Sam, 9h à 18h',
}

import Footer from './Footer'

describe('Footer', () => {
  beforeEach(() => {
    singleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null })
    eqMock.mockClear()
    selectMock.mockClear()
    fromMock.mockClear()
  })

  it('renders contact info and quick links from site content', async () => {
    render(<MemoryRouter><Footer /></MemoryRouter>)
    expect(screen.getAllByText(/braids by mira/i).length).toBeGreaterThan(0)
    expect(await screen.findByRole('link', { name: 'hello@braidsbymira.com' })).toHaveAttribute('href', 'mailto:hello@braidsbymira.com')
    expect(screen.getByText('Mar–Sam, 9h à 18h')).toBeInTheDocument()
    expect(screen.getByRole('link', { name: /boutique/i })).toHaveAttribute('href', '/shop')
    expect(screen.getByRole('link', { name: /règlements/i })).toHaveAttribute('href', '/rules')
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/components/Footer.test.tsx Expected: FAIL — Footer.tsx still renders the hardcoded email/hours, doesn't call supabase.from.

  • Step 3: Implement

Replace src/components/Footer.tsx in full:

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
import styles from './Footer.module.css'

function Footer() {
  const [content, setContent] = useState<SiteContent | null>(null)

  useEffect(() => {
    async function load() {
      try {
        setContent(await fetchSiteContent())
      } catch {
        // Footer content is non-critical; leave contact info blank on failure
        // rather than surfacing an error on every single page.
      }
    }
    void load()
  }, [])

  return (
    <footer className={styles.footer}>
      <div className={styles.inner}>
        <div className={styles.brand}>
          <p className={styles.wordmark}>Braids by Mira</p>
          <p className={styles.tagline}>Vos cheveux, votre couronne</p>
        </div>

        <div className={styles.column}>
          <p className={styles.columnTitle}>Contact</p>
          {content && (
            <>
              <a href={`mailto:${content.contactEmail}`}>{content.contactEmail}</a>
              <p>{content.contactHours}</p>
            </>
          )}
        </div>

        <div className={styles.column}>
          <p className={styles.columnTitle}>Navigation</p>
          <ul className={styles.links}>
            <li><Link to="/">Accueil</Link></li>
            <li><Link to="/shop">Boutique</Link></li>
            <li><Link to="/book">Réserver</Link></li>
            <li><Link to="/rules">Règlements</Link></li>
          </ul>
        </div>
      </div>

      <p className={styles.copyright}>&copy; {new Date().getFullYear()} Braids by Mira. Tous droits réservés.</p>
    </footer>
  )
}

export default Footer
  • Step 4: Run test to verify it passes

Run: npx vitest run src/components/Footer.test.tsx Expected: PASS (1 test).

  • Step 5: Commit
git add src/components/Footer.tsx src/components/Footer.test.tsx
git commit -m "feat: wire Footer contact info to live site content"

Task 9: Wire Rules.tsx to fetchRules

Files:

  • Modify: src/pages/Rules.tsx
  • Modify: src/pages/Rules.test.tsx

Interfaces:

  • Consumes: fetchRules and RuleSection (Task 5), RuleIcon's iconKey prop (Task 5).

  • Step 1: Update the failing test

Replace src/pages/Rules.test.tsx in full:

import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))

const RULE_ROWS = [
  { id: 'r1', title: 'Acompte', body: 'Un acompte est requis.', icon_key: 'deposit', sort_order: 0 },
  { id: 'r2', title: 'Retard', body: 'Les retards ont des frais.', icon_key: 'lateness', sort_order: 1 },
]

import Rules from './Rules'

describe('Rules page', () => {
  beforeEach(() => {
    orderMock.mockReset().mockResolvedValue({ data: RULE_ROWS, error: null })
    selectMock.mockClear()
    fromMock.mockClear()
  })

  it('renders every rule section title and body', async () => {
    render(<MemoryRouter><Rules /></MemoryRouter>)
    for (const rule of RULE_ROWS) {
      expect(await screen.findByText(rule.title)).toBeInTheDocument()
      expect(screen.getByText(rule.body)).toBeInTheDocument()
    }
  })

  it('links back to booking', async () => {
    render(<MemoryRouter><Rules /></MemoryRouter>)
    await screen.findByText('Acompte')
    expect(screen.getByRole('link', { name: /réserver/i })).toHaveAttribute('href', '/book')
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/Rules.test.tsx Expected: FAIL — Rules.tsx still imports the (now-deleted) static rules export from ../data/rules, which no longer exists — a compile error.

  • Step 3: Implement

Replace src/pages/Rules.tsx in full:

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import ScrollReveal from '../components/ScrollReveal'
import RuleIcon from '../components/RuleIcon'
import { fetchRules, type RuleSection } from '../data/rules'
import styles from './Rules.module.css'

function Rules() {
  const [rules, setRules] = useState<RuleSection[]>([])
  const [error, setError] = useState('')

  useEffect(() => {
    async function load() {
      try {
        setRules(await fetchRules())
      } catch (err) {
        setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
      }
    }
    void load()
  }, [])

  return (
    <div className={styles.page}>
      <h1>Règlements et politiques du salon</h1>
      <p>Merci de les lire avant de réserver votre rendez-vous.</p>
      {error && <p className={styles.error}>{error}</p>}
      {rules.map((rule, index) => (
        <ScrollReveal key={rule.id} delay={index * 0.08}>
          <div className={styles.section}>
            <RuleIcon iconKey={rule.iconKey} />
            <div>
              <h2>{rule.title}</h2>
              <p>{rule.body}</p>
            </div>
          </div>
        </ScrollReveal>
      ))}
      <div className={styles.cta}>
        <p>En réservant, vous acceptez ces conditions.</p>
        <Link to="/book">Réserver maintenant</Link>
      </div>
    </div>
  )
}

export default Rules

Rules.module.css has no .error class yet. Append it:

.error {
  color: #a33;
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/pages/Rules.test.tsx Expected: PASS (2 tests).

  • Step 5: Run the full suite to confirm Task 5's deferred breakage is now resolved

Run: npx vitest run Expected: all test files pass, including src/data/rules.test.ts and src/components/RuleIcon.test.tsx from Task 5.

  • Step 6: Commit
git add src/pages/Rules.tsx src/pages/Rules.test.tsx src/pages/Rules.module.css
git commit -m "feat: wire Rules page to live fetchRules data"

Task 10: New admin page — AdminContent (site content singleton)

Files:

  • Create: src/pages/admin/AdminContent.tsx
  • Create: src/pages/admin/AdminContent.module.css
  • Test: src/pages/admin/AdminContent.test.tsx

Interfaces:

  • Consumes: supabase directly (matches every other admin page's pattern — admin pages talk to Supabase directly rather than through the public src/data/* fetch functions, which are Product/inStock-shaped for the public site's needs, not admin-editing needs), uploadSalonImage from ../../lib/storage (Phase 2C, unchanged).

  • Produces: route /admin/content, nav label "Contenu" (wired in Task 14).

  • Step 1: Write the failing test

// src/pages/admin/AdminContent.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const singleMock = vi.fn()
const eqSelectMock = vi.fn(() => ({ single: singleMock }))
const selectMock = vi.fn(() => ({ eq: eqSelectMock }))
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock; update: typeof updateMock }>(() => ({
  select: selectMock,
  update: updateMock,
}))
const uploadSalonImageMock = vi.fn()

vi.mock('../../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../../lib/storage', () => ({
  uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
}))

const CONTENT_ROW = {
  id: 'default',
  hero_headline: 'Vos cheveux, votre couronne',
  hero_tagline: 'Coiffures protectrices par Mira',
  about_bio: 'Bio actuelle.',
  about_portrait_image: 'about/mira-portrait.jpg',
  contact_email: 'hello@braidsbymira.com',
  contact_hours: 'Mar–Sam, 9h à 18h',
}

import AdminContent from './AdminContent'

describe('AdminContent', () => {
  beforeEach(() => {
    singleMock.mockReset().mockResolvedValue({ data: CONTENT_ROW, error: null })
    eqSelectMock.mockClear()
    selectMock.mockClear()
    eqUpdateMock.mockReset().mockResolvedValue({ error: null })
    updateMock.mockClear()
    fromMock.mockClear()
    uploadSalonImageMock.mockReset()
  })

  it('loads and displays the current site content in the form', async () => {
    render(<AdminContent />)
    expect(await screen.findByDisplayValue('Vos cheveux, votre couronne')).toBeInTheDocument()
    expect(screen.getByDisplayValue('hello@braidsbymira.com')).toBeInTheDocument()
    expect(fromMock).toHaveBeenCalledWith('site_content')
  })

  it('saves edited content', async () => {
    render(<AdminContent />)
    const headlineInput = await screen.findByDisplayValue('Vos cheveux, votre couronne')
    await userEvent.clear(headlineInput)
    await userEvent.type(headlineInput, 'Nouveau titre')
    await userEvent.click(screen.getByRole('button', { name: /enregistrer/i }))

    await waitFor(() =>
      expect(updateMock).toHaveBeenCalledWith(
        expect.objectContaining({ hero_headline: 'Nouveau titre' }),
      ),
    )
    expect(eqUpdateMock).toHaveBeenCalledWith('id', 'default')
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/admin/AdminContent.test.tsx Expected: FAIL — ./AdminContent module does not exist.

  • Step 3: Implement the component
// src/pages/admin/AdminContent.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import styles from './AdminContent.module.css'

interface SiteContentForm {
  id: string
  hero_headline: string
  hero_tagline: string
  about_bio: string
  about_portrait_image: string
  contact_email: string
  contact_hours: string
}

function AdminContent() {
  const [form, setForm] = useState<SiteContentForm | null>(null)
  const [file, setFile] = useState<File | null>(null)
  const [error, setError] = useState('')
  const [saving, setSaving] = useState(false)

  useEffect(() => {
    async function load() {
      const { data, error: fetchError } = await supabase.from('site_content').select('*').eq('id', 'default').single()
      if (fetchError) {
        setError(fetchError.message)
        return
      }
      setForm(data as SiteContentForm)
    }
    void load()
  }, [])

  async function handleSubmit(event: FormEvent) {
    event.preventDefault()
    if (!form) return
    setSaving(true)
    setError('')

    let about_portrait_image = form.about_portrait_image
    if (file) {
      try {
        about_portrait_image = await uploadSalonImage(file, `about/${file.name}`)
      } catch {
        setError("Échec du téléversement de l'image.")
        setSaving(false)
        return
      }
    }

    const { error: updateError } = await supabase
      .from('site_content')
      .update({
        hero_headline: form.hero_headline,
        hero_tagline: form.hero_tagline,
        about_bio: form.about_bio,
        about_portrait_image,
        contact_email: form.contact_email,
        contact_hours: form.contact_hours,
      })
      .eq('id', 'default')
    setSaving(false)
    if (updateError) {
      setError(updateError.message)
      return
    }
    setForm({ ...form, about_portrait_image })
    setFile(null)
  }

  if (!form) {
    return (
      <div className={styles.page}>
        <h1>Contenu</h1>
        {error && <p className={styles.error}>{error}</p>}
      </div>
    )
  }

  return (
    <div className={styles.page}>
      <h1>Contenu</h1>
      <form className={styles.form} onSubmit={handleSubmit}>
        <label htmlFor="content-hero-headline">
          Titre principal
          <input
            id="content-hero-headline"
            value={form.hero_headline}
            onChange={(e) => setForm({ ...form, hero_headline: e.target.value })}
            required
          />
        </label>
        <label htmlFor="content-hero-tagline">
          Sous-titre
          <input
            id="content-hero-tagline"
            value={form.hero_tagline}
            onChange={(e) => setForm({ ...form, hero_tagline: e.target.value })}
            required
          />
        </label>
        <label htmlFor="content-about-bio">
          Bio (À propos de Mira)
          <textarea
            id="content-about-bio"
            value={form.about_bio}
            onChange={(e) => setForm({ ...form, about_bio: e.target.value })}
            required
          />
        </label>
        <label htmlFor="content-about-portrait">
          Photo de portrait
          <input
            id="content-about-portrait"
            type="file"
            accept="image/*"
            onChange={(e) => setFile(e.target.files?.[0] ?? null)}
          />
        </label>
        <label htmlFor="content-contact-email">
          Courriel de contact
          <input
            id="content-contact-email"
            type="email"
            value={form.contact_email}
            onChange={(e) => setForm({ ...form, contact_email: e.target.value })}
            required
          />
        </label>
        <label htmlFor="content-contact-hours">
          Heures d'ouverture
          <input
            id="content-contact-hours"
            value={form.contact_hours}
            onChange={(e) => setForm({ ...form, contact_hours: e.target.value })}
            required
          />
        </label>
        {error && <p className={styles.error}>{error}</p>}
        <button type="submit" className={styles.button} disabled={saving}>Enregistrer le contenu</button>
      </form>
    </div>
  )
}

export default AdminContent
  • Step 4: Write the CSS module
/* src/pages/admin/AdminContent.module.css */
.page {
  composes: page from './adminShared.module.css';
}

.form {
  composes: form from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/pages/admin/AdminContent.test.tsx Expected: PASS (2 tests).

  • Step 6: Commit
git add src/pages/admin/AdminContent.tsx src/pages/admin/AdminContent.module.css src/pages/admin/AdminContent.test.tsx
git commit -m "feat: add AdminContent page for editing site_content"

Task 11: New admin page — AdminTestimonials (CRUD + reorder)

Files:

  • Create: src/pages/admin/AdminTestimonials.tsx
  • Create: src/pages/admin/AdminTestimonials.module.css
  • Test: src/pages/admin/AdminTestimonials.test.tsx

Interfaces:

  • Produces: route /admin/testimonials, nav label "Témoignages" (wired in Task 14).

  • Step 1: Write the failing test

// src/pages/admin/AdminTestimonials.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const insertMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const eqDeleteMock = vi.fn()
const deleteMock = vi.fn(() => ({ eq: eqDeleteMock }))
const fromMock = vi.fn<(table: string) => {
  select: typeof selectMock
  insert: typeof insertMock
  update: typeof updateMock
  delete: typeof deleteMock
}>(() => ({ select: selectMock, insert: insertMock, update: updateMock, delete: deleteMock }))
const uploadSalonImageMock = vi.fn()

vi.mock('../../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../../lib/storage', () => ({
  uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
}))

const TESTIMONIALS = [
  { id: 't1', quote: 'Fantastique !', author: 'Amara T.', avatar_image: 'testimonials/amara.jpg', sort_order: 0 },
  { id: 't2', quote: 'Excellent service.', author: 'Keisha R.', avatar_image: 'testimonials/keisha.jpg', sort_order: 1 },
]

import AdminTestimonials from './AdminTestimonials'

describe('AdminTestimonials', () => {
  beforeEach(() => {
    orderMock.mockReset().mockResolvedValue({ data: TESTIMONIALS, error: null })
    selectMock.mockClear()
    insertMock.mockReset().mockResolvedValue({ error: null })
    eqUpdateMock.mockReset().mockResolvedValue({ error: null })
    updateMock.mockClear()
    eqDeleteMock.mockReset().mockResolvedValue({ error: null })
    deleteMock.mockClear()
    fromMock.mockClear()
    uploadSalonImageMock.mockReset()
  })

  it('lists existing testimonials', async () => {
    render(<AdminTestimonials />)
    expect(await screen.findByText('Amara T.')).toBeInTheDocument()
    expect(screen.getByText('Keisha R.')).toBeInTheDocument()
    expect(fromMock).toHaveBeenCalledWith('testimonials')
  })

  it('creates a new testimonial from the form', async () => {
    render(<AdminTestimonials />)
    await screen.findByText('Amara T.')

    await userEvent.type(screen.getByLabelText(/citation/i), 'Superbe expérience.')
    await userEvent.type(screen.getByLabelText(/^auteur$/i), 'Nadia F.')
    await userEvent.click(screen.getByRole('button', { name: /ajouter le témoignage/i }))

    await waitFor(() =>
      expect(insertMock).toHaveBeenCalledWith(
        expect.objectContaining({ quote: 'Superbe expérience.', author: 'Nadia F.', sort_order: 2 }),
      ),
    )
  })

  it('deletes a testimonial with confirmation', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    render(<AdminTestimonials />)
    await screen.findByText('Amara T.')

    await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0])

    expect(deleteMock).toHaveBeenCalled()
    expect(eqDeleteMock).toHaveBeenCalledWith('id', 't1')
  })

  it('moves a testimonial down by swapping sort_order with the next row', async () => {
    render(<AdminTestimonials />)
    await screen.findByText('Amara T.')

    await userEvent.click(screen.getAllByRole('button', { name: /descendre/i })[0])

    await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ sort_order: 1 }))
    expect(eqUpdateMock).toHaveBeenCalledWith('id', 't1')
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/admin/AdminTestimonials.test.tsx Expected: FAIL — ./AdminTestimonials module does not exist.

  • Step 3: Implement the component
// src/pages/admin/AdminTestimonials.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import { friendlyError } from '../../lib/friendlyError'
import styles from './AdminTestimonials.module.css'

interface AdminTestimonial {
  id: string
  quote: string
  author: string
  avatar_image: string
  sort_order: number
}

function emptyForm(): Omit<AdminTestimonial, 'id' | 'sort_order'> & { id: string } {
  return { id: '', quote: '', author: '', avatar_image: '' }
}

function AdminTestimonials() {
  const [testimonials, setTestimonials] = useState<AdminTestimonial[]>([])
  const [form, setForm] = useState(emptyForm())
  const [file, setFile] = useState<File | null>(null)
  const [error, setError] = useState('')
  const [saving, setSaving] = useState(false)

  async function loadTestimonials() {
    const { data, error: fetchError } = await supabase.from('testimonials').select('*').order('sort_order')
    if (fetchError) {
      setError(fetchError.message)
      return
    }
    setTestimonials((data ?? []) as AdminTestimonial[])
  }

  useEffect(() => {
    void loadTestimonials()
  }, [])

  async function handleSubmit(event: FormEvent) {
    event.preventDefault()
    setSaving(true)
    setError('')

    let avatar_image = form.avatar_image
    if (file) {
      try {
        avatar_image = await uploadSalonImage(file, `testimonials/${Date.now()}-${file.name}`)
      } catch {
        setError("Échec du téléversement de l'image.")
        setSaving(false)
        return
      }
    }

    if (form.id) {
      const { error: updateError } = await supabase
        .from('testimonials')
        .update({ quote: form.quote, author: form.author, avatar_image })
        .eq('id', form.id)
      setSaving(false)
      if (updateError) {
        setError(updateError.message)
        return
      }
    } else {
      const maxOrder = testimonials.reduce((max, t) => Math.max(max, t.sort_order), -1)
      const { error: insertError } = await supabase
        .from('testimonials')
        .insert({ quote: form.quote, author: form.author, avatar_image, sort_order: maxOrder + 1 })
      setSaving(false)
      if (insertError) {
        setError(insertError.message)
        return
      }
    }
    setForm(emptyForm())
    setFile(null)
    await loadTestimonials()
  }

  async function handleDelete(id: string) {
    if (!window.confirm('Supprimer ce témoignage ?')) return
    const { error: deleteError } = await supabase.from('testimonials').delete().eq('id', id)
    if (deleteError) {
      setError(friendlyError(deleteError))
      return
    }
    await loadTestimonials()
  }

  async function handleMove(index: number, direction: -1 | 1) {
    const targetIndex = index + direction
    if (targetIndex < 0 || targetIndex >= testimonials.length) return
    const current = testimonials[index]
    const target = testimonials[targetIndex]
    setError('')
    const { error: err1 } = await supabase.from('testimonials').update({ sort_order: target.sort_order }).eq('id', current.id)
    if (err1) {
      setError(err1.message)
      return
    }
    const { error: err2 } = await supabase.from('testimonials').update({ sort_order: current.sort_order }).eq('id', target.id)
    if (err2) {
      setError(err2.message)
      return
    }
    await loadTestimonials()
  }

  const isEditing = Boolean(form.id)

  return (
    <div className={styles.page}>
      <h1>Témoignages</h1>
      <table className={styles.table}>
        <thead>
          <tr>
            <th>Auteur</th>
            <th>Citation</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {testimonials.map((t, index) => (
            <tr key={t.id}>
              <td>{t.author}</td>
              <td>{t.quote}</td>
              <td>
                <button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, -1)} disabled={index === 0}>Monter</button>
                <button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, 1)} disabled={index === testimonials.length - 1}>Descendre</button>
                <button type="button" className={styles.buttonSecondary} onClick={() => setForm(t)}>Modifier</button>
                <button type="button" className={styles.buttonDanger} onClick={() => handleDelete(t.id)}>Supprimer</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      <form className={styles.form} onSubmit={handleSubmit}>
        <h2>{isEditing ? 'Modifier le témoignage' : 'Nouveau témoignage'}</h2>
        <label htmlFor="testimonial-author">
          Auteur
          <input
            id="testimonial-author"
            value={form.author}
            onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
            required
          />
        </label>
        <label htmlFor="testimonial-quote">
          Citation
          <textarea
            id="testimonial-quote"
            value={form.quote}
            onChange={(e) => setForm((f) => ({ ...f, quote: e.target.value }))}
            required
          />
        </label>
        <label htmlFor="testimonial-avatar">
          Photo
          <input
            id="testimonial-avatar"
            type="file"
            accept="image/*"
            onChange={(e) => setFile(e.target.files?.[0] ?? null)}
          />
        </label>
        {error && <p className={styles.error}>{error}</p>}
        <button type="submit" className={styles.button} disabled={saving}>
          {isEditing ? 'Enregistrer le témoignage' : 'Ajouter le témoignage'}
        </button>
      </form>
    </div>
  )
}

export default AdminTestimonials
  • Step 4: Write the CSS module
/* src/pages/admin/AdminTestimonials.module.css */
.page {
  composes: page from './adminShared.module.css';
}

.table {
  composes: table from './adminShared.module.css';
}

.form {
  composes: form from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
  margin-right: var(--admin-space-xs);
}

.buttonDanger {
  composes: buttonDanger from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/pages/admin/AdminTestimonials.test.tsx Expected: PASS (4 tests).

  • Step 6: Commit
git add src/pages/admin/AdminTestimonials.tsx src/pages/admin/AdminTestimonials.module.css src/pages/admin/AdminTestimonials.test.tsx
git commit -m "feat: add AdminTestimonials page with CRUD and reordering"

Task 12: New admin page — AdminRules (CRUD + reorder + icon picker)

Files:

  • Create: src/pages/admin/AdminRules.tsx
  • Create: src/pages/admin/AdminRules.module.css
  • Test: src/pages/admin/AdminRules.test.tsx

Interfaces:

  • Consumes: RuleIcon (Task 5, iconKey prop).

  • Produces: route /admin/rules, nav label "Règlements" (wired in Task 14).

  • Step 1: Write the failing test

// src/pages/admin/AdminRules.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const insertMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const eqDeleteMock = vi.fn()
const deleteMock = vi.fn(() => ({ eq: eqDeleteMock }))
const fromMock = vi.fn<(table: string) => {
  select: typeof selectMock
  insert: typeof insertMock
  update: typeof updateMock
  delete: typeof deleteMock
}>(() => ({ select: selectMock, insert: insertMock, update: updateMock, delete: deleteMock }))

vi.mock('../../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))

const RULES = [
  { id: 'r1', title: 'Acompte', body: 'Un acompte est requis.', icon_key: 'deposit', sort_order: 0 },
  { id: 'r2', title: 'Retard', body: 'Des frais s\'appliquent.', icon_key: 'lateness', sort_order: 1 },
]

import AdminRules from './AdminRules'

describe('AdminRules', () => {
  beforeEach(() => {
    orderMock.mockReset().mockResolvedValue({ data: RULES, error: null })
    selectMock.mockClear()
    insertMock.mockReset().mockResolvedValue({ error: null })
    eqUpdateMock.mockReset().mockResolvedValue({ error: null })
    updateMock.mockClear()
    eqDeleteMock.mockReset().mockResolvedValue({ error: null })
    deleteMock.mockClear()
    fromMock.mockClear()
  })

  it('lists existing rules', async () => {
    render(<AdminRules />)
    expect(await screen.findByText('Acompte')).toBeInTheDocument()
    expect(screen.getByText('Retard')).toBeInTheDocument()
    expect(fromMock).toHaveBeenCalledWith('rules')
  })

  it('creates a new rule from the form', async () => {
    render(<AdminRules />)
    await screen.findByText('Acompte')

    await userEvent.type(screen.getByLabelText(/^titre$/i), 'Animaux')
    await userEvent.type(screen.getByLabelText(/^texte$/i), 'Aucun animal admis.')
    await userEvent.selectOptions(screen.getByLabelText(/icône/i), 'guests')
    await userEvent.click(screen.getByRole('button', { name: /ajouter le règlement/i }))

    await waitFor(() =>
      expect(insertMock).toHaveBeenCalledWith(
        expect.objectContaining({ title: 'Animaux', body: 'Aucun animal admis.', icon_key: 'guests', sort_order: 2 }),
      ),
    )
  })

  it('deletes a rule with confirmation', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    render(<AdminRules />)
    await screen.findByText('Acompte')

    await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0])

    expect(deleteMock).toHaveBeenCalled()
    expect(eqDeleteMock).toHaveBeenCalledWith('id', 'r1')
  })
})
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/admin/AdminRules.test.tsx Expected: FAIL — ./AdminRules module does not exist.

  • Step 3: Implement the component
// src/pages/admin/AdminRules.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { friendlyError } from '../../lib/friendlyError'
import RuleIcon from '../../components/RuleIcon'
import styles from './AdminRules.module.css'

interface AdminRule {
  id: string
  title: string
  body: string
  icon_key: string
  sort_order: number
}

const ICON_OPTIONS: { key: string; label: string }[] = [
  { key: 'deposit', label: 'Bouclier (dépôt)' },
  { key: 'hair-prep', label: 'Cheveux (préparation)' },
  { key: 'extensions', label: 'Mèches (extensions)' },
  { key: 'lateness', label: 'Horloge (retard)' },
  { key: 'guests', label: 'Personnes (accompagnant)' },
  { key: 'payment', label: 'Carte (paiement)' },
]

function emptyForm(): Omit<AdminRule, 'sort_order'> {
  return { id: '', title: '', body: '', icon_key: 'deposit' }
}

function AdminRules() {
  const [rules, setRules] = useState<AdminRule[]>([])
  const [form, setForm] = useState(emptyForm())
  const [error, setError] = useState('')
  const [saving, setSaving] = useState(false)

  async function loadRules() {
    const { data, error: fetchError } = await supabase.from('rules').select('*').order('sort_order')
    if (fetchError) {
      setError(fetchError.message)
      return
    }
    setRules((data ?? []) as AdminRule[])
  }

  useEffect(() => {
    void loadRules()
  }, [])

  async function handleSubmit(event: FormEvent) {
    event.preventDefault()
    setSaving(true)
    setError('')

    if (form.id) {
      const { error: updateError } = await supabase
        .from('rules')
        .update({ title: form.title, body: form.body, icon_key: form.icon_key })
        .eq('id', form.id)
      setSaving(false)
      if (updateError) {
        setError(updateError.message)
        return
      }
    } else {
      const maxOrder = rules.reduce((max, r) => Math.max(max, r.sort_order), -1)
      const { error: insertError } = await supabase
        .from('rules')
        .insert({ title: form.title, body: form.body, icon_key: form.icon_key, sort_order: maxOrder + 1 })
      setSaving(false)
      if (insertError) {
        setError(insertError.message)
        return
      }
    }
    setForm(emptyForm())
    await loadRules()
  }

  async function handleDelete(id: string) {
    if (!window.confirm('Supprimer ce règlement ?')) return
    const { error: deleteError } = await supabase.from('rules').delete().eq('id', id)
    if (deleteError) {
      setError(friendlyError(deleteError))
      return
    }
    await loadRules()
  }

  async function handleMove(index: number, direction: -1 | 1) {
    const targetIndex = index + direction
    if (targetIndex < 0 || targetIndex >= rules.length) return
    const current = rules[index]
    const target = rules[targetIndex]
    setError('')
    const { error: err1 } = await supabase.from('rules').update({ sort_order: target.sort_order }).eq('id', current.id)
    if (err1) {
      setError(err1.message)
      return
    }
    const { error: err2 } = await supabase.from('rules').update({ sort_order: current.sort_order }).eq('id', target.id)
    if (err2) {
      setError(err2.message)
      return
    }
    await loadRules()
  }

  const isEditing = Boolean(form.id)

  return (
    <div className={styles.page}>
      <h1>Règlements</h1>
      <table className={styles.table}>
        <thead>
          <tr>
            <th>Icône</th>
            <th>Titre</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {rules.map((rule, index) => (
            <tr key={rule.id}>
              <td><RuleIcon iconKey={rule.icon_key} /></td>
              <td>{rule.title}</td>
              <td>
                <button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, -1)} disabled={index === 0}>Monter</button>
                <button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, 1)} disabled={index === rules.length - 1}>Descendre</button>
                <button type="button" className={styles.buttonSecondary} onClick={() => setForm(rule)}>Modifier</button>
                <button type="button" className={styles.buttonDanger} onClick={() => handleDelete(rule.id)}>Supprimer</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      <form className={styles.form} onSubmit={handleSubmit}>
        <h2>{isEditing ? 'Modifier le règlement' : 'Nouveau règlement'}</h2>
        <label htmlFor="rule-title">
          Titre
          <input
            id="rule-title"
            value={form.title}
            onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
            required
          />
        </label>
        <label htmlFor="rule-body">
          Texte
          <textarea
            id="rule-body"
            value={form.body}
            onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))}
            required
          />
        </label>
        <label htmlFor="rule-icon">
          Icône
          <select
            id="rule-icon"
            value={form.icon_key}
            onChange={(e) => setForm((f) => ({ ...f, icon_key: e.target.value }))}
          >
            {ICON_OPTIONS.map((option) => (
              <option key={option.key} value={option.key}>{option.label}</option>
            ))}
          </select>
        </label>
        <RuleIcon iconKey={form.icon_key} />
        {error && <p className={styles.error}>{error}</p>}
        <button type="submit" className={styles.button} disabled={saving}>
          {isEditing ? 'Enregistrer le règlement' : 'Ajouter le règlement'}
        </button>
      </form>
    </div>
  )
}

export default AdminRules
  • Step 4: Write the CSS module
/* src/pages/admin/AdminRules.module.css */
.page {
  composes: page from './adminShared.module.css';
}

.table {
  composes: table from './adminShared.module.css';
}

.form {
  composes: form from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
  margin-right: var(--admin-space-xs);
}

.buttonDanger {
  composes: buttonDanger from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/pages/admin/AdminRules.test.tsx Expected: PASS (3 tests).

  • Step 6: Commit
git add src/pages/admin/AdminRules.tsx src/pages/admin/AdminRules.module.css src/pages/admin/AdminRules.test.tsx
git commit -m "feat: add AdminRules page with CRUD, reordering, and icon picker"

Files:

  • Modify: src/pages/admin/AdminProducts.tsx
  • Modify: src/pages/admin/AdminProducts.module.css
  • Modify: src/pages/admin/AdminProducts.test.tsx

Interfaces:

  • Consumes: products.featured/featured_order columns (Task 1).

  • Step 1: Update the failing test

Modify src/pages/admin/AdminProducts.test.tsx's EXISTING_PRODUCT constant to include the new columns, and add a new test for the featured toggle:

const EXISTING_PRODUCT = {
  id: 'edge-control',
  name: 'Silk Edge Control',
  category: 'hair-care',
  price: 12,
  image: 'products/edge-control.jpg',
  description: 'Hold with shine.',
  in_stock: true,
  featured: false,
  featured_order: null,
}

Add this test to the describe('AdminProducts', ...) block:

  it('marks a product as featured, assigning it the next featured_order', async () => {
    upsertMock.mockResolvedValue({ error: null })
    render(<AdminProducts />)
    await screen.findByText('Silk Edge Control')

    await userEvent.click(screen.getByRole('checkbox', { name: /vedette/i }))

    await waitFor(() =>
      expect(upsertMock).toHaveBeenCalledWith(
        expect.objectContaining({ id: 'edge-control', featured: true, featured_order: 0 }),
      ),
    )
  })
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/admin/AdminProducts.test.tsx Expected: FAIL — no "Vedette" checkbox exists yet.

  • Step 3: Implement

Replace src/pages/admin/AdminProducts.tsx in full:

import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import { friendlyError } from '../../lib/friendlyError'
import styles from './AdminProducts.module.css'

interface AdminProduct {
  id: string
  name: string
  category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
  price: number
  image: string
  description: string
  in_stock: boolean
  featured: boolean
  featured_order: number | null
}

const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories']

const CATEGORY_LABELS: Record<AdminProduct['category'], string> = {
  'hair-care': 'Soins capillaires',
  extensions: 'Extensions',
  tools: 'Outils',
  accessories: 'Accessoires',
}

function emptyForm(): AdminProduct {
  return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true, featured: false, featured_order: null }
}

function AdminProducts() {
  const [products, setProducts] = useState<AdminProduct[]>([])
  const [form, setForm] = useState<AdminProduct>(emptyForm())
  const [file, setFile] = useState<File | null>(null)
  const [error, setError] = useState('')
  const [saving, setSaving] = useState(false)

  async function loadProducts() {
    const { data, error: fetchError } = await supabase.from('products').select('*').order('name')
    if (fetchError) {
      setError(fetchError.message)
      return
    }
    setProducts((data ?? []) as AdminProduct[])
  }

  useEffect(() => {
    void loadProducts()
  }, [])

  async function handleSubmit(event: FormEvent) {
    event.preventDefault()
    setSaving(true)
    setError('')

    let image = form.image
    if (file) {
      try {
        image = await uploadSalonImage(file, `products/${form.id}-${file.name}`)
      } catch {
        setError("Échec du téléversement de l'image.")
        setSaving(false)
        return
      }
    }

    const { error: upsertError } = await supabase.from('products').upsert({ ...form, image })
    setSaving(false)
    if (upsertError) {
      setError(upsertError.message)
      return
    }
    setForm(emptyForm())
    setFile(null)
    await loadProducts()
  }

  async function handleDelete(id: string) {
    if (!window.confirm('Supprimer ce produit ?')) return
    const { error: deleteError } = await supabase.from('products').delete().eq('id', id)
    if (deleteError) {
      setError(friendlyError(deleteError))
      return
    }
    await loadProducts()
  }

  async function handleToggleFeatured(product: AdminProduct) {
    setError('')
    if (product.featured) {
      const { error: upsertError } = await supabase.from('products').upsert({ ...product, featured: false, featured_order: null })
      if (upsertError) {
        setError(upsertError.message)
        return
      }
    } else {
      const maxOrder = products.reduce((max, p) => (p.featured && p.featured_order !== null ? Math.max(max, p.featured_order) : max), -1)
      const { error: upsertError } = await supabase.from('products').upsert({ ...product, featured: true, featured_order: maxOrder + 1 })
      if (upsertError) {
        setError(upsertError.message)
        return
      }
    }
    await loadProducts()
  }

  async function handleMoveFeatured(product: AdminProduct, direction: -1 | 1) {
    const featured = products
      .filter((p) => p.featured)
      .sort((a, b) => (a.featured_order ?? 0) - (b.featured_order ?? 0))
    const index = featured.findIndex((p) => p.id === product.id)
    const targetIndex = index + direction
    if (targetIndex < 0 || targetIndex >= featured.length) return
    const current = featured[index]
    const target = featured[targetIndex]
    setError('')
    const { error: err1 } = await supabase.from('products').upsert({ ...current, featured_order: target.featured_order })
    if (err1) {
      setError(err1.message)
      return
    }
    const { error: err2 } = await supabase.from('products').upsert({ ...target, featured_order: current.featured_order })
    if (err2) {
      setError(err2.message)
      return
    }
    await loadProducts()
  }

  const isEditing = Boolean(form.id) && products.some((p) => p.id === form.id)
  const featuredProducts = products
    .filter((p) => p.featured)
    .sort((a, b) => (a.featured_order ?? 0) - (b.featured_order ?? 0))

  return (
    <div className={styles.page}>
      <h1>Produits</h1>
      <table className={styles.table}>
        <thead>
          <tr>
            <th>Nom</th>
            <th>Catégorie</th>
            <th>Prix</th>
            <th>En stock</th>
            <th>Vedette</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {products.map((product) => (
            <tr key={product.id}>
              <td>{product.name}</td>
              <td>{CATEGORY_LABELS[product.category]}</td>
              <td>{product.price} $</td>
              <td>{product.in_stock ? 'Oui' : 'Non'}</td>
              <td>
                <label>
                  <input
                    type="checkbox"
                    checked={product.featured}
                    onChange={() => handleToggleFeatured(product)}
                  />
                  Vedette
                </label>
                {product.featured && (
                  <>
                    <button
                      type="button"
                      className={styles.buttonSecondary}
                      onClick={() => handleMoveFeatured(product, -1)}
                      disabled={featuredProducts.findIndex((p) => p.id === product.id) === 0}
                    >
                      Monter
                    </button>
                    <button
                      type="button"
                      className={styles.buttonSecondary}
                      onClick={() => handleMoveFeatured(product, 1)}
                      disabled={featuredProducts.findIndex((p) => p.id === product.id) === featuredProducts.length - 1}
                    >
                      Descendre
                    </button>
                  </>
                )}
              </td>
              <td>
                <button type="button" className={styles.buttonSecondary} onClick={() => setForm(product)}>Modifier</button>
                <button type="button" className={styles.buttonDanger} onClick={() => handleDelete(product.id)}>Supprimer</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      <form className={styles.form} onSubmit={handleSubmit}>
        <h2>{isEditing ? 'Modifier le produit' : 'Nouveau produit'}</h2>
        <label htmlFor="product-id">
          Id
          <input
            id="product-id"
            value={form.id}
            onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))}
            required
            disabled={isEditing}
          />
        </label>
        <label htmlFor="product-name">
          Nom
          <input
            id="product-name"
            value={form.name}
            onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
            required
          />
        </label>
        <label htmlFor="product-category">
          Catégorie
          <select
            id="product-category"
            value={form.category}
            onChange={(e) => setForm((f) => ({ ...f, category: e.target.value as AdminProduct['category'] }))}
          >
            {CATEGORIES.map((category) => (
              <option key={category} value={category}>{CATEGORY_LABELS[category]}</option>
            ))}
          </select>
        </label>
        <label htmlFor="product-price">
          Prix
          <input
            id="product-price"
            type="number"
            step="0.01"
            value={form.price}
            onChange={(e) => setForm((f) => ({ ...f, price: Number(e.target.value) }))}
            required
          />
        </label>
        <label htmlFor="product-description">
          Description
          <textarea
            id="product-description"
            value={form.description}
            onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
            required
          />
        </label>
        <label htmlFor="product-image">
          Image
          <input
            id="product-image"
            type="file"
            accept="image/*"
            onChange={(e) => setFile(e.target.files?.[0] ?? null)}
          />
        </label>
        <label htmlFor="product-in-stock">
          <input
            id="product-in-stock"
            type="checkbox"
            checked={form.in_stock}
            onChange={(e) => setForm((f) => ({ ...f, in_stock: e.target.checked }))}
          />
          En stock
        </label>
        {error && <p className={styles.error}>{error}</p>}
        <button type="submit" className={styles.button} disabled={saving}>Enregistrer le produit</button>
      </form>
    </div>
  )
}

export default AdminProducts

Note: the new test's checkbox is queried via getByRole('checkbox', { name: /vedette/i }) — there's exactly one product row in the test fixture, so this resolves to a single element without ambiguity, matching the label text Vedette wrapping the checkbox input in the table row above.

  • Step 4: Update the CSS module to compose from the shared styles

Replace src/pages/admin/AdminProducts.module.css in full:

.page {
  composes: page from './adminShared.module.css';
}

.table {
  composes: table from './adminShared.module.css';
}

.form {
  composes: form from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
  margin-right: var(--admin-space-xs);
}

.buttonDanger {
  composes: buttonDanger from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/pages/admin/AdminProducts.test.tsx Expected: PASS (4 tests).

  • Step 6: Commit
git add src/pages/admin/AdminProducts.tsx src/pages/admin/AdminProducts.module.css src/pages/admin/AdminProducts.test.tsx
git commit -m "feat: add featured toggle/reorder to AdminProducts; apply shared admin design"

Task 14: Wire the 3 new pages into AdminLayout/AdminApp

Files:

  • Modify: src/pages/admin/AdminLayout.tsx
  • Modify: src/pages/admin/AdminApp.tsx
  • Modify: src/pages/admin/AdminApp.test.tsx

Interfaces:

  • Consumes: AdminContent (Task 10), AdminTestimonials (Task 11), AdminRules (Task 12).

  • Step 1: Update the failing test

Add these tests to the existing describe('AdminApp routing', ...) block in src/pages/admin/AdminApp.test.tsx, matching the file's existing render pattern exactly (each test wraps <AdminApp /> in its own MemoryRouter/Routes/Route path="/admin/*", not the top-level <App />):

  it('renders the AdminContent page at /admin/content', async () => {
    render(
      <MemoryRouter initialEntries={['/admin/content']}>
        <Routes>
          <Route path="/admin/*" element={<AdminApp />} />
        </Routes>
      </MemoryRouter>,
    )
    expect(await screen.findByRole('heading', { name: /^contenu$/i })).toBeInTheDocument()
  })

  it('renders the AdminTestimonials page at /admin/testimonials', async () => {
    render(
      <MemoryRouter initialEntries={['/admin/testimonials']}>
        <Routes>
          <Route path="/admin/*" element={<AdminApp />} />
        </Routes>
      </MemoryRouter>,
    )
    expect(await screen.findByRole('heading', { name: /^témoignages$/i })).toBeInTheDocument()
  })

  it('renders the AdminRules page at /admin/rules', async () => {
    render(
      <MemoryRouter initialEntries={['/admin/rules']}>
        <Routes>
          <Route path="/admin/*" element={<AdminApp />} />
        </Routes>
      </MemoryRouter>,
    )
    expect(await screen.findByRole('heading', { name: /^règlements$/i })).toBeInTheDocument()
  })

The file's existing vi.mock('../../lib/supabase', ...) block is:

vi.mock('../../lib/supabase', () => ({
  supabase: {
    auth: { signOut: vi.fn() },
    from: () => ({
      select: () => ({ order: () => Promise.resolve({ data: [], error: null }) }),
    }),
  },
}))

AdminContent's fetch calls .select().eq('id', 'default').single(), which this mock doesn't cover. Extend the select() return value to also provide eq, so both chains resolve:

vi.mock('../../lib/supabase', () => ({
  supabase: {
    auth: { signOut: vi.fn() },
    from: () => ({
      select: () => ({
        order: () => Promise.resolve({ data: [], error: null }),
        eq: () => ({
          single: () => Promise.resolve({
            data: { id: 'default', hero_headline: '', hero_tagline: '', about_bio: '', about_portrait_image: '', contact_email: '', contact_hours: '' },
            error: null,
          }),
        }),
      }),
    }),
  },
}))
  • Step 2: Run test to verify it fails

Run: npx vitest run src/pages/admin/AdminApp.test.tsx Expected: FAIL — no /admin/content, /admin/testimonials, or /admin/rules routes exist yet (they fall through to the catch-all redirect to /admin/products).

  • Step 3: Update AdminLayout.tsx's nav items
const NAV_ITEMS = [
  { to: '/admin/products', label: 'Produits' },
  { to: '/admin/services', label: 'Services' },
  { to: '/admin/schedule', label: 'Horaire' },
  { to: '/admin/bookings', label: 'Réservations' },
  { to: '/admin/inquiries', label: 'Demandes' },
  { to: '/admin/content', label: 'Contenu' },
  { to: '/admin/testimonials', label: 'Témoignages' },
  { to: '/admin/rules', label: 'Règlements' },
]

(Rest of AdminLayout.tsx unchanged.)

  • Step 4: Update AdminApp.tsx's routes
import { Routes, Route, Navigate } from 'react-router-dom'
import ProtectedRoute from '../../components/ProtectedRoute'
import AdminLogin from './AdminLogin'
import AdminLayout from './AdminLayout'
import AdminProducts from './AdminProducts'
import AdminServices from './AdminServices'
import AdminSchedule from './AdminSchedule'
import AdminBookings from './AdminBookings'
import AdminInquiries from './AdminInquiries'
import AdminContent from './AdminContent'
import AdminTestimonials from './AdminTestimonials'
import AdminRules from './AdminRules'
import styles from './AdminApp.module.css'

function AdminApp() {
  return (
    <div className={styles.adminRoot}>
      <Routes>
        <Route path="login" element={<AdminLogin />} />
        <Route element={<ProtectedRoute />}>
          <Route element={<AdminLayout />}>
            <Route index element={<Navigate to="/admin/products" replace />} />
            <Route path="products" element={<AdminProducts />} />
            <Route path="services" element={<AdminServices />} />
            <Route path="schedule" element={<AdminSchedule />} />
            <Route path="bookings" element={<AdminBookings />} />
            <Route path="inquiries" element={<AdminInquiries />} />
            <Route path="content" element={<AdminContent />} />
            <Route path="testimonials" element={<AdminTestimonials />} />
            <Route path="rules" element={<AdminRules />} />
            <Route path="*" element={<Navigate to="/admin/products" replace />} />
          </Route>
        </Route>
      </Routes>
    </div>
  )
}

export default AdminApp
  • Step 5: Run test to verify it passes

Run: npx vitest run src/pages/admin/AdminApp.test.tsx Expected: PASS (all tests, including the 3 new ones).

  • Step 6: Run the full suite

Run: npx vitest run Expected: all test files pass.

  • Step 7: Commit
git add src/pages/admin/AdminLayout.tsx src/pages/admin/AdminApp.tsx src/pages/admin/AdminApp.test.tsx
git commit -m "feat: wire AdminContent/AdminTestimonials/AdminRules into admin nav and routes"

Task 15: Apply the redesign to the remaining legacy admin pages

Files:

  • Modify: src/pages/admin/AdminServices.module.css
  • Modify: src/pages/admin/AdminServices.tsx
  • Modify: src/pages/admin/AdminSchedule.module.css
  • Modify: src/pages/admin/AdminSchedule.tsx
  • Modify: src/pages/admin/AdminBookings.module.css
  • Modify: src/pages/admin/AdminBookings.tsx
  • Modify: src/pages/admin/AdminInquiries.module.css
  • Modify: src/pages/admin/AdminInquiries.tsx

Interfaces:

  • Consumes: adminShared.module.css (Task 2).

CSS-only and className-only changes — no data-fetching or event-handler logic changes in any of the 4 files, so no test files change in this task.

  • Step 1: AdminServices.module.css

Replace in full:

.page {
  composes: page from './adminShared.module.css';
}

.table {
  composes: table from './adminShared.module.css';
}

.form {
  composes: form from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
  margin-right: var(--admin-space-xs);
}

.buttonDanger {
  composes: buttonDanger from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}

In AdminServices.tsx, add classNames to the 3 buttons (no other change):

                <button type="button" className={styles.buttonSecondary} onClick={() => setForm(service)}>Modifier</button>
                <button type="button" className={styles.buttonDanger} onClick={() => handleDelete(service.id)}>Supprimer</button>

and

        <button type="submit" className={styles.button} disabled={saving}>Enregistrer le service</button>
  • Step 2: AdminSchedule.module.css

Replace in full:

.page {
  composes: page from './adminShared.module.css';
}

.section {
  composes: card from './adminShared.module.css';
  padding: var(--admin-space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-md);
  max-width: 480px;
}

.days {
  display: flex;
  flex-wrap: wrap;
  gap: var(--admin-space-sm);
}

.days label {
  display: flex;
  align-items: center;
  gap: var(--admin-space-xs);
  font-size: 0.875rem;
  color: var(--admin-text);
}

.form label,
.section label {
  display: flex;
  flex-direction: column;
  gap: var(--admin-space-xs);
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--admin-text);
}

.form input,
.section input {
  padding: var(--admin-space-sm);
  border: 1px solid var(--admin-border);
  border-radius: calc(var(--admin-radius) / 2);
  font: inherit;
}

.exceptionsTable {
  composes: table from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
}

.form {
  composes: form from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}

In AdminSchedule.tsx:

  • Change <button type="submit" disabled={saving}>Enregistrer l&apos;horaire</button> to <button type="submit" className={styles.button} disabled={saving}>Enregistrer l&apos;horaire</button>.

  • Change <button type="button" onClick={() => handleRemoveException(exception.date)}>Retirer</button> to <button type="button" className={styles.buttonSecondary} onClick={() => handleRemoveException(exception.date)}>Retirer</button>.

  • Change <button type="submit">Ajouter une exception</button> to <button type="submit" className={styles.button}>Ajouter une exception</button>.

  • Step 3: AdminBookings.module.css

Replace in full:

.page {
  composes: page from './adminShared.module.css';
}

.table {
  composes: table from './adminShared.module.css';
}

.button {
  composes: button from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
  margin-right: var(--admin-space-xs);
}

.buttonDanger {
  composes: buttonDanger from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}

In AdminBookings.tsx, style "Annuler" as danger and "Marquer comme terminée" as the neutral secondary action:

                    <button
                      type="button"
                      className={styles.buttonDanger}
                      onClick={() => {
                        if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) {
                          void setStatus(booking.id, 'cancelled')
                        }
                      }}
                    >
                      Annuler
                    </button>
                    <button
                      type="button"
                      className={styles.buttonSecondary}
                      onClick={() => {
                        if (window.confirm('Marquer cette réservation comme terminée ?')) {
                          void setStatus(booking.id, 'completed')
                        }
                      }}
                    >
                      Marquer comme terminée
                    </button>
  • Step 4: AdminInquiries.module.css

Replace in full:

.page {
  composes: page from './adminShared.module.css';
}

.table {
  composes: table from './adminShared.module.css';
}

.buttonSecondary {
  composes: buttonSecondary from './adminShared.module.css';
}

.error {
  composes: error from './adminShared.module.css';
}

In AdminInquiries.tsx:

                {inquiry.status === 'new' && (
                  <button type="button" className={styles.buttonSecondary} onClick={() => markRead(inquiry.id)}>Marquer comme lue</button>
                )}
  • Step 5: Run the full test suite and build

Run: npx vitest run && npx tsc -b && npm run build && npm run lint Expected: all tests pass unchanged, build succeeds, lint clean. (Existing tests query by role/label/text, none of which changed — only className and CSS were touched.)

  • Step 6: Commit
git add src/pages/admin/AdminServices.module.css src/pages/admin/AdminServices.tsx src/pages/admin/AdminSchedule.module.css src/pages/admin/AdminSchedule.tsx src/pages/admin/AdminBookings.module.css src/pages/admin/AdminBookings.tsx src/pages/admin/AdminInquiries.module.css src/pages/admin/AdminInquiries.tsx
git commit -m "style: apply shared admin design to Services/Schedule/Bookings/Inquiries pages"

Task 16: Deploy and live verification

Files: None (operational task).

  • Step 1: Run full local verification

Run: npx supabase db reset && npx supabase test db Expected: Result: PASS, Tests=63.

Run: npx vitest run && npx tsc -b && npm run build && npm run lint Expected: all green.

  • Step 2: Push the migration to the live Supabase project

Run: npx supabase migration list (confirm project is still linked to ylxppnclfpnnolnjkbwv; re-link with npx supabase link --project-ref ylxppnclfpnnolnjkbwv if needed). Run: npx supabase db push Expected: 20260813000001_site_content_testimonials_rules.sql applies to the live project with no errors.

  • Step 3: Redeploy the frontend

Run: npx vercel --prod (from the linked braidsbymira project directory — verify with cat .vercel/project.json that projectName is braidsbymira before deploying, per the prior session's incident where an unlinked directory created a stray duplicate project). Expected: deployment succeeds, aliased to https://braidsbymira.vercel.app.

  • Step 4: Live smoke test — public site

Using the Playwright browser tool, navigate to https://braidsbymira.vercel.app/ and verify:

  • Hero headline/tagline render (from site_content).
  • "À propos de Mira" bio text renders.
  • "Sélection boutique" shows only the products marked featured in the seed data (none, initially — the migration seeds featured = false for all existing products; note this to the user as an expected empty state until they mark some featured via the new admin page).
  • Testimonials render (the 3 seeded ones).
  • Footer shows the seeded contact email/hours.

Navigate to https://braidsbymira.vercel.app/rules and verify all 6 rule sections render with their icons.

  • Step 5: Live smoke test — admin panel

Log in at https://braidsbymira.vercel.app/admin/login with the current admin credentials. Verify:

  • The sidebar shows 8 nav items including Contenu, Témoignages, Règlements, with the new visual design (no unstyled default buttons).
  • /admin/content loads the seeded site content into the form.
  • /admin/testimonials lists the 3 seeded testimonials.
  • /admin/rules lists the 6 seeded rules with correct icons.
  • On /admin/products, mark one product as featured and confirm it appears in the homepage's "Sélection boutique" after a refresh.

Ask the user for confirmation before performing any write through the live admin UI (per this session's established pattern for production-mutating actions), then revert/clean up any test data created during verification.

  • Step 6: Report completion

Summarize to the user: migration live, frontend redeployed, all smoke tests passed (or any findings), and that the homepage's featured-products section will show empty until they mark products as featured (since the migration seeds featured = false for all existing products, matching the pre-existing arbitrary "first 3 in stock" behavior being replaced — this is a deliberate, disclosed behavior change, not a bug).