feat: add featured toggle/reorder to AdminProducts; apply shared admin design
This commit is contained in:
@@ -1,45 +1,28 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
composes: page from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.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);
|
||||
composes: table from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
max-width: 420px;
|
||||
background: white;
|
||||
padding: var(--space-md);
|
||||
composes: form from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-charcoal);
|
||||
.button {
|
||||
composes: button from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select,
|
||||
.form textarea {
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--color-gold);
|
||||
.buttonSecondary {
|
||||
composes: buttonSecondary from './adminShared.module.css';
|
||||
margin-right: var(--admin-space-xs);
|
||||
}
|
||||
|
||||
.buttonDanger {
|
||||
composes: buttonDanger from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #a33;
|
||||
composes: error from './adminShared.module.css';
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ const EXISTING_PRODUCT = {
|
||||
image: 'products/edge-control.jpg',
|
||||
description: 'Hold with shine.',
|
||||
in_stock: true,
|
||||
featured: false,
|
||||
featured_order: null,
|
||||
}
|
||||
|
||||
import AdminProducts from './AdminProducts'
|
||||
@@ -80,4 +82,18 @@ describe('AdminProducts', () => {
|
||||
expect(deleteMock).toHaveBeenCalled()
|
||||
expect(eqMock).toHaveBeenCalledWith('id', 'edge-control')
|
||||
})
|
||||
|
||||
it('marks a product as featured, assigning it the next featured_order', async () => {
|
||||
upsertMock.mockResolvedValue({ error: null })
|
||||
render(<AdminProducts />)
|
||||
await screen.findByText('Silk Edge Control')
|
||||
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /vedette/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(upsertMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'edge-control', featured: true, featured_order: 0 }),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ interface AdminProduct {
|
||||
image: string
|
||||
description: string
|
||||
in_stock: boolean
|
||||
featured: boolean
|
||||
featured_order: number | null
|
||||
}
|
||||
|
||||
const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories']
|
||||
@@ -24,7 +26,7 @@ const CATEGORY_LABELS: Record<AdminProduct['category'], string> = {
|
||||
}
|
||||
|
||||
function emptyForm(): AdminProduct {
|
||||
return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true }
|
||||
return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true, featured: false, featured_order: null }
|
||||
}
|
||||
|
||||
function AdminProducts() {
|
||||
@@ -44,15 +46,7 @@ function AdminProducts() {
|
||||
}
|
||||
|
||||
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()
|
||||
void loadProducts()
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
@@ -92,7 +86,52 @@ function AdminProducts() {
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(product: AdminProduct) {
|
||||
setError('')
|
||||
if (product.featured) {
|
||||
const { error: upsertError } = await supabase.from('products').upsert({ ...product, featured: false, featured_order: null })
|
||||
if (upsertError) {
|
||||
setError(upsertError.message)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const maxOrder = products.reduce((max, p) => (p.featured && p.featured_order !== null ? Math.max(max, p.featured_order) : max), -1)
|
||||
const { error: upsertError } = await supabase.from('products').upsert({ ...product, featured: true, featured_order: maxOrder + 1 })
|
||||
if (upsertError) {
|
||||
setError(upsertError.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
async function handleMoveFeatured(product: AdminProduct, direction: -1 | 1) {
|
||||
const featured = products
|
||||
.filter((p) => p.featured)
|
||||
.sort((a, b) => (a.featured_order ?? 0) - (b.featured_order ?? 0))
|
||||
const index = featured.findIndex((p) => p.id === product.id)
|
||||
const targetIndex = index + direction
|
||||
if (targetIndex < 0 || targetIndex >= featured.length) return
|
||||
const current = featured[index]
|
||||
const target = featured[targetIndex]
|
||||
setError('')
|
||||
const { error: err1 } = await supabase.from('products').upsert({ ...current, featured_order: target.featured_order })
|
||||
if (err1) {
|
||||
setError(err1.message)
|
||||
return
|
||||
}
|
||||
const { error: err2 } = await supabase.from('products').upsert({ ...target, featured_order: current.featured_order })
|
||||
if (err2) {
|
||||
setError(err2.message)
|
||||
return
|
||||
}
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
const isEditing = Boolean(form.id) && products.some((p) => p.id === form.id)
|
||||
const featuredProducts = products
|
||||
.filter((p) => p.featured)
|
||||
.sort((a, b) => (a.featured_order ?? 0) - (b.featured_order ?? 0))
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -104,6 +143,7 @@ function AdminProducts() {
|
||||
<th>Catégorie</th>
|
||||
<th>Prix</th>
|
||||
<th>En stock</th>
|
||||
<th>Vedette</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -115,8 +155,38 @@ function AdminProducts() {
|
||||
<td>{product.price} $</td>
|
||||
<td>{product.in_stock ? 'Oui' : 'Non'}</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => setForm(product)}>Modifier</button>
|
||||
<button type="button" onClick={() => handleDelete(product.id)}>Supprimer</button>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={product.featured}
|
||||
onChange={() => handleToggleFeatured(product)}
|
||||
/>
|
||||
Vedette
|
||||
</label>
|
||||
{product.featured && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.buttonSecondary}
|
||||
onClick={() => handleMoveFeatured(product, -1)}
|
||||
disabled={featuredProducts.findIndex((p) => p.id === product.id) === 0}
|
||||
>
|
||||
Monter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.buttonSecondary}
|
||||
onClick={() => handleMoveFeatured(product, 1)}
|
||||
disabled={featuredProducts.findIndex((p) => p.id === product.id) === featuredProducts.length - 1}
|
||||
>
|
||||
Descendre
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(product)}>Modifier</button>
|
||||
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(product.id)}>Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -195,7 +265,7 @@ function AdminProducts() {
|
||||
En stock
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<button type="submit" disabled={saving}>Enregistrer le produit</button>
|
||||
<button type="submit" className={styles.button} disabled={saving}>Enregistrer le produit</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user