diff --git a/src/pages/admin/AdminProducts.module.css b/src/pages/admin/AdminProducts.module.css new file mode 100644 index 0000000..bc2825b --- /dev/null +++ b/src/pages/admin/AdminProducts.module.css @@ -0,0 +1,45 @@ +.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; +} diff --git a/src/pages/admin/AdminProducts.test.tsx b/src/pages/admin/AdminProducts.test.tsx new file mode 100644 index 0000000..3380a94 --- /dev/null +++ b/src/pages/admin/AdminProducts.test.tsx @@ -0,0 +1,83 @@ +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() + expect(await screen.findByText('Silk Edge Control')).toBeInTheDocument() + expect(fromMock).toHaveBeenCalledWith('products') + }) + + it('creates a new product from the form', async () => { + render() + 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() + await screen.findByText('Silk Edge Control') + + await userEvent.click(screen.getByRole('button', { name: /delete/i })) + + expect(deleteMock).toHaveBeenCalled() + expect(eqMock).toHaveBeenCalledWith('id', 'edge-control') + }) +}) diff --git a/src/pages/admin/AdminProducts.tsx b/src/pages/admin/AdminProducts.tsx new file mode 100644 index 0000000..826f454 --- /dev/null +++ b/src/pages/admin/AdminProducts.tsx @@ -0,0 +1,193 @@ +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([]) + const [form, setForm] = useState(emptyForm()) + const [file, setFile] = useState(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(() => { + async function load() { + const { data, error: fetchError } = await supabase.from('products').select('*').order('name') + if (fetchError) { + setError(fetchError.message) + return + } + setProducts((data ?? []) as AdminProduct[]) + } + void load() + }, []) + + 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 ( +
+

Products

+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
NameCategoryPriceIn stock
{product.name}{product.category}${product.price}{product.in_stock ? 'Yes' : 'No'} + + +
+ +
+

{form.id && products.some((p) => p.id === form.id) ? 'Edit product' : 'New product'}

+ + + + +