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

3550 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```sql
-- 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:
```sql
-- 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**
```bash
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**
```css
/* 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**
```css
/* 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`:
```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:
```css
.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:
```css
.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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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`**
```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`:
```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`**
```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**
```bash
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:
```ts
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,
}
```
```ts
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[]`):
```ts
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`:
```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**
```bash
git add src/data/products.ts src/data/products.test.ts
git commit -m "feat: extend Product with featured/featuredOrder"
```
---
### Task 7: Wire `Home.tsx` to site content, testimonials, and featured products
**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:
```tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { describe, it, expect, 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:
```tsx
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**
```bash
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:
```tsx
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:
```tsx
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**
```bash
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:
```tsx
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:
```tsx
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:
```css
.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**
```bash
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**
```tsx
// 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**
```tsx
// 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**
```css
/* 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**
```bash
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**
```tsx
// 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**
```tsx
// 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**
```css
/* 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**
```bash
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**
```tsx
// 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**
```tsx
// 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**
```css
/* 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**
```bash
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"
```
---
### Task 13: Extend `AdminProducts` with featured toggle/reorder, and apply the new design
**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:
```tsx
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:
```tsx
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:
```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 './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:
```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/AdminProducts.test.tsx`
Expected: PASS (4 tests).
- [ ] **Step 6: Commit**
```bash
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 />`):
```tsx
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:
```tsx
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:
```tsx
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**
```tsx
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**
```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 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**
```bash
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:
```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';
}
```
In `AdminServices.tsx`, add classNames to the 3 buttons (no other change):
```tsx
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(service)}>Modifier</button>
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(service.id)}>Supprimer</button>
```
and
```tsx
<button type="submit" className={styles.button} disabled={saving}>Enregistrer le service</button>
```
- [ ] **Step 2: `AdminSchedule.module.css`**
Replace in full:
```css
.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:
```css
.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:
```tsx
<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:
```css
.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`:
```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**
```bash
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).