fix: react-doctor unused-file 123→27 (-96 dead files removed)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{stop.city}, {stop.state}"?</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{product.name}"?
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 "{product.name}"?
|
||||
</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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user