From 64a0ee5a8418de4d3cce75e4e8d58419862677a5 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Thu, 13 Aug 2026 08:24:13 +0200 Subject: [PATCH] docs: add Phase 2D implementation plan Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH --- ...phase2d-site-content-and-admin-redesign.md | 3549 +++++++++++++++++ 1 file changed, 3549 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-phase2d-site-content-and-admin-redesign.md diff --git a/docs/superpowers/plans/2026-08-13-phase2d-site-content-and-admin-redesign.md b/docs/superpowers/plans/2026-08-13-phase2d-site-content-and-admin-redesign.md new file mode 100644 index 0000000..8412fec --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-phase2d-site-content-and-admin-redesign.md @@ -0,0 +1,3549 @@ +# 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 8 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(