From 3c7bb59421720613d4221e9c900b3258327400b0 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Wed, 12 Aug 2026 10:38:20 +0200 Subject: [PATCH] docs: add Phase 2B admin panel implementation plan --- .../plans/2026-08-12-phase2b-admin-panel.md | 2712 +++++++++++++++++ 1 file changed, 2712 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md diff --git a/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md b/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md new file mode 100644 index 0000000..1022ab9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md @@ -0,0 +1,2712 @@ +# Phase 2B: Admin Panel 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:** Give Mira a working admin panel — login, CRUD for products/services/schedule (with image upload), and the ability to view and act on bookings/inquiries. + +**Architecture:** Lazy-loaded `/admin/*` route subtree in the existing React app, session-gated by a `ProtectedRoute` wrapper backed by `supabase.auth`. Every page talks to Postgres directly via `supabase-js` (no new data-fetching library), matching the codebase's existing pattern (see `src/hooks/useBooking.ts`). RLS from Phase 2A already gates every write to the fixed admin UUID — no separate authorization layer needed client-side. + +**Tech Stack:** React 19, TypeScript, react-router-dom v7, `@supabase/supabase-js`, Vitest + React Testing Library, framer-motion (for consistency with the public site's transition feel), Postgres/Supabase (migrations + pgTAP). + +## Global Constraints + +- Admin UUID (from Phase 2A, hardcoded in every admin-gated RLS policy): `21c90224-e0b2-412e-a431-8e7f097c1cef` +- No new frontend dependencies — use `supabase-js`, React state, and what's already in `package.json` +- All admin pages are default exports; CSS via CSS Modules (`ComponentName.module.css`), matching every existing page in `src/pages/` +- New migrations must be added as new files — never edit an already-applied migration (`supabase/migrations/2026080*` through `2026081*`) +- Design spec: `docs/superpowers/specs/2026-08-12-phase2b-admin-panel-design.md` + +--- + +### Task 1: Storage bucket policies for `salon-images` + +**Files:** +- Create: `supabase/migrations/_salon_images_storage_policies.sql` — generate `` by running `date -u +%Y%m%d%H%M%S` at the start of this task and using its output as the filename prefix (this avoids colliding with any other in-flight migration work) +- Modify: `supabase/tests/database/rls.test.sql` + +**Interfaces:** +- Consumes: nothing from other tasks in this plan +- Produces: a public-readable, admin-write-only `salon-images` Storage bucket, consumed by Task 6's `uploadSalonImage()` + +- [ ] **Step 1: Write the failing pgTAP tests** + +Open `supabase/tests/database/rls.test.sql`. Note its current `select plan(N)` count. You'll add 3 new test cases, so you'll bump it to `N + 3`. Insert the following block right before the final `select * from finish();` line: + +```sql +-- salon-images bucket: anyone can read, only admin can write +set local role anon; +select lives_ok( + $$ select * from storage.objects where bucket_id = 'salon-images' $$, + 'anon can read salon-images objects' +); + +select throws_ok( + $$ insert into storage.objects (bucket_id, name) values ('salon-images', 'test.jpg') $$, + '42501', + null, + 'anon cannot upload to salon-images' +); + +set local role authenticated; +set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef'; +select lives_ok( + $$ insert into storage.objects (bucket_id, name) values ('salon-images', 'admin-test.jpg') $$, + 'admin can upload to salon-images' +); +``` + +Update the `select plan(N)` line at the top of the file to the new total. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx supabase test db` +Expected: FAIL — the new tests error out because the `salon-images` bucket and its policies don't exist yet locally (`insert or update on table "objects" violates foreign key constraint` or similar, since `bucket_id` won't resolve, or a generic RLS-default-deny failure). This confirms the tests are actually exercising something real. + +- [ ] **Step 3: Write the migration** + +Create `supabase/migrations/_salon_images_storage_policies.sql`: + +```sql +-- The salon-images bucket was created manually in the live project during +-- Phase 2A's Task 8 (private, no policies — reserved for this task). Local +-- dev stacks don't have it at all (it wasn't created via migration), so +-- this creates it if missing and makes it public either way: product/ +-- service photos are marketing images meant to be publicly visible once the +-- public site (Sub-project C) reads live data. +insert into storage.buckets (id, name, public) +values ('salon-images', 'salon-images', true) +on conflict (id) do update set public = true; + +create policy "salon_images_public_read" on storage.objects + for select using (bucket_id = 'salon-images'); + +create policy "salon_images_admin_write" on storage.objects + for all + using (bucket_id = 'salon-images' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check (bucket_id = 'salon-images' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); +``` + +- [ ] **Step 4: Apply the migration and run the tests** + +Run: `npx supabase db reset` then `npx supabase test db` +Expected: all tests pass, including the 3 new ones. If `insert into storage.objects` fails with a missing-column error, check the actual columns Supabase's `storage.objects` table requires in this CLI version (`\d storage.objects` via `npx supabase db query --local "select column_name, is_nullable, column_default from information_schema.columns where table_schema='storage' and table_name='objects'"`) and adjust the test inserts to supply any other required columns — don't just add `not null` workarounds that weaken the test. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: unaffected, all pass (this task only touches SQL). + +- [ ] **Step 6: Commit** + +```bash +git add supabase/migrations supabase/tests/database/rls.test.sql +git commit -m "feat: add public-read, admin-write RLS policies for salon-images bucket" +``` + +--- + +### Task 2: Allow `bookings.status = 'completed'` + +**Files:** +- Create: `supabase/migrations/_add_completed_booking_status.sql` +- Modify: `supabase/tests/database/rls.test.sql` + +**Interfaces:** +- Consumes: nothing from other tasks in this plan +- Produces: `bookings.status` accepting `'completed'` in addition to `'confirmed'`/`'cancelled'`, consumed by Task 10's `AdminBookings` page + +- [ ] **Step 1: Write the failing pgTAP test** + +Add before `select * from finish();` in `supabase/tests/database/rls.test.sql` (still as the admin role from the end of the existing file — reuse the `set local role authenticated; set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';` context already established near the end of the file, or repeat it if you inserted Task 1's tests after that point): + +```sql +select lives_ok( + $$ update public.bookings set status = 'completed' where service_id = 'knotless' $$, + 'admin can mark a booking completed' +); + +select throws_ok( + $$ update public.bookings set status = 'bogus' where service_id = 'knotless' $$, + '23514', + null, + 'booking status is still constrained to known values' +); +``` + +Bump `select plan(N)` by 2. + +- [ ] **Step 2: Run the tests to verify the first one fails** + +Run: `npx supabase test db` +Expected: the "admin can mark a booking completed" test FAILs with a check constraint violation (SQLSTATE 23514), since `'completed'` isn't in the current `bookings_status_check` constraint. The second test should already pass (it's testing existing behavior). + +- [ ] **Step 3: Write the migration** + +Create `supabase/migrations/_add_completed_booking_status.sql`: + +```sql +-- Widen the bookings.status check constraint to allow 'completed', so the +-- admin panel can mark past appointments completed (not just cancelled). +alter table public.bookings drop constraint bookings_status_check; +alter table public.bookings add constraint bookings_status_check + check (status in ('confirmed', 'cancelled', 'completed')); +``` + +If `drop constraint bookings_status_check` fails with "constraint does not exist", find the real name first: `npx supabase db query --local "select conname from pg_constraint where conrelid = 'public.bookings'::regclass and contype = 'c'"`, then use that name instead. + +- [ ] **Step 4: Apply the migration and run the tests** + +Run: `npx supabase db reset` then `npx supabase test db` +Expected: all tests pass. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: unaffected, all pass. + +- [ ] **Step 6: Commit** + +```bash +git add supabase/migrations supabase/tests/database/rls.test.sql +git commit -m "feat: allow bookings.status to be marked completed" +``` + +--- + +### Task 3: Admin auth hook and route guard + +**Files:** +- Create: `src/hooks/useAdminAuth.ts` +- Create: `src/hooks/useAdminAuth.test.ts` +- Create: `src/components/ProtectedRoute.tsx` +- Create: `src/components/ProtectedRoute.test.tsx` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts` +- Produces: `useAdminAuth(): { session: Session | null; loading: boolean }` (default export from `src/hooks/useAdminAuth.ts`); `ProtectedRoute` component (default export from `src/components/ProtectedRoute.tsx`) — renders `` when there's a session, redirects to `/admin/login` otherwise, renders nothing while loading. Consumed by Task 12's `AdminApp`. + +- [ ] **Step 1: Write the failing hook test** + +Create `src/hooks/useAdminAuth.test.ts`: + +```ts +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Session } from '@supabase/supabase-js' + +const getSessionMock = vi.fn() +const onAuthStateChangeMock = vi.fn() + +vi.mock('../lib/supabase', () => ({ + supabase: { + auth: { + getSession: (...args: unknown[]) => getSessionMock(...args), + onAuthStateChange: (...args: unknown[]) => onAuthStateChangeMock(...args), + }, + }, +})) + +const FAKE_SESSION = { access_token: 'token', user: { id: 'admin' } } as unknown as Session + +describe('useAdminAuth', () => { + beforeEach(() => { + getSessionMock.mockReset() + onAuthStateChangeMock.mockReset() + onAuthStateChangeMock.mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } }) + }) + + it('starts loading, then resolves with the current session', async () => { + getSessionMock.mockResolvedValue({ data: { session: FAKE_SESSION } }) + const { default: useAdminAuth } = await import('./useAdminAuth') + const { result } = renderHook(() => useAdminAuth()) + + expect(result.current.loading).toBe(true) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.session).toBe(FAKE_SESSION) + }) + + it('resolves with a null session when signed out', async () => { + getSessionMock.mockResolvedValue({ data: { session: null } }) + const { default: useAdminAuth } = await import('./useAdminAuth') + const { result } = renderHook(() => useAdminAuth()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.session).toBeNull() + }) + + it('updates session when the auth state changes', async () => { + getSessionMock.mockResolvedValue({ data: { session: null } }) + let capturedCallback: ((event: string, session: Session | null) => void) | undefined + onAuthStateChangeMock.mockImplementation((callback) => { + capturedCallback = callback + return { data: { subscription: { unsubscribe: vi.fn() } } } + }) + const { default: useAdminAuth } = await import('./useAdminAuth') + const { result } = renderHook(() => useAdminAuth()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + capturedCallback?.('SIGNED_IN', FAKE_SESSION) + await waitFor(() => expect(result.current.session).toBe(FAKE_SESSION)) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/hooks/useAdminAuth.test.ts` +Expected: FAIL — `./useAdminAuth` does not exist yet. + +- [ ] **Step 3: Implement the hook** + +Create `src/hooks/useAdminAuth.ts`: + +```ts +import { useEffect, useState } from 'react' +import type { Session } from '@supabase/supabase-js' +import { supabase } from '../lib/supabase' + +export interface AdminAuthState { + session: Session | null + loading: boolean +} + +export default function useAdminAuth(): AdminAuthState { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => { + setSession(data.session) + setLoading(false) + }) + + const { data: listener } = supabase.auth.onAuthStateChange((_event, newSession) => { + setSession(newSession) + }) + + return () => listener.subscription.unsubscribe() + }, []) + + return { session, loading } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/hooks/useAdminAuth.test.ts` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Write the failing ProtectedRoute test** + +Create `src/components/ProtectedRoute.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { describe, it, expect, vi } from 'vitest' + +const useAdminAuthMock = vi.fn() +vi.mock('../hooks/useAdminAuth', () => ({ default: () => useAdminAuthMock() })) + +import ProtectedRoute from './ProtectedRoute' + +function renderWithRoute() { + return render( + + + Login page

} /> + }> + Products page

} /> +
+
+
, + ) +} + +describe('ProtectedRoute', () => { + it('renders nothing while loading', () => { + useAdminAuthMock.mockReturnValue({ session: null, loading: true }) + renderWithRoute() + expect(screen.queryByText('Products page')).not.toBeInTheDocument() + expect(screen.queryByText('Login page')).not.toBeInTheDocument() + }) + + it('redirects to /admin/login when there is no session', () => { + useAdminAuthMock.mockReturnValue({ session: null, loading: false }) + renderWithRoute() + expect(screen.getByText('Login page')).toBeInTheDocument() + }) + + it('renders the protected content when there is a session', () => { + useAdminAuthMock.mockReturnValue({ session: { user: { id: 'admin' } }, loading: false }) + renderWithRoute() + expect(screen.getByText('Products page')).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 6: Run the test to verify it fails** + +Run: `npx vitest run src/components/ProtectedRoute.test.tsx` +Expected: FAIL — `./ProtectedRoute` does not exist yet. + +- [ ] **Step 7: Implement ProtectedRoute** + +Create `src/components/ProtectedRoute.tsx`: + +```tsx +import { Navigate, Outlet } from 'react-router-dom' +import useAdminAuth from '../hooks/useAdminAuth' + +function ProtectedRoute() { + const { session, loading } = useAdminAuth() + + if (loading) return null + if (!session) return + return +} + +export default ProtectedRoute +``` + +- [ ] **Step 8: Run the test to verify it passes** + +Run: `npx vitest run src/components/ProtectedRoute.test.tsx` +Expected: PASS, 3 tests. + +- [ ] **Step 9: Commit** + +```bash +git add src/hooks/useAdminAuth.ts src/hooks/useAdminAuth.test.ts src/components/ProtectedRoute.tsx src/components/ProtectedRoute.test.tsx +git commit -m "feat: add admin auth hook and protected route guard" +``` + +--- + +### Task 4: Admin login page + +**Files:** +- Create: `src/pages/admin/AdminLogin.tsx` +- Create: `src/pages/admin/AdminLogin.module.css` +- Create: `src/pages/admin/AdminLogin.test.tsx` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts` +- Produces: `AdminLogin` component (default export). Consumed by Task 12's `AdminApp`. + +- [ ] **Step 1: Write the failing test** + +Create `src/pages/admin/AdminLogin.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const signInMock = vi.fn() +const navigateMock = vi.fn() + +vi.mock('../../lib/supabase', () => ({ + supabase: { auth: { signInWithPassword: (...args: unknown[]) => signInMock(...args) } }, +})) + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { ...actual, useNavigate: () => navigateMock } +}) + +import AdminLogin from './AdminLogin' + +describe('AdminLogin', () => { + beforeEach(() => { + signInMock.mockReset() + navigateMock.mockReset() + }) + + it('signs in and navigates to the admin dashboard on success', async () => { + signInMock.mockResolvedValue({ error: null }) + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'mira@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'correct-password') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + expect(signInMock).toHaveBeenCalledWith({ email: 'mira@example.com', password: 'correct-password' }) + expect(navigateMock).toHaveBeenCalledWith('/admin/products') + }) + + it('shows an error message on failed sign-in and does not navigate', async () => { + signInMock.mockResolvedValue({ error: { message: 'Invalid login credentials' } }) + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'mira@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'wrong-password') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + expect(await screen.findByText(/incorrect email or password/i)).toBeInTheDocument() + expect(navigateMock).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/pages/admin/AdminLogin.test.tsx` +Expected: FAIL — `./AdminLogin` does not exist yet. + +- [ ] **Step 3: Implement AdminLogin** + +Create `src/pages/admin/AdminLogin.module.css`: + +```css +.page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-cream); + padding: var(--space-lg); +} + +.form { + display: flex; + flex-direction: column; + gap: var(--space-md); + width: 100%; + max-width: 320px; +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.field label { + color: var(--color-charcoal); +} + +.field input { + padding: var(--space-sm); + border: 1px solid var(--color-gold); + background: white; +} + +.error { + color: #a33; +} + +.form button { + background: var(--color-charcoal); + color: var(--color-cream); + border: none; + padding: var(--space-sm) var(--space-md); + cursor: pointer; +} + +.form button:disabled { + opacity: 0.6; + cursor: default; +} +``` + +Create `src/pages/admin/AdminLogin.tsx`: + +```tsx +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { supabase } from '../../lib/supabase' +import styles from './AdminLogin.module.css' + +function AdminLogin() { + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setSubmitting(true) + setError('') + const { error: signInError } = await supabase.auth.signInWithPassword({ email, password }) + setSubmitting(false) + if (signInError) { + setError('Incorrect email or password.') + return + } + navigate('/admin/products') + } + + return ( +
+
+

Admin Login

+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+
+ ) +} + +export default AdminLogin +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/pages/admin/AdminLogin.test.tsx` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/admin/AdminLogin.tsx src/pages/admin/AdminLogin.module.css src/pages/admin/AdminLogin.test.tsx +git commit -m "feat: add admin login page" +``` + +--- + +### Task 5: Admin layout (sidebar + sign out) + +**Files:** +- Create: `src/pages/admin/AdminLayout.tsx` +- Create: `src/pages/admin/AdminLayout.module.css` +- Create: `src/pages/admin/AdminLayout.test.tsx` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts` +- Produces: `AdminLayout` component (default export) rendering a sidebar nav and ``. Consumed by Task 12's `AdminApp`. + +- [ ] **Step 1: Write the failing test** + +Create `src/pages/admin/AdminLayout.test.tsx`: + +```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 signOutMock = vi.fn() +const navigateMock = vi.fn() + +vi.mock('../../lib/supabase', () => ({ + supabase: { auth: { signOut: (...args: unknown[]) => signOutMock(...args) } }, +})) + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { ...actual, useNavigate: () => navigateMock } +}) + +import AdminLayout from './AdminLayout' + +function renderLayout() { + return render( + + + }> + Products content

} /> +
+
+
, + ) +} + +describe('AdminLayout', () => { + beforeEach(() => { + signOutMock.mockReset().mockResolvedValue({ error: null }) + navigateMock.mockReset() + }) + + it('renders nav links for every admin section and the outlet content', () => { + renderLayout() + expect(screen.getByRole('link', { name: /products/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /services/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /schedule/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /bookings/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /inquiries/i })).toBeInTheDocument() + expect(screen.getByText('Products content')).toBeInTheDocument() + }) + + it('signs out and navigates to the login page', async () => { + renderLayout() + await userEvent.click(screen.getByRole('button', { name: /sign out/i })) + expect(signOutMock).toHaveBeenCalled() + expect(navigateMock).toHaveBeenCalledWith('/admin/login') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/pages/admin/AdminLayout.test.tsx` +Expected: FAIL — `./AdminLayout` does not exist yet. + +- [ ] **Step 3: Implement AdminLayout** + +Create `src/pages/admin/AdminLayout.module.css`: + +```css +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 220px; + flex-shrink: 0; + background: var(--color-charcoal); + color: var(--color-cream); + padding: var(--space-lg) var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.brand { + font-family: var(--font-script); + font-size: 1.5rem; + color: var(--color-gold); +} + +.sidebar nav { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.navLink { + color: var(--color-cream); + text-decoration: none; + padding: var(--space-sm); +} + +.navLinkActive { + color: var(--color-gold); + font-weight: bold; +} + +.signOut { + margin-top: auto; + background: none; + border: 1px solid var(--color-gold); + color: var(--color-cream); + padding: var(--space-sm); + cursor: pointer; +} + +.content { + flex: 1; + padding: var(--space-xl) var(--space-lg); + background: var(--color-cream-alt); +} +``` + +Create `src/pages/admin/AdminLayout.tsx`: + +```tsx +import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { supabase } from '../../lib/supabase' +import styles from './AdminLayout.module.css' + +const NAV_ITEMS = [ + { to: '/admin/products', label: 'Products' }, + { to: '/admin/services', label: 'Services' }, + { to: '/admin/schedule', label: 'Schedule' }, + { to: '/admin/bookings', label: 'Bookings' }, + { to: '/admin/inquiries', label: 'Inquiries' }, +] + +function AdminLayout() { + const navigate = useNavigate() + + async function handleSignOut() { + await supabase.auth.signOut() + navigate('/admin/login') + } + + return ( +
+ +
+ +
+
+ ) +} + +export default AdminLayout +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/pages/admin/AdminLayout.test.tsx` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/admin/AdminLayout.tsx src/pages/admin/AdminLayout.module.css src/pages/admin/AdminLayout.test.tsx +git commit -m "feat: add admin layout with sidebar nav and sign out" +``` + +--- + +### Task 6: Image upload helper + +**Files:** +- Create: `src/lib/storage.ts` +- Create: `src/lib/storage.test.ts` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts`; the `salon-images` bucket/policies from Task 1 +- Produces: `uploadSalonImage(file: File, path: string): Promise` — uploads a file and resolves to its public URL, or rejects with the Supabase error. Consumed by Task 7 (`AdminProducts`) and Task 8 (`AdminServices`). + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/storage.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const uploadMock = vi.fn() +const getPublicUrlMock = vi.fn() +const fromMock = vi.fn(() => ({ upload: uploadMock, getPublicUrl: getPublicUrlMock })) + +vi.mock('./supabase', () => ({ + supabase: { storage: { from: (...args: unknown[]) => fromMock(...args) } }, +})) + +import { uploadSalonImage } from './storage' + +describe('uploadSalonImage', () => { + beforeEach(() => { + uploadMock.mockReset() + getPublicUrlMock.mockReset() + fromMock.mockClear() + }) + + it('uploads to the salon-images bucket and returns the public URL', async () => { + uploadMock.mockResolvedValue({ error: null }) + getPublicUrlMock.mockReturnValue({ data: { publicUrl: 'https://example.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg' } }) + + const file = new File(['fake'], 'edge-control.jpg', { type: 'image/jpeg' }) + const url = await uploadSalonImage(file, 'products/edge-control.jpg') + + expect(fromMock).toHaveBeenCalledWith('salon-images') + expect(uploadMock).toHaveBeenCalledWith('products/edge-control.jpg', file, { upsert: true }) + expect(url).toBe('https://example.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg') + }) + + it('throws when the upload fails', async () => { + uploadMock.mockResolvedValue({ error: { message: 'Storage error' } }) + const file = new File(['fake'], 'bad.jpg', { type: 'image/jpeg' }) + + await expect(uploadSalonImage(file, 'products/bad.jpg')).rejects.toEqual({ message: 'Storage error' }) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/storage.test.ts` +Expected: FAIL — `./storage` does not exist yet. + +- [ ] **Step 3: Implement the helper** + +Create `src/lib/storage.ts`: + +```ts +import { supabase } from './supabase' + +export async function uploadSalonImage(file: File, path: string): Promise { + const { error } = await supabase.storage.from('salon-images').upload(path, file, { upsert: true }) + if (error) throw error + const { data } = supabase.storage.from('salon-images').getPublicUrl(path) + return data.publicUrl +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/storage.test.ts` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/storage.ts src/lib/storage.test.ts +git commit -m "feat: add salon-images upload helper" +``` + +--- + +### Task 7: Admin products page + +**Files:** +- Create: `src/pages/admin/AdminProducts.tsx` +- Create: `src/pages/admin/AdminProducts.module.css` +- Create: `src/pages/admin/AdminProducts.test.tsx` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts`; `uploadSalonImage` from `src/lib/storage.ts` (Task 6) +- Produces: `AdminProducts` component (default export). Consumed by Task 12's `AdminApp`. +- Data shape (matches `public.products` columns exactly — snake_case, as returned by `supabase-js`): + +```ts +interface AdminProduct { + id: string + name: string + category: 'hair-care' | 'extensions' | 'tools' | 'accessories' + price: number + image: string + description: string + in_stock: boolean +} +``` + +- [ ] **Step 1: Write the failing test** + +Create `src/pages/admin/AdminProducts.test.tsx`: + +```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 selectMock = vi.fn() +const orderMock = vi.fn() +const upsertMock = vi.fn() +const deleteMock = vi.fn() +const eqMock = vi.fn() +const fromMock = vi.fn() +const uploadSalonImageMock = vi.fn() + +vi.mock('../../lib/supabase', () => ({ + supabase: { from: (...args: unknown[]) => fromMock(...args) }, +})) +vi.mock('../../lib/storage', () => ({ + uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args), +})) + +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, +} + +import AdminProducts from './AdminProducts' + +describe('AdminProducts', () => { + beforeEach(() => { + selectMock.mockReset() + orderMock.mockReset() + upsertMock.mockReset() + deleteMock.mockReset() + eqMock.mockReset() + fromMock.mockReset() + uploadSalonImageMock.mockReset() + + orderMock.mockResolvedValue({ data: [EXISTING_PRODUCT], error: null }) + selectMock.mockReturnValue({ order: orderMock }) + upsertMock.mockResolvedValue({ error: null }) + eqMock.mockResolvedValue({ error: null }) + deleteMock.mockReturnValue({ eq: eqMock }) + fromMock.mockReturnValue({ select: selectMock, upsert: upsertMock, delete: deleteMock }) + }) + + it('lists existing products', async () => { + render() + expect(await screen.findByText('Silk Edge Control')).toBeInTheDocument() + expect(fromMock).toHaveBeenCalledWith('products') + }) + + it('creates a new product from the form', async () => { + render() + await screen.findByText('Silk Edge Control') + + await userEvent.type(screen.getByLabelText(/^id$/i), 'braid-oil') + await userEvent.type(screen.getByLabelText(/^name$/i), 'Nourishing Braid Oil') + await userEvent.type(screen.getByLabelText(/^price$/i), '16') + await userEvent.type(screen.getByLabelText(/^description$/i), 'Lightweight scalp oil.') + await userEvent.click(screen.getByRole('button', { name: /save product/i })) + + await waitFor(() => + expect(upsertMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'braid-oil', name: 'Nourishing Braid Oil', price: 16 }), + ), + ) + }) + + it('deletes a product with confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + render() + await screen.findByText('Silk Edge Control') + + await userEvent.click(screen.getByRole('button', { name: /delete/i })) + + expect(deleteMock).toHaveBeenCalled() + expect(eqMock).toHaveBeenCalledWith('id', 'edge-control') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/pages/admin/AdminProducts.test.tsx` +Expected: FAIL — `./AdminProducts` does not exist yet. + +- [ ] **Step 3: Implement AdminProducts** + +Create `src/pages/admin/AdminProducts.module.css`: + +```css +.page { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.table { + width: 100%; + border-collapse: collapse; + background: white; +} + +.table th, +.table td { + text-align: left; + padding: var(--space-sm); + border-bottom: 1px solid var(--color-cream-alt); +} + +.form { + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-width: 420px; + background: white; + padding: var(--space-md); +} + +.form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: var(--color-charcoal); +} + +.form input, +.form select, +.form textarea { + padding: var(--space-sm); + border: 1px solid var(--color-gold); +} + +.error { + color: #a33; +} +``` + +Create `src/pages/admin/AdminProducts.tsx`: + +```tsx +import { useEffect, useState, type FormEvent } from 'react' +import { supabase } from '../../lib/supabase' +import { uploadSalonImage } from '../../lib/storage' +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 +} + +const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories'] + +function emptyForm(): AdminProduct { + return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true } +} + +function AdminProducts() { + const [products, setProducts] = useState([]) + const [form, setForm] = useState(emptyForm()) + const [file, setFile] = useState(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(() => { + 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('Image upload failed.') + 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('Delete this product?')) return + const { error: deleteError } = await supabase.from('products').delete().eq('id', id) + if (deleteError) { + setError(deleteError.message) + return + } + await loadProducts() + } + + return ( +
+

Products

+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
NameCategoryPriceIn stock
{product.name}{product.category}${product.price}{product.in_stock ? 'Yes' : 'No'} + + +
+ +
+

{form.id && products.some((p) => p.id === form.id) ? 'Edit product' : 'New product'}

+ + + + +