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

This commit is contained in:
Nora
2026-06-26 04:38:44 -06:00
parent ab9813b4ee
commit 27b2ded94e
96 changed files with 0 additions and 17319 deletions
-218
View File
@@ -1,218 +0,0 @@
/**
* @deprecated Use AdminSidebar instead. AdminHeader is deprecated and will be removed.
* The AdminSidebar provides the complete navigation system for admin pages.
* It includes an expandable Settings sub-menu with all settings pages.
*/
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { signOutAction } from "@/actions/auth-actions";
async function handleLogout() {
await signOutAction();
}
type AdminHeaderProps = {
userRole?: string | null;
canManageUsers?: boolean;
routeTraceEnabled?: boolean;
};
type NavLink = {
href: string;
label: string;
external?: boolean;
};
type SettingsItem = {
href: string;
label: string;
description: string;
};
const SETTINGS_ITEMS: SettingsItem[] = [
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence, recovered carts" },
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
];
const BASE_NAV_LINKS: NavLink[] = [
{ href: "/admin/orders", label: "Orders" },
{ href: "/admin/stops", label: "Stops & Routes" },
{ href: "/admin/products", label: "Products" },
{ href: "/admin/time-tracking", label: "Time Tracking" },
{ href: "/admin/communications", label: "Harvest Reach" },
];
const EMPLOYEE_NAV_LINKS: NavLink[] = [
{ href: "/admin/wholesale", label: "Wholesale" },
{ href: "/admin/pickup", label: "Pickup" },
{ href: "/wholesale/employee", label: "Pickup Portal", external: true },
];
export default function AdminHeader({ userRole, canManageUsers, routeTraceEnabled }: AdminHeaderProps) {
const router = useRouter();
const pathname = usePathname();
const isStoreEmployee = userRole === "store_employee";
const moduleLinks: NavLink[] = routeTraceEnabled
? [{ href: "/admin/route-trace", label: "Route Trace" }]
: [];
const fullNavLinks = BASE_NAV_LINKS.concat(moduleLinks);
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") {
return pathname === "/admin/settings" || pathname.startsWith("/admin/settings/");
}
return pathname.startsWith(href);
};
return (
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
<div className="flex items-center h-16">
{/* Logo mark */}
<Link
href={homeHref}
className="flex items-center gap-3 mr-8 text-white hover:text-emerald-400 transition-colors"
>
<div className="relative">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/30">
<svg className="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l.707-.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
<span className="text-sm font-semibold tracking-tight">{homeLabel}</span>
</Link>
<nav className="flex h-full items-center gap-1">
{navLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200",
active
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
{link.label}
</Link>
);
})}
{/* Settings dropdown — key={pathname} remounts on route change to auto-close */}
<SettingsDropdown key={pathname} pathname={pathname} />
</nav>
</div>
<div className="flex items-center gap-6">
{roleLabel && (
<div className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/5">
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
</div>
)}
<button type="button"
onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
</header>
);
}
function SettingsDropdown({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<div className="relative ml-2" ref={ref}>
<button type="button"
onClick={() => setOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
);
}
-349
View File
@@ -1,349 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { publishStop, deleteStop } from "@/actions/stops";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brand_id: string;
status?: string;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: Stop[];
brandId: string;
};
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
} catch {
return dateStr;
}
}
function formatTime(timeStr: string) {
if (!timeStr) return "—";
try {
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
} catch {
return timeStr;
}
}
export default function AdminStopsPanel({ stops, brandId }: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [page, setPage] = useState(0);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const PAGE_SIZE = 50;
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
await publishStop(stopId, brandId);
setPublishingId(null);
router.refresh();
}
async function handleDelete(stopId: string) {
const data = await deleteStop(stopId, brandId);
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
router.refresh();
}
}
const activeCount = stops.filter((s) => s.active && s.status !== "draft").length;
const draftCount = stops.filter((s) => s.status === "draft").length;
return (
<div className="min-h-screen bg-stone-50">
{/* Header */}
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-7xl px-6 py-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-stone-950 tracking-tight">Tour Stops</h1>
<div className="mt-2 flex items-center gap-4">
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{activeCount}</span> active
</span>
{draftCount > 0 && (
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{draftCount}</span> drafts
</span>
)}
</div>
</div>
<div className="flex gap-3">
<button type="button"
onClick={() => setShowImport(true)}
className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Upload Schedule">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload Schedule
</button>
<button type="button"
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
aria-label="Add Stop">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add Stop
</button>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-6">
{/* Filters */}
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm mb-5">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-64">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input aria-label="Search City Or Location..."
type="search"
placeholder="Search city or location..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20"
/>
</div>
{/* Status tabs */}
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{(["all", "active", "draft", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
statusFilter === f
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
}`}
>
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Count */}
<span className="ml-auto text-sm text-stone-500">{filtered.length} stops</span>
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
{filtered.length === 0 ? (
<div className="py-16 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="text-lg font-medium text-stone-600">No stops found</p>
<p className="mt-1 text-sm text-stone-400">Create a stop or adjust your filters</p>
</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">Brand</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Status</th>
<th className="px-5 py-4" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{paginatedStops.map((stop) => (
<tr key={stop.id} className="hover:bg-stone-50 transition-colors">
<td className="px-5 py-4">
<Link href={`/admin/stops/${stop.id}`} className="font-medium text-stone-900 hover:text-emerald-600 transition-colors">
{stop.city}, {stop.state}
</Link>
</td>
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatDate(stop.date)}</span>
</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatTime(stop.time)}</span>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<span className="text-stone-600">
{Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands?.name}
</span>
</td>
<td className="px-5 py-4">
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button type="button"
onClick={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
className="rounded-lg px-2 py-1.5 text-[var(--admin-text-muted)] hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{openMenu === stop.id && (
<>
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-lg">
{stop.status === "draft" && (
<button type="button"
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
)}
<a href={`/admin/stops/new?duplicate=${stop.id}`} className="block px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50">
Duplicate
</a>
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50"
aria-label="Delete">
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<button type="button" aria-label="Cancel delete confirmation" className="fixed inset-0 z-30 cursor-default bg-transparent border-0 p-0" onClick={() => setConfirmDelete(null)} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg p-4">
<p className="text-sm font-semibold text-stone-900">Delete &quot;{stop.city}, {stop.state}&quot;?</p>
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
<div className="mt-4 flex gap-2">
<button type="button"
onClick={() => setConfirmDelete(null)}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(stop.id)}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--admin-danger-hover)]"
aria-label="Delete">
Delete
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-5 flex items-center justify-between">
<p className="text-sm text-stone-500">
Showing {page * PAGE_SIZE + 1} to {Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length}
</p>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Previous">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Next">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</main>
{/* Modals */}
<ScheduleImportModal brandId={brandId} onClose={() => setShowImport(false)} onComplete={() => router.refresh()} />
<AddStopModal isOpen={showAdd} onClose={() => setShowAdd(false)} brandId={brandId} onSuccess={() => router.refresh()} />
</div>
);
}
@@ -1,240 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import AdvancedIntegrations from "@/components/admin/AdvancedIntegrations";
import AdvancedAIPanel from "@/components/admin/AdvancedAIPanel";
import AdvancedSquareSync from "@/components/admin/AdvancedSquareSync";
import AdvancedShipping from "@/components/admin/AdvancedShipping";
import AdvancedPayments from "@/components/admin/AdvancedPayments";
type Tab = "integrations" | "ai" | "square" | "shipping" | "webhooks" | "payments";
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "integrations", label: "Integrations", icon: "plug" },
{ id: "ai", label: "AI Tools", icon: "sparkles" },
{ id: "square", label: "Square Sync", icon: "square" },
{ id: "shipping", label: "Shipping", icon: "truck" },
{ id: "webhooks", label: "Webhooks", icon: "webhook" },
{ id: "payments", label: "Payments", icon: "credit-card" },
];
// Icon components
const Icons = {
plug: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>
),
sparkles: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"/>
</svg>
),
square: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 12h6M12 9v6"/>
</svg>
),
truck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
</svg>
),
webhook: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M8.25 8.25l-.75.75M14.25 14.25l-.75.75M8.25 14.25l.75-.75M14.25 8.25l.75.75"/>
</svg>
),
"credit-card": (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<path d="M2 10h20"/>
</svg>
),
};
type Props = {
brandId: string;
brands: { id: string; name: string }[];
stripeConnect?: {
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
} | null;
};
export default function AdvancedSettingsClient({ brandId, brands, stripeConnect }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("integrations");
// Handle Stripe Connect callback params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("stripe_connected") === "true") {
// Successfully connected - could show a toast or refresh
window.history.replaceState({}, "", window.location.pathname);
} else if (params.get("stripe_refresh") === "true") {
// Link expired, need to refresh
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
return (
<div>
{/* Tab navigation */}
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
{TABS.map((tab) => (
<button type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
activeTab === tab.id
? "bg-[var(--admin-accent)] text-white"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
}`}
>
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
{tab.icon === "square" && Icons.square("h-4 w-4")}
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
)}
</button>
))}
</nav>
{/* Content */}
<div className="py-4 sm:py-6">
{activeTab === "integrations" && (
<AdvancedIntegrations brandId={brandId} brands={brands} />
)}
{activeTab === "ai" && (
<AdvancedAIPanel brandId={brandId} />
)}
{activeTab === "square" && (
<AdvancedSquareSync brandId={brandId} />
)}
{activeTab === "shipping" && (
<AdvancedShipping brandId={brandId} />
)}
{activeTab === "webhooks" && (
<div className="space-y-6">
{/* Page Header */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--admin-bg)]">
{Icons.webhook("h-6 w-6 text-[var(--admin-text-muted)]")}
</div>
<div className="flex-1">
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Configure webhook endpoints to receive real-time notifications for order events, payments, and inventory updates.
</p>
</div>
</div>
</div>
{/* Coming Soon Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="bg-[var(--admin-bg)]/50 px-6 py-4 border-b border-[var(--admin-border)]">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Available Webhook Events</h3>
</div>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
<svg className="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Coming Soon</p>
<p className="text-xs text-[var(--admin-text-muted)]">Webhook configuration will be available shortly</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-6">
{[
{ icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", label: "Order Status Changes", desc: "Track order lifecycle events" },
{ icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: "Payment Confirmations", desc: "Get notified on successful payments" },
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
].map((event, i) => (
<div key={`${event.label}-${event.desc}`} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-[var(--admin-border)]">
<svg className="h-4 w-4 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={event.icon}/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{event.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{event.desc}</p>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span className="text-sm text-[var(--admin-text-secondary)]">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
</div>
</div>
<div className="mt-6 flex items-center justify-between pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Need webhooks now?{" "}
<button type="button" className="text-green-600 hover:text-green-700 font-medium">
Contact support
</button>
</p>
<button type="button" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Notify Me
</button>
</div>
</div>
</div>
{/* Technical Info Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-3">Webhook Payload Format</h4>
<div className="bg-[var(--admin-text-primary)] rounded-lg p-4 overflow-x-auto">
<pre className="text-xs text-[var(--admin-bg)] font-mono leading-relaxed">
{`{
"event": "order.status_changed",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"order_id": "...",
"status": "delivered"
}
}`}
</pre>
</div>
</div>
</div>
)}
{activeTab === "payments" && (
<AdvancedPayments brandId={brandId} initialStatus={stripeConnect ?? null} />
)}
</div>
</div>
);
}
-189
View File
@@ -1,189 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
import { toggleBrandFeature } from "@/actions/settings/features";
import GlassModal from "./GlassModal";
type Props = {
brandId: string;
initialEnabledFeatures: Record<string, boolean>;
};
export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: Props) {
const router = useRouter();
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
const [toggling, setToggling] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showEnableModal, setShowEnableModal] = useState<BrandFeatureKey | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleToggle = async (key: BrandFeatureKey, enable: boolean) => {
setToggling(key);
setShowEnableModal(null);
setEnabledFeatures((prev) => ({ ...prev, [key]: enable }));
const result = await toggleBrandFeature(brandId, key, enable);
if (result.success) {
showToast(enable ? `${ADDON_CATALOG[key].name} enabled` : `${ADDON_CATALOG[key].name} disabled`);
router.refresh();
} else {
setEnabledFeatures((prev) => ({ ...prev, [key]: !enable }));
showToast(`Error: ${result.error}`, 'error');
}
setToggling(null);
};
const keys = Object.keys(ADDON_CATALOG) as BrandFeatureKey[];
return (
<>
{toast && (
<div
className="fixed bottom-6 right-6 z-50 rounded-xl px-5 py-3 text-sm font-medium shadow-lg backdrop-blur-sm"
style={{
background: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(15, 118, 110, 0.95)',
color: 'white',
}}
>
{toast.message}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{keys.map((key) => {
const addon = ADDON_CATALOG[key];
const enabled = !!enabledFeatures[key];
const busy = toggling === key;
return (
<div
key={key}
className="rounded-2xl p-6 transition-all"
style={{
background: 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(20px)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.3)' : '1px solid rgba(0, 0, 0, 0.06)',
boxShadow: enabled
? '0 8px 24px rgba(16, 185, 129, 0.1), inset 0 1px 0 rgba(255,255,255,0.8)'
: '0 4px 12px rgba(0, 0, 0, 0.04)',
}}
>
<div className="flex items-start gap-4">
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
style={{
background: enabled
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)'
: 'rgba(0, 0, 0, 0.04)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
<span className="text-2xl">{addon.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-stone-950">{addon.name}</h3>
<span
className="text-xs font-medium rounded-full px-2.5 py-0.5"
style={{
background: enabled ? 'rgba(16, 185, 129, 0.15)' : 'rgba(0, 0, 0, 0.04)',
color: enabled ? '#047857' : 'rgba(0, 0, 0, 0.4)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
{enabled ? "Active" : "Inactive"}
</span>
</div>
<p className="text-sm text-stone-500 leading-relaxed">
{addon.description}
</p>
{addon.addOnPrice && (
<p className="mt-2 text-xs font-medium" style={{ color: '#b45309' }}>
{addon.addOnPrice}/month
</p>
)}
</div>
</div>
<div className="mt-5 flex items-center justify-between">
<button type="button"
onClick={() => handleToggle(key, !enabled)}
disabled={busy}
className="rounded-xl px-5 py-2.5 text-sm font-semibold transition-all"
style={{
background: enabled
? 'rgba(239, 68, 68, 0.1)'
: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
color: enabled ? '#dc2626' : 'white',
border: enabled ? '1px solid rgba(239, 68, 68, 0.2)' : 'none',
boxShadow: enabled ? 'none' : '0 2px 8px rgba(16, 185, 129, 0.25)',
opacity: busy ? 0.5 : 1,
}}
>
{busy ? "..." : enabled ? "Disable" : "Enable"}
</button>
{enabled && addon.adminRoute && (
<a
href={addon.adminRoute}
className="rounded-xl px-4 py-2 text-sm font-medium transition-all"
style={{
background: 'rgba(0, 0, 0, 0.02)',
color: 'rgba(0, 0, 0, 0.6)',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
Open
</a>
)}
</div>
</div>
);
})}
</div>
{/* Enable confirmation modal */}
{showEnableModal && (
<GlassModal
title={`Enable ${ADDON_CATALOG[showEnableModal].name}?`}
subtitle={`This will activate the add-on feature.`}
onClose={() => setShowEnableModal(null)}
>
<div className="space-y-4">
<p className="text-sm text-stone-500">
{ADDON_CATALOG[showEnableModal].description}
</p>
{ADDON_CATALOG[showEnableModal].addOnPrice && (
<p className="text-sm font-medium" style={{ color: '#b45309' }}>
Price: {ADDON_CATALOG[showEnableModal].addOnPrice}/month
</p>
)}
<div className="flex justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button type="button"
onClick={() => setShowEnableModal(null)}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 transition-all"
>
Cancel
</button>
<button type="button"
onClick={() => handleToggle(showEnableModal, true)}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.25)',
}}
>
Enable
</button>
</div>
</div>
</GlassModal>
)}
</>
);
}
-24
View File
@@ -1,24 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useCart } from "@/context/CartContext";
type Props = { userId: string };
export default function CartHydration({ userId }: Props) {
const { loadServerCart } = useCart();
// Hold the latest loadServerCart via ref so the effect below can call the
// freshest closure without depending on it (which would re-fire on every
// render of CartProvider and trigger an infinite hydration loop).
const loadServerCartRef = useRef(loadServerCart);
useEffect(() => {
loadServerCartRef.current = loadServerCart;
});
useEffect(() => {
if (!userId) return;
loadServerCartRef.current(userId).catch(() => {});
}, [userId]);
return null;
}
@@ -1,276 +0,0 @@
"use client";
// Loading skeleton components for Communications page
function SkeletonBlock({ className = "" }: { className?: string }) {
return (
<div className={`animate-pulse bg-stone-200 rounded ${className}`} />
);
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-3 w-24" />
</div>
</div>
<SkeletonBlock className="h-9 w-28 rounded-lg" />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-stone-50 rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
<SkeletonBlock className="h-3 w-16 mb-2" />
<SkeletonBlock className="h-6 w-20" />
</div>
))}
</div>
<SkeletonBlock className="h-64 w-full rounded-xl" />
</div>
);
}
function SkeletonTableRow() {
return (
<tr className="border-b border-[var(--admin-border)]">
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-32" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-20" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
</tr>
);
}
function SkeletonTable({ rows = 5 }: { rows?: number }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
<tr>
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-16" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-16" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
</div>
);
}
function SkeletonComposer() {
return (
<div className="flex flex-col gap-6">
{/* Step indicator skeleton */}
<div className="flex items-center gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center">
<SkeletonBlock className={`h-10 rounded-full ${i === 1 ? "w-24" : "w-20"}`} />
{i < 4 && <SkeletonBlock className="h-0.5 w-6 mx-1" />}
</div>
))}
</div>
{/* Main card skeleton */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="space-y-4">
<SkeletonBlock className="h-6 w-40" />
<SkeletonBlock className="h-4 w-64" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-6">
{[1, 2, 3, 4].map((i) => (
<SkeletonBlock key={i} className="h-28 rounded-xl" />
))}
</div>
</div>
</div>
{/* Recent campaigns skeleton */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-6 py-4 border-b border-[var(--admin-border)]">
<SkeletonBlock className="h-5 w-36" />
</div>
<SkeletonTable rows={3} />
</div>
</div>
);
}
function SkeletonSegmentBuilder() {
return (
<div className="p-4 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-4 w-48" />
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4">
{/* Sidebar skeleton */}
<div className="lg:w-72 flex-shrink-0">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="h-8 w-8 rounded-lg" />
</div>
<SkeletonBlock className="h-10 w-full" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-12 w-full rounded-xl" />
))}
</div>
</div>
</div>
{/* Builder + Preview skeleton */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-20" />
<SkeletonBlock className="h-8 w-16 rounded-lg" />
</div>
<SkeletonBlock className="h-24 w-full rounded-xl" />
<div className="flex gap-2 flex-wrap">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-7 w-24 rounded-full" />
))}
</div>
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-32" />
<SkeletonBlock className="h-6 w-16 rounded-full" />
</div>
<SkeletonBlock className="h-48 w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
}
function SkeletonContacts() {
return (
<div className="space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-24" />
<SkeletonBlock className="h-4 w-32" />
</div>
</div>
<div className="flex gap-2">
<SkeletonBlock className="h-9 w-24 rounded-lg" />
<SkeletonBlock className="h-9 w-32 rounded-lg" />
</div>
</div>
<SkeletonBlock className="h-10 w-full mb-4" />
<SkeletonTable rows={5} />
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<SkeletonBlock className="h-5 w-28 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-24 rounded-xl" />
))}
</div>
</div>
</div>
);
}
function SkeletonLogs() {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-20" />
<SkeletonBlock className="h-4 w-32" />
</div>
</div>
<div className="flex gap-2">
<SkeletonBlock className="h-9 w-28 rounded-lg" />
<SkeletonBlock className="h-9 w-20 rounded-lg" />
</div>
</div>
<SkeletonBlock className="h-10 w-full mb-4" />
<SkeletonTable rows={6} />
</div>
);
}
// Main loading component that shows skeleton based on current tab
export default function CommunicationsLoading({
activeTab = "campaigns"
}: {
activeTab?: string
}) {
const currentTab = activeTab;
switch (currentTab) {
case "campaigns":
return <SkeletonCard />;
case "templates":
return <SkeletonCard />;
case "contacts":
return <SkeletonContacts />;
case "segments":
return <SkeletonSegmentBuilder />;
case "logs":
return <SkeletonLogs />;
case "analytics":
return <SkeletonCard />;
default:
return <SkeletonCard />;
}
}
// Named exports for specific loading states
export {
SkeletonCard,
SkeletonComposer,
SkeletonSegmentBuilder,
SkeletonContacts,
SkeletonLogs,
SkeletonTable,
SkeletonBlock,
};
-120
View File
@@ -1,120 +0,0 @@
"use client";
const TABS = [
{ id: "campaigns", label: "Campaigns" },
{ id: "compose", label: "Compose" },
{ id: "segments", label: "Segments" },
{ id: "analytics", label: "Analytics" },
{ id: "templates", label: "Templates" },
{ id: "contacts", label: "Contacts" },
{ id: "logs", label: "Logs" },
{ id: "settings", label: "Settings" },
];
// Icon components
const Icons = {
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
),
send: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m22 2-11 11"/>
<path d="M22 2 15 22l-4-9-9-4 20-7z"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
<line x1="10" x2="8" y1="9" y2="9"/>
</svg>
),
userCheck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<polyline points="16 11 18 13 22 9"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6"/>
<line x1="8" x2="21" y1="12" y2="12"/>
<line x1="8" x2="21" y1="18" y2="18"/>
<line x1="3" x2="3.01" y1="6" y2="6"/>
<line x1="3" x2="3.01" y1="12" y2="12"/>
<line x1="3" x2="3.01" y1="18" y2="18"/>
</svg>
),
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
filter: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
),
};
export default function CommunicationsNav({
activeTab,
}: {
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "segments" | "analytics" | "compose" | "settings";
}) {
return (
<nav className="grid grid-cols-4 sm:grid-cols-7 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={`/admin/communications/${tab.id}`}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-3 py-2.5 text-[10px] sm:text-xs font-semibold transition-colors ${
isActive
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.id === "campaigns" && Icons.send("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "compose" && Icons.mail("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "segments" && Icons.users("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "analytics" && Icons.chart("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "templates" && Icons.fileText("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "contacts" && Icons.userCheck("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "logs" && Icons.list("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "settings" && Icons.settings("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>{tab.label}</span>
{isActive && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-4 sm:w-8 bg-white rounded-full" />
)}
</a>
);
})}
</nav>
);
}
export { TABS, Icons };
-61
View File
@@ -1,61 +0,0 @@
"use client";
import Link from "next/link";
import { useState, useCallback } from "react";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
interface DashboardHeaderProps {
brandId: string | null;
brandName: string;
planTier: string;
}
export default function DashboardHeader({ brandId, brandName, planTier }: DashboardHeaderProps) {
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const openUpgrade = useCallback(() => setIsUpgradeOpen(true), []);
const closeUpgrade = useCallback(() => setIsUpgradeOpen(false), []);
return (
<>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
</div>
</div>
<div className="flex items-center gap-4">
<Link href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
Billing
</Link>
{planTier === "starter" && brandId && (
<button type="button"
onClick={openUpgrade}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
)}
</div>
</div>
{planTier === "starter" && brandId && (
<UpgradePlanModal
isOpen={isUpgradeOpen}
onClose={closeUpgrade}
brandId={brandId}
currentTier={planTier}
/>
)}
</>
);
}
@@ -1,36 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
interface DashboardUpgradeButtonProps {
brandId: string | null;
currentTier: string;
}
export default function DashboardUpgradeButton({ brandId, currentTier }: DashboardUpgradeButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);
if (!brandId) return null;
return (
<>
<button type="button"
onClick={openModal}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
<UpgradePlanModal
isOpen={isOpen}
onClose={closeModal}
brandId={brandId}
currentTier={currentTier}
/>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
title: string;
titleIcon?: React.ReactNode;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
variant?: "default" | "elegant";
};
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
// showModal() opens it in modal mode; we listen to "cancel" so the
// parent React state stays in sync with native close behavior.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
const isElegant = variant === "elegant";
return (
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
</dialog>
);
}
@@ -1,82 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{
href: "/admin/harvest-reach/segments",
label: "Segments",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
},
{
href: "/admin/harvest-reach/campaigns",
label: "Campaigns",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
),
},
{
href: "/admin/harvest-reach/analytics",
label: "Analytics",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
},
];
export default function HarvestReachNav() {
const pathname = usePathname();
return (
<div className="flex flex-col">
{/* Brand header */}
<div className="flex items-center gap-3 pb-4 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-stone-900">Harvest Reach</h1>
<p className="text-xs text-stone-500">Email marketing & campaigns</p>
</div>
</div>
{/* Navigation tabs */}
<nav className="flex gap-1 p-1 bg-stone-100 rounded-xl">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg transition-all ${
active
? "bg-white text-emerald-600 shadow-sm"
: "text-stone-500 hover:text-stone-700 hover:bg-white/50"
}`}
>
{tab.icon}
<span>{tab.label}</span>
</Link>
);
})}
</nav>
</div>
);
}
-336
View File
@@ -1,336 +0,0 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
};
type SyncOption = {
key: string;
label: string;
description?: string;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
syncOptions: SyncOption[];
};
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "AI",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "border-violet-200",
credentials: [
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
],
syncOptions: [
{ key: "campaign_writer", label: "Campaign Idea Generator" },
{ key: "product_writer", label: "Product Description Writer" },
{ key: "report_explainer", label: "Report Explainer" },
{ key: "pricing_advisor", label: "Pricing Advisor" },
],
},
{
id: "resend",
name: "Resend",
icon: "Email",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
syncOptions: [
{ key: "transactional", label: "Transactional Email" },
{ key: "marketing", label: "Marketing Campaigns" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "Stripe",
description: "Process online payments for orders.",
accentColor: "border-stone-300",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
],
syncOptions: [
{ key: "checkout", label: "Online Checkout" },
{ key: "refunds", label: "Refund Processing" },
],
},
{
id: "twilio",
name: "Twilio",
icon: "SMS",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
syncOptions: [
{ key: "sms_campaigns", label: "SMS Campaigns" },
{ key: "alerts", label: "Order Alerts" },
],
},
];
const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
(i) => i.id !== "openai"
);
const iconColors: Record<string, string> = {
AI: "bg-violet-100 text-violet-700",
Email: "bg-amber-100 text-amber-700",
Stripe: "bg-stone-100 text-stone-700",
SMS: "bg-blue-100 text-blue-700",
};
function IntegrationCard({
integration,
initialCredentials,
brandId,
}: {
integration: Integration;
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTest() {
setTestResult(null);
setError(null);
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div className={`rounded-xl border p-5 bg-white shadow-sm ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-stone-100 text-stone-700"}`}>{integration.icon}</span>
<div>
<h3 className="text-base font-semibold text-stone-900">{integration.name}</h3>
<p className="text-xs text-stone-500 mt-0.5">{integration.description}</p>
</div>
</div>
</div>
<div className="space-y-3 mb-4">
{integration.credentials.map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
<div className="relative">
<input aria-label="Input"
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm pr-16 text-stone-900 placeholder:text-stone-400 outline-none focus:border-emerald-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
className="text-xs text-stone-400 hover:text-stone-600 px-1"
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</div>
</div>
))}
</div>
{testResult && (
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{error && (
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
)}
<div className="flex gap-2">
<button type="button" onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button type="button" onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
);
}
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function IntegrationsInner({ brandId, brands }: Props) {
// There is no in-component brand picker, so derive the selected brand from
// the current prop instead of copying it into useState.
const selectedBrandId = brandId;
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
return (
<div>
{/* AI Provider — separate panel */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-stone-800">AI Provider</h3>
<Link href="/admin/settings/ai" className="text-xs text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Configure AI </Link>
</div>
<AIProviderPanel brandId={selectedBrandId} />
</div>
{/* Payment & email integrations grid */}
<div className="space-y-3 mb-4">
{INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={selectedBrandId}
/>
))}
</div>
</div>
);
}
-387
View File
@@ -1,387 +0,0 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useTransition, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { deleteLocation } from "@/actions/locations";
import AddLocationModal from "@/components/admin/AddLocationModal";
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
export type AdminLocation = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
slug: string | null;
stop_count: number;
};
type Props = {
locations: AdminLocation[];
brandId: string;
};
type StatusFilter = "all" | "active" | "inactive";
export default function LocationsTab({ locations, brandId }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<LocationForEdit | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const PAGE_SIZE = 50;
useEffect(() => {
setIsLoading(true);
const t = setTimeout(() => setIsLoading(false), 250);
return () => clearTimeout(t);
}, [page, statusFilter, search]);
const counts = useMemo(
() => ({
all: locations.length,
active: locations.filter((l) => l.active).length,
inactive: locations.filter((l) => !l.active).length,
}),
[locations]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return locations.filter((l) => {
const matchesSearch =
!q ||
l.name.toLowerCase().includes(q) ||
(l.address ?? "").toLowerCase().includes(q) ||
(l.city ?? "").toLowerCase().includes(q) ||
(l.contact_name ?? "").toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && l.active) ||
(statusFilter === "inactive" && !l.active);
return matchesSearch && matchesStatus;
});
}, [locations, search, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
];
function handleAdded() {
setShowAdd(false);
showSuccess("Venue created");
startTransition(() => router.refresh());
}
function handleEdited() {
setEditing(null);
showSuccess("Venue updated");
startTransition(() => router.refresh());
}
async function handleDelete(loc: AdminLocation) {
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
setPendingDeleteId(loc.id);
setDeleteError(null);
const res = await deleteLocation(loc.id, brandId);
setPendingDeleteId(null);
if (res.success) {
showSuccess("Venue deleted");
startTransition(() => router.refresh());
} else {
setDeleteError(res.error ?? "Delete failed");
showError("Delete failed", res.error ?? "Unknown error");
}
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search venues by name, city, or contact…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(value as StatusFilter);
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Venue
</AdminButton>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Address</th>
<th className="px-5 py-4 font-semibold">Contact</th>
<th className="px-5 py-4 font-semibold text-center">Stops</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center">
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-sm">
{search || statusFilter !== "all"
? "No venues match your filters."
: "No venues yet. Add your first venue to get started."}
</p>
</div>
</td>
</tr>
) : (
paginated.map((loc) => (
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
{(loc.city || loc.state) && (
<div className="text-xs text-[var(--admin-text-muted)]">
{[loc.city, loc.state].filter(Boolean).join(", ")}
</div>
)}
</td>
<td className="px-5 py-4">
{loc.address ? (
<div>
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
{loc.zip && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4">
{loc.contact_name || loc.phone || loc.contact_email ? (
<div className="space-y-0.5">
{loc.contact_name && (
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
)}
{loc.phone && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
)}
{loc.contact_email && (
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
{loc.contact_email}
</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4 text-center">
{loc.stop_count > 0 ? (
<span
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
style={{
background: "rgba(16, 185, 129, 0.12)",
color: "#047857",
}}
>
{loc.stop_count}
</span>
) : (
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
)}
</td>
<td className="px-5 py-4">
{loc.active ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
Active
</span>
) : (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
Inactive
</span>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button type="button"
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
aria-label="Edit venue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button type="button"
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
title="Delete venue"
aria-label="Delete venue">
{pendingDeleteId === loc.id ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
</svg>
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{showAdd && (
<AddLocationModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAdded}
/>
)}
{editing && (
<EditLocationModal
isOpen={!!editing}
onClose={() => setEditing(null)}
location={editing}
brandId={brandId}
onSuccess={handleEdited}
/>
)}
</>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
stop_id: string;
status: string;
subtotal: number;
pickup_complete: boolean;
created_at: string;
};
type OrderStatusTone = "warning" | "info" | "success" | "danger" | "neutral";
const statusToTone: Record<string, OrderStatusTone> = {
pending: "warning",
confirmed: "info",
paid: "success",
cancelled: "danger",
completed: "neutral",
};
export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
);
const [error, setError] = useState<string | null>(null);
async function togglePickup(orderId: string, current: boolean) {
const next = !current;
setPickupToggles((prev) => ({ ...prev, [orderId]: next }));
setError(null);
const result = await toggleOrderPickupComplete({ orderId, pickupComplete: next });
if (!result.success) {
// Revert optimistic update on failure
setPickupToggles((prev) => ({ ...prev, [orderId]: current }));
setError(result.error);
}
}
return (
<tbody style={{ borderColor: "var(--admin-border)" }}>
{error && (
<tr>
<td
colSpan={6}
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-danger)" }}
>
{error}
</td>
</tr>
)}
{orders.map((order) => (
<tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-3 py-2">
<span
className="font-mono text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.id.slice(0, 8)}
</span>
</td>
<td className="px-3 py-2">
<div
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</div>
<div
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_email}
</div>
</td>
<td className="px-3 py-2">
<AdminBadge tone={statusToTone[order.status] ?? "neutral"}>
{order.status ?? "pending"}
</AdminBadge>
</td>
<td
className="px-3 py-2 font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
${Number(order.subtotal).toFixed(2)}
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className="transition-opacity hover:opacity-80"
>
<AdminBadge tone={pickupToggles[order.id] ? "success" : "neutral"} dot>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</AdminBadge>
</button>
</td>
<td
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(new Date(order.created_at))}
</td>
</tr>
))}
</tbody>
);
}
@@ -1,142 +0,0 @@
"use client";
import { useState } from "react";
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
export default function ProductAssignmentForm({
stopId,
allProducts,
assignedProductIds,
}: {
stopId: string;
allProducts: Product[];
assignedProductIds: string[];
}) {
const [selected, setSelected] = useState("");
const [loading, setLoading] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set(assignedProductIds));
const [error, setError] = useState<string | null>(null);
const availableProducts = allProducts.filter(
(p) => !assignedIds.has(p.id)
);
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setLoading(true);
setError(null);
const result = await assignProductToStop({ stopId, productId: selected });
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setAssignedIds((prev) => new Set(prev).add(selected));
setSelected("");
setLoading(false);
}
async function handleRemove(productId: string) {
const result = await unassignProductFromStop({ stopId, productId });
if (!result.success) {
setError(result.error);
return;
}
setAssignedIds((prev) => {
const next = new Set(prev);
next.delete(productId);
return next;
});
}
const assignedProducts = allProducts.filter((p) => assignedIds.has(p.id));
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Assigned products list */}
{assignedProducts.length > 0 && (
<div>
<p className="text-sm font-medium text-zinc-300 mb-3">
Currently assigned
</p>
<div className="space-y-2">
{assignedProducts.map((product) => (
<div
key={product.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 p-4"
>
<div>
<p className="font-medium text-zinc-100">{product.name}</p>
<p className="text-sm text-zinc-500">
{product.type} · ${product.price}
</p>
</div>
<button type="button"
onClick={() => handleRemove(product.id)}
className="text-sm text-red-400 hover:text-red-800"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Assign form */}
<form onSubmit={handleAssign} className="flex gap-3">
<select aria-label="Select"
value={selected}
onChange={(e) => setSelected(e.target.value)}
required
className="flex-1 rounded-xl border border-zinc-600 px-4 py-3 outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.name} {product.type} (${product.price})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || loading}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Assigning..." : "Assign Product"}
</button>
</form>
{availableProducts.length === 0 && assignedProducts.length > 0 && (
<p className="text-sm text-zinc-500">All products already assigned.</p>
)}
{allProducts.length === 0 && (
<p className="text-sm text-zinc-500">
No active products for this brand yet.
</p>
)}
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
"use client";
import { useState } from "react";
import { Product } from "@/types";
type Props = {
products: Product[];
onSearchChange: (s: string) => void;
onStatusChange: (f: "all" | "active" | "inactive") => void;
search: string;
statusFilter: "all" | "active" | "inactive";
count: number;
};
export default function ProductFilterBar({
products,
onSearchChange,
onStatusChange,
search,
statusFilter,
count,
}: Props) {
return (
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{count}</span>
</div>
);
}
-101
View File
@@ -1,101 +0,0 @@
"use client";
import { useState } from "react";
import { syncSquareNow, getSyncLog } from "@/actions/square-sync-ui";
import Link from "next/link";
type Props = {
brandId: string;
hasSquareToken: boolean;
};
export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [logs, setLogs] = useState<{ event_type: string; status: string; created_at: string }[]>([]);
async function handleSyncProducts() {
setSyncing(true);
setMsg(null);
const result = await syncSquareNow(brandId, "products");
setMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Products synced — ${result.synced} item(s).`
: `Failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
const logResult = await getSyncLog(brandId);
if (logResult.success) setLogs(logResult.logs.filter(l => l.entity_type === "product").slice(0, 5));
}
if (!hasSquareToken) {
return (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<span className="text-amber-700">Square not connected.</span>
<Link href="/admin/settings/payments" className="ml-2 font-medium text-emerald-600 hover:text-emerald-700 underline transition-colors">
Connect Square
</Link>
</div>
);
}
return (
<div className="mb-4">
<div className="flex items-center gap-3">
<button type="button"
onClick={handleSyncProducts}
disabled={syncing}
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
>
{syncing ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Syncing...
</span>
) : (
<span className="flex items-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sync Products to Square
</span>
)}
</button>
<Link
href="/admin/settings/square-sync"
className="text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Square Sync Settings
</Link>
</div>
{msg && (
<div className={`mt-2 rounded-lg border px-3 py-2 text-sm ${
msg.kind === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-red-200 bg-red-50 text-red-700"
}`}>
{msg.text}
</div>
)}
{logs.length > 0 && (
<div className="mt-2 space-y-1">
{logs.map((log, i) => (
<div key={`${log.event_type}-${log.status}-${log.created_at}`} className="flex items-center gap-2 text-xs text-stone-500">
<span className={`rounded px-1.5 py-0.5 font-medium ${
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
}`}>{log.status}</span>
<span className="text-stone-600">{log.event_type}</span>
<span className="text-stone-400">{new Date(log.created_at).toLocaleTimeString()}</span>
</div>
))}
</div>
)}
</div>
);
}
-249
View File
@@ -1,249 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteProduct } from "@/actions/products";
import AdminBadge from "./design-system/AdminBadge";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
brands: { name: string } | { name: string }[];
is_taxable?: boolean;
};
type ProductTableBodyProps = {
products: Product[];
search: string;
statusFilter: "all" | "active" | "inactive";
onSearchChange: (v: string) => void;
onStatusChange: (v: "all" | "active" | "inactive") => void;
onDeleted: (productId: string) => void;
};
export default function ProductTableBody({
products,
search,
statusFilter,
onSearchChange,
onStatusChange,
onDeleted,
}: ProductTableBodyProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
async function handleDelete(productId: string) {
setDeletingId(productId);
setDeleteError(null);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted(productId);
} else {
setDeleteError(result.error ?? "Delete failed");
}
}
return (
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-primary)]"
/>
<div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-[var(--admin-primary)] text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
</div>
{/* Delete error banner */}
{deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}
<button type="button"
onClick={() => setDeleteError(null)}
className="ml-2 underline hover:no-underline"
aria-label="Dismiss">
Dismiss
</button>
</div>
)}
<tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<tr
key={product.id}
className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
>
<td className="px-3 py-2">
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
>
{product.name}
</Link>
<div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div>
</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
<td
className="px-3 py-2 font-semibold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</td>
<td className="px-3 py-2">
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
</td>
<td className="px-3 py-2">
{product.is_taxable === false ? (
<AdminBadge tone="warning">Non-taxable</AdminBadge>
) : (
<AdminBadge tone="success">Taxable</AdminBadge>
)}
</td>
{/* Actions */}
<td className="px-3 py-2 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Edit
</Link>
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
<>
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 backdrop-blur-sm border-0 p-0 cursor-default"
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</>
);
}
-292
View File
@@ -1,292 +0,0 @@
"use client";
import React, { useState, useTransition, useCallback } from "react";
import Image from "next/image";
import { deleteProduct } from "@/actions/products";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
image_url?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
products: Product[];
};
export default function ProductTableClient({ products }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
const handleDeleted = useCallback(() => {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}, [router]);
return (
<>
{/* Filter bar */}
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-sm text-stone-500 px-2">{filtered.length} products</span>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 bg-stone-50">
<tr>
<th className="px-5 py-4 font-semibold text-stone-500">Product</th>
<th className="px-5 py-4 font-semibold text-stone-500">Brand</th>
<th className="px-5 py-4 font-semibold text-stone-500">Type</th>
<th className="px-5 py-4 font-semibold text-stone-500">Price</th>
<th className="px-5 py-4 font-semibold text-stone-500">Status</th>
<th className="px-5 py-4 font-semibold text-stone-500" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-16 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<ProductRow
key={product.id}
product={product}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function ProductRowBase({
product,
onDeleted,
onDeleteError,
}: {
product: Product;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
async function handleDelete(productId: string) {
setDeletingId(productId);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{product.image_url ? (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-stone-200">
<Image
src={product.image_url}
alt={product.name}
fill
sizes="48px"
style={{ objectFit: "cover" }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
) : (
<div className="h-12 w-12 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-stone-200">
<span className="text-stone-400 text-lg"></span>
</div>
)}
<div>
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{product.name}
</Link>
<div className="text-stone-500 line-clamp-1 text-sm">
{product.description || (
<span className="italic text-stone-400">No description</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-stone-600 font-mono text-xs uppercase tracking-wide">{product.type}</td>
<td className="px-5 py-4 font-mono font-semibold text-stone-900">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
<>
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-stone-500">
This will remove the product. If it is attached to any orders,
it will be hidden instead of deleted.
</p>
<div className="mt-4 flex gap-2">
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const ProductRow = React.memo(ProductRowBase);
@@ -1,9 +0,0 @@
"use client";
import SquareSyncWidget from "@/components/admin/SquareSyncWidget";
type Props = { brandId: string };
export default function SquareSyncWidgetWrapper({ brandId }: Props) {
return <SquareSyncWidget brandId={brandId} />;
}
-31
View File
@@ -1,31 +0,0 @@
type Stat = {
label: string;
value: number | string;
emphasis?: boolean;
};
type Props = {
stats: Stat[];
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
right?: React.ReactNode;
};
export default function StatsStrip({ stats, right }: Props) {
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{stats.map((s, i) => (
<span key={`${s.label}-${s.value}`} className="flex items-baseline gap-1.5">
<span
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
>
{s.value}
</span>
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
</span>
))}
</div>
{right && <div className="ml-auto">{right}</div>}
</div>
);
}
-394
View File
@@ -1,394 +0,0 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getStopDetails } from "@/actions/stops/get-stop-details";
import { useToast } from "@/components/admin/design-system";
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
type Tab = "details" | "products" | "message";
type Props = {
stopId: string;
/** Optional: when the user clicks Duplicate from inside the modal. */
onDuplicate?: (stopId: string) => void;
onClose: () => void;
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
return (
<GlassModal
title="Stop"
subtitle="Stop details"
onClose={onClose}
maxWidth="max-w-3xl"
>
{/* Keying by stopId causes a full remount + fresh data fetch
whenever the target stop changes, eliminating the need to
reset internal state in an effect. */}
<StopDetailContent
key={stopId}
stopId={stopId}
onDuplicate={onDuplicate}
onClose={onClose}
/>
</GlassModal>
);
}
function StopDetailContent({
stopId,
onDuplicate,
onClose: _onClose,
}: {
stopId: string;
onDuplicate?: (stopId: string) => void;
onClose: () => void;
}) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
// Group the load result into a single state object so the effect
// only writes one piece of state at a time (satisfies
// `no-cascading-set-state`).
const [loadData, setLoadData] = useState<{
loading: boolean;
loadError: string | null;
stop: StopDetail | null;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
callerUid: string;
}>({
loading: true,
loadError: null,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
});
const { loading, loadError, stop, allProducts, assignedProducts, brands, callerUid } = loadData;
const [tab, setTab] = useState<Tab>("details");
// Track the last stopId we kicked off a fetch for. When the prop
// changes we flip `loading` inline during render so users never see
// stale "loaded" UI between the prop change and the effect running.
const [lastFetchedStopId, setLastFetchedStopId] = useState<string | null>(null);
if (stopId !== lastFetchedStopId) {
setLastFetchedStopId(stopId);
setLoadData({
loading: true,
loadError: null,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
});
}
useEffect(() => {
let cancelled = false;
void (async () => {
let nextState: typeof loadData;
try {
const res = await getStopDetails(stopId);
if (cancelled) return;
if (!res.success) {
nextState = {
loading: false,
loadError: res.error,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
};
} else {
nextState = {
loading: false,
loadError: null,
stop: res.stop,
allProducts: res.allProducts,
assignedProducts: res.assignedProducts,
brands: res.brands,
callerUid: res.callerUid,
};
}
} catch (err) {
if (cancelled) return;
nextState = {
loading: false,
loadError: err instanceof Error ? err.message : "Failed to load stop",
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
};
}
setLoadData(nextState);
})();
return () => {
cancelled = true;
};
}, [stopId]);
function refresh() {
getStopDetails(stopId).then((res) => {
if (!res.success) return;
setLoadData((prev) => ({
...prev,
stop: res.stop,
allProducts: res.allProducts,
assignedProducts: res.assignedProducts,
brands: res.brands,
callerUid: res.callerUid,
}));
});
}
function handleEditSaved() {
refresh();
showSuccess("Stop updated", "Changes have been saved");
startTransition(() => router.refresh());
}
const subtitle = stop?.brands
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
: undefined;
return (
<>
<div className="mb-3 hidden">
<span>{subtitle}</span>
</div>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-1/2 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
</div>
) : loadError ? (
<div
className="rounded-xl border px-4 py-3 text-sm text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
borderColor: "color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
Details
</TabButton>
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
<span className="inline-flex items-center gap-1.5">
Products
{assignedProducts.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
{assignedProducts.length}
</span>
)}
</span>
</TabButton>
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
Message
</TabButton>
</div>
{tab === "details" && (
<DetailsPanel
stop={stop}
brands={brands}
onDuplicate={onDuplicate}
onSaved={handleEditSaved}
/>
)}
{tab === "products" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Assigned Products
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Manage which products are available at this stop.
</p>
<div className="mt-4">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={callerUid}
/>
</div>
</div>
)}
{tab === "message" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Message Customers
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-4">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
)}
</div>
) : null}
</>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
}`}
>
{children}
</button>
);
}
function DetailsPanel({
stop,
brands,
onDuplicate,
onSaved,
}: {
stop: StopDetail;
brands: { id: string; name: string; slug: string }[];
onDuplicate?: (stopId: string) => void;
onSaved: () => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
</div>
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
</div>
{stop.address && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</dd>
</div>
)}
{stop.cutoff_time && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_time).toLocaleString()}
</dd>
</div>
)}
</dl>
</div>
<div className="flex justify-end">
{onDuplicate ? (
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</a>
)}
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Update stop details, location, and availability.
</p>
<div className="mt-4">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.slug,
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brands}
onSaved={onSaved}
/>
</div>
</div>
</div>
);
}
-240
View File
@@ -1,240 +0,0 @@
"use client";
import { useState } from "react";
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast";
import { AdminButton } from "@/components/admin/design-system";
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
];
export default function StopMessagingForm({
stopId,
brandId,
}: {
stopId: string;
brandId: string;
}) {
const [customers, setCustomers] = useState<StopCustomer[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [message, setMessage] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(0);
const [error, setError] = useState<string | null>(null);
async function loadCustomers() {
setLoading(true);
setError(null);
const result = await getStopPendingCustomers(stopId);
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setCustomers(result.customers);
setLoaded(true);
setLoading(false);
}
function applyQuickMessage(msg: string) {
setMessage(msg);
setCustomMessage(msg);
}
async function handleSend() {
if (!message.trim()) return;
setSending(true);
setError(null);
const blast = await sendStopBlast({
stopId,
brandId,
channel,
body: message,
audience: "pending",
});
if (!blast.success) {
setError(blast.error);
setSending(false);
return;
}
setSent(blast.messages_logged);
setSending(false);
}
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
return (
<div className="space-y-6">
<div>
<h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
Send Message
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Notify all customers with pending pickups at this stop.
</p>
</div>
{!loaded ? (
<button type="button"
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
>
{loading ? "Loading customers..." : "Load Pending Customers"}
</button>
) : (
<>
{customers.length === 0 ? (
<div className="rounded-xl bg-[var(--admin-bg-subtle)] p-6 text-center text-[var(--admin-text-muted)]">
No pending orders for this stop yet.
</div>
) : (
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span>
)}
</div>
)}
{/* Channel */}
<div>
<p className="ha-field-label mb-2">
<span>Send via</span>
</p>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button type="button"
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{ch === "sms" ? "SMS" : ch === "email" ? "Email" : "Both"}
</button>
))}
</div>
</div>
{/* Quick messages */}
<div>
<p className="ha-field-label mb-2">
<span>Quick messages</span>
</p>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
<button type="button"
key={qm.label}
onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{qm.label}
</button>
))}
</div>
</div>
{/* Message preview */}
<div>
<label htmlFor="fld-stop-message" className="ha-field-label mb-2">
<span>Message</span>
</label>
<textarea id="fld-stop-message" aria-label="Type Your Message..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
setCustomMessage(e.target.value);
}}
rows={4}
className="ha-field-textarea"
placeholder="Type your message..."
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)] tabular-nums">
{message.length} characters
</p>
</div>
{error && (
<div
role="alert"
className="rounded-xl p-4 text-sm text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)" }}
>
{error}
</div>
)}
{sent > 0 && (
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div>
)}
{/* Recipients */}
{recipients.length > 0 && message && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
Recipients ({recipients.length}):
</p>
<div className="space-y-2">
{recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
<span className="text-[var(--admin-text-muted)] tabular-nums">
{c.customer_phone ?? c.customer_email ?? "no contact"}
</span>
</div>
))}
</div>
</div>
)}
<AdminButton
variant="primary"
size="lg"
onClick={handleSend}
disabled={!message || recipients.length === 0 || sending}
isLoading={sending}
className="w-full"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</AdminButton>
</>
)}
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
"use client";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brands: { name: string } | { name: string }[];
};
export default function StopTableBody({ stops }: { stops: Stop[] }) {
return (
<tbody className="divide-y divide-slate-200">
{stops?.map((stop) => (
<tr
key={stop.id}
onClick={() =>
(window.location.href = `/admin/stops/${stop.id}`)
}
className="cursor-pointer hover:bg-zinc-800"
>
<td className="px-3 py-2">
<span className="font-medium text-zinc-100">
{stop.city}, {stop.state}
</span>
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.location}
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.date}
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.time}
</td>
<td className="px-3 py-2 text-zinc-300">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-3 py-2">
<span className="rounded-full bg-zinc-950 px-3 py-1 text-xs font-medium text-zinc-300">
{stop.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
);
}
@@ -1,754 +0,0 @@
"use client";
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { useToast } from "@/components/admin/design-system";
import type { StopForView } from "./StopsViewClient";
type Props = {
stops: StopForView[];
};
/* ================================================================== */
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
/* ================================================================== */
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmd(s: string): Date | null {
// Treat YYYY-MM-DD as a local date (no UTC shift)
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const [, y, mo, d] = m;
return new Date(Number(y), Number(mo) - 1, Number(d));
}
function todayYmd(): string {
return ymd(new Date());
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const MONTH_NAMES_ITALIC = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function buildMonthGrid(month: Date): Date[] {
// Start at the Sunday on or before the 1st of the month
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const startDayOfWeek = first.getDay(); // 0 = Sun
const start = new Date(first);
start.setDate(1 - startDayOfWeek);
// 6 weeks = 42 cells
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
if (stop.status === "draft") return "draft";
if (stop.active) return "active";
return "inactive";
}
function statusLabel(s: "active" | "draft" | "inactive"): string {
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
}
function brandName(s: StopForView): string {
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
}
function formatTime12(t: string | null | undefined): string {
if (!t) return "—";
// t is "HH:MM" or "HH:MM:SS"
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "pm" : "am";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr} ${ampm}`;
}
function formatTimeCompact(t: string | null | undefined): string {
if (!t) return "—";
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "p" : "a";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr}${ampm}`;
}
function formatDateLong(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}`;
}
function formatDateLongSpelled(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
}
/* ================================================================== */
/* Component */
/* ================================================================== */
const MAX_EVENTS_PER_CELL = 3;
export default function StopsCalendarClient({ stops }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [viewMonth, setViewMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activePopover, setActivePopover] = useState<{
stopId: string;
cellRect: DOMRect;
} | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
// Bucket stops by YYYY-MM-DD
const stopsByDate = useMemo(() => {
const map = new Map<string, StopForView[]>();
for (const s of stops) {
if (!s.date) continue;
const list = map.get(s.date) ?? [];
list.push(s);
map.set(s.date, list);
}
// Sort each day by time
for (const list of map.values()) {
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const todayStr = useMemo(() => todayYmd(), []);
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
// Earliest/latest stop dates — used to enable/disable nav
const dateRange = useMemo(() => {
if (stops.length === 0) return null;
const dates = stops.flatMap((s) => (s.date ? [s.date] : [])).sort();
return { min: dates[0], max: dates[dates.length - 1] };
}, [stops]);
const isFirstMonth = useMemo(() => {
if (!dateRange?.min) return false;
const minDate = parseYmd(dateRange.min);
if (!minDate) return false;
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
}, [dateRange, viewMonth]);
const isLastMonth = useMemo(() => {
if (!dateRange?.max) return false;
const maxDate = parseYmd(dateRange.max);
if (!maxDate) return false;
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
}, [dateRange, viewMonth]);
function goPrevMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
}
function goNextMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
}
function goToday() {
const today = new Date();
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
setSelectedDate(todayYmd());
}
// Close popover on outside click / Escape
useEffect(() => {
if (!activePopover) return;
function onDown(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
setActivePopover(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setActivePopover(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [activePopover]);
// Close drawer on Escape
useEffect(() => {
if (!selectedDate) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setSelectedDate(null);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [selectedDate]);
// Lock body scroll when drawer is open
useEffect(() => {
if (selectedDate) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [selectedDate]);
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
}, []);
const handlePublish = useCallback(
async (stop: StopForView) => {
setActivePopover(null);
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
startTransition(() => router.refresh());
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
},
[router, showSuccess, showError]
);
const activeStop = useMemo(
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
[activePopover, stops]
);
const selectedDayStops = useMemo(() => {
if (!selectedDate) return [];
return stopsByDate.get(selectedDate) ?? [];
}, [selectedDate, stopsByDate]);
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
// Compute popover position with viewport edge detection
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover) return null;
const POPOVER_W = 288; // 18rem
const MARGIN = 8;
const rect = activePopover.cellRect;
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
// Default: place below the chip, left-aligned
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
const top = rect.bottom + MARGIN;
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
if (left < 8) left = 8;
return { left, top };
}, [activePopover]);
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover || !popoverStyle) return null;
const rect = activePopover.cellRect;
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
}, [activePopover, popoverStyle]);
// === Render ===
return (
<>
<div className="ha-calendar">
{/* Header */}
<div className="ha-calendar-header">
<div className="ha-calendar-title-block">
<span className="ha-calendar-eyebrow">
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
</span>
<h2 className="ha-calendar-title">
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
</h2>
</div>
<div className="ha-calendar-nav">
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goPrevMonth}
disabled={isFirstMonth}
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button" className="ha-calendar-today" onClick={goToday}>
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
Today
</button>
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goNextMonth}
disabled={isLastMonth}
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* DOW header */}
<div className="ha-calendar-dow" role="row">
{DOW_LABELS.map((label) => (
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
{label}
</div>
))}
</div>
{/* Grid */}
<div className="ha-calendar-grid" role="grid">
{monthCells.map((d) => {
const key = ymd(d);
const dayStops = stopsByDate.get(key) ?? [];
const isOut = d.getMonth() !== viewMonth.getMonth();
const isToday = key === todayStr;
const hasStops = dayStops.length > 0;
const dow = d.getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = selectedDate === key;
return (
<div
key={key}
role="gridcell"
tabIndex={hasStops ? 0 : -1}
onClick={() => hasStops && setSelectedDate(key)}
onKeyDown={(e) => {
if (hasStops && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setSelectedDate(key);
}
}}
className={[
"ha-calendar-cell",
isOut ? "ha-calendar-cell--out" : "",
isWeekend ? "ha-calendar-cell--weekend" : "",
hasStops ? "ha-calendar-cell--has-stops" : "",
isToday ? "ha-calendar-cell--today" : "",
isSelected ? "ha-calendar-cell--selected" : "",
]
.filter(Boolean)
.join(" ")}
aria-label={hasStops ? `${formatDateLong(d)}${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
>
<div className="ha-calendar-daynum">
<span>{d.getDate()}</span>
{isToday && <span className="ha-calendar-today-dot" />}
</div>
<div className="ha-calendar-events">
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
<EventChip
key={stop.id}
stop={stop}
isPublishing={publishingId === stop.id}
onOpen={(rect) => openStop(stop, rect)}
isActive={activePopover?.stopId === stop.id}
/>
))}
{dayStops.length > MAX_EVENTS_PER_CELL && (
<button
type="button"
className="ha-calendar-event-more"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(key);
}}
>
+{dayStops.length - MAX_EVENTS_PER_CELL} more
</button>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="ha-calendar-legend">
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
Active
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-warning)" }} />
Draft
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
Inactive
</span>
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
Tip click any day with stops to see the full route · click an event for details
</span>
</div>
</div>
{/* Event popover */}
{activeStop && activePopover && popoverStyle && (
<EventPopover
stop={activeStop}
isPublishing={publishingId === activeStop.id}
onPublish={() => handlePublish(activeStop)}
onOpenRoute={() => {
setActivePopover(null);
setSelectedDate(activeStop.date);
}}
style={popoverStyle}
arrowStyle={popoverArrowStyle}
/>
)}
{/* Day-route drawer */}
{selectedDate && selectedDayDate && (
<DayRouteDrawer
date={selectedDayDate}
dateStr={selectedDate}
stops={selectedDayStops}
onClose={() => setSelectedDate(null)}
onPublish={handlePublish}
/>
)}
</>
);
}
/* ================================================================== */
/* EventChip — single stop on a day cell */
/* ================================================================== */
function EventChip({
stop,
onOpen,
isActive,
isPublishing,
}: {
stop: StopForView;
onOpen: (rect: DOMRect) => void;
isActive: boolean;
isPublishing: boolean;
}) {
const ref = useRef<HTMLButtonElement>(null);
const status = statusOf(stop);
return (
<button
ref={ref}
type="button"
data-event-chip
onClick={(e) => {
e.stopPropagation();
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
}}
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
title={`${stop.city}, ${stop.state}${stop.location}`}
>
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
</button>
);
}
/* ================================================================== */
/* EventPopover — floating detail card */
/* ================================================================== */
function EventPopover({
stop,
isPublishing,
onPublish,
onOpenRoute,
style,
arrowStyle,
}: {
stop: StopForView;
isPublishing: boolean;
onPublish: () => void;
onOpenRoute: () => void;
style: React.CSSProperties;
arrowStyle: React.CSSProperties | null;
}) {
const status = statusOf(stop);
return (
<div
data-event-popover
className="ha-event-popover"
style={style}
onClick={(e) => e.stopPropagation()}
>
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
<div className="ha-event-popover-eyebrow">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "var(--admin-warning)" : "var(--admin-text-muted)",
}}
/>
{statusLabel(status)} · {brandName(stop)}
</div>
<h3 className="ha-event-popover-title">
{stop.city}, {stop.state}
</h3>
<p className="ha-event-popover-location">{stop.location}</p>
<div className="ha-event-popover-grid">
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Pickup</span>
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
</div>
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Cutoff</span>
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
</div>
{stop.address && (
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
<span className="ha-event-popover-field-label">Address</span>
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
</div>
)}
</div>
<div className="ha-event-popover-actions">
{status === "draft" && (
<button
type="button"
onClick={onPublish}
disabled={isPublishing}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
{isPublishing ? "Publishing…" : "Publish"}
</button>
)}
<button
type="button"
onClick={onOpenRoute}
className="ha-event-popover-btn"
>
View route
</button>
<Link
href={`/admin/stops/${stop.id}`}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
);
}
/* ================================================================== */
/* DayRouteDrawer — full day's route on the side */
/* ================================================================== */
function DayRouteDrawer({
date,
dateStr,
stops,
onClose,
onPublish,
}: {
date: Date;
dateStr: string;
stops: StopForView[];
onClose: () => void;
onPublish: (stop: StopForView) => void;
}) {
const sorted = useMemo(() => stops.toSorted((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const counts = useMemo(() => {
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
const draft = sorted.filter((s) => s.status === "draft").length;
const total = sorted.length;
return { active, draft, total };
}, [sorted]);
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
const routeNumber = useMemo(() => {
// Editorial "Route 03" — count of stops with status badge
return String(sorted.length).padStart(2, "0");
}, [sorted.length]);
const earliest = sorted[0]?.time;
const latest = sorted[sorted.length - 1]?.time;
return (
<>
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
<header className="ha-drawer-header">
<div>
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
<p className="ha-drawer-subtitle">
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
{earliest && latest && sorted.length > 1 && (
<> · {formatTime12(earliest)} {formatTime12(latest)}</>
)}
</p>
</div>
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="ha-drawer-body">
{sorted.length === 0 ? (
<div className="ha-drawer-empty">
<p className="ha-drawer-empty-mark">No route on this day</p>
<p className="ha-drawer-empty-text">
No stops are scheduled for {formatDateLongSpelled(date)}.
</p>
</div>
) : (
<>
{/* Summary cards */}
<div className="ha-route-summary">
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.total}</span>
<span className="ha-route-summary-label">Stops</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.active}</span>
<span className="ha-route-summary-label">Live</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{brands.length}</span>
<span className="ha-route-summary-label">Brands</span>
</div>
</div>
{/* Route spine */}
<div className="ha-route-list">
{sorted.map((stop, idx) => {
const status = statusOf(stop);
return (
<article key={stop.id} className="ha-route-stop">
<div className="ha-route-stop-spine">
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
{String(idx + 1).padStart(2, "0")}
</div>
<div className="ha-route-stop-line" />
</div>
<div className="ha-route-stop-content">
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
<h3 className="ha-route-stop-name">
{stop.city}, {stop.state}
</h3>
<p className="ha-route-stop-location">{stop.location}</p>
<div className="ha-route-stop-meta">
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
<span
className="ha-route-stop-meta-pill"
style={{
background:
status === "active"
? "var(--admin-accent-light)"
: status === "draft"
? "var(--admin-warning-soft)"
: "var(--admin-bg-subtle)",
color:
status === "active"
? "var(--admin-accent-text)"
: status === "draft"
? "var(--admin-warning)"
: "var(--admin-text-secondary)",
borderColor:
status === "active"
? "var(--admin-accent)"
: status === "draft"
? "var(--admin-warning)"
: "var(--admin-border-light)",
}}
>
{statusLabel(status)}
</span>
{stop.cutoff_time && (
<span className="ha-route-stop-meta-pill">
Cutoff {formatTime12(stop.cutoff_time)}
</span>
)}
{stop.address && (
<span className="ha-route-stop-meta-pill">
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
)}
</div>
<div className="ha-route-stop-actions">
{status === "draft" && (
<button
type="button"
onClick={() => onPublish(stop)}
className="ha-route-stop-action ha-route-stop-action--primary"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Publish
</button>
)}
<Link
href={`/admin/stops/${stop.id}`}
className="ha-route-stop-action ha-route-stop-action--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
className="ha-route-stop-action"
>
Duplicate
</Link>
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
</aside>
</>
);
}
@@ -1,73 +0,0 @@
"use client";
import { useState } from "react";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { useRouter } from "next/navigation";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
/** Hide on the Locations tab — actions belong to the Stops tab only. */
tab?: "stops" | "locations";
};
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const router = useRouter();
if (tab !== "stops") return null;
function handleImportComplete(count: number) {
router.refresh();
}
function handleAddSuccess(stopId: string) {
router.refresh();
}
return (
<>
<div className="flex gap-3">
<AdminButton
variant="secondary"
onClick={() => setShowImport(true)}
icon={
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
>
Upload Schedule
</AdminButton>
<AdminButton
variant="primary"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Stop
</AdminButton>
</div>
{showImport && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setShowImport(false)}
onComplete={handleImportComplete}
/>
)}
<AddStopModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAddSuccess}
/>
</>
);
}
-107
View File
@@ -1,107 +0,0 @@
"use client";
import Link from "next/link";
type Props = {
active: "stops" | "locations";
stopCount: number;
locationCount: number;
stopsSublabel?: string; // e.g. "269 · 7 cities"
locationsSublabel?: string; // e.g. "41 · 8 cities"
};
const StopIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
const PinIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function StopsLocationsTabs({
active,
stopCount,
locationCount,
stopsSublabel,
locationsSublabel,
}: Props) {
const tabs = [
{
value: "stops" as const,
label: "Stops",
count: stopCount,
sublabel: stopsSublabel,
icon: <StopIcon />,
href: "/admin/stops",
},
{
value: "locations" as const,
label: "Locations",
count: locationCount,
sublabel: locationsSublabel,
icon: <PinIcon />,
href: "/admin/stops?tab=locations",
},
];
return (
<div
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
role="tablist"
aria-label="Stops and Locations tabs"
>
{tabs.map((t) => {
const isActive = t.value === active;
return (
<Link
key={t.value}
href={t.href}
role="tab"
aria-selected={isActive}
className={`
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200
${isActive
? "bg-white text-[var(--admin-primary)] border border-[var(--admin-primary)]/30 shadow-sm"
: "text-[var(--admin-text-secondary)] border border-transparent hover:text-[var(--admin-primary)] hover:bg-[var(--admin-primary-soft)]/40"
}
`}
>
<span
className={`flex-shrink-0 transition-colors ${isActive ? "text-[var(--admin-primary)]" : "text-[var(--admin-text-muted)] group-hover:text-[var(--admin-primary)]"}`}
aria-hidden
>
{t.icon}
</span>
<span>{t.label}</span>
<span
className={`
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums
${isActive
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] group-hover:bg-[var(--admin-primary-soft)] group-hover:text-[var(--admin-primary)]"
}
`}
>
{t.count}
</span>
{t.sublabel && (
<span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-[var(--admin-text-muted)]" : "text-[var(--admin-text-muted)]/80"}`}
>
· {t.sublabel}
</span>
)}
</Link>
);
})}
</div>
);
}
-132
View File
@@ -1,132 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import StopTableClient from "@/components/admin/StopTableClient";
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
export type StopsViewMode = "calendar" | "table";
export type StopForView = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: StopForView[];
};
export default function StopsViewClient({ stops }: Props) {
const [view, setView] = useState<StopsViewMode>("calendar");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
// Apply shared filter so both views agree on what's visible
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const isDraft = s.status === "draft";
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && !isDraft) ||
(statusFilter === "inactive" && !s.active && !isDraft) ||
(statusFilter === "draft" && isDraft);
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const counts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && s.status !== "draft").length,
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
{ value: "draft", label: "Draft", count: counts.draft },
];
return (
<>
{/* Filter / search / view-toggle bar — shared across both views */}
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
tabs={tabs}
size="sm"
showCounts
/>
<AdminSearchInput
placeholder="Search city, state, or venue…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
</span>
<div className="sm:ml-auto">
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
<button
type="button"
role="tab"
aria-selected={view === "calendar"}
onClick={() => setView("calendar")}
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
Calendar
</button>
<button
type="button"
role="tab"
aria-selected={view === "table"}
onClick={() => setView("table")}
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
Table
</button>
</div>
</div>
</div>
{view === "calendar" ? (
<StopsCalendarClient stops={filtered} />
) : (
<StopTableClient stops={filtered} hideInternalFilterBar />
)}
</>
);
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
type Props = {
tabKey: string;
children: React.ReactNode;
};
/**
* Fades the content of a tabbed panel when the active tab changes.
* The tab key drives the animation, so switching tabs triggers a quick
* opacity-only fade-in of the new content.
*
* Motion note: the previous version used `y: 4` / `y: -4` on enter/exit
* for a small slide. That 4px slide, multiplied across every tab change
* in the admin, was a steady source of positional movement. Now it's
* pure opacity, 120ms, ease-out. `reducedMotion="user"` on the global
* <MotionConfig> will further strip this to instant if the user has
* prefers-reduced-motion enabled.
*
* No motion when the tab key is stable (i.e. on first render of a given tab).
*/
export function TabSwitcher({ tabKey, children }: Props) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tabKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: "easeOut" }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
@@ -1,95 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getTaxSummaryAction } from "@/actions/tax";
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
};
function quarterLabel(): string {
const now = new Date();
const q = Math.floor(now.getMonth() / 3) + 1;
return `Q${q} ${now.getFullYear()}`;
}
function quarterDateRange(): { start: string; end: string } {
const now = new Date();
const q = Math.floor(now.getMonth() / 3);
const startDate = new Date(now.getFullYear(), q * 3, 1);
const end = now.toISOString().slice(0, 10);
return { start: startDate.toISOString().slice(0, 10), end };
}
export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
const [data, setData] = useState<TaxSummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const range = quarterDateRange();
let cancelled = false;
(async () => {
const result = await getTaxSummaryAction({
brandId,
startDate: range.start,
endDate: range.end,
});
if (cancelled) return;
if (result.success) {
setData({
total_tax_collected: result.data.total_tax_collected,
total_gross_sales: result.data.total_gross_sales,
order_count: result.data.order_count,
});
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-xs text-[var(--admin-text-secondary)]">Loading tax summary...</span>
</div>
);
}
if (!data || data.order_count === 0) return null;
const effectiveRate = data.total_gross_sales > 0
? (data.total_tax_collected / data.total_gross_sales) * 100
: 0;
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]">
{quarterLabel()} Tax Collected
</span>
</div>
<Link
href="/admin/taxes"
className="text-xs text-[var(--admin-accent-text)] hover:text-[var(--admin-accent)] font-medium transition-colors"
>
View Details
</Link>
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-xl font-bold text-[var(--admin-text-primary)]">
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
<span className="text-xs text-[var(--admin-text-muted)]">
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
</span>
</div>
</div>
);
}
@@ -1,79 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
};
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
return (
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
/>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
<div>
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
>
<svg aria-hidden="true" className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
</dialog>
);
}
@@ -1,6 +0,0 @@
// Toast notification system - re-exports for design-system
// The actual implementations are in the parent admin directory
// Use: import { ToastProvider, useToast, useToastActions, ToastContainer } from "@/components/admin/Toast";
export { ToastProvider, useToast, useToastActions } from "@/components/admin/Toast";
export { ToastContainer, InlineToast } from "@/components/admin/ToastContainer";
-123
View File
@@ -1,123 +0,0 @@
"use client";
import { useState } from "react";
type Column<T> = {
key: keyof T | string;
header: string;
render?: (item: T) => React.ReactNode;
className?: string;
align?: "left" | "right" | "center";
};
type DataTableProps<T> = {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowClassName?: string;
paginate?: boolean;
pageSize?: number;
};
export default function DataTable<T>({
data,
columns,
keyExtractor,
emptyMessage = "No data found",
onRowClick,
rowClassName,
paginate = false,
pageSize = 50,
}: DataTableProps<T>) {
const [page, setPage] = useState(0);
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
const totalPages = Math.ceil(data.length / pageSize);
const alignClass = (align?: "left" | "right" | "center") => {
switch (align) {
case "right": return "text-right";
case "center": return "text-center";
default: return "text-left";
}
};
return (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50">
{columns.map((col) => (
<th
key={String(col.key)}
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{displayed.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
{emptyMessage}
</td>
</tr>
) : (
displayed.map((item) => (
<tr
key={keyExtractor(item)}
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
onRowClick ? "cursor-pointer" : ""
} ${rowClassName ?? ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<td
key={String(col.key)}
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{paginate && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
<span className="text-xs text-stone-500">
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
"use client";
type FilterOption = {
value: string;
label: string;
};
type FilterBarProps = {
searchPlaceholder?: string;
searchValue: string;
onSearchChange: (value: string) => void;
filters?: {
label?: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}[];
tabs?: {
label: string;
value: string;
count?: number;
}[];
activeTab?: string;
onTabChange?: (value: string) => void;
actions?: React.ReactNode;
resultCount?: number;
showCount?: boolean;
};
export default function FilterBar({
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
tabs = [],
activeTab,
onTabChange,
actions,
resultCount,
showCount = true,
}: FilterBarProps) {
return (
<div className="space-y-3">
{/* Search + Filters Row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<input aria-label="Search"
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
</div>
{filters.map((filter, i) => (
<div key={`${filter.label ?? ""}-${filter.value}-${i}`} className="space-y-1">
{filter.label && (
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
)}
<select aria-label="Select"
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
{actions && <div className="flex items-center gap-2">{actions}</div>}
{showCount && resultCount !== undefined && (
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
)}
</div>
{/* Tabs Row */}
{tabs.length > 0 && (
<div className="flex gap-1 border-b border-stone-200 pb-0">
{tabs.map((tab) => (
<button type="button"
key={tab.value}
onClick={() => onTabChange?.(tab.value)}
className={`
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
${activeTab === tab.value
? "text-emerald-600 border-emerald-600"
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
}
`}
>
{tab.label}
{tab.count !== undefined && (
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,42 +0,0 @@
"use client";
import Link from "next/link";
type PageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
title: string;
description?: string;
action?: React.ReactNode;
};
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
return (
<div className="mb-8">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
{breadcrumb.map((crumb, i) => (
<span key={`${crumb.label}-${i}`} className="flex items-center gap-2">
{crumb.href ? (
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-stone-600">{crumb.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
{description && (
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
</div>
);
}
@@ -1,51 +0,0 @@
type StatusBadgeProps = {
status: string;
size?: "sm" | "md";
};
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
// Active/Enabled states
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
// Pending/Warning states
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
// Inactive/Disabled states
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
// Info states
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
// Special states
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
};
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
const config = STATUS_STYLES[status] ?? {
bg: "bg-stone-100",
text: "text-stone-600",
label: status.replace(/_/g, " "),
};
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
return (
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
{config.label}
</span>
);
}
-4
View File
@@ -1,4 +0,0 @@
export { default as PageHeader } from "./PageHeader";
export { default as StatusBadge } from "./StatusBadge";
export { default as FilterBar } from "./FilterBar";
export { default as DataTable } from "./DataTable";
@@ -1,432 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type Stop,
type StopStatus,
formatMonthYear,
formatTime12,
getStopDate,
getStopStatus,
isSameDay,
} from "./types";
type Props = {
stops: Stop[];
};
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
active: {
bg: "bg-[var(--admin-accent-light)]",
text: "text-[var(--admin-accent-text)]",
dot: "bg-[var(--admin-accent-dot)]",
border: "border-[var(--admin-accent)]/40",
},
draft: {
bg: "bg-amber-50",
text: "text-amber-800",
dot: "bg-amber-500",
border: "border-amber-300",
},
inactive: {
bg: "bg-stone-100",
text: "text-stone-500",
dot: "bg-stone-400",
border: "border-stone-300",
},
};
function buildMonthGrid(viewYear: number, viewMonth: number) {
// Sunday-first 6-row grid
const first = new Date(viewYear, viewMonth, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells: { date: Date; inMonth: boolean }[] = [];
// Leading days from previous month
for (let i = startWeekday - 1; i >= 0; i--) {
const d = new Date(viewYear, viewMonth, -i);
cells.push({ date: d, inMonth: false });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
}
// Trailing days to fill 6 rows = 42 cells
while (cells.length < 42) {
const last = cells[cells.length - 1].date;
const next = new Date(last);
next.setDate(last.getDate() + 1);
cells.push({ date: next, inMonth: false });
}
return cells;
}
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export default function StopsCalendar({ stops }: Props) {
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [selectedDate, setSelectedDate] = useState<string | null>(() => ymd(today));
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
const stopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
const k = ymd(d);
const arr = map.get(k) ?? [];
arr.push(s);
map.set(k, arr);
}
// sort each bucket by time
for (const arr of map.values()) {
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const visibleStopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const [k, arr] of stopsByDate.entries()) {
map.set(
k,
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
);
}
return map;
}, [stopsByDate]);
const monthStopsCount = useMemo(() => {
let count = 0;
for (const cell of cells) {
if (!cell.inMonth) continue;
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
}
return count;
}, [cells, stopsByDate]);
const upcomingWeek = useMemo(() => {
const end = new Date(today);
end.setDate(end.getDate() + 7);
let count = 0;
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
}
return count;
}, [stops, today]);
const goPrev = () => {
setView((v) => {
const m = v.month - 1;
if (m < 0) return { year: v.year - 1, month: 11 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goNext = () => {
setView((v) => {
const m = v.month + 1;
if (m > 11) return { year: v.year + 1, month: 0 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goToday = () => {
setView({ year: today.getFullYear(), month: today.getMonth() });
setSelectedDate(ymd(today));
};
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
{/* Calendar card */}
<section
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
aria-label="Stops calendar"
>
{/* Decorative paper texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.4]"
style={{
backgroundImage:
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
{/* Header */}
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
<div className="flex items-baseline gap-4">
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
{monthLabel.split(" ")[0]}
<span className="text-[var(--admin-accent)] italic font-light">
{" "}
{monthLabel.split(" ")[1]}
</span>
</h2>
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
</span>
</div>
<div className="flex items-center gap-1.5">
<button type="button"
onClick={goPrev}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<button type="button"
onClick={goToday}
disabled={isCurrentMonth}
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Today
</button>
<button type="button"
onClick={goNext}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</header>
{/* Weekday header */}
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
{WEEKDAY_LABELS.map((d, i) => (
<div
key={d}
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
}`}
>
{d}
</div>
))}
</div>
{/* Grid */}
<div className="relative grid grid-cols-7 grid-rows-6">
{cells.map((cell, idx) => {
const k = ymd(cell.date);
const dayStops = stopsByDate.get(k) ?? [];
const visibleStops = visibleStopsByDate.get(k) ?? [];
const isToday = isSameDay(cell.date, today);
const isSelected = k === selectedDate;
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
const overflow = dayStops.length - visibleStops.length;
return (
<button
key={k}
type="button"
onClick={() => setSelectedDate(k)}
className={`
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
`}
>
{/* Date number row */}
<div className="flex items-center justify-between">
<span
className={`
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
${isToday
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
: !cell.inMonth
? "text-[var(--admin-text-muted)]/50"
: isSelected
? "text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-primary)]"
}
`}
>
{cell.date.getDate()}
</span>
{dayStops.length > 0 && cell.inMonth && (
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{dayStops.length}
</span>
)}
</div>
{/* Stop chips */}
<div className="flex flex-col gap-0.5 overflow-hidden">
{visibleStops.slice(0, 3).map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<div
key={s.id}
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
<span className="truncate">
{s.time && (
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
)}
{s.city}
</span>
</div>
);
})}
{overflow > 0 && (
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
+{overflow} more
</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
const st = STATUS_STYLES[s];
return (
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
<span>{s}</span>
</div>
);
})}
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span>
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
{upcomingWeek}
</span>{" "}
upcoming in 7d
</span>
</div>
</footer>
</section>
{/* Day detail panel */}
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<div className="border-b border-[var(--admin-border)] px-5 py-4">
<div className="flex items-baseline gap-2">
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
: "Pick a day"}
</h3>
</div>
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "Select a date on the calendar"}
</p>
</div>
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
{!selectedDate ? (
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
No day selected
</div>
) : selectedDayStops.length === 0 ? (
<div className="px-4 py-10 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
Free day on the route
</p>
</div>
) : (
<ul className="space-y-1">
{selectedDayStops.map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
>
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
{formatTime12(s.time).split(" ")[0]}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{s.city}, {s.state}
</p>
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{formatTime12(s.time).split(" ")[1]}
</span>
</div>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
<div className="mt-1.5 flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
{status}
</span>
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
</span>
) : null}
</div>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
}
@@ -1,236 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { type Stop, type StopView, getStopStatus } from "./types";
import StopsCalendar from "./StopsCalendar";
import StopsLocations from "./StopsLocations";
import StopsList from "./StopsList";
type Props = {
stops: Stop[];
page?: number;
totalPages?: number;
totalCount?: number;
};
const TABS: { value: StopView; label: string; hint: string }[] = [
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
{ value: "list", label: "List", hint: "All stops in order" },
];
export default function StopsDashboardClient({ stops, page = 1, totalPages = 1, totalCount = 0 }: Props) {
const [view, setView] = useState<StopView>("calendar");
const stats = useMemo(() => {
const total = stops.length;
const active = stops.filter((s) => getStopStatus(s) === "active").length;
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
const venues = new Set(
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
).size;
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7 = new Date(today);
in7.setDate(today.getDate() + 7);
const upcoming = stops.filter((s) => {
if (!s.date) return false;
if (getStopStatus(s) === "inactive") return false;
const d = new Date(s.date + "T00:00:00");
return d >= today && d < in7;
}).length;
return { total, active, draft, cities, venues, upcoming };
}, [stops]);
return (
<div className="space-y-6">
{/* Top stats strip — almanac style */}
<section
aria-label="Stops overview"
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
Harvest Dispatch · Almanac
</p>
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
{stats.total === 0
? "No stops scheduled"
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
</span>
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
</p>
</div>
{/* Tab nav — binder-style with notched corners */}
<div className="flex items-end gap-1">
{TABS.map((t) => {
const isActive = t.value === view;
return (
<button
key={t.value}
type="button"
onClick={() => setView(t.value)}
aria-pressed={isActive}
className={`
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
${isActive
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
}
`}
>
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
{t.label}
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
{t.hint}
</span>
{isActive && (
<span
aria-hidden
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
/>
)}
</button>
);
})}
</div>
</div>
{/* Stat cells */}
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
<AlmanacStat
numeral="I"
label="Active"
value={stats.active}
accent
/>
<AlmanacStat
numeral="II"
label="Upcoming 7d"
value={stats.upcoming}
accent={stats.upcoming > 0}
/>
<AlmanacStat
numeral="III"
label="Cities"
value={stats.cities}
/>
<AlmanacStat
numeral="IV"
label="Venues"
value={stats.venues}
/>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="relative flex items-center justify-between border-t border-[var(--admin-border-light)] pt-4 mt-4">
<p className="font-mono text-xs text-[var(--admin-text-muted)]">
Showing {((page - 1) * 50) + 1}{Math.min(page * 50, totalCount)} of {totalCount}
</p>
<div className="flex items-center gap-1">
<a
href={page > 1 ? `?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
page <= 1
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
</a>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const p = i + 1;
return (
<a
key={p}
href={`?page=${p}`}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
p === page
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
{p}
</a>
);
})}
<a
href={page < totalPages ? `?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
page >= totalPages
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
</a>
</div>
</div>
)}
</section>
{/* Active view */}
<section>
{view === "calendar" && <StopsCalendar stops={stops} />}
{view === "locations" && <StopsLocations stops={stops} />}
{view === "list" && <StopsList stops={stops} />}
</section>
</div>
);
}
function AlmanacStat({
numeral,
label,
value,
accent,
}: {
numeral: string;
label: string;
value: number;
accent?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<span
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
{numeral}
</span>
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{label}
</p>
<p
className={`font-display text-2xl font-medium leading-none tabular-nums ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
"use client";
import { useMemo } from "react";
import { type Stop, getStopStatus } from "./types";
type Props = {
stops: Stop[];
};
// Minimal list view used inside the StopsDashboard tab nav.
// It is a calmer, read-only list; for bulk publish/edit use the
// dedicated /admin/stops page.
export default function StopsList({ stops }: Props) {
const sorted = useMemo(() => {
return stops.toSorted((a, b) => (a.date || "").localeCompare(b.date || ""));
}, [stops]);
if (sorted.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Switch to a different tab to add stops.
</p>
</div>
);
}
return (
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
{sorted.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
return (
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{s.time ? s.time.slice(0, 5) : "—"}
</span>
<span className="flex-1 min-w-0">
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
{s.city}, {s.state}
</span>
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
</span>
{brand && (
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{brand}
</span>
)}
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</li>
);
})}
</ul>
);
}
@@ -1,412 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type LocationGroup,
type Stop,
formatTime12,
getStopStatus,
} from "./types";
type Props = {
stops: Stop[];
};
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
const map = new Map<string, LocationGroup>();
for (const s of stops) {
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
let group = map.get(key);
if (!group) {
group = {
key,
city: s.city || "Unknown",
state: s.state || "",
venueCount: 0,
total: 0,
active: 0,
draft: 0,
inactive: 0,
upcoming: 0,
nextDate: null,
firstDate: null,
lastDate: null,
sampleVenue: s.location,
stops: [],
};
map.set(key, group);
}
group.stops.push(s);
group.total += 1;
const status = getStopStatus(s);
if (status === "active") group.active += 1;
else if (status === "draft") group.draft += 1;
else group.inactive += 1;
if (s.date) {
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
}
}
// post-process: count distinct venues, upcoming stops
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
for (const g of map.values()) {
const venues = new Set(
g.stops.flatMap((s) => {
const trimmed = (s.location || "").trim().toLowerCase();
return trimmed ? [trimmed] : [];
})
);
g.venueCount = Math.max(1, venues.size);
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
const future: string[] = [];
for (const s of g.stops) {
if (s.date >= todayStr && getStopStatus(s) !== "inactive") {
future.push(s.date);
}
}
future.sort();
g.nextDate = future[0] ?? null;
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}
return Array.from(map.values()).sort((a, b) => {
// active locations with upcoming stops first, sorted by next date; then by city
if (a.nextDate && !b.nextDate) return -1;
if (!a.nextDate && b.nextDate) return 1;
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
return a.city.localeCompare(b.city);
});
}
function formatDateLabel(ymd: string | null): string {
if (!ymd) return "—";
const d = new Date(ymd + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatRange(a: string | null, b: string | null): string {
if (!a) return "—";
if (!b || a === b) return formatDateLabel(a);
return `${formatDateLabel(a)}${formatDateLabel(b)}`;
}
export default function StopsLocations({ stops }: Props) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [showOnlyActive, setShowOnlyActive] = useState(false);
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return groups.filter((g) => {
if (q) {
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (showOnlyActive && g.active === 0) return false;
return true;
});
}, [groups, query, showOnlyActive]);
// Aggregated stats
const stats = useMemo(() => {
const totalLocations = groups.length;
const totalStops = stops.length;
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
}, [groups, stops]);
if (stops.length === 0) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
No locations yet
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Create your first stop to see pickup locations here.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Stats strip — almanac style */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{[
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
].map((s) => (
<div
key={s.numeral}
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
>
<div className="flex items-start justify-between">
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{s.numeral} · {s.label}
</p>
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
{s.value}
</p>
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{s.hint}
</p>
</div>
<div
aria-hidden
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
>
{s.numeral}
</div>
</div>
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
</div>
))}
</section>
{/* Filter bar */}
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
<div className="relative flex-1 min-w-[200px] max-w-md">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input aria-label="Search City, State, Venue..."
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search city, state, venue..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
/>
</div>
<div className="flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={showOnlyActive}
onClick={() => setShowOnlyActive((v) => !v)}
aria-label="Show only active locations"
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
Active only
</span>
</div>
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{filtered.length} of {groups.length} locations
</span>
</section>
{/* Location grid */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
</div>
) : (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((g, idx) => {
const isOpen = expandedKey === g.key;
return (
<article
key={g.key}
className={`
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
`}
>
{/* Decorative route number */}
<div
aria-hidden
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
>
{String(idx + 1).padStart(2, "0")}
</div>
<button
type="button"
onClick={() => setExpandedKey(isOpen ? null : g.key)}
className="block w-full text-left p-5"
aria-expanded={isOpen}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Pin marker */}
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
</svg>
</span>
<div className="min-w-0">
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
{g.city}
{g.state && (
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
{g.state}
</span>
)}
</h3>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
{g.sampleVenue}
</p>
</div>
</div>
</div>
<div
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</div>
</div>
{/* Stat grid */}
<div className="mt-5 grid grid-cols-3 gap-2">
<Stat label="Stops" value={g.total} />
<Stat label="Active" value={g.active} accent={g.active > 0} />
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
</div>
{/* Date range + drafts */}
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
<div className="text-[var(--admin-text-muted)]">
{g.firstDate ? (
<>
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
{" · "}
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
</>
) : (
"No dates"
)}
</div>
{g.draft > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
{g.draft} draft
</span>
)}
</div>
{/* Next up ribbon */}
{g.nextDate && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
Next
</span>
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
{formatDateLabel(g.nextDate)}
</span>
</div>
)}
</button>
{/* Expanded stop list */}
{isOpen && (
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
All stops · {g.stops.length}
</h4>
<Link
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
>
+ Add at this city
</Link>
</div>
<ul className="space-y-1">
{g.stops.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{formatTime12(s.time)}
</span>
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
{s.location}
</span>
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</Link>
</li>
);
})}
</ul>
</div>
)}
</article>
);
})}
</section>
)}
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div
className={`rounded-lg border px-2.5 py-2 ${
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
}`}
>
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
<p
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
export type StopStatus = "draft" | "active" | "inactive";
export type Stop = {
id: string;
city: string;
state: string;
date: string; // YYYY-MM-DD
time: string; // HH:MM
location: string;
active: boolean;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
cutoff_date?: string | null;
brands: { name: string } | { name: string }[];
};
export type StopView = "calendar" | "locations" | "list";
export type LocationGroup = {
key: string;
city: string;
state: string;
venueCount: number; // distinct location strings at this city
total: number;
active: number;
draft: number;
inactive: number;
upcoming: number;
nextDate: string | null; // YYYY-MM-DD
firstDate: string | null;
lastDate: string | null;
sampleVenue: string;
stops: Stop[];
};
export function getStopStatus(s: Stop): StopStatus {
if (s.status === "draft") return "draft";
return s.active ? "active" : "inactive";
}
export function getStopDate(s: Stop): Date | null {
if (!s.date) return null;
const d = new Date(s.date + "T00:00:00");
return isNaN(d.getTime()) ? null : d;
}
export function formatTime12(time: string): string {
if (!time) return "—";
const [h, m] = time.split(":");
const hour = parseInt(h, 10);
if (isNaN(hour)) return time;
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
}
export function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}