# 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 (
)
}
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 AdminBookings
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/pages/admin/AdminBookings.test.tsx`
Expected: PASS, 3 tests.
- [ ] **Step 5: Commit**
```bash
git add src/pages/admin/AdminBookings.tsx src/pages/admin/AdminBookings.module.css src/pages/admin/AdminBookings.test.tsx
git commit -m "feat: add admin bookings page with status actions"
```
---
### Task 11: Admin inquiries page
**Files:**
- Create: `src/pages/admin/AdminInquiries.tsx`
- Create: `src/pages/admin/AdminInquiries.module.css`
- Create: `src/pages/admin/AdminInquiries.test.tsx`
**Interfaces:**
- Consumes: `supabase` from `src/lib/supabase.ts`
- Produces: `AdminInquiries` component (default export). Consumed by Task 12's `AdminApp`.
- Data shape (PostgREST embed of the related product name via the `product_id` foreign key; `status` values are the existing schema's `'new'`/`'read'`, not `'responded'`):
```ts
interface AdminInquiry {
id: string
contact_name: string
contact_email: string
contact_notes: string
status: 'new' | 'read'
products: { name: string } | null
}
```
- [ ] **Step 1: Write the failing test**
Create `src/pages/admin/AdminInquiries.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 orderMock = vi.fn()
const selectMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn()
const fromMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) },
}))
const INQUIRY = {
id: 'inquiry-1',
contact_name: 'Nadia',
contact_email: 'nadia@example.com',
contact_notes: 'Is this in stock?',
status: 'new',
products: { name: 'Silk Edge Control' },
}
import AdminInquiries from './AdminInquiries'
describe('AdminInquiries', () => {
beforeEach(() => {
orderMock.mockReset().mockResolvedValue({ data: [INQUIRY], error: null })
selectMock.mockReset().mockReturnValue({ order: orderMock })
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
updateMock.mockReset().mockReturnValue({ eq: eqUpdateMock })
fromMock.mockReset().mockReturnValue({ select: selectMock, update: updateMock })
})
it('lists inquiries with the related product name', async () => {
render()
expect(await screen.findByText('Nadia')).toBeInTheDocument()
expect(screen.getByText('Silk Edge Control')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('inquiries')
})
it('marks an inquiry as read', async () => {
render()
await screen.findByText('Nadia')
await userEvent.click(screen.getByRole('button', { name: /mark as read/i }))
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'read' }))
expect(eqUpdateMock).toHaveBeenCalledWith('id', 'inquiry-1')
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/pages/admin/AdminInquiries.test.tsx`
Expected: FAIL — `./AdminInquiries` does not exist yet.
- [ ] **Step 3: Implement AdminInquiries**
Create `src/pages/admin/AdminInquiries.module.css` (same shape as `AdminBookings.module.css` from Task 10):
```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);
vertical-align: top;
}
.error {
color: #a33;
}
```
Create `src/pages/admin/AdminInquiries.tsx`:
```tsx
import { useEffect, useState } from 'react'
import { supabase } from '../../lib/supabase'
import styles from './AdminInquiries.module.css'
interface AdminInquiry {
id: string
contact_name: string
contact_email: string
contact_notes: string
status: 'new' | 'read'
products: { name: string } | null
}
function AdminInquiries() {
const [inquiries, setInquiries] = useState([])
const [error, setError] = useState('')
async function loadInquiries() {
const { data, error: fetchError } = await supabase
.from('inquiries')
.select('id, contact_name, contact_email, contact_notes, status, products(name)')
.order('created_at', { ascending: false })
if (fetchError) {
setError(fetchError.message)
return
}
setInquiries((data ?? []) as unknown as AdminInquiry[])
}
useEffect(() => {
loadInquiries()
}, [])
async function markRead(id: string) {
const { error: updateError } = await supabase.from('inquiries').update({ status: 'read' }).eq('id', id)
if (updateError) {
setError(updateError.message)
return
}
await loadInquiries()
}
return (
Inquiries
{error &&
{error}
}
Product
Contact
Notes
Status
{inquiries.map((inquiry) => (
{inquiry.products?.name}
{inquiry.contact_name}
{inquiry.contact_email}
{inquiry.contact_notes}
{inquiry.status}
{inquiry.status === 'new' && (
)}
))}
)
}
export default AdminInquiries
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/pages/admin/AdminInquiries.test.tsx`
Expected: PASS, 2 tests.
- [ ] **Step 5: Commit**
```bash
git add src/pages/admin/AdminInquiries.tsx src/pages/admin/AdminInquiries.module.css src/pages/admin/AdminInquiries.test.tsx
git commit -m "feat: add admin inquiries page with mark-as-read action"
```
---
### Task 12: Wire up `/admin/*` routing
**Files:**
- Create: `src/pages/admin/AdminApp.tsx`
- Create: `src/pages/admin/AdminApp.test.tsx`
- Modify: `src/App.tsx`
**Interfaces:**
- Consumes: `ProtectedRoute` (Task 3), `AdminLogin` (Task 4), `AdminLayout` (Task 5), `AdminProducts` (Task 7), `AdminServices` (Task 8), `AdminSchedule` (Task 9), `AdminBookings` (Task 10), `AdminInquiries` (Task 11)
- Produces: `AdminApp` component (default export) — the full `/admin/*` route subtree, mounted once in `src/App.tsx`
- [ ] **Step 1: Write the failing test**
Create `src/pages/admin/AdminApp.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi } from 'vitest'
vi.mock('../../hooks/useAdminAuth', () => ({
default: () => ({ session: { user: { id: 'admin' } }, loading: false }),
}))
vi.mock('../../lib/supabase', () => ({
supabase: {
auth: { signOut: vi.fn() },
from: () => ({
select: () => ({ order: () => Promise.resolve({ data: [], error: null }) }),
}),
},
}))
import AdminApp from './AdminApp'
describe('AdminApp routing', () => {
it('renders the login page at /admin/login', () => {
render(
,
)
expect(screen.getByRole('heading', { name: /admin login/i })).toBeInTheDocument()
})
it('renders the products page (with layout) at /admin/products when authenticated', async () => {
render(
,
)
expect(await screen.findByRole('heading', { name: /^products$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /bookings/i })).toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run src/pages/admin/AdminApp.test.tsx`
Expected: FAIL — `./AdminApp` does not exist yet.
- [ ] **Step 3: Implement AdminApp**
Create `src/pages/admin/AdminApp.tsx`:
```tsx
import { Routes, Route } 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'
function AdminApp() {
return (
} />
}>
}>
} />
} />
} />
} />
} />
)
}
export default AdminApp
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run src/pages/admin/AdminApp.test.tsx`
Expected: PASS, 2 tests.
- [ ] **Step 5: Mount AdminApp in the main router**
Read `src/App.tsx` first — it currently has one `` inside `AppRoutes()`, with public pages nested under a `}>` wrapper (so they get the public `Nav`/`Footer`). The admin route must NOT be nested under that `Layout` wrapper (the admin panel has its own `AdminLayout` with a sidebar, not the public nav/footer), so add it as a sibling route at the top level of ``, lazy-loaded the same way `Home` already is.
Modify `src/App.tsx`:
```tsx
import { lazy, Suspense } from 'react'
import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Shop from './pages/Shop'
import Book from './pages/Book'
import Rules from './pages/Rules'
const Home = lazy(() => import('./pages/Home'))
const AdminApp = lazy(() => import('./pages/admin/AdminApp'))
interface AppProps {
initialEntries?: string[]
}
function AppRoutes() {
return (
}>
}
/>
} />
} />
} />
}
/>
)
}
function App({ initialEntries }: AppProps) {
if (initialEntries) {
return (
)
}
return (
)
}
export default App
```
- [ ] **Step 6: Run the full test suite**
Run: `npx vitest run`
Expected: PASS, all tests including every admin page's tests plus the pre-existing public-site tests.
- [ ] **Step 7: Run build and lint**
Run: `npm run build && npm run lint`
Expected: both succeed with no errors (the build's existing >500kB chunk-size warning is pre-existing and expected).
- [ ] **Step 8: Manual verification against the local Supabase stack**
Run `npx supabase start` (if not already running) and `npm run dev`, then in a browser:
1. Visit `/admin/products` while signed out — confirm it redirects to `/admin/login`.
2. Sign in with the local admin user (create one first if the local stack doesn't have one: `SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_SERVICE_ROLE_KEY= ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=test-password-123 npx tsx scripts/create-admin.ts`, then also set that same UUID as the admin gate in a throwaway local-only way — actually simplest: since RLS policies are hardcoded to the *production* admin UUID `21c90224-e0b2-412e-a431-8e7f097c1cef`, sign in via the Supabase Studio UI at `http://127.0.0.1:54323` and directly set the local test user's UID to that exact value, or accept that local manual testing exercises the UI/auth flow correctly while write operations will get `42501` until the UID matches — note this discrepancy in your task report rather than silently working around it).
3. Confirm the sidebar nav links work and each page loads without a console error.
4. Create a product with an image upload, confirm it appears in the list and the image renders.
5. Cancel a booking (seed one first via the seed script or the public `/book` flow) and confirm it disappears from the "confirmed" actions.
Report what you observed — this step is about catching integration issues automated tests can't (routing edge cases, real Storage upload behavior, RLS UID mismatches), not about achieving a specific pass/fail.
- [ ] **Step 9: Commit**
```bash
git add src/pages/admin/AdminApp.tsx src/pages/admin/AdminApp.test.tsx src/App.tsx
git commit -m "feat: wire up /admin/* routing"
```
---
## Self-Review Notes
- **Spec coverage:** login (Task 4), products/services CRUD with image upload (Tasks 6-8), schedule template + exceptions (Task 9), bookings/inquiries view + act (Tasks 10-11), route protection (Task 3), routing/lazy-loading (Task 12), Storage bucket policies (Task 1). All spec sections covered.
- **Type consistency:** `AdminProduct`/`AdminService`/`ScheduleTemplate`/`ScheduleException`/`AdminBooking`/`AdminInquiry` are each defined once, in the task that first needs them, matching real Postgres column names (snake_case) exactly rather than the frontend's existing camelCase mock-data types (`Product`/`Service` in `src/data/`) — those are separate, intentionally not reused, since this admin panel talks to the live schema, not the mock data.
- **Deviations from the design spec, explicit:** the spec's wording ("mark an inquiry as responded") used a status value that doesn't exist in the schema (`'new'`/`'read'` are the only two) — Task 11 uses `'read'` instead of inventing a new status, avoiding an unnecessary migration. The spec's wording for schedule exceptions ("closed-or-custom-hours") doesn't match the actual `schedule_exceptions` schema, which only has `closed: boolean` and a free-text `note` — Task 9 implements the real schema (closed toggle + note), not structured custom hours.
- **No placeholders:** every step has literal file contents; the two new migration timestamps are the only execution-time-generated values, generated via a documented command rather than guessed, to avoid collision with any other in-flight migration work on this repository.