2713 lines
82 KiB
Markdown
2713 lines
82 KiB
Markdown
# 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:
|
|
|
|
```sql
|
|
-- salon-images bucket: anyone can read, only admin can write
|
|
set local role anon;
|
|
select lives_ok(
|
|
$$ select * from storage.objects where bucket_id = 'salon-images' $$,
|
|
'anon can read salon-images objects'
|
|
);
|
|
|
|
select throws_ok(
|
|
$$ insert into storage.objects (bucket_id, name) values ('salon-images', 'test.jpg') $$,
|
|
'42501',
|
|
null,
|
|
'anon cannot upload to salon-images'
|
|
);
|
|
|
|
set local role authenticated;
|
|
set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';
|
|
select lives_ok(
|
|
$$ insert into storage.objects (bucket_id, name) values ('salon-images', 'admin-test.jpg') $$,
|
|
'admin can upload to salon-images'
|
|
);
|
|
```
|
|
|
|
Update the `select plan(N)` line at the top of the file to the new total.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx supabase test db`
|
|
Expected: FAIL — the new tests error out because the `salon-images` bucket and its policies don't exist yet locally (`insert or update on table "objects" violates foreign key constraint` or similar, since `bucket_id` won't resolve, or a generic RLS-default-deny failure). This confirms the tests are actually exercising something real.
|
|
|
|
- [ ] **Step 3: Write the migration**
|
|
|
|
Create `supabase/migrations/<timestamp>_salon_images_storage_policies.sql`:
|
|
|
|
```sql
|
|
-- The salon-images bucket was created manually in the live project during
|
|
-- Phase 2A's Task 8 (private, no policies — reserved for this task). Local
|
|
-- dev stacks don't have it at all (it wasn't created via migration), so
|
|
-- this creates it if missing and makes it public either way: product/
|
|
-- service photos are marketing images meant to be publicly visible once the
|
|
-- public site (Sub-project C) reads live data.
|
|
insert into storage.buckets (id, name, public)
|
|
values ('salon-images', 'salon-images', true)
|
|
on conflict (id) do update set public = true;
|
|
|
|
create policy "salon_images_public_read" on storage.objects
|
|
for select using (bucket_id = 'salon-images');
|
|
|
|
create policy "salon_images_admin_write" on storage.objects
|
|
for all
|
|
using (bucket_id = 'salon-images' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid)
|
|
with check (bucket_id = 'salon-images' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid);
|
|
```
|
|
|
|
- [ ] **Step 4: Apply the migration and run the tests**
|
|
|
|
Run: `npx supabase db reset` then `npx supabase test db`
|
|
Expected: all tests pass, including the 3 new ones. If `insert into storage.objects` fails with a missing-column error, check the actual columns Supabase's `storage.objects` table requires in this CLI version (`\d storage.objects` via `npx supabase db query --local "select column_name, is_nullable, column_default from information_schema.columns where table_schema='storage' and table_name='objects'"`) and adjust the test inserts to supply any other required columns — don't just add `not null` workarounds that weaken the test.
|
|
|
|
- [ ] **Step 5: Run full local verification**
|
|
|
|
Run: `npx vitest run && npm run build && npm run lint`
|
|
Expected: unaffected, all pass (this task only touches SQL).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add supabase/migrations supabase/tests/database/rls.test.sql
|
|
git commit -m "feat: add public-read, admin-write RLS policies for salon-images bucket"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Allow `bookings.status = 'completed'`
|
|
|
|
**Files:**
|
|
- Create: `supabase/migrations/<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):
|
|
|
|
```sql
|
|
select lives_ok(
|
|
$$ update public.bookings set status = 'completed' where service_id = 'knotless' $$,
|
|
'admin can mark a booking completed'
|
|
);
|
|
|
|
select throws_ok(
|
|
$$ update public.bookings set status = 'bogus' where service_id = 'knotless' $$,
|
|
'23514',
|
|
null,
|
|
'booking status is still constrained to known values'
|
|
);
|
|
```
|
|
|
|
Bump `select plan(N)` by 2.
|
|
|
|
- [ ] **Step 2: Run the tests to verify the first one fails**
|
|
|
|
Run: `npx supabase test db`
|
|
Expected: the "admin can mark a booking completed" test FAILs with a check constraint violation (SQLSTATE 23514), since `'completed'` isn't in the current `bookings_status_check` constraint. The second test should already pass (it's testing existing behavior).
|
|
|
|
- [ ] **Step 3: Write the migration**
|
|
|
|
Create `supabase/migrations/<timestamp>_add_completed_booking_status.sql`:
|
|
|
|
```sql
|
|
-- Widen the bookings.status check constraint to allow 'completed', so the
|
|
-- admin panel can mark past appointments completed (not just cancelled).
|
|
alter table public.bookings drop constraint bookings_status_check;
|
|
alter table public.bookings add constraint bookings_status_check
|
|
check (status in ('confirmed', 'cancelled', 'completed'));
|
|
```
|
|
|
|
If `drop constraint bookings_status_check` fails with "constraint does not exist", find the real name first: `npx supabase db query --local "select conname from pg_constraint where conrelid = 'public.bookings'::regclass and contype = 'c'"`, then use that name instead.
|
|
|
|
- [ ] **Step 4: Apply the migration and run the tests**
|
|
|
|
Run: `npx supabase db reset` then `npx supabase test db`
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 5: Run full local verification**
|
|
|
|
Run: `npx vitest run && npm run build && npm run lint`
|
|
Expected: unaffected, all pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add supabase/migrations supabase/tests/database/rls.test.sql
|
|
git commit -m "feat: allow bookings.status to be marked completed"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Admin auth hook and route guard
|
|
|
|
**Files:**
|
|
- Create: `src/hooks/useAdminAuth.ts`
|
|
- Create: `src/hooks/useAdminAuth.test.ts`
|
|
- Create: `src/components/ProtectedRoute.tsx`
|
|
- Create: `src/components/ProtectedRoute.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `supabase` from `src/lib/supabase.ts`
|
|
- Produces: `useAdminAuth(): { session: Session | null; loading: boolean }` (default export from `src/hooks/useAdminAuth.ts`); `ProtectedRoute` component (default export from `src/components/ProtectedRoute.tsx`) — renders `<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`:
|
|
|
|
```ts
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { Session } from '@supabase/supabase-js'
|
|
|
|
const getSessionMock = vi.fn()
|
|
const onAuthStateChangeMock = vi.fn()
|
|
|
|
vi.mock('../lib/supabase', () => ({
|
|
supabase: {
|
|
auth: {
|
|
getSession: (...args: unknown[]) => getSessionMock(...args),
|
|
onAuthStateChange: (...args: unknown[]) => onAuthStateChangeMock(...args),
|
|
},
|
|
},
|
|
}))
|
|
|
|
const FAKE_SESSION = { access_token: 'token', user: { id: 'admin' } } as unknown as Session
|
|
|
|
describe('useAdminAuth', () => {
|
|
beforeEach(() => {
|
|
getSessionMock.mockReset()
|
|
onAuthStateChangeMock.mockReset()
|
|
onAuthStateChangeMock.mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
|
|
})
|
|
|
|
it('starts loading, then resolves with the current session', async () => {
|
|
getSessionMock.mockResolvedValue({ data: { session: FAKE_SESSION } })
|
|
const { default: useAdminAuth } = await import('./useAdminAuth')
|
|
const { result } = renderHook(() => useAdminAuth())
|
|
|
|
expect(result.current.loading).toBe(true)
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(result.current.session).toBe(FAKE_SESSION)
|
|
})
|
|
|
|
it('resolves with a null session when signed out', async () => {
|
|
getSessionMock.mockResolvedValue({ data: { session: null } })
|
|
const { default: useAdminAuth } = await import('./useAdminAuth')
|
|
const { result } = renderHook(() => useAdminAuth())
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(result.current.session).toBeNull()
|
|
})
|
|
|
|
it('updates session when the auth state changes', async () => {
|
|
getSessionMock.mockResolvedValue({ data: { session: null } })
|
|
let capturedCallback: ((event: string, session: Session | null) => void) | undefined
|
|
onAuthStateChangeMock.mockImplementation((callback) => {
|
|
capturedCallback = callback
|
|
return { data: { subscription: { unsubscribe: vi.fn() } } }
|
|
})
|
|
const { default: useAdminAuth } = await import('./useAdminAuth')
|
|
const { result } = renderHook(() => useAdminAuth())
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
capturedCallback?.('SIGNED_IN', FAKE_SESSION)
|
|
await waitFor(() => expect(result.current.session).toBe(FAKE_SESSION))
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `npx vitest run src/hooks/useAdminAuth.test.ts`
|
|
Expected: FAIL — `./useAdminAuth` does not exist yet.
|
|
|
|
- [ ] **Step 3: Implement the hook**
|
|
|
|
Create `src/hooks/useAdminAuth.ts`:
|
|
|
|
```ts
|
|
import { useEffect, useState } from 'react'
|
|
import type { Session } from '@supabase/supabase-js'
|
|
import { supabase } from '../lib/supabase'
|
|
|
|
export interface AdminAuthState {
|
|
session: Session | null
|
|
loading: boolean
|
|
}
|
|
|
|
export default function useAdminAuth(): AdminAuthState {
|
|
const [session, setSession] = useState<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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
git add src/hooks/useAdminAuth.ts src/hooks/useAdminAuth.test.ts src/components/ProtectedRoute.tsx src/components/ProtectedRoute.test.tsx
|
|
git commit -m "feat: add admin auth hook and protected route guard"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Admin login page
|
|
|
|
**Files:**
|
|
- Create: `src/pages/admin/AdminLogin.tsx`
|
|
- Create: `src/pages/admin/AdminLogin.module.css`
|
|
- Create: `src/pages/admin/AdminLogin.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `supabase` from `src/lib/supabase.ts`
|
|
- Produces: `AdminLogin` component (default export). Consumed by Task 12's `AdminApp`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `src/pages/admin/AdminLogin.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { MemoryRouter } from 'react-router-dom'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const signInMock = vi.fn()
|
|
const navigateMock = vi.fn()
|
|
|
|
vi.mock('../../lib/supabase', () => ({
|
|
supabase: { auth: { signInWithPassword: (...args: unknown[]) => signInMock(...args) } },
|
|
}))
|
|
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom')
|
|
return { ...actual, useNavigate: () => navigateMock }
|
|
})
|
|
|
|
import AdminLogin from './AdminLogin'
|
|
|
|
describe('AdminLogin', () => {
|
|
beforeEach(() => {
|
|
signInMock.mockReset()
|
|
navigateMock.mockReset()
|
|
})
|
|
|
|
it('signs in and navigates to the admin dashboard on success', async () => {
|
|
signInMock.mockResolvedValue({ error: null })
|
|
render(<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`:
|
|
|
|
```css
|
|
.page {
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-cream);
|
|
padding: var(--space-lg);
|
|
}
|
|
|
|
.form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-md);
|
|
width: 100%;
|
|
max-width: 320px;
|
|
}
|
|
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.field label {
|
|
color: var(--color-charcoal);
|
|
}
|
|
|
|
.field input {
|
|
padding: var(--space-sm);
|
|
border: 1px solid var(--color-gold);
|
|
background: white;
|
|
}
|
|
|
|
.error {
|
|
color: #a33;
|
|
}
|
|
|
|
.form button {
|
|
background: var(--color-charcoal);
|
|
color: var(--color-cream);
|
|
border: none;
|
|
padding: var(--space-sm) var(--space-md);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.form button:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
```
|
|
|
|
Create `src/pages/admin/AdminLogin.tsx`:
|
|
|
|
```tsx
|
|
import { useState, type FormEvent } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { supabase } from '../../lib/supabase'
|
|
import styles from './AdminLogin.module.css'
|
|
|
|
function AdminLogin() {
|
|
const navigate = useNavigate()
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
async function handleSubmit(event: FormEvent) {
|
|
event.preventDefault()
|
|
setSubmitting(true)
|
|
setError('')
|
|
const { error: signInError } = await supabase.auth.signInWithPassword({ email, password })
|
|
setSubmitting(false)
|
|
if (signInError) {
|
|
setError('Incorrect email or password.')
|
|
return
|
|
}
|
|
navigate('/admin/products')
|
|
}
|
|
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/pages/admin/AdminLogin.tsx src/pages/admin/AdminLogin.module.css src/pages/admin/AdminLogin.test.tsx
|
|
git commit -m "feat: add admin login page"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Admin layout (sidebar + sign out)
|
|
|
|
**Files:**
|
|
- Create: `src/pages/admin/AdminLayout.tsx`
|
|
- Create: `src/pages/admin/AdminLayout.module.css`
|
|
- Create: `src/pages/admin/AdminLayout.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `supabase` from `src/lib/supabase.ts`
|
|
- Produces: `AdminLayout` component (default export) rendering a sidebar nav and `<Outlet />`. Consumed by Task 12's `AdminApp`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `src/pages/admin/AdminLayout.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const signOutMock = vi.fn()
|
|
const navigateMock = vi.fn()
|
|
|
|
vi.mock('../../lib/supabase', () => ({
|
|
supabase: { auth: { signOut: (...args: unknown[]) => signOutMock(...args) } },
|
|
}))
|
|
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom')
|
|
return { ...actual, useNavigate: () => navigateMock }
|
|
})
|
|
|
|
import AdminLayout from './AdminLayout'
|
|
|
|
function renderLayout() {
|
|
return render(
|
|
<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`:
|
|
|
|
```css
|
|
.layout {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 220px;
|
|
flex-shrink: 0;
|
|
background: var(--color-charcoal);
|
|
color: var(--color-cream);
|
|
padding: var(--space-lg) var(--space-md);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.brand {
|
|
font-family: var(--font-script);
|
|
font-size: 1.5rem;
|
|
color: var(--color-gold);
|
|
}
|
|
|
|
.sidebar nav {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.navLink {
|
|
color: var(--color-cream);
|
|
text-decoration: none;
|
|
padding: var(--space-sm);
|
|
}
|
|
|
|
.navLinkActive {
|
|
color: var(--color-gold);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.signOut {
|
|
margin-top: auto;
|
|
background: none;
|
|
border: 1px solid var(--color-gold);
|
|
color: var(--color-cream);
|
|
padding: var(--space-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
padding: var(--space-xl) var(--space-lg);
|
|
background: var(--color-cream-alt);
|
|
}
|
|
```
|
|
|
|
Create `src/pages/admin/AdminLayout.tsx`:
|
|
|
|
```tsx
|
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
|
import { supabase } from '../../lib/supabase'
|
|
import styles from './AdminLayout.module.css'
|
|
|
|
const NAV_ITEMS = [
|
|
{ to: '/admin/products', label: 'Products' },
|
|
{ to: '/admin/services', label: 'Services' },
|
|
{ to: '/admin/schedule', label: 'Schedule' },
|
|
{ to: '/admin/bookings', label: 'Bookings' },
|
|
{ to: '/admin/inquiries', label: 'Inquiries' },
|
|
]
|
|
|
|
function AdminLayout() {
|
|
const navigate = useNavigate()
|
|
|
|
async function handleSignOut() {
|
|
await supabase.auth.signOut()
|
|
navigate('/admin/login')
|
|
}
|
|
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/pages/admin/AdminLayout.tsx src/pages/admin/AdminLayout.module.css src/pages/admin/AdminLayout.test.tsx
|
|
git commit -m "feat: add admin layout with sidebar nav and sign out"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Image upload helper
|
|
|
|
**Files:**
|
|
- Create: `src/lib/storage.ts`
|
|
- Create: `src/lib/storage.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `supabase` from `src/lib/supabase.ts`; the `salon-images` bucket/policies from Task 1
|
|
- Produces: `uploadSalonImage(file: File, path: string): Promise<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`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const uploadMock = vi.fn()
|
|
const getPublicUrlMock = vi.fn()
|
|
const fromMock = vi.fn(() => ({ upload: uploadMock, getPublicUrl: getPublicUrlMock }))
|
|
|
|
vi.mock('./supabase', () => ({
|
|
supabase: { storage: { from: (...args: unknown[]) => fromMock(...args) } },
|
|
}))
|
|
|
|
import { uploadSalonImage } from './storage'
|
|
|
|
describe('uploadSalonImage', () => {
|
|
beforeEach(() => {
|
|
uploadMock.mockReset()
|
|
getPublicUrlMock.mockReset()
|
|
fromMock.mockClear()
|
|
})
|
|
|
|
it('uploads to the salon-images bucket and returns the public URL', async () => {
|
|
uploadMock.mockResolvedValue({ error: null })
|
|
getPublicUrlMock.mockReturnValue({ data: { publicUrl: 'https://example.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg' } })
|
|
|
|
const file = new File(['fake'], 'edge-control.jpg', { type: 'image/jpeg' })
|
|
const url = await uploadSalonImage(file, 'products/edge-control.jpg')
|
|
|
|
expect(fromMock).toHaveBeenCalledWith('salon-images')
|
|
expect(uploadMock).toHaveBeenCalledWith('products/edge-control.jpg', file, { upsert: true })
|
|
expect(url).toBe('https://example.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg')
|
|
})
|
|
|
|
it('throws when the upload fails', async () => {
|
|
uploadMock.mockResolvedValue({ error: { message: 'Storage error' } })
|
|
const file = new File(['fake'], 'bad.jpg', { type: 'image/jpeg' })
|
|
|
|
await expect(uploadSalonImage(file, 'products/bad.jpg')).rejects.toEqual({ message: 'Storage error' })
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `npx vitest run src/lib/storage.test.ts`
|
|
Expected: FAIL — `./storage` does not exist yet.
|
|
|
|
- [ ] **Step 3: Implement the helper**
|
|
|
|
Create `src/lib/storage.ts`:
|
|
|
|
```ts
|
|
import { supabase } from './supabase'
|
|
|
|
export async function uploadSalonImage(file: File, path: string): Promise<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**
|
|
|
|
```bash
|
|
git add src/lib/storage.ts src/lib/storage.test.ts
|
|
git commit -m "feat: add salon-images upload helper"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Admin products page
|
|
|
|
**Files:**
|
|
- Create: `src/pages/admin/AdminProducts.tsx`
|
|
- Create: `src/pages/admin/AdminProducts.module.css`
|
|
- Create: `src/pages/admin/AdminProducts.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `supabase` from `src/lib/supabase.ts`; `uploadSalonImage` from `src/lib/storage.ts` (Task 6)
|
|
- Produces: `AdminProducts` component (default export). Consumed by Task 12's `AdminApp`.
|
|
- Data shape (matches `public.products` columns exactly — snake_case, as returned by `supabase-js`):
|
|
|
|
```ts
|
|
interface AdminProduct {
|
|
id: string
|
|
name: string
|
|
category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
|
|
price: number
|
|
image: string
|
|
description: string
|
|
in_stock: boolean
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `src/pages/admin/AdminProducts.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const selectMock = vi.fn()
|
|
const orderMock = vi.fn()
|
|
const upsertMock = vi.fn()
|
|
const deleteMock = vi.fn()
|
|
const eqMock = vi.fn()
|
|
const fromMock = vi.fn()
|
|
const uploadSalonImageMock = vi.fn()
|
|
|
|
vi.mock('../../lib/supabase', () => ({
|
|
supabase: { from: (...args: unknown[]) => fromMock(...args) },
|
|
}))
|
|
vi.mock('../../lib/storage', () => ({
|
|
uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
|
|
}))
|
|
|
|
const EXISTING_PRODUCT = {
|
|
id: 'edge-control',
|
|
name: 'Silk Edge Control',
|
|
category: 'hair-care',
|
|
price: 12,
|
|
image: 'products/edge-control.jpg',
|
|
description: 'Hold with shine.',
|
|
in_stock: true,
|
|
}
|
|
|
|
import AdminProducts from './AdminProducts'
|
|
|
|
describe('AdminProducts', () => {
|
|
beforeEach(() => {
|
|
selectMock.mockReset()
|
|
orderMock.mockReset()
|
|
upsertMock.mockReset()
|
|
deleteMock.mockReset()
|
|
eqMock.mockReset()
|
|
fromMock.mockReset()
|
|
uploadSalonImageMock.mockReset()
|
|
|
|
orderMock.mockResolvedValue({ data: [EXISTING_PRODUCT], error: null })
|
|
selectMock.mockReturnValue({ order: orderMock })
|
|
upsertMock.mockResolvedValue({ error: null })
|
|
eqMock.mockResolvedValue({ error: null })
|
|
deleteMock.mockReturnValue({ eq: eqMock })
|
|
fromMock.mockReturnValue({ select: selectMock, upsert: upsertMock, delete: deleteMock })
|
|
})
|
|
|
|
it('lists existing products', async () => {
|
|
render(<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`:
|
|
|
|
```css
|
|
.page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: white;
|
|
}
|
|
|
|
.table th,
|
|
.table td {
|
|
text-align: left;
|
|
padding: var(--space-sm);
|
|
border-bottom: 1px solid var(--color-cream-alt);
|
|
}
|
|
|
|
.form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
max-width: 420px;
|
|
background: white;
|
|
padding: var(--space-md);
|
|
}
|
|
|
|
.form label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
color: var(--color-charcoal);
|
|
}
|
|
|
|
.form input,
|
|
.form select,
|
|
.form textarea {
|
|
padding: var(--space-sm);
|
|
border: 1px solid var(--color-gold);
|
|
}
|
|
|
|
.error {
|
|
color: #a33;
|
|
}
|
|
```
|
|
|
|
Create `src/pages/admin/AdminProducts.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useState, type FormEvent } from 'react'
|
|
import { supabase } from '../../lib/supabase'
|
|
import { uploadSalonImage } from '../../lib/storage'
|
|
import styles from './AdminProducts.module.css'
|
|
|
|
interface AdminProduct {
|
|
id: string
|
|
name: string
|
|
category: 'hair-care' | 'extensions' | 'tools' | 'accessories'
|
|
price: number
|
|
image: string
|
|
description: string
|
|
in_stock: boolean
|
|
}
|
|
|
|
const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories']
|
|
|
|
function emptyForm(): AdminProduct {
|
|
return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true }
|
|
}
|
|
|
|
function AdminProducts() {
|
|
const [products, setProducts] = useState<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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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):
|
|
|
|
```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 textarea {
|
|
padding: var(--space-sm);
|
|
border: 1px solid var(--color-gold);
|
|
}
|
|
|
|
.error {
|
|
color: #a33;
|
|
}
|
|
```
|
|
|
|
Create `src/pages/admin/AdminServices.tsx`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
git add src/pages/admin/AdminBookings.tsx src/pages/admin/AdminBookings.module.css src/pages/admin/AdminBookings.test.tsx
|
|
git commit -m "feat: add admin bookings page with status actions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Admin inquiries page
|
|
|
|
**Files:**
|
|
- Create: `src/pages/admin/AdminInquiries.tsx`
|
|
- Create: `src/pages/admin/AdminInquiries.module.css`
|
|
- Create: `src/pages/admin/AdminInquiries.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `supabase` from `src/lib/supabase.ts`
|
|
- Produces: `AdminInquiries` component (default export). Consumed by Task 12's `AdminApp`.
|
|
- Data shape (PostgREST embed of the related product name via the `product_id` foreign key; `status` values are the existing schema's `'new'`/`'read'`, not `'responded'`):
|
|
|
|
```ts
|
|
interface AdminInquiry {
|
|
id: string
|
|
contact_name: string
|
|
contact_email: string
|
|
contact_notes: string
|
|
status: 'new' | 'read'
|
|
products: { name: string } | null
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `src/pages/admin/AdminInquiries.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const orderMock = vi.fn()
|
|
const selectMock = vi.fn()
|
|
const eqUpdateMock = vi.fn()
|
|
const updateMock = vi.fn()
|
|
const fromMock = vi.fn()
|
|
|
|
vi.mock('../../lib/supabase', () => ({
|
|
supabase: { from: (...args: unknown[]) => fromMock(...args) },
|
|
}))
|
|
|
|
const INQUIRY = {
|
|
id: 'inquiry-1',
|
|
contact_name: 'Nadia',
|
|
contact_email: 'nadia@example.com',
|
|
contact_notes: 'Is this in stock?',
|
|
status: 'new',
|
|
products: { name: 'Silk Edge Control' },
|
|
}
|
|
|
|
import AdminInquiries from './AdminInquiries'
|
|
|
|
describe('AdminInquiries', () => {
|
|
beforeEach(() => {
|
|
orderMock.mockReset().mockResolvedValue({ data: [INQUIRY], error: null })
|
|
selectMock.mockReset().mockReturnValue({ order: orderMock })
|
|
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
|
|
updateMock.mockReset().mockReturnValue({ eq: eqUpdateMock })
|
|
fromMock.mockReset().mockReturnValue({ select: selectMock, update: updateMock })
|
|
})
|
|
|
|
it('lists inquiries with the related product name', async () => {
|
|
render(<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):
|
|
|
|
```css
|
|
.page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: white;
|
|
}
|
|
|
|
.table th,
|
|
.table td {
|
|
text-align: left;
|
|
padding: var(--space-sm);
|
|
border-bottom: 1px solid var(--color-cream-alt);
|
|
vertical-align: top;
|
|
}
|
|
|
|
.error {
|
|
color: #a33;
|
|
}
|
|
```
|
|
|
|
Create `src/pages/admin/AdminInquiries.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useState } from 'react'
|
|
import { supabase } from '../../lib/supabase'
|
|
import styles from './AdminInquiries.module.css'
|
|
|
|
interface AdminInquiry {
|
|
id: string
|
|
contact_name: string
|
|
contact_email: string
|
|
contact_notes: string
|
|
status: 'new' | 'read'
|
|
products: { name: string } | null
|
|
}
|
|
|
|
function AdminInquiries() {
|
|
const [inquiries, setInquiries] = useState<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**
|
|
|
|
```bash
|
|
git add src/pages/admin/AdminInquiries.tsx src/pages/admin/AdminInquiries.module.css src/pages/admin/AdminInquiries.test.tsx
|
|
git commit -m "feat: add admin inquiries page with mark-as-read action"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Wire up `/admin/*` routing
|
|
|
|
**Files:**
|
|
- Create: `src/pages/admin/AdminApp.tsx`
|
|
- Create: `src/pages/admin/AdminApp.test.tsx`
|
|
- Modify: `src/App.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ProtectedRoute` (Task 3), `AdminLogin` (Task 4), `AdminLayout` (Task 5), `AdminProducts` (Task 7), `AdminServices` (Task 8), `AdminSchedule` (Task 9), `AdminBookings` (Task 10), `AdminInquiries` (Task 11)
|
|
- Produces: `AdminApp` component (default export) — the full `/admin/*` route subtree, mounted once in `src/App.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `src/pages/admin/AdminApp.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react'
|
|
import { MemoryRouter } from 'react-router-dom'
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
|
|
vi.mock('../../hooks/useAdminAuth', () => ({
|
|
default: () => ({ session: { user: { id: 'admin' } }, loading: false }),
|
|
}))
|
|
vi.mock('../../lib/supabase', () => ({
|
|
supabase: {
|
|
auth: { signOut: vi.fn() },
|
|
from: () => ({
|
|
select: () => ({ order: () => Promise.resolve({ data: [], error: null }) }),
|
|
}),
|
|
},
|
|
}))
|
|
|
|
import AdminApp from './AdminApp'
|
|
|
|
describe('AdminApp routing', () => {
|
|
it('renders the login page at /admin/login', () => {
|
|
render(
|
|
<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`:
|
|
|
|
```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`:
|
|
|
|
```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:54323` and directly set the local test user's UID to that exact value, or accept that local manual testing exercises the UI/auth flow correctly while write operations will get `42501` until the UID matches — note this discrepancy in your task report rather than silently working around it).
|
|
3. Confirm the sidebar nav links work and each page loads without a console error.
|
|
4. Create a product with an image upload, confirm it appears in the list and the image renders.
|
|
5. Cancel a booking (seed one first via the seed script or the public `/book` flow) and confirm it disappears from the "confirmed" actions.
|
|
|
|
Report what you observed — this step is about catching integration issues automated tests can't (routing edge cases, real Storage upload behavior, RLS UID mismatches), not about achieving a specific pass/fail.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add src/pages/admin/AdminApp.tsx src/pages/admin/AdminApp.test.tsx src/App.tsx
|
|
git commit -m "feat: wire up /admin/* routing"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Notes
|
|
|
|
- **Spec coverage:** login (Task 4), products/services CRUD with image upload (Tasks 6-8), schedule template + exceptions (Task 9), bookings/inquiries view + act (Tasks 10-11), route protection (Task 3), routing/lazy-loading (Task 12), Storage bucket policies (Task 1). All spec sections covered.
|
|
- **Type consistency:** `AdminProduct`/`AdminService`/`ScheduleTemplate`/`ScheduleException`/`AdminBooking`/`AdminInquiry` are each defined once, in the task that first needs them, matching real Postgres column names (snake_case) exactly rather than the frontend's existing camelCase mock-data types (`Product`/`Service` in `src/data/`) — those are separate, intentionally not reused, since this admin panel talks to the live schema, not the mock data.
|
|
- **Deviations from the design spec, explicit:** the spec's wording ("mark an inquiry as responded") used a status value that doesn't exist in the schema (`'new'`/`'read'` are the only two) — Task 11 uses `'read'` instead of inventing a new status, avoiding an unnecessary migration. The spec's wording for schedule exceptions ("closed-or-custom-hours") doesn't match the actual `schedule_exceptions` schema, which only has `closed: boolean` and a free-text `note` — Task 9 implements the real schema (closed toggle + note), not structured custom hours.
|
|
- **No placeholders:** every step has literal file contents; the two new migration timestamps are the only execution-time-generated values, generated via a documented command rather than guessed, to avoid collision with any other in-flight migration work on this repository.
|