chore: add client/src/pages/admin/products.tsx
This commit is contained in:
parent
dfba4f08fb
commit
5ad1e8a6df
1 changed files with 220 additions and 0 deletions
220
client/src/pages/admin/products.tsx
Normal file
220
client/src/pages/admin/products.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { queryClient, apiRequest } from "../../lib/queryClient";
|
||||||
|
import { AdminNav } from "./dashboard";
|
||||||
|
import { toast } from "../../hooks/use-toast";
|
||||||
|
import { Plus, Pencil, Trash2, Search, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
handle: string;
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
status: string;
|
||||||
|
vendor?: string;
|
||||||
|
images?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductForm {
|
||||||
|
title: string;
|
||||||
|
handle: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
compareAtPrice: string;
|
||||||
|
vendor: string;
|
||||||
|
status: string;
|
||||||
|
tags: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ProductForm = { title: "", handle: "", description: "", price: "", compareAtPrice: "", vendor: "", status: "active", tags: "" };
|
||||||
|
|
||||||
|
function slugify(s: string) {
|
||||||
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductModal({ product, onClose }: { product?: Product; onClose: () => void }) {
|
||||||
|
const isEdit = !!product;
|
||||||
|
const [form, setForm] = useState<ProductForm>(product ? {
|
||||||
|
title: product.title,
|
||||||
|
handle: product.handle,
|
||||||
|
description: "",
|
||||||
|
price: product.price,
|
||||||
|
compareAtPrice: "",
|
||||||
|
vendor: product.vendor || "",
|
||||||
|
status: product.status,
|
||||||
|
tags: "",
|
||||||
|
} : DEFAULT_FORM);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: object) => isEdit
|
||||||
|
? apiRequest("PATCH", `/api/admin/products/${product!.id}`, body)
|
||||||
|
: apiRequest("POST", "/api/admin/products", body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/admin/products"] });
|
||||||
|
toast({ title: isEdit ? "Product updated" : "Product created" });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate({
|
||||||
|
...form,
|
||||||
|
handle: form.handle || slugify(form.title),
|
||||||
|
tags: form.tags ? form.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = (label: string, key: keyof ProductForm, type = "text", placeholder = "") => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">{label}</label>
|
||||||
|
<input type={type} value={form[key]} onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
data-testid={`input-product-${key}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-card border border-border rounded-xl w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||||
|
<h2 className="font-semibold">{isEdit ? "Edit product" : "New product"}</h2>
|
||||||
|
<button onClick={onClose}><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||||
|
{field("Title", "title")}
|
||||||
|
{field("Handle (URL slug)", "handle", "text", "auto-generated from title")}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Description</label>
|
||||||
|
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{field("Price ($)", "price", "text", "0.00")}
|
||||||
|
{field("Compare at price ($)", "compareAtPrice", "text", "optional")}
|
||||||
|
</div>
|
||||||
|
{field("Vendor / Brand", "vendor")}
|
||||||
|
{field("Tags (comma-separated)", "tags", "text", "e.g. supplements, vitamins")}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">Status</label>
|
||||||
|
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none">
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors">Cancel</button>
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="flex-1 bg-primary text-primary-foreground py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-60">
|
||||||
|
{mutation.isPending ? "Saving..." : (isEdit ? "Update" : "Create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminProducts() {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [modal, setModal] = useState<"new" | Product | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ products: Product[]; total: number }>({
|
||||||
|
queryKey: ["/api/admin/products", { q: search, page }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: "20" });
|
||||||
|
if (search) params.set("q", search);
|
||||||
|
const r = await fetch(`/api/admin/products?${params}`, { credentials: "include" });
|
||||||
|
if (r.status === 401) { window.location.href = "/admin/login"; return { products: [], total: 0 }; }
|
||||||
|
return r.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiRequest("DELETE", `/api/admin/products/${id}`),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/admin/products"] }); toast({ title: "Product deleted" }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden" data-testid="admin-products">
|
||||||
|
<AdminNav active="/admin/products" />
|
||||||
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Products</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); setSearch(q); setPage(1); }} className="flex gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search products..."
|
||||||
|
className="pl-8 pr-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none w-52" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<button onClick={() => setModal("new")}
|
||||||
|
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
data-testid="button-new-product">
|
||||||
|
<Plus size={14} /> New product
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Product</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Price</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i}><td colSpan={4} className="px-4 py-3"><div className="h-4 bg-muted rounded animate-pulse" /></td></tr>
|
||||||
|
))
|
||||||
|
) : data?.products.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-muted/20 transition-colors" data-testid={`product-row-${p.id}`}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{p.images?.[0] && <img src={p.images[0]} alt="" className="w-8 h-8 rounded border border-border object-cover flex-shrink-0" />}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{p.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">{p.handle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${p.status === "active" ? "bg-green-100 text-green-700" : "bg-muted text-muted-foreground"}`}>{p.status}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm font-medium">${parseFloat(p.price).toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button onClick={() => setModal(p)} className="p-1.5 text-muted-foreground hover:text-foreground transition-colors" data-testid={`edit-product-${p.id}`}><Pencil size={14} /></button>
|
||||||
|
<button onClick={() => { if (confirm("Delete this product?")) deleteMutation.mutate(p.id); }}
|
||||||
|
className="p-1.5 text-muted-foreground hover:text-destructive transition-colors" data-testid={`delete-product-${p.id}`}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && !data?.products.length && (
|
||||||
|
<tr><td colSpan={4} className="text-center py-12 text-muted-foreground">No products yet. <button onClick={() => setModal("new")} className="text-primary hover:underline">Add one</button></td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ProductModal product={modal === "new" ? undefined : modal} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue