Files
route-commerce/src/components/admin/ProductsClient.tsx
T
Nora f7ac9399b2 fix: react-doctor control-has-associated-label 137→? (auto aria-labels)
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.
2026-06-26 04:08:56 -06:00

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 &quot;{openProduct.name}&quot;?
</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 &quot;{product.name}&quot;?
</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>
);
}