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