diff --git a/package.json b/package.json index c721700..f91f6b6 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "pg": "^8.20.0", "playwright": "^1.59.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5.9.3" }, "overrides": "{}" } diff --git a/src/app/admin/stops/page.tsx b/src/app/admin/stops/page.tsx index fc969d2..1d9820c 100644 --- a/src/app/admin/stops/page.tsx +++ b/src/app/admin/stops/page.tsx @@ -1,16 +1,10 @@ -import StopsViewClient from "@/components/admin/StopsViewClient"; +import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient"; import StopsHeaderActions from "@/components/admin/StopsHeaderActions"; -import LocationsTab from "@/components/admin/LocationsTab"; -import StopsLocationsTabs from "@/components/admin/StopsLocationsTabs"; -import StatsStrip from "@/components/admin/StatsStrip"; import { supabase } from "@/lib/supabase"; import { getAdminUser } from "@/lib/admin-permissions"; -import { getActiveBrandId } from "@/lib/brand-scope"; -import { adminListLocations } from "@/actions/locations"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import { PageHeader } from "@/components/admin/design-system"; import { redirect } from "next/navigation"; -import { TabSwitcher } from "@/components/admin/TabSwitcher"; const StopIcon = () => ( @@ -19,50 +13,45 @@ const StopIcon = () => ( ); -type TabValue = "stops" | "locations"; - -function isTab(v: string | undefined): v is TabValue { - return v === "stops" || v === "locations"; -} - -type PageProps = { - searchParams: Promise<{ tab?: string }>; -}; - -export default async function AdminStopsPage({ searchParams }: PageProps) { +export default async function AdminStopsPage() { const adminUser = await getAdminUser(); if (!adminUser) return ; - if (!adminUser.can_manage_stops) redirect("/admin/pickup"); - const params = await searchParams; - const tab: TabValue = isTab(params.tab) ? params.tab : "stops"; + if (!adminUser.can_manage_stops) { + redirect("/admin/pickup"); + } - // Always fetch stops + locations; the page is fast and a server component can - // hand both to the client. The Locations tab only needs the array — it does - // its own filtering in JS. Stops tab uses the existing client table. - const activeBrandId = await getActiveBrandId(adminUser); - - const stopsQuery = supabase + let query = supabase .from("stops") .select(` - id, city, state, date, time, location, active, deleted_at, brand_id, - address, zip, cutoff_time, status, - brands ( name ) + id, + city, + state, + date, + time, + location, + active, + deleted_at, + brand_id, + address, + zip, + cutoff_time, + status, + brands ( + name + ) `) .is("deleted_at", null) .order("date", { ascending: true }); - if (activeBrandId) { - stopsQuery.eq("brand_id", activeBrandId); + if (adminUser?.brand_id) { + query = query.eq("brand_id", adminUser.brand_id); } - const [{ data: stops, error: stopsError }, locations] = await Promise.all([ - stopsQuery, - adminListLocations(activeBrandId ?? ""), - ]); + const { data: stops, error } = await query; - if (stopsError) { + if (error) { return (
@@ -76,7 +65,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { Error loading stops
-              {stopsError.message}
+              {error.message}
             
@@ -84,91 +73,25 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { ); } - // Derive stats for the strip. The stops/locations arrays are already on hand. - const safeStops = stops ?? []; - const cityCount = new Set( - safeStops.map((s) => `${(s.city ?? "").toLowerCase()}|${(s.state ?? "").toLowerCase()}`) - ).size; - const stateCount = new Set(safeStops.map((s) => (s.state ?? "").toUpperCase())).size; - const draftCount = safeStops.filter((s) => s.status === "draft").length; - const activeCount = safeStops.filter((s) => s.active && s.status !== "draft").length; - const inactiveCount = safeStops.filter((s) => !s.active && s.status !== "draft").length; - - const locationCityCount = new Set( - locations.map((l) => (l.city ?? "").toLowerCase()) - ).size; - const locationWithStops = locations.filter((l) => l.stop_count > 0).length; - - const subtitle = - tab === "locations" - ? "Reusable venues. Each stop links to one venue, so editing here updates every stop using it." - : adminUser?.brand_id - ? "Manage stops, venues, and the routes that connect them." - : "Manage stops, venues, and the routes that connect them."; - - const stopsStats = [ - { value: safeStops.length, label: "stops" }, - { value: cityCount, label: "cities" }, - { value: stateCount, label: "states" }, - { value: activeCount, label: "active" }, - { value: draftCount, label: "draft", emphasis: draftCount > 0 }, - { value: inactiveCount, label: "inactive" }, - ]; - - const locationsStats = [ - { value: locations.length, label: "venues" }, - { value: locationCityCount, label: "cities" }, - { value: locationWithStops, label: "with stops" }, - ]; - return (
- {/* Header */} -
-
- } - title="Stops & Routes" - subtitle={subtitle} - actions={ - - } - /> -
- -
-
+
+ } + title="Stops & Routes" + subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."} + actions={} + />
- {/* Card: tabs + content */} -
-
-
- - - {tab === "stops" ? ( - - ) : ( - - )} - -
-
+ {/* Content */} +
+
); -} +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 96e51b7..137af18 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,15 +1,11 @@ @import "tailwindcss"; @theme { - /* ─── Font families (Tailwind v4 @theme) ────────────────────────── - * Used by the editorial product pages: - * font-display → Fraunces (variable serif, set per-page via next/font) - * font-mono → JetBrains Mono (variable, set per-page via next/font) - * Pages that don't load these fonts fall back to the system serif/mono - * stacks, so the rest of the app is unaffected. - */ - --font-display: var(--font-display, ui-serif, Georgia, "Times New Roman", serif); - --font-mono: var(--font-mono, ui-monospace, "SF Mono", "JetBrains Mono", monospace); + /* Custom typefaces — Field Almanac */ + --font-display: var(--font-fraunces, "Georgia"), Georgia, serif; + --font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace; + /* Apple-inspired dark palette — sophisticated depth */ --color-surface-50: #f5f5f7; --color-surface-100: #e8e8ed; @@ -94,7 +90,7 @@ html { } body { - font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-family: var(--font-sans); background: #ffffff; background-attachment: fixed; color: #1a1a1a; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4b65600..8f40af2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,32 @@ import type { Metadata, Viewport } from "next"; -import { Fraunces, Geist, JetBrains_Mono } from "next/font/google"; +import { Fraunces, Manrope, Fragment_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "@/components/Providers"; import ToastNotificationContainer from "@/components/notifications/ToastNotification"; import CookieConsentBanner from "@/components/legal/CookieConsentBanner"; +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; + const fraunces = Fraunces({ subsets: ["latin"], - weight: ["400", "500", "600", "700", "800"], - style: ["normal", "italic"], + display: "swap", variable: "--font-fraunces", - display: "swap", + axes: ["SOFT", "WONK", "opsz"], }); -const geist = Geist({ +const manrope = Manrope({ subsets: ["latin"], - weight: ["300", "400", "500", "600", "700"], - variable: "--font-geist", display: "swap", + variable: "--font-manrope", }); -const jetbrainsMono = JetBrains_Mono({ +const fragmentMono = Fragment_Mono({ subsets: ["latin"], - weight: ["400", "500", "600", "700"], - variable: "--font-jetbrains-mono", display: "swap", + variable: "--font-fragment-mono", + weight: "400", }); -const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; - export const viewport: Viewport = { width: "device-width", initialScale: 1, @@ -73,12 +71,8 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - + + {children} diff --git a/src/components/admin/stops/StopsCalendar.tsx b/src/components/admin/stops/StopsCalendar.tsx new file mode 100644 index 0000000..536eac6 --- /dev/null +++ b/src/components/admin/stops/StopsCalendar.tsx @@ -0,0 +1,432 @@ +"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 = { + 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(ymd(today)); + + const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]); + + const stopsByDate = useMemo(() => { + const map = new Map(); + 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(); + 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 ( +
+ {/* Calendar card */} +
+ {/* Decorative paper texture */} +
+
\")", + }} + /> + + {/* Header */} +
+
+

