Merge branch 'main' of github.com:dzinesco/route-commerce

# Conflicts:
#	src/app/admin/stops/page.tsx
#	src/app/globals.css
#	src/app/layout.tsx
This commit is contained in:
2026-06-04 18:43:58 +00:00
9 changed files with 1222 additions and 148 deletions
+42 -119
View File
@@ -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 = () => (
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -19,50 +13,45 @@ const StopIcon = () => (
</svg>
);
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 <AdminAccessDenied />;
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 (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
@@ -76,7 +65,7 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
Error loading stops
</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
{stopsError.message}
{error.message}
</pre>
</div>
</div>
@@ -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 (
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<div className="px-4 sm:px-6 md:px-8 pt-6 sm:pt-8 pb-4 sm:pb-5">
<div className="max-w-6xl mx-auto">
<PageHeader
breadcrumb={[
{ label: "Admin", href: "/admin" },
{ label: "Stops & Routes" }
]}
icon={<StopIcon />}
title="Stops & Routes"
subtitle={subtitle}
actions={
<StopsHeaderActions
brandId={activeBrandId ?? adminUser?.brand_id ?? ""}
/>
}
/>
<div className="mt-3 ml-[52px]">
<StatsStrip
stats={tab === "locations" ? locationsStats : stopsStats}
/>
</div>
</div>
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<PageHeader
breadcrumb={[
{ label: "Admin", href: "/admin" },
{ label: "Stops & Routes" }
]}
icon={<StopIcon />}
title="Stops & Routes"
subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />}
/>
</div>
{/* Card: tabs + content */}
<div className="px-4 sm:px-6 md:px-8 pb-8">
<div className="max-w-6xl mx-auto">
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<StopsLocationsTabs
active={tab}
stopCount={safeStops.length}
locationCount={locations.length}
stopsSublabel={`${cityCount} cities`}
locationsSublabel={`${locationCityCount} cities`}
/>
<TabSwitcher tabKey={tab}>
{tab === "stops" ? (
<StopsViewClient stops={safeStops} />
) : (
<LocationsTab locations={locations} brandId={adminUser?.brand_id ?? ""} />
)}
</TabSwitcher>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<StopsDashboardClient stops={stops ?? []} />
</div>
</main>
);
}
}
+6 -10
View File
@@ -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;
+12 -18
View File
@@ -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 (
<html
lang="en"
suppressHydrationWarning
className={`${fraunces.variable} ${geist.variable} ${jetbrainsMono.variable}`}
>
<body>
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
<body className="font-sans">
<Providers>{children}</Providers>
<ToastNotificationContainer />
<CookieConsentBanner />