feat: add admin products CRUD page

This commit is contained in:
belviskhoremk
2026-08-12 11:33:39 +02:00
parent 795635b4fa
commit 4c2c558fca
3 changed files with 321 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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(<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')
})
})

View File

@@ -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<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(() => {
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 (
<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