Files
route-commerce/src/components/admin/stops/StopsLocations.tsx
T

412 lines
19 KiB
TypeScript

"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>
<label className="flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={showOnlyActive}
onClick={() => setShowOnlyActive((v) => !v)}
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>
</label>
<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>
);
}