82 KiB
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 inpackage.json - All admin pages are default exports; CSS via CSS Modules (
ComponentName.module.css), matching every existing page insrc/pages/ - New migrations must be added as new files — never edit an already-applied migration (
supabase/migrations/2026080*through2026081*) - 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/<timestamp>_salon_images_storage_policies.sql— generate<timestamp>by runningdate -u +%Y%m%d%H%M%Sat 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-imagesStorage bucket, consumed by Task 6'suploadSalonImage() -
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:
-- 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/<timestamp>_salon_images_storage_policies.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
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/<timestamp>_add_completed_booking_status.sql - Modify:
supabase/tests/database/rls.test.sql
Interfaces:
-
Consumes: nothing from other tasks in this plan
-
Produces:
bookings.statusaccepting'completed'in addition to'confirmed'/'cancelled', consumed by Task 10'sAdminBookingspage -
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):
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/<timestamp>_add_completed_booking_status.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
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:
supabasefromsrc/lib/supabase.ts -
Produces:
useAdminAuth(): { session: Session | null; loading: boolean }(default export fromsrc/hooks/useAdminAuth.ts);ProtectedRoutecomponent (default export fromsrc/components/ProtectedRoute.tsx) — renders<Outlet />when there's a session, redirects to/admin/loginotherwise, renders nothing while loading. Consumed by Task 12'sAdminApp. -
Step 1: Write the failing hook test
Create src/hooks/useAdminAuth.test.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:
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<Session | null>(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:
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(
<MemoryRouter initialEntries={['/admin/products']}>
<Routes>
<Route path="/admin/login" element={<p>Login page</p>} />
<Route element={<ProtectedRoute />}>
<Route path="/admin/products" element={<p>Products page</p>} />
</Route>
</Routes>
</MemoryRouter>,
)
}
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:
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 <Navigate to="/admin/login" replace />
return <Outlet />
}
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
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:
supabasefromsrc/lib/supabase.ts -
Produces:
AdminLogincomponent (default export). Consumed by Task 12'sAdminApp. -
Step 1: Write the failing test
Create src/pages/admin/AdminLogin.test.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(<MemoryRouter><AdminLogin /></MemoryRouter>)
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(<MemoryRouter><AdminLogin /></MemoryRouter>)
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:
.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:
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 (
<div className={styles.page}>
<form onSubmit={handleSubmit} className={styles.form}>
<h1>Admin Login</h1>
<div className={styles.field}>
<label htmlFor="admin-email">Email</label>
<input
id="admin-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="admin-password">Password</label>
<input
id="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}
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
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:
supabasefromsrc/lib/supabase.ts -
Produces:
AdminLayoutcomponent (default export) rendering a sidebar nav and<Outlet />. Consumed by Task 12'sAdminApp. -
Step 1: Write the failing test
Create src/pages/admin/AdminLayout.test.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(
<MemoryRouter initialEntries={['/admin/products']}>
<Routes>
<Route element={<AdminLayout />}>
<Route path="/admin/products" element={<p>Products content</p>} />
</Route>
</Routes>
</MemoryRouter>,
)
}
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:
.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:
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 (
<div className={styles.layout}>
<aside className={styles.sidebar}>
<p className={styles.brand}>Braids by Mira</p>
<nav>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink)}
>
{item.label}
</NavLink>
))}
</nav>
<button type="button" className={styles.signOut} onClick={handleSignOut}>
Sign out
</button>
</aside>
<main className={styles.content}>
<Outlet />
</main>
</div>
)
}
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
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:
supabasefromsrc/lib/supabase.ts; thesalon-imagesbucket/policies from Task 1 -
Produces:
uploadSalonImage(file: File, path: string): Promise<string>— 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:
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:
import { supabase } from './supabase'
export async function uploadSalonImage(file: File, path: string): Promise<string> {
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
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:
supabasefromsrc/lib/supabase.ts;uploadSalonImagefromsrc/lib/storage.ts(Task 6) - Produces:
AdminProductscomponent (default export). Consumed by Task 12'sAdminApp. - Data shape (matches
public.productscolumns exactly — snake_case, as returned bysupabase-js):
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:
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(<AdminProducts />)
expect(await screen.findByText('Silk Edge Control')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('products')
})
it('creates a new product from the form', async () => {
render(<AdminProducts />)
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(<AdminProducts />)
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:
.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:
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<AdminProduct[]>([])
const [form, setForm] = useState<AdminProduct>(emptyForm())
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadProducts() {
const { data, error: fetchError } = await supabase.from('products').select('*').order('name')
if (fetchError) {
setError(fetchError.message)
return
}
setProducts((data ?? []) as AdminProduct[])
}
useEffect(() => {
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 (
<div className={styles.page}>
<h1>Products</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>In stock</th>
<th></th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id}>
<td>{product.name}</td>
<td>{product.category}</td>
<td>${product.price}</td>
<td>{product.in_stock ? 'Yes' : 'No'}</td>
<td>
<button type="button" onClick={() => setForm(product)}>Edit</button>
<button type="button" onClick={() => handleDelete(product.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{form.id && products.some((p) => p.id === form.id) ? 'Edit product' : 'New product'}</h2>
<label htmlFor="product-id">
Id
<input
id="product-id"
value={form.id}
onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))}
required
/>
</label>
<label htmlFor="product-name">
Name
<input
id="product-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<label htmlFor="product-category">
Category
<select
id="product-category"
value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value as AdminProduct['category'] }))}
>
{CATEGORIES.map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
</label>
<label htmlFor="product-price">
Price
<input
id="product-price"
type="number"
step="0.01"
value={form.price}
onChange={(e) => setForm((f) => ({ ...f, price: Number(e.target.value) }))}
required
/>
</label>
<label htmlFor="product-description">
Description
<textarea
id="product-description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
/>
</label>
<label htmlFor="product-image">
Image
<input
id="product-image"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label htmlFor="product-in-stock">
<input
id="product-in-stock"
type="checkbox"
checked={form.in_stock}
onChange={(e) => setForm((f) => ({ ...f, in_stock: e.target.checked }))}
/>
In stock
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={saving}>Save product</button>
</form>
</div>
)
}
export default AdminProducts
- Step 4: Run the test to verify it passes
Run: npx vitest run src/pages/admin/AdminProducts.test.tsx
Expected: PASS, 3 tests.
- Step 5: Commit
git add src/pages/admin/AdminProducts.tsx src/pages/admin/AdminProducts.module.css src/pages/admin/AdminProducts.test.tsx
git commit -m "feat: add admin products CRUD page"
Task 8: Admin services page
Files:
- Create:
src/pages/admin/AdminServices.tsx - Create:
src/pages/admin/AdminServices.module.css - Create:
src/pages/admin/AdminServices.test.tsx
Interfaces:
- Consumes:
supabasefromsrc/lib/supabase.ts;uploadSalonImagefromsrc/lib/storage.ts(Task 6) - Produces:
AdminServicescomponent (default export). Consumed by Task 12'sAdminApp. - Data shape (matches
public.servicescolumns exactly):
interface AdminService {
id: string
name: string
duration_minutes: number
price_from: number
description: string
image: string
}
This task mirrors Task 7's structure exactly, applied to services instead of products (no category/in_stock fields; duration_minutes and price_from instead of price).
- Step 1: Write the failing test
Create src/pages/admin/AdminServices.test.tsx:
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const selectMock = vi.fn()
const orderMock = vi.fn()
const upsertMock = vi.fn()
const deleteMock = vi.fn()
const eqMock = vi.fn()
const fromMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) },
}))
vi.mock('../../lib/storage', () => ({
uploadSalonImage: vi.fn(),
}))
const EXISTING_SERVICE = {
id: 'knotless',
name: 'Knotless Braids',
duration_minutes: 240,
price_from: 150,
description: 'Tension-free braids.',
image: 'styles/knotless.jpg',
}
import AdminServices from './AdminServices'
describe('AdminServices', () => {
beforeEach(() => {
selectMock.mockReset()
orderMock.mockReset()
upsertMock.mockReset()
deleteMock.mockReset()
eqMock.mockReset()
fromMock.mockReset()
orderMock.mockResolvedValue({ data: [EXISTING_SERVICE], 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 services', async () => {
render(<AdminServices />)
expect(await screen.findByText('Knotless Braids')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('services')
})
it('creates a new service from the form', async () => {
render(<AdminServices />)
await screen.findByText('Knotless Braids')
await userEvent.type(screen.getByLabelText(/^id$/i), 'cornrows')
await userEvent.type(screen.getByLabelText(/^name$/i), 'Cornrows')
await userEvent.type(screen.getByLabelText(/duration/i), '120')
await userEvent.type(screen.getByLabelText(/price from/i), '90')
await userEvent.type(screen.getByLabelText(/^description$/i), 'Classic style.')
await userEvent.click(screen.getByRole('button', { name: /save service/i }))
await waitFor(() =>
expect(upsertMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'cornrows', name: 'Cornrows', duration_minutes: 120, price_from: 90 }),
),
)
})
it('deletes a service with confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<AdminServices />)
await screen.findByText('Knotless Braids')
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
expect(deleteMock).toHaveBeenCalled()
expect(eqMock).toHaveBeenCalledWith('id', 'knotless')
})
})
- Step 2: Run the test to verify it fails
Run: npx vitest run src/pages/admin/AdminServices.test.tsx
Expected: FAIL — ./AdminServices does not exist yet.
- Step 3: Implement AdminServices
Create src/pages/admin/AdminServices.module.css (same shape as AdminProducts.module.css from Task 7 — copy it verbatim, since both are table+form CRUD pages using the same visual pattern):
.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 textarea {
padding: var(--space-sm);
border: 1px solid var(--color-gold);
}
.error {
color: #a33;
}
Create src/pages/admin/AdminServices.tsx:
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import styles from './AdminServices.module.css'
interface AdminService {
id: string
name: string
duration_minutes: number
price_from: number
description: string
image: string
}
function emptyForm(): AdminService {
return { id: '', name: '', duration_minutes: 0, price_from: 0, description: '', image: '' }
}
function AdminServices() {
const [servicesList, setServicesList] = useState<AdminService[]>([])
const [form, setForm] = useState<AdminService>(emptyForm())
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadServices() {
const { data, error: fetchError } = await supabase.from('services').select('*').order('name')
if (fetchError) {
setError(fetchError.message)
return
}
setServicesList((data ?? []) as AdminService[])
}
useEffect(() => {
loadServices()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
let image = form.image
if (file) {
try {
image = await uploadSalonImage(file, `styles/${form.id}-${file.name}`)
} catch {
setError('Image upload failed.')
setSaving(false)
return
}
}
const { error: upsertError } = await supabase.from('services').upsert({ ...form, image })
setSaving(false)
if (upsertError) {
setError(upsertError.message)
return
}
setForm(emptyForm())
setFile(null)
await loadServices()
}
async function handleDelete(id: string) {
if (!window.confirm('Delete this service?')) return
const { error: deleteError } = await supabase.from('services').delete().eq('id', id)
if (deleteError) {
setError(deleteError.message)
return
}
await loadServices()
}
return (
<div className={styles.page}>
<h1>Services</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Duration (min)</th>
<th>Price from</th>
<th></th>
</tr>
</thead>
<tbody>
{servicesList.map((service) => (
<tr key={service.id}>
<td>{service.name}</td>
<td>{service.duration_minutes}</td>
<td>${service.price_from}</td>
<td>
<button type="button" onClick={() => setForm(service)}>Edit</button>
<button type="button" onClick={() => handleDelete(service.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{form.id && servicesList.some((s) => s.id === form.id) ? 'Edit service' : 'New service'}</h2>
<label htmlFor="service-id">
Id
<input
id="service-id"
value={form.id}
onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))}
required
/>
</label>
<label htmlFor="service-name">
Name
<input
id="service-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<label htmlFor="service-duration">
Duration minutes
<input
id="service-duration"
type="number"
value={form.duration_minutes}
onChange={(e) => setForm((f) => ({ ...f, duration_minutes: Number(e.target.value) }))}
required
/>
</label>
<label htmlFor="service-price-from">
Price from
<input
id="service-price-from"
type="number"
step="0.01"
value={form.price_from}
onChange={(e) => setForm((f) => ({ ...f, price_from: Number(e.target.value) }))}
required
/>
</label>
<label htmlFor="service-description">
Description
<textarea
id="service-description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
/>
</label>
<label htmlFor="service-image">
Image
<input
id="service-image"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={saving}>Save service</button>
</form>
</div>
)
}
export default AdminServices
- Step 4: Run the test to verify it passes
Run: npx vitest run src/pages/admin/AdminServices.test.tsx
Expected: PASS, 3 tests.
- Step 5: Commit
git add src/pages/admin/AdminServices.tsx src/pages/admin/AdminServices.module.css src/pages/admin/AdminServices.test.tsx
git commit -m "feat: add admin services CRUD page"
Task 9: Admin schedule page
Files:
- Create:
src/pages/admin/AdminSchedule.tsx - Create:
src/pages/admin/AdminSchedule.module.css - Create:
src/pages/admin/AdminSchedule.test.tsx
Interfaces:
- Consumes:
supabasefromsrc/lib/supabase.ts - Produces:
AdminSchedulecomponent (default export). Consumed by Task 12'sAdminApp. - Data shapes (matches
public.schedule_templateandpublic.schedule_exceptionsexactly):
interface ScheduleTemplate {
id: string // always 'default' — single-row table
working_days: number[] // 0 = Sunday .. 6 = Saturday, matches JS Date.getDay()
start_time: string // 'HH:MM:SS'
end_time: string
slot_minutes: number
}
interface ScheduleException {
date: string // ISO date, primary key
closed: boolean
note: string | null
}
- Step 1: Write the failing test
Create src/pages/admin/AdminSchedule.test.tsx:
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const templateSingleMock = vi.fn()
const templateEqUpdateMock = vi.fn()
const templateUpdateMock = vi.fn()
const exceptionsOrderMock = vi.fn()
const exceptionsInsertMock = vi.fn()
const exceptionsDeleteEqMock = vi.fn()
const exceptionsDeleteMock = vi.fn()
const fromMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) },
}))
const TEMPLATE = {
id: 'default',
working_days: [1, 2, 3, 4, 5, 6],
start_time: '09:00:00',
end_time: '18:00:00',
slot_minutes: 60,
}
const EXCEPTION = { date: '2026-12-25', closed: true, note: 'Christmas' }
import AdminSchedule from './AdminSchedule'
describe('AdminSchedule', () => {
beforeEach(() => {
templateSingleMock.mockReset().mockResolvedValue({ data: TEMPLATE, error: null })
templateEqUpdateMock.mockReset().mockResolvedValue({ error: null })
templateUpdateMock.mockReset().mockReturnValue({ eq: templateEqUpdateMock })
exceptionsOrderMock.mockReset().mockResolvedValue({ data: [EXCEPTION], error: null })
exceptionsInsertMock.mockReset().mockResolvedValue({ error: null })
exceptionsDeleteEqMock.mockReset().mockResolvedValue({ error: null })
exceptionsDeleteMock.mockReset().mockReturnValue({ eq: exceptionsDeleteEqMock })
fromMock.mockReset()
fromMock.mockImplementation((table: string) => {
if (table === 'schedule_template') {
return {
select: () => ({ eq: () => ({ single: templateSingleMock }) }),
update: templateUpdateMock,
}
}
if (table === 'schedule_exceptions') {
return {
select: () => ({ order: exceptionsOrderMock }),
insert: exceptionsInsertMock,
delete: exceptionsDeleteMock,
}
}
throw new Error(`unexpected table: ${table}`)
})
})
it('loads and displays the weekly template and exceptions', async () => {
render(<AdminSchedule />)
expect(await screen.findByDisplayValue('09:00:00')).toBeInTheDocument()
expect(screen.getByText('2026-12-25')).toBeInTheDocument()
expect(screen.getByText('Christmas')).toBeInTheDocument()
})
it('saves the weekly template', async () => {
render(<AdminSchedule />)
await screen.findByDisplayValue('09:00:00')
await userEvent.click(screen.getByRole('button', { name: /save schedule/i }))
await waitFor(() => expect(templateUpdateMock).toHaveBeenCalled())
expect(templateEqUpdateMock).toHaveBeenCalledWith('id', 'default')
})
it('adds a new exception', async () => {
render(<AdminSchedule />)
await screen.findByDisplayValue('09:00:00')
await userEvent.type(screen.getByLabelText(/exception date/i), '2026-11-01')
await userEvent.type(screen.getByLabelText(/^note$/i), 'Closed for training')
await userEvent.click(screen.getByRole('button', { name: /add exception/i }))
await waitFor(() =>
expect(exceptionsInsertMock).toHaveBeenCalledWith(
expect.objectContaining({ date: '2026-11-01', note: 'Closed for training' }),
),
)
})
})
- Step 2: Run the test to verify it fails
Run: npx vitest run src/pages/admin/AdminSchedule.test.tsx
Expected: FAIL — ./AdminSchedule does not exist yet.
- Step 3: Implement AdminSchedule
Create src/pages/admin/AdminSchedule.module.css:
.page {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.section {
background: white;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
max-width: 480px;
}
.days {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.days label {
display: flex;
align-items: center;
gap: 0.25rem;
}
.form label,
.section label {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: var(--color-charcoal);
}
.form input {
padding: var(--space-sm);
border: 1px solid var(--color-gold);
}
.exceptionsTable {
width: 100%;
border-collapse: collapse;
}
.exceptionsTable th,
.exceptionsTable td {
text-align: left;
padding: var(--space-sm);
border-bottom: 1px solid var(--color-cream-alt);
}
.error {
color: #a33;
}
Create src/pages/admin/AdminSchedule.tsx:
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import styles from './AdminSchedule.module.css'
interface ScheduleTemplate {
id: string
working_days: number[]
start_time: string
end_time: string
slot_minutes: number
}
interface ScheduleException {
date: string
closed: boolean
note: string | null
}
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function AdminSchedule() {
const [template, setTemplate] = useState<ScheduleTemplate | null>(null)
const [exceptions, setExceptions] = useState<ScheduleException[]>([])
const [newException, setNewException] = useState({ date: '', closed: true, note: '' })
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadTemplate() {
const { data, error: fetchError } = await supabase
.from('schedule_template')
.select('*')
.eq('id', 'default')
.single()
if (fetchError) {
setError(fetchError.message)
return
}
setTemplate(data as ScheduleTemplate)
}
async function loadExceptions() {
const { data, error: fetchError } = await supabase.from('schedule_exceptions').select('*').order('date')
if (fetchError) {
setError(fetchError.message)
return
}
setExceptions((data ?? []) as ScheduleException[])
}
useEffect(() => {
loadTemplate()
loadExceptions()
}, [])
function toggleDay(day: number) {
if (!template) return
const working_days = template.working_days.includes(day)
? template.working_days.filter((d) => d !== day)
: [...template.working_days, day].sort()
setTemplate({ ...template, working_days })
}
async function handleSaveTemplate(event: FormEvent) {
event.preventDefault()
if (!template) return
setSaving(true)
setError('')
const { error: updateError } = await supabase
.from('schedule_template')
.update({
working_days: template.working_days,
start_time: template.start_time,
end_time: template.end_time,
slot_minutes: template.slot_minutes,
})
.eq('id', 'default')
setSaving(false)
if (updateError) setError(updateError.message)
}
async function handleAddException(event: FormEvent) {
event.preventDefault()
setError('')
const { error: insertError } = await supabase.from('schedule_exceptions').insert({
date: newException.date,
closed: newException.closed,
note: newException.note || null,
})
if (insertError) {
setError(insertError.message)
return
}
setNewException({ date: '', closed: true, note: '' })
await loadExceptions()
}
async function handleRemoveException(date: string) {
const { error: deleteError } = await supabase.from('schedule_exceptions').delete().eq('date', date)
if (deleteError) {
setError(deleteError.message)
return
}
await loadExceptions()
}
return (
<div className={styles.page}>
<h1>Schedule</h1>
{template && (
<form className={styles.section} onSubmit={handleSaveTemplate}>
<h2>Weekly hours</h2>
<div className={styles.days}>
{DAY_LABELS.map((label, day) => (
<label key={day}>
<input
type="checkbox"
checked={template.working_days.includes(day)}
onChange={() => toggleDay(day)}
/>
{label}
</label>
))}
</div>
<label htmlFor="schedule-start">
Start time
<input
id="schedule-start"
type="time"
step="1"
value={template.start_time}
onChange={(e) => setTemplate({ ...template, start_time: e.target.value })}
/>
</label>
<label htmlFor="schedule-end">
End time
<input
id="schedule-end"
type="time"
step="1"
value={template.end_time}
onChange={(e) => setTemplate({ ...template, end_time: e.target.value })}
/>
</label>
<label htmlFor="schedule-slot-minutes">
Slot length (minutes)
<input
id="schedule-slot-minutes"
type="number"
value={template.slot_minutes}
onChange={(e) => setTemplate({ ...template, slot_minutes: Number(e.target.value) })}
/>
</label>
<button type="submit" disabled={saving}>Save schedule</button>
</form>
)}
<div className={styles.section}>
<h2>Exceptions</h2>
<table className={styles.exceptionsTable}>
<thead>
<tr>
<th>Date</th>
<th>Closed</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
{exceptions.map((exception) => (
<tr key={exception.date}>
<td>{exception.date}</td>
<td>{exception.closed ? 'Yes' : 'No'}</td>
<td>{exception.note}</td>
<td>
<button type="button" onClick={() => handleRemoveException(exception.date)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
<form onSubmit={handleAddException} className={styles.form}>
<label htmlFor="exception-date">
Exception date
<input
id="exception-date"
type="date"
value={newException.date}
onChange={(e) => setNewException((f) => ({ ...f, date: e.target.value }))}
required
/>
</label>
<label>
<input
type="checkbox"
checked={newException.closed}
onChange={(e) => setNewException((f) => ({ ...f, closed: e.target.checked }))}
/>
Closed
</label>
<label htmlFor="exception-note">
Note
<input
id="exception-note"
value={newException.note}
onChange={(e) => setNewException((f) => ({ ...f, note: e.target.value }))}
/>
</label>
<button type="submit">Add exception</button>
</form>
</div>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
export default AdminSchedule
- Step 4: Run the test to verify it passes
Run: npx vitest run src/pages/admin/AdminSchedule.test.tsx
Expected: PASS, 3 tests.
- Step 5: Commit
git add src/pages/admin/AdminSchedule.tsx src/pages/admin/AdminSchedule.module.css src/pages/admin/AdminSchedule.test.tsx
git commit -m "feat: add admin schedule template and exceptions page"
Task 10: Admin bookings page
Files:
- Create:
src/pages/admin/AdminBookings.tsx - Create:
src/pages/admin/AdminBookings.module.css - Create:
src/pages/admin/AdminBookings.test.tsx
Interfaces:
- Consumes:
supabasefromsrc/lib/supabase.ts;bookings.status = 'completed'from Task 2 - Produces:
AdminBookingscomponent (default export). Consumed by Task 12'sAdminApp. - Data shape (PostgREST embed of the related service name via the
service_idforeign key):
interface AdminBooking {
id: string
date: string
time: string
contact_name: string
contact_phone: string
contact_email: string
contact_notes: string
status: 'confirmed' | 'cancelled' | 'completed'
services: { name: string } | null
}
- Step 1: Write the failing test
Create src/pages/admin/AdminBookings.test.tsx:
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn()
const fromMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) },
}))
const BOOKING = {
id: 'booking-1',
date: '2026-09-15',
time: '10:00 AM',
contact_name: 'Jane Doe',
contact_phone: '555-0100',
contact_email: 'jane@example.com',
contact_notes: '',
status: 'confirmed',
services: { name: 'Knotless Braids' },
}
import AdminBookings from './AdminBookings'
describe('AdminBookings', () => {
beforeEach(() => {
orderMock.mockReset().mockResolvedValue({ data: [BOOKING], 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 bookings with the related service name', async () => {
render(<AdminBookings />)
expect(await screen.findByText('Jane Doe')).toBeInTheDocument()
expect(screen.getByText('Knotless Braids')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('bookings')
})
it('cancels a booking', async () => {
render(<AdminBookings />)
await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /cancel/i }))
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' }))
expect(eqUpdateMock).toHaveBeenCalledWith('id', 'booking-1')
})
it('marks a booking completed', async () => {
render(<AdminBookings />)
await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /mark completed/i }))
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'completed' }))
})
})
- Step 2: Run the test to verify it fails
Run: npx vitest run src/pages/admin/AdminBookings.test.tsx
Expected: FAIL — ./AdminBookings does not exist yet.
- Step 3: Implement AdminBookings
Create src/pages/admin/AdminBookings.module.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/AdminBookings.tsx:
import { useEffect, useState } from 'react'
import { supabase } from '../../lib/supabase'
import styles from './AdminBookings.module.css'
interface AdminBooking {
id: string
date: string
time: string
contact_name: string
contact_phone: string
contact_email: string
contact_notes: string
status: 'confirmed' | 'cancelled' | 'completed'
services: { name: string } | null
}
function AdminBookings() {
const [bookings, setBookings] = useState<AdminBooking[]>([])
const [error, setError] = useState('')
async function loadBookings() {
const { data, error: fetchError } = await supabase
.from('bookings')
.select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)')
.order('date', { ascending: false })
if (fetchError) {
setError(fetchError.message)
return
}
setBookings((data ?? []) as unknown as AdminBooking[])
}
useEffect(() => {
loadBookings()
}, [])
async function setStatus(id: string, status: AdminBooking['status']) {
const { error: updateError } = await supabase.from('bookings').update({ status }).eq('id', id)
if (updateError) {
setError(updateError.message)
return
}
await loadBookings()
}
return (
<div className={styles.page}>
<h1>Bookings</h1>
{error && <p className={styles.error}>{error}</p>}
<table className={styles.table}>
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Service</th>
<th>Contact</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{bookings.map((booking) => (
<tr key={booking.id}>
<td>{booking.date}</td>
<td>{booking.time}</td>
<td>{booking.services?.name}</td>
<td>
{booking.contact_name}
<br />
{booking.contact_phone}
<br />
{booking.contact_email}
{booking.contact_notes && (
<>
<br />
{booking.contact_notes}
</>
)}
</td>
<td>{booking.status}</td>
<td>
{booking.status === 'confirmed' && (
<>
<button type="button" onClick={() => setStatus(booking.id, 'cancelled')}>Cancel</button>
<button type="button" onClick={() => setStatus(booking.id, 'completed')}>Mark completed</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
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
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:
supabasefromsrc/lib/supabase.ts - Produces:
AdminInquiriescomponent (default export). Consumed by Task 12'sAdminApp. - Data shape (PostgREST embed of the related product name via the
product_idforeign key;statusvalues are the existing schema's'new'/'read', not'responded'):
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:
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(<AdminInquiries />)
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(<AdminInquiries />)
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):
.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:
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<AdminInquiry[]>([])
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 (
<div className={styles.page}>
<h1>Inquiries</h1>
{error && <p className={styles.error}>{error}</p>}
<table className={styles.table}>
<thead>
<tr>
<th>Product</th>
<th>Contact</th>
<th>Notes</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{inquiries.map((inquiry) => (
<tr key={inquiry.id}>
<td>{inquiry.products?.name}</td>
<td>
{inquiry.contact_name}
<br />
{inquiry.contact_email}
</td>
<td>{inquiry.contact_notes}</td>
<td>{inquiry.status}</td>
<td>
{inquiry.status === 'new' && (
<button type="button" onClick={() => markRead(inquiry.id)}>Mark as read</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
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
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:
AdminAppcomponent (default export) — the full/admin/*route subtree, mounted once insrc/App.tsx -
Step 1: Write the failing test
Create src/pages/admin/AdminApp.test.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(
<MemoryRouter initialEntries={['/admin/login']}>
<AdminApp />
</MemoryRouter>,
)
expect(screen.getByRole('heading', { name: /admin login/i })).toBeInTheDocument()
})
it('renders the products page (with layout) at /admin/products when authenticated', async () => {
render(
<MemoryRouter initialEntries={['/admin/products']}>
<AdminApp />
</MemoryRouter>,
)
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:
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 (
<Routes>
<Route path="login" element={<AdminLogin />} />
<Route element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route path="products" element={<AdminProducts />} />
<Route path="services" element={<AdminServices />} />
<Route path="schedule" element={<AdminSchedule />} />
<Route path="bookings" element={<AdminBookings />} />
<Route path="inquiries" element={<AdminInquiries />} />
</Route>
</Route>
</Routes>
)
}
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 <Routes> inside AppRoutes(), with public pages nested under a <Route element={<Layout />}> 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 <Routes>, lazy-loaded the same way Home already is.
Modify src/App.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 (
<Routes>
<Route element={<Layout />}>
<Route
path="/"
element={
<Suspense fallback={null}>
<Home />
</Suspense>
}
/>
<Route path="/shop" element={<Shop />} />
<Route path="/book" element={<Book />} />
<Route path="/rules" element={<Rules />} />
</Route>
<Route
path="/admin/*"
element={
<Suspense fallback={null}>
<AdminApp />
</Suspense>
}
/>
</Routes>
)
}
function App({ initialEntries }: AppProps) {
if (initialEntries) {
return (
<MemoryRouter initialEntries={initialEntries}>
<AppRoutes />
</MemoryRouter>
)
}
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
)
}
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:
- Visit
/admin/productswhile signed out — confirm it redirects to/admin/login. - 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=<local service role key from \npx supabase status`> 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 UUID21c90224-e0b2-412e-a431-8e7f097c1cef, sign in via the Supabase Studio UI athttp://127.0.0.1:54323and 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 get42501` until the UID matches — note this discrepancy in your task report rather than silently working around it). - Confirm the sidebar nav links work and each page loads without a console error.
- Create a product with an image upload, confirm it appears in the list and the image renders.
- Cancel a booking (seed one first via the seed script or the public
/bookflow) 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
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/AdminInquiryare 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/Serviceinsrc/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 actualschedule_exceptionsschema, which only hasclosed: booleanand a free-textnote— 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.