feat: add admin products CRUD page
This commit is contained in:
45
src/pages/admin/AdminProducts.module.css
Normal file
45
src/pages/admin/AdminProducts.module.css
Normal 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;
|
||||
}
|
||||
83
src/pages/admin/AdminProducts.test.tsx
Normal file
83
src/pages/admin/AdminProducts.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
193
src/pages/admin/AdminProducts.tsx
Normal file
193
src/pages/admin/AdminProducts.tsx
Normal 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
|
||||
Reference in New Issue
Block a user