Files
braidsbymira/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md
2026-08-12 10:38:20 +02:00

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 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/<timestamp>_salon_images_storage_policies.sql — generate <timestamp> 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:

-- 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.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):

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: 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 <Outlet /> 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:

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: 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:

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: supabase from src/lib/supabase.ts

  • Produces: AdminLayout component (default export) rendering a sidebar nav and <Outlet />. Consumed by Task 12's AdminApp.

  • 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: supabase from src/lib/supabase.ts; the salon-images bucket/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: supabase from src/lib/supabase.ts; uploadSalonImage from src/lib/storage.ts (Task 6)
  • Produces: AdminProducts component (default export). Consumed by Task 12's AdminApp.
  • Data shape (matches public.products columns exactly — snake_case, as returned by supabase-js):
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: supabase from src/lib/supabase.ts; uploadSalonImage from src/lib/storage.ts (Task 6)
  • Produces: AdminServices component (default export). Consumed by Task 12's AdminApp.
  • Data shape (matches public.services columns 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: supabase from src/lib/supabase.ts
  • Produces: AdminSchedule component (default export). Consumed by Task 12's AdminApp.
  • Data shapes (matches public.schedule_template and public.schedule_exceptions exactly):
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: supabase from src/lib/supabase.ts; bookings.status = 'completed' from Task 2
  • Produces: AdminBookings component (default export). Consumed by Task 12's AdminApp.
  • Data shape (PostgREST embed of the related service name via the service_id foreign 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: 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'):
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: 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:

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:

  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=<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 UUID 21c90224-e0b2-412e-a431-8e7f097c1cef, sign in via the Supabase Studio UI at http://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).
  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
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.