diff --git a/src/pages/admin/AdminServices.module.css b/src/pages/admin/AdminServices.module.css new file mode 100644 index 0000000..a61768e --- /dev/null +++ b/src/pages/admin/AdminServices.module.css @@ -0,0 +1,44 @@ +.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; +} diff --git a/src/pages/admin/AdminServices.test.tsx b/src/pages/admin/AdminServices.test.tsx new file mode 100644 index 0000000..c4be681 --- /dev/null +++ b/src/pages/admin/AdminServices.test.tsx @@ -0,0 +1,81 @@ +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() + expect(await screen.findByText('Knotless Braids')).toBeInTheDocument() + expect(fromMock).toHaveBeenCalledWith('services') + }) + + it('creates a new service from the form', async () => { + render() + 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() + await screen.findByText('Knotless Braids') + + await userEvent.click(screen.getByRole('button', { name: /delete/i })) + + expect(deleteMock).toHaveBeenCalled() + expect(eqMock).toHaveBeenCalledWith('id', 'knotless') + }) +}) diff --git a/src/pages/admin/AdminServices.tsx b/src/pages/admin/AdminServices.tsx new file mode 100644 index 0000000..1add643 --- /dev/null +++ b/src/pages/admin/AdminServices.tsx @@ -0,0 +1,177 @@ +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([]) + const [form, setForm] = useState(emptyForm()) + const [file, setFile] = useState(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(() => { + async function load() { + const { data, error: fetchError } = await supabase.from('services').select('*').order('name') + if (fetchError) { + setError(fetchError.message) + return + } + setServicesList((data ?? []) as AdminService[]) + } + void load() + }, []) + + 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 ( + + Services + + + + Name + Duration (min) + Price from + + + + + {servicesList.map((service) => ( + + {service.name} + {service.duration_minutes} + ${service.price_from} + + setForm(service)}>Edit + handleDelete(service.id)}>Delete + + + ))} + + + + + {form.id && servicesList.some((s) => s.id === form.id) ? 'Edit service' : 'New service'} + + Id + setForm((f) => ({ ...f, id: e.target.value }))} + required + /> + + + Name + setForm((f) => ({ ...f, name: e.target.value }))} + required + /> + + + Duration minutes + setForm((f) => ({ ...f, duration_minutes: Number(e.target.value) }))} + required + /> + + + Price from + setForm((f) => ({ ...f, price_from: Number(e.target.value) }))} + required + /> + + + Description + setForm((f) => ({ ...f, description: e.target.value }))} + required + /> + + + Image + setFile(e.target.files?.[0] ?? null)} + /> + + {error && {error}} + Save service + + + ) +} + +export default AdminServices
{error}