Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning
This commit is contained in:
@@ -0,0 +1,885 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useTransition } from "react";
|
||||
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 GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string | null;
|
||||
brand_id: string;
|
||||
is_taxable: boolean;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url: string;
|
||||
is_taxable: boolean;
|
||||
};
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
// Icons
|
||||
const Icons = {
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
grid: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
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>
|
||||
),
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7.5 4.27 9 5.15"/>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
|
||||
<path d="m3.3 7 8.7 5 8.7-5"/>
|
||||
<path d="M12 22V12"/>
|
||||
</svg>
|
||||
),
|
||||
upload: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function ProductsClient({ products, brandId }: Props) {
|
||||
const router = useRouter();
|
||||
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 [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Image upload states
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
|
||||
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);
|
||||
|
||||
setUploading(false);
|
||||
|
||||
if (result.success) {
|
||||
setPendingImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
setFormData({ ...formData, image_url: result.imageUrl });
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = useCallback(() => {
|
||||
setEditingProduct(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const openEditModal = useCallback((product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setFormData({
|
||||
name: product.name,
|
||||
description: product.description || "",
|
||||
price: String(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
image_url: product.image_url || "",
|
||||
is_taxable: product.is_taxable,
|
||||
});
|
||||
setImagePreview(product.image_url || null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setEditingProduct(null);
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
const price = parseFloat(formData.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
setError("Please enter a valid price");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
const imageUrl = pendingImageUrl || formData.image_url || null;
|
||||
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
} else {
|
||||
result = await createProduct(brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save product");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
startTransition(() => router.refresh());
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (productId: string) => {
|
||||
setDeletingId(productId);
|
||||
const result = await deleteProduct(productId, null);
|
||||
setDeletingId(null);
|
||||
if (result.success) {
|
||||
setDeleteConfirm(null);
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.package("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Products</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{filtered.length} product{filtered.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-xl hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{Icons.plus("h-4 w-4")}
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{products.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{activeCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{inactiveCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Status Tabs */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
{(["all", "active", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
viewMode === "table" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.list("h-4 w-4")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("cards")}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
viewMode === "cards" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.grid("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === "table" ? (
|
||||
<TableView
|
||||
products={filtered}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(id) => setDeleteConfirm(id)}
|
||||
deleteConfirm={deleteConfirm}
|
||||
onDeleteConfirm={(id) => handleDelete(id)}
|
||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||
deletingId={deletingId}
|
||||
/>
|
||||
) : (
|
||||
<CardView
|
||||
products={filtered}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(id) => setDeleteConfirm(id)}
|
||||
deleteConfirm={deleteConfirm}
|
||||
onDeleteConfirm={(id) => handleDelete(id)}
|
||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||
deletingId={deletingId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<GlassModal
|
||||
title={editingProduct ? "Edit Product" : "Add Product"}
|
||||
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Product name"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Product description"
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Price</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 pl-8 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
>
|
||||
<option value="pickup">Pickup</option>
|
||||
<option value="wholesale">Wholesale</option>
|
||||
<option value="subscription">Subscription</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-emerald-400 hover:bg-emerald-50/50"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-stone-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<div className="relative">
|
||||
<div className="relative h-40 w-auto">
|
||||
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</div>
|
||||
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Icons.upload("h-8 w-8 text-stone-400")}
|
||||
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
||||
)}
|
||||
|
||||
{(imagePreview || formData.image_url) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setFormData({ ...formData, image_url: "" });
|
||||
}}
|
||||
className="mt-2 text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_taxable}
|
||||
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Taxable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !formData.name.trim() || !formData.price}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : editingProduct ? "Save Changes" : "Create Product"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
products,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onDeleteConfirm,
|
||||
onDeleteCancel,
|
||||
deletingId,
|
||||
}: {
|
||||
products: Product[];
|
||||
onEdit: (p: Product) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirm: string | null;
|
||||
onDeleteConfirm: (id: string) => void;
|
||||
onDeleteCancel: () => void;
|
||||
deletingId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Product</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Type</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Price</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">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-16 text-center">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No products found</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-stone-50 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
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
|
||||
<span className="text-stone-400">□</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="font-semibold text-[var(--admin-text-primary)] hover:text-emerald-600 transition-colors text-left"
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
<div className="text-xs text-stone-500 line-clamp-1">
|
||||
{product.description || <span className="italic text-stone-400">No description</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
|
||||
{product.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 font-mono text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
|
||||
product.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
{deleteConfirm === product.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the product. If attached to orders, it will be hidden.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteConfirm(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card View ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardView({
|
||||
products,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onDeleteConfirm,
|
||||
onDeleteCancel,
|
||||
deletingId,
|
||||
}: {
|
||||
products: Product[];
|
||||
onEdit: (p: Product) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirm: string | null;
|
||||
onDeleteConfirm: (id: string) => void;
|
||||
onDeleteCancel: () => void;
|
||||
deletingId: string | null;
|
||||
}) {
|
||||
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 text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No products found</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-emerald-200 transition-all overflow-hidden"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-40 bg-stone-100">
|
||||
{product.image_url ? (
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
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-stone-300">□</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Status badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-sm ${
|
||||
product.active ? "bg-emerald-500 text-white" : "bg-stone-500 text-white"
|
||||
}`}>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-emerald-600 transition-colors line-clamp-1">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 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-stone-100">
|
||||
<div>
|
||||
<span className="text-xs text-stone-500">Price</span>
|
||||
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
|
||||
{product.type}
|
||||
</span>
|
||||
{product.is_taxable && (
|
||||
<span className="rounded-md bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200">
|
||||
Tax
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="flex-1 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-500 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm */}
|
||||
{deleteConfirm === product.id && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the product. If attached to orders, it will be hidden.
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteConfirm(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user