Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type ProductDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [{ data: product, error }, { data: brands }] = await Promise.all([
|
||||
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
|
||||
supabase.from("brands").select("id, name, slug"),
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
{product.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-stone-600">
|
||||
{product.description}
|
||||
</p>
|
||||
</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 className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Price</p>
|
||||
<p className="mt-1 text-2xl font-bold text-stone-950">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{product.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Update product details, pricing, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ProductEditForm
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: Number(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
brand_id: product.brand_id,
|
||||
image_url: product.image_url,
|
||||
is_taxable: product.is_taxable,
|
||||
pickup_type: product.pickup_type,
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseProductCSV } from "@/lib/csv-parsers";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
|
||||
type PreviewRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string;
|
||||
_rowIndex: number;
|
||||
_warnings: string[];
|
||||
};
|
||||
|
||||
export default function ProductImportPage() {
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
|
||||
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; updated: number; errors: { product: string; error: string }[] } | null>(null);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const SAMPLE_CSV = `name,description,price,type,active,image_url
|
||||
Dozen Sweet Corn,Fresh picked sweet corn,12.99,Pickup,TRUE,
|
||||
Corn & Butter Bundle,Dozen corn with herb butter,18.99,Pickup & Shipping,TRUE,
|
||||
Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
`;
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
setCsvText(text);
|
||||
handlePreview(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handlePreview(text?: string) {
|
||||
const toParse = text ?? csvText;
|
||||
if (!toParse.trim()) return;
|
||||
|
||||
const parseResult = await parseProductCSV(toParse);
|
||||
if (!parseResult.success) {
|
||||
setPreview(null);
|
||||
setParseErrors([{ row: 0, error: parseResult.error }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(parseResult.rows);
|
||||
setParseErrors(parseResult.errors);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!preview || !brandId) return;
|
||||
setImporting(true);
|
||||
const importResult = await importProductsBatch(
|
||||
brandId,
|
||||
preview.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
price: r.price,
|
||||
type: r.type,
|
||||
active: r.active,
|
||||
image_url: r.image_url,
|
||||
}))
|
||||
);
|
||||
setImporting(false);
|
||||
if (importResult.success) {
|
||||
setResult({ created: importResult.created, updated: importResult.updated, errors: importResult.errors });
|
||||
} else {
|
||||
setResult({ created: 0, updated: 0, errors: [{ product: "", error: importResult.error }] });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<Link href="/admin/products" className="text-sm text-stone-500 hover:text-stone-700">
|
||||
← Back to Products
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">Import Products</h1>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Upload a CSV file to bulk-create or update products. Matching by product name within brand.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCsvText(SAMPLE_CSV);
|
||||
handlePreview(SAMPLE_CSV);
|
||||
}}
|
||||
className="rounded-xl border border-stone-300 px-4 py-2 text-sm text-stone-600 hover:bg-stone-200"
|
||||
>
|
||||
Load Sample CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Brand ID */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-stone-700">Brand ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de (Tuxedo)"
|
||||
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<label className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFile}
|
||||
className="w-full text-sm text-stone-500"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
Or paste CSV content below:
|
||||
</p>
|
||||
<textarea
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
rows={6}
|
||||
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500"
|
||||
placeholder="name,description,price,type,active,image_url"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handlePreview()}
|
||||
className="mt-3 rounded-xl border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Parse errors */}
|
||||
{parseErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 p-4">
|
||||
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{parseErrors.map((e) => (
|
||||
<li key={e.row} className="text-sm text-red-600">
|
||||
Row {e.row}: {e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview !== null && (
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-stone-950">
|
||||
Preview ({preview.length} rows)
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!brandId || importing}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-500"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${preview.length} Products`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200">
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Name</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Price</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Type</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Active</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row) => (
|
||||
<tr key={row._rowIndex} className="border-b border-stone-200">
|
||||
<td className="py-2 px-3 font-medium text-stone-950">{row.name}</td>
|
||||
<td className="py-2 px-3 text-stone-600">${row.price.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.type}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.active ? "Yes" : "No"}</td>
|
||||
<td className="max-w-[120px] truncate py-2 px-3 text-stone-500">
|
||||
{row.image_url ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result !== null && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
|
||||
<div className="mt-3 flex gap-6">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-emerald-600">{result.created}</p>
|
||||
<p className="text-sm text-stone-500">Created</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-blue-600">{result.updated}</p>
|
||||
<p className="text-sm text-stone-500">Updated</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-red-600">{result.errors.length}</p>
|
||||
<p className="text-sm text-stone-500">Errors</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-sm text-red-600">
|
||||
{e.product && `${e.product}: `}{e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import NewProductForm from "@/components/admin/NewProductForm";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser?.can_manage_products) redirect("/admin/pickup");
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h1 className="text-3xl font-bold text-stone-950">
|
||||
Create Product
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-500">
|
||||
Add a new product for Tuxedo Corn or Indian River Direct.
|
||||
</p>
|
||||
|
||||
<NewProductForm />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductTableClient from "@/components/admin/ProductTableClient";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import ProductSyncBanner from "@/components/admin/ProductSyncBanner";
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [settingsResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
]);
|
||||
const hasSquareToken = !!(
|
||||
settingsResult.success && settingsResult.settings?.square_access_token
|
||||
);
|
||||
|
||||
let query = supabase
|
||||
.from("products")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
type,
|
||||
active,
|
||||
deleted_at,
|
||||
image_url,
|
||||
brand_id,
|
||||
is_taxable,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("name");
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
}
|
||||
|
||||
const { data: products, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-red-600">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Products</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
Manage your product catalog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 font-semibold text-white text-sm transition-colors shadow-sm"
|
||||
>
|
||||
Add Product
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
|
||||
<ProductSyncBanner brandId={brandId} hasSquareToken={hasSquareToken} />
|
||||
<ProductTableClient products={products ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user