f7ac9399b2
Add aria-label to buttons (icon-only, pagination, action) and inputs (placeholder-derived) via balanced-brace JSX parser. Files with complex onClick expressions (deep brace nesting) skipped to avoid breakage.
749 lines
30 KiB
TypeScript
749 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useTransition, useEffect, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import Image from "next/image";
|
|
import { useRouter } from "next/navigation";
|
|
import { createProduct } from "@/actions/products/create-product";
|
|
import { updateProduct } from "@/actions/products/update-product";
|
|
import { deleteProduct } from "@/actions/products";
|
|
import { uploadProductImage } from "@/actions/products/upload-image";
|
|
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
|
|
import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, AdminBadge, EmptyState, useToast, Skeleton } from "@/components/admin/design-system";
|
|
import { Package as PackageIconLucide, Inbox as InboxIcon } from "lucide-react";
|
|
|
|
type Product = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
price: number;
|
|
type: string;
|
|
active: boolean;
|
|
image_url?: string | null;
|
|
brand_id: string;
|
|
is_taxable: boolean;
|
|
available_from?: string | null;
|
|
available_until?: string | null;
|
|
};
|
|
|
|
type ViewMode = "table" | "cards";
|
|
|
|
// Icons
|
|
const Icons = {
|
|
plus: (className: string) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 5v14M5 12h14"/>
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = document.createElement("img");
|
|
img.onload = () => {
|
|
let { width, height } = img;
|
|
if (width > maxWidth) {
|
|
height = Math.round(height * (maxWidth / width));
|
|
width = maxWidth;
|
|
}
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d")!;
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
canvas.toBlob((blob) => {
|
|
if (!blob) {
|
|
reject(new Error("Failed to resize image"));
|
|
return;
|
|
}
|
|
blob.arrayBuffer().then((buf) => resolve(buf));
|
|
}, "image/jpeg", 0.85);
|
|
};
|
|
img.onerror = reject;
|
|
img.src = URL.createObjectURL(file);
|
|
});
|
|
}
|
|
|
|
export default function ProductsClient({
|
|
products,
|
|
brandId,
|
|
brands = [],
|
|
isPlatformAdmin = false,
|
|
}: {
|
|
products: Product[];
|
|
brandId?: string;
|
|
brands?: { id: string; name: string }[];
|
|
isPlatformAdmin?: boolean;
|
|
}) {
|
|
const router = useRouter();
|
|
const { success: showSuccess, error: showError } = useToast();
|
|
const [, startTransition] = useTransition();
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
|
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const filtered = products.filter((p) => {
|
|
const matchesSearch =
|
|
!search ||
|
|
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
p.description.toLowerCase().includes(search.toLowerCase());
|
|
const matchesStatus =
|
|
statusFilter === "all" ||
|
|
(statusFilter === "active" && p.active) ||
|
|
(statusFilter === "inactive" && !p.active);
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
const activeCount = products.filter((p) => p.active).length;
|
|
const inactiveCount = products.filter((p) => !p.active).length;
|
|
|
|
const handleUploadImage = useCallback(
|
|
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
|
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
|
if (!validTypes.includes(file.type)) {
|
|
return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
return { success: false, error: "Image must be under 5MB." };
|
|
}
|
|
|
|
const resizedBuffer = await resizeImage(file, 1200);
|
|
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
|
|
|
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
|
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
|
if (result.success) {
|
|
return { success: true, imageUrl: result.imageUrl };
|
|
}
|
|
return { success: false, error: result.error };
|
|
},
|
|
[editingProduct]
|
|
);
|
|
|
|
const openAddModal = useCallback(() => {
|
|
setEditingProduct(null);
|
|
setShowModal(true);
|
|
}, []);
|
|
|
|
const openEditModal = useCallback((product: Product) => {
|
|
setEditingProduct(product);
|
|
setShowModal(true);
|
|
}, []);
|
|
|
|
const closeModal = useCallback(() => {
|
|
setShowModal(false);
|
|
setEditingProduct(null);
|
|
}, []);
|
|
|
|
const handleModalSubmit = useCallback(
|
|
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
|
|
setIsLoading(true);
|
|
try {
|
|
const price = parseFloat(values.price);
|
|
if (isNaN(price) || price < 0) {
|
|
return { success: false, error: "Please enter a valid price" };
|
|
}
|
|
if (!values.name.trim()) {
|
|
return { success: false, error: "Product name is required" };
|
|
}
|
|
|
|
// Platform admins pick a brand in the modal; everyone else is locked.
|
|
const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
|
|
if (!effectiveBrandId) {
|
|
return { success: false, error: "Please choose a brand for this product." };
|
|
}
|
|
|
|
let result;
|
|
if (editingProduct) {
|
|
result = await updateProduct(editingProduct.id, effectiveBrandId, {
|
|
name: values.name.trim(),
|
|
description: values.description.trim(),
|
|
price,
|
|
type: values.type,
|
|
active: values.active,
|
|
image_url: values.image_url,
|
|
is_taxable: values.is_taxable,
|
|
});
|
|
} else {
|
|
result = await createProduct(effectiveBrandId, {
|
|
name: values.name.trim(),
|
|
description: values.description.trim(),
|
|
price,
|
|
type: values.type,
|
|
active: values.active,
|
|
image_url: values.image_url,
|
|
is_taxable: values.is_taxable,
|
|
});
|
|
}
|
|
|
|
if (!result.success) {
|
|
showError("Failed to save product", result.error ?? "Please try again");
|
|
return { success: false, error: result.error };
|
|
}
|
|
|
|
showSuccess(
|
|
editingProduct ? "Product updated" : "Product created",
|
|
`${values.name} has been saved`
|
|
);
|
|
startTransition(() => router.refresh());
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false, error: "Network error. Please try again." };
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
|
|
);
|
|
|
|
const handleDelete = async (productId: string) => {
|
|
setDeletingId(productId);
|
|
const result = await deleteProduct(productId, null);
|
|
setDeletingId(null);
|
|
if (result.success) {
|
|
setDeleteConfirm(null);
|
|
showSuccess("Product deleted", "The product has been removed");
|
|
startTransition(() => router.refresh());
|
|
} else {
|
|
showError("Failed to delete product", result.error ?? "Please try again");
|
|
}
|
|
};
|
|
|
|
// Filter tabs configuration
|
|
const filterTabs = [
|
|
{ value: "all", label: "All", count: products.length },
|
|
{ value: "active", label: "Active", count: activeCount },
|
|
{ value: "inactive", label: "Inactive", count: inactiveCount },
|
|
];
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6">
|
|
{/* Page Header — eyebrow + title + subtitle + primary CTA */}
|
|
<div className="ha-eyebrow mb-2">Operations</div>
|
|
<PageHeader
|
|
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
|
|
title="Products"
|
|
subtitle="Manage your product catalog, pricing, and availability."
|
|
actions={
|
|
<AdminButton
|
|
onClick={openAddModal}
|
|
icon={Icons.plus("h-4 w-4")}
|
|
>
|
|
Add product
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
|
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
|
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
|
|
<p
|
|
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
|
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}
|
|
</p>
|
|
</div>
|
|
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
|
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
|
|
<p
|
|
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
|
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}
|
|
</p>
|
|
</div>
|
|
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
|
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
|
|
<p
|
|
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
|
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
|
{/* Status Tabs */}
|
|
<AdminFilterTabs
|
|
activeTab={statusFilter}
|
|
onTabChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
|
|
tabs={filterTabs}
|
|
size="md"
|
|
/>
|
|
|
|
{/* Search */}
|
|
<AdminSearchInput
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onClear={() => setSearch("")}
|
|
placeholder="Search products..."
|
|
containerClassName="flex-1"
|
|
/>
|
|
|
|
{/* View Toggle */}
|
|
<AdminViewModeTabs
|
|
activeTab={viewMode}
|
|
onTabChange={(value) => setViewMode(value as ViewMode)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{viewMode === "table" ? (
|
|
<TableView
|
|
products={filtered}
|
|
onEdit={openEditModal}
|
|
onDelete={(id) => setDeleteConfirm(id)}
|
|
deleteConfirm={deleteConfirm}
|
|
onDeleteConfirm={(id) => handleDelete(id)}
|
|
onDeleteCancel={() => setDeleteConfirm(null)}
|
|
deletingId={deletingId}
|
|
onAdd={openAddModal}
|
|
/>
|
|
) : (
|
|
<CardView
|
|
products={filtered}
|
|
onEdit={openEditModal}
|
|
onDelete={(id) => setDeleteConfirm(id)}
|
|
deleteConfirm={deleteConfirm}
|
|
onDeleteConfirm={(id) => handleDelete(id)}
|
|
onDeleteCancel={() => setDeleteConfirm(null)}
|
|
deletingId={deletingId}
|
|
onAdd={openAddModal}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal — Atelier des Récoltes (editorial product form) */}
|
|
<ProductFormModal
|
|
key={editingProduct?.id ?? "new"}
|
|
open={showModal}
|
|
mode={editingProduct ? "edit" : "add"}
|
|
onClose={closeModal}
|
|
onSubmit={handleModalSubmit}
|
|
onUploadImage={handleUploadImage}
|
|
brands={brands}
|
|
lockBrand={!isPlatformAdmin}
|
|
lockedBrandId={brandId ?? ""}
|
|
initial={
|
|
editingProduct
|
|
? {
|
|
name: editingProduct.name,
|
|
description: editingProduct.description || "",
|
|
price: String(editingProduct.price),
|
|
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
|
|
brand_id: editingProduct.brand_id,
|
|
active: editingProduct.active,
|
|
is_taxable: editingProduct.is_taxable,
|
|
available_from: editingProduct.available_from ?? null,
|
|
available_until: editingProduct.available_until ?? null,
|
|
}
|
|
: undefined
|
|
}
|
|
initialImageUrl={editingProduct?.image_url ?? null}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ── Table View ─────────────────────────────────────────────────────────────────
|
|
|
|
function TableView({
|
|
products,
|
|
onEdit,
|
|
onDelete,
|
|
deleteConfirm,
|
|
onDeleteConfirm,
|
|
onDeleteCancel,
|
|
deletingId,
|
|
onAdd,
|
|
}: {
|
|
products: Product[];
|
|
onEdit: (p: Product) => void;
|
|
onDelete: (id: string) => void;
|
|
deleteConfirm: string | null;
|
|
onDeleteConfirm: (id: string) => void;
|
|
onDeleteCancel: () => void;
|
|
deletingId: string | null;
|
|
onAdd: () => void;
|
|
}) {
|
|
// Track the position of the open row's three-dots button so the popup can be
|
|
// rendered via portal at body level (escapes any overflow:hidden ancestors
|
|
// and any table-row stacking-context quirks).
|
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
const popupDialogRef = useRef<HTMLDialogElement>(null);
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
setMounted(true);
|
|
};
|
|
init();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!deleteConfirm) return;
|
|
const dialog = popupDialogRef.current;
|
|
if (!dialog) return;
|
|
if (!dialog.open) dialog.showModal();
|
|
const onCancel = (e: Event) => {
|
|
e.preventDefault();
|
|
onDeleteCancel();
|
|
};
|
|
dialog.addEventListener("cancel", onCancel);
|
|
return () => {
|
|
dialog.removeEventListener("cancel", onCancel);
|
|
};
|
|
}, [deleteConfirm, onDeleteCancel]);
|
|
|
|
// Track the previous deleteConfirm value so we can adjust menuPos
|
|
// inline during render when the prop changes — avoids a stale frame
|
|
// between the prop change and the effect running. Use a lazy
|
|
// initializer to avoid the "Prop derived into useState" lint.
|
|
const [prevDeleteConfirm, setPrevDeleteConfirm] = useState<string | null>(() => deleteConfirm);
|
|
if (deleteConfirm !== prevDeleteConfirm) {
|
|
setPrevDeleteConfirm(deleteConfirm);
|
|
if (!deleteConfirm) {
|
|
setMenuPos(null);
|
|
} else {
|
|
const btn = buttonRefs.current[deleteConfirm];
|
|
if (btn) {
|
|
const rect = btn.getBoundingClientRect();
|
|
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reposition on scroll/resize so the popup stays anchored to its button.
|
|
useEffect(() => {
|
|
if (!deleteConfirm) return;
|
|
const updatePos = () => {
|
|
const btn = buttonRefs.current[deleteConfirm];
|
|
if (btn) {
|
|
const rect = btn.getBoundingClientRect();
|
|
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
|
}
|
|
};
|
|
window.addEventListener("scroll", updatePos, true);
|
|
window.addEventListener("resize", updatePos);
|
|
return () => {
|
|
window.removeEventListener("scroll", updatePos, true);
|
|
window.removeEventListener("resize", updatePos);
|
|
};
|
|
}, [deleteConfirm]);
|
|
|
|
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
|
|
|
|
return (
|
|
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[var(--admin-bg)]">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Product</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Type</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Price</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Status</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--admin-border)]">
|
|
{products.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-8">
|
|
<EmptyState
|
|
icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
|
|
title="No products found"
|
|
description="Try adjusting your filters, or add your first product to get started."
|
|
action={{ label: "Add your first product", onClick: onAdd }}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
products.map((product) => (
|
|
<tr key={product.id} className="hover:bg-[var(--admin-primary-soft)] transition-colors">
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
{product.image_url ? (
|
|
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg border border-[var(--admin-border)]">
|
|
<Image
|
|
src={product.image_url}
|
|
alt={product.name}
|
|
fill
|
|
sizes="40px"
|
|
style={{ objectFit: "cover" }}
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="h-10 w-10 rounded-lg bg-[var(--admin-bg-subtle)] flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
|
|
<span className="text-[var(--admin-text-muted)]">□</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<button type="button"
|
|
onClick={() => onEdit(product)}
|
|
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)] transition-colors text-left"
|
|
>
|
|
{product.name}
|
|
</button>
|
|
<div className="text-xs text-[var(--admin-text-muted)] line-clamp-1">
|
|
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-4 py-3">
|
|
<span className="rounded-md bg-[var(--admin-bg-subtle)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[var(--admin-text-secondary)]">
|
|
{product.type}
|
|
</span>
|
|
</td>
|
|
|
|
<td
|
|
className="px-4 py-3 text-sm font-semibold text-[var(--admin-text-primary)]"
|
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
${Number(product.price).toFixed(2)}
|
|
</td>
|
|
|
|
<td className="px-4 py-3">
|
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
|
{product.active ? "Active" : "Inactive"}
|
|
</AdminBadge>
|
|
</td>
|
|
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="relative inline-flex items-center justify-end gap-1">
|
|
<AdminButton
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onEdit(product)}
|
|
>
|
|
Edit
|
|
</AdminButton>
|
|
<button type="button"
|
|
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
|
onClick={() => onDelete(product.id)}
|
|
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)] transition-colors"
|
|
aria-label="Product actions"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* Delete confirmation popup — rendered via portal at body level with
|
|
position: fixed so it sits above all table rows and escapes the
|
|
overflow-visible container. */}
|
|
{mounted && deleteConfirm && openProduct && menuPos &&
|
|
createPortal(
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-[60]"
|
|
/>
|
|
<dialog
|
|
ref={popupDialogRef}
|
|
aria-label="Confirm delete"
|
|
className="fixed z-[70] m-0 p-4 max-w-none max-h-none w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl"
|
|
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
|
>
|
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
|
Delete "{openProduct.name}"?
|
|
</p>
|
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
|
This will remove the product. If attached to orders, it will be hidden.
|
|
</p>
|
|
<div className="mt-3 flex gap-2">
|
|
<button type="button"
|
|
onClick={onDeleteCancel}
|
|
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
|
|
aria-label="Cancel">
|
|
Cancel
|
|
</button>
|
|
<AdminButton
|
|
onClick={() => onDeleteConfirm(openProduct.id)}
|
|
disabled={deletingId === openProduct.id}
|
|
variant="danger"
|
|
size="sm"
|
|
className="flex-1"
|
|
>
|
|
{deletingId === openProduct.id ? "..." : "Delete"}
|
|
</AdminButton>
|
|
</div>
|
|
</dialog>
|
|
</>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Card View ──────────────────────────────────────────────────────────────────
|
|
|
|
function CardView({
|
|
products,
|
|
onEdit,
|
|
onDelete,
|
|
deleteConfirm,
|
|
onDeleteConfirm,
|
|
onDeleteCancel,
|
|
deletingId,
|
|
onAdd,
|
|
}: {
|
|
products: Product[];
|
|
onEdit: (p: Product) => void;
|
|
onDelete: (id: string) => void;
|
|
deleteConfirm: string | null;
|
|
onDeleteConfirm: (id: string) => void;
|
|
onDeleteCancel: () => void;
|
|
deletingId: string | null;
|
|
onAdd: () => void;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{products.length === 0 ? (
|
|
<div className="col-span-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
|
|
<EmptyState
|
|
icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
|
|
title="No products found"
|
|
description="Try adjusting your filters, or add your first product to get started."
|
|
action={{ label: "Add your first product", onClick: onAdd }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
products.map((product) => (
|
|
<div
|
|
key={product.id}
|
|
className="group relative rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] hover:shadow-md hover:border-[var(--admin-primary)]/40 transition-all overflow-hidden"
|
|
>
|
|
{/* Image */}
|
|
<div className="relative h-40 bg-[var(--admin-bg-subtle)]">
|
|
{product.image_url ? (
|
|
<Image
|
|
src={product.image_url}
|
|
alt={product.name}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
style={{ objectFit: "cover" }}
|
|
className="transition-transform group-hover:scale-105"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<span className="text-4xl text-[var(--admin-text-muted)]">□</span>
|
|
</div>
|
|
)}
|
|
{/* Status badge */}
|
|
<div className="absolute top-3 right-3">
|
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
|
{product.active ? "Active" : "Inactive"}
|
|
</AdminBadge>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4">
|
|
<button type="button"
|
|
onClick={() => onEdit(product)}
|
|
className="block w-full text-left"
|
|
>
|
|
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-primary)] transition-colors line-clamp-1">
|
|
{product.name}
|
|
</h3>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mt-1 line-clamp-2 h-8">
|
|
{product.description || <span className="italic">No description</span>}
|
|
</p>
|
|
</button>
|
|
|
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-[var(--admin-border-light)]">
|
|
<div>
|
|
<span className="text-xs text-[var(--admin-text-muted)]">Price</span>
|
|
<p
|
|
className="text-lg font-bold text-[var(--admin-text-primary)]"
|
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
${Number(product.price).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="rounded-md bg-[var(--admin-bg-subtle)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[var(--admin-text-secondary)]">
|
|
{product.type}
|
|
</span>
|
|
{product.is_taxable && (
|
|
<span className="rounded-md bg-[var(--admin-accent-soft)] px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)] border border-[var(--admin-accent)]">
|
|
Tax
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<AdminButton
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => onEdit(product)}
|
|
className="flex-1"
|
|
>
|
|
Edit
|
|
</AdminButton>
|
|
<button type="button"
|
|
onClick={() => onDelete(product.id)}
|
|
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-[var(--admin-danger-soft)] hover:text-[var(--admin-danger)] hover:border-[var(--admin-danger)] transition-colors"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete confirm */}
|
|
{deleteConfirm === product.id && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm" style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 50%, transparent)" }}>
|
|
<div className="bg-[var(--admin-card-bg)] rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
|
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
|
Delete "{product.name}"?
|
|
</p>
|
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
|
This will remove the product. If attached to orders, it will be hidden.
|
|
</p>
|
|
<div className="flex gap-2 mt-3">
|
|
<button type="button"
|
|
onClick={onDeleteCancel}
|
|
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
|
|
aria-label="Cancel">
|
|
Cancel
|
|
</button>
|
|
<button type="button"
|
|
onClick={() => onDeleteConfirm(product.id)}
|
|
disabled={deletingId === product.id}
|
|
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-[var(--admin-danger-hover)]"
|
|
>
|
|
{deletingId === product.id ? "..." : "Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
} |