+ {monthLabel.split(" ")[0]} + + {" "} + {monthLabel.split(" ")[1]} + +

+ + {monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled + +
+
+ + + +
+
+ + {/* Weekday header */} +
+ {WEEKDAY_LABELS.map((d, i) => ( +
+ {d} +
+ ))} +
+ + {/* Grid */} +
+ {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 ( + + ); + })} +
+ + {/* Legend */} +
+ {(["active", "draft", "inactive"] as StopStatus[]).map((s) => { + const st = STATUS_STYLES[s]; + return ( +
+ + {s} +
+ ); + })} +
+ + + {upcomingWeek} + {" "} + upcoming in 7d + +
+
+
+ + {/* Day detail panel */} + +
+ ); +} diff --git a/src/components/admin/stops/StopsDashboardClient.tsx b/src/components/admin/stops/StopsDashboardClient.tsx new file mode 100644 index 0000000..ce29829 --- /dev/null +++ b/src/components/admin/stops/StopsDashboardClient.tsx @@ -0,0 +1,184 @@ +"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[]; +}; + +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 }: Props) { + const [view, setView] = useState("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 ( +
+ {/* Top stats strip — almanac style */} +
+
\")", + }} + /> +
+
+

+ Harvest Dispatch · Almanac +

+

+ {stats.total === 0 + ? "No stops scheduled" + : `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`} + + {stats.cities} cit{stats.cities === 1 ? "y" : "ies"} + +

