113 KiB
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, perdocs/superpowers/plans/2026-08-08-phase2a-backend-foundation-supabase.mdStep 2 and the2026-08-12214640_replace_admin_uuidmigration). - 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 insupabase/migrations/20260808000001_initial_schema.sql. - Test Supabase client mocking: mock
../lib/supabase(or../../lib/supabasefrom admin pages) directly — nevervi.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-argvi.fn()invoked via(...args: unknown[]) => mock(...args)failstsc -bwith TS2556.- Thrown/returned Supabase errors are rendered via
err.messageafter atypeof err === 'object' && 'message' in errguard, nevererr instanceof Error(a Postgrest error object is not anErrorinstance). - 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
classNameadditions) — 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.productsgainsfeatured boolean not null default falseandfeatured_order integer(nullable). -
Step 1: Write the migration
-- Phase 2D: adds site-editable content tables (site_content, testimonials,
-- rules) and featured-product columns, all following the existing
-- public-read/admin-write RLS pattern from the initial schema. Seed values
-- match what the site currently shows via hardcoded frontend constants, so
-- this migration doesn't blank any live content.
create table public.site_content (
id text primary key default 'default',
hero_headline text not null,
hero_tagline text not null,
about_bio text not null,
about_portrait_image text not null,
contact_email text not null,
contact_hours text not null
);
alter table public.site_content enable row level security;
create policy "site_content_public_read" on public.site_content
for select using (true);
create policy "site_content_admin_write" on public.site_content
for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid)
with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid);
insert into public.site_content (id, hero_headline, hero_tagline, about_bio, about_portrait_image, contact_email, contact_hours)
values (
'default',
'Vos cheveux, votre couronne',
'Coiffures protectrices par Mira',
'Avec plus d''une décennie d''expérience derrière le fauteuil, Mira aborde chaque installation comme une œuvre d''art — douce pour le cuir chevelu, précise à la racine, et achevée pour une couronne que vous aurez envie d''afficher.',
'about/mira-portrait.jpg',
'hello@braidsbymira.com',
'Mar–Sam, 9h à 18h'
);
create table public.testimonials (
id uuid primary key default gen_random_uuid(),
quote text not null,
author text not null,
avatar_image text not null,
sort_order integer not null
);
alter table public.testimonials enable row level security;
create policy "testimonials_public_read" on public.testimonials
for select using (true);
create policy "testimonials_admin_write" on public.testimonials
for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid)
with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid);
insert into public.testimonials (quote, author, avatar_image, sort_order) values
('Mira m''a fait sentir comme une reine sur son fauteuil. Mes tresses ont tenu 8 semaines !', 'Amara T.', 'testimonials/amara.jpg', 0),
('La tresseuse la plus douce et la plus précise que j''aie jamais consultée.', 'Keisha R.', 'testimonials/keisha.jpg', 1),
('La réservation était si simple, et le résultat parle de lui-même.', 'Nadia F.', 'testimonials/nadia.jpg', 2);
create table public.rules (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
icon_key text not null,
sort_order integer not null
);
alter table public.rules enable row level security;
create policy "rules_public_read" on public.rules
for select using (true);
create policy "rules_admin_write" on public.rules
for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid)
with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid);
insert into public.rules (title, body, icon_key, sort_order) values
('Acompte', 'Un acompte de 30 $ est exigé afin de confirmer toute réservation. Celui-ci n''est pas remboursable, mais transférable à une date ultérieure sur entente, pour un maximum d''une fois. Toute annulation moins de 48 heures avant le rendez-vous entraînera la perte de l''acompte, qui ne sera ni remboursé ni transféré. En cas de non-présentation (« no-show »), l''acompte est automatiquement perdu.', 'deposit', 0),
('Préalables cheveux', 'Prière de garder vos cheveux propres, démêlés et secs, prêts à être tressés. Le blowdry peut être facturé si cette règle n''est pas respectée. Pas de bains d''huiles la veille ou le jour du rendez-vous, s''il vous plaît. Merci de toujours préciser, photo à l''appui, vos caractéristiques spécifiques (cheveux courts, locks, etc.).', 'hair-prep', 1),
('Mèches et extensions', 'Les mèches sont à déposer 24 à 48h avant la prestation si ce n''est pas moi qui les fournis. Choisissez de préférence des mèches pré-étirées ; l''étirage des mèches vous sera facturé en fonction du nombre de paquets.', 'extensions', 2),
('Retard', 'Les retards sans notification préalable ne sont pas autorisés. Un retard de 15 à 20 minutes entraîne des frais supplémentaires, et à partir de 30 minutes, le rendez-vous est automatiquement annulé.', 'lateness', 3),
('Accompagnant', 'Vous êtes tenue de vous présenter seule à votre rendez-vous. Merci de ne pas venir accompagnée sans l''avoir signalé au préalable.', 'guests', 4),
('Paiement', 'Le paiement peut être fait en espèces ou par virement Interac.', 'payment', 5);
alter table public.products add column featured boolean not null default false;
alter table public.products add column featured_order integer;
- Step 2: Add pgTAP tests
Open supabase/tests/database/rls.test.sql. Change select plan(53); to select plan(63);.
Insert the following block right before the final select * from finish(); / rollback; lines at the end of the file:
-- site_content: public read, admin-only write. Singleton row seeded by the
-- migration, so no insert test is needed -- only read and update.
select isnt_empty(
$$ select * from public.site_content where id = 'default' $$,
'anon can read site_content'
);
select throws_ok(
$$ update public.site_content set hero_headline = 'hack' where id = 'default' $$,
'42501',
null,
'anon cannot update site_content'
);
set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
$$ update public.site_content set hero_headline = 'Updated Headline' where id = 'default' $$,
'admin can update site_content'
);
-- testimonials: public read, admin-only write.
set local role anon;
select isnt_empty(
$$ select * from public.testimonials $$,
'anon can read testimonials'
);
select throws_ok(
$$ insert into public.testimonials (quote, author, avatar_image, sort_order)
values ('hack', 'Hacker', 'x.jpg', 99) $$,
'42501',
null,
'anon cannot insert into testimonials'
);
set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
$$ insert into public.testimonials (quote, author, avatar_image, sort_order)
values ('Great service!', 'Test Client', 'test.jpg', 99) $$,
'admin can insert into testimonials'
);
-- rules: public read, admin-only write.
set local role anon;
select isnt_empty(
$$ select * from public.rules $$,
'anon can read rules'
);
select throws_ok(
$$ insert into public.rules (title, body, icon_key, sort_order)
values ('hack', 'hack', 'deposit', 99) $$,
'42501',
null,
'anon cannot insert into rules'
);
set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
$$ insert into public.rules (title, body, icon_key, sort_order)
values ('Test Rule', 'Test body.', 'payment', 99) $$,
'admin can insert into rules'
);
-- products.featured / featured_order: no new RLS policy (existing
-- products_admin_write already covers all columns), but confirm anon still
-- can't set them and admin can.
set local role anon;
select throws_ok(
$$ update public.products set featured = true where id = 'edge-control' $$,
'42501',
null,
'anon cannot set products.featured'
);
set local role authenticated;
set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81';
select lives_ok(
$$ update public.products set featured = true, featured_order = 0 where id = 'edge-control' $$,
'admin can set products.featured and featured_order'
);
- Step 3: Run local verification
Run: npx supabase db reset
Expected: all migrations (including the new one) apply cleanly, no errors.
Run: npx supabase test db
Expected: Result: PASS, Tests=63, Failed: 0.
- Step 4: Commit
git add supabase/migrations/20260813000001_site_content_testimonials_rules.sql supabase/tests/database/rls.test.sql
git commit -m "feat: add site_content, testimonials, and rules tables; featured products"
Task 2: Admin design tokens and AdminLayout/AdminLogin redesign
Files:
- Create:
src/pages/admin/AdminApp.module.css - Create:
src/pages/admin/adminShared.module.css - Modify:
src/pages/admin/AdminApp.tsx - Modify:
src/pages/admin/AdminLayout.module.css - Modify:
src/pages/admin/AdminLogin.module.css
Interfaces:
- Produces: CSS custom properties
--admin-bg,--admin-surface,--admin-border,--admin-text,--admin-text-muted,--admin-primary,--admin-primary-hover,--admin-danger,--admin-radius,--admin-shadow,--admin-font,--admin-space-xs/sm/md/lg/xl, scoped to a.adminRootwrapper class every later admin task builds pages inside. Produces composable classespage,card,table,form,error,button,buttonSecondary,buttonDangerinadminShared.module.css, which every later admin page's own.module.csscomposes from (composes: table from './adminShared.module.css').
This task establishes the visual system before any new admin page is built, so every subsequent admin page (Tasks 9-12) is built against the new design from the start rather than needing a second pass.
- Step 1: Create the token file
/* src/pages/admin/AdminApp.module.css */
.adminRoot {
--admin-bg: #F4F5F7;
--admin-surface: #FFFFFF;
--admin-border: #E1E4E9;
--admin-text: #1F2430;
--admin-text-muted: #6B7280;
--admin-primary: #2A5C8A;
--admin-primary-hover: #1E4566;
--admin-danger: #B3261E;
--admin-radius: 8px;
--admin-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
--admin-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--admin-space-xs: 0.25rem;
--admin-space-sm: 0.5rem;
--admin-space-md: 1rem;
--admin-space-lg: 1.5rem;
--admin-space-xl: 2.5rem;
min-height: 100vh;
background: var(--admin-bg);
color: var(--admin-text);
font-family: var(--admin-font);
}
Scoped to .adminRoot (not global :root) so these tokens never leak into or collide with the public site's src/styles/tokens.css, which the admin bundle doesn't import and vice versa.
- Step 2: Create the shared component-style file
/* src/pages/admin/adminShared.module.css */
.page {
display: flex;
flex-direction: column;
gap: var(--admin-space-lg);
}
.card {
background: var(--admin-surface);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
box-shadow: var(--admin-shadow);
}
.table {
composes: card;
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: var(--admin-space-sm) var(--admin-space-md);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--admin-text-muted);
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg);
}
.table td {
padding: var(--admin-space-sm) var(--admin-space-md);
border-bottom: 1px solid var(--admin-border);
vertical-align: middle;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: var(--admin-bg);
}
.form {
composes: card;
display: flex;
flex-direction: column;
gap: var(--admin-space-md);
max-width: 480px;
padding: var(--admin-space-lg);
}
.form label {
display: flex;
flex-direction: column;
gap: var(--admin-space-xs);
font-size: 0.875rem;
font-weight: 500;
color: var(--admin-text);
}
.form input,
.form select,
.form textarea {
padding: var(--admin-space-sm);
border: 1px solid var(--admin-border);
border-radius: calc(var(--admin-radius) / 2);
font: inherit;
color: var(--admin-text);
background: var(--admin-surface);
}
.form input:focus,
.form select:focus,
.form textarea:focus {
outline: 2px solid var(--admin-primary);
outline-offset: 1px;
}
.form textarea {
min-height: 6rem;
resize: vertical;
}
.button {
font: inherit;
font-weight: 600;
padding: var(--admin-space-sm) var(--admin-space-md);
border-radius: calc(var(--admin-radius) / 2);
border: 1px solid transparent;
cursor: pointer;
background: var(--admin-primary);
color: white;
}
.button:hover {
background: var(--admin-primary-hover);
}
.button:disabled {
opacity: 0.6;
cursor: default;
}
.buttonSecondary {
composes: button;
font-weight: 500;
background: var(--admin-surface);
color: var(--admin-text);
border-color: var(--admin-border);
}
.buttonSecondary:hover {
background: var(--admin-bg);
}
.buttonDanger {
composes: buttonSecondary;
color: var(--admin-danger);
}
.buttonDanger:hover {
background: #FEF2F2;
border-color: var(--admin-danger);
}
.error {
color: var(--admin-danger);
font-size: 0.875rem;
}
- Step 3: Wrap
AdminAppin the token scope
Modify src/pages/admin/AdminApp.tsx:
import { Routes, Route, Navigate } from 'react-router-dom'
import ProtectedRoute from '../../components/ProtectedRoute'
import AdminLogin from './AdminLogin'
import AdminLayout from './AdminLayout'
import AdminProducts from './AdminProducts'
import AdminServices from './AdminServices'
import AdminSchedule from './AdminSchedule'
import AdminBookings from './AdminBookings'
import AdminInquiries from './AdminInquiries'
import styles from './AdminApp.module.css'
function AdminApp() {
return (
<div className={styles.adminRoot}>
<Routes>
<Route path="login" element={<AdminLogin />} />
<Route element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/products" replace />} />
<Route path="products" element={<AdminProducts />} />
<Route path="services" element={<AdminServices />} />
<Route path="schedule" element={<AdminSchedule />} />
<Route path="bookings" element={<AdminBookings />} />
<Route path="inquiries" element={<AdminInquiries />} />
<Route path="*" element={<Navigate to="/admin/products" replace />} />
</Route>
</Route>
</Routes>
</div>
)
}
export default AdminApp
(Task 14 below adds the 3 new routes to this same file.)
- Step 4: Redesign
AdminLayout.module.css
Replace its full contents:
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--admin-surface);
border-right: 1px solid var(--admin-border);
padding: var(--admin-space-lg) var(--admin-space-md);
display: flex;
flex-direction: column;
gap: var(--admin-space-xl);
}
.brand {
font-weight: 700;
font-size: 1.125rem;
color: var(--admin-text);
padding: 0 var(--admin-space-sm);
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: var(--admin-space-xs);
}
.navLink {
color: var(--admin-text-muted);
text-decoration: none;
padding: var(--admin-space-sm);
border-radius: var(--admin-radius);
font-size: 0.9375rem;
font-weight: 500;
}
.navLink:hover {
background: var(--admin-bg);
color: var(--admin-text);
}
.navLinkActive {
background: var(--admin-primary);
color: white;
}
.navLinkActive:hover {
background: var(--admin-primary-hover);
color: white;
}
.signOut {
margin-top: auto;
background: none;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
color: var(--admin-text-muted);
padding: var(--admin-space-sm);
cursor: pointer;
font: inherit;
}
.signOut:hover {
border-color: var(--admin-danger);
color: var(--admin-danger);
}
.content {
flex: 1;
padding: var(--admin-space-xl) var(--admin-space-lg);
max-width: 1100px;
}
No changes needed to AdminLayout.tsx — it already applies styles.navLink and (conditionally) styles.navLinkActive together via `${styles.navLink} ${styles.navLinkActive}`, which continues to work unchanged with the new rules.
- Step 5: Redesign
AdminLogin.module.css
Replace its full contents:
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--admin-bg);
padding: var(--admin-space-lg);
}
.form {
display: flex;
flex-direction: column;
gap: var(--admin-space-md);
width: 100%;
max-width: 340px;
background: var(--admin-surface);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
box-shadow: var(--admin-shadow);
padding: var(--admin-space-xl) var(--admin-space-lg);
}
.form h1 {
font-size: 1.25rem;
font-weight: 700;
color: var(--admin-text);
margin: 0 0 var(--admin-space-sm);
}
.field {
display: flex;
flex-direction: column;
gap: var(--admin-space-xs);
}
.field label {
font-size: 0.875rem;
font-weight: 500;
color: var(--admin-text);
}
.field input {
padding: var(--admin-space-sm);
border: 1px solid var(--admin-border);
border-radius: calc(var(--admin-radius) / 2);
font: inherit;
background: var(--admin-surface);
color: var(--admin-text);
}
.field input:focus {
outline: 2px solid var(--admin-primary);
outline-offset: 1px;
}
.error {
color: var(--admin-danger);
font-size: 0.875rem;
}
.form button {
background: var(--admin-primary);
color: white;
border: none;
border-radius: calc(var(--admin-radius) / 2);
padding: var(--admin-space-sm) var(--admin-space-md);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.form button:hover {
background: var(--admin-primary-hover);
}
.form button:disabled {
opacity: 0.6;
cursor: default;
}
No AdminLogin.tsx changes needed — its JSX structure (.page > .form > h1 + .field + .error + button) already matches these selectors.
- Step 6: Verify
Run: npm run build && npx vitest run src/pages/admin/AdminLogin.test.tsx src/pages/admin/AdminApp.test.tsx
Expected: build succeeds, both test files pass unchanged (no assertions reference styling).
- Step 7: Commit
git add src/pages/admin/AdminApp.module.css src/pages/admin/adminShared.module.css src/pages/admin/AdminApp.tsx src/pages/admin/AdminLayout.module.css src/pages/admin/AdminLogin.module.css
git commit -m "feat: introduce admin design tokens and redesign AdminLayout/AdminLogin"
Task 3: fetchSiteContent data function
Files:
- Create:
src/data/siteContent.ts - Test:
src/data/siteContent.test.ts
Interfaces:
-
Produces:
export interface SiteContent { heroHeadline: string; heroTagline: string; aboutBio: string; aboutPortraitImage: string; contactEmail: string; contactHours: string }andexport async function fetchSiteContent(): Promise<SiteContent>. -
Consumes:
resolveImageUrlfrom../lib/resolveImageUrl(Phase 2C, unchanged),supabasefrom../lib/supabase. -
Step 1: Write the failing test
// src/data/siteContent.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const singleMock = vi.fn()
const eqMock = vi.fn(() => ({ single: singleMock }))
const selectMock = vi.fn(() => ({ eq: eqMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`)
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
resolveImageUrl: (path: string) => resolveImageUrlMock(path),
}))
const ROW = {
id: 'default',
hero_headline: 'Vos cheveux, votre couronne',
hero_tagline: 'Coiffures protectrices par Mira',
about_bio: 'Bio text.',
about_portrait_image: 'about/mira-portrait.jpg',
contact_email: 'hello@braidsbymira.com',
contact_hours: 'Mar–Sam, 9h à 18h',
}
import { fetchSiteContent, type SiteContent } from './siteContent'
describe('fetchSiteContent', () => {
beforeEach(() => {
singleMock.mockReset()
eqMock.mockClear()
selectMock.mockClear()
fromMock.mockClear()
resolveImageUrlMock.mockClear()
})
it('fetches the default row and maps snake_case columns to the SiteContent shape', async () => {
singleMock.mockResolvedValue({ data: ROW, error: null })
const content = await fetchSiteContent()
expect(fromMock).toHaveBeenCalledWith('site_content')
expect(eqMock).toHaveBeenCalledWith('id', 'default')
expect(resolveImageUrlMock).toHaveBeenCalledWith('about/mira-portrait.jpg')
expect(content).toEqual<SiteContent>({
heroHeadline: 'Vos cheveux, votre couronne',
heroTagline: 'Coiffures protectrices par Mira',
aboutBio: 'Bio text.',
aboutPortraitImage: 'https://cdn.example/about/mira-portrait.jpg',
contactEmail: 'hello@braidsbymira.com',
contactHours: 'Mar–Sam, 9h à 18h',
})
})
it('throws when the fetch fails', async () => {
singleMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
await expect(fetchSiteContent()).rejects.toEqual({ message: 'network error' })
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/data/siteContent.test.ts
Expected: FAIL — ./siteContent module does not exist.
- Step 3: Implement
// src/data/siteContent.ts
import { supabase } from '../lib/supabase'
import { resolveImageUrl } from '../lib/resolveImageUrl'
export interface SiteContent {
heroHeadline: string
heroTagline: string
aboutBio: string
aboutPortraitImage: string
contactEmail: string
contactHours: string
}
interface SiteContentRow {
hero_headline: string
hero_tagline: string
about_bio: string
about_portrait_image: string
contact_email: string
contact_hours: string
}
export async function fetchSiteContent(): Promise<SiteContent> {
const { data, error } = await supabase.from('site_content').select('*').eq('id', 'default').single()
if (error) throw error
const row = data as SiteContentRow
return {
heroHeadline: row.hero_headline,
heroTagline: row.hero_tagline,
aboutBio: row.about_bio,
aboutPortraitImage: resolveImageUrl(row.about_portrait_image),
contactEmail: row.contact_email,
contactHours: row.contact_hours,
}
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/data/siteContent.test.ts
Expected: PASS (2 tests).
- Step 5: Commit
git add src/data/siteContent.ts src/data/siteContent.test.ts
git commit -m "feat: add fetchSiteContent data function"
Task 4: fetchTestimonials data function
Files:
- Create:
src/data/testimonials.ts - Test:
src/data/testimonials.test.ts
Interfaces:
-
Produces:
export interface Testimonial { id: string; quote: string; author: string; avatarImage: string }andexport async function fetchTestimonials(): Promise<Testimonial[]>. -
Step 1: Write the failing test
// src/data/testimonials.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`)
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
resolveImageUrl: (path: string) => resolveImageUrlMock(path),
}))
const ROW = { id: 'a1', quote: 'Great!', author: 'Amara T.', avatar_image: 'testimonials/amara.jpg', sort_order: 0 }
import { fetchTestimonials, type Testimonial } from './testimonials'
describe('fetchTestimonials', () => {
beforeEach(() => {
orderMock.mockReset()
selectMock.mockClear()
fromMock.mockClear()
resolveImageUrlMock.mockClear()
})
it('fetches testimonials ordered by sort_order and maps to the Testimonial shape', async () => {
orderMock.mockResolvedValue({ data: [ROW], error: null })
const testimonials = await fetchTestimonials()
expect(fromMock).toHaveBeenCalledWith('testimonials')
expect(orderMock).toHaveBeenCalledWith('sort_order')
expect(resolveImageUrlMock).toHaveBeenCalledWith('testimonials/amara.jpg')
expect(testimonials).toEqual<Testimonial[]>([
{ id: 'a1', quote: 'Great!', author: 'Amara T.', avatarImage: 'https://cdn.example/testimonials/amara.jpg' },
])
})
it('throws when the fetch fails', async () => {
orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
await expect(fetchTestimonials()).rejects.toEqual({ message: 'network error' })
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/data/testimonials.test.ts
Expected: FAIL — ./testimonials module does not exist.
- Step 3: Implement
// src/data/testimonials.ts
import { supabase } from '../lib/supabase'
import { resolveImageUrl } from '../lib/resolveImageUrl'
export interface Testimonial {
id: string
quote: string
author: string
avatarImage: string
}
interface TestimonialRow {
id: string
quote: string
author: string
avatar_image: string
sort_order: number
}
export async function fetchTestimonials(): Promise<Testimonial[]> {
const { data, error } = await supabase.from('testimonials').select('*').order('sort_order')
if (error) throw error
return ((data ?? []) as TestimonialRow[]).map((row) => ({
id: row.id,
quote: row.quote,
author: row.author,
avatarImage: resolveImageUrl(row.avatar_image),
}))
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/data/testimonials.test.ts
Expected: PASS (2 tests).
- Step 5: Commit
git add src/data/testimonials.ts src/data/testimonials.test.ts
git commit -m "feat: add fetchTestimonials data function"
Task 5: Rewrite rules.ts to fetchRules; rename RuleIcon's prop to iconKey
Files:
- Modify:
src/data/rules.ts - Modify:
src/components/RuleIcon.tsx - Test:
src/data/rules.test.ts(new) - Modify:
src/components/RuleIcon.test.tsx
Interfaces:
-
Produces:
export interface RuleSection { id: string; title: string; body: string; iconKey: string }andexport async function fetchRules(): Promise<RuleSection[]>(replaces the staticexport const rulesarray).RuleIcontakes{ iconKey: string }instead of{ ruleId: string }. -
Consumes by later tasks: Task 8 (
Rules.tsx) callsfetchRules()and renders<RuleIcon iconKey={rule.iconKey} />; Task 12 (AdminRules) uses the sameiconKeyvalues for its dropdown. -
Step 1: Write the failing data-function test
// src/data/rules.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
const ROW = { id: 'r1', title: 'Acompte', body: 'Body text.', icon_key: 'deposit', sort_order: 0 }
import { fetchRules, type RuleSection } from './rules'
describe('fetchRules', () => {
beforeEach(() => {
orderMock.mockReset()
selectMock.mockClear()
fromMock.mockClear()
})
it('fetches rules ordered by sort_order and maps to the RuleSection shape', async () => {
orderMock.mockResolvedValue({ data: [ROW], error: null })
const rules = await fetchRules()
expect(fromMock).toHaveBeenCalledWith('rules')
expect(orderMock).toHaveBeenCalledWith('sort_order')
expect(rules).toEqual<RuleSection[]>([
{ id: 'r1', title: 'Acompte', body: 'Body text.', iconKey: 'deposit' },
])
})
it('throws when the fetch fails', async () => {
orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
await expect(fetchRules()).rejects.toEqual({ message: 'network error' })
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/data/rules.test.ts
Expected: FAIL — fetchRules is not exported (current file only exports a static rules array).
- Step 3: Rewrite
src/data/rules.ts
import { supabase } from '../lib/supabase'
export interface RuleSection {
id: string
title: string
body: string
iconKey: string
}
interface RuleRow {
id: string
title: string
body: string
icon_key: string
sort_order: number
}
export async function fetchRules(): Promise<RuleSection[]> {
const { data, error } = await supabase.from('rules').select('*').order('sort_order')
if (error) throw error
return ((data ?? []) as RuleRow[]).map((row) => ({
id: row.id,
title: row.title,
body: row.body,
iconKey: row.icon_key,
}))
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/data/rules.test.ts
Expected: PASS (2 tests).
- Step 5: Update
RuleIconto takeiconKey
Modify src/components/RuleIcon.tsx:
interface RuleIconProps {
iconKey: string
}
const ICON_PATHS: Record<string, string> = {
deposit: 'M4 7h16M4 7a2 2 0 00-2 2v8a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2M4 7l2-4h12l2 4M9 13h1v3H9zM14 13h1v3h-1z',
'hair-prep': 'M12 3c-2 3-5 5-5 9a5 5 0 0010 0c0-4-3-6-5-9zM12 12v9',
extensions: 'M6 4c2 4 2 8 0 12M12 4c2 4 2 8 0 12M18 4c2 4 2 8 0 12',
lateness: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
guests: 'M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m5-8.13a4 4 0 110 8 4 4 0 010-8zm6 4a4 4 0 10-3.2-6.4M5.2 5.6A4 4 0 108.4 12',
payment: 'M3 6h18v12H3zM3 10h18M7 15h4',
}
function RuleIcon({ iconKey }: RuleIconProps) {
const d = ICON_PATHS[iconKey] ?? ICON_PATHS.deposit
return (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={d} />
</svg>
)
}
export default RuleIcon
- Step 6: Update
RuleIcon.test.tsx
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import RuleIcon from './RuleIcon'
describe('RuleIcon', () => {
it('renders an svg for a known icon key', () => {
const { container } = render(<RuleIcon iconKey="deposit" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('falls back to a default icon for an unknown icon key without throwing', () => {
const { container } = render(<RuleIcon iconKey="not-a-real-key" />)
expect(container.querySelector('svg path')).toBeInTheDocument()
})
})
- Step 7: Run both test files
Run: npx vitest run src/data/rules.test.ts src/components/RuleIcon.test.tsx
Expected: PASS (2 + 2 tests). Note: src/pages/Rules.tsx and src/pages/Rules.test.tsx still reference the old static rules export and ruleId prop at this point in the plan — they are rewritten in Task 9 and will fail to compile/pass until then. This is expected and resolved within this same plan, not left as a dangling break at the end.
- Step 8: Commit
git add src/data/rules.ts src/data/rules.test.ts src/components/RuleIcon.tsx src/components/RuleIcon.test.tsx
git commit -m "feat: replace static rules array with fetchRules; RuleIcon takes iconKey"
Task 6: Extend products.ts with featured/featuredOrder
Files:
- Modify:
src/data/products.ts - Modify:
src/data/products.test.ts
Interfaces:
-
Produces:
Productgainsfeatured: booleanandfeaturedOrder: number | null. -
Consumes by later tasks: Task 7 (
Home.tsx) filters/sorts on these two fields; Task 13 (AdminProducts) reads/writesfeatured/featured_order. -
Step 1: Update the test's expected shape (write the failing assertion)
Modify src/data/products.test.ts's ROW constant and the toEqual assertion in the first fetchProducts test:
const ROW = {
id: 'edge-control',
name: 'Gel Contour Soyeux',
category: 'hair-care',
price: 12,
image: 'products/edge-control.jpg',
description: 'Tenue longue durée.',
in_stock: true,
featured: true,
featured_order: 0,
}
it('fetches products ordered by name and maps snake_case columns to the Product shape', async () => {
orderMock.mockResolvedValue({ data: [ROW], error: null })
const products = await fetchProducts()
expect(fromMock).toHaveBeenCalledWith('products')
expect(orderMock).toHaveBeenCalledWith('name')
expect(resolveImageUrlMock).toHaveBeenCalledWith('products/edge-control.jpg')
expect(products).toEqual<Product[]>([
{
id: 'edge-control',
name: 'Gel Contour Soyeux',
category: 'hair-care',
price: 12,
image: 'https://cdn.example/products/edge-control.jpg',
description: 'Tenue longue durée.',
inStock: true,
featured: true,
featuredOrder: 0,
},
])
})
Also add featured: false, featuredOrder: null to both entries in the getProductsByCategory describe block's products array (so the type continues to satisfy Product[]):
describe('getProductsByCategory', () => {
const products: Product[] = [
{ id: 'a', name: 'A', category: 'hair-care', price: 1, image: '', description: '', inStock: true, featured: false, featuredOrder: null },
{ id: 'b', name: 'B', category: 'tools', price: 1, image: '', description: '', inStock: true, featured: false, featuredOrder: null },
]
// ... rest unchanged
- Step 2: Run test to verify it fails
Run: npx vitest run src/data/products.test.ts
Expected: FAIL — actual fetchProducts() result is missing featured/featuredOrder.
- Step 3: Implement
Modify src/data/products.ts:
import { supabase } from '../lib/supabase'
import { resolveImageUrl } from '../lib/resolveImageUrl'
export interface Product {
id: string
name: string
category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
price: number
image: string
description: string
inStock: boolean
featured: boolean
featuredOrder: number | null
}
interface ProductRow {
id: string
name: string
category: Product['category']
price: number
image: string
description: string
in_stock: boolean
featured: boolean
featured_order: number | null
}
export async function fetchProducts(): Promise<Product[]> {
const { data, error } = await supabase.from('products').select('*').order('name')
if (error) throw error
return ((data ?? []) as ProductRow[]).map((row) => ({
id: row.id,
name: row.name,
category: row.category,
price: row.price,
image: resolveImageUrl(row.image),
description: row.description,
inStock: row.in_stock,
featured: row.featured,
featuredOrder: row.featured_order,
}))
}
export function getProductsByCategory(products: Product[], category: Product['category'] | 'all'): Product[] {
if (category === 'all') return products
return products.filter((p) => p.category === category)
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/data/products.test.ts
Expected: PASS (4 tests).
- Step 5: Commit
git add src/data/products.ts src/data/products.test.ts
git commit -m "feat: extend Product with featured/featuredOrder"
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:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const productsOrderMock = vi.fn()
const servicesOrderMock = vi.fn()
const testimonialsOrderMock = vi.fn()
const siteContentSingleMock = vi.fn()
const siteContentEqMock = vi.fn(() => ({ single: siteContentSingleMock }))
const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } | { eq: typeof siteContentEqMock } }>((table: string) => {
if (table === 'products') return { select: () => ({ order: productsOrderMock }) }
if (table === 'services') return { select: () => ({ order: servicesOrderMock }) }
if (table === 'testimonials') return { select: () => ({ order: testimonialsOrderMock }) }
if (table === 'site_content') return { select: () => ({ eq: siteContentEqMock }) }
throw new Error(`unexpected table: ${table}`)
})
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
resolveImageUrl: (path: string) => path,
}))
const PRODUCT_ROWS = [
{ id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true, featured: true, featured_order: 0 },
{ id: 'braid-oil', name: 'Huile Nourrissante', category: 'hair-care', price: 16, image: '', description: '', in_stock: true, featured: false, featured_order: null },
]
const SERVICE_ROWS = [
{ id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' },
]
const TESTIMONIAL_ROWS = [
{ id: 't1', quote: 'Fantastique service !', author: 'Amara T.', avatar_image: '', sort_order: 0 },
]
const SITE_CONTENT_ROW = {
id: 'default',
hero_headline: 'Vos cheveux, votre couronne',
hero_tagline: 'Coiffures protectrices par Mira',
about_bio: 'Bio de test.',
about_portrait_image: '',
contact_email: 'hello@braidsbymira.com',
contact_hours: 'Mar–Sam, 9h à 18h',
}
import Home from './Home'
import Book from './Book'
function renderHome() {
return render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/book" element={<Book />} />
</Routes>
</MemoryRouter>,
)
}
describe('Home page', () => {
beforeEach(() => {
productsOrderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null })
servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null })
testimonialsOrderMock.mockReset().mockResolvedValue({ data: TESTIMONIAL_ROWS, error: null })
siteContentSingleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null })
fromMock.mockClear()
})
it('renders the hero headline and all signature styles', async () => {
renderHome()
expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
for (const service of SERVICE_ROWS) {
expect(await screen.findByText(service.name)).toBeInTheDocument()
}
})
it('renders only featured products in shop highlights, and a link to the rules page', async () => {
renderHome()
expect(await screen.findByText('Gel Contour Soyeux')).toBeInTheDocument()
expect(screen.queryByText('Huile Nourrissante')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /voir toute la boutique/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /règlements du salon/i })).toHaveAttribute('href', '/rules')
})
it('renders testimonials from site content', async () => {
renderHome()
expect(await screen.findByText(/fantastique service/i)).toBeInTheDocument()
expect(screen.getByText('Amara T.')).toBeInTheDocument()
})
it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => {
renderHome()
const button = await screen.findByRole('button', { name: new RegExp(SERVICE_ROWS[0].name) })
await userEvent.click(button)
expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument()
})
it('renders the About Mira portrait and bio from site content', async () => {
renderHome()
expect(await screen.findByText('Bio de test.')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument()
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/Home.test.tsx
Expected: FAIL — Home.tsx doesn't yet fetch testimonials/site_content, still shows the hardcoded hero/testimonials/first-3-in-stock-products.
- Step 3: Implement
Replace src/pages/Home.tsx in full:
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { motion, useScroll, useTransform, useReducedMotion } from 'framer-motion'
import Hero3DScene from '../components/Hero3DScene'
import ScrollReveal from '../components/ScrollReveal'
import ServiceCard from '../components/ServiceCard'
import ProductCard from '../components/ProductCard'
import { fetchServices, type Service } from '../data/services'
import { fetchProducts, type Product } from '../data/products'
import { fetchTestimonials, type Testimonial } from '../data/testimonials'
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
import styles from './Home.module.css'
function Home() {
const navigate = useNavigate()
const { scrollYProgress } = useScroll()
const prefersReducedMotion = useReducedMotion()
const portraitY = useTransform(scrollYProgress, [0, 1], prefersReducedMotion ? [0, 0] : [0, -40])
const [services, setServices] = useState<Service[]>([])
const [products, setProducts] = useState<Product[]>([])
const [testimonials, setTestimonials] = useState<Testimonial[]>([])
const [content, setContent] = useState<SiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function load() {
try {
const [servicesResult, productsResult, testimonialsResult, contentResult] = await Promise.all([
fetchServices(),
fetchProducts(),
fetchTestimonials(),
fetchSiteContent(),
])
setServices(servicesResult)
setProducts(productsResult)
setTestimonials(testimonialsResult)
setContent(contentResult)
} catch (err) {
setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
} finally {
setLoading(false)
}
}
void load()
}, [])
const featuredProducts = products
.filter((p) => p.featured)
.sort((a, b) => (a.featuredOrder ?? 0) - (b.featuredOrder ?? 0))
return (
<div>
<section className={styles.hero}>
<Hero3DScene />
<h1 className={styles.headline}>{content?.heroHeadline ?? 'Vos cheveux, votre couronne'}</h1>
<p className={styles.tagline}>{content?.heroTagline ?? 'Coiffures protectrices par Mira'}</p>
<div className={styles.ctas}>
<Link to="/book">Réserver maintenant</Link>
<Link to="/shop">Voir les produits</Link>
</div>
</section>
{loading && <p>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>}
<ScrollReveal>
<section className={styles.altSection}>
<div className={styles.aboutGrid}>
<motion.img
src={content?.aboutPortraitImage}
alt="Mira, propriétaire et coiffeuse chez Braids by Mira"
className={styles.portrait}
style={{ y: portraitY }}
/>
<div>
<h2>À propos de Mira</h2>
<p>{content?.aboutBio}</p>
</div>
</div>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.section}>
<h2>Styles Signature</h2>
<div className={styles.row}>
{services.map((service) => (
<ServiceCard
key={service.id}
service={service}
onSelect={(serviceId) => navigate('/book', { state: { serviceId } })}
/>
))}
</div>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.altSection}>
<h2>Sélection boutique</h2>
<div className={styles.row}>
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
))}
</div>
<p><Link to="/shop">Voir toute la boutique</Link></p>
</section>
</ScrollReveal>
<ScrollReveal>
<section className={styles.section}>
<h2>Avis de nos clientes</h2>
<div className={styles.row}>
{testimonials.map((t) => (
<blockquote key={t.id} className={styles.testimonial}>
<img src={t.avatarImage} alt={t.author} className={styles.avatar} />
<p>“{t.quote}”</p>
<cite>{t.author}</cite>
</blockquote>
))}
</div>
</section>
</ScrollReveal>
<section className={styles.banner}>
<h2>Avant de réserver</h2>
<p>
Merci de prendre un instant pour lire les <Link to="/rules">règlements du salon</Link> avant de réserver votre rendez-vous.
</p>
</section>
</div>
)
}
export default Home
- Step 4: Run test to verify it passes
Run: npx vitest run src/pages/Home.test.tsx
Expected: PASS (5 tests).
- Step 5: Commit
git add src/pages/Home.tsx src/pages/Home.test.tsx
git commit -m "feat: wire Home page to live site content, testimonials, and featured products"
Task 8: Wire Footer.tsx to site content
Files:
- Modify:
src/components/Footer.tsx - Modify:
src/components/Footer.test.tsx
Interfaces:
-
Consumes:
fetchSiteContent(Task 3). -
Step 1: Update the failing test
Replace src/components/Footer.test.tsx in full:
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const singleMock = vi.fn()
const eqMock = vi.fn(() => ({ single: singleMock }))
const selectMock = vi.fn(() => ({ eq: eqMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
resolveImageUrl: (path: string) => path,
}))
const SITE_CONTENT_ROW = {
id: 'default',
hero_headline: 'Vos cheveux, votre couronne',
hero_tagline: 'Coiffures protectrices par Mira',
about_bio: '',
about_portrait_image: '',
contact_email: 'hello@braidsbymira.com',
contact_hours: 'Mar–Sam, 9h à 18h',
}
import Footer from './Footer'
describe('Footer', () => {
beforeEach(() => {
singleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null })
eqMock.mockClear()
selectMock.mockClear()
fromMock.mockClear()
})
it('renders contact info and quick links from site content', async () => {
render(<MemoryRouter><Footer /></MemoryRouter>)
expect(screen.getAllByText(/braids by mira/i).length).toBeGreaterThan(0)
expect(await screen.findByRole('link', { name: 'hello@braidsbymira.com' })).toHaveAttribute('href', 'mailto:hello@braidsbymira.com')
expect(screen.getByText('Mar–Sam, 9h à 18h')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /boutique/i })).toHaveAttribute('href', '/shop')
expect(screen.getByRole('link', { name: /règlements/i })).toHaveAttribute('href', '/rules')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/components/Footer.test.tsx
Expected: FAIL — Footer.tsx still renders the hardcoded email/hours, doesn't call supabase.from.
- Step 3: Implement
Replace src/components/Footer.tsx in full:
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
import styles from './Footer.module.css'
function Footer() {
const [content, setContent] = useState<SiteContent | null>(null)
useEffect(() => {
async function load() {
try {
setContent(await fetchSiteContent())
} catch {
// Footer content is non-critical; leave contact info blank on failure
// rather than surfacing an error on every single page.
}
}
void load()
}, [])
return (
<footer className={styles.footer}>
<div className={styles.inner}>
<div className={styles.brand}>
<p className={styles.wordmark}>Braids by Mira</p>
<p className={styles.tagline}>Vos cheveux, votre couronne</p>
</div>
<div className={styles.column}>
<p className={styles.columnTitle}>Contact</p>
{content && (
<>
<a href={`mailto:${content.contactEmail}`}>{content.contactEmail}</a>
<p>{content.contactHours}</p>
</>
)}
</div>
<div className={styles.column}>
<p className={styles.columnTitle}>Navigation</p>
<ul className={styles.links}>
<li><Link to="/">Accueil</Link></li>
<li><Link to="/shop">Boutique</Link></li>
<li><Link to="/book">Réserver</Link></li>
<li><Link to="/rules">Règlements</Link></li>
</ul>
</div>
</div>
<p className={styles.copyright}>© {new Date().getFullYear()} Braids by Mira. Tous droits réservés.</p>
</footer>
)
}
export default Footer
- Step 4: Run test to verify it passes
Run: npx vitest run src/components/Footer.test.tsx
Expected: PASS (1 test).
- Step 5: Commit
git add src/components/Footer.tsx src/components/Footer.test.tsx
git commit -m "feat: wire Footer contact info to live site content"
Task 9: Wire Rules.tsx to fetchRules
Files:
- Modify:
src/pages/Rules.tsx - Modify:
src/pages/Rules.test.tsx
Interfaces:
-
Consumes:
fetchRulesandRuleSection(Task 5),RuleIcon'siconKeyprop (Task 5). -
Step 1: Update the failing test
Replace src/pages/Rules.test.tsx in full:
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock }))
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
const RULE_ROWS = [
{ id: 'r1', title: 'Acompte', body: 'Un acompte est requis.', icon_key: 'deposit', sort_order: 0 },
{ id: 'r2', title: 'Retard', body: 'Les retards ont des frais.', icon_key: 'lateness', sort_order: 1 },
]
import Rules from './Rules'
describe('Rules page', () => {
beforeEach(() => {
orderMock.mockReset().mockResolvedValue({ data: RULE_ROWS, error: null })
selectMock.mockClear()
fromMock.mockClear()
})
it('renders every rule section title and body', async () => {
render(<MemoryRouter><Rules /></MemoryRouter>)
for (const rule of RULE_ROWS) {
expect(await screen.findByText(rule.title)).toBeInTheDocument()
expect(screen.getByText(rule.body)).toBeInTheDocument()
}
})
it('links back to booking', async () => {
render(<MemoryRouter><Rules /></MemoryRouter>)
await screen.findByText('Acompte')
expect(screen.getByRole('link', { name: /réserver/i })).toHaveAttribute('href', '/book')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/Rules.test.tsx
Expected: FAIL — Rules.tsx still imports the (now-deleted) static rules export from ../data/rules, which no longer exists — a compile error.
- Step 3: Implement
Replace src/pages/Rules.tsx in full:
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import ScrollReveal from '../components/ScrollReveal'
import RuleIcon from '../components/RuleIcon'
import { fetchRules, type RuleSection } from '../data/rules'
import styles from './Rules.module.css'
function Rules() {
const [rules, setRules] = useState<RuleSection[]>([])
const [error, setError] = useState('')
useEffect(() => {
async function load() {
try {
setRules(await fetchRules())
} catch (err) {
setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
}
}
void load()
}, [])
return (
<div className={styles.page}>
<h1>Règlements et politiques du salon</h1>
<p>Merci de les lire avant de réserver votre rendez-vous.</p>
{error && <p className={styles.error}>{error}</p>}
{rules.map((rule, index) => (
<ScrollReveal key={rule.id} delay={index * 0.08}>
<div className={styles.section}>
<RuleIcon iconKey={rule.iconKey} />
<div>
<h2>{rule.title}</h2>
<p>{rule.body}</p>
</div>
</div>
</ScrollReveal>
))}
<div className={styles.cta}>
<p>En réservant, vous acceptez ces conditions.</p>
<Link to="/book">Réserver maintenant</Link>
</div>
</div>
)
}
export default Rules
Rules.module.css has no .error class yet. Append it:
.error {
color: #a33;
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/pages/Rules.test.tsx
Expected: PASS (2 tests).
- Step 5: Run the full suite to confirm Task 5's deferred breakage is now resolved
Run: npx vitest run
Expected: all test files pass, including src/data/rules.test.ts and src/components/RuleIcon.test.tsx from Task 5.
- Step 6: Commit
git add src/pages/Rules.tsx src/pages/Rules.test.tsx src/pages/Rules.module.css
git commit -m "feat: wire Rules page to live fetchRules data"
Task 10: New admin page — AdminContent (site content singleton)
Files:
- Create:
src/pages/admin/AdminContent.tsx - Create:
src/pages/admin/AdminContent.module.css - Test:
src/pages/admin/AdminContent.test.tsx
Interfaces:
-
Consumes:
supabasedirectly (matches every other admin page's pattern — admin pages talk to Supabase directly rather than through the publicsrc/data/*fetch functions, which are Product/inStock-shaped for the public site's needs, not admin-editing needs),uploadSalonImagefrom../../lib/storage(Phase 2C, unchanged). -
Produces: route
/admin/content, nav label "Contenu" (wired in Task 14). -
Step 1: Write the failing test
// src/pages/admin/AdminContent.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const singleMock = vi.fn()
const eqSelectMock = vi.fn(() => ({ single: singleMock }))
const selectMock = vi.fn(() => ({ eq: eqSelectMock }))
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const fromMock = vi.fn<(table: string) => { select: typeof selectMock; update: typeof updateMock }>(() => ({
select: selectMock,
update: updateMock,
}))
const uploadSalonImageMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../../lib/storage', () => ({
uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
}))
const CONTENT_ROW = {
id: 'default',
hero_headline: 'Vos cheveux, votre couronne',
hero_tagline: 'Coiffures protectrices par Mira',
about_bio: 'Bio actuelle.',
about_portrait_image: 'about/mira-portrait.jpg',
contact_email: 'hello@braidsbymira.com',
contact_hours: 'Mar–Sam, 9h à 18h',
}
import AdminContent from './AdminContent'
describe('AdminContent', () => {
beforeEach(() => {
singleMock.mockReset().mockResolvedValue({ data: CONTENT_ROW, error: null })
eqSelectMock.mockClear()
selectMock.mockClear()
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
updateMock.mockClear()
fromMock.mockClear()
uploadSalonImageMock.mockReset()
})
it('loads and displays the current site content in the form', async () => {
render(<AdminContent />)
expect(await screen.findByDisplayValue('Vos cheveux, votre couronne')).toBeInTheDocument()
expect(screen.getByDisplayValue('hello@braidsbymira.com')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('site_content')
})
it('saves edited content', async () => {
render(<AdminContent />)
const headlineInput = await screen.findByDisplayValue('Vos cheveux, votre couronne')
await userEvent.clear(headlineInput)
await userEvent.type(headlineInput, 'Nouveau titre')
await userEvent.click(screen.getByRole('button', { name: /enregistrer/i }))
await waitFor(() =>
expect(updateMock).toHaveBeenCalledWith(
expect.objectContaining({ hero_headline: 'Nouveau titre' }),
),
)
expect(eqUpdateMock).toHaveBeenCalledWith('id', 'default')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/admin/AdminContent.test.tsx
Expected: FAIL — ./AdminContent module does not exist.
- Step 3: Implement the component
// src/pages/admin/AdminContent.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import styles from './AdminContent.module.css'
interface SiteContentForm {
id: string
hero_headline: string
hero_tagline: string
about_bio: string
about_portrait_image: string
contact_email: string
contact_hours: string
}
function AdminContent() {
const [form, setForm] = useState<SiteContentForm | null>(null)
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
async function load() {
const { data, error: fetchError } = await supabase.from('site_content').select('*').eq('id', 'default').single()
if (fetchError) {
setError(fetchError.message)
return
}
setForm(data as SiteContentForm)
}
void load()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
if (!form) return
setSaving(true)
setError('')
let about_portrait_image = form.about_portrait_image
if (file) {
try {
about_portrait_image = await uploadSalonImage(file, `about/${file.name}`)
} catch {
setError("Échec du téléversement de l'image.")
setSaving(false)
return
}
}
const { error: updateError } = await supabase
.from('site_content')
.update({
hero_headline: form.hero_headline,
hero_tagline: form.hero_tagline,
about_bio: form.about_bio,
about_portrait_image,
contact_email: form.contact_email,
contact_hours: form.contact_hours,
})
.eq('id', 'default')
setSaving(false)
if (updateError) {
setError(updateError.message)
return
}
setForm({ ...form, about_portrait_image })
setFile(null)
}
if (!form) {
return (
<div className={styles.page}>
<h1>Contenu</h1>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
return (
<div className={styles.page}>
<h1>Contenu</h1>
<form className={styles.form} onSubmit={handleSubmit}>
<label htmlFor="content-hero-headline">
Titre principal
<input
id="content-hero-headline"
value={form.hero_headline}
onChange={(e) => setForm({ ...form, hero_headline: e.target.value })}
required
/>
</label>
<label htmlFor="content-hero-tagline">
Sous-titre
<input
id="content-hero-tagline"
value={form.hero_tagline}
onChange={(e) => setForm({ ...form, hero_tagline: e.target.value })}
required
/>
</label>
<label htmlFor="content-about-bio">
Bio (À propos de Mira)
<textarea
id="content-about-bio"
value={form.about_bio}
onChange={(e) => setForm({ ...form, about_bio: e.target.value })}
required
/>
</label>
<label htmlFor="content-about-portrait">
Photo de portrait
<input
id="content-about-portrait"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label htmlFor="content-contact-email">
Courriel de contact
<input
id="content-contact-email"
type="email"
value={form.contact_email}
onChange={(e) => setForm({ ...form, contact_email: e.target.value })}
required
/>
</label>
<label htmlFor="content-contact-hours">
Heures d'ouverture
<input
id="content-contact-hours"
value={form.contact_hours}
onChange={(e) => setForm({ ...form, contact_hours: e.target.value })}
required
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button} disabled={saving}>Enregistrer le contenu</button>
</form>
</div>
)
}
export default AdminContent
- Step 4: Write the CSS module
/* src/pages/admin/AdminContent.module.css */
.page {
composes: page from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
- Step 5: Run test to verify it passes
Run: npx vitest run src/pages/admin/AdminContent.test.tsx
Expected: PASS (2 tests).
- Step 6: Commit
git add src/pages/admin/AdminContent.tsx src/pages/admin/AdminContent.module.css src/pages/admin/AdminContent.test.tsx
git commit -m "feat: add AdminContent page for editing site_content"
Task 11: New admin page — AdminTestimonials (CRUD + reorder)
Files:
- Create:
src/pages/admin/AdminTestimonials.tsx - Create:
src/pages/admin/AdminTestimonials.module.css - Test:
src/pages/admin/AdminTestimonials.test.tsx
Interfaces:
-
Produces: route
/admin/testimonials, nav label "Témoignages" (wired in Task 14). -
Step 1: Write the failing test
// src/pages/admin/AdminTestimonials.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const insertMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const eqDeleteMock = vi.fn()
const deleteMock = vi.fn(() => ({ eq: eqDeleteMock }))
const fromMock = vi.fn<(table: string) => {
select: typeof selectMock
insert: typeof insertMock
update: typeof updateMock
delete: typeof deleteMock
}>(() => ({ select: selectMock, insert: insertMock, update: updateMock, delete: deleteMock }))
const uploadSalonImageMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../../lib/storage', () => ({
uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
}))
const TESTIMONIALS = [
{ id: 't1', quote: 'Fantastique !', author: 'Amara T.', avatar_image: 'testimonials/amara.jpg', sort_order: 0 },
{ id: 't2', quote: 'Excellent service.', author: 'Keisha R.', avatar_image: 'testimonials/keisha.jpg', sort_order: 1 },
]
import AdminTestimonials from './AdminTestimonials'
describe('AdminTestimonials', () => {
beforeEach(() => {
orderMock.mockReset().mockResolvedValue({ data: TESTIMONIALS, error: null })
selectMock.mockClear()
insertMock.mockReset().mockResolvedValue({ error: null })
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
updateMock.mockClear()
eqDeleteMock.mockReset().mockResolvedValue({ error: null })
deleteMock.mockClear()
fromMock.mockClear()
uploadSalonImageMock.mockReset()
})
it('lists existing testimonials', async () => {
render(<AdminTestimonials />)
expect(await screen.findByText('Amara T.')).toBeInTheDocument()
expect(screen.getByText('Keisha R.')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('testimonials')
})
it('creates a new testimonial from the form', async () => {
render(<AdminTestimonials />)
await screen.findByText('Amara T.')
await userEvent.type(screen.getByLabelText(/citation/i), 'Superbe expérience.')
await userEvent.type(screen.getByLabelText(/^auteur$/i), 'Nadia F.')
await userEvent.click(screen.getByRole('button', { name: /ajouter le témoignage/i }))
await waitFor(() =>
expect(insertMock).toHaveBeenCalledWith(
expect.objectContaining({ quote: 'Superbe expérience.', author: 'Nadia F.', sort_order: 2 }),
),
)
})
it('deletes a testimonial with confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<AdminTestimonials />)
await screen.findByText('Amara T.')
await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0])
expect(deleteMock).toHaveBeenCalled()
expect(eqDeleteMock).toHaveBeenCalledWith('id', 't1')
})
it('moves a testimonial down by swapping sort_order with the next row', async () => {
render(<AdminTestimonials />)
await screen.findByText('Amara T.')
await userEvent.click(screen.getAllByRole('button', { name: /descendre/i })[0])
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ sort_order: 1 }))
expect(eqUpdateMock).toHaveBeenCalledWith('id', 't1')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/admin/AdminTestimonials.test.tsx
Expected: FAIL — ./AdminTestimonials module does not exist.
- Step 3: Implement the component
// src/pages/admin/AdminTestimonials.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import { friendlyError } from '../../lib/friendlyError'
import styles from './AdminTestimonials.module.css'
interface AdminTestimonial {
id: string
quote: string
author: string
avatar_image: string
sort_order: number
}
function emptyForm(): Omit<AdminTestimonial, 'id' | 'sort_order'> & { id: string } {
return { id: '', quote: '', author: '', avatar_image: '' }
}
function AdminTestimonials() {
const [testimonials, setTestimonials] = useState<AdminTestimonial[]>([])
const [form, setForm] = useState(emptyForm())
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadTestimonials() {
const { data, error: fetchError } = await supabase.from('testimonials').select('*').order('sort_order')
if (fetchError) {
setError(fetchError.message)
return
}
setTestimonials((data ?? []) as AdminTestimonial[])
}
useEffect(() => {
void loadTestimonials()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
let avatar_image = form.avatar_image
if (file) {
try {
avatar_image = await uploadSalonImage(file, `testimonials/${Date.now()}-${file.name}`)
} catch {
setError("Échec du téléversement de l'image.")
setSaving(false)
return
}
}
if (form.id) {
const { error: updateError } = await supabase
.from('testimonials')
.update({ quote: form.quote, author: form.author, avatar_image })
.eq('id', form.id)
setSaving(false)
if (updateError) {
setError(updateError.message)
return
}
} else {
const maxOrder = testimonials.reduce((max, t) => Math.max(max, t.sort_order), -1)
const { error: insertError } = await supabase
.from('testimonials')
.insert({ quote: form.quote, author: form.author, avatar_image, sort_order: maxOrder + 1 })
setSaving(false)
if (insertError) {
setError(insertError.message)
return
}
}
setForm(emptyForm())
setFile(null)
await loadTestimonials()
}
async function handleDelete(id: string) {
if (!window.confirm('Supprimer ce témoignage ?')) return
const { error: deleteError } = await supabase.from('testimonials').delete().eq('id', id)
if (deleteError) {
setError(friendlyError(deleteError))
return
}
await loadTestimonials()
}
async function handleMove(index: number, direction: -1 | 1) {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= testimonials.length) return
const current = testimonials[index]
const target = testimonials[targetIndex]
setError('')
const { error: err1 } = await supabase.from('testimonials').update({ sort_order: target.sort_order }).eq('id', current.id)
if (err1) {
setError(err1.message)
return
}
const { error: err2 } = await supabase.from('testimonials').update({ sort_order: current.sort_order }).eq('id', target.id)
if (err2) {
setError(err2.message)
return
}
await loadTestimonials()
}
const isEditing = Boolean(form.id)
return (
<div className={styles.page}>
<h1>Témoignages</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Auteur</th>
<th>Citation</th>
<th></th>
</tr>
</thead>
<tbody>
{testimonials.map((t, index) => (
<tr key={t.id}>
<td>{t.author}</td>
<td>{t.quote}</td>
<td>
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, -1)} disabled={index === 0}>Monter</button>
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, 1)} disabled={index === testimonials.length - 1}>Descendre</button>
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(t)}>Modifier</button>
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(t.id)}>Supprimer</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{isEditing ? 'Modifier le témoignage' : 'Nouveau témoignage'}</h2>
<label htmlFor="testimonial-author">
Auteur
<input
id="testimonial-author"
value={form.author}
onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
required
/>
</label>
<label htmlFor="testimonial-quote">
Citation
<textarea
id="testimonial-quote"
value={form.quote}
onChange={(e) => setForm((f) => ({ ...f, quote: e.target.value }))}
required
/>
</label>
<label htmlFor="testimonial-avatar">
Photo
<input
id="testimonial-avatar"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button} disabled={saving}>
{isEditing ? 'Enregistrer le témoignage' : 'Ajouter le témoignage'}
</button>
</form>
</div>
)
}
export default AdminTestimonials
- Step 4: Write the CSS module
/* src/pages/admin/AdminTestimonials.module.css */
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
margin-right: var(--admin-space-xs);
}
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
- Step 5: Run test to verify it passes
Run: npx vitest run src/pages/admin/AdminTestimonials.test.tsx
Expected: PASS (4 tests).
- Step 6: Commit
git add src/pages/admin/AdminTestimonials.tsx src/pages/admin/AdminTestimonials.module.css src/pages/admin/AdminTestimonials.test.tsx
git commit -m "feat: add AdminTestimonials page with CRUD and reordering"
Task 12: New admin page — AdminRules (CRUD + reorder + icon picker)
Files:
- Create:
src/pages/admin/AdminRules.tsx - Create:
src/pages/admin/AdminRules.module.css - Test:
src/pages/admin/AdminRules.test.tsx
Interfaces:
-
Consumes:
RuleIcon(Task 5,iconKeyprop). -
Produces: route
/admin/rules, nav label "Règlements" (wired in Task 14). -
Step 1: Write the failing test
// src/pages/admin/AdminRules.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const insertMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const eqDeleteMock = vi.fn()
const deleteMock = vi.fn(() => ({ eq: eqDeleteMock }))
const fromMock = vi.fn<(table: string) => {
select: typeof selectMock
insert: typeof insertMock
update: typeof updateMock
delete: typeof deleteMock
}>(() => ({ select: selectMock, insert: insertMock, update: updateMock, delete: deleteMock }))
vi.mock('../../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
const RULES = [
{ id: 'r1', title: 'Acompte', body: 'Un acompte est requis.', icon_key: 'deposit', sort_order: 0 },
{ id: 'r2', title: 'Retard', body: 'Des frais s\'appliquent.', icon_key: 'lateness', sort_order: 1 },
]
import AdminRules from './AdminRules'
describe('AdminRules', () => {
beforeEach(() => {
orderMock.mockReset().mockResolvedValue({ data: RULES, error: null })
selectMock.mockClear()
insertMock.mockReset().mockResolvedValue({ error: null })
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
updateMock.mockClear()
eqDeleteMock.mockReset().mockResolvedValue({ error: null })
deleteMock.mockClear()
fromMock.mockClear()
})
it('lists existing rules', async () => {
render(<AdminRules />)
expect(await screen.findByText('Acompte')).toBeInTheDocument()
expect(screen.getByText('Retard')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('rules')
})
it('creates a new rule from the form', async () => {
render(<AdminRules />)
await screen.findByText('Acompte')
await userEvent.type(screen.getByLabelText(/^titre$/i), 'Animaux')
await userEvent.type(screen.getByLabelText(/^texte$/i), 'Aucun animal admis.')
await userEvent.selectOptions(screen.getByLabelText(/icône/i), 'guests')
await userEvent.click(screen.getByRole('button', { name: /ajouter le règlement/i }))
await waitFor(() =>
expect(insertMock).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Animaux', body: 'Aucun animal admis.', icon_key: 'guests', sort_order: 2 }),
),
)
})
it('deletes a rule with confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<AdminRules />)
await screen.findByText('Acompte')
await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0])
expect(deleteMock).toHaveBeenCalled()
expect(eqDeleteMock).toHaveBeenCalledWith('id', 'r1')
})
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/admin/AdminRules.test.tsx
Expected: FAIL — ./AdminRules module does not exist.
- Step 3: Implement the component
// src/pages/admin/AdminRules.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { friendlyError } from '../../lib/friendlyError'
import RuleIcon from '../../components/RuleIcon'
import styles from './AdminRules.module.css'
interface AdminRule {
id: string
title: string
body: string
icon_key: string
sort_order: number
}
const ICON_OPTIONS: { key: string; label: string }[] = [
{ key: 'deposit', label: 'Bouclier (dépôt)' },
{ key: 'hair-prep', label: 'Cheveux (préparation)' },
{ key: 'extensions', label: 'Mèches (extensions)' },
{ key: 'lateness', label: 'Horloge (retard)' },
{ key: 'guests', label: 'Personnes (accompagnant)' },
{ key: 'payment', label: 'Carte (paiement)' },
]
function emptyForm(): Omit<AdminRule, 'sort_order'> {
return { id: '', title: '', body: '', icon_key: 'deposit' }
}
function AdminRules() {
const [rules, setRules] = useState<AdminRule[]>([])
const [form, setForm] = useState(emptyForm())
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadRules() {
const { data, error: fetchError } = await supabase.from('rules').select('*').order('sort_order')
if (fetchError) {
setError(fetchError.message)
return
}
setRules((data ?? []) as AdminRule[])
}
useEffect(() => {
void loadRules()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
if (form.id) {
const { error: updateError } = await supabase
.from('rules')
.update({ title: form.title, body: form.body, icon_key: form.icon_key })
.eq('id', form.id)
setSaving(false)
if (updateError) {
setError(updateError.message)
return
}
} else {
const maxOrder = rules.reduce((max, r) => Math.max(max, r.sort_order), -1)
const { error: insertError } = await supabase
.from('rules')
.insert({ title: form.title, body: form.body, icon_key: form.icon_key, sort_order: maxOrder + 1 })
setSaving(false)
if (insertError) {
setError(insertError.message)
return
}
}
setForm(emptyForm())
await loadRules()
}
async function handleDelete(id: string) {
if (!window.confirm('Supprimer ce règlement ?')) return
const { error: deleteError } = await supabase.from('rules').delete().eq('id', id)
if (deleteError) {
setError(friendlyError(deleteError))
return
}
await loadRules()
}
async function handleMove(index: number, direction: -1 | 1) {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= rules.length) return
const current = rules[index]
const target = rules[targetIndex]
setError('')
const { error: err1 } = await supabase.from('rules').update({ sort_order: target.sort_order }).eq('id', current.id)
if (err1) {
setError(err1.message)
return
}
const { error: err2 } = await supabase.from('rules').update({ sort_order: current.sort_order }).eq('id', target.id)
if (err2) {
setError(err2.message)
return
}
await loadRules()
}
const isEditing = Boolean(form.id)
return (
<div className={styles.page}>
<h1>Règlements</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Icône</th>
<th>Titre</th>
<th></th>
</tr>
</thead>
<tbody>
{rules.map((rule, index) => (
<tr key={rule.id}>
<td><RuleIcon iconKey={rule.icon_key} /></td>
<td>{rule.title}</td>
<td>
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, -1)} disabled={index === 0}>Monter</button>
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, 1)} disabled={index === rules.length - 1}>Descendre</button>
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(rule)}>Modifier</button>
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(rule.id)}>Supprimer</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{isEditing ? 'Modifier le règlement' : 'Nouveau règlement'}</h2>
<label htmlFor="rule-title">
Titre
<input
id="rule-title"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
/>
</label>
<label htmlFor="rule-body">
Texte
<textarea
id="rule-body"
value={form.body}
onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))}
required
/>
</label>
<label htmlFor="rule-icon">
Icône
<select
id="rule-icon"
value={form.icon_key}
onChange={(e) => setForm((f) => ({ ...f, icon_key: e.target.value }))}
>
{ICON_OPTIONS.map((option) => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
</label>
<RuleIcon iconKey={form.icon_key} />
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button} disabled={saving}>
{isEditing ? 'Enregistrer le règlement' : 'Ajouter le règlement'}
</button>
</form>
</div>
)
}
export default AdminRules
- Step 4: Write the CSS module
/* src/pages/admin/AdminRules.module.css */
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
margin-right: var(--admin-space-xs);
}
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
- Step 5: Run test to verify it passes
Run: npx vitest run src/pages/admin/AdminRules.test.tsx
Expected: PASS (3 tests).
- Step 6: Commit
git add src/pages/admin/AdminRules.tsx src/pages/admin/AdminRules.module.css src/pages/admin/AdminRules.test.tsx
git commit -m "feat: add AdminRules page with CRUD, reordering, and icon picker"
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_ordercolumns (Task 1). -
Step 1: Update the failing test
Modify src/pages/admin/AdminProducts.test.tsx's EXISTING_PRODUCT constant to include the new columns, and add a new test for the featured toggle:
const EXISTING_PRODUCT = {
id: 'edge-control',
name: 'Silk Edge Control',
category: 'hair-care',
price: 12,
image: 'products/edge-control.jpg',
description: 'Hold with shine.',
in_stock: true,
featured: false,
featured_order: null,
}
Add this test to the describe('AdminProducts', ...) block:
it('marks a product as featured, assigning it the next featured_order', async () => {
upsertMock.mockResolvedValue({ error: null })
render(<AdminProducts />)
await screen.findByText('Silk Edge Control')
await userEvent.click(screen.getByRole('checkbox', { name: /vedette/i }))
await waitFor(() =>
expect(upsertMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'edge-control', featured: true, featured_order: 0 }),
),
)
})
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/admin/AdminProducts.test.tsx
Expected: FAIL — no "Vedette" checkbox exists yet.
- Step 3: Implement
Replace src/pages/admin/AdminProducts.tsx in full:
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import { friendlyError } from '../../lib/friendlyError'
import styles from './AdminProducts.module.css'
interface AdminProduct {
id: string
name: string
category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
price: number
image: string
description: string
in_stock: boolean
featured: boolean
featured_order: number | null
}
const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories']
const CATEGORY_LABELS: Record<AdminProduct['category'], string> = {
'hair-care': 'Soins capillaires',
extensions: 'Extensions',
tools: 'Outils',
accessories: 'Accessoires',
}
function emptyForm(): AdminProduct {
return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true, featured: false, featured_order: null }
}
function AdminProducts() {
const [products, setProducts] = useState<AdminProduct[]>([])
const [form, setForm] = useState<AdminProduct>(emptyForm())
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadProducts() {
const { data, error: fetchError } = await supabase.from('products').select('*').order('name')
if (fetchError) {
setError(fetchError.message)
return
}
setProducts((data ?? []) as AdminProduct[])
}
useEffect(() => {
void loadProducts()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
let image = form.image
if (file) {
try {
image = await uploadSalonImage(file, `products/${form.id}-${file.name}`)
} catch {
setError("Échec du téléversement de l'image.")
setSaving(false)
return
}
}
const { error: upsertError } = await supabase.from('products').upsert({ ...form, image })
setSaving(false)
if (upsertError) {
setError(upsertError.message)
return
}
setForm(emptyForm())
setFile(null)
await loadProducts()
}
async function handleDelete(id: string) {
if (!window.confirm('Supprimer ce produit ?')) return
const { error: deleteError } = await supabase.from('products').delete().eq('id', id)
if (deleteError) {
setError(friendlyError(deleteError))
return
}
await loadProducts()
}
async function handleToggleFeatured(product: AdminProduct) {
setError('')
if (product.featured) {
const { error: upsertError } = await supabase.from('products').upsert({ ...product, featured: false, featured_order: null })
if (upsertError) {
setError(upsertError.message)
return
}
} else {
const maxOrder = products.reduce((max, p) => (p.featured && p.featured_order !== null ? Math.max(max, p.featured_order) : max), -1)
const { error: upsertError } = await supabase.from('products').upsert({ ...product, featured: true, featured_order: maxOrder + 1 })
if (upsertError) {
setError(upsertError.message)
return
}
}
await loadProducts()
}
async function handleMoveFeatured(product: AdminProduct, direction: -1 | 1) {
const featured = products
.filter((p) => p.featured)
.sort((a, b) => (a.featured_order ?? 0) - (b.featured_order ?? 0))
const index = featured.findIndex((p) => p.id === product.id)
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= featured.length) return
const current = featured[index]
const target = featured[targetIndex]
setError('')
const { error: err1 } = await supabase.from('products').upsert({ ...current, featured_order: target.featured_order })
if (err1) {
setError(err1.message)
return
}
const { error: err2 } = await supabase.from('products').upsert({ ...target, featured_order: current.featured_order })
if (err2) {
setError(err2.message)
return
}
await loadProducts()
}
const isEditing = Boolean(form.id) && products.some((p) => p.id === form.id)
const featuredProducts = products
.filter((p) => p.featured)
.sort((a, b) => (a.featured_order ?? 0) - (b.featured_order ?? 0))
return (
<div className={styles.page}>
<h1>Produits</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Nom</th>
<th>Catégorie</th>
<th>Prix</th>
<th>En stock</th>
<th>Vedette</th>
<th></th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id}>
<td>{product.name}</td>
<td>{CATEGORY_LABELS[product.category]}</td>
<td>{product.price} $</td>
<td>{product.in_stock ? 'Oui' : 'Non'}</td>
<td>
<label>
<input
type="checkbox"
checked={product.featured}
onChange={() => handleToggleFeatured(product)}
/>
Vedette
</label>
{product.featured && (
<>
<button
type="button"
className={styles.buttonSecondary}
onClick={() => handleMoveFeatured(product, -1)}
disabled={featuredProducts.findIndex((p) => p.id === product.id) === 0}
>
Monter
</button>
<button
type="button"
className={styles.buttonSecondary}
onClick={() => handleMoveFeatured(product, 1)}
disabled={featuredProducts.findIndex((p) => p.id === product.id) === featuredProducts.length - 1}
>
Descendre
</button>
</>
)}
</td>
<td>
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(product)}>Modifier</button>
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(product.id)}>Supprimer</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{isEditing ? 'Modifier le produit' : 'Nouveau produit'}</h2>
<label htmlFor="product-id">
Id
<input
id="product-id"
value={form.id}
onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))}
required
disabled={isEditing}
/>
</label>
<label htmlFor="product-name">
Nom
<input
id="product-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<label htmlFor="product-category">
Catégorie
<select
id="product-category"
value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value as AdminProduct['category'] }))}
>
{CATEGORIES.map((category) => (
<option key={category} value={category}>{CATEGORY_LABELS[category]}</option>
))}
</select>
</label>
<label htmlFor="product-price">
Prix
<input
id="product-price"
type="number"
step="0.01"
value={form.price}
onChange={(e) => setForm((f) => ({ ...f, price: Number(e.target.value) }))}
required
/>
</label>
<label htmlFor="product-description">
Description
<textarea
id="product-description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
/>
</label>
<label htmlFor="product-image">
Image
<input
id="product-image"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label htmlFor="product-in-stock">
<input
id="product-in-stock"
type="checkbox"
checked={form.in_stock}
onChange={(e) => setForm((f) => ({ ...f, in_stock: e.target.checked }))}
/>
En stock
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button} disabled={saving}>Enregistrer le produit</button>
</form>
</div>
)
}
export default AdminProducts
Note: the new test's checkbox is queried via getByRole('checkbox', { name: /vedette/i }) — there's exactly one product row in the test fixture, so this resolves to a single element without ambiguity, matching the label text Vedette wrapping the checkbox input in the table row above.
- Step 4: Update the CSS module to compose from the shared styles
Replace src/pages/admin/AdminProducts.module.css in full:
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
margin-right: var(--admin-space-xs);
}
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
- Step 5: Run test to verify it passes
Run: npx vitest run src/pages/admin/AdminProducts.test.tsx
Expected: PASS (4 tests).
- Step 6: Commit
git add src/pages/admin/AdminProducts.tsx src/pages/admin/AdminProducts.module.css src/pages/admin/AdminProducts.test.tsx
git commit -m "feat: add featured toggle/reorder to AdminProducts; apply shared admin design"
Task 14: Wire the 3 new pages into AdminLayout/AdminApp
Files:
- Modify:
src/pages/admin/AdminLayout.tsx - Modify:
src/pages/admin/AdminApp.tsx - Modify:
src/pages/admin/AdminApp.test.tsx
Interfaces:
-
Consumes:
AdminContent(Task 10),AdminTestimonials(Task 11),AdminRules(Task 12). -
Step 1: Update the failing test
Add these tests to the existing describe('AdminApp routing', ...) block in src/pages/admin/AdminApp.test.tsx, matching the file's existing render pattern exactly (each test wraps <AdminApp /> in its own MemoryRouter/Routes/Route path="/admin/*", not the top-level <App />):
it('renders the AdminContent page at /admin/content', async () => {
render(
<MemoryRouter initialEntries={['/admin/content']}>
<Routes>
<Route path="/admin/*" element={<AdminApp />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByRole('heading', { name: /^contenu$/i })).toBeInTheDocument()
})
it('renders the AdminTestimonials page at /admin/testimonials', async () => {
render(
<MemoryRouter initialEntries={['/admin/testimonials']}>
<Routes>
<Route path="/admin/*" element={<AdminApp />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByRole('heading', { name: /^témoignages$/i })).toBeInTheDocument()
})
it('renders the AdminRules page at /admin/rules', async () => {
render(
<MemoryRouter initialEntries={['/admin/rules']}>
<Routes>
<Route path="/admin/*" element={<AdminApp />} />
</Routes>
</MemoryRouter>,
)
expect(await screen.findByRole('heading', { name: /^règlements$/i })).toBeInTheDocument()
})
The file's existing vi.mock('../../lib/supabase', ...) block is:
vi.mock('../../lib/supabase', () => ({
supabase: {
auth: { signOut: vi.fn() },
from: () => ({
select: () => ({ order: () => Promise.resolve({ data: [], error: null }) }),
}),
},
}))
AdminContent's fetch calls .select().eq('id', 'default').single(), which this mock doesn't cover. Extend the select() return value to also provide eq, so both chains resolve:
vi.mock('../../lib/supabase', () => ({
supabase: {
auth: { signOut: vi.fn() },
from: () => ({
select: () => ({
order: () => Promise.resolve({ data: [], error: null }),
eq: () => ({
single: () => Promise.resolve({
data: { id: 'default', hero_headline: '', hero_tagline: '', about_bio: '', about_portrait_image: '', contact_email: '', contact_hours: '' },
error: null,
}),
}),
}),
}),
},
}))
- Step 2: Run test to verify it fails
Run: npx vitest run src/pages/admin/AdminApp.test.tsx
Expected: FAIL — no /admin/content, /admin/testimonials, or /admin/rules routes exist yet (they fall through to the catch-all redirect to /admin/products).
- Step 3: Update
AdminLayout.tsx's nav items
const NAV_ITEMS = [
{ to: '/admin/products', label: 'Produits' },
{ to: '/admin/services', label: 'Services' },
{ to: '/admin/schedule', label: 'Horaire' },
{ to: '/admin/bookings', label: 'Réservations' },
{ to: '/admin/inquiries', label: 'Demandes' },
{ to: '/admin/content', label: 'Contenu' },
{ to: '/admin/testimonials', label: 'Témoignages' },
{ to: '/admin/rules', label: 'Règlements' },
]
(Rest of AdminLayout.tsx unchanged.)
- Step 4: Update
AdminApp.tsx's routes
import { Routes, Route, Navigate } from 'react-router-dom'
import ProtectedRoute from '../../components/ProtectedRoute'
import AdminLogin from './AdminLogin'
import AdminLayout from './AdminLayout'
import AdminProducts from './AdminProducts'
import AdminServices from './AdminServices'
import AdminSchedule from './AdminSchedule'
import AdminBookings from './AdminBookings'
import AdminInquiries from './AdminInquiries'
import AdminContent from './AdminContent'
import AdminTestimonials from './AdminTestimonials'
import AdminRules from './AdminRules'
import styles from './AdminApp.module.css'
function AdminApp() {
return (
<div className={styles.adminRoot}>
<Routes>
<Route path="login" element={<AdminLogin />} />
<Route element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/products" replace />} />
<Route path="products" element={<AdminProducts />} />
<Route path="services" element={<AdminServices />} />
<Route path="schedule" element={<AdminSchedule />} />
<Route path="bookings" element={<AdminBookings />} />
<Route path="inquiries" element={<AdminInquiries />} />
<Route path="content" element={<AdminContent />} />
<Route path="testimonials" element={<AdminTestimonials />} />
<Route path="rules" element={<AdminRules />} />
<Route path="*" element={<Navigate to="/admin/products" replace />} />
</Route>
</Route>
</Routes>
</div>
)
}
export default AdminApp
- Step 5: Run test to verify it passes
Run: npx vitest run src/pages/admin/AdminApp.test.tsx
Expected: PASS (all tests, including the 3 new ones).
- Step 6: Run the full suite
Run: npx vitest run
Expected: all test files pass.
- Step 7: Commit
git add src/pages/admin/AdminLayout.tsx src/pages/admin/AdminApp.tsx src/pages/admin/AdminApp.test.tsx
git commit -m "feat: wire AdminContent/AdminTestimonials/AdminRules into admin nav and routes"
Task 15: Apply the redesign to the remaining legacy admin pages
Files:
- Modify:
src/pages/admin/AdminServices.module.css - Modify:
src/pages/admin/AdminServices.tsx - Modify:
src/pages/admin/AdminSchedule.module.css - Modify:
src/pages/admin/AdminSchedule.tsx - Modify:
src/pages/admin/AdminBookings.module.css - Modify:
src/pages/admin/AdminBookings.tsx - Modify:
src/pages/admin/AdminInquiries.module.css - Modify:
src/pages/admin/AdminInquiries.tsx
Interfaces:
- Consumes:
adminShared.module.css(Task 2).
CSS-only and className-only changes — no data-fetching or event-handler logic changes in any of the 4 files, so no test files change in this task.
- Step 1:
AdminServices.module.css
Replace in full:
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
margin-right: var(--admin-space-xs);
}
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
In AdminServices.tsx, add classNames to the 3 buttons (no other change):
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(service)}>Modifier</button>
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(service.id)}>Supprimer</button>
and
<button type="submit" className={styles.button} disabled={saving}>Enregistrer le service</button>
- Step 2:
AdminSchedule.module.css
Replace in full:
.page {
composes: page from './adminShared.module.css';
}
.section {
composes: card from './adminShared.module.css';
padding: var(--admin-space-lg);
display: flex;
flex-direction: column;
gap: var(--admin-space-md);
max-width: 480px;
}
.days {
display: flex;
flex-wrap: wrap;
gap: var(--admin-space-sm);
}
.days label {
display: flex;
align-items: center;
gap: var(--admin-space-xs);
font-size: 0.875rem;
color: var(--admin-text);
}
.form label,
.section label {
display: flex;
flex-direction: column;
gap: var(--admin-space-xs);
font-size: 0.875rem;
font-weight: 500;
color: var(--admin-text);
}
.form input,
.section input {
padding: var(--admin-space-sm);
border: 1px solid var(--admin-border);
border-radius: calc(var(--admin-radius) / 2);
font: inherit;
}
.exceptionsTable {
composes: table from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
In AdminSchedule.tsx:
-
Change
<button type="submit" disabled={saving}>Enregistrer l'horaire</button>to<button type="submit" className={styles.button} disabled={saving}>Enregistrer l'horaire</button>. -
Change
<button type="button" onClick={() => handleRemoveException(exception.date)}>Retirer</button>to<button type="button" className={styles.buttonSecondary} onClick={() => handleRemoveException(exception.date)}>Retirer</button>. -
Change
<button type="submit">Ajouter une exception</button>to<button type="submit" className={styles.button}>Ajouter une exception</button>. -
Step 3:
AdminBookings.module.css
Replace in full:
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
margin-right: var(--admin-space-xs);
}
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
In AdminBookings.tsx, style "Annuler" as danger and "Marquer comme terminée" as the neutral secondary action:
<button
type="button"
className={styles.buttonDanger}
onClick={() => {
if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) {
void setStatus(booking.id, 'cancelled')
}
}}
>
Annuler
</button>
<button
type="button"
className={styles.buttonSecondary}
onClick={() => {
if (window.confirm('Marquer cette réservation comme terminée ?')) {
void setStatus(booking.id, 'completed')
}
}}
>
Marquer comme terminée
</button>
- Step 4:
AdminInquiries.module.css
Replace in full:
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}
In AdminInquiries.tsx:
{inquiry.status === 'new' && (
<button type="button" className={styles.buttonSecondary} onClick={() => markRead(inquiry.id)}>Marquer comme lue</button>
)}
- Step 5: Run the full test suite and build
Run: npx vitest run && npx tsc -b && npm run build && npm run lint
Expected: all tests pass unchanged, build succeeds, lint clean. (Existing tests query by role/label/text, none of which changed — only className and CSS were touched.)
- Step 6: Commit
git add src/pages/admin/AdminServices.module.css src/pages/admin/AdminServices.tsx src/pages/admin/AdminSchedule.module.css src/pages/admin/AdminSchedule.tsx src/pages/admin/AdminBookings.module.css src/pages/admin/AdminBookings.tsx src/pages/admin/AdminInquiries.module.css src/pages/admin/AdminInquiries.tsx
git commit -m "style: apply shared admin design to Services/Schedule/Bookings/Inquiries pages"
Task 16: Deploy and live verification
Files: None (operational task).
- Step 1: Run full local verification
Run: npx supabase db reset && npx supabase test db
Expected: Result: PASS, Tests=63.
Run: npx vitest run && npx tsc -b && npm run build && npm run lint
Expected: all green.
- Step 2: Push the migration to the live Supabase project
Run: npx supabase migration list (confirm project is still linked to ylxppnclfpnnolnjkbwv; re-link with npx supabase link --project-ref ylxppnclfpnnolnjkbwv if needed).
Run: npx supabase db push
Expected: 20260813000001_site_content_testimonials_rules.sql applies to the live project with no errors.
- Step 3: Redeploy the frontend
Run: npx vercel --prod (from the linked braidsbymira project directory — verify with cat .vercel/project.json that projectName is braidsbymira before deploying, per the prior session's incident where an unlinked directory created a stray duplicate project).
Expected: deployment succeeds, aliased to https://braidsbymira.vercel.app.
- Step 4: Live smoke test — public site
Using the Playwright browser tool, navigate to https://braidsbymira.vercel.app/ and verify:
- Hero headline/tagline render (from
site_content). - "À propos de Mira" bio text renders.
- "Sélection boutique" shows only the products marked featured in the seed data (none, initially — the migration seeds
featured = falsefor 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/contentloads the seeded site content into the form./admin/testimonialslists the 3 seeded testimonials./admin/ruleslists 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).