feat(admin): apply new design system to Products pages
- List page: new PageHeader with 'OPERATIONS' eyebrow + 'Add product' CTA - New/Edit pages: PageHeader with eyebrow, icon, status badge (edit) - ProductsClient: EmptyState for zero products, AdminBadge for status, Fragment Mono on stats, token-based colors throughout - ProductTableBody: AdminBadge tones for status/taxable, token-based hover/selected states, Fragment Mono on price column - NewProductForm / ProductEditForm / ProductFormModal: surrounding chrome on tokens; save bars use .ha-btn-primary / .ha-btn-ghost - .atelier-* body preserved as editorial treatment
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
|
||||||
|
import { Package as PackageIcon } from "lucide-react";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -51,15 +53,15 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
|||||||
|
|
||||||
if (error || !product) {
|
if (error || !product) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<h1 className="text-3xl font-bold text-red-600">Product not found</h1>
|
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
|
||||||
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
|
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
||||||
{error?.message ?? "Product not found"}
|
{error?.message ?? "Product not found"}
|
||||||
</pre>
|
</pre>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/products"
|
href="/admin/products"
|
||||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
||||||
>
|
>
|
||||||
← Back to Products
|
← Back to Products
|
||||||
</Link>
|
</Link>
|
||||||
@@ -69,58 +71,66 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<Link
|
<div className="ha-eyebrow mb-2">Operations</div>
|
||||||
href="/admin/products"
|
<PageHeader
|
||||||
className="text-sm text-stone-500 hover:text-stone-700"
|
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
||||||
>
|
title="Edit product"
|
||||||
← Back to Products
|
subtitle="Update product details, pricing, and availability."
|
||||||
</Link>
|
actions={
|
||||||
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||||
|
{product.active ? "Active" : "Inactive"}
|
||||||
|
</AdminBadge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href="/admin/products"
|
||||||
|
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Products
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||||
{product.brands?.name}
|
{product.brands?.name}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-lg text-stone-600">
|
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
|
||||||
{product.description}
|
{product.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
|
||||||
product.active
|
|
||||||
? "bg-emerald-100 text-emerald-600"
|
|
||||||
: "bg-stone-200 text-stone-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{product.active ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-500">Price</p>
|
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
|
||||||
<p className="mt-1 text-2xl font-bold text-stone-950">
|
<p
|
||||||
|
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
|
||||||
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
${Number(product.price).toFixed(2)}
|
${Number(product.price).toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-500">Type</p>
|
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
|
||||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||||
{product.type}
|
{product.type}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||||
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2>
|
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
|
||||||
<p className="mt-1 text-stone-500">
|
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||||
Update product details, pricing, and availability.
|
Update product details, pricing, and availability.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import NewProductForm from "@/components/admin/NewProductForm";
|
import NewProductForm from "@/components/admin/NewProductForm";
|
||||||
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
|
import { Package as PackageIcon } from "lucide-react";
|
||||||
import { getBrands } from "@/actions/admin/users";
|
import { getBrands } from "@/actions/admin/users";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -19,28 +21,25 @@ export default async function NewProductPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<div className="mb-8">
|
<div className="ha-eyebrow mb-2">Operations</div>
|
||||||
|
<PageHeader
|
||||||
|
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
||||||
|
title="New product"
|
||||||
|
subtitle="Add a product to your catalog with pricing, type, and availability."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/products"
|
href="/admin/products"
|
||||||
className="text-sm text-stone-500 hover:text-stone-700"
|
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
← Back to Products
|
← Back to Products
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||||
<h1 className="text-3xl font-bold text-stone-950">
|
|
||||||
Create Product
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 text-stone-500">
|
|
||||||
{isPlatformAdmin
|
|
||||||
? "Add a new product to any brand you administer."
|
|
||||||
: "Add a new product to your brand's catalog."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<NewProductForm
|
<NewProductForm
|
||||||
defaultBrandId={adminUser.brand_id ?? ""}
|
defaultBrandId={adminUser.brand_id ?? ""}
|
||||||
brands={brands}
|
brands={brands}
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|||||||
import ProductsClient from "@/components/admin/ProductsClient";
|
import ProductsClient from "@/components/admin/ProductsClient";
|
||||||
import { getBrands } from "@/actions/admin/users";
|
import { getBrands } from "@/actions/admin/users";
|
||||||
|
|
||||||
// Icon for page header
|
|
||||||
const PackageIcon = () => (
|
|
||||||
<svg className="h-6 w-6" 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Shape ProductsClient expects (legacy columns mapped from the new schema).
|
// Shape ProductsClient expects (legacy columns mapped from the new schema).
|
||||||
type ProductRow = {
|
type ProductRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,8 +31,8 @@ export default async function AdminProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Access Denied</h1>
|
||||||
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p>
|
<p className="mt-2 text-sm text-[var(--admin-text-muted)]">You do not have permission to manage products.</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -133,8 +123,8 @@ export default async function AdminProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Error loading products</h1>
|
||||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
|
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
||||||
{queryError}
|
{queryError}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
|
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-[var(--admin-danger)] text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -182,7 +182,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
<AdminInput label="Brand" required>
|
<AdminInput label="Brand" required>
|
||||||
{lockBrand ? (
|
{lockBrand ? (
|
||||||
// brand_admin / store_employee — brand is fixed by their admin_users record
|
// brand_admin / store_employee — brand is fixed by their admin_users record
|
||||||
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
|
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
|
||||||
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
|
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -197,7 +197,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!lockBrand && brands.length === 0 && (
|
{!lockBrand && brands.length === 0 && (
|
||||||
<p className="mt-1 text-xs text-stone-500">
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
Loading available brands — if this persists, check the admin Brands settings.
|
Loading available brands — if this persists, check the admin Brands settings.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -237,8 +237,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
</AdminInput>
|
</AdminInput>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
|
<label className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
|
||||||
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
@@ -250,28 +250,28 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
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-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
|
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
|
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
|
||||||
<span className="text-sm text-zinc-500">Uploading...</span>
|
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
|
||||||
</>
|
</>
|
||||||
) : imagePreview ? (
|
) : imagePreview ? (
|
||||||
<>
|
<>
|
||||||
<span className="relative inline-block h-32 w-auto">
|
<span className="relative inline-block h-32 w-auto">
|
||||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-zinc-500">Click or drop to replace</span>
|
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
|
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@@ -286,31 +286,32 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
|
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||||
|
|
||||||
{imagePreview && (
|
{imagePreview && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
|
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
|
||||||
className="mt-2 text-xs text-red-500 hover:underline"
|
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
|
||||||
>
|
>
|
||||||
Remove image
|
Remove image
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{/* Save button bar — new design tokens */}
|
||||||
|
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
|
className="ha-btn-primary"
|
||||||
>
|
>
|
||||||
{loading ? "Creating..." : "Create Product"}
|
{loading ? "Creating..." : "Create Product"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin/products"
|
href="/admin/products"
|
||||||
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
|
className="ha-btn-ghost"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
|
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
|
||||||
@@ -147,13 +148,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-sm text-[var(--admin-danger)]">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saved && (
|
{saved && (
|
||||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
<div className="rounded-xl bg-[var(--admin-primary-soft)] border border-[var(--admin-primary)] p-4 text-sm text-[var(--admin-primary)]">
|
||||||
Product updated successfully.
|
Product updated successfully.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -205,13 +206,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
</AdminInput>
|
</AdminInput>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
|
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Status</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActive((v) => !v)}
|
onClick={() => setActive((v) => !v)}
|
||||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-green-900/40 text-green-400"
|
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
|
||||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
: "bg-[var(--admin-bg)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{active ? "Active" : "Inactive"}
|
{active ? "Active" : "Inactive"}
|
||||||
@@ -219,19 +220,19 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Taxable</label>
|
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Taxable</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIs_taxable((v) => !v)}
|
onClick={() => setIs_taxable((v) => !v)}
|
||||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
|
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
|
||||||
is_taxable
|
is_taxable
|
||||||
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
|
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
|
||||||
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200"
|
: "bg-[var(--admin-accent-soft)] text-[var(--admin-accent)] ring-1 ring-[var(--admin-accent)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
|
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
|
||||||
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
|
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand's nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
|
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">Tax is calculated at checkout for shipping orders in your brand's nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminInput
|
<AdminInput
|
||||||
@@ -249,8 +250,8 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
</AdminInput>
|
</AdminInput>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
|
<label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
|
||||||
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
@@ -262,28 +263,28 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||||
${dragOver ? "border-green-500 bg-green-900/30" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
|
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
|
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
|
||||||
<span className="text-sm text-zinc-500">Uploading...</span>
|
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
|
||||||
</>
|
</>
|
||||||
) : imagePreview ? (
|
) : imagePreview ? (
|
||||||
<>
|
<>
|
||||||
<span className="relative inline-block h-32 w-auto">
|
<span className="relative inline-block h-32 w-auto">
|
||||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-zinc-500">Click or drop to replace</span>
|
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
|
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@@ -298,26 +299,35 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
|
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||||
|
|
||||||
{imagePreview && (
|
{imagePreview && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRemoveImage}
|
onClick={handleRemoveImage}
|
||||||
className="mt-2 text-xs text-red-500 hover:underline"
|
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
|
||||||
>
|
>
|
||||||
Remove image
|
Remove image
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Save button bar — new design tokens */}
|
||||||
onClick={handleSave}
|
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||||
disabled={saving}
|
<button
|
||||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
onClick={handleSave}
|
||||||
>
|
disabled={saving}
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
className="ha-btn-primary"
|
||||||
</button>
|
>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/admin/products"
|
||||||
|
className="ha-btn-ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export default function ProductFormModal({
|
|||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgba(28, 25, 23, 0.55)",
|
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
}}
|
}}
|
||||||
@@ -256,7 +256,8 @@ export default function ProductFormModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
||||||
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60"
|
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]"
|
||||||
|
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
|
||||||
>
|
>
|
||||||
{/* grain overlay */}
|
{/* grain overlay */}
|
||||||
<div className="atelier-grain" aria-hidden />
|
<div className="atelier-grain" aria-hidden />
|
||||||
@@ -357,7 +358,7 @@ export default function ProductFormModal({
|
|||||||
|
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" />
|
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[var(--admin-primary)] animate-spin" />
|
||||||
<p className="atelier-drop-title">Pouring into the cellar…</p>
|
<p className="atelier-drop-title">Pouring into the cellar…</p>
|
||||||
</div>
|
</div>
|
||||||
) : imagePreview ? (
|
) : imagePreview ? (
|
||||||
@@ -390,7 +391,7 @@ export default function ProductFormModal({
|
|||||||
<>
|
<>
|
||||||
{/* ornamental drop zone content */}
|
{/* ornamental drop zone content */}
|
||||||
<svg
|
<svg
|
||||||
className="h-10 w-10 text-[#224E2F]/70"
|
className="h-10 w-10 text-[var(--admin-primary)]/70"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -425,7 +426,7 @@ export default function ProductFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}>
|
<p className="mt-3 atelier-hint" style={{ color: "var(--admin-danger)" }}>
|
||||||
{uploadError}
|
{uploadError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -668,7 +669,7 @@ export default function ProductFormModal({
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* FOOTER */}
|
{/* FOOTER */}
|
||||||
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-stone-200/60 bg-[#FAF7F0]/80 backdrop-blur-sm">
|
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-[var(--admin-border)] bg-[var(--admin-bg)]/80 backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -685,9 +686,10 @@ export default function ProductFormModal({
|
|||||||
className={[
|
className={[
|
||||||
"h-1.5 w-1.5 rounded-full",
|
"h-1.5 w-1.5 rounded-full",
|
||||||
name.trim() && price
|
name.trim() && price
|
||||||
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]"
|
? "bg-[var(--admin-primary)]"
|
||||||
: "bg-stone-300",
|
: "bg-stone-300",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
|
style={name.trim() && price ? { boxShadow: "0 0 0 3px var(--admin-primary-soft)" } : undefined}
|
||||||
/>
|
/>
|
||||||
{name.trim() && price ? "Ready" : "Required fields pending"}
|
{name.trim() && price ? "Ready" : "Required fields pending"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,35 +68,35 @@ export default function ProductTableBody({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Filter bar — sits outside <table> in the parent */}
|
{/* Filter bar — sits outside <table> in the parent */}
|
||||||
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
|
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-slate-900"
|
className="flex-1 min-w-40 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-primary)]"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
|
<div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
|
||||||
{(["all", "active", "inactive"] as const).map((f) => (
|
{(["all", "active", "inactive"] as const).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => onStatusChange(f)}
|
onClick={() => onStatusChange(f)}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
statusFilter === f
|
statusFilter === f
|
||||||
? "bg-slate-900 text-white"
|
? "bg-[var(--admin-primary)] text-white"
|
||||||
: "text-zinc-400 hover:bg-zinc-950"
|
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400">{filtered.length}</span>
|
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete error banner */}
|
{/* Delete error banner */}
|
||||||
{deleteError && (
|
{deleteError && (
|
||||||
<div className="mx-5 mt-3 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
<div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
|
||||||
{deleteError}
|
{deleteError}
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteError(null)}
|
onClick={() => setDeleteError(null)}
|
||||||
@@ -107,10 +107,10 @@ export default function ProductTableBody({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-zinc-500">
|
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
||||||
{search || statusFilter !== "all"
|
{search || statusFilter !== "all"
|
||||||
? "No products match your search."
|
? "No products match your search."
|
||||||
: "No products found."}
|
: "No products found."}
|
||||||
@@ -120,43 +120,46 @@ export default function ProductTableBody({
|
|||||||
filtered.map((product) => (
|
filtered.map((product) => (
|
||||||
<tr
|
<tr
|
||||||
key={product.id}
|
key={product.id}
|
||||||
className="hover:bg-zinc-800 transition-colors relative"
|
className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/products/${product.id}`}
|
href={`/admin/products/${product.id}`}
|
||||||
className="block font-medium text-zinc-100 hover:text-zinc-400"
|
className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
|
||||||
>
|
>
|
||||||
{product.name}
|
{product.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="text-zinc-500 line-clamp-1 text-sm">
|
<div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
|
||||||
{product.description || <span className="italic text-slate-400">No description</span>}
|
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2 text-zinc-300">
|
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">
|
||||||
{Array.isArray(product.brands)
|
{Array.isArray(product.brands)
|
||||||
? product.brands[0]?.name
|
? product.brands[0]?.name
|
||||||
: product.brands?.name}
|
: product.brands?.name}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2 text-zinc-300">{product.type}</td>
|
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
|
||||||
|
|
||||||
<td className="px-3 py-2 font-semibold text-zinc-100">
|
<td
|
||||||
|
className="px-3 py-2 font-semibold text-[var(--admin-text-primary)]"
|
||||||
|
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
${Number(product.price).toFixed(2)}
|
${Number(product.price).toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<AdminBadge variant={product.active ? "success" : "default"} dot>
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||||
{product.active ? "Active" : "Inactive"}
|
{product.active ? "Active" : "Inactive"}
|
||||||
</AdminBadge>
|
</AdminBadge>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{product.is_taxable === false ? (
|
{product.is_taxable === false ? (
|
||||||
<AdminBadge variant="warning">Non-taxable</AdminBadge>
|
<AdminBadge tone="warning">Non-taxable</AdminBadge>
|
||||||
) : (
|
) : (
|
||||||
<AdminBadge variant="success">Taxable</AdminBadge>
|
<AdminBadge tone="success">Taxable</AdminBadge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -165,7 +168,7 @@ export default function ProductTableBody({
|
|||||||
<div className="relative inline-flex items-center justify-end gap-2">
|
<div className="relative inline-flex items-center justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/products/${product.id}`}
|
href={`/admin/products/${product.id}`}
|
||||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-950"
|
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@@ -174,7 +177,7 @@ export default function ProductTableBody({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpenMenu(openMenu === product.id ? null : product.id);
|
setOpenMenu(openMenu === product.id ? null : product.id);
|
||||||
}}
|
}}
|
||||||
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
|
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||||
@@ -187,10 +190,10 @@ export default function ProductTableBody({
|
|||||||
className="fixed inset-0 z-10"
|
className="fixed inset-0 z-10"
|
||||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-zinc-900 shadow-lg ring-1 ring-zinc-700 overflow-hidden">
|
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
|
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
|
||||||
className="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-red-900/30"
|
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -201,21 +204,22 @@ export default function ProductTableBody({
|
|||||||
{confirmDelete === product.id && (
|
{confirmDelete === product.id && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-30 backdrop-blur-sm"
|
||||||
|
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
|
||||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||||
/>
|
/>
|
||||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6">
|
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
|
||||||
<p className="text-sm font-semibold text-zinc-100">
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
Delete "{product.name}"?
|
Delete "{product.name}"?
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-zinc-500">
|
<p className="mt-2 text-xs text-[var(--admin-text-muted)]">
|
||||||
This will remove the product. If it is attached to any
|
This will remove the product. If it is attached to any
|
||||||
orders, it will be hidden instead of deleted.
|
orders, it will be hidden instead of deleted.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 flex gap-3">
|
<div className="mt-5 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||||
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { updateProduct } from "@/actions/products/update-product";
|
|||||||
import { deleteProduct } from "@/actions/products";
|
import { deleteProduct } from "@/actions/products";
|
||||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||||
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
|
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
|
||||||
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
|
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 = {
|
type Product = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,44 +30,13 @@ type ViewMode = "table" | "cards";
|
|||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const 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>
|
|
||||||
),
|
|
||||||
plus: (className: string) => (
|
plus: (className: string) => (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M12 5v14M5 12h14"/>
|
<path d="M12 5v14M5 12h14"/>
|
||||||
</svg>
|
</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>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Page header icon
|
|
||||||
const PackageIconHeader = () => (
|
|
||||||
<svg className="h-6 w-6" 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ProductsClient({
|
export default function ProductsClient({
|
||||||
products,
|
products,
|
||||||
brandId,
|
brandId,
|
||||||
@@ -253,34 +223,50 @@ export default function ProductsClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6">
|
||||||
{/* Page Header */}
|
{/* Page Header — eyebrow + title + subtitle + primary CTA */}
|
||||||
|
<div className="ha-eyebrow mb-2">Operations</div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={<PackageIconHeader />}
|
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
|
||||||
title="Products"
|
title="Products"
|
||||||
subtitle={`${filtered.length} product${filtered.length !== 1 ? "s" : ""}`}
|
subtitle="Manage your product catalog, pricing, and availability."
|
||||||
actions={
|
actions={
|
||||||
<AdminButton
|
<AdminButton
|
||||||
onClick={openAddModal}
|
onClick={openAddModal}
|
||||||
icon={Icons.plus("h-4 w-4")}
|
icon={Icons.plus("h-4 w-4")}
|
||||||
>
|
>
|
||||||
Add Product
|
Add product
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
<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">Total</p>
|
<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">{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}</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>
|
||||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
<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">Active</p>
|
<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-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}</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>
|
||||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
<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">Inactive</p>
|
<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-stone-400 mt-1">{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,6 +306,7 @@ export default function ProductsClient({
|
|||||||
onDeleteConfirm={(id) => handleDelete(id)}
|
onDeleteConfirm={(id) => handleDelete(id)}
|
||||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||||
deletingId={deletingId}
|
deletingId={deletingId}
|
||||||
|
onAdd={openAddModal}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CardView
|
<CardView
|
||||||
@@ -330,6 +317,7 @@ export default function ProductsClient({
|
|||||||
onDeleteConfirm={(id) => handleDelete(id)}
|
onDeleteConfirm={(id) => handleDelete(id)}
|
||||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||||
deletingId={deletingId}
|
deletingId={deletingId}
|
||||||
|
onAdd={openAddModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -375,6 +363,7 @@ function TableView({
|
|||||||
onDeleteConfirm,
|
onDeleteConfirm,
|
||||||
onDeleteCancel,
|
onDeleteCancel,
|
||||||
deletingId,
|
deletingId,
|
||||||
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
onEdit: (p: Product) => void;
|
onEdit: (p: Product) => void;
|
||||||
@@ -383,6 +372,7 @@ function TableView({
|
|||||||
onDeleteConfirm: (id: string) => void;
|
onDeleteConfirm: (id: string) => void;
|
||||||
onDeleteCancel: () => void;
|
onDeleteCancel: () => void;
|
||||||
deletingId: string | null;
|
deletingId: string | null;
|
||||||
|
onAdd: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Track the position of the open row's three-dots button so the popup can be
|
// 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
|
// rendered via portal at body level (escapes any overflow:hidden ancestors
|
||||||
@@ -434,31 +424,32 @@ function TableView({
|
|||||||
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
|
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
|
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-stone-50">
|
<thead className="bg-[var(--admin-bg)]">
|
||||||
<tr>
|
<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 uppercase tracking-wider">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 uppercase tracking-wider">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 uppercase tracking-wider">Price</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</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" />
|
<th className="px-4 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-16 text-center">
|
<td colSpan={5} className="px-4 py-8">
|
||||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
<EmptyState
|
||||||
{Icons.package("h-8 w-8 text-stone-400")}
|
icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
|
||||||
</div>
|
title="No products found"
|
||||||
<p className="text-sm font-medium text-stone-600">No products found</p>
|
description="Try adjusting your filters, or add your first product to get started."
|
||||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
action={{ label: "Add your first product", onClick: onAdd }}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
products.map((product) => (
|
products.map((product) => (
|
||||||
<tr key={product.id} className="hover:bg-stone-50 transition-colors">
|
<tr key={product.id} className="hover:bg-[var(--admin-primary-soft)] transition-colors">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{product.image_url ? (
|
{product.image_url ? (
|
||||||
@@ -472,40 +463,41 @@ function TableView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
|
<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-stone-400">□</span>
|
<span className="text-[var(--admin-text-muted)]">□</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(product)}
|
onClick={() => onEdit(product)}
|
||||||
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors text-left"
|
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)] transition-colors text-left"
|
||||||
>
|
>
|
||||||
{product.name}
|
{product.name}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-xs text-stone-500 line-clamp-1">
|
<div className="text-xs text-[var(--admin-text-muted)] line-clamp-1">
|
||||||
{product.description || <span className="italic text-stone-400">No description</span>}
|
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-4 py-3">
|
<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">
|
<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}
|
{product.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-4 py-3 font-mono text-sm font-semibold text-[var(--admin-text-primary)]">
|
<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)}
|
${Number(product.price).toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-4 py-3">
|
<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 ${
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||||
product.active ? "bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]" : "bg-stone-100 text-stone-500"
|
|
||||||
}`}>
|
|
||||||
{product.active ? "Active" : "Inactive"}
|
{product.active ? "Active" : "Inactive"}
|
||||||
</span>
|
</AdminBadge>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
@@ -520,7 +512,7 @@ function TableView({
|
|||||||
<button
|
<button
|
||||||
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
||||||
onClick={() => onDelete(product.id)}
|
onClick={() => onDelete(product.id)}
|
||||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
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"
|
aria-label="Product actions"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
@@ -548,19 +540,19 @@ function TableView({
|
|||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Confirm delete"
|
aria-label="Confirm delete"
|
||||||
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
|
className="fixed z-[70] w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl p-4"
|
||||||
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-stone-900">
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
Delete "{openProduct.name}"?
|
Delete "{openProduct.name}"?
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-stone-500">
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
This will remove the product. If attached to orders, it will be hidden.
|
This will remove the product. If attached to orders, it will be hidden.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteCancel}
|
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"
|
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)]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -592,6 +584,7 @@ function CardView({
|
|||||||
onDeleteConfirm,
|
onDeleteConfirm,
|
||||||
onDeleteCancel,
|
onDeleteCancel,
|
||||||
deletingId,
|
deletingId,
|
||||||
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
onEdit: (p: Product) => void;
|
onEdit: (p: Product) => void;
|
||||||
@@ -600,25 +593,27 @@ function CardView({
|
|||||||
onDeleteConfirm: (id: string) => void;
|
onDeleteConfirm: (id: string) => void;
|
||||||
onDeleteCancel: () => void;
|
onDeleteCancel: () => void;
|
||||||
deletingId: string | null;
|
deletingId: string | null;
|
||||||
|
onAdd: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
|
<div className="col-span-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
|
||||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
<EmptyState
|
||||||
{Icons.package("h-8 w-8 text-stone-400")}
|
icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
|
||||||
</div>
|
title="No products found"
|
||||||
<p className="text-sm font-medium text-stone-600">No products found</p>
|
description="Try adjusting your filters, or add your first product to get started."
|
||||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
action={{ label: "Add your first product", onClick: onAdd }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
products.map((product) => (
|
products.map((product) => (
|
||||||
<div
|
<div
|
||||||
key={product.id}
|
key={product.id}
|
||||||
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden"
|
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 */}
|
{/* Image */}
|
||||||
<div className="relative h-40 bg-stone-100">
|
<div className="relative h-40 bg-[var(--admin-bg-subtle)]">
|
||||||
{product.image_url ? (
|
{product.image_url ? (
|
||||||
<Image
|
<Image
|
||||||
src={product.image_url}
|
src={product.image_url}
|
||||||
@@ -630,16 +625,14 @@ function CardView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-4xl text-stone-300">□</span>
|
<span className="text-4xl text-[var(--admin-text-muted)]">□</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Status badge */}
|
{/* Status badge */}
|
||||||
<div className="absolute top-3 right-3">
|
<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 ${
|
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||||
product.active ? "bg-[var(--admin-accent)] text-white" : "bg-stone-500 text-white"
|
|
||||||
}`}>
|
|
||||||
{product.active ? "Active" : "Inactive"}
|
{product.active ? "Active" : "Inactive"}
|
||||||
</span>
|
</AdminBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -649,27 +642,30 @@ function CardView({
|
|||||||
onClick={() => onEdit(product)}
|
onClick={() => onEdit(product)}
|
||||||
className="block w-full text-left"
|
className="block w-full text-left"
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors line-clamp-1">
|
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-primary)] transition-colors line-clamp-1">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8">
|
<p className="text-xs text-[var(--admin-text-muted)] mt-1 line-clamp-2 h-8">
|
||||||
{product.description || <span className="italic">No description</span>}
|
{product.description || <span className="italic">No description</span>}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-stone-100">
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-[var(--admin-border-light)]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-stone-500">Price</span>
|
<span className="text-xs text-[var(--admin-text-muted)]">Price</span>
|
||||||
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono">
|
<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)}
|
${Number(product.price).toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
<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}
|
{product.type}
|
||||||
</span>
|
</span>
|
||||||
{product.is_taxable && (
|
{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">
|
<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
|
Tax
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -687,7 +683,7 @@ function CardView({
|
|||||||
</AdminButton>
|
</AdminButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(product.id)}
|
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-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
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}>
|
<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" />
|
<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" />
|
||||||
@@ -698,25 +694,25 @@ function CardView({
|
|||||||
|
|
||||||
{/* Delete confirm */}
|
{/* Delete confirm */}
|
||||||
{deleteConfirm === product.id && (
|
{deleteConfirm === product.id && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<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-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
|
<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-stone-900">
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
Delete "{product.name}"?
|
Delete "{product.name}"?
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-stone-500">
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
This will remove the product. If attached to orders, it will be hidden.
|
This will remove the product. If attached to orders, it will be hidden.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteCancel}
|
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"
|
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)]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteConfirm(product.id)}
|
onClick={() => onDeleteConfirm(product.id)}
|
||||||
disabled={deletingId === 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"
|
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"}
|
{deletingId === product.id ? "..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user