fix: react-doctor unused-file 123→27 (-96 dead files removed)

This commit is contained in:
Nora
2026-06-26 04:38:44 -06:00
parent ab9813b4ee
commit 27b2ded94e
96 changed files with 0 additions and 17319 deletions
-218
View File
@@ -1,218 +0,0 @@
/**
* @deprecated Use AdminSidebar instead. AdminHeader is deprecated and will be removed.
* The AdminSidebar provides the complete navigation system for admin pages.
* It includes an expandable Settings sub-menu with all settings pages.
*/
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { signOutAction } from "@/actions/auth-actions";
async function handleLogout() {
await signOutAction();
}
type AdminHeaderProps = {
userRole?: string | null;
canManageUsers?: boolean;
routeTraceEnabled?: boolean;
};
type NavLink = {
href: string;
label: string;
external?: boolean;
};
type SettingsItem = {
href: string;
label: string;
description: string;
};
const SETTINGS_ITEMS: SettingsItem[] = [
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence, recovered carts" },
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
];
const BASE_NAV_LINKS: NavLink[] = [
{ href: "/admin/orders", label: "Orders" },
{ href: "/admin/stops", label: "Stops & Routes" },
{ href: "/admin/products", label: "Products" },
{ href: "/admin/time-tracking", label: "Time Tracking" },
{ href: "/admin/communications", label: "Harvest Reach" },
];
const EMPLOYEE_NAV_LINKS: NavLink[] = [
{ href: "/admin/wholesale", label: "Wholesale" },
{ href: "/admin/pickup", label: "Pickup" },
{ href: "/wholesale/employee", label: "Pickup Portal", external: true },
];
export default function AdminHeader({ userRole, canManageUsers, routeTraceEnabled }: AdminHeaderProps) {
const router = useRouter();
const pathname = usePathname();
const isStoreEmployee = userRole === "store_employee";
const moduleLinks: NavLink[] = routeTraceEnabled
? [{ href: "/admin/route-trace", label: "Route Trace" }]
: [];
const fullNavLinks = BASE_NAV_LINKS.concat(moduleLinks);
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") {
return pathname === "/admin/settings" || pathname.startsWith("/admin/settings/");
}
return pathname.startsWith(href);
};
return (
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
<div className="flex items-center h-16">
{/* Logo mark */}
<Link
href={homeHref}
className="flex items-center gap-3 mr-8 text-white hover:text-emerald-400 transition-colors"
>
<div className="relative">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/30">
<svg className="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l.707-.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
<span className="text-sm font-semibold tracking-tight">{homeLabel}</span>
</Link>
<nav className="flex h-full items-center gap-1">
{navLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200",
active
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
{link.label}
</Link>
);
})}
{/* Settings dropdown — key={pathname} remounts on route change to auto-close */}
<SettingsDropdown key={pathname} pathname={pathname} />
</nav>
</div>
<div className="flex items-center gap-6">
{roleLabel && (
<div className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/5">
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
</div>
)}
<button type="button"
onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
</header>
);
}
function SettingsDropdown({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<div className="relative ml-2" ref={ref}>
<button type="button"
onClick={() => setOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
);
}
-349
View File
@@ -1,349 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { publishStop, deleteStop } from "@/actions/stops";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brand_id: string;
status?: string;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: Stop[];
brandId: string;
};
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
} catch {
return dateStr;
}
}
function formatTime(timeStr: string) {
if (!timeStr) return "—";
try {
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
} catch {
return timeStr;
}
}
export default function AdminStopsPanel({ stops, brandId }: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [page, setPage] = useState(0);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const PAGE_SIZE = 50;
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
await publishStop(stopId, brandId);
setPublishingId(null);
router.refresh();
}
async function handleDelete(stopId: string) {
const data = await deleteStop(stopId, brandId);
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
router.refresh();
}
}
const activeCount = stops.filter((s) => s.active && s.status !== "draft").length;
const draftCount = stops.filter((s) => s.status === "draft").length;
return (
<div className="min-h-screen bg-stone-50">
{/* Header */}
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-7xl px-6 py-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-stone-950 tracking-tight">Tour Stops</h1>
<div className="mt-2 flex items-center gap-4">
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{activeCount}</span> active
</span>
{draftCount > 0 && (
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{draftCount}</span> drafts
</span>
)}
</div>
</div>
<div className="flex gap-3">
<button type="button"
onClick={() => setShowImport(true)}
className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Upload Schedule">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload Schedule
</button>
<button type="button"
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
aria-label="Add Stop">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add Stop
</button>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-6">
{/* Filters */}
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm mb-5">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-64">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input aria-label="Search City Or Location..."
type="search"
placeholder="Search city or location..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20"
/>
</div>
{/* Status tabs */}
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{(["all", "active", "draft", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
statusFilter === f
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
}`}
>
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Count */}
<span className="ml-auto text-sm text-stone-500">{filtered.length} stops</span>
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
{filtered.length === 0 ? (
<div className="py-16 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="text-lg font-medium text-stone-600">No stops found</p>
<p className="mt-1 text-sm text-stone-400">Create a stop or adjust your filters</p>
</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">Brand</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Status</th>
<th className="px-5 py-4" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{paginatedStops.map((stop) => (
<tr key={stop.id} className="hover:bg-stone-50 transition-colors">
<td className="px-5 py-4">
<Link href={`/admin/stops/${stop.id}`} className="font-medium text-stone-900 hover:text-emerald-600 transition-colors">
{stop.city}, {stop.state}
</Link>
</td>
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatDate(stop.date)}</span>
</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatTime(stop.time)}</span>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<span className="text-stone-600">
{Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands?.name}
</span>
</td>
<td className="px-5 py-4">
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button type="button"
onClick={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
className="rounded-lg px-2 py-1.5 text-[var(--admin-text-muted)] hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{openMenu === stop.id && (
<>
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-lg">
{stop.status === "draft" && (
<button type="button"
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
)}
<a href={`/admin/stops/new?duplicate=${stop.id}`} className="block px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50">
Duplicate
</a>
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50"
aria-label="Delete">
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<button type="button" aria-label="Cancel delete confirmation" className="fixed inset-0 z-30 cursor-default bg-transparent border-0 p-0" onClick={() => setConfirmDelete(null)} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg p-4">
<p className="text-sm font-semibold text-stone-900">Delete &quot;{stop.city}, {stop.state}&quot;?</p>
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
<div className="mt-4 flex gap-2">
<button type="button"
onClick={() => setConfirmDelete(null)}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(stop.id)}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--admin-danger-hover)]"
aria-label="Delete">
Delete
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-5 flex items-center justify-between">
<p className="text-sm text-stone-500">
Showing {page * PAGE_SIZE + 1} to {Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length}
</p>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Previous">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Next">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</main>
{/* Modals */}
<ScheduleImportModal brandId={brandId} onClose={() => setShowImport(false)} onComplete={() => router.refresh()} />
<AddStopModal isOpen={showAdd} onClose={() => setShowAdd(false)} brandId={brandId} onSuccess={() => router.refresh()} />
</div>
);
}
@@ -1,240 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import AdvancedIntegrations from "@/components/admin/AdvancedIntegrations";
import AdvancedAIPanel from "@/components/admin/AdvancedAIPanel";
import AdvancedSquareSync from "@/components/admin/AdvancedSquareSync";
import AdvancedShipping from "@/components/admin/AdvancedShipping";
import AdvancedPayments from "@/components/admin/AdvancedPayments";
type Tab = "integrations" | "ai" | "square" | "shipping" | "webhooks" | "payments";
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "integrations", label: "Integrations", icon: "plug" },
{ id: "ai", label: "AI Tools", icon: "sparkles" },
{ id: "square", label: "Square Sync", icon: "square" },
{ id: "shipping", label: "Shipping", icon: "truck" },
{ id: "webhooks", label: "Webhooks", icon: "webhook" },
{ id: "payments", label: "Payments", icon: "credit-card" },
];
// Icon components
const Icons = {
plug: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>
),
sparkles: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"/>
</svg>
),
square: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 12h6M12 9v6"/>
</svg>
),
truck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
</svg>
),
webhook: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M8.25 8.25l-.75.75M14.25 14.25l-.75.75M8.25 14.25l.75-.75M14.25 8.25l.75.75"/>
</svg>
),
"credit-card": (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<path d="M2 10h20"/>
</svg>
),
};
type Props = {
brandId: string;
brands: { id: string; name: string }[];
stripeConnect?: {
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
} | null;
};
export default function AdvancedSettingsClient({ brandId, brands, stripeConnect }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("integrations");
// Handle Stripe Connect callback params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("stripe_connected") === "true") {
// Successfully connected - could show a toast or refresh
window.history.replaceState({}, "", window.location.pathname);
} else if (params.get("stripe_refresh") === "true") {
// Link expired, need to refresh
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
return (
<div>
{/* Tab navigation */}
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
{TABS.map((tab) => (
<button type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
activeTab === tab.id
? "bg-[var(--admin-accent)] text-white"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
}`}
>
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
{tab.icon === "square" && Icons.square("h-4 w-4")}
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
)}
</button>
))}
</nav>
{/* Content */}
<div className="py-4 sm:py-6">
{activeTab === "integrations" && (
<AdvancedIntegrations brandId={brandId} brands={brands} />
)}
{activeTab === "ai" && (
<AdvancedAIPanel brandId={brandId} />
)}
{activeTab === "square" && (
<AdvancedSquareSync brandId={brandId} />
)}
{activeTab === "shipping" && (
<AdvancedShipping brandId={brandId} />
)}
{activeTab === "webhooks" && (
<div className="space-y-6">
{/* Page Header */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--admin-bg)]">
{Icons.webhook("h-6 w-6 text-[var(--admin-text-muted)]")}
</div>
<div className="flex-1">
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Configure webhook endpoints to receive real-time notifications for order events, payments, and inventory updates.
</p>
</div>
</div>
</div>
{/* Coming Soon Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="bg-[var(--admin-bg)]/50 px-6 py-4 border-b border-[var(--admin-border)]">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Available Webhook Events</h3>
</div>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
<svg className="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Coming Soon</p>
<p className="text-xs text-[var(--admin-text-muted)]">Webhook configuration will be available shortly</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-6">
{[
{ icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", label: "Order Status Changes", desc: "Track order lifecycle events" },
{ icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: "Payment Confirmations", desc: "Get notified on successful payments" },
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
].map((event, i) => (
<div key={`${event.label}-${event.desc}`} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-[var(--admin-border)]">
<svg className="h-4 w-4 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={event.icon}/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{event.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{event.desc}</p>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span className="text-sm text-[var(--admin-text-secondary)]">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
</div>
</div>
<div className="mt-6 flex items-center justify-between pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Need webhooks now?{" "}
<button type="button" className="text-green-600 hover:text-green-700 font-medium">
Contact support
</button>
</p>
<button type="button" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Notify Me
</button>
</div>
</div>
</div>
{/* Technical Info Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-3">Webhook Payload Format</h4>
<div className="bg-[var(--admin-text-primary)] rounded-lg p-4 overflow-x-auto">
<pre className="text-xs text-[var(--admin-bg)] font-mono leading-relaxed">
{`{
"event": "order.status_changed",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"order_id": "...",
"status": "delivered"
}
}`}
</pre>
</div>
</div>
</div>
)}
{activeTab === "payments" && (
<AdvancedPayments brandId={brandId} initialStatus={stripeConnect ?? null} />
)}
</div>
</div>
);
}
-189
View File
@@ -1,189 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
import { toggleBrandFeature } from "@/actions/settings/features";
import GlassModal from "./GlassModal";
type Props = {
brandId: string;
initialEnabledFeatures: Record<string, boolean>;
};
export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: Props) {
const router = useRouter();
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
const [toggling, setToggling] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showEnableModal, setShowEnableModal] = useState<BrandFeatureKey | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleToggle = async (key: BrandFeatureKey, enable: boolean) => {
setToggling(key);
setShowEnableModal(null);
setEnabledFeatures((prev) => ({ ...prev, [key]: enable }));
const result = await toggleBrandFeature(brandId, key, enable);
if (result.success) {
showToast(enable ? `${ADDON_CATALOG[key].name} enabled` : `${ADDON_CATALOG[key].name} disabled`);
router.refresh();
} else {
setEnabledFeatures((prev) => ({ ...prev, [key]: !enable }));
showToast(`Error: ${result.error}`, 'error');
}
setToggling(null);
};
const keys = Object.keys(ADDON_CATALOG) as BrandFeatureKey[];
return (
<>
{toast && (
<div
className="fixed bottom-6 right-6 z-50 rounded-xl px-5 py-3 text-sm font-medium shadow-lg backdrop-blur-sm"
style={{
background: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(15, 118, 110, 0.95)',
color: 'white',
}}
>
{toast.message}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{keys.map((key) => {
const addon = ADDON_CATALOG[key];
const enabled = !!enabledFeatures[key];
const busy = toggling === key;
return (
<div
key={key}
className="rounded-2xl p-6 transition-all"
style={{
background: 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(20px)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.3)' : '1px solid rgba(0, 0, 0, 0.06)',
boxShadow: enabled
? '0 8px 24px rgba(16, 185, 129, 0.1), inset 0 1px 0 rgba(255,255,255,0.8)'
: '0 4px 12px rgba(0, 0, 0, 0.04)',
}}
>
<div className="flex items-start gap-4">
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
style={{
background: enabled
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)'
: 'rgba(0, 0, 0, 0.04)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
<span className="text-2xl">{addon.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-stone-950">{addon.name}</h3>
<span
className="text-xs font-medium rounded-full px-2.5 py-0.5"
style={{
background: enabled ? 'rgba(16, 185, 129, 0.15)' : 'rgba(0, 0, 0, 0.04)',
color: enabled ? '#047857' : 'rgba(0, 0, 0, 0.4)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
{enabled ? "Active" : "Inactive"}
</span>
</div>
<p className="text-sm text-stone-500 leading-relaxed">
{addon.description}
</p>
{addon.addOnPrice && (
<p className="mt-2 text-xs font-medium" style={{ color: '#b45309' }}>
{addon.addOnPrice}/month
</p>
)}
</div>
</div>
<div className="mt-5 flex items-center justify-between">
<button type="button"
onClick={() => handleToggle(key, !enabled)}
disabled={busy}
className="rounded-xl px-5 py-2.5 text-sm font-semibold transition-all"
style={{
background: enabled
? 'rgba(239, 68, 68, 0.1)'
: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
color: enabled ? '#dc2626' : 'white',
border: enabled ? '1px solid rgba(239, 68, 68, 0.2)' : 'none',
boxShadow: enabled ? 'none' : '0 2px 8px rgba(16, 185, 129, 0.25)',
opacity: busy ? 0.5 : 1,
}}
>
{busy ? "..." : enabled ? "Disable" : "Enable"}
</button>
{enabled && addon.adminRoute && (
<a
href={addon.adminRoute}
className="rounded-xl px-4 py-2 text-sm font-medium transition-all"
style={{
background: 'rgba(0, 0, 0, 0.02)',
color: 'rgba(0, 0, 0, 0.6)',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
Open
</a>
)}
</div>
</div>
);
})}
</div>
{/* Enable confirmation modal */}
{showEnableModal && (
<GlassModal
title={`Enable ${ADDON_CATALOG[showEnableModal].name}?`}
subtitle={`This will activate the add-on feature.`}
onClose={() => setShowEnableModal(null)}
>
<div className="space-y-4">
<p className="text-sm text-stone-500">
{ADDON_CATALOG[showEnableModal].description}
</p>
{ADDON_CATALOG[showEnableModal].addOnPrice && (
<p className="text-sm font-medium" style={{ color: '#b45309' }}>
Price: {ADDON_CATALOG[showEnableModal].addOnPrice}/month
</p>
)}
<div className="flex justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button type="button"
onClick={() => setShowEnableModal(null)}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 transition-all"
>
Cancel
</button>
<button type="button"
onClick={() => handleToggle(showEnableModal, true)}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.25)',
}}
>
Enable
</button>
</div>
</div>
</GlassModal>
)}
</>
);
}
-24
View File
@@ -1,24 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useCart } from "@/context/CartContext";
type Props = { userId: string };
export default function CartHydration({ userId }: Props) {
const { loadServerCart } = useCart();
// Hold the latest loadServerCart via ref so the effect below can call the
// freshest closure without depending on it (which would re-fire on every
// render of CartProvider and trigger an infinite hydration loop).
const loadServerCartRef = useRef(loadServerCart);
useEffect(() => {
loadServerCartRef.current = loadServerCart;
});
useEffect(() => {
if (!userId) return;
loadServerCartRef.current(userId).catch(() => {});
}, [userId]);
return null;
}
@@ -1,276 +0,0 @@
"use client";
// Loading skeleton components for Communications page
function SkeletonBlock({ className = "" }: { className?: string }) {
return (
<div className={`animate-pulse bg-stone-200 rounded ${className}`} />
);
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-3 w-24" />
</div>
</div>
<SkeletonBlock className="h-9 w-28 rounded-lg" />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-stone-50 rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
<SkeletonBlock className="h-3 w-16 mb-2" />
<SkeletonBlock className="h-6 w-20" />
</div>
))}
</div>
<SkeletonBlock className="h-64 w-full rounded-xl" />
</div>
);
}
function SkeletonTableRow() {
return (
<tr className="border-b border-[var(--admin-border)]">
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-32" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-20" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
</tr>
);
}
function SkeletonTable({ rows = 5 }: { rows?: number }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
<tr>
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-16" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-16" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
</div>
);
}
function SkeletonComposer() {
return (
<div className="flex flex-col gap-6">
{/* Step indicator skeleton */}
<div className="flex items-center gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center">
<SkeletonBlock className={`h-10 rounded-full ${i === 1 ? "w-24" : "w-20"}`} />
{i < 4 && <SkeletonBlock className="h-0.5 w-6 mx-1" />}
</div>
))}
</div>
{/* Main card skeleton */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="space-y-4">
<SkeletonBlock className="h-6 w-40" />
<SkeletonBlock className="h-4 w-64" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-6">
{[1, 2, 3, 4].map((i) => (
<SkeletonBlock key={i} className="h-28 rounded-xl" />
))}
</div>
</div>
</div>
{/* Recent campaigns skeleton */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-6 py-4 border-b border-[var(--admin-border)]">
<SkeletonBlock className="h-5 w-36" />
</div>
<SkeletonTable rows={3} />
</div>
</div>
);
}
function SkeletonSegmentBuilder() {
return (
<div className="p-4 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-4 w-48" />
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4">
{/* Sidebar skeleton */}
<div className="lg:w-72 flex-shrink-0">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="h-8 w-8 rounded-lg" />
</div>
<SkeletonBlock className="h-10 w-full" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-12 w-full rounded-xl" />
))}
</div>
</div>
</div>
{/* Builder + Preview skeleton */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-20" />
<SkeletonBlock className="h-8 w-16 rounded-lg" />
</div>
<SkeletonBlock className="h-24 w-full rounded-xl" />
<div className="flex gap-2 flex-wrap">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-7 w-24 rounded-full" />
))}
</div>
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-32" />
<SkeletonBlock className="h-6 w-16 rounded-full" />
</div>
<SkeletonBlock className="h-48 w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
}
function SkeletonContacts() {
return (
<div className="space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-24" />
<SkeletonBlock className="h-4 w-32" />
</div>
</div>
<div className="flex gap-2">
<SkeletonBlock className="h-9 w-24 rounded-lg" />
<SkeletonBlock className="h-9 w-32 rounded-lg" />
</div>
</div>
<SkeletonBlock className="h-10 w-full mb-4" />
<SkeletonTable rows={5} />
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<SkeletonBlock className="h-5 w-28 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-24 rounded-xl" />
))}
</div>
</div>
</div>
);
}
function SkeletonLogs() {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-20" />
<SkeletonBlock className="h-4 w-32" />
</div>
</div>
<div className="flex gap-2">
<SkeletonBlock className="h-9 w-28 rounded-lg" />
<SkeletonBlock className="h-9 w-20 rounded-lg" />
</div>
</div>
<SkeletonBlock className="h-10 w-full mb-4" />
<SkeletonTable rows={6} />
</div>
);
}
// Main loading component that shows skeleton based on current tab
export default function CommunicationsLoading({
activeTab = "campaigns"
}: {
activeTab?: string
}) {
const currentTab = activeTab;
switch (currentTab) {
case "campaigns":
return <SkeletonCard />;
case "templates":
return <SkeletonCard />;
case "contacts":
return <SkeletonContacts />;
case "segments":
return <SkeletonSegmentBuilder />;
case "logs":
return <SkeletonLogs />;
case "analytics":
return <SkeletonCard />;
default:
return <SkeletonCard />;
}
}
// Named exports for specific loading states
export {
SkeletonCard,
SkeletonComposer,
SkeletonSegmentBuilder,
SkeletonContacts,
SkeletonLogs,
SkeletonTable,
SkeletonBlock,
};
-120
View File
@@ -1,120 +0,0 @@
"use client";
const TABS = [
{ id: "campaigns", label: "Campaigns" },
{ id: "compose", label: "Compose" },
{ id: "segments", label: "Segments" },
{ id: "analytics", label: "Analytics" },
{ id: "templates", label: "Templates" },
{ id: "contacts", label: "Contacts" },
{ id: "logs", label: "Logs" },
{ id: "settings", label: "Settings" },
];
// Icon components
const Icons = {
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
),
send: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m22 2-11 11"/>
<path d="M22 2 15 22l-4-9-9-4 20-7z"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
<line x1="10" x2="8" y1="9" y2="9"/>
</svg>
),
userCheck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<polyline points="16 11 18 13 22 9"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6"/>
<line x1="8" x2="21" y1="12" y2="12"/>
<line x1="8" x2="21" y1="18" y2="18"/>
<line x1="3" x2="3.01" y1="6" y2="6"/>
<line x1="3" x2="3.01" y1="12" y2="12"/>
<line x1="3" x2="3.01" y1="18" y2="18"/>
</svg>
),
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
filter: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
),
};
export default function CommunicationsNav({
activeTab,
}: {
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "segments" | "analytics" | "compose" | "settings";
}) {
return (
<nav className="grid grid-cols-4 sm:grid-cols-7 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={`/admin/communications/${tab.id}`}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-3 py-2.5 text-[10px] sm:text-xs font-semibold transition-colors ${
isActive
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.id === "campaigns" && Icons.send("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "compose" && Icons.mail("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "segments" && Icons.users("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "analytics" && Icons.chart("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "templates" && Icons.fileText("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "contacts" && Icons.userCheck("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "logs" && Icons.list("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "settings" && Icons.settings("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>{tab.label}</span>
{isActive && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-4 sm:w-8 bg-white rounded-full" />
)}
</a>
);
})}
</nav>
);
}
export { TABS, Icons };
-61
View File
@@ -1,61 +0,0 @@
"use client";
import Link from "next/link";
import { useState, useCallback } from "react";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
interface DashboardHeaderProps {
brandId: string | null;
brandName: string;
planTier: string;
}
export default function DashboardHeader({ brandId, brandName, planTier }: DashboardHeaderProps) {
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const openUpgrade = useCallback(() => setIsUpgradeOpen(true), []);
const closeUpgrade = useCallback(() => setIsUpgradeOpen(false), []);
return (
<>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 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"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
</div>
</div>
<div className="flex items-center gap-4">
<Link href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
Billing
</Link>
{planTier === "starter" && brandId && (
<button type="button"
onClick={openUpgrade}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
)}
</div>
</div>
{planTier === "starter" && brandId && (
<UpgradePlanModal
isOpen={isUpgradeOpen}
onClose={closeUpgrade}
brandId={brandId}
currentTier={planTier}
/>
)}
</>
);
}
@@ -1,36 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
interface DashboardUpgradeButtonProps {
brandId: string | null;
currentTier: string;
}
export default function DashboardUpgradeButton({ brandId, currentTier }: DashboardUpgradeButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);
if (!brandId) return null;
return (
<>
<button type="button"
onClick={openModal}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
<UpgradePlanModal
isOpen={isOpen}
onClose={closeModal}
brandId={brandId}
currentTier={currentTier}
/>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
title: string;
titleIcon?: React.ReactNode;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
variant?: "default" | "elegant";
};
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
// showModal() opens it in modal mode; we listen to "cancel" so the
// parent React state stays in sync with native close behavior.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
const isElegant = variant === "elegant";
return (
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
</dialog>
);
}
@@ -1,82 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{
href: "/admin/harvest-reach/segments",
label: "Segments",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
},
{
href: "/admin/harvest-reach/campaigns",
label: "Campaigns",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
),
},
{
href: "/admin/harvest-reach/analytics",
label: "Analytics",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
},
];
export default function HarvestReachNav() {
const pathname = usePathname();
return (
<div className="flex flex-col">
{/* Brand header */}
<div className="flex items-center gap-3 pb-4 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-stone-900">Harvest Reach</h1>
<p className="text-xs text-stone-500">Email marketing & campaigns</p>
</div>
</div>
{/* Navigation tabs */}
<nav className="flex gap-1 p-1 bg-stone-100 rounded-xl">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg transition-all ${
active
? "bg-white text-emerald-600 shadow-sm"
: "text-stone-500 hover:text-stone-700 hover:bg-white/50"
}`}
>
{tab.icon}
<span>{tab.label}</span>
</Link>
);
})}
</nav>
</div>
);
}
-336
View File
@@ -1,336 +0,0 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
};
type SyncOption = {
key: string;
label: string;
description?: string;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
syncOptions: SyncOption[];
};
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "AI",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "border-violet-200",
credentials: [
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
],
syncOptions: [
{ key: "campaign_writer", label: "Campaign Idea Generator" },
{ key: "product_writer", label: "Product Description Writer" },
{ key: "report_explainer", label: "Report Explainer" },
{ key: "pricing_advisor", label: "Pricing Advisor" },
],
},
{
id: "resend",
name: "Resend",
icon: "Email",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
syncOptions: [
{ key: "transactional", label: "Transactional Email" },
{ key: "marketing", label: "Marketing Campaigns" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "Stripe",
description: "Process online payments for orders.",
accentColor: "border-stone-300",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
],
syncOptions: [
{ key: "checkout", label: "Online Checkout" },
{ key: "refunds", label: "Refund Processing" },
],
},
{
id: "twilio",
name: "Twilio",
icon: "SMS",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
syncOptions: [
{ key: "sms_campaigns", label: "SMS Campaigns" },
{ key: "alerts", label: "Order Alerts" },
],
},
];
const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
(i) => i.id !== "openai"
);
const iconColors: Record<string, string> = {
AI: "bg-violet-100 text-violet-700",
Email: "bg-amber-100 text-amber-700",
Stripe: "bg-stone-100 text-stone-700",
SMS: "bg-blue-100 text-blue-700",
};
function IntegrationCard({
integration,
initialCredentials,
brandId,
}: {
integration: Integration;
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTest() {
setTestResult(null);
setError(null);
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div className={`rounded-xl border p-5 bg-white shadow-sm ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-stone-100 text-stone-700"}`}>{integration.icon}</span>
<div>
<h3 className="text-base font-semibold text-stone-900">{integration.name}</h3>
<p className="text-xs text-stone-500 mt-0.5">{integration.description}</p>
</div>
</div>
</div>
<div className="space-y-3 mb-4">
{integration.credentials.map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
<div className="relative">
<input aria-label="Input"
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm pr-16 text-stone-900 placeholder:text-stone-400 outline-none focus:border-emerald-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
className="text-xs text-stone-400 hover:text-stone-600 px-1"
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</div>
</div>
))}
</div>
{testResult && (
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{error && (
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
)}
<div className="flex gap-2">
<button type="button" onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button type="button" onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
);
}
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function IntegrationsInner({ brandId, brands }: Props) {
// There is no in-component brand picker, so derive the selected brand from
// the current prop instead of copying it into useState.
const selectedBrandId = brandId;
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
return (
<div>
{/* AI Provider — separate panel */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-stone-800">AI Provider</h3>
<Link href="/admin/settings/ai" className="text-xs text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Configure AI </Link>
</div>
<AIProviderPanel brandId={selectedBrandId} />
</div>
{/* Payment & email integrations grid */}
<div className="space-y-3 mb-4">
{INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={selectedBrandId}
/>
))}
</div>
</div>
);
}
-387
View File
@@ -1,387 +0,0 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useTransition, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { deleteLocation } from "@/actions/locations";
import AddLocationModal from "@/components/admin/AddLocationModal";
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
export type AdminLocation = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
slug: string | null;
stop_count: number;
};
type Props = {
locations: AdminLocation[];
brandId: string;
};
type StatusFilter = "all" | "active" | "inactive";
export default function LocationsTab({ locations, brandId }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<LocationForEdit | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const PAGE_SIZE = 50;
useEffect(() => {
setIsLoading(true);
const t = setTimeout(() => setIsLoading(false), 250);
return () => clearTimeout(t);
}, [page, statusFilter, search]);
const counts = useMemo(
() => ({
all: locations.length,
active: locations.filter((l) => l.active).length,
inactive: locations.filter((l) => !l.active).length,
}),
[locations]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return locations.filter((l) => {
const matchesSearch =
!q ||
l.name.toLowerCase().includes(q) ||
(l.address ?? "").toLowerCase().includes(q) ||
(l.city ?? "").toLowerCase().includes(q) ||
(l.contact_name ?? "").toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && l.active) ||
(statusFilter === "inactive" && !l.active);
return matchesSearch && matchesStatus;
});
}, [locations, search, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
];
function handleAdded() {
setShowAdd(false);
showSuccess("Venue created");
startTransition(() => router.refresh());
}
function handleEdited() {
setEditing(null);
showSuccess("Venue updated");
startTransition(() => router.refresh());
}
async function handleDelete(loc: AdminLocation) {
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
setPendingDeleteId(loc.id);
setDeleteError(null);
const res = await deleteLocation(loc.id, brandId);
setPendingDeleteId(null);
if (res.success) {
showSuccess("Venue deleted");
startTransition(() => router.refresh());
} else {
setDeleteError(res.error ?? "Delete failed");
showError("Delete failed", res.error ?? "Unknown error");
}
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search venues by name, city, or contact…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(value as StatusFilter);
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Venue
</AdminButton>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Address</th>
<th className="px-5 py-4 font-semibold">Contact</th>
<th className="px-5 py-4 font-semibold text-center">Stops</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center">
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-sm">
{search || statusFilter !== "all"
? "No venues match your filters."
: "No venues yet. Add your first venue to get started."}
</p>
</div>
</td>
</tr>
) : (
paginated.map((loc) => (
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
{(loc.city || loc.state) && (
<div className="text-xs text-[var(--admin-text-muted)]">
{[loc.city, loc.state].filter(Boolean).join(", ")}
</div>
)}
</td>
<td className="px-5 py-4">
{loc.address ? (
<div>
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
{loc.zip && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4">
{loc.contact_name || loc.phone || loc.contact_email ? (
<div className="space-y-0.5">
{loc.contact_name && (
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
)}
{loc.phone && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
)}
{loc.contact_email && (
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
{loc.contact_email}
</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4 text-center">
{loc.stop_count > 0 ? (
<span
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
style={{
background: "rgba(16, 185, 129, 0.12)",
color: "#047857",
}}
>
{loc.stop_count}
</span>
) : (
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
)}
</td>
<td className="px-5 py-4">
{loc.active ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
Active
</span>
) : (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
Inactive
</span>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button type="button"
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
aria-label="Edit venue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button type="button"
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
title="Delete venue"
aria-label="Delete venue">
{pendingDeleteId === loc.id ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<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 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
</svg>
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{showAdd && (
<AddLocationModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAdded}
/>
)}
{editing && (
<EditLocationModal
isOpen={!!editing}
onClose={() => setEditing(null)}
location={editing}
brandId={brandId}
onSuccess={handleEdited}
/>
)}
</>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
stop_id: string;
status: string;
subtotal: number;
pickup_complete: boolean;
created_at: string;
};
type OrderStatusTone = "warning" | "info" | "success" | "danger" | "neutral";
const statusToTone: Record<string, OrderStatusTone> = {
pending: "warning",
confirmed: "info",
paid: "success",
cancelled: "danger",
completed: "neutral",
};
export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
);
const [error, setError] = useState<string | null>(null);
async function togglePickup(orderId: string, current: boolean) {
const next = !current;
setPickupToggles((prev) => ({ ...prev, [orderId]: next }));
setError(null);
const result = await toggleOrderPickupComplete({ orderId, pickupComplete: next });
if (!result.success) {
// Revert optimistic update on failure
setPickupToggles((prev) => ({ ...prev, [orderId]: current }));
setError(result.error);
}
}
return (
<tbody style={{ borderColor: "var(--admin-border)" }}>
{error && (
<tr>
<td
colSpan={6}
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-danger)" }}
>
{error}
</td>
</tr>
)}
{orders.map((order) => (
<tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-3 py-2">
<span
className="font-mono text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.id.slice(0, 8)}
</span>
</td>
<td className="px-3 py-2">
<div
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</div>
<div
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_email}
</div>
</td>
<td className="px-3 py-2">
<AdminBadge tone={statusToTone[order.status] ?? "neutral"}>
{order.status ?? "pending"}
</AdminBadge>
</td>
<td
className="px-3 py-2 font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
${Number(order.subtotal).toFixed(2)}
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className="transition-opacity hover:opacity-80"
>
<AdminBadge tone={pickupToggles[order.id] ? "success" : "neutral"} dot>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</AdminBadge>
</button>
</td>
<td
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(new Date(order.created_at))}
</td>
</tr>
))}
</tbody>
);
}
@@ -1,142 +0,0 @@
"use client";
import { useState } from "react";
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
export default function ProductAssignmentForm({
stopId,
allProducts,
assignedProductIds,
}: {
stopId: string;
allProducts: Product[];
assignedProductIds: string[];
}) {
const [selected, setSelected] = useState("");
const [loading, setLoading] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set(assignedProductIds));
const [error, setError] = useState<string | null>(null);
const availableProducts = allProducts.filter(
(p) => !assignedIds.has(p.id)
);
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setLoading(true);
setError(null);
const result = await assignProductToStop({ stopId, productId: selected });
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setAssignedIds((prev) => new Set(prev).add(selected));
setSelected("");
setLoading(false);
}
async function handleRemove(productId: string) {
const result = await unassignProductFromStop({ stopId, productId });
if (!result.success) {
setError(result.error);
return;
}
setAssignedIds((prev) => {
const next = new Set(prev);
next.delete(productId);
return next;
});
}
const assignedProducts = allProducts.filter((p) => assignedIds.has(p.id));
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Assigned products list */}
{assignedProducts.length > 0 && (
<div>
<p className="text-sm font-medium text-zinc-300 mb-3">
Currently assigned
</p>
<div className="space-y-2">
{assignedProducts.map((product) => (
<div
key={product.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 p-4"
>
<div>
<p className="font-medium text-zinc-100">{product.name}</p>
<p className="text-sm text-zinc-500">
{product.type} · ${product.price}
</p>
</div>
<button type="button"
onClick={() => handleRemove(product.id)}
className="text-sm text-red-400 hover:text-red-800"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Assign form */}
<form onSubmit={handleAssign} className="flex gap-3">
<select aria-label="Select"
value={selected}
onChange={(e) => setSelected(e.target.value)}
required
className="flex-1 rounded-xl border border-zinc-600 px-4 py-3 outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.name} {product.type} (${product.price})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || loading}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Assigning..." : "Assign Product"}
</button>
</form>
{availableProducts.length === 0 && assignedProducts.length > 0 && (
<p className="text-sm text-zinc-500">All products already assigned.</p>
)}
{allProducts.length === 0 && (
<p className="text-sm text-zinc-500">
No active products for this brand yet.
</p>
)}
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
"use client";
import { useState } from "react";
import { Product } from "@/types";
type Props = {
products: Product[];
onSearchChange: (s: string) => void;
onStatusChange: (f: "all" | "active" | "inactive") => void;
search: string;
statusFilter: "all" | "active" | "inactive";
count: number;
};
export default function ProductFilterBar({
products,
onSearchChange,
onStatusChange,
search,
statusFilter,
count,
}: Props) {
return (
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
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 outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{count}</span>
</div>
);
}
-101
View File
@@ -1,101 +0,0 @@
"use client";
import { useState } from "react";
import { syncSquareNow, getSyncLog } from "@/actions/square-sync-ui";
import Link from "next/link";
type Props = {
brandId: string;
hasSquareToken: boolean;
};
export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [logs, setLogs] = useState<{ event_type: string; status: string; created_at: string }[]>([]);
async function handleSyncProducts() {
setSyncing(true);
setMsg(null);
const result = await syncSquareNow(brandId, "products");
setMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Products synced — ${result.synced} item(s).`
: `Failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
const logResult = await getSyncLog(brandId);
if (logResult.success) setLogs(logResult.logs.filter(l => l.entity_type === "product").slice(0, 5));
}
if (!hasSquareToken) {
return (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<span className="text-amber-700">Square not connected.</span>
<Link href="/admin/settings/payments" className="ml-2 font-medium text-emerald-600 hover:text-emerald-700 underline transition-colors">
Connect Square
</Link>
</div>
);
}
return (
<div className="mb-4">
<div className="flex items-center gap-3">
<button type="button"
onClick={handleSyncProducts}
disabled={syncing}
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
>
{syncing ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Syncing...
</span>
) : (
<span className="flex items-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sync Products to Square
</span>
)}
</button>
<Link
href="/admin/settings/square-sync"
className="text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Square Sync Settings
</Link>
</div>
{msg && (
<div className={`mt-2 rounded-lg border px-3 py-2 text-sm ${
msg.kind === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-red-200 bg-red-50 text-red-700"
}`}>
{msg.text}
</div>
)}
{logs.length > 0 && (
<div className="mt-2 space-y-1">
{logs.map((log, i) => (
<div key={`${log.event_type}-${log.status}-${log.created_at}`} className="flex items-center gap-2 text-xs text-stone-500">
<span className={`rounded px-1.5 py-0.5 font-medium ${
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
}`}>{log.status}</span>
<span className="text-stone-600">{log.event_type}</span>
<span className="text-stone-400">{new Date(log.created_at).toLocaleTimeString()}</span>
</div>
))}
</div>
)}
</div>
);
}
-249
View File
@@ -1,249 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteProduct } from "@/actions/products";
import AdminBadge from "./design-system/AdminBadge";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
brands: { name: string } | { name: string }[];
is_taxable?: boolean;
};
type ProductTableBodyProps = {
products: Product[];
search: string;
statusFilter: "all" | "active" | "inactive";
onSearchChange: (v: string) => void;
onStatusChange: (v: "all" | "active" | "inactive") => void;
onDeleted: (productId: string) => void;
};
export default function ProductTableBody({
products,
search,
statusFilter,
onSearchChange,
onStatusChange,
onDeleted,
}: ProductTableBodyProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
async function handleDelete(productId: string) {
setDeletingId(productId);
setDeleteError(null);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted(productId);
} else {
setDeleteError(result.error ?? "Delete failed");
}
}
return (
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
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-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-[var(--admin-primary)] text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
</div>
{/* Delete error banner */}
{deleteError && (
<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}
<button type="button"
onClick={() => setDeleteError(null)}
className="ml-2 underline hover:no-underline"
aria-label="Dismiss">
Dismiss
</button>
</div>
)}
<tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<tr
key={product.id}
className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
>
<td className="px-3 py-2">
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
>
{product.name}
</Link>
<div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div>
</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
<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)}
</td>
<td className="px-3 py-2">
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
</td>
<td className="px-3 py-2">
{product.is_taxable === false ? (
<AdminBadge tone="warning">Non-taxable</AdminBadge>
) : (
<AdminBadge tone="success">Taxable</AdminBadge>
)}
</td>
{/* Actions */}
<td className="px-3 py-2 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Edit
</Link>
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
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">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
<>
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<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 type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 backdrop-blur-sm border-0 p-0 cursor-default"
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
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-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
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
</button>
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</>
);
}
-292
View File
@@ -1,292 +0,0 @@
"use client";
import React, { useState, useTransition, useCallback } from "react";
import Image from "next/image";
import { deleteProduct } from "@/actions/products";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
image_url?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
products: Product[];
};
export default function ProductTableClient({ products }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
const handleDeleted = useCallback(() => {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}, [router]);
return (
<>
{/* Filter bar */}
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-sm text-stone-500 px-2">{filtered.length} products</span>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 bg-stone-50">
<tr>
<th className="px-5 py-4 font-semibold text-stone-500">Product</th>
<th className="px-5 py-4 font-semibold text-stone-500">Brand</th>
<th className="px-5 py-4 font-semibold text-stone-500">Type</th>
<th className="px-5 py-4 font-semibold text-stone-500">Price</th>
<th className="px-5 py-4 font-semibold text-stone-500">Status</th>
<th className="px-5 py-4 font-semibold text-stone-500" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-16 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<ProductRow
key={product.id}
product={product}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function ProductRowBase({
product,
onDeleted,
onDeleteError,
}: {
product: Product;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
async function handleDelete(productId: string) {
setDeletingId(productId);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{product.image_url ? (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-stone-200">
<Image
src={product.image_url}
alt={product.name}
fill
sizes="48px"
style={{ objectFit: "cover" }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
) : (
<div className="h-12 w-12 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-stone-200">
<span className="text-stone-400 text-lg"></span>
</div>
)}
<div>
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{product.name}
</Link>
<div className="text-stone-500 line-clamp-1 text-sm">
{product.description || (
<span className="italic text-stone-400">No description</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-stone-600 font-mono text-xs uppercase tracking-wide">{product.type}</td>
<td className="px-5 py-4 font-mono font-semibold text-stone-900">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<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"/>
</svg>
</button>
{openMenu === product.id && (
<>
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-stone-500">
This will remove the product. If it is attached to any orders,
it will be hidden instead of deleted.
</p>
<div className="mt-4 flex gap-2">
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const ProductRow = React.memo(ProductRowBase);
@@ -1,9 +0,0 @@
"use client";
import SquareSyncWidget from "@/components/admin/SquareSyncWidget";
type Props = { brandId: string };
export default function SquareSyncWidgetWrapper({ brandId }: Props) {
return <SquareSyncWidget brandId={brandId} />;
}
-31
View File
@@ -1,31 +0,0 @@
type Stat = {
label: string;
value: number | string;
emphasis?: boolean;
};
type Props = {
stats: Stat[];
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
right?: React.ReactNode;
};
export default function StatsStrip({ stats, right }: Props) {
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{stats.map((s, i) => (
<span key={`${s.label}-${s.value}`} className="flex items-baseline gap-1.5">
<span
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
>
{s.value}
</span>
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
</span>
))}
</div>
{right && <div className="ml-auto">{right}</div>}
</div>
);
}
-394
View File
@@ -1,394 +0,0 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getStopDetails } from "@/actions/stops/get-stop-details";
import { useToast } from "@/components/admin/design-system";
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
type Tab = "details" | "products" | "message";
type Props = {
stopId: string;
/** Optional: when the user clicks Duplicate from inside the modal. */
onDuplicate?: (stopId: string) => void;
onClose: () => void;
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
return (
<GlassModal
title="Stop"
subtitle="Stop details"
onClose={onClose}
maxWidth="max-w-3xl"
>
{/* Keying by stopId causes a full remount + fresh data fetch
whenever the target stop changes, eliminating the need to
reset internal state in an effect. */}
<StopDetailContent
key={stopId}
stopId={stopId}
onDuplicate={onDuplicate}
onClose={onClose}
/>
</GlassModal>
);
}
function StopDetailContent({
stopId,
onDuplicate,
onClose: _onClose,
}: {
stopId: string;
onDuplicate?: (stopId: string) => void;
onClose: () => void;
}) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
// Group the load result into a single state object so the effect
// only writes one piece of state at a time (satisfies
// `no-cascading-set-state`).
const [loadData, setLoadData] = useState<{
loading: boolean;
loadError: string | null;
stop: StopDetail | null;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
callerUid: string;
}>({
loading: true,
loadError: null,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
});
const { loading, loadError, stop, allProducts, assignedProducts, brands, callerUid } = loadData;
const [tab, setTab] = useState<Tab>("details");
// Track the last stopId we kicked off a fetch for. When the prop
// changes we flip `loading` inline during render so users never see
// stale "loaded" UI between the prop change and the effect running.
const [lastFetchedStopId, setLastFetchedStopId] = useState<string | null>(null);
if (stopId !== lastFetchedStopId) {
setLastFetchedStopId(stopId);
setLoadData({
loading: true,
loadError: null,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
});
}
useEffect(() => {
let cancelled = false;
void (async () => {
let nextState: typeof loadData;
try {
const res = await getStopDetails(stopId);
if (cancelled) return;
if (!res.success) {
nextState = {
loading: false,
loadError: res.error,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
};
} else {
nextState = {
loading: false,
loadError: null,
stop: res.stop,
allProducts: res.allProducts,
assignedProducts: res.assignedProducts,
brands: res.brands,
callerUid: res.callerUid,
};
}
} catch (err) {
if (cancelled) return;
nextState = {
loading: false,
loadError: err instanceof Error ? err.message : "Failed to load stop",
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
};
}
setLoadData(nextState);
})();
return () => {
cancelled = true;
};
}, [stopId]);
function refresh() {
getStopDetails(stopId).then((res) => {
if (!res.success) return;
setLoadData((prev) => ({
...prev,
stop: res.stop,
allProducts: res.allProducts,
assignedProducts: res.assignedProducts,
brands: res.brands,
callerUid: res.callerUid,
}));
});
}
function handleEditSaved() {
refresh();
showSuccess("Stop updated", "Changes have been saved");
startTransition(() => router.refresh());
}
const subtitle = stop?.brands
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
: undefined;
return (
<>
<div className="mb-3 hidden">
<span>{subtitle}</span>
</div>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-1/2 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
</div>
) : loadError ? (
<div
className="rounded-xl border px-4 py-3 text-sm text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
borderColor: "color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
Details
</TabButton>
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
<span className="inline-flex items-center gap-1.5">
Products
{assignedProducts.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
{assignedProducts.length}
</span>
)}
</span>
</TabButton>
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
Message
</TabButton>
</div>
{tab === "details" && (
<DetailsPanel
stop={stop}
brands={brands}
onDuplicate={onDuplicate}
onSaved={handleEditSaved}
/>
)}
{tab === "products" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Assigned Products
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Manage which products are available at this stop.
</p>
<div className="mt-4">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={callerUid}
/>
</div>
</div>
)}
{tab === "message" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Message Customers
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-4">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
)}
</div>
) : null}
</>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
}`}
>
{children}
</button>
);
}
function DetailsPanel({
stop,
brands,
onDuplicate,
onSaved,
}: {
stop: StopDetail;
brands: { id: string; name: string; slug: string }[];
onDuplicate?: (stopId: string) => void;
onSaved: () => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
</div>
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
</div>
{stop.address && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</dd>
</div>
)}
{stop.cutoff_time && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_time).toLocaleString()}
</dd>
</div>
)}
</dl>
</div>
<div className="flex justify-end">
{onDuplicate ? (
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</a>
)}
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Update stop details, location, and availability.
</p>
<div className="mt-4">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.slug,
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brands}
onSaved={onSaved}
/>
</div>
</div>
</div>
);
}
-240
View File
@@ -1,240 +0,0 @@
"use client";
import { useState } from "react";
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast";
import { AdminButton } from "@/components/admin/design-system";
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
];
export default function StopMessagingForm({
stopId,
brandId,
}: {
stopId: string;
brandId: string;
}) {
const [customers, setCustomers] = useState<StopCustomer[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [message, setMessage] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(0);
const [error, setError] = useState<string | null>(null);
async function loadCustomers() {
setLoading(true);
setError(null);
const result = await getStopPendingCustomers(stopId);
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setCustomers(result.customers);
setLoaded(true);
setLoading(false);
}
function applyQuickMessage(msg: string) {
setMessage(msg);
setCustomMessage(msg);
}
async function handleSend() {
if (!message.trim()) return;
setSending(true);
setError(null);
const blast = await sendStopBlast({
stopId,
brandId,
channel,
body: message,
audience: "pending",
});
if (!blast.success) {
setError(blast.error);
setSending(false);
return;
}
setSent(blast.messages_logged);
setSending(false);
}
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
return (
<div className="space-y-6">
<div>
<h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
Send Message
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Notify all customers with pending pickups at this stop.
</p>
</div>
{!loaded ? (
<button type="button"
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
>
{loading ? "Loading customers..." : "Load Pending Customers"}
</button>
) : (
<>
{customers.length === 0 ? (
<div className="rounded-xl bg-[var(--admin-bg-subtle)] p-6 text-center text-[var(--admin-text-muted)]">
No pending orders for this stop yet.
</div>
) : (
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span>
)}
</div>
)}
{/* Channel */}
<div>
<p className="ha-field-label mb-2">
<span>Send via</span>
</p>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button type="button"
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{ch === "sms" ? "SMS" : ch === "email" ? "Email" : "Both"}
</button>
))}
</div>
</div>
{/* Quick messages */}
<div>
<p className="ha-field-label mb-2">
<span>Quick messages</span>
</p>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
<button type="button"
key={qm.label}
onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{qm.label}
</button>
))}
</div>
</div>
{/* Message preview */}
<div>
<label htmlFor="fld-stop-message" className="ha-field-label mb-2">
<span>Message</span>
</label>
<textarea id="fld-stop-message" aria-label="Type Your Message..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
setCustomMessage(e.target.value);
}}
rows={4}
className="ha-field-textarea"
placeholder="Type your message..."
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)] tabular-nums">
{message.length} characters
</p>
</div>
{error && (
<div
role="alert"
className="rounded-xl p-4 text-sm text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)" }}
>
{error}
</div>
)}
{sent > 0 && (
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div>
)}
{/* Recipients */}
{recipients.length > 0 && message && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
Recipients ({recipients.length}):
</p>
<div className="space-y-2">
{recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
<span className="text-[var(--admin-text-muted)] tabular-nums">
{c.customer_phone ?? c.customer_email ?? "no contact"}
</span>
</div>
))}
</div>
</div>
)}
<AdminButton
variant="primary"
size="lg"
onClick={handleSend}
disabled={!message || recipients.length === 0 || sending}
isLoading={sending}
className="w-full"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</AdminButton>
</>
)}
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
"use client";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brands: { name: string } | { name: string }[];
};
export default function StopTableBody({ stops }: { stops: Stop[] }) {
return (
<tbody className="divide-y divide-slate-200">
{stops?.map((stop) => (
<tr
key={stop.id}
onClick={() =>
(window.location.href = `/admin/stops/${stop.id}`)
}
className="cursor-pointer hover:bg-zinc-800"
>
<td className="px-3 py-2">
<span className="font-medium text-zinc-100">
{stop.city}, {stop.state}
</span>
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.location}
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.date}
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.time}
</td>
<td className="px-3 py-2 text-zinc-300">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-3 py-2">
<span className="rounded-full bg-zinc-950 px-3 py-1 text-xs font-medium text-zinc-300">
{stop.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
);
}
@@ -1,754 +0,0 @@
"use client";
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { useToast } from "@/components/admin/design-system";
import type { StopForView } from "./StopsViewClient";
type Props = {
stops: StopForView[];
};
/* ================================================================== */
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
/* ================================================================== */
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmd(s: string): Date | null {
// Treat YYYY-MM-DD as a local date (no UTC shift)
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const [, y, mo, d] = m;
return new Date(Number(y), Number(mo) - 1, Number(d));
}
function todayYmd(): string {
return ymd(new Date());
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const MONTH_NAMES_ITALIC = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function buildMonthGrid(month: Date): Date[] {
// Start at the Sunday on or before the 1st of the month
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const startDayOfWeek = first.getDay(); // 0 = Sun
const start = new Date(first);
start.setDate(1 - startDayOfWeek);
// 6 weeks = 42 cells
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
if (stop.status === "draft") return "draft";
if (stop.active) return "active";
return "inactive";
}
function statusLabel(s: "active" | "draft" | "inactive"): string {
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
}
function brandName(s: StopForView): string {
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
}
function formatTime12(t: string | null | undefined): string {
if (!t) return "—";
// t is "HH:MM" or "HH:MM:SS"
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "pm" : "am";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr} ${ampm}`;
}
function formatTimeCompact(t: string | null | undefined): string {
if (!t) return "—";
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "p" : "a";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr}${ampm}`;
}
function formatDateLong(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}`;
}
function formatDateLongSpelled(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
}
/* ================================================================== */
/* Component */
/* ================================================================== */
const MAX_EVENTS_PER_CELL = 3;
export default function StopsCalendarClient({ stops }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [viewMonth, setViewMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activePopover, setActivePopover] = useState<{
stopId: string;
cellRect: DOMRect;
} | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
// Bucket stops by YYYY-MM-DD
const stopsByDate = useMemo(() => {
const map = new Map<string, StopForView[]>();
for (const s of stops) {
if (!s.date) continue;
const list = map.get(s.date) ?? [];
list.push(s);
map.set(s.date, list);
}
// Sort each day by time
for (const list of map.values()) {
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const todayStr = useMemo(() => todayYmd(), []);
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
// Earliest/latest stop dates — used to enable/disable nav
const dateRange = useMemo(() => {
if (stops.length === 0) return null;
const dates = stops.flatMap((s) => (s.date ? [s.date] : [])).sort();
return { min: dates[0], max: dates[dates.length - 1] };
}, [stops]);
const isFirstMonth = useMemo(() => {
if (!dateRange?.min) return false;
const minDate = parseYmd(dateRange.min);
if (!minDate) return false;
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
}, [dateRange, viewMonth]);
const isLastMonth = useMemo(() => {
if (!dateRange?.max) return false;
const maxDate = parseYmd(dateRange.max);
if (!maxDate) return false;
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
}, [dateRange, viewMonth]);
function goPrevMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
}
function goNextMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
}
function goToday() {
const today = new Date();
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
setSelectedDate(todayYmd());
}
// Close popover on outside click / Escape
useEffect(() => {
if (!activePopover) return;
function onDown(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
setActivePopover(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setActivePopover(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [activePopover]);
// Close drawer on Escape
useEffect(() => {
if (!selectedDate) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setSelectedDate(null);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [selectedDate]);
// Lock body scroll when drawer is open
useEffect(() => {
if (selectedDate) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [selectedDate]);
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
}, []);
const handlePublish = useCallback(
async (stop: StopForView) => {
setActivePopover(null);
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
startTransition(() => router.refresh());
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
},
[router, showSuccess, showError]
);
const activeStop = useMemo(
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
[activePopover, stops]
);
const selectedDayStops = useMemo(() => {
if (!selectedDate) return [];
return stopsByDate.get(selectedDate) ?? [];
}, [selectedDate, stopsByDate]);
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
// Compute popover position with viewport edge detection
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover) return null;
const POPOVER_W = 288; // 18rem
const MARGIN = 8;
const rect = activePopover.cellRect;
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
// Default: place below the chip, left-aligned
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
const top = rect.bottom + MARGIN;
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
if (left < 8) left = 8;
return { left, top };
}, [activePopover]);
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover || !popoverStyle) return null;
const rect = activePopover.cellRect;
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
}, [activePopover, popoverStyle]);
// === Render ===
return (
<>
<div className="ha-calendar">
{/* Header */}
<div className="ha-calendar-header">
<div className="ha-calendar-title-block">
<span className="ha-calendar-eyebrow">
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
</span>
<h2 className="ha-calendar-title">
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
</h2>
</div>
<div className="ha-calendar-nav">
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goPrevMonth}
disabled={isFirstMonth}
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button" className="ha-calendar-today" onClick={goToday}>
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
Today
</button>
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goNextMonth}
disabled={isLastMonth}
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* DOW header */}
<div className="ha-calendar-dow" role="row">
{DOW_LABELS.map((label) => (
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
{label}
</div>
))}
</div>
{/* Grid */}
<div className="ha-calendar-grid" role="grid">
{monthCells.map((d) => {
const key = ymd(d);
const dayStops = stopsByDate.get(key) ?? [];
const isOut = d.getMonth() !== viewMonth.getMonth();
const isToday = key === todayStr;
const hasStops = dayStops.length > 0;
const dow = d.getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = selectedDate === key;
return (
<div
key={key}
role="gridcell"
tabIndex={hasStops ? 0 : -1}
onClick={() => hasStops && setSelectedDate(key)}
onKeyDown={(e) => {
if (hasStops && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setSelectedDate(key);
}
}}
className={[
"ha-calendar-cell",
isOut ? "ha-calendar-cell--out" : "",
isWeekend ? "ha-calendar-cell--weekend" : "",
hasStops ? "ha-calendar-cell--has-stops" : "",
isToday ? "ha-calendar-cell--today" : "",
isSelected ? "ha-calendar-cell--selected" : "",
]
.filter(Boolean)
.join(" ")}
aria-label={hasStops ? `${formatDateLong(d)}${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
>
<div className="ha-calendar-daynum">
<span>{d.getDate()}</span>
{isToday && <span className="ha-calendar-today-dot" />}
</div>
<div className="ha-calendar-events">
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
<EventChip
key={stop.id}
stop={stop}
isPublishing={publishingId === stop.id}
onOpen={(rect) => openStop(stop, rect)}
isActive={activePopover?.stopId === stop.id}
/>
))}
{dayStops.length > MAX_EVENTS_PER_CELL && (
<button
type="button"
className="ha-calendar-event-more"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(key);
}}
>
+{dayStops.length - MAX_EVENTS_PER_CELL} more
</button>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="ha-calendar-legend">
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
Active
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-warning)" }} />
Draft
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
Inactive
</span>
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
Tip click any day with stops to see the full route · click an event for details
</span>
</div>
</div>
{/* Event popover */}
{activeStop && activePopover && popoverStyle && (
<EventPopover
stop={activeStop}
isPublishing={publishingId === activeStop.id}
onPublish={() => handlePublish(activeStop)}
onOpenRoute={() => {
setActivePopover(null);
setSelectedDate(activeStop.date);
}}
style={popoverStyle}
arrowStyle={popoverArrowStyle}
/>
)}
{/* Day-route drawer */}
{selectedDate && selectedDayDate && (
<DayRouteDrawer
date={selectedDayDate}
dateStr={selectedDate}
stops={selectedDayStops}
onClose={() => setSelectedDate(null)}
onPublish={handlePublish}
/>
)}
</>
);
}
/* ================================================================== */
/* EventChip — single stop on a day cell */
/* ================================================================== */
function EventChip({
stop,
onOpen,
isActive,
isPublishing,
}: {
stop: StopForView;
onOpen: (rect: DOMRect) => void;
isActive: boolean;
isPublishing: boolean;
}) {
const ref = useRef<HTMLButtonElement>(null);
const status = statusOf(stop);
return (
<button
ref={ref}
type="button"
data-event-chip
onClick={(e) => {
e.stopPropagation();
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
}}
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
title={`${stop.city}, ${stop.state}${stop.location}`}
>
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
</button>
);
}
/* ================================================================== */
/* EventPopover — floating detail card */
/* ================================================================== */
function EventPopover({
stop,
isPublishing,
onPublish,
onOpenRoute,
style,
arrowStyle,
}: {
stop: StopForView;
isPublishing: boolean;
onPublish: () => void;
onOpenRoute: () => void;
style: React.CSSProperties;
arrowStyle: React.CSSProperties | null;
}) {
const status = statusOf(stop);
return (
<div
data-event-popover
className="ha-event-popover"
style={style}
onClick={(e) => e.stopPropagation()}
>
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
<div className="ha-event-popover-eyebrow">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "var(--admin-warning)" : "var(--admin-text-muted)",
}}
/>
{statusLabel(status)} · {brandName(stop)}
</div>
<h3 className="ha-event-popover-title">
{stop.city}, {stop.state}
</h3>
<p className="ha-event-popover-location">{stop.location}</p>
<div className="ha-event-popover-grid">
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Pickup</span>
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
</div>
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Cutoff</span>
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
</div>
{stop.address && (
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
<span className="ha-event-popover-field-label">Address</span>
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
</div>
)}
</div>
<div className="ha-event-popover-actions">
{status === "draft" && (
<button
type="button"
onClick={onPublish}
disabled={isPublishing}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
{isPublishing ? "Publishing…" : "Publish"}
</button>
)}
<button
type="button"
onClick={onOpenRoute}
className="ha-event-popover-btn"
>
View route
</button>
<Link
href={`/admin/stops/${stop.id}`}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
);
}
/* ================================================================== */
/* DayRouteDrawer — full day's route on the side */
/* ================================================================== */
function DayRouteDrawer({
date,
dateStr,
stops,
onClose,
onPublish,
}: {
date: Date;
dateStr: string;
stops: StopForView[];
onClose: () => void;
onPublish: (stop: StopForView) => void;
}) {
const sorted = useMemo(() => stops.toSorted((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const counts = useMemo(() => {
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
const draft = sorted.filter((s) => s.status === "draft").length;
const total = sorted.length;
return { active, draft, total };
}, [sorted]);
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
const routeNumber = useMemo(() => {
// Editorial "Route 03" — count of stops with status badge
return String(sorted.length).padStart(2, "0");
}, [sorted.length]);
const earliest = sorted[0]?.time;
const latest = sorted[sorted.length - 1]?.time;
return (
<>
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
<header className="ha-drawer-header">
<div>
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
<p className="ha-drawer-subtitle">
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
{earliest && latest && sorted.length > 1 && (
<> · {formatTime12(earliest)} {formatTime12(latest)}</>
)}
</p>
</div>
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="ha-drawer-body">
{sorted.length === 0 ? (
<div className="ha-drawer-empty">
<p className="ha-drawer-empty-mark">No route on this day</p>
<p className="ha-drawer-empty-text">
No stops are scheduled for {formatDateLongSpelled(date)}.
</p>
</div>
) : (
<>
{/* Summary cards */}
<div className="ha-route-summary">
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.total}</span>
<span className="ha-route-summary-label">Stops</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.active}</span>
<span className="ha-route-summary-label">Live</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{brands.length}</span>
<span className="ha-route-summary-label">Brands</span>
</div>
</div>
{/* Route spine */}
<div className="ha-route-list">
{sorted.map((stop, idx) => {
const status = statusOf(stop);
return (
<article key={stop.id} className="ha-route-stop">
<div className="ha-route-stop-spine">
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
{String(idx + 1).padStart(2, "0")}
</div>
<div className="ha-route-stop-line" />
</div>
<div className="ha-route-stop-content">
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
<h3 className="ha-route-stop-name">
{stop.city}, {stop.state}
</h3>
<p className="ha-route-stop-location">{stop.location}</p>
<div className="ha-route-stop-meta">
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
<span
className="ha-route-stop-meta-pill"
style={{
background:
status === "active"
? "var(--admin-accent-light)"
: status === "draft"
? "var(--admin-warning-soft)"
: "var(--admin-bg-subtle)",
color:
status === "active"
? "var(--admin-accent-text)"
: status === "draft"
? "var(--admin-warning)"
: "var(--admin-text-secondary)",
borderColor:
status === "active"
? "var(--admin-accent)"
: status === "draft"
? "var(--admin-warning)"
: "var(--admin-border-light)",
}}
>
{statusLabel(status)}
</span>
{stop.cutoff_time && (
<span className="ha-route-stop-meta-pill">
Cutoff {formatTime12(stop.cutoff_time)}
</span>
)}
{stop.address && (
<span className="ha-route-stop-meta-pill">
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
)}
</div>
<div className="ha-route-stop-actions">
{status === "draft" && (
<button
type="button"
onClick={() => onPublish(stop)}
className="ha-route-stop-action ha-route-stop-action--primary"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Publish
</button>
)}
<Link
href={`/admin/stops/${stop.id}`}
className="ha-route-stop-action ha-route-stop-action--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
className="ha-route-stop-action"
>
Duplicate
</Link>
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
</aside>
</>
);
}
@@ -1,73 +0,0 @@
"use client";
import { useState } from "react";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { useRouter } from "next/navigation";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
/** Hide on the Locations tab — actions belong to the Stops tab only. */
tab?: "stops" | "locations";
};
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const router = useRouter();
if (tab !== "stops") return null;
function handleImportComplete(count: number) {
router.refresh();
}
function handleAddSuccess(stopId: string) {
router.refresh();
}
return (
<>
<div className="flex gap-3">
<AdminButton
variant="secondary"
onClick={() => setShowImport(true)}
icon={
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
>
Upload Schedule
</AdminButton>
<AdminButton
variant="primary"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Stop
</AdminButton>
</div>
{showImport && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setShowImport(false)}
onComplete={handleImportComplete}
/>
)}
<AddStopModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAddSuccess}
/>
</>
);
}
-107
View File
@@ -1,107 +0,0 @@
"use client";
import Link from "next/link";
type Props = {
active: "stops" | "locations";
stopCount: number;
locationCount: number;
stopsSublabel?: string; // e.g. "269 · 7 cities"
locationsSublabel?: string; // e.g. "41 · 8 cities"
};
const StopIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
const PinIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function StopsLocationsTabs({
active,
stopCount,
locationCount,
stopsSublabel,
locationsSublabel,
}: Props) {
const tabs = [
{
value: "stops" as const,
label: "Stops",
count: stopCount,
sublabel: stopsSublabel,
icon: <StopIcon />,
href: "/admin/stops",
},
{
value: "locations" as const,
label: "Locations",
count: locationCount,
sublabel: locationsSublabel,
icon: <PinIcon />,
href: "/admin/stops?tab=locations",
},
];
return (
<div
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
role="tablist"
aria-label="Stops and Locations tabs"
>
{tabs.map((t) => {
const isActive = t.value === active;
return (
<Link
key={t.value}
href={t.href}
role="tab"
aria-selected={isActive}
className={`
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200
${isActive
? "bg-white text-[var(--admin-primary)] border border-[var(--admin-primary)]/30 shadow-sm"
: "text-[var(--admin-text-secondary)] border border-transparent hover:text-[var(--admin-primary)] hover:bg-[var(--admin-primary-soft)]/40"
}
`}
>
<span
className={`flex-shrink-0 transition-colors ${isActive ? "text-[var(--admin-primary)]" : "text-[var(--admin-text-muted)] group-hover:text-[var(--admin-primary)]"}`}
aria-hidden
>
{t.icon}
</span>
<span>{t.label}</span>
<span
className={`
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums
${isActive
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] group-hover:bg-[var(--admin-primary-soft)] group-hover:text-[var(--admin-primary)]"
}
`}
>
{t.count}
</span>
{t.sublabel && (
<span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-[var(--admin-text-muted)]" : "text-[var(--admin-text-muted)]/80"}`}
>
· {t.sublabel}
</span>
)}
</Link>
);
})}
</div>
);
}
-132
View File
@@ -1,132 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import StopTableClient from "@/components/admin/StopTableClient";
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
export type StopsViewMode = "calendar" | "table";
export type StopForView = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: StopForView[];
};
export default function StopsViewClient({ stops }: Props) {
const [view, setView] = useState<StopsViewMode>("calendar");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
// Apply shared filter so both views agree on what's visible
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const isDraft = s.status === "draft";
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && !isDraft) ||
(statusFilter === "inactive" && !s.active && !isDraft) ||
(statusFilter === "draft" && isDraft);
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const counts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && s.status !== "draft").length,
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
{ value: "draft", label: "Draft", count: counts.draft },
];
return (
<>
{/* Filter / search / view-toggle bar — shared across both views */}
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
tabs={tabs}
size="sm"
showCounts
/>
<AdminSearchInput
placeholder="Search city, state, or venue…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
</span>
<div className="sm:ml-auto">
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
<button
type="button"
role="tab"
aria-selected={view === "calendar"}
onClick={() => setView("calendar")}
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
Calendar
</button>
<button
type="button"
role="tab"
aria-selected={view === "table"}
onClick={() => setView("table")}
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
Table
</button>
</div>
</div>
</div>
{view === "calendar" ? (
<StopsCalendarClient stops={filtered} />
) : (
<StopTableClient stops={filtered} hideInternalFilterBar />
)}
</>
);
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
type Props = {
tabKey: string;
children: React.ReactNode;
};
/**
* Fades the content of a tabbed panel when the active tab changes.
* The tab key drives the animation, so switching tabs triggers a quick
* opacity-only fade-in of the new content.
*
* Motion note: the previous version used `y: 4` / `y: -4` on enter/exit
* for a small slide. That 4px slide, multiplied across every tab change
* in the admin, was a steady source of positional movement. Now it's
* pure opacity, 120ms, ease-out. `reducedMotion="user"` on the global
* <MotionConfig> will further strip this to instant if the user has
* prefers-reduced-motion enabled.
*
* No motion when the tab key is stable (i.e. on first render of a given tab).
*/
export function TabSwitcher({ tabKey, children }: Props) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tabKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: "easeOut" }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
@@ -1,95 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getTaxSummaryAction } from "@/actions/tax";
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
};
function quarterLabel(): string {
const now = new Date();
const q = Math.floor(now.getMonth() / 3) + 1;
return `Q${q} ${now.getFullYear()}`;
}
function quarterDateRange(): { start: string; end: string } {
const now = new Date();
const q = Math.floor(now.getMonth() / 3);
const startDate = new Date(now.getFullYear(), q * 3, 1);
const end = now.toISOString().slice(0, 10);
return { start: startDate.toISOString().slice(0, 10), end };
}
export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
const [data, setData] = useState<TaxSummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const range = quarterDateRange();
let cancelled = false;
(async () => {
const result = await getTaxSummaryAction({
brandId,
startDate: range.start,
endDate: range.end,
});
if (cancelled) return;
if (result.success) {
setData({
total_tax_collected: result.data.total_tax_collected,
total_gross_sales: result.data.total_gross_sales,
order_count: result.data.order_count,
});
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-xs text-[var(--admin-text-secondary)]">Loading tax summary...</span>
</div>
);
}
if (!data || data.order_count === 0) return null;
const effectiveRate = data.total_gross_sales > 0
? (data.total_tax_collected / data.total_gross_sales) * 100
: 0;
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]">
{quarterLabel()} Tax Collected
</span>
</div>
<Link
href="/admin/taxes"
className="text-xs text-[var(--admin-accent-text)] hover:text-[var(--admin-accent)] font-medium transition-colors"
>
View Details
</Link>
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-xl font-bold text-[var(--admin-text-primary)]">
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
<span className="text-xs text-[var(--admin-text-muted)]">
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
</span>
</div>
</div>
);
}
@@ -1,79 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
};
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
return (
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
/>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
<div>
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
>
<svg aria-hidden="true" className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
</dialog>
);
}
@@ -1,6 +0,0 @@
// Toast notification system - re-exports for design-system
// The actual implementations are in the parent admin directory
// Use: import { ToastProvider, useToast, useToastActions, ToastContainer } from "@/components/admin/Toast";
export { ToastProvider, useToast, useToastActions } from "@/components/admin/Toast";
export { ToastContainer, InlineToast } from "@/components/admin/ToastContainer";
-123
View File
@@ -1,123 +0,0 @@
"use client";
import { useState } from "react";
type Column<T> = {
key: keyof T | string;
header: string;
render?: (item: T) => React.ReactNode;
className?: string;
align?: "left" | "right" | "center";
};
type DataTableProps<T> = {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowClassName?: string;
paginate?: boolean;
pageSize?: number;
};
export default function DataTable<T>({
data,
columns,
keyExtractor,
emptyMessage = "No data found",
onRowClick,
rowClassName,
paginate = false,
pageSize = 50,
}: DataTableProps<T>) {
const [page, setPage] = useState(0);
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
const totalPages = Math.ceil(data.length / pageSize);
const alignClass = (align?: "left" | "right" | "center") => {
switch (align) {
case "right": return "text-right";
case "center": return "text-center";
default: return "text-left";
}
};
return (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50">
{columns.map((col) => (
<th
key={String(col.key)}
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{displayed.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
{emptyMessage}
</td>
</tr>
) : (
displayed.map((item) => (
<tr
key={keyExtractor(item)}
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
onRowClick ? "cursor-pointer" : ""
} ${rowClassName ?? ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<td
key={String(col.key)}
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{paginate && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
<span className="text-xs text-stone-500">
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
"use client";
type FilterOption = {
value: string;
label: string;
};
type FilterBarProps = {
searchPlaceholder?: string;
searchValue: string;
onSearchChange: (value: string) => void;
filters?: {
label?: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}[];
tabs?: {
label: string;
value: string;
count?: number;
}[];
activeTab?: string;
onTabChange?: (value: string) => void;
actions?: React.ReactNode;
resultCount?: number;
showCount?: boolean;
};
export default function FilterBar({
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
tabs = [],
activeTab,
onTabChange,
actions,
resultCount,
showCount = true,
}: FilterBarProps) {
return (
<div className="space-y-3">
{/* Search + Filters Row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<input aria-label="Search"
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
</div>
{filters.map((filter, i) => (
<div key={`${filter.label ?? ""}-${filter.value}-${i}`} className="space-y-1">
{filter.label && (
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
)}
<select aria-label="Select"
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
{actions && <div className="flex items-center gap-2">{actions}</div>}
{showCount && resultCount !== undefined && (
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
)}
</div>
{/* Tabs Row */}
{tabs.length > 0 && (
<div className="flex gap-1 border-b border-stone-200 pb-0">
{tabs.map((tab) => (
<button type="button"
key={tab.value}
onClick={() => onTabChange?.(tab.value)}
className={`
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
${activeTab === tab.value
? "text-emerald-600 border-emerald-600"
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
}
`}
>
{tab.label}
{tab.count !== undefined && (
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,42 +0,0 @@
"use client";
import Link from "next/link";
type PageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
title: string;
description?: string;
action?: React.ReactNode;
};
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
return (
<div className="mb-8">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
{breadcrumb.map((crumb, i) => (
<span key={`${crumb.label}-${i}`} className="flex items-center gap-2">
{crumb.href ? (
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-stone-600">{crumb.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
{description && (
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
</div>
);
}
@@ -1,51 +0,0 @@
type StatusBadgeProps = {
status: string;
size?: "sm" | "md";
};
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
// Active/Enabled states
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
// Pending/Warning states
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
// Inactive/Disabled states
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
// Info states
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
// Special states
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
};
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
const config = STATUS_STYLES[status] ?? {
bg: "bg-stone-100",
text: "text-stone-600",
label: status.replace(/_/g, " "),
};
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
return (
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
{config.label}
</span>
);
}
-4
View File
@@ -1,4 +0,0 @@
export { default as PageHeader } from "./PageHeader";
export { default as StatusBadge } from "./StatusBadge";
export { default as FilterBar } from "./FilterBar";
export { default as DataTable } from "./DataTable";
@@ -1,432 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type Stop,
type StopStatus,
formatMonthYear,
formatTime12,
getStopDate,
getStopStatus,
isSameDay,
} from "./types";
type Props = {
stops: Stop[];
};
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
active: {
bg: "bg-[var(--admin-accent-light)]",
text: "text-[var(--admin-accent-text)]",
dot: "bg-[var(--admin-accent-dot)]",
border: "border-[var(--admin-accent)]/40",
},
draft: {
bg: "bg-amber-50",
text: "text-amber-800",
dot: "bg-amber-500",
border: "border-amber-300",
},
inactive: {
bg: "bg-stone-100",
text: "text-stone-500",
dot: "bg-stone-400",
border: "border-stone-300",
},
};
function buildMonthGrid(viewYear: number, viewMonth: number) {
// Sunday-first 6-row grid
const first = new Date(viewYear, viewMonth, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells: { date: Date; inMonth: boolean }[] = [];
// Leading days from previous month
for (let i = startWeekday - 1; i >= 0; i--) {
const d = new Date(viewYear, viewMonth, -i);
cells.push({ date: d, inMonth: false });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
}
// Trailing days to fill 6 rows = 42 cells
while (cells.length < 42) {
const last = cells[cells.length - 1].date;
const next = new Date(last);
next.setDate(last.getDate() + 1);
cells.push({ date: next, inMonth: false });
}
return cells;
}
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export default function StopsCalendar({ stops }: Props) {
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [selectedDate, setSelectedDate] = useState<string | null>(() => ymd(today));
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
const stopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
const k = ymd(d);
const arr = map.get(k) ?? [];
arr.push(s);
map.set(k, arr);
}
// sort each bucket by time
for (const arr of map.values()) {
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const visibleStopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const [k, arr] of stopsByDate.entries()) {
map.set(
k,
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
);
}
return map;
}, [stopsByDate]);
const monthStopsCount = useMemo(() => {
let count = 0;
for (const cell of cells) {
if (!cell.inMonth) continue;
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
}
return count;
}, [cells, stopsByDate]);
const upcomingWeek = useMemo(() => {
const end = new Date(today);
end.setDate(end.getDate() + 7);
let count = 0;
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
}
return count;
}, [stops, today]);
const goPrev = () => {
setView((v) => {
const m = v.month - 1;
if (m < 0) return { year: v.year - 1, month: 11 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goNext = () => {
setView((v) => {
const m = v.month + 1;
if (m > 11) return { year: v.year + 1, month: 0 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goToday = () => {
setView({ year: today.getFullYear(), month: today.getMonth() });
setSelectedDate(ymd(today));
};
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
{/* Calendar card */}
<section
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
aria-label="Stops calendar"
>
{/* Decorative paper texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.4]"
style={{
backgroundImage:
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
{/* Header */}
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
<div className="flex items-baseline gap-4">
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
{monthLabel.split(" ")[0]}
<span className="text-[var(--admin-accent)] italic font-light">
{" "}
{monthLabel.split(" ")[1]}
</span>
</h2>
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
</span>
</div>
<div className="flex items-center gap-1.5">
<button type="button"
onClick={goPrev}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<button type="button"
onClick={goToday}
disabled={isCurrentMonth}
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Today
</button>
<button type="button"
onClick={goNext}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</header>
{/* Weekday header */}
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
{WEEKDAY_LABELS.map((d, i) => (
<div
key={d}
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
}`}
>
{d}
</div>
))}
</div>
{/* Grid */}
<div className="relative grid grid-cols-7 grid-rows-6">
{cells.map((cell, idx) => {
const k = ymd(cell.date);
const dayStops = stopsByDate.get(k) ?? [];
const visibleStops = visibleStopsByDate.get(k) ?? [];
const isToday = isSameDay(cell.date, today);
const isSelected = k === selectedDate;
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
const overflow = dayStops.length - visibleStops.length;
return (
<button
key={k}
type="button"
onClick={() => setSelectedDate(k)}
className={`
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
`}
>
{/* Date number row */}
<div className="flex items-center justify-between">
<span
className={`
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
${isToday
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
: !cell.inMonth
? "text-[var(--admin-text-muted)]/50"
: isSelected
? "text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-primary)]"
}
`}
>
{cell.date.getDate()}
</span>
{dayStops.length > 0 && cell.inMonth && (
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{dayStops.length}
</span>
)}
</div>
{/* Stop chips */}
<div className="flex flex-col gap-0.5 overflow-hidden">
{visibleStops.slice(0, 3).map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<div
key={s.id}
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
<span className="truncate">
{s.time && (
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
)}
{s.city}
</span>
</div>
);
})}
{overflow > 0 && (
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
+{overflow} more
</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
const st = STATUS_STYLES[s];
return (
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
<span>{s}</span>
</div>
);
})}
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span>
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
{upcomingWeek}
</span>{" "}
upcoming in 7d
</span>
</div>
</footer>
</section>
{/* Day detail panel */}
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<div className="border-b border-[var(--admin-border)] px-5 py-4">
<div className="flex items-baseline gap-2">
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
: "Pick a day"}
</h3>
</div>
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "Select a date on the calendar"}
</p>
</div>
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
{!selectedDate ? (
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
No day selected
</div>
) : selectedDayStops.length === 0 ? (
<div className="px-4 py-10 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
Free day on the route
</p>
</div>
) : (
<ul className="space-y-1">
{selectedDayStops.map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
>
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
{formatTime12(s.time).split(" ")[0]}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{s.city}, {s.state}
</p>
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{formatTime12(s.time).split(" ")[1]}
</span>
</div>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
<div className="mt-1.5 flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
{status}
</span>
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
</span>
) : null}
</div>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
}
@@ -1,236 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { type Stop, type StopView, getStopStatus } from "./types";
import StopsCalendar from "./StopsCalendar";
import StopsLocations from "./StopsLocations";
import StopsList from "./StopsList";
type Props = {
stops: Stop[];
page?: number;
totalPages?: number;
totalCount?: number;
};
const TABS: { value: StopView; label: string; hint: string }[] = [
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
{ value: "list", label: "List", hint: "All stops in order" },
];
export default function StopsDashboardClient({ stops, page = 1, totalPages = 1, totalCount = 0 }: Props) {
const [view, setView] = useState<StopView>("calendar");
const stats = useMemo(() => {
const total = stops.length;
const active = stops.filter((s) => getStopStatus(s) === "active").length;
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
const venues = new Set(
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
).size;
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7 = new Date(today);
in7.setDate(today.getDate() + 7);
const upcoming = stops.filter((s) => {
if (!s.date) return false;
if (getStopStatus(s) === "inactive") return false;
const d = new Date(s.date + "T00:00:00");
return d >= today && d < in7;
}).length;
return { total, active, draft, cities, venues, upcoming };
}, [stops]);
return (
<div className="space-y-6">
{/* Top stats strip — almanac style */}
<section
aria-label="Stops overview"
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
Harvest Dispatch · Almanac
</p>
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
{stats.total === 0
? "No stops scheduled"
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
</span>
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
</p>
</div>
{/* Tab nav — binder-style with notched corners */}
<div className="flex items-end gap-1">
{TABS.map((t) => {
const isActive = t.value === view;
return (
<button
key={t.value}
type="button"
onClick={() => setView(t.value)}
aria-pressed={isActive}
className={`
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
${isActive
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
}
`}
>
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
{t.label}
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
{t.hint}
</span>
{isActive && (
<span
aria-hidden
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
/>
)}
</button>
);
})}
</div>
</div>
{/* Stat cells */}
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
<AlmanacStat
numeral="I"
label="Active"
value={stats.active}
accent
/>
<AlmanacStat
numeral="II"
label="Upcoming 7d"
value={stats.upcoming}
accent={stats.upcoming > 0}
/>
<AlmanacStat
numeral="III"
label="Cities"
value={stats.cities}
/>
<AlmanacStat
numeral="IV"
label="Venues"
value={stats.venues}
/>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="relative flex items-center justify-between border-t border-[var(--admin-border-light)] pt-4 mt-4">
<p className="font-mono text-xs text-[var(--admin-text-muted)]">
Showing {((page - 1) * 50) + 1}{Math.min(page * 50, totalCount)} of {totalCount}
</p>
<div className="flex items-center gap-1">
<a
href={page > 1 ? `?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
page <= 1
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
</a>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const p = i + 1;
return (
<a
key={p}
href={`?page=${p}`}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
p === page
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
{p}
</a>
);
})}
<a
href={page < totalPages ? `?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
page >= totalPages
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
</a>
</div>
</div>
)}
</section>
{/* Active view */}
<section>
{view === "calendar" && <StopsCalendar stops={stops} />}
{view === "locations" && <StopsLocations stops={stops} />}
{view === "list" && <StopsList stops={stops} />}
</section>
</div>
);
}
function AlmanacStat({
numeral,
label,
value,
accent,
}: {
numeral: string;
label: string;
value: number;
accent?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<span
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
{numeral}
</span>
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{label}
</p>
<p
className={`font-display text-2xl font-medium leading-none tabular-nums ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
"use client";
import { useMemo } from "react";
import { type Stop, getStopStatus } from "./types";
type Props = {
stops: Stop[];
};
// Minimal list view used inside the StopsDashboard tab nav.
// It is a calmer, read-only list; for bulk publish/edit use the
// dedicated /admin/stops page.
export default function StopsList({ stops }: Props) {
const sorted = useMemo(() => {
return stops.toSorted((a, b) => (a.date || "").localeCompare(b.date || ""));
}, [stops]);
if (sorted.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Switch to a different tab to add stops.
</p>
</div>
);
}
return (
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
{sorted.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
return (
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{s.time ? s.time.slice(0, 5) : "—"}
</span>
<span className="flex-1 min-w-0">
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
{s.city}, {s.state}
</span>
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
</span>
{brand && (
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{brand}
</span>
)}
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</li>
);
})}
</ul>
);
}
@@ -1,412 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type LocationGroup,
type Stop,
formatTime12,
getStopStatus,
} from "./types";
type Props = {
stops: Stop[];
};
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
const map = new Map<string, LocationGroup>();
for (const s of stops) {
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
let group = map.get(key);
if (!group) {
group = {
key,
city: s.city || "Unknown",
state: s.state || "",
venueCount: 0,
total: 0,
active: 0,
draft: 0,
inactive: 0,
upcoming: 0,
nextDate: null,
firstDate: null,
lastDate: null,
sampleVenue: s.location,
stops: [],
};
map.set(key, group);
}
group.stops.push(s);
group.total += 1;
const status = getStopStatus(s);
if (status === "active") group.active += 1;
else if (status === "draft") group.draft += 1;
else group.inactive += 1;
if (s.date) {
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
}
}
// post-process: count distinct venues, upcoming stops
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
for (const g of map.values()) {
const venues = new Set(
g.stops.flatMap((s) => {
const trimmed = (s.location || "").trim().toLowerCase();
return trimmed ? [trimmed] : [];
})
);
g.venueCount = Math.max(1, venues.size);
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
const future: string[] = [];
for (const s of g.stops) {
if (s.date >= todayStr && getStopStatus(s) !== "inactive") {
future.push(s.date);
}
}
future.sort();
g.nextDate = future[0] ?? null;
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}
return Array.from(map.values()).sort((a, b) => {
// active locations with upcoming stops first, sorted by next date; then by city
if (a.nextDate && !b.nextDate) return -1;
if (!a.nextDate && b.nextDate) return 1;
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
return a.city.localeCompare(b.city);
});
}
function formatDateLabel(ymd: string | null): string {
if (!ymd) return "—";
const d = new Date(ymd + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatRange(a: string | null, b: string | null): string {
if (!a) return "—";
if (!b || a === b) return formatDateLabel(a);
return `${formatDateLabel(a)}${formatDateLabel(b)}`;
}
export default function StopsLocations({ stops }: Props) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [showOnlyActive, setShowOnlyActive] = useState(false);
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return groups.filter((g) => {
if (q) {
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (showOnlyActive && g.active === 0) return false;
return true;
});
}, [groups, query, showOnlyActive]);
// Aggregated stats
const stats = useMemo(() => {
const totalLocations = groups.length;
const totalStops = stops.length;
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
}, [groups, stops]);
if (stops.length === 0) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
No locations yet
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Create your first stop to see pickup locations here.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Stats strip — almanac style */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{[
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
].map((s) => (
<div
key={s.numeral}
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
>
<div className="flex items-start justify-between">
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{s.numeral} · {s.label}
</p>
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
{s.value}
</p>
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{s.hint}
</p>
</div>
<div
aria-hidden
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
>
{s.numeral}
</div>
</div>
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
</div>
))}
</section>
{/* Filter bar */}
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
<div className="relative flex-1 min-w-[200px] max-w-md">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input aria-label="Search City, State, Venue..."
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search city, state, venue..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
/>
</div>
<div className="flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={showOnlyActive}
onClick={() => setShowOnlyActive((v) => !v)}
aria-label="Show only active locations"
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
Active only
</span>
</div>
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{filtered.length} of {groups.length} locations
</span>
</section>
{/* Location grid */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
</div>
) : (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((g, idx) => {
const isOpen = expandedKey === g.key;
return (
<article
key={g.key}
className={`
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
`}
>
{/* Decorative route number */}
<div
aria-hidden
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
>
{String(idx + 1).padStart(2, "0")}
</div>
<button
type="button"
onClick={() => setExpandedKey(isOpen ? null : g.key)}
className="block w-full text-left p-5"
aria-expanded={isOpen}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Pin marker */}
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
</svg>
</span>
<div className="min-w-0">
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
{g.city}
{g.state && (
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
{g.state}
</span>
)}
</h3>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
{g.sampleVenue}
</p>
</div>
</div>
</div>
<div
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</div>
</div>
{/* Stat grid */}
<div className="mt-5 grid grid-cols-3 gap-2">
<Stat label="Stops" value={g.total} />
<Stat label="Active" value={g.active} accent={g.active > 0} />
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
</div>
{/* Date range + drafts */}
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
<div className="text-[var(--admin-text-muted)]">
{g.firstDate ? (
<>
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
{" · "}
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
</>
) : (
"No dates"
)}
</div>
{g.draft > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
{g.draft} draft
</span>
)}
</div>
{/* Next up ribbon */}
{g.nextDate && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
Next
</span>
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
{formatDateLabel(g.nextDate)}
</span>
</div>
)}
</button>
{/* Expanded stop list */}
{isOpen && (
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
All stops · {g.stops.length}
</h4>
<Link
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
>
+ Add at this city
</Link>
</div>
<ul className="space-y-1">
{g.stops.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{formatTime12(s.time)}
</span>
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
{s.location}
</span>
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</Link>
</li>
);
})}
</ul>
</div>
)}
</article>
);
})}
</section>
)}
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div
className={`rounded-lg border px-2.5 py-2 ${
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
}`}
>
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
<p
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
export type StopStatus = "draft" | "active" | "inactive";
export type Stop = {
id: string;
city: string;
state: string;
date: string; // YYYY-MM-DD
time: string; // HH:MM
location: string;
active: boolean;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
cutoff_date?: string | null;
brands: { name: string } | { name: string }[];
};
export type StopView = "calendar" | "locations" | "list";
export type LocationGroup = {
key: string;
city: string;
state: string;
venueCount: number; // distinct location strings at this city
total: number;
active: number;
draft: number;
inactive: number;
upcoming: number;
nextDate: string | null; // YYYY-MM-DD
firstDate: string | null;
lastDate: string | null;
sampleVenue: string;
stops: Stop[];
};
export function getStopStatus(s: Stop): StopStatus {
if (s.status === "draft") return "draft";
return s.active ? "active" : "inactive";
}
export function getStopDate(s: Stop): Date | null {
if (!s.date) return null;
const d = new Date(s.date + "T00:00:00");
return isNaN(d.getTime()) ? null : d;
}
export function formatTime12(time: string): string {
if (!time) return "—";
const [h, m] = time.split(":");
const hour = parseInt(h, 10);
if (isNaN(hour)) return time;
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
}
export function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
-118
View File
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
interface ChangelogEntry {
id: string;
version: string;
date: string;
title: string;
description: string;
category: "feature" | "improvement" | "bugfix" | "security";
}
interface ChangelogFeedProps {
entries?: ChangelogEntry[];
}
const DEFAULT_ENTRIES: ChangelogEntry[] = [
{
id: "1",
version: "2.4.0",
date: "2025-01-15",
title: "Harvest Reach Email Campaigns",
description: "Send beautiful email campaigns to your customers with templates, scheduling, and analytics.",
category: "feature",
},
{
id: "2",
version: "2.3.0",
date: "2025-01-08",
title: "Square Inventory Sync",
description: "Two-way sync with Square POS for products and inventory.",
category: "feature",
},
{
id: "3",
version: "2.2.0",
date: "2024-12-10",
title: "AI Intelligence Pack",
description: "Campaign writer, pricing advisor, and demand forecasting powered by AI.",
category: "feature",
},
];
const categoryColors = {
feature: "bg-emerald-100 text-emerald-700",
improvement: "bg-blue-100 text-blue-700",
bugfix: "bg-amber-100 text-amber-700",
security: "bg-purple-100 text-purple-700",
};
const categoryLabels = {
feature: "New Feature",
improvement: "Improvement",
bugfix: "Bug Fix",
security: "Security",
};
export default function ChangelogFeed({ entries = DEFAULT_ENTRIES }: ChangelogFeedProps) {
const [readItems, setReadItems] = useState<Set<string>>(new Set());
const [showAll, setShowAll] = useState(false);
const displayedEntries = showAll ? entries : entries.slice(0, 5);
const markAsRead = (id: string) => {
setReadItems((prev) => new Set([...prev, id]));
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
};
return (
<div className="space-y-4">
{displayedEntries.map((entry) => {
const isRead = readItems.has(entry.id);
return (
<div
key={entry.id}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
markAsRead(entry.id);
}
}}
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md cursor-pointer ${
isRead ? "border-gray-200 opacity-70" : "border-emerald-200 shadow-sm"
}`}
onClick={() => markAsRead(entry.id)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded-md text-xs font-semibold ${categoryColors[entry.category]}`}>
{categoryLabels[entry.category]}
</span>
<span className="text-xs font-mono text-gray-400">v{entry.version}</span>
</div>
<time className="text-xs text-gray-400">{formatDate(entry.date)}</time>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{entry.title}</h3>
<p className="text-sm text-gray-600">{entry.description}</p>
</div>
);
})}
{entries.length > 5 && (
<button type="button"
onClick={() => setShowAll(!showAll)}
className="w-full py-3 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
>
{showAll ? "Show less" : `Show ${entries.length - 5} more updates`}
</button>
)}
</div>
);
}
-683
View File
@@ -1,683 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ReactElement } from "react";
interface Feature {
icon: ReactElement;
title: string;
description: string;
bgColor: string;
borderColor: string;
hoverBg: string;
}
interface Stat {
prefix: string;
number: number;
suffix: string;
label: string;
}
const features: Feature[] = [
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
title: "Product Catalogs",
description:
"Showcase your seasonal produce with rich media galleries, detailed specs, and real-time availability updates.",
bgColor: "#f0f5f1",
borderColor: "#6b8f71",
hoverBg: "#e8f0e9",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
),
title: "Route Planning",
description:
"Optimize delivery routes across your farm network with intelligent mapping and scheduling tools.",
bgColor: "#fdf6f0",
borderColor: "#c97a3e",
hoverBg: "#fbeeda",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
),
title: "Order Management",
description:
"Track orders from placement through delivery with automated status updates and seamless integrations.",
bgColor: "#f5f0f8",
borderColor: "#7a5c9e",
hoverBg: "#ece6f5",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3h18v18H3zM21 9H3M9 21V9" />
</svg>
),
title: "Wholesale Portal",
description:
"Give buyers a dedicated space to browse pricing, request quotes, and place bulk orders on their schedule.",
bgColor: "#f0f5f5",
borderColor: "#5c8a8f",
hoverBg: "#e5f0f2",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
),
title: "Harvest Reach",
description:
"Send email and SMS campaigns that keep buyers informed about seasonal availability and new harvests.",
bgColor: "#faf5f0",
borderColor: "#c97a3e",
hoverBg: "#f5ebe0",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
title: "Smart Analytics",
description:
"Uncover sales trends, buyer behavior, and operational insights with real-time dashboards and reports.",
bgColor: "#f5f8f0",
borderColor: "#6b8f71",
hoverBg: "#eaf3e5",
},
];
const stats: Stat[] = [
{ prefix: "", number: 500, suffix: "+", label: "Produce Brands" },
{ prefix: "", number: 50000, suffix: "+", label: "Orders Delivered" },
{ prefix: "", number: 98, suffix: "%", label: "On-Time Delivery" },
{ prefix: "$", number: 2, suffix: "M+", label: "Weekly Sales" },
];
export default function FeaturesAndStats() {
const [countersStarted, setCountersStarted] = useState(false);
const [counters, setCounters] = useState<number[]>(() => stats.map(() => 0));
const statsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !countersStarted) {
setCountersStarted(true);
}
},
{ threshold: 0.3 }
);
if (statsRef.current) {
observer.observe(statsRef.current);
}
return () => observer.disconnect();
}, [countersStarted]);
useEffect(() => {
if (!countersStarted) return;
const duration = 2000;
const steps = 60;
const interval = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = step / steps;
const easeOut = 1 - Math.pow(1 - progress, 3);
setCounters(
stats.map((stat) => {
if (stat.number >= 1000) {
return Math.floor(stat.number * easeOut);
} else if (stat.number >= 100) {
return Math.floor(stat.number * easeOut);
} else if (stat.number >= 10) {
return Math.floor(stat.number * easeOut);
} else {
return Math.floor(stat.number * easeOut);
}
})
);
if (step >= steps) {
clearInterval(timer);
setCounters(stats.map((stat) => stat.number));
}
}, interval);
return () => clearInterval(timer);
}, [countersStarted]);
return (
<div
style={{
fontFamily: "var(--font-manrope)",
background: "#faf8f5",
color: "#1a1a1a",
minHeight: "100vh",
}}
>
<style dangerouslySetInnerHTML={{ __html: `
.section-label {
font-family: var(--font-manrope);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #c97a3e;
margin-bottom: 0.75rem;
}
.section-title {
font-family: var(--font-fraunces);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 600;
color: #1a4d2e;
line-height: 1.15;
margin-bottom: 1rem;
}
.section-subtitle {
font-family: var(--font-manrope);
font-size: 1.125rem;
color: #4a4a4a;
line-height: 1.6;
max-width: 540px;
}
.feature-card {
border-radius: 16px;
padding: 2rem;
border: 1px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: default;
opacity: 0;
transform: translateY(24px);
animation: fa-fade-in-up 0.6s ease forwards;
}
.feature-card:hover {
transform: translateY(-6px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
}
.feature-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
transition: transform 0.3s ease;
}
.feature-card:hover .feature-icon {
transform: scale(1.05);
}
.feature-title {
font-family: var(--font-fraunces);
font-size: 1.375rem;
font-weight: 600;
color: #1a4d2e;
margin-bottom: 0.625rem;
line-height: 1.3;
}
.feature-description {
font-size: 0.9375rem;
color: #555;
line-height: 1.65;
}
.stat-block {
text-align: center;
opacity: 0;
transform: translateY(20px);
animation: fa-fade-in-up 0.5s ease forwards;
}
.stat-number {
font-family: var(--font-fraunces);
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
color: #1a4d2e;
line-height: 1;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9375rem;
color: #6b8f71;
font-weight: 500;
letter-spacing: 0.02em;
}
.stats-divider {
width: 1px;
background: linear-gradient(
to bottom,
transparent,
#c97a3e40,
transparent
);
align-self: stretch;
margin: 0.1rem 0;
}
.section-divider {
width: 48px;
height: 2px;
background: linear-gradient(
to right,
#c97a3e,
#1a4d2e
);
border-radius: 1px;
margin: 0;
}
@keyframes fa-fade-in-up {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fa-slide-in-left {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fa-slide-in-right {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fa-scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-left {
animation: fa-slide-in-left 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-right {
animation: fa-slide-in-right 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-scale {
animation: fa-scale-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
.stagger-6 { animation-delay: 0.6s; }
.stat-stagger-1 { animation-delay: 0.15s; }
.stat-stagger-2 { animation-delay: 0.3s; }
.stat-stagger-3 { animation-delay: 0.45s; }
.stat-stagger-4 { animation-delay: 0.6s; }
`}} />
{/* ─── FEATURES SECTION ─── */}
<section
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "6rem 1.5rem",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
marginBottom: "4rem",
}}
>
<div
className="animate-scale"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<span className="section-label" style={{ marginBottom: "0.75rem" }}>
Platform Features
</span>
<div className="section-divider" style={{ marginBottom: "1.25rem" }} />
<h2 className="section-title" style={{ marginBottom: "1rem" }}>
Everything You Need
</h2>
<p
className="section-subtitle"
style={{
maxWidth: "520px",
marginBottom: "0",
color: "#5a5a5a",
fontSize: "1.0625rem",
}}
>
From farm to table, manage your entire produce supply chain in one
place.
</p>
</div>
</div>
{/* Feature Cards Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: "1.25rem",
}}
>
{features.map((feature, index) => (
<div
key={feature.title}
className={`feature-card stagger-${index + 1}`}
style={{
backgroundColor: feature.bgColor,
borderColor: `rgba(${index % 2 === 0 ? "107,143,113" : "201,122,62"}, 0.15)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = feature.hoverBg;
e.currentTarget.style.borderColor = feature.borderColor;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = feature.bgColor;
e.currentTarget.style.borderColor = `rgba(${
index % 2 === 0 ? "107,143,113" : "201,122,62"
}, 0.15)`;
}}
>
<div
className="feature-icon"
style={{
background: `linear-gradient(135deg, ${feature.borderColor}18, ${feature.borderColor}10)`,
color: feature.borderColor,
border: `1px solid ${feature.borderColor}20`,
}}
>
{feature.icon}
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-description">{feature.description}</p>
</div>
))}
</div>
</section>
{/* ─── STATS SECTION ─── */}
<section
ref={statsRef}
style={{
background: "#1a4d2e",
padding: "5rem 1.5rem",
position: "relative",
overflow: "hidden",
}}
>
{/* Subtle background pattern */}
<div
style={{
position: "absolute",
inset: 0,
opacity: 0.04,
backgroundImage:
"radial-gradient(circle at 25% 25%, #faf8f5 1px, transparent 1px), radial-gradient(circle at 75% 75%, #faf8f5 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
{/* Decorative top line */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "3px",
background:
"linear-gradient(to right, #c97a3e, #faf8f5, #c97a3e)",
}}
/>
<div
style={{
maxWidth: "1000px",
margin: "0 auto",
position: "relative",
zIndex: 1,
}}
>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: "3.5rem" }}>
<span
style={{
display: "block",
fontFamily: "var(--font-manrope)",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "#c97a3e",
marginBottom: "0.75rem",
}}
>
Our Reach
</span>
<h2
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2rem, 4vw, 2.75rem)",
fontWeight: 600,
color: "#faf8f5",
lineHeight: 1.2,
margin: "0 0 0.75rem",
}}
>
Growing Together
</h2>
<div
style={{
width: "48px",
height: "2px",
background: "linear-gradient(to right, transparent, #c97a3e, transparent)",
borderRadius: "1px",
margin: "0 auto",
}}
/>
</div>
{/* Stats Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
alignItems: "center",
}}
>
{stats.map((stat, index) => (
<div
key={stat.label}
className={`stat-block stat-stagger-${index + 1}`}
>
<div
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2.25rem, 4.5vw, 3.75rem)",
fontWeight: 700,
color: "#faf8f5",
lineHeight: 1,
marginBottom: "0.5rem",
display: "flex",
alignItems: "baseline",
justifyContent: "center",
gap: "0.1em",
}}
>
{stat.prefix && (
<span
style={{
fontSize: "0.55em",
fontWeight: 500,
color: "#c97a3e",
marginRight: "0.05em",
}}
>
{stat.prefix}
</span>
)}
<span>
{counters[index].toLocaleString()}
</span>
<span
style={{
fontSize: "0.55em",
fontWeight: 500,
color: "#c97a3e",
marginLeft: "0.05em",
}}
>
{stat.suffix}
</span>
</div>
<p
style={{
fontFamily: "var(--font-manrope)",
fontSize: "0.875rem",
fontWeight: 500,
color: "#6b8f71",
letterSpacing: "0.02em",
margin: 0,
}}
>
{stat.label}
</p>
{/* Divider between stats */}
{index < stats.length - 1 && (
<div
style={{
display: "none",
}}
className="stats-divider"
/>
)}
</div>
))}
</div>
</div>
</section>
</div>
);
}
@@ -1,670 +0,0 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
const testimonials = [
{
initials: "TC",
name: "Tuxedo Corn Co.",
location: "Olathe, Colorado",
quote:
"Route Commerce transformed how we manage our wholesale operations. Our pickup efficiency increased by 40% in the first month.",
},
{
initials: "IR",
name: "Indian River Citrus",
location: "Indian River, Florida",
quote:
"The Harvest Reach feature alone has recovered thousands in abandoned cart revenue. Game changer for our business.",
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: "easeOut" as const,
},
},
};
const cardHoverVariants = {
rest: { y: 0, boxShadow: "0 4px 24px rgba(26, 77, 46, 0.08)" },
hover: {
y: -4,
boxShadow: "0 12px 40px rgba(26, 77, 46, 0.12)",
transition: { duration: 0.3, ease: "easeOut" },
},
};
export default function TestimonialsAndCTA() {
return (
<>
{/* ─── Testimonials Section ─────────────────────────────────────────────── */}
<section
style={{
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
fontFamily: "var(--font-manrope)",
}}
>
{/* Decorative botanical accent */}
<div
style={{
position: "absolute",
left: "5%",
top: "20%",
width: "120px",
height: "120px",
opacity: 0.06,
background: "radial-gradient(ellipse at center, #1a4d2e 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div
style={{
position: "absolute",
right: "8%",
bottom: "15%",
width: "80px",
height: "80px",
opacity: 0.05,
background: "radial-gradient(ellipse at center, #c97a3e 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div className="relative mx-auto max-w-6xl px-6 py-24">
{/* Section header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.7, ease: [0.22, 0.61, 0.36, 1] }}
className="mb-16 text-center"
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#6b8f71",
marginBottom: "12px",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Testimonials
</span>
<h2
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2.5rem, 5vw, 3.5rem)",
fontWeight: 600,
lineHeight: 1.1,
color: "#1a1a1a",
letterSpacing: "-0.02em",
}}
>
Trusted by Farms
</h2>
<p
style={{
marginTop: "16px",
fontSize: "1.125rem",
color: "#555555",
maxWidth: "480px",
marginLeft: "auto",
marginRight: "auto",
}}
>
Produce operations across the country rely on Route Commerce to power their farm-fresh distribution.
</p>
</motion.div>
{/* Testimonial cards */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-40px" }}
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
gap: "24px",
maxWidth: "800px",
margin: "0 auto",
}}
>
{testimonials.map((testimonial) => (
<motion.div
key={testimonial.name}
variants={itemVariants}
style={{
background: "linear-gradient(145deg, #ffffff 0%, #fdfcfa 100%)",
borderRadius: "20px",
padding: "32px",
border: "1px solid rgba(26, 77, 46, 0.08)",
position: "relative",
overflow: "hidden",
}}
whileHover="hover"
initial="rest"
animate="rest"
>
{/* Decorative quote mark */}
<div
style={{
position: "absolute",
top: "-8px",
right: "20px",
fontSize: "80px",
fontFamily: "var(--font-fraunces)",
color: "rgba(26, 77, 46, 0.06)",
lineHeight: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
&ldquo;
</div>
{/* Avatar with gradient */}
<div
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
background: `linear-gradient(135deg, #1a4d2e 0%, #6b8f71 50%, #c97a3e 100%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
boxShadow: "0 4px 12px rgba(26, 77, 46, 0.15)",
}}
>
<span
style={{
color: "#ffffff",
fontSize: "1rem",
fontWeight: 700,
letterSpacing: "0.05em",
textShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
>
{testimonial.initials}
</span>
</div>
{/* Quote text */}
<blockquote
style={{
fontSize: "1rem",
lineHeight: 1.7,
color: "#1a1a1a",
margin: 0,
marginBottom: "20px",
position: "relative",
zIndex: 1,
}}
>
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
{/* Attribution */}
<div style={{ borderTop: "1px solid rgba(0,0,0,0.06)", paddingTop: "16px" }}>
<p
style={{
fontWeight: 700,
fontSize: "0.9375rem",
color: "#1a1a1a",
margin: 0,
}}
>
{testimonial.name}
</p>
<p
style={{
fontSize: "0.8125rem",
color: "#6b8f71",
margin: 0,
marginTop: "4px",
fontWeight: 500,
}}
>
{testimonial.location}
</p>
</div>
</motion.div>
))}
</motion.div>
{/* Trust indicators */}
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
style={{
display: "flex",
justifyContent: "center",
gap: "40px",
marginTop: "48px",
flexWrap: "wrap",
}}
>
{[
{ value: "200+", label: "Farms & Co-ops" },
{ value: "40%", label: "Avg. Efficiency Gain" },
{ value: "94%", label: "Customer Retention" },
].map((stat) => (
<div key={stat.label} style={{ textAlign: "center" }}>
<div
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "2.5rem",
fontWeight: 600,
color: "#1a4d2e",
lineHeight: 1,
}}
>
{stat.value}
</div>
<div
style={{
fontSize: "0.8125rem",
color: "#6b8f71",
marginTop: "6px",
fontWeight: 500,
}}
>
{stat.label}
</div>
</div>
))}
</motion.div>
</div>
</section>
{/* ─── CTA Section ──────────────────────────────────────────────────────── */}
<section
style={{
background: "linear-gradient(180deg, #f5f2ed 0%, #faf8f5 50%, #faf8f5 100%)",
position: "relative",
overflow: "hidden",
fontFamily: "var(--font-manrope)",
}}
>
{/* Decorative gradient orbs */}
<div
style={{
position: "absolute",
top: "10%",
left: "10%",
width: "300px",
height: "300px",
background: "radial-gradient(circle, rgba(26, 77, 46, 0.08) 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div
style={{
position: "absolute",
bottom: "0",
right: "5%",
width: "250px",
height: "250px",
background: "radial-gradient(circle, rgba(201, 122, 62, 0.06) 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
{/* Botanical line decoration */}
<svg
style={{
position: "absolute",
top: "20px",
right: "15%",
width: "60px",
height: "100px",
opacity: 0.15,
}}
viewBox="0 0 60 100"
fill="none"
aria-hidden="true"
>
<path
d="M30 100 C30 60, 10 50, 30 20 C50 50, 30 60, 30 100"
stroke="#1a4d2e"
strokeWidth="2"
fill="none"
/>
<path
d="M30 70 C20 60, 15 55, 25 45"
stroke="#1a4d2e"
strokeWidth="1.5"
fill="none"
/>
<path
d="M30 70 C40 60, 45 55, 35 45"
stroke="#1a4d2e"
strokeWidth="1.5"
fill="none"
/>
<circle cx="30" cy="15" r="3" fill="#6b8f71" />
<circle cx="22" cy="42" r="2" fill="#c97a3e" />
<circle cx="38" cy="42" r="2" fill="#c97a3e" />
</svg>
<div className="relative mx-auto max-w-4xl px-6 py-28 text-center">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
>
{/* Section label */}
<span
style={{
display: "inline-block",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#c97a3e",
marginBottom: "16px",
padding: "6px 14px",
background: "rgba(201, 122, 62, 0.1)",
borderRadius: "20px",
}}
>
Get Started Today
</span>
{/* Headline */}
<h2
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2.75rem, 6vw, 4rem)",
fontWeight: 600,
lineHeight: 1.1,
color: "#1a1a1a",
letterSpacing: "-0.02em",
marginBottom: "20px",
}}
>
Ready to Grow Your Routes?
</h2>
{/* Subtitle */}
<p
style={{
fontSize: "1.125rem",
lineHeight: 1.7,
color: "#555555",
maxWidth: "560px",
margin: "0 auto 36px",
}}
>
Join hundreds of produce brands who trust Route Commerce to power their farm-fresh operations.
</p>
{/* CTA Buttons */}
<div
style={{
display: "flex",
gap: "16px",
justifyContent: "center",
flexWrap: "wrap",
}}
>
{/* Primary button */}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Link
href="/admin"
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "16px 32px",
background: "rgba(26, 77, 46, 0.9)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.25)",
color: "#ffffff",
fontSize: "1rem",
fontWeight: 600,
borderRadius: "12px",
cursor: "pointer",
textDecoration: "none",
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.1)",
transition: "background 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease, border-color 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(45, 107, 79, 0.95)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(26, 77, 46, 0.45), inset 0 1px 0 rgba(255,255,255, 0.2)";
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.borderColor = "rgba(255,255,255, 0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(26, 77, 46, 0.9)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255,255,255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.1)";
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.borderColor = "rgba(255,255,255, 0.25)";
}}
>
Start Free Trial
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</motion.div>
{/* Secondary button */}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Link
href="/brands"
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "16px 32px",
background: "rgba(250, 248, 245, 0.8)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(26, 77, 46, 0.2)",
color: "#1a4d2e",
fontSize: "1rem",
fontWeight: 600,
borderRadius: "12px",
cursor: "pointer",
textDecoration: "none",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)",
transition: "background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.9)";
e.currentTarget.style.borderColor = "rgba(26, 77, 46, 0.4)";
e.currentTarget.style.boxShadow = "0 8px 24px rgba(26, 77, 46, 0.12), inset 0 1px 0 rgba(255,255,255, 0.6)";
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(250, 248, 245, 0.8)";
e.currentTarget.style.borderColor = "rgba(26, 77, 46, 0.2)";
e.currentTarget.style.boxShadow = "0 4px 16px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)";
e.currentTarget.style.transform = "translateY(0)";
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
View Farms
</Link>
</motion.div>
</div>
{/* Disclaimer */}
<p
style={{
marginTop: "24px",
fontSize: "0.8125rem",
color: "#888888",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.6 }}
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
No credit card required · 14-day free trial · Cancel anytime
</p>
</motion.div>
{/* Bottom decorative element */}
<div
style={{
marginTop: "60px",
display: "flex",
justifyContent: "center",
gap: "8px",
opacity: 0.3,
}}
>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
width: i === 2 ? "24px" : "6px",
height: "6px",
borderRadius: "3px",
background: i === 2 ? "#1a4d2e" : "#1a4d2e",
opacity: i === 2 ? 1 : 0.4,
}}
/>
))}
</div>
</div>
</section>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes tc-fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tc-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes tc-pulse-soft {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Scroll-triggered animations */
section {
scroll-margin-top: 80px;
}
/* Ensure smooth transitions */
a, button {
-webkit-tap-highlight-color: transparent;
}
`}} />
</>
);
}
@@ -1,219 +0,0 @@
// In-App Notification Center - Bell icon with dropdown panel
"use client";
import Link from "next/link";
import { useState, useEffect, useRef, useCallback } from "react";
interface Notification {
id: string;
title: string;
message: string;
type: "info" | "success" | "warning" | "error";
read: boolean;
created_at: string;
link?: string;
}
interface NotificationCenterProps {
brandId?: string;
userId?: string;
}
export default function NotificationCenter({ brandId, userId }: NotificationCenterProps) {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
// Define fetchNotifications before useEffect
const fetchNotifications = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/notifications?brand_id=${brandId}`);
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications || []);
}
} catch (error) {
console.error("Failed to fetch notifications:", error);
} finally {
setLoading(false);
}
}, [brandId]);
// Fetch notifications when opened — handled in the click handler so
// we don't need a useEffect to watch `isOpen` and re-fetch on every
// open transition. See handleToggleOpen below.
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const markAsRead = async (id: string) => {
try {
await fetch("/api/notifications", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, read: true }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
} catch (error) {
console.error("Failed to mark as read:", error);
}
};
const markAllAsRead = async () => {
try {
await fetch("/api/notifications/mark-all-read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand_id: brandId }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
} catch (error) {
console.error("Failed to mark all as read:", error);
}
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
const getTypeStyles = (type: string) => {
switch (type) {
case "success":
return "bg-emerald-100 text-emerald-600";
case "warning":
return "bg-amber-100 text-amber-600";
case "error":
return "bg-red-100 text-red-600";
default:
return "bg-blue-100 text-blue-600";
}
};
const handleToggleOpen = useCallback(async () => {
const next = !isOpen;
setIsOpen(next);
if (next && brandId) {
await fetchNotifications();
}
}, [isOpen, brandId, fetchNotifications]);
return (
<div className="relative" ref={dropdownRef}>
{/* Bell Icon Button */}
<button type="button"
onClick={handleToggleOpen}
className="relative p-2 hover:bg-gray-100 rounded-xl transition-colors"
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ""}`}
>
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button type="button"
onClick={markAllAsRead}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
Mark all as read
</button>
)}
</div>
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-8 text-center">
<div className="w-6 h-6 border-2 border-gray-300 border-t-[#1a4d2e] rounded-full animate-spin mx-auto" />
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-sm text-gray-500">No notifications yet</p>
</div>
) : (
<div className="divide-y divide-gray-50">
{notifications.map((notification) => (
<div
key={notification.id}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (!notification.read) markAsRead(notification.id);
if (notification.link) window.location.href = notification.link;
}
}}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
!notification.read ? "bg-emerald-50/50" : ""
}`}
onClick={() => {
if (!notification.read) markAsRead(notification.id);
if (notification.link) window.location.href = notification.link;
}}
>
<div className="flex items-start gap-3">
<span className={`shrink-0 w-2 h-2 mt-2 rounded-full ${getTypeStyles(notification.type)} ${notification.read ? "opacity-30" : ""}`} />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 text-sm">{notification.title}</p>
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{notification.message}</p>
<p className="text-xs text-gray-400 mt-2">{formatTime(notification.created_at)}</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3">
<Link href="/admin/notifications" className="text-sm text-emerald-600 hover:text-emerald-700 font-medium">
View all notifications
</Link>
</div>
</div>
)}
</div>
);
}
@@ -1,316 +0,0 @@
// Onboarding Flow - Welcome tour and interactive demo
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { analytics } from "@/lib/analytics";
interface OnboardingStep {
id: string;
title: string;
description: string;
icon: React.ReactNode;
action?: string;
}
const STEPS: OnboardingStep[] = [
{
id: "welcome",
title: "Welcome to Route Commerce",
description: "The all-in-one platform for fresh produce wholesale distribution. Let's take a quick tour to get you started.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
id: "products",
title: "Manage Your Products",
description: "Add your fresh produce with pricing, categories, and images. Perfect for showcasing your farm's best offerings.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
action: "Add First Product",
},
{
id: "stops",
title: "Schedule Pickup Stops",
description: "Create scheduled stops for customers to pick up their orders. Manage locations, times, and capacity.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
action: "Create First Stop",
},
{
id: "orders",
title: "Track Orders",
description: "Monitor customer orders in real-time. Handle pickups and shipments with ease.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
},
{
id: "communications",
title: "Harvest Reach",
description: "Send beautiful email campaigns to your customers. Keep them informed about seasonal offerings and promotions.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
{
id: "complete",
title: "You're All Set!",
description: "Your dashboard is ready. Start adding products and scheduling stops to grow your wholesale business.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
];
interface OnboardingProps {
brandId: string;
userId: string;
onComplete?: () => void;
}
export function OnboardingFlow({ brandId: _brandId, userId: _userId, onComplete }: OnboardingProps) {
const [currentStep, setCurrentStep] = useState(0);
const [isCompleted, setIsCompleted] = useState(false);
const router = useRouter();
useEffect(() => {
// Track onboarding start
analytics.onboardingStep("start", false);
}, []);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
const step = STEPS[currentStep];
analytics.onboardingStep(step.id, false);
setCurrentStep((prev) => prev + 1);
}
};
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1);
}
};
const handleSkip = () => {
analytics.onboardingStep("skipped", true);
setIsCompleted(true);
onComplete?.();
};
const handleComplete = () => {
analytics.onboardingStep("complete", true);
setIsCompleted(true);
onComplete?.();
};
const handleAction = () => {
const step = STEPS[currentStep];
analytics.buttonClicked(step.action || "next", "onboarding");
if (step.id === "products") {
router.push("/admin/products/new");
} else if (step.id === "stops") {
router.push("/admin/stops/new");
} else {
handleNext();
}
};
if (isCompleted) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="bg-white rounded-2xl p-8 max-w-md text-center"
>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete!</h2>
<p className="text-gray-600 mb-6">
You&apos;re ready to start growing your wholesale business.
</p>
<button type="button"
onClick={() => router.push("/admin")}
className="w-full px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
Go to Dashboard
</button>
</motion.div>
</div>
);
}
const step = STEPS[currentStep];
const progress = ((currentStep + 1) / STEPS.length) * 100;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden"
>
{/* Progress bar */}
<div className="h-1 bg-gray-200">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<div className="p-8">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{STEPS.map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
i <= currentStep ? "bg-primary" : "bg-gray-300"
}`}
/>
))}
</div>
{/* Icon */}
<div className="w-20 h-20 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-6 text-primary">
{step.icon}
</div>
{/* Content */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">{step.title}</h2>
<p className="text-gray-600">{step.description}</p>
</div>
{/* Actions */}
<div className="flex gap-3">
{currentStep > 0 ? (
<button type="button"
onClick={handlePrevious}
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
>
Back
</button>
) : (
<button type="button"
onClick={handleSkip}
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
>
Skip Tour
</button>
)}
{step.action ? (
<button type="button"
onClick={handleAction}
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
{step.action}
</button>
) : currentStep === STEPS.length - 1 ? (
<button type="button"
onClick={handleComplete}
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
Get Started
</button>
) : (
<button type="button"
onClick={handleNext}
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
Next
</button>
)}
</div>
</div>
{/* Step counter */}
<div className="bg-gray-50 px-8 py-4 text-center text-sm text-gray-500">
Step {currentStep + 1} of {STEPS.length}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
// Interactive demo component
export function InteractiveDemo({ onClose }: { onClose: () => void }) {
const [demoStep, setDemoStep] = useState(0);
const demoSteps = [
{ title: "Add Products", description: "Create your product catalog" },
{ title: "Schedule Stops", description: "Set up pickup locations" },
{ title: "Send Campaigns", description: "Email your customers" },
];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<h3 className="text-lg font-bold mb-4">Interactive Demo</h3>
<div className="space-y-4">
{demoSteps.map((step, i) => (
<div
key={step.title}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setDemoStep(i);
}}
className={`p-4 rounded-lg border-2 cursor-pointer transition-colors ${
i === demoStep ? "border-primary bg-primary/5" : "border-gray-200"
}`}
onClick={() => setDemoStep(i)}
>
<div className="font-medium">{step.title}</div>
<div className="text-sm text-gray-500">{step.description}</div>
</div>
))}
</div>
<div className="mt-6 flex gap-3">
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 border rounded-lg">
Close
</button>
<button type="button" className="flex-1 px-4 py-2 bg-primary text-white rounded-lg">
Start Demo
</button>
</div>
</div>
</div>
);
}
@@ -1,15 +0,0 @@
// Analytics Provider Component
"use client";
import { useEffect } from "react";
export default function AnalyticsProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Track initial page view
if (typeof window !== "undefined") {
console.log("[Analytics] Page loaded:", window.location.pathname);
}
}, []);
return <>{children}</>;
}
@@ -1,58 +0,0 @@
// Error Boundary Component
"use client";
import { Component, type ReactNode, type ErrorInfo } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Error boundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center max-w-md">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Something went wrong</h2>
<p className="text-gray-600 mb-4">We&apos;re sorry for the inconvenience. Please try refreshing the page.</p>
<button type="button"
onClick={() => window.location.reload()}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-500 transition-colors"
>
Refresh Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
-199
View File
@@ -1,199 +0,0 @@
// Referral System - Generate, share, and track referral codes
"use client";
import { useState } from "react";
import { analytics } from "@/lib/analytics";
interface ReferralCode {
code: string;
reward_type: "percentage" | "fixed";
reward_value: number;
uses: number;
max_uses: number;
created_at: string;
}
interface ReferralSystemProps {
brandId: string;
userId: string;
}
export function ReferralSystem({ brandId, userId }: ReferralSystemProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [selectedCode] = useState<ReferralCode | null>(null);
const generateCode = async () => {
setIsGenerating(true);
// Track event
analytics.featureUsed("referral_generate_code", { brand_id: brandId });
try {
const res = await fetch("/api/referrals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "generate",
brand_id: brandId,
user_id: userId,
}),
});
if (res.ok) {
// Code generated successfully - refresh list
analytics.referralShared("", "platform_select");
}
} catch (error) {
console.error("Failed to generate referral code:", error);
} finally {
setIsGenerating(false);
}
};
const shareCode = (code: ReferralCode, platform: string) => {
const shareUrl = `${window.location.origin}/register?ref=${code.code}`;
const text = `Join Route Commerce and get ${code.reward_value}% off your first month!`;
if (platform === "copy") {
navigator.clipboard.writeText(shareUrl);
analytics.referralShared(code.code, "copy_link");
} else if (platform === "email") {
window.location.href = `mailto:?subject=Try Route Commerce&body=${encodeURIComponent(text + "\n\n" + shareUrl)}`;
analytics.referralShared(code.code, "email");
} else if (platform === "twitter") {
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text + " " + shareUrl)}`, "_blank");
analytics.referralShared(code.code, "twitter");
} else if (platform === "facebook") {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, "_blank");
analytics.referralShared(code.code, "facebook");
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl p-6">
<div className="flex items-start justify-between">
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Refer & Earn</h3>
<p className="text-gray-600">
Share Route Commerce with other farms and earn rewards for each successful signup.
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">20%</div>
<div className="text-sm text-gray-500">Reward</div>
</div>
</div>
<button type="button"
onClick={generateCode}
disabled={isGenerating}
className="mt-4 w-full px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50"
>
{isGenerating ? "Generating..." : "Generate New Referral Code"}
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
<div className="text-2xl font-bold text-gray-900">0</div>
<div className="text-sm text-gray-500">Total Referrals</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
<div className="text-2xl font-bold text-green-600">0</div>
<div className="text-sm text-gray-500">Conversions</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
<div className="text-2xl font-bold text-gray-900">$0</div>
<div className="text-sm text-gray-500">Earned</div>
</div>
</div>
{/* How it works */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h4 className="font-semibold text-gray-900 mb-4">How It Works</h4>
<div className="space-y-4">
{[
{ step: "1", title: "Generate Code", desc: "Create your unique referral code" },
{ step: "2", title: "Share", desc: "Send to other farms via email or social" },
{ step: "3", title: "They Sign Up", desc: "Friend uses your code to register" },
{ step: "4", title: "Both Earn", desc: "Get 20% off, they get 20% off" },
].map((item) => (
<div key={item.title} className="flex items-start gap-4">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-primary font-bold">
{item.step}
</div>
<div>
<div className="font-medium text-gray-900">{item.title}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Share Modal */}
{shareModalOpen && selectedCode && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-md w-full p-6">
<h3 className="text-lg font-bold mb-4">Share Your Code</h3>
<div className="bg-gray-100 rounded-lg p-4 mb-4">
<code className="text-xl font-mono font-bold">{selectedCode.code}</code>
</div>
<div className="space-y-2">
<button type="button"
onClick={() => { shareCode(selectedCode, "copy"); setShareModalOpen(false); }}
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy Link
</button>
<button type="button"
onClick={() => { shareCode(selectedCode, "email"); setShareModalOpen(false); }}
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Email
</button>
<button type="button"
onClick={() => { shareCode(selectedCode, "twitter"); setShareModalOpen(false); }}
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
</svg>
Twitter
</button>
</div>
<button type="button"
onClick={() => setShareModalOpen(false)}
className="mt-4 w-full px-4 py-2 border border-gray-300 rounded-lg"
>
Close
</button>
</div>
</div>
)}
</div>
);
}
// Referral badge component for display
export function ReferralBadge({ code }: { code: string }) {
return (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<span>Ref: {code}</span>
</div>
);
}
@@ -1,310 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createHarvestLot } from "@/actions/route-trace/lots";
// One-color outline icons
const Icons = {
plant: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22V12M12 12C12 12 7 10 7 5c0-2.5 2.5-5 5-5s5 2.5 5 5c0 5-5 7-5 7z" />
</svg>
),
chevronUp: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m18 15-6-6-6 6"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
};
export default function LotCreateForm({ brandId }: { brandId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [notesOpen, setNotesOpen] = useState(false);
const [crop_type, setCropType] = useState("");
const [variety, setVariety] = useState("");
const [harvest_date, setHarvestDate] = useState(new Date().toISOString().split("T")[0]);
const [field_location, setFieldLocation] = useState("");
const [field_block, setFieldBlock] = useState("");
const [worker_name, setWorkerName] = useState("");
const [packer_name, setPackerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
const [yield_estimate_lbs, setYieldEstimateLbs] = useState("");
const [yield_unit, setYieldUnit] = useState("lbs");
const [customYieldUnit, setCustomYieldUnit] = useState("");
const [bin_id, setBinId] = useState("");
const [container_id, setContainerId] = useState("");
const [pallets, setPallets] = useState("");
const [notes, setNotes] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type,
variety: variety || undefined,
harvest_date,
field_location: field_location || undefined,
worker_name: worker_name || undefined,
packer_name: packer_name || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
notes: notes || undefined,
bin_id: bin_id || undefined,
container_id: container_id || undefined,
field_block: field_block || undefined,
yield_estimate_lbs: yield_estimate_lbs ? Number(yield_estimate_lbs) : undefined,
yield_unit: yield_unit === "custom" ? customYieldUnit.trim() || undefined : yield_unit,
pallets: pallets ? Number(pallets) : undefined,
});
if (result.success && result.lot) {
router.push(`/admin/route-trace/lots/${result.lot.id}`);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-green-100">
<span className="text-green-600">{Icons.plant("h-5 w-5")}</span>
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">New Harvest Lot</h2>
<p className="text-sm text-stone-500">Quick entry scan or fill in the fields below</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-700">{error}</div>
)}
{/* Required — large touch targets for field use */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-crop-type" className="block text-sm font-semibold text-stone-800 mb-2">Crop Type *</label>
<input id="fld-1-crop-type" aria-label=". Sweet Corn"
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-2-variety" className="block text-sm font-semibold text-stone-800 mb-2">Variety</label>
<input id="fld-2-variety" aria-label=". Golden Bantam"
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
placeholder="e.g. Golden Bantam"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label htmlFor="fld-3-harvest-date" className="block text-sm font-semibold text-stone-800 mb-2">Harvest Date *</label>
<input id="fld-3-harvest-date" aria-label="Date"
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
{/* Field & Location */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Field &amp; Location</p>
<div className="space-y-3">
<div>
<label htmlFor="fld-4-field-location" className="block text-xs font-medium text-stone-600 mb-1.5">Field / Location *</label>
<input id="fld-4-field-location" aria-label=". North Field"
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
placeholder="e.g. North Field"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-5-field-block" className="block text-xs font-medium text-stone-600 mb-1.5">Field Block</label>
<input id="fld-5-field-block" aria-label=". Block A"
type="text"
value={field_block}
onChange={(e) => setFieldBlock(e.target.value)}
placeholder="e.g. Block A"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-6-worker-harvest-lead" className="block text-xs font-medium text-stone-600 mb-1.5">Worker / Harvest Lead</label>
<input id="fld-6-worker-harvest-lead" aria-label=". Maria S."
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
placeholder="e.g. Maria S."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
</div>
{/* Quantity + Yield */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Weight &amp; Yield</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-7-actual-qty" className="block text-xs font-medium text-stone-600 mb-1.5">Actual Qty</label>
<input id="fld-7-actual-qty" aria-label=". 4800"
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
placeholder="e.g. 4800"
min="0"
step="0.01"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3.5 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5" htmlFor="fld-yield-est">Yield Est.</label>
<div className="flex rounded-xl border border-stone-200 bg-stone-50 overflow-hidden focus-within:border-emerald-600 focus-within:bg-white transition-colors">
<input id="fld-yield-est" aria-label=". 5000"
type="number"
value={yield_estimate_lbs}
onChange={(e) => setYieldEstimateLbs(e.target.value)}
placeholder="e.g. 5000"
min="0"
step="0.01"
className="flex-1 px-3 py-3.5 text-base outline-none bg-transparent"
/>
<select aria-label="Select"
value={yield_unit}
onChange={(e) => setYieldUnit(e.target.value)}
className="px-2 py-3.5 text-sm font-semibold text-stone-600 bg-stone-100 outline-none border-l border-stone-200 cursor-pointer"
>
<option value="lbs">lbs</option>
<option value="bushel">bu</option>
<option value="box">bx</option>
<option value="sack">sk</option>
<option value="crate">cr</option>
<option value="bin">bn</option>
<option value="pallet">plt</option>
<option value="custom">custom</option>
</select>
</div>
{yield_unit === "custom" && (
<input aria-label="Unit Name…"
type="text"
placeholder="Unit name…"
value={customYieldUnit}
onChange={(e) => setCustomYieldUnit(e.target.value)}
className="mt-1.5 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
)}
</div>
</div>
</div>
{/* Bin + Container */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Bin, Container &amp; Pallets</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-8-bin-id" className="block text-xs font-medium text-stone-600 mb-1.5">Bin ID</label>
<input id="fld-8-bin-id" aria-label=". BIN 001"
type="text"
value={bin_id}
onChange={(e) => setBinId(e.target.value)}
placeholder="e.g. BIN-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-9-container-id" className="block text-xs font-medium text-stone-600 mb-1.5">Container ID</label>
<input id="fld-9-container-id" aria-label=". CONT A42"
type="text"
value={container_id}
onChange={(e) => setContainerId(e.target.value)}
placeholder="e.g. CONT-A42"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-10-pallets" className="block text-xs font-medium text-stone-600 mb-1.5">Pallets</label>
<input id="fld-10-pallets" aria-label=". 4"
type="number"
value={pallets}
onChange={(e) => setPallets(e.target.value)}
placeholder="e.g. 4"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-11-packer-name" className="block text-xs font-medium text-stone-600 mb-1.5">Packer Name</label>
<input id="fld-11-packer-name" aria-label=". Jose R."
type="text"
value={packer_name}
onChange={(e) => setPackerName(e.target.value)}
placeholder="e.g. Jose R."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
{/* Notes — collapsible */}
<div className="border-t border-stone-100 pt-5">
<button
type="button"
onClick={() => setNotesOpen(!notesOpen)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-stone-400 hover:text-stone-600"
>
<span>Notes</span>
<span className="text-stone-500">{notesOpen ? Icons.chevronUp("h-4 w-4") : Icons.chevronDown("h-4 w-4")}</span>
</button>
{notesOpen && (
<textarea aria-label="Spray Records, Weather Conditions, Or Other Harvest Details..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Spray records, weather conditions, or other harvest details..."
rows={3}
className="mt-3 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors resize-none"
/>
)}
</div>
{/* Save — big button */}
<div className="flex items-center justify-end border-t border-stone-100 pt-5">
<button
type="submit"
disabled={isPending || !crop_type || !harvest_date || !field_location}
className="rounded-xl bg-green-600 px-8 py-4 text-base font-bold text-white hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm"
>
{isPending ? "Creating..." : <><span className="inline-flex items-center gap-1.5">{Icons.plant("h-4 w-4")} Create Lot</span></>}
</button>
</div>
</form>
</div>
);
}
@@ -1,12 +0,0 @@
"use client";
import { useState } from "react";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
export default function PublicTraceActions({ lotNumber }: { lotNumber: string }) {
return (
<div className="flex items-center justify-center gap-2 mb-4">
<ShareTraceButton lotNumber={lotNumber} />
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
"use client";
type EmptyStateProps = {
title: string;
description?: string;
icon?: string;
action?: React.ReactNode;
className?: string;
};
export default function EmptyState({ title, description, icon, action, className = "" }: EmptyStateProps) {
return (
<div className={`flex flex-col items-center justify-center py-16 px-6 text-center ${className}`}>
{icon && <span className="mb-4 text-4xl">{icon}</span>}
{!icon && (
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-slate-100">
<svg className="h-6 w-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
)}
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
{description && <p className="mt-1 text-sm text-slate-500 max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
-121
View File
@@ -1,121 +0,0 @@
"use client";
type SkeletonVariant = "text" | "card" | "table" | "avatar" | "button";
type SkeletonProps = {
variant?: SkeletonVariant;
width?: string;
height?: string;
className?: string;
count?: number;
};
function SkeletonLine({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-stone-200 ${className}`} />;
}
export function SkeletonText({ lines = 3, className = "" }: { lines?: number; className?: string }) {
return (
<div className={`space-y-2.5 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonLine key={i} className={i === lines - 1 ? "w-3/4" : "w-full"} />
))}
</div>
);
}
export function SkeletonCard({ className = "" }: SkeletonProps) {
return (
<div className={`rounded-2xl border border-stone-200 bg-white p-6 ${className}`}>
<div className="flex items-center gap-4">
<SkeletonLine className="h-10 w-10 rounded-xl" />
<div className="flex-1 space-y-2.5">
<SkeletonLine className="h-4 w-1/3" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, cols = 4, className = "" }: { rows?: number; cols?: number; className?: string }) {
return (
<div className={`overflow-hidden rounded-2xl border border-stone-200 bg-white ${className}`}>
<div className="border-b border-stone-100 bg-stone-50 px-5 py-4">
<div className="flex gap-6">
{Array.from({ length: cols }).map((_, i) => (
<SkeletonLine key={i} className="h-3 flex-1 max-w-24" />
))}
</div>
</div>
<div className="divide-y divide-stone-100">
{Array.from({ length: rows }).map((_, rowIdx) => (
<div key={rowIdx} className="flex items-center gap-6 px-5 py-4">
{Array.from({ length: cols }).map((_, colIdx) => (
<SkeletonLine key={colIdx} className="h-3 flex-1 max-w-32" />
))}
</div>
))}
</div>
</div>
);
}
export function SkeletonAvatar({ className = "" }: SkeletonProps) {
return <SkeletonLine className={`h-10 w-10 rounded-full ${className}`} />;
}
export function SkeletonButton({ className = "" }: SkeletonProps) {
return <SkeletonLine className={`h-10 w-28 rounded-xl ${className}`} />;
}
export default function LoadingSkeleton({ variant = "text", width, height, className = "", count = 1 }: SkeletonProps) {
const style: React.CSSProperties = {};
if (width) style.width = width;
if (height) style.height = height;
// Pre-compute stable keys per item position so the JSX below doesn't need
// to reference the map index variable (oxlint no-array-index-as-key).
const items = Array.from({ length: count }, (_, position) => ({
key: `skeleton-${variant}-${position}`,
}));
if (variant === "card") {
return (
<div className="space-y-4">
{items.map((item) => (
<SkeletonCard key={item.key} />
))}
</div>
);
}
if (variant === "table") {
return <SkeletonTable className={className} />;
}
if (variant === "avatar") {
return (
<div className="flex items-center gap-3">
{items.map((item) => (
<SkeletonAvatar key={item.key} />
))}
</div>
);
}
if (variant === "button") {
return (
<div className="flex gap-3">
{items.map((item) => (
<SkeletonButton key={item.key} />
))}
</div>
);
}
// default: text
return (
<div className="space-y-2.5">
{items.map((item) => (
<SkeletonText key={item.key} className={className} />
))}
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
type SectionHeaderProps = {
title: string;
description?: string;
titleClassName?: string;
descClassName?: string;
};
export default function SectionHeader({
title,
description,
titleClassName,
descClassName,
}: SectionHeaderProps) {
return (
<div className="mb-8">
<h2 className={`text-3xl font-bold tracking-tight text-stone-950 ${titleClassName ?? ""}`}>
{title}
</h2>
{description && (
<p className={`mt-2 max-w-2xl text-stone-700 leading-relaxed ${descClassName ?? ""}`}>
{description}
</p>
)}
</div>
);
}
-44
View File
@@ -1,44 +0,0 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useTheme } from "next-themes";
import { useEffect, useState, useSyncExternalStore } from "react";
export default function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme();
// Track whether we're on the client using useSyncExternalStore so the
// initial value comes from a snapshot rather than a useEffect — this
// avoids the "extra render with empty state" the
// `no-initialize-state` rule warns about.
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
if (!mounted) {
return <div className={`h-5 w-5 ${className ?? ""}`} />;
}
const isDark = resolvedTheme === "dark";
return (
<button type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-all border border-transparent hover:border-white/10 ${className ?? ""}`}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
// Sun icon
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
// Moon icon
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}
@@ -1,98 +0,0 @@
"use client";
import Link from "next/link";
export type BreadcrumbItem = {
label: string;
href?: string;
};
export type BreadcrumbNavProps = {
/** Array of breadcrumb items. Last item is the current page (no href). */
items: BreadcrumbItem[];
/** Optional brand accent for styling: 'green' (Tuxedo) or 'blue' (Indian River) */
brandAccent?: "green" | "blue";
/** Optional additional CSS classes */
className?: string;
};
/**
* Breadcrumb navigation component following Apple HIG.
* Uses semantic <nav> with aria-label and ordered list for accessibility.
*/
export default function BreadcrumbNav({
items,
brandAccent = "green",
className = "",
}: BreadcrumbNavProps) {
if (!items || items.length === 0) {
return null;
}
const accentColor = brandAccent === "blue"
? "text-blue-600 hover:text-blue-700"
: "text-emerald-600 hover:text-emerald-700";
const separatorColor = "text-stone-400";
return (
<nav
aria-label="Breadcrumb"
className={`w-full ${className}`}
>
<ol className="flex items-center flex-wrap gap-2 text-sm">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li
key={`${item.label}-${item.href ?? ""}`}
className="flex items-center"
>
{index > 0 && (
<span
className={`mx-2 ${separatorColor}`}
aria-hidden="true"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</span>
)}
{isLast ? (
<span
className="font-medium text-stone-700"
aria-current="page"
>
{item.label}
</span>
) : item.href ? (
<Link
href={item.href}
className={`font-medium transition-colors ${accentColor}`}
>
{item.label}
</Link>
) : (
<span className={`font-medium ${accentColor}`}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
@@ -1,21 +0,0 @@
"use client";
/**
* IndianRiverGrainOverlay
*
* Atmospheric grain texture component for Indian River Direct storefront.
* Provides the subtle film-grain texture that creates a premium, organic feel
* reminiscent of high-end agricultural photography.
*
* Uses a blue/emerald color scheme to complement the citrus brand identity.
*/
export default function IndianRiverGrainOverlay() {
return (
<div
className="fixed inset-0 pointer-events-none z-50 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
);
}
@@ -1,179 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import StopCard from "./StopCard";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
brand_id: string;
};
type Props = {
stops: Stop[];
brandSlug: string;
};
const THIS_WEEK_LIMIT = 10;
const TWO_WEEKS_LIMIT = 10;
const FULL_SEASON_PAGE_SIZE = 20;
type Tab = "this_week" | "next_two_weeks" | "full_season";
function getDateRange(tab: Tab): { start: Date; end: Date } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const addDays = (d: Date, n: number) => {
const copy = new Date(d);
copy.setDate(copy.getDate() + n);
return copy;
};
if (tab === "this_week") return { start: today, end: addDays(new Date(today), 7) };
if (tab === "next_two_weeks") return { start: today, end: addDays(new Date(today), 14) };
return { start: new Date(0), end: new Date("2100-01-01") };
}
export default function IndianRiverPaginatedStops({ stops, brandSlug }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("this_week");
const [fullSeasonPage, setFullSeasonPage] = useState(0);
const [fullSeasonSearch, setFullSeasonSearch] = useState("");
const { start, end } = getDateRange(activeTab);
const filtered = useMemo(() => {
if (activeTab === "full_season") {
if (!fullSeasonSearch.trim()) return stops;
const q = fullSeasonSearch.toLowerCase();
return stops.filter(
(s) =>
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q)
);
}
return stops.filter((s) => {
const d = new Date(s.date);
return d >= start && d <= end;
});
}, [activeTab, stops, start, end, fullSeasonSearch]);
const paginatedStops = useMemo(() => {
if (activeTab === "this_week") return filtered.slice(0, THIS_WEEK_LIMIT);
if (activeTab === "next_two_weeks") return filtered.slice(0, TWO_WEEKS_LIMIT);
const offset = fullSeasonPage * FULL_SEASON_PAGE_SIZE;
return filtered.slice(offset, offset + FULL_SEASON_PAGE_SIZE);
}, [activeTab, filtered, fullSeasonPage]);
const totalCount = filtered.length;
const totalStops = stops.length;
const totalPages = Math.ceil(totalCount / FULL_SEASON_PAGE_SIZE);
const handleTabChange = (tab: Tab) => {
setActiveTab(tab);
setFullSeasonPage(0);
setFullSeasonSearch("");
};
return (
<div>
{/* Header row */}
<div className="mb-8 flex items-center justify-between">
<p className="text-sm text-stone-400 font-medium">
{totalStops} stop{totalStops !== 1 ? "s" : ""} this season
</p>
</div>
{/* Tabs */}
<div className="mb-10 flex gap-2 flex-wrap">
{(["this_week", "next_two_weeks", "full_season"] as Tab[]).map((tab) => (
<button type="button"
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-2xl px-5 py-3 text-sm font-semibold transition-colors ${
activeTab === tab
? "bg-blue-600 text-white"
: "bg-white text-stone-500 ring-1 ring-stone-200 hover:bg-stone-50"
}`}
>
{tab === "this_week"
? "This Week"
: tab === "next_two_weeks"
? "Next 2 Weeks"
: "Full Season"}
</button>
))}
</div>
{/* Search — full season */}
{activeTab === "full_season" && (
<div className="mb-10 flex items-center gap-4 flex-col sm:flex-row">
<input aria-label="Search By City, Location, Or State..."
type="text"
placeholder="Search by city, location, or state..."
value={fullSeasonSearch}
onChange={(e) => {
setFullSeasonSearch(e.target.value);
setFullSeasonPage(0);
}}
className="flex-1 rounded-2xl border border-stone-200 bg-white px-5 py-3.5 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
/>
<span className="text-sm text-stone-500 whitespace-nowrap">
{totalCount > 0
? `${paginatedStops.length} of ${totalCount} stops`
: `${totalCount} stops`}
</span>
</div>
)}
{/* Empty state */}
{filtered.length === 0 ? (
<div className="rounded-3xl bg-white p-14 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No stops found for this period.</p>
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-3">
{paginatedStops.map((stop) => (
<StopCard
key={stop.id}
{...stop}
brandSlug={brandSlug}
brandAccent="blue"
/>
))}
</div>
{/* Pagination */}
{activeTab === "full_season" && totalPages > 1 && (
<div className="mt-10 flex items-center justify-between flex-col sm:flex-row gap-6">
<span className="text-sm text-stone-500">
Page {fullSeasonPage + 1} of {totalPages}
</span>
<div className="flex gap-3">
<button type="button"
onClick={() => setFullSeasonPage((p) => Math.max(0, p - 1))}
disabled={fullSeasonPage === 0}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Previous
</button>
<button type="button"
onClick={() => setFullSeasonPage((p) => p + 1)}
disabled={fullSeasonPage >= totalPages - 1}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
-120
View File
@@ -1,120 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/format-date";
type ZipCodeSearchProps = {
allStops?: Array<{
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
}>;
brandAccent?: "green" | "orange" | "blue";
};
export default function ZipCodeSearch({ allStops = [], brandAccent = "blue" }: ZipCodeSearchProps) {
const router = useRouter();
const [zipCode, setZipCode] = useState("");
const [filteredStops, setFilteredStops] = useState<typeof allStops>([]);
const [searched, setSearched] = useState(false);
const isBlue = brandAccent === "blue";
return (
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${isBlue ? "bg-blue-50" : "bg-stone-100"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-stone-500"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold tracking-tight text-stone-950">Find Stops Near You</h2>
<p className="text-sm text-stone-500 mt-0.5">Search by ZIP code or city name</p>
</div>
</div>
{/* Search row */}
<div className="flex gap-3">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input aria-label="ZIP Code Or City..."
type="text"
placeholder="ZIP code or city..."
value={zipCode}
onChange={(e) => setZipCode(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { if (!zipCode.trim()) return; const normalized = zipCode.trim().replace(/\D/g, ""); setFilteredStops(allStops.filter((s) => s.city.toLowerCase().includes(normalized) || s.location.toLowerCase().includes(normalized) || s.state.toLowerCase().includes(normalized.toUpperCase()))); setSearched(true); } }}
className="w-full rounded-xl border border-stone-200 pl-11 pr-4 py-3.5 text-sm text-stone-900 placeholder-stone-400 bg-white outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors shadow-sm"
/>
</div>
<button type="button"
onClick={() => {
if (!zipCode.trim()) return;
const normalized = zipCode.trim().replace(/\D/g, "");
setFilteredStops(allStops.filter((s) =>
s.city.toLowerCase().includes(normalized) ||
s.location.toLowerCase().includes(normalized) ||
s.state.toLowerCase().includes(normalized.toUpperCase())
));
setSearched(true);
}}
className="rounded-xl bg-stone-900 px-7 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-all hover:-translate-y-0.5 shadow-sm"
>
Search
</button>
</div>
{/* Results */}
{searched && (
<div className="mt-6 pt-6 border-t border-stone-100">
{filteredStops.length > 0 ? (
<div className="space-y-3">
<p className="text-sm font-semibold text-stone-700">
{filteredStops.length} stop{filteredStops.length !== 1 ? "s" : ""} found
</p>
{filteredStops.map((stop) => (
<button type="button"
key={stop.id}
onClick={() => router.push(`#stops`)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-5 py-4 text-left hover:bg-stone-100 group transition-all"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-base text-stone-950">{stop.city}, {stop.state}</p>
<p className="mt-1 text-sm text-stone-500">{formatDate(stop.date)} &middot; {stop.time} &middot; {stop.location}</p>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-stone-200/60 mt-0.5">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-stone-100 mb-4">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="font-medium text-stone-600">No stops found for that location.</p>
<p className="text-sm mt-1 text-stone-400">Try a nearby ZIP code or browse all stops below.</p>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,52 +0,0 @@
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
// Inline form used inside the SettingsTab to add a notification recipient.
export default function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => void }) {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
function isValidEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
}
function handleAdd() {
if (!email.trim()) return;
if (!isValidEmail(email.trim())) {
setError("Enter a valid email address.");
return;
}
setError("");
onAdd(email.trim(), name.trim());
setEmail("");
setName("");
}
return (
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
type="email" placeholder="Email address"
value={email} onChange={e => { setEmail(e.target.value); setError(""); }}
onKeyDown={e => e.key === "Enter" && handleAdd()}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" aria-label="Email address"/>
</div>
<div className="w-36">
<input
type="text" placeholder="Name (optional)"
value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleAdd()}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" aria-label="Name (optional)"/>
</div>
<AdminButton
variant="primary"
size="sm"
onClick={handleAdd}
>
Add
</AdminButton>
{error && <p className="text-xs text-[var(--admin-danger)] mt-1">{error}</p>}
</div>
);
}
@@ -1,124 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
import {
getWholesaleCustomerPricing,
upsertWholesaleCustomerPricing,
deleteWholesaleCustomerPricing,
} from "@/actions/wholesale-register";
import type { WholesaleCustomer, WholesaleProduct } from "@/actions/wholesale";
import type { MsgFn } from "./types";
interface CustomerPricingPanelProps {
customer: WholesaleCustomer;
products: WholesaleProduct[];
onClose: () => void;
onMsg: MsgFn;
}
// Modal panel that lets an admin override per-product pricing for a wholesale customer.
// Blank override clears the override and falls back to the standard tier.
export default function CustomerPricingPanel({ customer, products, onClose, onMsg }: CustomerPricingPanelProps) {
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
getWholesaleCustomerPricing(customer.id).then(pricing => {
const map: Record<string, string> = {};
for (const p of pricing) {
map[p.product_id] = p.custom_unit_price.toString();
}
setOverrides(map);
setLoading(false);
});
}, [customer.id]);
async function handleSave(productId: string, price: string) {
if (!price || isNaN(Number(price))) {
await deleteWholesaleCustomerPricing({ customerId: customer.id, productId });
setOverrides(prev => { const n = { ...prev }; delete n[productId]; return n; });
} else {
await upsertWholesaleCustomerPricing({ customerId: customer.id, productId, customUnitPrice: Number(price) });
setOverrides(prev => ({ ...prev, [productId]: price }));
}
onMsg("success", "Pricing updated.");
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Pricing Overrides</h2>
<p className="text-sm text-[var(--admin-text-muted)]">{customer.company_name}</p>
</div>
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4">
{loading ? (
<p className="text-[var(--admin-text-muted)] text-sm">Loading...</p>
) : products.length === 0 ? (
<p className="text-[var(--admin-text-muted)] text-sm">No products available. Add wholesale products to see them here.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-2 font-medium">Product</th>
<th className="pb-2 font-medium">Standard Price</th>
<th className="pb-2 font-medium">Override Price</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.map(p => {
const standardPrice = p.price_tiers?.[0]?.price ?? 0;
const overridePrice = overrides[p.id] ?? "";
return (
<tr key={p.id}>
<td className="py-2.5 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="py-2.5 text-[var(--admin-text-muted)]">${standardPrice.toFixed(2)}</td>
<td className="py-2.5">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--admin-text-muted)]">$</span>
<input
type="number"
step="0.01"
min="0"
value={overridePrice}
onChange={e => setOverrides(prev => ({ ...prev, [p.id]: e.target.value }))}
placeholder={standardPrice.toFixed(2)}
className="w-24 rounded-lg border border-[var(--admin-border)] px-2 py-1 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
{overridePrice && overridePrice !== standardPrice.toFixed(2) && (
<span className="text-xs text-[var(--admin-accent)] font-medium">Custom</span>
)}
</div>
</td>
<td className="py-2.5">
<AdminButton
variant="primary"
size="sm"
onClick={() => handleSave(p.id, overrides[p.id] ?? "")}
disabled={saving}
>
Save
</AdminButton>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<p className="mt-3 text-xs text-[var(--admin-text-muted)]">
Leave override price blank to remove custom pricing and use standard price tiers.
</p>
</div>
</div>
</div>
);
}
@@ -1,531 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
import {
type WholesaleCustomer,
type WholesaleProduct,
deleteWholesaleCustomer,
saveWholesaleCustomer,
} from "@/actions/wholesale";
import { approveWholesaleRegistration } from "@/actions/wholesale-register";
import { formatDate } from "@/lib/format-date";
import CustomerPricingPanel from "./CustomerPricingPanel";
import PriceSheetModal from "./PriceSheetModal";
import type { MsgFn, PendingRegistration } from "./types";
interface CustomersTabProps {
customers: WholesaleCustomer[];
products: WholesaleProduct[];
brandId: string;
onMsg: MsgFn;
registrations?: PendingRegistration[];
onRefresh: () => void;
}
// Customers tab — list/approve pending registrations, manage customers, send
// price sheets, edit per-customer pricing overrides.
export default function CustomersTab({
customers,
products,
brandId,
onMsg,
registrations = [],
onRefresh,
}: CustomersTabProps) {
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<WholesaleCustomer | null>(null);
const [subTab, setSubTab] = useState<"customers" | "registrations">("customers");
const [form, setForm] = useState({
companyName: "", contactName: "", email: "", phone: "",
accountStatus: "active", creditLimit: 0,
depositsEnabled: false, depositThreshold: "", depositPercentage: "",
orderEmail: "", invoiceEmail: "", adminNotes: "",
});
const [saving, setSaving] = useState(false);
const [processingReg, setProcessingReg] = useState<string | null>(null);
const [pricingCustomer, setPricingCustomer] = useState<WholesaleCustomer | null>(null);
const [selectedCustomers, setSelectedCustomers] = useState<Set<string>>(new Set());
const [sendingPriceSheet, setSendingPriceSheet] = useState(false);
const [priceSheetTarget, setPriceSheetTarget] = useState<{
customerIds: string[];
defaultSubject: string;
} | null>(null);
const [openCustomerActions, setOpenCustomerActions] = useState<string | null>(null);
const [deletingCustomer, setDeletingCustomer] = useState<string | null>(null);
function toggleSelectCustomer(id: string) {
setSelectedCustomers(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function toggleAllCustomers() {
if (selectedCustomers.size === customers.length) {
setSelectedCustomers(new Set());
} else {
setSelectedCustomers(new Set(customers.map(c => c.id)));
}
}
function openPriceSheetModal(customerIds: string[]) {
const ids = customerIds;
if (ids.length === 0) return;
setPriceSheetTarget({
customerIds: ids,
defaultSubject: `Wholesale Price Sheet — ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`,
});
}
async function handleConfirmPriceSheet(subject: string, customNote: string) {
if (!priceSheetTarget) return;
setSendingPriceSheet(true);
setPriceSheetTarget(null);
try {
const res = await fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customerIds: priceSheetTarget.customerIds,
brandId,
subject,
customNote: customNote || undefined,
}),
});
const data = await res.json();
if (res.ok) {
onMsg("success", `Price sheet sent to ${data.enqueued} customer(s).`);
setSelectedCustomers(new Set());
} else {
onMsg("error", data.error ?? "Failed to send price sheet.");
}
} catch {
onMsg("error", "Failed to send price sheet.");
} finally {
setSendingPriceSheet(false);
}
}
async function handleApproveReject(regId: string, action: "approve" | "reject") {
setProcessingReg(regId);
const result = await approveWholesaleRegistration(regId, brandId, action);
setProcessingReg(null);
if (result.success) {
onMsg("success", action === "approve" ? "Registration approved." : "Registration rejected.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to process registration.");
}
}
// Close customer actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".customer-actions-cell")) {
setOpenCustomerActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleCustomerActions(customerId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenCustomerActions(prev => prev === customerId ? null : customerId);
}
async function handleDeleteCustomer(customerId: string) {
if (!confirm("Delete this customer? This cannot be undone. Customers with existing orders cannot be deleted.")) return;
setDeletingCustomer(customerId);
const result = await deleteWholesaleCustomer(customerId);
setDeletingCustomer(null);
if (result.success) {
onMsg("success", "Customer deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete customer.");
}
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleCustomer({
brandId,
userId: editing?.user_id ?? undefined,
companyName: form.companyName || undefined,
contactName: form.contactName || undefined,
email: form.email || undefined,
phone: form.phone || undefined,
accountStatus: form.accountStatus,
creditLimit: form.creditLimit,
depositsEnabled: form.depositsEnabled,
depositThreshold: form.depositThreshold ? Number(form.depositThreshold) : undefined,
depositPercentage: form.depositPercentage ? Number(form.depositPercentage) : undefined,
orderEmail: form.orderEmail || undefined,
invoiceEmail: form.invoiceEmail || undefined,
adminNotes: form.adminNotes || undefined,
});
setSaving(false);
if (result.success) {
onMsg("success", editing ? "Customer updated." : "Customer created.");
setShowForm(false);
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
function openNew() {
setEditing(null);
setForm({ companyName: "", contactName: "", email: "", phone: "", accountStatus: "active", creditLimit: 0, depositsEnabled: false, depositThreshold: "", depositPercentage: "", orderEmail: "", invoiceEmail: "", adminNotes: "" });
setShowForm(true);
}
function openEdit(c: WholesaleCustomer) {
setEditing(c);
setForm({
companyName: c.company_name ?? "",
contactName: c.contact_name ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
accountStatus: c.account_status ?? "active",
creditLimit: Number(c.credit_limit),
depositsEnabled: c.deposits_enabled,
depositThreshold: c.deposit_threshold?.toString() ?? "",
depositPercentage: c.deposit_percentage?.toString() ?? "",
orderEmail: c.order_email ?? "",
invoiceEmail: c.invoice_email ?? "",
adminNotes: c.admin_notes ?? "",
});
setShowForm(true);
}
return (
<div className="space-y-4">
{/* Sub-tab nav */}
<div className="flex items-center gap-4">
<AdminButton
variant={subTab === "customers" ? "primary" : "secondary"}
size="sm"
onClick={() => setSubTab("customers")}
>
Customers ({customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length})
</AdminButton>
<AdminButton
variant={subTab === "registrations" ? "primary" : "secondary"}
size="sm"
onClick={() => setSubTab("registrations")}
>
Registrations ({registrations.filter(r => r.account_status === "pending_approval").length})
</AdminButton>
{subTab === "customers" && (
<AdminButton
variant="primary"
size="sm"
onClick={openNew}
className="ml-auto"
>
+ Add Customer
</AdminButton>
)}
</div>
{subTab === "registrations" && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-[var(--admin-text-secondary)]">Pending Registrations</h3>
{registrations.filter(r => r.account_status === "pending_approval").length === 0 ? (
<p className="text-sm text-[var(--admin-text-muted)] py-4 text-center">No pending registrations.</p>
) : (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Company</th>
<th className="px-5 py-3 font-semibold text-left">Contact</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Registered</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{registrations.flatMap(r =>
r.account_status === "pending_approval" ? [r] : []
).map(r => (
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
<td className="px-5 py-3">
<AdminBadge variant="warning">Pending</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(r.created_at))}</td>
<td className="px-5 py-3">
<div className="flex gap-2">
<AdminButton
variant="primary"
size="sm"
onClick={() => handleApproveReject(r.id, "approve")}
disabled={processingReg === r.id}
isLoading={processingReg === r.id}
>
{processingReg === r.id ? "..." : "Approve"}
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={() => handleApproveReject(r.id, "reject")}
disabled={processingReg === r.id}
>
Reject
</AdminButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{subTab === "customers" && (
<>
{showForm && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
<input id="ws-company-name" value={form.companyName} onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
<input id="ws-contact-name" value={form.contactName} onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
<input id="ws-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="email" />
</div>
<div>
<label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
<input id="ws-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="tel" />
</div>
<div>
<label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
<select id="ws-account-status" value={form.accountStatus} onChange={e => setForm(f => ({ ...f, accountStatus: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="disabled">Disabled</option>
<option value="pending_approval">Pending Approval</option>
</select>
</div>
<div>
<label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
<input id="ws-credit-limit" type="number" value={form.creditLimit} onChange={e => setForm(f => ({ ...f, creditLimit: Number(e.target.value) }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" aria-label="0 = unlimited"/>
</div>
</div>
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] space-y-3">
<p className="text-sm font-semibold text-[var(--admin-text-secondary)]">Deposit Rules</p>
<div className="flex items-center gap-3">
<input type="checkbox" checked={form.depositsEnabled}
onChange={e => setForm(f => ({ ...f, depositsEnabled: e.target.checked }))}
className="rounded" id="dep-enabled" />
<label htmlFor="dep-enabled" className="text-sm text-[var(--admin-text-secondary)]">Enable deposit requirement</label>
</div>
{form.depositsEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
<input id="ws-deposit-threshold" type="number" value={form.depositThreshold} onChange={e => setForm(f => ({ ...f, depositThreshold: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" aria-label="0"/>
</div>
<div>
<label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
<input id="ws-deposit-pct" type="number" value={form.depositPercentage} onChange={e => setForm(f => ({ ...f, depositPercentage: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" aria-label="20"/>
</div>
</div>
)}
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} disabled={saving} isLoading={saving} variant="primary">
{saving ? "Saving..." : "Save Customer"}
</AdminButton>
<AdminButton onClick={() => setShowForm(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
)}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">
<input type="checkbox" className="rounded border-[var(--admin-border)]"
checked={selectedCustomers.size === customers.length && customers.length > 0}
onChange={toggleAllCustomers}
/>
</th>
<th className="px-5 py-3 font-semibold text-left">Company</th>
<th className="px-5 py-3 font-semibold text-left">Contact</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Credit</th>
<th className="px-5 py-3 font-semibold text-left">Deposits</th>
<th className="px-5 py-3 font-semibold text-left">Created</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{customers.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No customers yet</p>
<p className="text-xs text-slate-400">Wholesale customers will appear here once registered.</p>
</div>
</td>
</tr>
) : customers.map(c => (
<tr key={c.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3">
<input type="checkbox" className="rounded border-[var(--admin-border)]"
checked={selectedCustomers.has(c.id)}
onChange={() => toggleSelectCustomer(c.id)}
/>
</td>
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{c.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{c.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{c.email}</span></td>
<td className="px-5 py-3">
<AdminBadge variant={c.account_status === "active" ? "success" : c.account_status === "on_hold" ? "warning" : "danger"}>
{c.account_status}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
{c.credit_limit <= 0 ? "Unlimited" : `$${Number(c.credit_limit).toFixed(2)}`}
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
{c.deposits_enabled ? `${c.deposit_percentage}%` : "—"}
</td>
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(c.created_at))}</td>
<td className="px-5 py-4 customer-actions-cell relative">
<div className="flex items-center gap-1.5">
<button type="button"
onClick={() => openPriceSheetModal([c.id])}
title="Send price sheet"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-accent)] hover:text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)] transition-colors"
aria-label="Send price sheet">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</button>
<div className="relative">
<button type="button"
onClick={(e) => toggleCustomerActions(c.id, e)}
title="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
aria-label="More actions">
<svg className="w-4 h-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"/></svg>
</button>
{openCustomerActions === c.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-52 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { setOpenCustomerActions(null); openEdit(c); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
aria-label="Edit Customer">
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Customer
</button>
<button type="button"
onClick={() => { setOpenCustomerActions(null); setPricingCustomer(c); }}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
aria-label="Pricing">
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Pricing
</button>
<a
href={`/wholesale/portal?preview_customer_id=${c.id}&_admin_preview=1`}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
View Portal As
</a>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button type="button"
onClick={() => { setOpenCustomerActions(null); handleDeleteCustomer(c.id); }}
disabled={deletingCustomer === c.id}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{deletingCustomer === c.id ? "Deleting..." : "Delete Customer"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
{selectedCustomers.size > 0 && (
<tfoot className="bg-[var(--admin-accent-light)] border-t border-[var(--admin-accent)]">
<tr>
<td colSpan={8} className="px-5 py-3 flex items-center gap-4">
<span className="text-sm text-[var(--admin-accent-text)] font-medium">{selectedCustomers.size} selected</span>
<AdminButton
onClick={() => openPriceSheetModal(Array.from(selectedCustomers))}
disabled={sendingPriceSheet}
variant="primary"
size="sm"
isLoading={sendingPriceSheet}
>
{sendingPriceSheet ? "Sending..." : `Send Price Sheet to ${selectedCustomers.size} Customer(s)`}
</AdminButton>
</td>
</tr>
</tfoot>
)}
</table>
</div>
</>
)}
{/* Customer Pricing Overlays Panel */}
{pricingCustomer && (
<CustomerPricingPanel
customer={pricingCustomer}
products={products}
onClose={() => setPricingCustomer(null)}
onMsg={onMsg}
/>
)}
{/* Price Sheet Modal */}
{priceSheetTarget && (
<PriceSheetModal
customerCount={priceSheetTarget.customerIds.length}
defaultSubject={priceSheetTarget.defaultSubject}
onConfirm={handleConfirmPriceSheet}
onClose={() => { setPriceSheetTarget(null); setSendingPriceSheet(false); }}
/>
)}
</div>
);
}
@@ -1,126 +0,0 @@
import type { WholesaleOrder, WholesaleDashboardStats } from "@/actions/wholesale";
import StatusBadge from "./StatusBadge";
import type { MsgFn, WebhookActivityEntry } from "./types";
interface DashboardTabProps {
stats: WholesaleDashboardStats;
recentOrders: WholesaleOrder[];
webhookActivity: WebhookActivityEntry[];
}
// Top-level dashboard: stat cards + recent orders table + recent webhook activity.
export default function DashboardTab({ stats, recentOrders, webhookActivity }: DashboardTabProps) {
return (
<div className="space-y-6">
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{[
{ label: "Open Orders", value: stats.open_orders, variant: "default" },
{ label: "Pickup Today", value: stats.pickup_today, variant: "default" },
{ label: "Past Due", value: stats.past_due, variant: "danger" },
{ label: "Total Unpaid", value: `$${stats.total_unpaid.toFixed(2)}`, variant: "default" },
{ label: "Awaiting Deposit", value: stats.awaiting_deposit, variant: "warning" },
{ label: "Fulfilled Today", value: stats.fulfilled_today, variant: "success" },
].map((card) => (
<div key={card.label} className={`rounded-xl border shadow-sm ${card.variant === "danger" ? "bg-[var(--admin-danger-light)] border-[var(--admin-danger)]" : card.variant === "warning" ? "bg-[var(--admin-warning-light)] border-[var(--admin-warning)]" : card.variant === "success" ? "bg-[var(--admin-accent-light)] border-[var(--admin-accent)]" : "bg-white border-[var(--admin-border)]"}`}>
<p className={`text-xs ${card.variant === "danger" ? "text-[var(--admin-danger)]" : card.variant === "warning" ? "text-[var(--admin-warning)]" : card.variant === "success" ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-muted)]"}`}>{card.label}</p>
<p className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]">{card.value}</p>
</div>
))}
</div>
{/* Recent orders */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Orders</h2>
{recentOrders.length === 0 ? (
<div className="text-center py-12">
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<p className="text-base font-semibold text-slate-600 mb-1">No wholesale orders yet</p>
<p className="text-sm text-slate-400">Wholesale orders placed by your customers will appear here.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-3 font-medium">Invoice</th>
<th className="pb-3 font-medium">Customer</th>
<th className="pb-3 font-medium">Pickup Date</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Status</th>
<th className="pb-3 font-medium">Payment</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{recentOrders.map((order) => (
<tr key={order.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{order.invoice_number ?? "—"}</td>
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{order.company_name}</td>
<td className="py-3 text-[var(--admin-text-secondary)]">{order.anticipated_pickup_date ?? "—"}</td>
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(order.subtotal).toFixed(2)}</td>
<td className="py-3">
<StatusBadge status={order.status} />
</td>
<td className="py-3">
<span className={`text-xs font-medium ${
order.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"
}`}>
{order.payment_status === "paid" ? "Paid" : order.balance_due > 0 ? `$${Number(order.balance_due).toFixed(2)} due` : "Partial"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Recent webhook activity */}
{webhookActivity.length > 0 && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Webhook Activity</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-3 font-medium">Event</th>
<th className="pb-3 font-medium">Order</th>
<th className="pb-3 font-medium">Status</th>
<th className="pb-3 font-medium">Attempts</th>
<th className="pb-3 font-medium">Sent At</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{webhookActivity.map((entry) => (
<tr key={entry.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="py-3">
<span className="font-mono text-xs bg-[var(--admin-accent)] text-white px-2 py-0.5 rounded">{entry.event_type}</span>
</td>
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{entry.order_id ? entry.order_id.slice(0, 8) : "—"}</td>
<td className="py-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
entry.status === "sent" ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" :
entry.status === "failed" ? "bg-[var(--admin-danger-light)] text-[var(--admin-danger)]" :
entry.status === "retrying" ? "bg-[var(--admin-warning-light)] text-[var(--admin-warning)]" :
"bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
}`}>
{entry.status}
</span>
</td>
<td className="py-3 text-[var(--admin-text-muted)]">{entry.attempts}</td>
<td className="py-3 text-[var(--admin-text-muted)]">{new Date(entry.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -1,538 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminSearchInput } from "@/components/admin/design-system";
import { openHtmlInPopup } from "@/lib/safe-window";
import {
type WholesaleOrder,
type WholesaleCustomer,
bulkFulfillWholesaleOrders,
bulkRecordWholesaleDeposit,
markWholesaleOrderFulfilled,
recordWholesaleDeposit,
updateWholesaleOrderStatus,
deleteWholesaleOrder,
getWholesaleSettings,
enqueueWholesaleNotification,
} from "@/actions/wholesale";
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
import StatusBadge from "./StatusBadge";
import type { MsgFn } from "./types";
interface OrdersTabProps {
orders: WholesaleOrder[];
customers: WholesaleCustomer[];
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
}
// Orders tab — list/filter orders, fulfill, record deposits, bulk operations,
// cancel/delete, generate manifests, and dispatch notification emails on fulfillment.
export default function OrdersTab({ orders, customers, brandId, onMsg, onRefresh }: OrdersTabProps) {
const [showDepForm, setShowDepForm] = useState<string | null>(null);
const [depAmount, setDepAmount] = useState("");
const [depMethod, setDepMethod] = useState("cash");
const [fulfilling, setFulfilling] = useState<string | null>(null);
const [manifestLoading, setManifestLoading] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState("all");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [bulkDepAmount, setBulkDepAmount] = useState("");
const [bulkDepMethod, setBulkDepMethod] = useState("cash");
const [showBulkDep, setShowBulkDep] = useState(false);
const [bulkLoading, setBulkLoading] = useState(false);
const [openActions, setOpenActions] = useState<string | null>(null);
const [showViewOrder, setShowViewOrder] = useState<WholesaleOrder | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const filtered = orders.filter(o => {
if (statusFilter !== "all" && o.status !== statusFilter) return false;
if (dateFrom && (!o.anticipated_pickup_date || o.anticipated_pickup_date < dateFrom)) return false;
if (dateTo && (!o.anticipated_pickup_date || o.anticipated_pickup_date > dateTo)) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
if (!(o.company_name?.toLowerCase().includes(q) || o.invoice_number?.toLowerCase().includes(q) || o.customer_email?.toLowerCase().includes(q))) return false;
}
return true;
});
function toggleSelect(id: string) {
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
function toggleAll() {
if (selected.size === filtered.length) { setSelected(new Set()); }
else { setSelected(new Set(filtered.map(o => o.id))); }
}
async function handleBulkFulfill() {
if (selected.size === 0) return;
setBulkLoading(true);
const result = await bulkFulfillWholesaleOrders(Array.from(selected));
setBulkLoading(false);
if (result.success) {
onMsg("success", `${result.count} order(s) marked as fulfilled.`);
setSelected(new Set());
onRefresh();
} else {
onMsg("error", result.error ?? "Bulk fulfill failed.");
}
}
async function handleBulkDeposit() {
if (selected.size === 0 || !bulkDepAmount) return;
setBulkLoading(true);
const result = await bulkRecordWholesaleDeposit(Array.from(selected), Number(bulkDepAmount), bulkDepMethod);
setBulkLoading(false);
if (result.success) {
onMsg("success", `Deposit recorded on ${result.count} order(s).`);
setSelected(new Set());
setShowBulkDep(false);
setBulkDepAmount("");
onRefresh();
} else {
onMsg("error", result.error ?? "Bulk deposit failed.");
}
}
async function handleFulfill(orderId: string) {
setFulfilling(orderId);
const result = await markWholesaleOrderFulfilled(orderId);
setFulfilling(null);
if (result.success) {
onMsg("success", "Order marked as fulfilled.");
onRefresh();
// Enqueue order fulfilled notification
const order = orders.find(o => o.id === orderId);
const customer = order ? customers.find(c => c.email === order.customer_email) : null;
if (order && customer) {
enqueueWholesaleNotification({
brandId: brandId,
customerId: customer.id,
orderId,
type: "order_fulfilled",
emailTo: customer.email,
subject: `Order ${order.invoice_number ?? orderId.slice(0, 8)} Ready for Pickup`,
bodyHtml: `
<h2>Your Order is Ready!</h2>
<p>Hi ${customer.company_name},</p>
<p>Your wholesale order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}.</p>
<p>Thank you for your business!</p>
`,
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}. Thank you!`,
});
// Also notify the admin
const settings = await getWholesaleSettings(brandId);
const adminEmail = settings?.notification_email ?? settings?.from_email ?? settings?.invoice_business_email ?? null;
if (adminEmail) {
enqueueWholesaleNotification({
brandId: brandId,
customerId: customer.id,
orderId,
type: "order_fulfilled",
emailTo: adminEmail,
subject: `[Admin] Order Fulfilled — ${order.invoice_number ?? orderId.slice(0, 8)}`,
bodyHtml: `
<h2>Order Fulfilled</h2>
<p>Order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> for <strong>${customer.company_name}</strong> has been marked as fulfilled.</p>
${order.anticipated_pickup_date ? `<p><strong>Pickup Date:</strong> ${order.anticipated_pickup_date}</p>` : ""}
`,
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} for ${customer.company_name} has been fulfilled.`,
});
}
// Trigger notification send (fire-and-forget)
fetch(`/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
}
} else {
onMsg("error", result.error ?? "Failed to fulfill order.");
}
}
// Close actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".actions-cell")) {
setOpenActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleActions(orderId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenActions(prev => prev === orderId ? null : orderId);
}
async function handleRecordDeposit(orderId: string) {
const result = await recordWholesaleDeposit(orderId, Number(depAmount), depMethod);
setShowDepForm(null);
setDepAmount("");
if (result.success) {
onMsg("success", "Deposit recorded.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to record deposit.");
}
}
async function handleCancelOrder(orderId: string) {
if (!confirm("Cancel this order? This cannot be undone.")) return;
const result = await updateWholesaleOrderStatus(orderId, "cancelled");
if (result.success) {
onMsg("success", "Order cancelled.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to cancel order.");
}
}
async function handleDeleteOrder(orderId: string) {
if (!confirm("Delete this order? Fulfilled and paid orders cannot be deleted. This cannot be undone.")) return;
setDeleting(orderId);
const result = await deleteWholesaleOrder(orderId);
setDeleting(null);
if (result.success) {
onMsg("success", "Order deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete order.");
}
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Orders ({orders.length})</h2>
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-3 rounded-2xl bg-white border border-[var(--admin-border)] p-4 shadow-sm">
<div>
<label htmlFor="fld-1-status" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
<select id="fld-1-status" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="awaiting_deposit">Awaiting Deposit</option>
<option value="confirmed">Confirmed</option>
<option value="fulfilled">Fulfilled</option>
</select>
</div>
<div>
<label htmlFor="fld-2-pickup-from" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup From</label>
<input id="fld-2-pickup-from" type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-3-pickup-to" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup To</label>
<input id="fld-3-pickup-to" type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="flex-1 min-w-[180px]">
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1" htmlFor="fld-search">Search</label>
<AdminSearchInput
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery("")}
placeholder="Invoice, customer, email..."
/>
</div>
{filtered.length !== orders.length && (
<div className="self-end pb-1">
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length} of {orders.length}</span>
</div>
)}
</div>
{/* Bulk actions */}
{selected.size > 0 && (
<div className="flex items-center gap-3 rounded-2xl bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-4 py-3">
<span className="text-sm font-medium text-[var(--admin-accent-text)]">{selected.size} selected</span>
<AdminButton variant="primary" size="sm" onClick={handleBulkFulfill} disabled={bulkLoading} isLoading={bulkLoading}>
{bulkLoading ? "..." : "Bulk Fulfill"}
</AdminButton>
<AdminButton variant="secondary" size="sm" onClick={() => setShowBulkDep(true)}>
Bulk Record Deposit
</AdminButton>
<button type="button" onClick={() => setSelected(new Set())} className="ml-auto text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Clear selection</button>
</div>
)}
{/* Manifest section */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-4 flex items-center gap-4 shadow-sm">
<div>
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Load Manifest</p>
<p className="text-xs text-[var(--admin-text-muted)]">Generate a printable manifest for pending/active orders.</p>
</div>
<AdminButton
variant="secondary"
size="sm"
onClick={async () => {
setManifestLoading(true);
const pending = filtered.filter(o => o.fulfillment_status !== "fulfilled");
if (pending.length === 0) { setManifestLoading(false); return; }
const html = await fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders: pending }),
}).then(r => r.text());
openHtmlInPopup(html);
setManifestLoading(false);
}}
disabled={manifestLoading}
isLoading={manifestLoading}
>
{manifestLoading ? "Generating..." : "Generate Manifest"}
</AdminButton>
</div>
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left w-10">
<input id="fld-search" type="checkbox" checked={selected.size === filtered.length && filtered.length > 0} onChange={toggleAll} aria-label="Select all orders" className="rounded" />
</th>
<th className="px-5 py-3 font-semibold text-left">Invoice</th>
<th className="px-5 py-3 font-semibold text-left">Customer</th>
<th className="px-5 py-3 font-semibold text-left">Pickup Date</th>
<th className="px-5 py-3 font-semibold text-right">Subtotal</th>
<th className="px-5 py-3 font-semibold text-right">Deposit</th>
<th className="px-5 py-3 font-semibold text-right">Balance</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Payment</th>
<th className="px-5 py-3 font-semibold text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? (
<tr><td colSpan={10} className="py-8 text-center text-[var(--admin-text-muted)]">No orders match the current filters.</td></tr>
) : filtered.map(o => (
<tr key={o.id} className={`hover:bg-[var(--admin-bg-subtle)] ${selected.has(o.id) ? "bg-[var(--admin-accent-light)]" : ""}`}>
<td className="px-5 py-3">
<input type="checkbox" checked={selected.has(o.id)} onChange={() => toggleSelect(o.id)} aria-label={`Select order ${o.invoice_number ?? o.id}`} className="rounded" />
</td>
<td className="px-5 py-3 font-mono text-xs text-[var(--admin-text-muted)]">{o.invoice_number ?? "—"}</td>
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{o.company_name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{o.anticipated_pickup_date ?? "—"}</td>
<td className="px-5 py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(o.subtotal).toFixed(2)}</td>
<td className="px-5 py-3 text-right text-[var(--admin-text-secondary)]">${Number(o.deposit_paid).toFixed(2)} / ${Number(o.deposit_required).toFixed(2)}</td>
<td className="px-5 py-3 text-right">
<span className={Number(o.balance_due) > 0 ? "text-[var(--admin-warning)] font-medium" : "text-[var(--admin-accent)]"}>
${Number(o.balance_due).toFixed(2)}
</span>
</td>
<td className="px-5 py-3"><StatusBadge status={o.status} /></td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${o.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"}`}>
{o.payment_status}
</span>
</td>
<td className="px-5 py-4 actions-cell relative">
<div className="flex items-center gap-2">
{/* Primary: Mark Fulfilled — only when order is not yet fulfilled */}
{o.fulfillment_status !== "fulfilled" && (
<AdminButton
variant="primary"
size="sm"
onClick={() => !fulfilling && handleFulfill(o.id)}
disabled={fulfilling === o.id}
isLoading={fulfilling === o.id}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
{fulfilling === o.id ? "..." : "Fulfill"}
</AdminButton>
)}
{/* Primary: Record Deposit — when there's a balance due */}
{Number(o.balance_due) > 0 && o.fulfillment_status !== "fulfilled" && (
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowDepForm(o.id)}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-9-9h18" /></svg>
Deposit
</AdminButton>
)}
{/* Invoice download — always visible as icon button */}
<a aria-label="Download Invoice"
href={`/api/wholesale/invoice/${o.id}/pdf`}
target="_blank"
rel="noopener noreferrer" title="Download Invoice"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
</a>
{/* ⋮ Actions dropdown — opens upward to avoid table cutoff */}
<div className="relative">
<button type="button"
onClick={(e) => toggleActions(o.id, e)}
title="More actions"
aria-label={`More actions for order ${o.invoice_number ?? o.id}`}
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
<svg className="w-4 h-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"/></svg>
</button>
{openActions === o.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { setOpenActions(null); setShowViewOrder(o); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
>
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
View / Edit Order
</button>
<button type="button"
onClick={() => {
setOpenActions(null);
fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders: [o] }),
})
.then((r) => r.text())
.then((html) => openHtmlInPopup(html));
}}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 01-18 0 9 9 0 0118 0z"/></svg>
Generate Manifest
</button>
<button type="button"
onClick={() => {
setOpenActions(null);
fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerIds: [o.customer_id], brandId }),
}).then(r => r.json()).then(d => onMsg("success", `Price sheet sent to customer.`)).catch(() => onMsg("error", "Failed to send price sheet."));
}}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Send Price Sheet
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
{o.status !== "cancelled" && o.fulfillment_status !== "fulfilled" && (
<button type="button"
onClick={() => { setOpenActions(null); handleCancelOrder(o.id); }}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
Cancel Order
</button>
)}
{o.fulfillment_status !== "fulfilled" && (
<button type="button"
onClick={() => { setOpenActions(null); handleDeleteOrder(o.id); }}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete Order
</button>
)}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Deposit modal */}
{showDepForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Record Deposit</h3>
<div className="space-y-3">
<div>
<label htmlFor="fld-4-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount ($)</label>
<input id="fld-4-amount" type="number" value={depAmount} onChange={e => setDepAmount(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" />
</div>
<div>
<label htmlFor="fld-5-method" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
<select id="fld-5-method" value={depMethod} onChange={e => setDepMethod(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="wire">Wire</option>
<option value="card">Card</option>
</select>
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={() => handleRecordDeposit(showDepForm)} variant="primary">
Record Deposit
</AdminButton>
<AdminButton onClick={() => setShowDepForm(null)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
</div>
)}
{/* Bulk Deposit modal */}
{showBulkDep && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Bulk Record Deposit ({selected.size} orders)</h3>
<div className="space-y-3">
<div>
<label htmlFor="fld-6-amount-per-order" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount per order ($)</label>
<input id="fld-6-amount-per-order" type="number" value={bulkDepAmount} onChange={e => setBulkDepAmount(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="0.00" />
</div>
<div>
<label htmlFor="fld-7-method-2" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
<select id="fld-7-method-2" value={bulkDepMethod} onChange={e => setBulkDepMethod(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="wire">Wire</option>
<option value="card">Card</option>
</select>
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleBulkDeposit} variant="primary" disabled={!bulkDepAmount || bulkLoading} isLoading={bulkLoading}>
{bulkLoading ? "Recording..." : "Record on All"}
</AdminButton>
<AdminButton onClick={() => setShowBulkDep(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
</div>
)}
{/* Order Details modal */}
{showViewOrder && (
<OrderDetailsModal
order={showViewOrder}
onClose={() => setShowViewOrder(null)}
onFulfill={handleFulfill}
onRecordDeposit={(id) => { setShowViewOrder(null); setShowDepForm(id); }}
fulfilling={fulfilling}
/>
)}
</div>
);
}
@@ -1,101 +0,0 @@
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
interface PriceSheetModalProps {
customerCount: number;
defaultSubject: string;
onConfirm: (subject: string, customNote: string) => void;
onClose: () => void;
}
// Modal that confirms a price-sheet send — gathers subject + optional note,
// then delegates to onConfirm.
export default function PriceSheetModal({
customerCount,
defaultSubject,
onConfirm,
onClose,
}: PriceSheetModalProps) {
const [subject, setSubject] = useState(defaultSubject);
const [note, setNote] = useState("");
const [sending, setSending] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!subject.trim()) return;
setSending(true);
onConfirm(subject.trim(), note.trim());
}
return (
<div
role="dialog"
aria-modal="true"
aria-label="Send price sheet"
tabIndex={-1}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{customerCount === 1 ? "1 customer" : `${customerCount} customers`}
</p>
</div>
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-5 space-y-4">
<div>
<label htmlFor="ws-price-subject" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label>
<input
id="ws-price-subject"
value={subject}
onChange={e => setSubject(e.target.value)}
required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div>
<label htmlFor="ws-price-note" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">
Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label>
<textarea
id="ws-price-note"
value={note}
onChange={e => setNote(e.target.value)}
rows={4}
placeholder="e.g. 'Check out our new seasonal items on page 2. Place your order by Friday for weekend pickup.'"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] resize-none"
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This note will appear at the top of the email, above the product table.
</p>
</div>
</div>
<div className="px-6 py-4 border-t border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)] rounded-b-2xl">
<div className="text-sm text-[var(--admin-text-muted)]">
{customerCount === 1 ? "1 customer" : `${customerCount} customers`} will receive this email.
</div>
<div className="flex gap-3">
<AdminButton type="button" variant="secondary" onClick={onClose}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={sending || !subject.trim()} isLoading={sending}>
{sending ? "Sending..." : `Send to ${customerCount === 1 ? "1 Customer" : `${customerCount} Customers`}`}
</AdminButton>
</div>
</div>
</form>
</div>
</div>
);
}
@@ -1,262 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
import {
type WholesaleProduct,
deleteWholesaleProduct,
saveWholesaleProduct,
} from "@/actions/wholesale";
import type { MsgFn } from "./types";
interface ProductsTabProps {
products: WholesaleProduct[];
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
}
// Wholesale product catalog management. Create/edit/delete products and their price tiers.
export default function ProductsTab({ products, brandId, onMsg, onRefresh }: ProductsTabProps) {
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<WholesaleProduct | null>(null);
const [form, setForm] = useState({
name: "", description: "", unitType: "each", availability: "available",
qtyAvailable: 0, priceTiers: "", hpSku: "", hpItemId: "",
handlingInstructions: "", storageWarning: "", productLabel: "",
});
const [saving, setSaving] = useState(false);
const [openProductActions, setOpenProductActions] = useState<string | null>(null);
const [deletingProduct, setDeletingProduct] = useState<string | null>(null);
// Close product actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".product-actions-cell")) {
setOpenProductActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleProductActions(productId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenProductActions(prev => prev === productId ? null : productId);
}
async function handleDeleteProduct(productId: string) {
if (!confirm("Delete this product? Products attached to order items cannot be deleted. Set availability to unavailable instead.")) return;
setDeletingProduct(productId);
const result = await deleteWholesaleProduct(productId);
setDeletingProduct(null);
if (result.success) {
onMsg("success", "Product deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete product.");
}
}
async function handleSave() {
setSaving(true);
let tiers: Array<{ min_qty: number; max_qty: number; price: number }> = [];
try { tiers = JSON.parse(form.priceTiers || "[]"); } catch {}
const result = await saveWholesaleProduct({
brandId,
id: editing?.id,
name: form.name,
description: form.description || undefined,
unitType: form.unitType,
availability: form.availability,
qtyAvailable: form.qtyAvailable,
priceTiers: tiers,
hpSku: form.hpSku || undefined,
hpItemId: form.hpItemId || undefined,
handlingInstructions: form.handlingInstructions || undefined,
storageWarning: form.storageWarning || undefined,
productLabel: form.productLabel || undefined,
});
setSaving(false);
if (result.success) {
onMsg("success", editing ? "Product updated." : "Product created.");
setShowForm(false);
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
function openNew() {
setEditing(null);
setForm({ name: "", description: "", unitType: "each", availability: "available", qtyAvailable: 0, priceTiers: "", hpSku: "", hpItemId: "", handlingInstructions: "", storageWarning: "", productLabel: "" });
setShowForm(true);
}
function openEdit(p: WholesaleProduct) {
setEditing(p);
setForm({
name: p.name,
description: p.description ?? "",
unitType: p.unit_type,
availability: p.availability,
qtyAvailable: Number(p.qty_available),
priceTiers: JSON.stringify(p.price_tiers),
hpSku: p.hp_sku ?? "",
hpItemId: p.hp_item_id ?? "",
handlingInstructions: p.handling_instructions ?? "",
storageWarning: p.storage_warning ?? "",
productLabel: p.product_label ?? "",
});
setShowForm(true);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Products ({products.length})</h2>
<AdminButton variant="primary" size="sm" onClick={openNew}>
+ Add Product
</AdminButton>
</div>
{showForm && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
<input id="ws-prod-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
<textarea id="ws-prod-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div>
<div>
<label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
<select id="ws-prod-unit" value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div>
<label htmlFor="fld-1-availability" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Availability</label>
<select id="fld-1-availability" value={form.availability} onChange={e => setForm(f => ({ ...f, availability: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
<option value="coming_soon">Coming Soon</option>
</select>
</div>
<div>
<label htmlFor="fld-2-qty-available" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Qty Available</label>
<input id="fld-2-qty-available" type="number" value={form.qtyAvailable} onChange={e => setForm(f => ({ ...f, qtyAvailable: Number(e.target.value) }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-3-hp-sku" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">HP SKU</label>
<input id="fld-3-hp-sku" value={form.hpSku} onChange={e => setForm(f => ({ ...f, hpSku: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Price Tiers (JSON array of {"{\"min_qty\":1,\"max_qty\":24,\"price\":20}"})</label>
<input id="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" value={form.priceTiers} onChange={e => setForm(f => ({ ...f, priceTiers: e.target.value }))}
placeholder='[{"min_qty":1,"max_qty":24,"price":20},{"min_qty":25,"max_qty":0,"price":18}]'
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm font-mono outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
{saving ? "Saving..." : "Save Product"}
</AdminButton>
<AdminButton onClick={() => setShowForm(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
)}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Product</th>
<th className="px-5 py-3 font-semibold text-left">Unit</th>
<th className="px-5 py-3 font-semibold text-left">Availability</th>
<th className="px-5 py-3 font-semibold text-left">In Stock</th>
<th className="px-5 py-3 font-semibold text-left">HP SKU</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No wholesale products yet</p>
<p className="text-xs text-slate-400">Add products to your wholesale catalog to get started.</p>
</div>
</td>
</tr>
) : products.map(p => (
<tr key={p.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.unit_type}</td>
<td className="px-5 py-3">
<AdminBadge variant={p.availability === "available" ? "success" : p.availability === "coming_soon" ? "warning" : "default"}>
{p.availability.replace("_", " ")}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.qty_available}</td>
<td className="px-5 py-4 font-mono text-xs text-[var(--admin-text-muted)]">{p.hp_sku ?? "—"}</td>
<td className="px-5 py-4 product-actions-cell relative">
<div className="flex items-center gap-1.5">
<div className="relative">
<button type="button"
onClick={(e) => toggleProductActions(p.id, e)}
title="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
aria-label="More actions">
<svg className="w-4 h-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"/></svg>
</button>
{openProductActions === p.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-48 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { setOpenProductActions(null); openEdit(p); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
aria-label="Edit Product">
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Product
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button type="button"
onClick={() => { setOpenProductActions(null); handleDeleteProduct(p.id); }}
disabled={deletingProduct === p.id}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{deletingProduct === p.id ? "Deleting..." : "Delete Product"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,287 +0,0 @@
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
import {
type WholesaleSettings,
type NotificationRecipient,
saveWholesaleSettings,
} from "@/actions/wholesale";
import AddRecipientForm from "./AddRecipientForm";
import WebhookSettingsSection from "./WebhookSettingsSection";
import type { MsgFn } from "./types";
interface SettingsTabProps {
settings: WholesaleSettings | null;
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
canManageSettings: boolean;
}
// Wholesale portal settings: approval flow, payments, pickup location, invoice
// details, Square sync toggle, team notification recipients, and outbound webhook.
export default function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }: SettingsTabProps) {
// Hooks must be called unconditionally - always declare them first
const [form, setForm] = useState({
requireApproval: settings?.require_approval ?? true,
minOrderAmount: settings?.min_order_amount ?? "",
onlinePaymentEnabled: settings?.online_payment_enabled ?? false,
wholesaleEnabled: settings?.wholesale_enabled ?? true,
squareSyncEnabled: settings?.square_sync_enabled ?? false,
pickupLocation: settings?.pickup_location ?? "",
fobLocation: settings?.fob_location ?? "",
fromEmail: settings?.from_email ?? "",
invoiceBusinessName: settings?.invoice_business_name ?? "",
invoiceBusinessAddress: settings?.invoice_business_address ?? "",
invoiceBusinessPhone: settings?.invoice_business_phone ?? "",
invoiceBusinessEmail: settings?.invoice_business_email ?? "",
invoiceBusinessWebsite: settings?.invoice_business_website ?? "",
notificationRecipients: settings?.notification_recipients ?? [],
});
const [saving, setSaving] = useState(false);
// Permission check after hooks
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleSettings({
brandId,
requireApproval: form.requireApproval,
minOrderAmount: form.minOrderAmount ? Number(form.minOrderAmount) : undefined,
onlinePaymentEnabled: form.onlinePaymentEnabled,
wholesaleEnabled: form.wholesaleEnabled,
squareSyncEnabled: form.squareSyncEnabled,
pickupLocation: form.pickupLocation,
fobLocation: form.fobLocation,
fromEmail: form.fromEmail,
invoiceBusinessName: form.invoiceBusinessName,
invoiceBusinessAddress: form.invoiceBusinessAddress,
invoiceBusinessPhone: form.invoiceBusinessPhone,
invoiceBusinessWebsite: form.invoiceBusinessWebsite,
notificationRecipients: form.notificationRecipients,
});
setSaving(false);
if (result.success) {
onMsg("success", "Settings saved.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save settings.");
}
}
// ── Notification Recipients helpers ────────────────────────────────────────
function addRecipient(email: string, name: string = "") {
setForm(f => ({
...f,
notificationRecipients: [
...f.notificationRecipients,
{ email, name, active: true },
],
}));
}
function removeRecipient(idx: number) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.filter((_: unknown, i: number) => i !== idx),
}));
}
function toggleRecipient(idx: number) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
i === idx ? { ...r, active: !r.active } : r
),
}));
}
function updateRecipientName(idx: number, name: string) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
i === idx ? { ...r, name } : r
),
}));
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Settings</h2>
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Require Approval</p>
<p className="text-sm text-[var(--admin-text-muted)]">New registrations must be manually approved.</p>
</div>
<button type="button"
onClick={() => setForm(f => ({ ...f, requireApproval: !f.requireApproval }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.requireApproval ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.requireApproval ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand&apos;s storefront page.</p>
</div>
<button type="button"
onClick={() => setForm(f => ({ ...f, wholesaleEnabled: !f.wholesaleEnabled }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.wholesaleEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.wholesaleEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent-light)]">
<svg className="h-4 w-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Square Sync for Wholesale</p>
<p className="text-xs text-[var(--admin-text-muted)]">
When enabled, wholesale product changes are automatically pushed to Square.
{form.squareSyncEnabled
? " Auto-sync is active."
: " Auto-sync is disabled — changes will not be pushed to Square."}
</p>
</div>
</div>
<button type="button"
onClick={() => setForm(f => ({ ...f, squareSyncEnabled: !f.squareSyncEnabled }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.squareSyncEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.squareSyncEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-minimum-order-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Minimum Order Amount ($)</label>
<input id="fld-1-minimum-order-amount" type="number" value={form.minOrderAmount} onChange={e => setForm(f => ({ ...f, minOrderAmount: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="No minimum" aria-label="No minimum"/>
</div>
<div>
<label htmlFor="fld-2-from-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">From Email</label>
<input id="fld-2-from-email" type="email" value={form.fromEmail} onChange={e => setForm(f => ({ ...f, fromEmail: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-3-pickup-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Pickup Location</label>
<textarea id="fld-3-pickup-location" value={form.pickupLocation} onChange={e => setForm(f => ({ ...f, pickupLocation: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono" rows={3} />
</div>
<div className="col-span-2">
<label htmlFor="fld-4-fob-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">FOB Location</label>
<input id="fld-4-fob-location" value={form.fobLocation} onChange={e => setForm(f => ({ ...f, fobLocation: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-5-invoice-business-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Name</label>
<input id="fld-5-invoice-business-name" value={form.invoiceBusinessName} onChange={e => setForm(f => ({ ...f, invoiceBusinessName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-6-invoice-business-address" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Address</label>
<textarea id="fld-6-invoice-business-address" value={form.invoiceBusinessAddress} onChange={e => setForm(f => ({ ...f, invoiceBusinessAddress: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div>
<div>
<label htmlFor="fld-7-invoice-business-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Phone</label>
<input id="fld-7-invoice-business-phone" value={form.invoiceBusinessPhone} onChange={e => setForm(f => ({ ...f, invoiceBusinessPhone: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-8-invoice-business-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Email</label>
<input id="fld-8-invoice-business-email" type="email" value={form.invoiceBusinessEmail} onChange={e => setForm(f => ({ ...f, invoiceBusinessEmail: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-9-invoice-business-website" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Website</label>
<input id="fld-9-invoice-business-website" value={form.invoiceBusinessWebsite} onChange={e => setForm(f => ({ ...f, invoiceBusinessWebsite: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="https://" aria-label="https://"/>
</div>
{/* Team Notification Recipients */}
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1" htmlFor="fld-team-notification-recipients">Team Notification Recipients</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
These team members receive all wholesale notifications for this brand new orders,
deposits, fulfillments, price sheets, and pickup reminders. If no recipients are
active, the system falls back to any configured notification email on file.
</p>
{/* Empty state */}
{form.notificationRecipients.length === 0 && (
<div className="text-center py-6 mb-4 rounded-xl bg-white ring-1 ring-[var(--admin-border)]">
<p className="text-sm text-[var(--admin-text-muted)] mb-1">No recipients added yet.</p>
<p className="text-xs text-[var(--admin-text-muted)]">Add an email address below to get started.</p>
</div>
)}
{/* Recipients list */}
<div className="space-y-2 mb-4">
{form.notificationRecipients.map((r: NotificationRecipient, idx: number) => (
<div key={r.email} className={`flex items-center gap-2 rounded-xl px-3 py-2.5 bg-white ring-1 ${r.active ? "ring-[var(--admin-border)]" : "ring-[var(--admin-border-light)] opacity-70"}`}>
<input id="fld-team-notification-recipients"
type="checkbox" checked={r.active} onChange={() => toggleRecipient(idx)}
className="rounded border-[var(--admin-border)] mt-0.5" title={r.active ? "Active — receives notifications" : "Inactive"} />
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${r.active ? "text-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"}`}>{r.email}</p>
{r.name && <p className="text-xs text-[var(--admin-text-muted)] truncate">{r.name}</p>}
{r.notification_types && r.notification_types.length > 0 && (
<p className="text-xs text-[var(--admin-text-secondary)] mt-0.5" title="Per-type filtering coming soon">Types: {r.notification_types.join(", ")}</p>
)}
</div>
<input
type="text" placeholder="Name (optional)"
value={r.name ?? ""}
onChange={e => updateRecipientName(idx, e.target.value)}
className="rounded-xl border border-[var(--admin-border)] px-2.5 py-1.5 text-xs w-36 outline-none focus:border-[var(--admin-accent)]" aria-label="Name (optional)"/>
<button type="button" onClick={() => removeRecipient(idx)}
className="text-[var(--admin-danger)] hover:text-[var(--admin-danger)] text-xs font-medium px-2 py-1 rounded-lg hover:bg-[var(--admin-danger-light)] transition-colors"
title="Remove recipient" aria-label="Remove recipient">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Add recipient */}
<AddRecipientForm onAdd={addRecipient} />
</div>
{/* Webhook Settings */}
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1" htmlFor="fld-webhook-integration">Webhook Integration</label><label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1">Webhook Integration</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
Send order events to external systems (Harvest Point, ERPs, etc.). Payload is signed
with HMAC-SHA256. Enable and configure the URL and secret below.
</p>
<WebhookSettingsSection brandId={brandId} onMsg={onMsg} />
</div>
</div>
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
{saving ? "Saving..." : "Save Settings"}
</AdminButton>
</div>
</div>
</div>
);
}
@@ -1,25 +0,0 @@
import { AdminBadge } from "@/components/admin/design-system";
// Maps wholesale order status strings to AdminBadge variants + labels.
// Falls back to the raw status for unknown values.
const VARIANT_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
pending: "warning",
awaiting_deposit: "info",
confirmed: "info",
fulfilled: "success",
};
const LABEL_MAP: Record<string, string> = {
pending: "Pending",
awaiting_deposit: "Awaiting Deposit",
confirmed: "Confirmed",
fulfilled: "Fulfilled",
};
export default function StatusBadge({ status }: { status: string }) {
return (
<AdminBadge variant={VARIANT_MAP[status] ?? "default"}>
{LABEL_MAP[status] ?? status}
</AdminBadge>
);
}
@@ -1,165 +0,0 @@
import { useEffect, useState } from "react";
import crypto from "crypto";
import { AdminButton } from "@/components/admin/design-system";
import { getWebhookSettings, saveWebhookSettings } from "@/actions/wholesale";
import type { MsgFn } from "./types";
interface WebhookSettingsSectionProps {
brandId: string;
onMsg: MsgFn;
}
// Section inside SettingsTab that lets admins configure an outbound HMAC-signed
// webhook for wholesale order events. Includes a "Send Test Webhook" button that
// dispatches a signed payload to the configured URL.
export default function WebhookSettingsSection({ brandId, onMsg }: WebhookSettingsSectionProps) {
const [settings, setSettings] = useState<{
url: string;
secret: string;
enabled: boolean;
}>({ url: "", secret: "", enabled: false });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
getWebhookSettings(brandId).then((s: { url: string; secret: string; enabled: boolean } | null) => {
if (s) {
setSettings({ url: s.url ?? "", secret: s.secret ?? "", enabled: s.enabled });
} else {
setSettings({ url: "", secret: "", enabled: false });
}
setLoading(false);
});
}, [brandId]);
async function handleSave() {
setSaving(true);
const result = await saveWebhookSettings({
brandId,
url: settings.url,
secret: settings.secret,
enabled: settings.enabled,
});
setSaving(false);
if (result.success) {
onMsg("success", "Webhook settings saved.");
} else {
onMsg("error", result.error ?? "Failed to save webhook settings.");
}
}
async function handleTestDispatch() {
if (!settings.url || !settings.secret) {
onMsg("error", "Enter both a webhook URL and signing secret before testing.");
return;
}
setTesting(true);
onMsg("success", "Sending test webhook...");
const testPayload = {
event: "order_created",
test: true,
brand_id: brandId,
message: "Test webhook from Route Commerce wholesale portal",
timestamp: new Date().toISOString(),
order_id: null,
};
const payloadString = JSON.stringify(testPayload);
const signature = crypto
.createHmac("sha256", settings.secret)
.update(payloadString)
.digest("hex");
try {
const res = await fetch(settings.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${signature}`,
"X-Webhook-Event": "order_created",
},
body: payloadString,
});
const responseText = await res.text().catch(() => "");
if (res.ok) {
onMsg("success", `Test delivered — ${res.status} response from your endpoint. Check wholesale_sync_log for the logged entry.`);
} else {
onMsg("error", `Webhook endpoint returned ${res.status}: ${responseText.slice(0, 120)}`);
}
} catch (err) {
onMsg("error", `Connection failed: ${err instanceof Error ? err.message : "Network error"}`);
}
setTesting(false);
}
if (loading) {
return <p className="text-sm text-slate-400">Loading webhook settings...</p>;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Webhook Enabled</p>
<p className="text-xs text-[var(--admin-text-muted)]">When disabled, no events are queued or sent.</p>
</div>
<button type="button"
onClick={() => setSettings(s => ({ ...s, enabled: !s.enabled }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${settings.enabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${settings.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div>
<label htmlFor="fld-1-webhook-url" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Webhook URL</label>
<input id="fld-1-webhook-url"
type="url"
value={settings.url}
onChange={e => setSettings(s => ({ ...s, url: e.target.value }))}
placeholder="https://your-system.com/webhooks/wholesale"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
aria-label="https://your-system.com/webhooks/wholesale"/>
</div>
<div>
<label htmlFor="fld-2-signing-secret-hmac-sha256" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Signing Secret (HMAC-SHA256)</label>
<input id="fld-2-signing-secret-hmac-sha256"
type="password"
value={settings.secret}
onChange={e => setSettings(s => ({ ...s, secret: e.target.value }))}
placeholder="Leave blank to keep current secret"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono"
aria-label="Leave blank to keep current secret"/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
The receiver should verify the <code className="bg-[var(--admin-bg-subtle)] px-1">X-Webhook-Signature</code> header using this secret.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<AdminButton
onClick={handleSave}
disabled={saving}
variant="primary"
size="sm"
isLoading={saving}
>
{saving ? "Saving..." : "Save Webhook Settings"}
</AdminButton>
{settings.enabled && settings.url && (
<AdminButton
onClick={handleTestDispatch}
disabled={testing}
variant="secondary"
size="sm"
isLoading={testing}
>
{testing ? "Dispatching..." : "Send Test Webhook"}
</AdminButton>
)}
</div>
</div>
);
}
@@ -1,9 +0,0 @@
// SVG icon for the Wholesale Portal header. Defined at module level so it
// isn't recreated on every render of the parent.
export default function WholesaleIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
}
@@ -1,47 +0,0 @@
// Skeleton shown by the shell while initial data loads.
export default function WholesaleLoadingSkeleton() {
return (
<div className="space-y-6 px-6 py-6">
{/* Header skeleton */}
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-slate-200 animate-pulse" />
<div className="space-y-2">
<div className="h-6 w-40 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-64 bg-slate-100 rounded animate-pulse" />
</div>
</div>
{/* Tab bar skeleton */}
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className={`h-10 rounded-xl bg-slate-200 animate-pulse ${i === 1 ? "w-24" : "w-20"}`} />
))}
</div>
{/* Stat cards skeleton */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="rounded-xl border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-3 w-20 bg-slate-100 rounded mb-2" />
<div className="h-7 w-16 bg-slate-200 rounded" />
</div>
))}
</div>
{/* Recent orders skeleton */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="h-5 w-28 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="space-y-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-slate-100 last:border-0 animate-pulse">
<div className="h-4 w-20 bg-slate-100 rounded" />
<div className="h-4 w-32 bg-slate-100 rounded flex-1" />
<div className="h-4 w-24 bg-slate-100 rounded hidden md:block" />
<div className="h-5 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
// Shared types used across the wholesale admin client module.
// Lives here so each tab/panel can import them without depending on the shell.
export type MsgKind = "success" | "error";
/** Toast-style message callback passed down from the shell to every tab/panel. */
export type MsgFn = (kind: MsgKind, text: string) => void;
/** Pending wholesale-customer registration row used by CustomersTab. */
export interface PendingRegistration {
id: string;
company_name: string | null;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
created_at: string;
}
/** Compact row used on the Dashboard's webhook-activity table. */
export interface WebhookActivityEntry {
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}