Files
route-commerce/src/components/admin/StopTableClient.tsx
T
Tyler 83ad6536a3
Deploy to route.crispygoat.com / deploy (push) Successful in 5m46s
feat: production-readiness pass
- Migrate login page to atelier design system (editorial modal style)
- Polish root error.tsx, not-found.tsx, loading.tsx with atelier design
- Add JSON-LD structured data: SoftwareApplication + LocalBusiness
- Fix next.config.ts outputFileTracingRoot absolute path warning
- Add force-dynamic to protected-example (uses cookies)
- Add proper type for stops page (replace : any)
- Fix unescaped quotes in StopTableClient
- Fix no-explicit-any in db-schema route
- Clean up console.logs in admin-permissions
- Fix inline any in admin/stops page
- Update OG image references to existing og-default.svg
2026-06-16 23:11:35 -06:00

1097 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useTransition, useEffect, useMemo, useCallback, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop, deleteStop } from "@/actions/stops";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
AdminViewModeTabs,
useToast,
Skeleton,
AdminPagination,
} from "@/components/admin/design-system";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import EditStopModal from "@/components/admin/EditStopModal";
type Stop = {
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 }[] | null;
};
type Props = {
stops: Stop[];
hideInternalFilterBar?: boolean;
};
type ViewMode = "table" | "cards";
type SortField = "date" | "city" | "location" | "status";
type SortDirection = "asc" | "desc";
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
all: "all",
active: "active",
inactive: "inactive",
draft: "draft",
};
// Icons
const Icons = {
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
),
pin: (className: string) => (
<svg className={className} 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>
),
upload: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
),
};
// Page header icon
const StopIconHeader = () => (
<svg className="h-6 w-6" 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>
);
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
const [bulkPublishing, setBulkPublishing] = useState(false);
const [showImport, setShowImport] = useState(false);
const [editingStop, setEditingStop] = useState<Stop | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const PAGE_SIZE = 25;
useEffect(() => {
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 220);
return () => clearTimeout(timer);
}, [page, statusFilter, search, sortField, sortDirection]);
// Compute stats
const stats = useMemo(() => {
const now = new Date();
const today = now.toISOString().split("T")[0];
const activeStops = stops.filter((s) => s.active && s.status !== "draft");
const upcomingStops = stops.filter((s) => s.date >= today && s.status !== "draft");
const draftStops = stops.filter((s) => s.status === "draft");
return {
total: stops.length,
active: activeStops.length,
upcoming: upcomingStops.length,
draft: draftStops.length,
};
}, [stops]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q);
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;
});
}, [stops, search, statusFilter]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "date":
comparison = a.date.localeCompare(b.date);
break;
case "city":
comparison = a.city.localeCompare(b.city);
break;
case "location":
comparison = a.location.localeCompare(b.location);
break;
case "status":
const statusOrder = { draft: 0, inactive: 1, active: 2 };
const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive";
const bStatus = b.status === "draft" ? "draft" : b.active ? "active" : "inactive";
comparison = statusOrder[aStatus] - statusOrder[bStatus];
break;
}
return sortDirection === "asc" ? comparison : -comparison;
});
}, [filtered, sortField, sortDirection]);
const statusCounts = 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 = useMemo(
() => [
{ value: "all", label: "All", count: statusCounts.all },
{ value: "active", label: "Active", count: statusCounts.active },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
{ value: "draft", label: "Draft", count: statusCounts.draft },
],
[statusCounts]
);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const paginatedStops = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
setPage(0);
}, [sortField]);
const toggleSelectAll = () => {
if (selectedStops.size === paginatedStops.length) {
setSelectedStops(new Set());
} else {
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
}
};
const toggleStopSelection = (stopId: string) => {
setSelectedStops((prev) => {
const next = new Set(prev);
if (next.has(stopId)) {
next.delete(stopId);
} else {
next.add(stopId);
}
return next;
});
};
async function handleBulkPublish() {
if (selectedStops.size === 0) return;
setBulkPublishing(true);
let successCount = 0;
let failCount = 0;
for (const stopId of selectedStops) {
const stop = stops.find((s) => s.id === stopId);
if (stop && stop.status === "draft") {
const result = await publishStop(stopId, stop.brand_id);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
}
setBulkPublishing(false);
setSelectedStops(new Set());
if (failCount === 0) {
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
} else {
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
}
startTransition(() => router.refresh());
}
function handleDeleted() {
setDeleteError(null);
startTransition(() => router.refresh());
}
function refresh() {
startTransition(() => router.refresh());
}
// Sort icon component
const SortIcon = ({ field }: { field: SortField }) => (
<span className={`inline-flex ml-1.5 ${sortField === field ? "opacity-100" : "opacity-30"}`}>
{sortField === field && sortDirection === "desc" ? (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
) : (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
)}
</span>
);
return (
<>
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
<StopIconHeader />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
Stops & Routes
</h1>
<p className="text-xs text-[var(--admin-text-muted)]">
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
{search || statusFilter !== "all" ? " matching filters" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowImport(true)}
icon={Icons.upload("h-4 w-4")}
>
Upload
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAddModal(true)}
icon={Icons.plus("h-4 w-4")}
>
Add Stop
</AdminButton>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : stats.total}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : stats.active}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : stats.upcoming}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : stats.draft}
</p>
</div>
</div>
{/* Filters */}
{!hideInternalFilterBar && (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
tabs={tabs}
size="md"
/>
<AdminSearchInput
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
placeholder="Search stops..."
containerClassName="flex-1"
/>
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={(value) => setViewMode(value as ViewMode)}
/>
</div>
)}
{/* Bulk actions bar */}
{selectedStops.size > 0 && (
<div className="mb-4 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
</span>
<button
onClick={() => setSelectedStops(new Set())}
className="text-xs text-stone-500 hover:text-stone-700"
>
Clear
</button>
</div>
<AdminButton
variant="primary"
size="sm"
onClick={handleBulkPublish}
isLoading={bulkPublishing}
>
Publish Selected
</AdminButton>
</div>
)}
{/* Delete error */}
{deleteError && (
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Content */}
{viewMode === "table" ? (
<TableView
stops={paginatedStops}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={setEditingStop}
onDelete={(id) => {
const stop = stops.find((s) => s.id === id);
if (stop) {
setEditingStop(stop);
}
}}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
selectedStops={selectedStops}
onToggleSelectAll={toggleSelectAll}
onToggleSelect={toggleStopSelection}
isLoading={isLoading}
search={search}
statusFilter={statusFilter}
/>
) : (
<CardView
stops={paginatedStops}
onEdit={setEditingStop}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
isLoading={isLoading}
search={search}
statusFilter={statusFilter}
/>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
<span className="text-xs text-[var(--admin-text-muted)]">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length}
</span>
<AdminPagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
{showImport && (
<ScheduleImportModal
brandId={stops[0]?.brand_id ?? ""}
onClose={() => setShowImport(false)}
onComplete={refresh}
/>
)}
<EditStopModal
isOpen={showAddModal || editingStop !== null}
onClose={() => {
setShowAddModal(false);
setEditingStop(null);
}}
brandId={stops[0]?.brand_id ?? ""}
stop={editingStop}
onSuccess={refresh}
/>
</>
);
}
// ── Table View ─────────────────────────────────────────────────────────────────
function TableView({
stops,
sortField,
sortDirection,
onSort,
onEdit,
onDelete,
onDeleted,
onDeleteError,
selectedStops,
onToggleSelectAll,
onToggleSelect,
isLoading,
search,
statusFilter,
}: {
stops: Stop[];
sortField: SortField;
sortDirection: SortDirection;
onSort: (field: SortField) => void;
onEdit: (stop: Stop) => void;
onDelete: (id: string) => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
selectedStops: Set<string>;
onToggleSelectAll: () => void;
onToggleSelect: (id: string) => void;
isLoading: boolean;
search: string;
statusFilter: string;
}) {
return (
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr>
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={selectedStops.size === stops.length && stops.length > 0}
onChange={onToggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label="Select all"
/>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("date")}>
<span className="inline-flex items-center">
When
<SortIcon field="date" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("city")}>
<span className="inline-flex items-center">
Where
<SortIcon field="city" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("location")}>
<span className="inline-flex items-center">
Venue
<SortIcon field="location" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs cursor-pointer hover:text-stone-600 transition-colors" onClick={() => onSort("status")}>
<span className="inline-flex items-center">
Status
<SortIcon field="status" sortField={sortField} sortDirection={sortDirection} />
</span>
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
</tr>
))
) : stops.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-16 text-center">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.pin("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">
{search || statusFilter !== "all"
? "No stops match your filters"
: "No stops yet"}
</p>
<p className="text-xs text-stone-400 mt-1">
{search || statusFilter !== "all"
? "Try a different search or clear the filters"
: "Add your first stop to get started"}
</p>
</td>
</tr>
) : (
stops.map((stop) => (
<StopRow
key={stop.id}
stop={stop}
onEdit={() => onEdit(stop)}
onDelete={() => onDelete(stop.id)}
onDeleted={onDeleted}
onDeleteError={onDeleteError}
isSelected={selectedStops.has(stop.id)}
onToggleSelect={() => onToggleSelect(stop.id)}
/>
))
)}
</tbody>
</table>
</div>
);
}
// ── Card View ──────────────────────────────────────────────────────────────────
function CardView({
stops,
onEdit,
onDeleted,
onDeleteError,
isLoading,
search,
statusFilter,
}: {
stops: Stop[];
onEdit: (stop: Stop) => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
isLoading: boolean;
search: string;
statusFilter: string;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{stops.length === 0 ? (
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.pin("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">
{search || statusFilter !== "all"
? "No stops match your filters"
: "No stops yet"}
</p>
<p className="text-xs text-stone-400 mt-1">
{search || statusFilter !== "all"
? "Try a different search or clear the filters"
: "Add your first stop to get started"}
</p>
</div>
) : (
stops.map((stop) => (
<StopCard
key={stop.id}
stop={stop}
onEdit={() => onEdit(stop)}
onDeleted={onDeleted}
onDeleteError={onDeleteError}
/>
))
)}
</div>
);
}
// ── Shared helpers ─────────────────────────────────────────────────────────────
function SortIcon({ field, sortField, sortDirection }: { field: SortField; sortField: SortField; sortDirection: SortDirection }) {
return (
<span className={`inline-flex ml-1.5 ${sortField === field ? "opacity-100" : "opacity-30"}`}>
{sortField === field && sortDirection === "desc" ? (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
) : (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
)}
</span>
);
}
function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false };
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
const month = dt.toLocaleDateString("en-US", { month: "short" });
return {
date: `${month} ${d}`,
weekday,
isToday: iso === todayStr,
isPast: iso < todayStr
};
}
function formatTime(t: string): string {
if (!t) return "";
const [hStr, mStr] = t.split(":");
const h = parseInt(hStr, 10);
if (Number.isNaN(h)) return t;
const period = h >= 12 ? "PM" : "AM";
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${hh}:${mStr} ${period}`;
}
// ── Row Component ───────────────────────────────────────────────────────────────
function StopRow({
stop,
onEdit,
onDelete,
onDeleted,
onDeleteError,
isSelected,
onToggleSelect,
}: {
stop: Stop;
onEdit: () => void;
onDelete: () => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
isSelected: boolean;
onToggleSelect: () => void;
}) {
const { success: showSuccess, error: showError } = useToast();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const { date, weekday, isToday, isPast } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handlePublish() {
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", "The stop is now visible on the storefront");
onDeleted();
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
}
async function handleDelete() {
setDeletingId(stop.id);
const result = await deleteStop(stop.id, stop.brand_id);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
showSuccess("Stop deleted", "The stop has been removed");
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<tr
onClick={onEdit}
className={`group cursor-pointer transition-all duration-150 ${
isSelected
? "bg-emerald-50/60"
: "hover:bg-stone-50/80"
} ${isPast && stop.status !== "draft" ? "opacity-60" : ""}`}
>
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect();
}}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label={`Select stop in ${stop.city}`}
/>
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<div className="flex items-center gap-1.5">
{isToday && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-sky-700">
Today
</span>
)}
<span className={`font-semibold tabular-nums ${isPast ? "text-stone-400" : "text-stone-800"}`}>
{date}
</span>
</div>
<span className="text-[11px] text-stone-500 tabular-nums">
{weekday} {time && `· ${time}`}
</span>
</div>
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className={`font-semibold ${isPast ? "text-stone-400" : "text-stone-800"}`}>{stop.city}</span>
<span className="text-[11px] text-stone-500 tabular-nums">{stop.state}</span>
</div>
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className="text-stone-600">{stop.location}</span>
{stop.address && (
<span className="text-[11px] text-stone-400 truncate max-w-[260px]">
{stop.address}
</span>
)}
</div>
</td>
<td className="px-4 py-3.5">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] 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-500 border border-stone-200"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
stop.status === "draft"
? "bg-amber-500"
: stop.active
? "bg-emerald-500"
: "bg-stone-400"
}`}
/>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="relative inline-flex items-center justify-end gap-1">
<AdminButton
variant="ghost"
size="sm"
onClick={onEdit}
>
Edit
</AdminButton>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpenMenu(openMenu === stop.id ? null : stop.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label="Stop actions"
>
<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 && (
<>
<div
className="fixed inset-0 z-10"
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(null);
}}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button
onClick={(e) => {
e.stopPropagation();
handlePublish();
setOpenMenu(null);
}}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<svg className="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{publishingId === stop.id ? "Publishing…" : "Publish"}
</button>
)}
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
>
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Duplicate
</Link>
<button
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(stop.id);
}}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2"
>
<svg className="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<div
className="fixed inset-0 z-30"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-stone-800">
Delete &ldquo;{stop.city}, {stop.state}&rdquo;?
</p>
<p className="mt-1.5 text-xs text-stone-500">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="mt-4 flex gap-2 justify-end">
<AdminButton
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
>
Cancel
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
disabled={deletingId === stop.id}
isLoading={deletingId === stop.id}
>
{deletingId === stop.id ? "Deleting…" : "Delete"}
</AdminButton>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
// ── Card Component ─────────────────────────────────────────────────────────────
function StopCard({
stop,
onEdit,
onDeleted,
onDeleteError,
}: {
stop: Stop;
onEdit: () => void;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const { success: showSuccess, error: showError } = useToast();
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const { date, weekday, isToday, isPast } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handleDelete() {
setDeleting(true);
const result = await deleteStop(stop.id, stop.brand_id);
setDeleting(false);
if (result.success) {
showSuccess("Stop deleted", "The stop has been removed");
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<div
className={`group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden ${
isPast && stop.status !== "draft" ? "opacity-70" : ""
}`}
>
{/* Header with date */}
<div className="bg-gradient-to-br from-stone-50 to-stone-100/50 px-4 pt-4 pb-3 border-b border-stone-100">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-1.5 mb-1">
{isToday && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-sky-700">
Today
</span>
)}
</div>
<p className="text-lg font-bold text-stone-800 tabular-nums">{date}</p>
<p className="text-xs text-stone-500 tabular-nums">{weekday} {time && `· ${time}`}</p>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-[10px] 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-500 border border-stone-200"
}`}
>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Content */}
<div className="p-4">
<button onClick={onEdit} className="block w-full text-left">
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{stop.city}, {stop.state}
</h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2">
{stop.location}
</p>
{stop.address && (
<p className="text-xs text-stone-400 mt-1 line-clamp-1">
{stop.address}
</p>
)}
</button>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-stone-100">
<AdminButton
variant="secondary"
size="sm"
onClick={onEdit}
className="flex-1"
>
Edit
</AdminButton>
<button
onClick={() => setConfirmDelete(true)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{/* Delete confirm */}
{confirmDelete && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/95 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4 border border-[var(--admin-border)]">
<p className="text-sm font-semibold text-stone-900">
Delete &ldquo;{stop.city}, {stop.state}&rdquo;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={() => setConfirmDelete(false)}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
>
{deleting ? "..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}