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:
Tyler
2026-06-17 00:48:26 -06:00
parent 467f7e63fd
commit 1e0e278451
8 changed files with 249 additions and 237 deletions
+41 -31
View File
@@ -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>
+13 -14
View File
@@ -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}
+4 -14
View File
@@ -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>
+17 -16
View File
@@ -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>
+36 -26
View File
@@ -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&apos;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&apos;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>
); );
} }
+9 -7
View File
@@ -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>
+32 -28
View File
@@ -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 &quot;{product.name}&quot;? Delete &quot;{product.name}&quot;?
</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>
+97 -101
View File
@@ -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 &quot;{openProduct.name}&quot;? Delete &quot;{openProduct.name}&quot;?
</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 &quot;{product.name}&quot;? Delete &quot;{product.name}&quot;?
</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>