feat: add featured toggle/reorder to AdminProducts; apply shared admin design

This commit is contained in:
belviskhoremk
2026-08-13 09:32:14 +02:00
parent 194144fa79
commit b72acd8a26
3 changed files with 112 additions and 43 deletions

View File

@@ -1,45 +1,28 @@
.page { .page {
display: flex; composes: page from './adminShared.module.css';
flex-direction: column;
gap: var(--space-lg);
} }
.table { .table {
width: 100%; composes: table from './adminShared.module.css';
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 { .form {
display: flex; composes: form from './adminShared.module.css';
flex-direction: column;
gap: var(--space-sm);
max-width: 420px;
background: white;
padding: var(--space-md);
} }
.form label { .button {
display: flex; composes: button from './adminShared.module.css';
flex-direction: column;
gap: 0.25rem;
color: var(--color-charcoal);
} }
.form input, .buttonSecondary {
.form select, composes: buttonSecondary from './adminShared.module.css';
.form textarea { margin-right: var(--admin-space-xs);
padding: var(--space-sm); }
border: 1px solid var(--color-gold);
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
} }
.error { .error {
color: #a33; composes: error from './adminShared.module.css';
} }

View File

@@ -25,6 +25,8 @@ const EXISTING_PRODUCT = {
image: 'products/edge-control.jpg', image: 'products/edge-control.jpg',
description: 'Hold with shine.', description: 'Hold with shine.',
in_stock: true, in_stock: true,
featured: false,
featured_order: null,
} }
import AdminProducts from './AdminProducts' import AdminProducts from './AdminProducts'
@@ -80,4 +82,18 @@ describe('AdminProducts', () => {
expect(deleteMock).toHaveBeenCalled() expect(deleteMock).toHaveBeenCalled()
expect(eqMock).toHaveBeenCalledWith('id', 'edge-control') 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 }),
),
)
})
}) })

View File

@@ -12,6 +12,8 @@ interface AdminProduct {
image: string image: string
description: string description: string
in_stock: boolean in_stock: boolean
featured: boolean
featured_order: number | null
} }
const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories'] const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories']
@@ -24,7 +26,7 @@ const CATEGORY_LABELS: Record<AdminProduct['category'], string> = {
} }
function emptyForm(): AdminProduct { 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() { function AdminProducts() {
@@ -44,15 +46,7 @@ function AdminProducts() {
} }
useEffect(() => { useEffect(() => {
async function load() { void loadProducts()
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) { async function handleSubmit(event: FormEvent) {
@@ -92,7 +86,52 @@ function AdminProducts() {
await loadProducts() 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 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 ( return (
<div className={styles.page}> <div className={styles.page}>
@@ -104,6 +143,7 @@ function AdminProducts() {
<th>Catégorie</th> <th>Catégorie</th>
<th>Prix</th> <th>Prix</th>
<th>En stock</th> <th>En stock</th>
<th>Vedette</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -115,8 +155,38 @@ function AdminProducts() {
<td>{product.price} $</td> <td>{product.price} $</td>
<td>{product.in_stock ? 'Oui' : 'Non'}</td> <td>{product.in_stock ? 'Oui' : 'Non'}</td>
<td> <td>
<button type="button" onClick={() => setForm(product)}>Modifier</button> <label>
<button type="button" onClick={() => handleDelete(product.id)}>Supprimer</button> <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> </td>
</tr> </tr>
))} ))}
@@ -195,7 +265,7 @@ function AdminProducts() {
En stock En stock
</label> </label>
{error && <p className={styles.error}>{error}</p>} {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> </form>
</div> </div>
) )