# Phase 2D: Site Content Management & Admin Panel Redesign Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the homepage hero, About-Mira bio, testimonials, featured products, footer contact info, and salon rules editable from the admin panel, and give the admin panel itself a professional visual redesign. **Architecture:** Three new Postgres tables (`site_content` singleton, `testimonials`, `rules`) plus two new columns on `products` (`featured`, `featured_order`), all under the existing public-read/admin-write RLS pattern. New `src/data/*` fetch functions feed the public `Home`/`Footer`/`Rules` pages. Three new admin pages (Contenu, Témoignages, Règlements) plus an extended `AdminProducts` give the admin CRUD/reorder control. A new admin design-token system and shared CSS Module (composed into every admin page's own module) replaces the ad-hoc, unstyled admin look. **Tech Stack:** React 19 + TypeScript + Vite, react-router-dom v7, Supabase (Postgres/Auth/Storage), Vitest + React Testing Library, pgTAP, CSS Modules. **Spec:** `docs/superpowers/specs/2026-08-13-phase2d-site-content-and-admin-redesign-design.md` ## Global Constraints - Admin UUID for all new RLS policies: `2b88676b-f901-459a-9147-b914190c4f81` (current admin, per `docs/superpowers/plans/2026-08-08-phase2a-backend-foundation-supabase.md` Step 2 and the `2026-08-12214640_replace_admin_uuid` migration). - RLS pattern: `for select using (true)` for public read; `for all using ((select auth.uid()) = ''::uuid) with check (same)` for admin write — copied verbatim from every existing table's policy in `supabase/migrations/20260808000001_initial_schema.sql`. - Test Supabase client mocking: mock `../lib/supabase` (or `../../lib/supabase` from admin pages) directly — never `vi.importActual`, which crashes with "supabaseUrl is required" in a test env with no `.env`. - `vi.fn()` used with a typed implementation and spread args must have an explicit generic, e.g. `vi.fn<(table: string) => {...}>(...)` — an inferred zero-arg `vi.fn()` invoked via `(...args: unknown[]) => mock(...args)` fails `tsc -b` with TS2556. - Thrown/returned Supabase errors are rendered via `err.message` after a `typeof err === 'object' && 'message' in err` guard, never `err instanceof Error` (a Postgrest error object is not an `Error` instance). - Icon keys for rules stay fixed to the existing 6 in `RuleIcon.tsx` (`deposit`, `hair-prep`, `extensions`, `lateness`, `guests`, `payment`) — no free-text or upload. - Redesign scope is CSS/layout/shared-component-structure only (class names, composed styles, and button `className` additions) — no page's data-fetching logic changes as part of a redesign-only task. --- ### Task 1: Migration — new tables, RLS, seed data, pgTAP tests **Files:** - Create: `supabase/migrations/20260813000001_site_content_testimonials_rules.sql` - Modify: `supabase/tests/database/rls.test.sql` **Interfaces:** - Produces: tables `public.site_content` (columns: `id text primary key default 'default'`, `hero_headline text not null`, `hero_tagline text not null`, `about_bio text not null`, `about_portrait_image text not null`, `contact_email text not null`, `contact_hours text not null`), `public.testimonials` (`id uuid primary key default gen_random_uuid()`, `quote text not null`, `author text not null`, `avatar_image text not null`, `sort_order integer not null`), `public.rules` (`id uuid primary key default gen_random_uuid()`, `title text not null`, `body text not null`, `icon_key text not null`, `sort_order integer not null`); `public.products` gains `featured boolean not null default false` and `featured_order integer` (nullable). - [ ] **Step 1: Write the migration** ```sql -- Phase 2D: adds site-editable content tables (site_content, testimonials, -- rules) and featured-product columns, all following the existing -- public-read/admin-write RLS pattern from the initial schema. Seed values -- match what the site currently shows via hardcoded frontend constants, so -- this migration doesn't blank any live content. create table public.site_content ( id text primary key default 'default', hero_headline text not null, hero_tagline text not null, about_bio text not null, about_portrait_image text not null, contact_email text not null, contact_hours text not null ); alter table public.site_content enable row level security; create policy "site_content_public_read" on public.site_content for select using (true); create policy "site_content_admin_write" on public.site_content for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid) with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid); insert into public.site_content (id, hero_headline, hero_tagline, about_bio, about_portrait_image, contact_email, contact_hours) values ( 'default', 'Vos cheveux, votre couronne', 'Coiffures protectrices par Mira', 'Avec plus d''une décennie d''expérience derrière le fauteuil, Mira aborde chaque installation comme une œuvre d''art — douce pour le cuir chevelu, précise à la racine, et achevée pour une couronne que vous aurez envie d''afficher.', 'about/mira-portrait.jpg', 'hello@braidsbymira.com', 'Mar–Sam, 9h à 18h' ); create table public.testimonials ( id uuid primary key default gen_random_uuid(), quote text not null, author text not null, avatar_image text not null, sort_order integer not null ); alter table public.testimonials enable row level security; create policy "testimonials_public_read" on public.testimonials for select using (true); create policy "testimonials_admin_write" on public.testimonials for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid) with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid); insert into public.testimonials (quote, author, avatar_image, sort_order) values ('Mira m''a fait sentir comme une reine sur son fauteuil. Mes tresses ont tenu 8 semaines !', 'Amara T.', 'testimonials/amara.jpg', 0), ('La tresseuse la plus douce et la plus précise que j''aie jamais consultée.', 'Keisha R.', 'testimonials/keisha.jpg', 1), ('La réservation était si simple, et le résultat parle de lui-même.', 'Nadia F.', 'testimonials/nadia.jpg', 2); create table public.rules ( id uuid primary key default gen_random_uuid(), title text not null, body text not null, icon_key text not null, sort_order integer not null ); alter table public.rules enable row level security; create policy "rules_public_read" on public.rules for select using (true); create policy "rules_admin_write" on public.rules for all using ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid) with check ((select auth.uid()) = '2b88676b-f901-459a-9147-b914190c4f81'::uuid); insert into public.rules (title, body, icon_key, sort_order) values ('Acompte', 'Un acompte de 30 $ est exigé afin de confirmer toute réservation. Celui-ci n''est pas remboursable, mais transférable à une date ultérieure sur entente, pour un maximum d''une fois. Toute annulation moins de 48 heures avant le rendez-vous entraînera la perte de l''acompte, qui ne sera ni remboursé ni transféré. En cas de non-présentation (« no-show »), l''acompte est automatiquement perdu.', 'deposit', 0), ('Préalables cheveux', 'Prière de garder vos cheveux propres, démêlés et secs, prêts à être tressés. Le blowdry peut être facturé si cette règle n''est pas respectée. Pas de bains d''huiles la veille ou le jour du rendez-vous, s''il vous plaît. Merci de toujours préciser, photo à l''appui, vos caractéristiques spécifiques (cheveux courts, locks, etc.).', 'hair-prep', 1), ('Mèches et extensions', 'Les mèches sont à déposer 24 à 48h avant la prestation si ce n''est pas moi qui les fournis. Choisissez de préférence des mèches pré-étirées ; l''étirage des mèches vous sera facturé en fonction du nombre de paquets.', 'extensions', 2), ('Retard', 'Les retards sans notification préalable ne sont pas autorisés. Un retard de 15 à 20 minutes entraîne des frais supplémentaires, et à partir de 30 minutes, le rendez-vous est automatiquement annulé.', 'lateness', 3), ('Accompagnant', 'Vous êtes tenue de vous présenter seule à votre rendez-vous. Merci de ne pas venir accompagnée sans l''avoir signalé au préalable.', 'guests', 4), ('Paiement', 'Le paiement peut être fait en espèces ou par virement Interac.', 'payment', 5); alter table public.products add column featured boolean not null default false; alter table public.products add column featured_order integer; ``` - [ ] **Step 2: Add pgTAP tests** Open `supabase/tests/database/rls.test.sql`. Change `select plan(53);` to `select plan(63);`. Insert the following block right before the final `select * from finish();` / `rollback;` lines at the end of the file: ```sql -- site_content: public read, admin-only write. Singleton row seeded by the -- migration, so no insert test is needed -- only read and update. select isnt_empty( $$ select * from public.site_content where id = 'default' $$, 'anon can read site_content' ); select throws_ok( $$ update public.site_content set hero_headline = 'hack' where id = 'default' $$, '42501', null, 'anon cannot update site_content' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ update public.site_content set hero_headline = 'Updated Headline' where id = 'default' $$, 'admin can update site_content' ); -- testimonials: public read, admin-only write. set local role anon; select isnt_empty( $$ select * from public.testimonials $$, 'anon can read testimonials' ); select throws_ok( $$ insert into public.testimonials (quote, author, avatar_image, sort_order) values ('hack', 'Hacker', 'x.jpg', 99) $$, '42501', null, 'anon cannot insert into testimonials' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ insert into public.testimonials (quote, author, avatar_image, sort_order) values ('Great service!', 'Test Client', 'test.jpg', 99) $$, 'admin can insert into testimonials' ); -- rules: public read, admin-only write. set local role anon; select isnt_empty( $$ select * from public.rules $$, 'anon can read rules' ); select throws_ok( $$ insert into public.rules (title, body, icon_key, sort_order) values ('hack', 'hack', 'deposit', 99) $$, '42501', null, 'anon cannot insert into rules' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ insert into public.rules (title, body, icon_key, sort_order) values ('Test Rule', 'Test body.', 'payment', 99) $$, 'admin can insert into rules' ); -- products.featured / featured_order: no new RLS policy (existing -- products_admin_write already covers all columns), but confirm anon still -- can't set them and admin can. set local role anon; select throws_ok( $$ update public.products set featured = true where id = 'edge-control' $$, '42501', null, 'anon cannot set products.featured' ); set local role authenticated; set local request.jwt.claim.sub = '2b88676b-f901-459a-9147-b914190c4f81'; select lives_ok( $$ update public.products set featured = true, featured_order = 0 where id = 'edge-control' $$, 'admin can set products.featured and featured_order' ); ``` - [ ] **Step 3: Run local verification** Run: `npx supabase db reset` Expected: all migrations (including the new one) apply cleanly, no errors. Run: `npx supabase test db` Expected: `Result: PASS`, `Tests=63`, `Failed: 0`. - [ ] **Step 4: Commit** ```bash git add supabase/migrations/20260813000001_site_content_testimonials_rules.sql supabase/tests/database/rls.test.sql git commit -m "feat: add site_content, testimonials, and rules tables; featured products" ``` --- ### Task 2: Admin design tokens and AdminLayout/AdminLogin redesign **Files:** - Create: `src/pages/admin/AdminApp.module.css` - Create: `src/pages/admin/adminShared.module.css` - Modify: `src/pages/admin/AdminApp.tsx` - Modify: `src/pages/admin/AdminLayout.module.css` - Modify: `src/pages/admin/AdminLogin.module.css` **Interfaces:** - Produces: CSS custom properties `--admin-bg`, `--admin-surface`, `--admin-border`, `--admin-text`, `--admin-text-muted`, `--admin-primary`, `--admin-primary-hover`, `--admin-danger`, `--admin-radius`, `--admin-shadow`, `--admin-font`, `--admin-space-xs/sm/md/lg/xl`, scoped to a `.adminRoot` wrapper class every later admin task builds pages inside. Produces composable classes `page`, `card`, `table`, `form`, `error`, `button`, `buttonSecondary`, `buttonDanger` in `adminShared.module.css`, which every later admin page's own `.module.css` composes from (`composes: table from './adminShared.module.css'`). This task establishes the visual system before any new admin page is built, so every subsequent admin page (Tasks 9-12) is built against the new design from the start rather than needing a second pass. - [ ] **Step 1: Create the token file** ```css /* src/pages/admin/AdminApp.module.css */ .adminRoot { --admin-bg: #F4F5F7; --admin-surface: #FFFFFF; --admin-border: #E1E4E9; --admin-text: #1F2430; --admin-text-muted: #6B7280; --admin-primary: #2A5C8A; --admin-primary-hover: #1E4566; --admin-danger: #B3261E; --admin-radius: 8px; --admin-shadow: 0 1px 2px rgba(16, 24, 40, 0.06); --admin-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; --admin-space-xs: 0.25rem; --admin-space-sm: 0.5rem; --admin-space-md: 1rem; --admin-space-lg: 1.5rem; --admin-space-xl: 2.5rem; min-height: 100vh; background: var(--admin-bg); color: var(--admin-text); font-family: var(--admin-font); } ``` Scoped to `.adminRoot` (not global `:root`) so these tokens never leak into or collide with the public site's `src/styles/tokens.css`, which the admin bundle doesn't import and vice versa. - [ ] **Step 2: Create the shared component-style file** ```css /* src/pages/admin/adminShared.module.css */ .page { display: flex; flex-direction: column; gap: var(--admin-space-lg); } .card { background: var(--admin-surface); border: 1px solid var(--admin-border); border-radius: var(--admin-radius); box-shadow: var(--admin-shadow); } .table { composes: card; width: 100%; border-collapse: collapse; } .table th { text-align: left; padding: var(--admin-space-sm) var(--admin-space-md); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--admin-text-muted); border-bottom: 1px solid var(--admin-border); background: var(--admin-bg); } .table td { padding: var(--admin-space-sm) var(--admin-space-md); border-bottom: 1px solid var(--admin-border); vertical-align: middle; } .table tbody tr:last-child td { border-bottom: none; } .table tbody tr:hover { background: var(--admin-bg); } .form { composes: card; display: flex; flex-direction: column; gap: var(--admin-space-md); max-width: 480px; padding: var(--admin-space-lg); } .form label { display: flex; flex-direction: column; gap: var(--admin-space-xs); font-size: 0.875rem; font-weight: 500; color: var(--admin-text); } .form input, .form select, .form textarea { padding: var(--admin-space-sm); border: 1px solid var(--admin-border); border-radius: calc(var(--admin-radius) / 2); font: inherit; color: var(--admin-text); background: var(--admin-surface); } .form input:focus, .form select:focus, .form textarea:focus { outline: 2px solid var(--admin-primary); outline-offset: 1px; } .form textarea { min-height: 6rem; resize: vertical; } .button { font: inherit; font-weight: 600; padding: var(--admin-space-sm) var(--admin-space-md); border-radius: calc(var(--admin-radius) / 2); border: 1px solid transparent; cursor: pointer; background: var(--admin-primary); color: white; } .button:hover { background: var(--admin-primary-hover); } .button:disabled { opacity: 0.6; cursor: default; } .buttonSecondary { composes: button; font-weight: 500; background: var(--admin-surface); color: var(--admin-text); border-color: var(--admin-border); } .buttonSecondary:hover { background: var(--admin-bg); } .buttonDanger { composes: buttonSecondary; color: var(--admin-danger); } .buttonDanger:hover { background: #FEF2F2; border-color: var(--admin-danger); } .error { color: var(--admin-danger); font-size: 0.875rem; } ``` - [ ] **Step 3: Wrap `AdminApp` in the token scope** Modify `src/pages/admin/AdminApp.tsx`: ```tsx import { Routes, Route, Navigate } from 'react-router-dom' import ProtectedRoute from '../../components/ProtectedRoute' import AdminLogin from './AdminLogin' import AdminLayout from './AdminLayout' import AdminProducts from './AdminProducts' import AdminServices from './AdminServices' import AdminSchedule from './AdminSchedule' import AdminBookings from './AdminBookings' import AdminInquiries from './AdminInquiries' import styles from './AdminApp.module.css' function AdminApp() { return (
} /> }> }> } /> } /> } /> } /> } /> } /> } />
) } export default AdminApp ``` (Task 14 below adds the 3 new routes to this same file.) - [ ] **Step 4: Redesign `AdminLayout.module.css`** Replace its full contents: ```css .layout { display: flex; min-height: 100vh; } .sidebar { width: 240px; flex-shrink: 0; background: var(--admin-surface); border-right: 1px solid var(--admin-border); padding: var(--admin-space-lg) var(--admin-space-md); display: flex; flex-direction: column; gap: var(--admin-space-xl); } .brand { font-weight: 700; font-size: 1.125rem; color: var(--admin-text); padding: 0 var(--admin-space-sm); } .sidebar nav { display: flex; flex-direction: column; gap: var(--admin-space-xs); } .navLink { color: var(--admin-text-muted); text-decoration: none; padding: var(--admin-space-sm); border-radius: var(--admin-radius); font-size: 0.9375rem; font-weight: 500; } .navLink:hover { background: var(--admin-bg); color: var(--admin-text); } .navLinkActive { background: var(--admin-primary); color: white; } .navLinkActive:hover { background: var(--admin-primary-hover); color: white; } .signOut { margin-top: auto; background: none; border: 1px solid var(--admin-border); border-radius: var(--admin-radius); color: var(--admin-text-muted); padding: var(--admin-space-sm); cursor: pointer; font: inherit; } .signOut:hover { border-color: var(--admin-danger); color: var(--admin-danger); } .content { flex: 1; padding: var(--admin-space-xl) var(--admin-space-lg); max-width: 1100px; } ``` No changes needed to `AdminLayout.tsx` — it already applies `styles.navLink` and (conditionally) `styles.navLinkActive` together via `` `${styles.navLink} ${styles.navLinkActive}` ``, which continues to work unchanged with the new rules. - [ ] **Step 5: Redesign `AdminLogin.module.css`** Replace its full contents: ```css .page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--admin-bg); padding: var(--admin-space-lg); } .form { display: flex; flex-direction: column; gap: var(--admin-space-md); width: 100%; max-width: 340px; background: var(--admin-surface); border: 1px solid var(--admin-border); border-radius: var(--admin-radius); box-shadow: var(--admin-shadow); padding: var(--admin-space-xl) var(--admin-space-lg); } .form h1 { font-size: 1.25rem; font-weight: 700; color: var(--admin-text); margin: 0 0 var(--admin-space-sm); } .field { display: flex; flex-direction: column; gap: var(--admin-space-xs); } .field label { font-size: 0.875rem; font-weight: 500; color: var(--admin-text); } .field input { padding: var(--admin-space-sm); border: 1px solid var(--admin-border); border-radius: calc(var(--admin-radius) / 2); font: inherit; background: var(--admin-surface); color: var(--admin-text); } .field input:focus { outline: 2px solid var(--admin-primary); outline-offset: 1px; } .error { color: var(--admin-danger); font-size: 0.875rem; } .form button { background: var(--admin-primary); color: white; border: none; border-radius: calc(var(--admin-radius) / 2); padding: var(--admin-space-sm) var(--admin-space-md); font: inherit; font-weight: 600; cursor: pointer; } .form button:hover { background: var(--admin-primary-hover); } .form button:disabled { opacity: 0.6; cursor: default; } ``` No `AdminLogin.tsx` changes needed — its JSX structure (`.page` > `.form` > `h1` + `.field` + `.error` + `button`) already matches these selectors. - [ ] **Step 6: Verify** Run: `npm run build && npx vitest run src/pages/admin/AdminLogin.test.tsx src/pages/admin/AdminApp.test.tsx` Expected: build succeeds, both test files pass unchanged (no assertions reference styling). - [ ] **Step 7: Commit** ```bash git add src/pages/admin/AdminApp.module.css src/pages/admin/adminShared.module.css src/pages/admin/AdminApp.tsx src/pages/admin/AdminLayout.module.css src/pages/admin/AdminLogin.module.css git commit -m "feat: introduce admin design tokens and redesign AdminLayout/AdminLogin" ``` --- ### Task 3: `fetchSiteContent` data function **Files:** - Create: `src/data/siteContent.ts` - Test: `src/data/siteContent.test.ts` **Interfaces:** - Produces: `export interface SiteContent { heroHeadline: string; heroTagline: string; aboutBio: string; aboutPortraitImage: string; contactEmail: string; contactHours: string }` and `export async function fetchSiteContent(): Promise`. - Consumes: `resolveImageUrl` from `../lib/resolveImageUrl` (Phase 2C, unchanged), `supabase` from `../lib/supabase`. - [ ] **Step 1: Write the failing test** ```ts // src/data/siteContent.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' const singleMock = vi.fn() const eqMock = vi.fn(() => ({ single: singleMock })) const selectMock = vi.fn(() => ({ eq: eqMock })) const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock })) const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`) vi.mock('../lib/supabase', () => ({ supabase: { from: (table: string) => fromMock(table) }, })) vi.mock('../lib/resolveImageUrl', () => ({ resolveImageUrl: (path: string) => resolveImageUrlMock(path), })) const ROW = { id: 'default', hero_headline: 'Vos cheveux, votre couronne', hero_tagline: 'Coiffures protectrices par Mira', about_bio: 'Bio text.', about_portrait_image: 'about/mira-portrait.jpg', contact_email: 'hello@braidsbymira.com', contact_hours: 'Mar–Sam, 9h à 18h', } import { fetchSiteContent, type SiteContent } from './siteContent' describe('fetchSiteContent', () => { beforeEach(() => { singleMock.mockReset() eqMock.mockClear() selectMock.mockClear() fromMock.mockClear() resolveImageUrlMock.mockClear() }) it('fetches the default row and maps snake_case columns to the SiteContent shape', async () => { singleMock.mockResolvedValue({ data: ROW, error: null }) const content = await fetchSiteContent() expect(fromMock).toHaveBeenCalledWith('site_content') expect(eqMock).toHaveBeenCalledWith('id', 'default') expect(resolveImageUrlMock).toHaveBeenCalledWith('about/mira-portrait.jpg') expect(content).toEqual({ heroHeadline: 'Vos cheveux, votre couronne', heroTagline: 'Coiffures protectrices par Mira', aboutBio: 'Bio text.', aboutPortraitImage: 'https://cdn.example/about/mira-portrait.jpg', contactEmail: 'hello@braidsbymira.com', contactHours: 'Mar–Sam, 9h à 18h', }) }) it('throws when the fetch fails', async () => { singleMock.mockResolvedValue({ data: null, error: { message: 'network error' } }) await expect(fetchSiteContent()).rejects.toEqual({ message: 'network error' }) }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/siteContent.test.ts` Expected: FAIL — `./siteContent` module does not exist. - [ ] **Step 3: Implement** ```ts // src/data/siteContent.ts import { supabase } from '../lib/supabase' import { resolveImageUrl } from '../lib/resolveImageUrl' export interface SiteContent { heroHeadline: string heroTagline: string aboutBio: string aboutPortraitImage: string contactEmail: string contactHours: string } interface SiteContentRow { hero_headline: string hero_tagline: string about_bio: string about_portrait_image: string contact_email: string contact_hours: string } export async function fetchSiteContent(): Promise { const { data, error } = await supabase.from('site_content').select('*').eq('id', 'default').single() if (error) throw error const row = data as SiteContentRow return { heroHeadline: row.hero_headline, heroTagline: row.hero_tagline, aboutBio: row.about_bio, aboutPortraitImage: resolveImageUrl(row.about_portrait_image), contactEmail: row.contact_email, contactHours: row.contact_hours, } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/siteContent.test.ts` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/data/siteContent.ts src/data/siteContent.test.ts git commit -m "feat: add fetchSiteContent data function" ``` --- ### Task 4: `fetchTestimonials` data function **Files:** - Create: `src/data/testimonials.ts` - Test: `src/data/testimonials.test.ts` **Interfaces:** - Produces: `export interface Testimonial { id: string; quote: string; author: string; avatarImage: string }` and `export async function fetchTestimonials(): Promise`. - [ ] **Step 1: Write the failing test** ```ts // src/data/testimonials.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' const orderMock = vi.fn() const selectMock = vi.fn(() => ({ order: orderMock })) const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock })) const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`) vi.mock('../lib/supabase', () => ({ supabase: { from: (table: string) => fromMock(table) }, })) vi.mock('../lib/resolveImageUrl', () => ({ resolveImageUrl: (path: string) => resolveImageUrlMock(path), })) const ROW = { id: 'a1', quote: 'Great!', author: 'Amara T.', avatar_image: 'testimonials/amara.jpg', sort_order: 0 } import { fetchTestimonials, type Testimonial } from './testimonials' describe('fetchTestimonials', () => { beforeEach(() => { orderMock.mockReset() selectMock.mockClear() fromMock.mockClear() resolveImageUrlMock.mockClear() }) it('fetches testimonials ordered by sort_order and maps to the Testimonial shape', async () => { orderMock.mockResolvedValue({ data: [ROW], error: null }) const testimonials = await fetchTestimonials() expect(fromMock).toHaveBeenCalledWith('testimonials') expect(orderMock).toHaveBeenCalledWith('sort_order') expect(resolveImageUrlMock).toHaveBeenCalledWith('testimonials/amara.jpg') expect(testimonials).toEqual([ { id: 'a1', quote: 'Great!', author: 'Amara T.', avatarImage: 'https://cdn.example/testimonials/amara.jpg' }, ]) }) it('throws when the fetch fails', async () => { orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } }) await expect(fetchTestimonials()).rejects.toEqual({ message: 'network error' }) }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/testimonials.test.ts` Expected: FAIL — `./testimonials` module does not exist. - [ ] **Step 3: Implement** ```ts // src/data/testimonials.ts import { supabase } from '../lib/supabase' import { resolveImageUrl } from '../lib/resolveImageUrl' export interface Testimonial { id: string quote: string author: string avatarImage: string } interface TestimonialRow { id: string quote: string author: string avatar_image: string sort_order: number } export async function fetchTestimonials(): Promise { const { data, error } = await supabase.from('testimonials').select('*').order('sort_order') if (error) throw error return ((data ?? []) as TestimonialRow[]).map((row) => ({ id: row.id, quote: row.quote, author: row.author, avatarImage: resolveImageUrl(row.avatar_image), })) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/testimonials.test.ts` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/data/testimonials.ts src/data/testimonials.test.ts git commit -m "feat: add fetchTestimonials data function" ``` --- ### Task 5: Rewrite `rules.ts` to `fetchRules`; rename `RuleIcon`'s prop to `iconKey` **Files:** - Modify: `src/data/rules.ts` - Modify: `src/components/RuleIcon.tsx` - Test: `src/data/rules.test.ts` (new) - Modify: `src/components/RuleIcon.test.tsx` **Interfaces:** - Produces: `export interface RuleSection { id: string; title: string; body: string; iconKey: string }` and `export async function fetchRules(): Promise` (replaces the static `export const rules` array). `RuleIcon` takes `{ iconKey: string }` instead of `{ ruleId: string }`. - Consumes by later tasks: Task 8 (`Rules.tsx`) calls `fetchRules()` and renders ``; Task 12 (`AdminRules`) uses the same `iconKey` values for its dropdown. - [ ] **Step 1: Write the failing data-function test** ```ts // src/data/rules.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' const orderMock = vi.fn() const selectMock = vi.fn(() => ({ order: orderMock })) const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock })) vi.mock('../lib/supabase', () => ({ supabase: { from: (table: string) => fromMock(table) }, })) const ROW = { id: 'r1', title: 'Acompte', body: 'Body text.', icon_key: 'deposit', sort_order: 0 } import { fetchRules, type RuleSection } from './rules' describe('fetchRules', () => { beforeEach(() => { orderMock.mockReset() selectMock.mockClear() fromMock.mockClear() }) it('fetches rules ordered by sort_order and maps to the RuleSection shape', async () => { orderMock.mockResolvedValue({ data: [ROW], error: null }) const rules = await fetchRules() expect(fromMock).toHaveBeenCalledWith('rules') expect(orderMock).toHaveBeenCalledWith('sort_order') expect(rules).toEqual([ { id: 'r1', title: 'Acompte', body: 'Body text.', iconKey: 'deposit' }, ]) }) it('throws when the fetch fails', async () => { orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } }) await expect(fetchRules()).rejects.toEqual({ message: 'network error' }) }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/rules.test.ts` Expected: FAIL — `fetchRules` is not exported (current file only exports a static `rules` array). - [ ] **Step 3: Rewrite `src/data/rules.ts`** ```ts import { supabase } from '../lib/supabase' export interface RuleSection { id: string title: string body: string iconKey: string } interface RuleRow { id: string title: string body: string icon_key: string sort_order: number } export async function fetchRules(): Promise { const { data, error } = await supabase.from('rules').select('*').order('sort_order') if (error) throw error return ((data ?? []) as RuleRow[]).map((row) => ({ id: row.id, title: row.title, body: row.body, iconKey: row.icon_key, })) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/rules.test.ts` Expected: PASS (2 tests). - [ ] **Step 5: Update `RuleIcon` to take `iconKey`** Modify `src/components/RuleIcon.tsx`: ```tsx interface RuleIconProps { iconKey: string } const ICON_PATHS: Record = { 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 ( ) } export default RuleIcon ``` - [ ] **Step 6: Update `RuleIcon.test.tsx`** ```tsx import { render } from '@testing-library/react' import { describe, it, expect } from 'vitest' import RuleIcon from './RuleIcon' describe('RuleIcon', () => { it('renders an svg for a known icon key', () => { const { container } = render() expect(container.querySelector('svg')).toBeInTheDocument() }) it('falls back to a default icon for an unknown icon key without throwing', () => { const { container } = render() expect(container.querySelector('svg path')).toBeInTheDocument() }) }) ``` - [ ] **Step 7: Run both test files** Run: `npx vitest run src/data/rules.test.ts src/components/RuleIcon.test.tsx` Expected: PASS (2 + 2 tests). Note: `src/pages/Rules.tsx` and `src/pages/Rules.test.tsx` still reference the old static `rules` export and `ruleId` prop at this point in the plan — they are rewritten in Task 9 and will fail to compile/pass until then. This is expected and resolved within this same plan, not left as a dangling break at the end. - [ ] **Step 8: Commit** ```bash git add src/data/rules.ts src/data/rules.test.ts src/components/RuleIcon.tsx src/components/RuleIcon.test.tsx git commit -m "feat: replace static rules array with fetchRules; RuleIcon takes iconKey" ``` --- ### Task 6: Extend `products.ts` with `featured`/`featuredOrder` **Files:** - Modify: `src/data/products.ts` - Modify: `src/data/products.test.ts` **Interfaces:** - Produces: `Product` gains `featured: boolean` and `featuredOrder: number | null`. - Consumes by later tasks: Task 7 (`Home.tsx`) filters/sorts on these two fields; Task 13 (`AdminProducts`) reads/writes `featured`/`featured_order`. - [ ] **Step 1: Update the test's expected shape (write the failing assertion)** Modify `src/data/products.test.ts`'s `ROW` constant and the `toEqual` assertion in the first `fetchProducts` test: ```ts const ROW = { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: 'products/edge-control.jpg', description: 'Tenue longue durée.', in_stock: true, featured: true, featured_order: 0, } ``` ```ts it('fetches products ordered by name and maps snake_case columns to the Product shape', async () => { orderMock.mockResolvedValue({ data: [ROW], error: null }) const products = await fetchProducts() expect(fromMock).toHaveBeenCalledWith('products') expect(orderMock).toHaveBeenCalledWith('name') expect(resolveImageUrlMock).toHaveBeenCalledWith('products/edge-control.jpg') expect(products).toEqual([ { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: 'https://cdn.example/products/edge-control.jpg', description: 'Tenue longue durée.', inStock: true, featured: true, featuredOrder: 0, }, ]) }) ``` Also add `featured: false, featuredOrder: null` to both entries in the `getProductsByCategory` describe block's `products` array (so the type continues to satisfy `Product[]`): ```ts describe('getProductsByCategory', () => { const products: Product[] = [ { id: 'a', name: 'A', category: 'hair-care', price: 1, image: '', description: '', inStock: true, featured: false, featuredOrder: null }, { id: 'b', name: 'B', category: 'tools', price: 1, image: '', description: '', inStock: true, featured: false, featuredOrder: null }, ] // ... rest unchanged ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/products.test.ts` Expected: FAIL — actual `fetchProducts()` result is missing `featured`/`featuredOrder`. - [ ] **Step 3: Implement** Modify `src/data/products.ts`: ```ts import { supabase } from '../lib/supabase' import { resolveImageUrl } from '../lib/resolveImageUrl' export interface Product { id: string name: string category: 'hair-care' | 'extensions' | 'tools' | 'accessories' price: number image: string description: string inStock: boolean featured: boolean featuredOrder: number | null } interface ProductRow { id: string name: string category: Product['category'] price: number image: string description: string in_stock: boolean featured: boolean featured_order: number | null } export async function fetchProducts(): Promise { const { data, error } = await supabase.from('products').select('*').order('name') if (error) throw error return ((data ?? []) as ProductRow[]).map((row) => ({ id: row.id, name: row.name, category: row.category, price: row.price, image: resolveImageUrl(row.image), description: row.description, inStock: row.in_stock, featured: row.featured, featuredOrder: row.featured_order, })) } export function getProductsByCategory(products: Product[], category: Product['category'] | 'all'): Product[] { if (category === 'all') return products return products.filter((p) => p.category === category) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/products.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/data/products.ts src/data/products.test.ts git commit -m "feat: extend Product with featured/featuredOrder" ``` --- ### Task 7: Wire `Home.tsx` to site content, testimonials, and featured products **Files:** - Modify: `src/pages/Home.tsx` - Modify: `src/pages/Home.test.tsx` **Interfaces:** - Consumes: `fetchSiteContent` (Task 3), `fetchTestimonials` (Task 4), `Product.featured`/`featuredOrder` (Task 6). - [ ] **Step 1: Update the failing test** Modify `src/pages/Home.test.tsx` in full: ```tsx import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Routes, Route } from 'react-router-dom' import { describe, it, expect, vi, beforeEach } from 'vitest' const productsOrderMock = vi.fn() const servicesOrderMock = vi.fn() const testimonialsOrderMock = vi.fn() const siteContentSingleMock = vi.fn() const siteContentEqMock = vi.fn(() => ({ single: siteContentSingleMock })) const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } | { eq: typeof siteContentEqMock } }>((table: string) => { if (table === 'products') return { select: () => ({ order: productsOrderMock }) } if (table === 'services') return { select: () => ({ order: servicesOrderMock }) } if (table === 'testimonials') return { select: () => ({ order: testimonialsOrderMock }) } if (table === 'site_content') return { select: () => ({ eq: siteContentEqMock }) } throw new Error(`unexpected table: ${table}`) }) vi.mock('../lib/supabase', () => ({ supabase: { from: (table: string) => fromMock(table) }, })) vi.mock('../lib/resolveImageUrl', () => ({ resolveImageUrl: (path: string) => path, })) const PRODUCT_ROWS = [ { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true, featured: true, featured_order: 0 }, { id: 'braid-oil', name: 'Huile Nourrissante', category: 'hair-care', price: 16, image: '', description: '', in_stock: true, featured: false, featured_order: null }, ] const SERVICE_ROWS = [ { id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' }, ] const TESTIMONIAL_ROWS = [ { id: 't1', quote: 'Fantastique service !', author: 'Amara T.', avatar_image: '', sort_order: 0 }, ] const SITE_CONTENT_ROW = { id: 'default', hero_headline: 'Vos cheveux, votre couronne', hero_tagline: 'Coiffures protectrices par Mira', about_bio: 'Bio de test.', about_portrait_image: '', contact_email: 'hello@braidsbymira.com', contact_hours: 'Mar–Sam, 9h à 18h', } import Home from './Home' import Book from './Book' function renderHome() { return render( } /> } /> , ) } describe('Home page', () => { beforeEach(() => { productsOrderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null }) servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null }) testimonialsOrderMock.mockReset().mockResolvedValue({ data: TESTIMONIAL_ROWS, error: null }) siteContentSingleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null }) fromMock.mockClear() }) it('renders the hero headline and all signature styles', async () => { renderHome() expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument() for (const service of SERVICE_ROWS) { expect(await screen.findByText(service.name)).toBeInTheDocument() } }) it('renders only featured products in shop highlights, and a link to the rules page', async () => { renderHome() expect(await screen.findByText('Gel Contour Soyeux')).toBeInTheDocument() expect(screen.queryByText('Huile Nourrissante')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /voir toute la boutique/i })).toHaveAttribute('href', '/shop') expect(screen.getByRole('link', { name: /règlements du salon/i })).toHaveAttribute('href', '/rules') }) it('renders testimonials from site content', async () => { renderHome() expect(await screen.findByText(/fantastique service/i)).toBeInTheDocument() expect(screen.getByText('Amara T.')).toBeInTheDocument() }) it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => { renderHome() const button = await screen.findByRole('button', { name: new RegExp(SERVICE_ROWS[0].name) }) await userEvent.click(button) expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument() }) it('renders the About Mira portrait and bio from site content', async () => { renderHome() expect(await screen.findByText('Bio de test.')).toBeInTheDocument() expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument() }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/pages/Home.test.tsx` Expected: FAIL — `Home.tsx` doesn't yet fetch `testimonials`/`site_content`, still shows the hardcoded hero/testimonials/first-3-in-stock-products. - [ ] **Step 3: Implement** Replace `src/pages/Home.tsx` in full: ```tsx import { useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { motion, useScroll, useTransform, useReducedMotion } from 'framer-motion' import Hero3DScene from '../components/Hero3DScene' import ScrollReveal from '../components/ScrollReveal' import ServiceCard from '../components/ServiceCard' import ProductCard from '../components/ProductCard' import { fetchServices, type Service } from '../data/services' import { fetchProducts, type Product } from '../data/products' import { fetchTestimonials, type Testimonial } from '../data/testimonials' import { fetchSiteContent, type SiteContent } from '../data/siteContent' import styles from './Home.module.css' function Home() { const navigate = useNavigate() const { scrollYProgress } = useScroll() const prefersReducedMotion = useReducedMotion() const portraitY = useTransform(scrollYProgress, [0, 1], prefersReducedMotion ? [0, 0] : [0, -40]) const [services, setServices] = useState([]) const [products, setProducts] = useState([]) const [testimonials, setTestimonials] = useState([]) const [content, setContent] = useState(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 (

{content?.heroHeadline ?? 'Vos cheveux, votre couronne'}

{content?.heroTagline ?? 'Coiffures protectrices par Mira'}

Réserver maintenant Voir les produits
{loading &&

Chargement…

} {error &&

{error}

}

À propos de Mira

{content?.aboutBio}

Styles Signature

{services.map((service) => ( navigate('/book', { state: { serviceId } })} /> ))}

Sélection boutique

{featuredProducts.map((product) => ( navigate('/shop')} /> ))}

Voir toute la boutique

Avis de nos clientes

{testimonials.map((t) => (
{t.author}

“{t.quote}”

{t.author}
))}

Avant de réserver

Merci de prendre un instant pour lire les règlements du salon avant de réserver votre rendez-vous.

) } export default Home ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/pages/Home.test.tsx` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/pages/Home.tsx src/pages/Home.test.tsx git commit -m "feat: wire Home page to live site content, testimonials, and featured products" ``` --- ### Task 8: Wire `Footer.tsx` to site content **Files:** - Modify: `src/components/Footer.tsx` - Modify: `src/components/Footer.test.tsx` **Interfaces:** - Consumes: `fetchSiteContent` (Task 3). - [ ] **Step 1: Update the failing test** Replace `src/components/Footer.test.tsx` in full: ```tsx import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { describe, it, expect, vi, beforeEach } from 'vitest' const singleMock = vi.fn() const eqMock = vi.fn(() => ({ single: singleMock })) const selectMock = vi.fn(() => ({ eq: eqMock })) const fromMock = vi.fn<(table: string) => { select: typeof selectMock }>(() => ({ select: selectMock })) vi.mock('../lib/supabase', () => ({ supabase: { from: (table: string) => fromMock(table) }, })) vi.mock('../lib/resolveImageUrl', () => ({ resolveImageUrl: (path: string) => path, })) const SITE_CONTENT_ROW = { id: 'default', hero_headline: 'Vos cheveux, votre couronne', hero_tagline: 'Coiffures protectrices par Mira', about_bio: '', about_portrait_image: '', contact_email: 'hello@braidsbymira.com', contact_hours: 'Mar–Sam, 9h à 18h', } import Footer from './Footer' describe('Footer', () => { beforeEach(() => { singleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null }) eqMock.mockClear() selectMock.mockClear() fromMock.mockClear() }) it('renders contact info and quick links from site content', async () => { render(