+

+ {stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues +

+
+ + {/* Tab nav — binder-style with notched corners */} +
+ {TABS.map((t) => { + const isActive = t.value === view; + return ( + + ); + })} +
+
+ + {/* Stat cells */} +
+ + 0} + /> + + +
+
+ + {/* Active view */} +
+ {view === "calendar" && } + {view === "locations" && } + {view === "list" && } +
+
+ ); +} + +function AlmanacStat({ + numeral, + label, + value, + accent, +}: { + numeral: string; + label: string; + value: number; + accent?: boolean; +}) { + return ( +
+ + {numeral} + +
+

+ {label} +

+

+ {value} +

+
+
+ ); +} diff --git a/src/components/admin/stops/StopsList.tsx b/src/components/admin/stops/StopsList.tsx new file mode 100644 index 0000000..aa72da7 --- /dev/null +++ b/src/components/admin/stops/StopsList.tsx @@ -0,0 +1,72 @@ +"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].sort((a, b) => (a.date || "").localeCompare(b.date || "")); + }, [stops]); + + if (sorted.length === 0) { + return ( +
+

No stops yet

+

+ Switch to a different tab to add stops. +

+
+ ); + } + + return ( +
    + {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 ( +
  • + + + {s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"} + + + {s.time ? s.time.slice(0, 5) : "—"} + + + + {s.city}, {s.state} + + {s.location} + + {brand && ( + + {brand} + + )} + + {status} + +
  • + ); + })} +
+ ); +} diff --git a/src/components/admin/stops/StopsLocations.tsx b/src/components/admin/stops/StopsLocations.tsx new file mode 100644 index 0000000..9ca88f2 --- /dev/null +++ b/src/components/admin/stops/StopsLocations.tsx @@ -0,0 +1,403 @@ +"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(); + 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.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean)); + g.venueCount = Math.max(1, venues.size); + g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length; + const future = g.stops + .filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive") + .map((s) => s.date) + .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(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 ( +
+
+ + + + +
+

+ No locations yet +

+

+ Create your first stop to see pickup locations here. +

+
+ ); + } + + return ( +
+ {/* Stats strip — almanac style */} +
+ {[ + { 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) => ( +
+
+
+

+ {s.numeral} · {s.label} +

+

+ {s.value} +

+

+ {s.hint} +

+
+
+ {s.numeral} +
+
+
+
+ ))} +
+ + {/* Filter bar */} +
+
+ + + + 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" + /> +
+ + + {filtered.length} of {groups.length} locations + +
+ + {/* Location grid */} + {filtered.length === 0 ? ( +
+

No locations match.

+

Try a different search.

+
+ ) : ( +
+ {filtered.map((g, idx) => { + const isOpen = expandedKey === g.key; + return ( +
+ {/* Decorative route number */} +
+ {String(idx + 1).padStart(2, "0")} +
+ + + + {/* Expanded stop list */} + {isOpen && ( +
+
+

+ All stops · {g.stops.length} +

+ + + Add at this city + +
+
    + {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 ( +
  • + + + + {s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"} + + + {formatTime12(s.time)} + + + {s.location} + + + {status} + + +
  • + ); + })} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/src/components/admin/stops/types.ts b/src/components/admin/stops/types.ts new file mode 100644 index 0000000..3816a97 --- /dev/null +++ b/src/components/admin/stops/types.ts @@ -0,0 +1,70 @@ +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; + deleted_at?: string | null; + brand_id: string; + status?: string; + address?: string | null; + zip?: string | null; + cutoff_time?: 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() + ); +}