# 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) => ( ))}
Name Category Price In 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'}