diff --git a/src/app/admin/stops/page.tsx b/src/app/admin/stops/page.tsx index 1d323a9..fc969d2 100644 --- a/src/app/admin/stops/page.tsx +++ b/src/app/admin/stops/page.tsx @@ -1,4 +1,5 @@ -import StopTableClient from "@/components/admin/StopTableClient"; +import StopsViewClient from "@/components/admin/StopsViewClient"; +import StopsHeaderActions from "@/components/admin/StopsHeaderActions"; import LocationsTab from "@/components/admin/LocationsTab"; import StopsLocationsTabs from "@/components/admin/StopsLocationsTabs"; import StatsStrip from "@/components/admin/StatsStrip"; @@ -133,6 +134,11 @@ export default async function AdminStopsPage({ searchParams }: PageProps) { icon={} title="Stops & Routes" subtitle={subtitle} + actions={ + + } />
{tab === "stops" ? ( - + ) : ( )} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 63f68bd..4b65600 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,32 @@ import type { Metadata, Viewport } from "next"; +import { Fraunces, Geist, JetBrains_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 fraunces = Fraunces({ + subsets: ["latin"], + weight: ["400", "500", "600", "700", "800"], + style: ["normal", "italic"], + variable: "--font-fraunces", + display: "swap", +}); + +const geist = Geist({ + subsets: ["latin"], + weight: ["300", "400", "500", "600", "700"], + variable: "--font-geist", + display: "swap", +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], + variable: "--font-jetbrains-mono", + display: "swap", +}); + const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; export const viewport: Viewport = { @@ -50,7 +73,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/components/admin/AddStopModal.tsx b/src/components/admin/AddStopModal.tsx index dfe7921..194d2b4 100644 --- a/src/components/admin/AddStopModal.tsx +++ b/src/components/admin/AddStopModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { createStop } from "@/actions/stops/create-stop"; import GlassModal from "@/components/admin/GlassModal"; @@ -21,12 +21,20 @@ type Props = { onSuccess?: (stopId: string) => void; }; +/* Pin icon for the modal header */ +const PinIcon = () => ( + +); + export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [city, setCity] = useState(""); - const [state, setState] = useState(""); + const [stateField, setStateField] = useState(""); const [location, setLocation] = useState(""); const [date, setDate] = useState(""); const [time, setTime] = useState(""); @@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, const [cutoffTime, setCutoffTime] = useState(""); const [status, setStatus] = useState<"draft" | "active">("draft"); + const cityRef = useRef(null); + useEffect(() => { if (isOpen) { // Reset form when modal opens with optional duplicate source setCity(duplicateFrom?.city ?? ""); - setState(duplicateFrom?.state ?? ""); + setStateField(duplicateFrom?.state ?? ""); setLocation(duplicateFrom?.location ?? ""); setDate(duplicateFrom?.date ?? ""); setTime(duplicateFrom?.time ?? ""); @@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, setCutoffTime(duplicateFrom?.cutoff_time ?? ""); setStatus("draft"); setError(null); + // Auto-focus city field for fast data entry + requestAnimationFrame(() => cityRef.current?.focus()); } /* eslint-enable react-hooks/set-state-in-effect */ }, [isOpen, duplicateFrom]); - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); - if (!city.trim() || !state.trim() || !location.trim() || !date) { - setError("City, state, location, and date are required."); - return; - } - - setLoading(true); - try { - const result = await createStop(brandId, { - city: city.trim(), - state: state.trim(), - location: location.trim(), - date, - time: time || "08:00", - address: address || undefined, - zip: zip || undefined, - cutoff_time: cutoffTime || undefined, - active: status === "active", - }); - - if (result.success) { - onSuccess?.(result.id); - onClose(); - } else { - setError(result.error ?? "Failed to create stop"); + if (!city.trim() || !stateField.trim() || !location.trim() || !date) { + setError("City, state, location, and date are required."); + return; } - } catch { - setError("Network error. Please try again."); - } finally { - setLoading(false); - } - }, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]); + + setLoading(true); + try { + const result = await createStop(brandId, { + city: city.trim(), + state: stateField.trim(), + location: location.trim(), + date, + time: time || "08:00", + address: address || undefined, + zip: zip || undefined, + cutoff_time: cutoffTime || undefined, + active: status === "active", + }); + + if (result.success) { + onSuccess?.(result.id); + onClose(); + } else { + setError(result.error ?? "Failed to create stop"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }, + [ + brandId, + city, + stateField, + location, + date, + time, + address, + zip, + cutoffTime, + status, + onSuccess, + onClose, + ] + ); const isDuplicate = Boolean(duplicateFrom); - const title = isDuplicate ? "Duplicate Stop" : "Add New Stop"; - const subtitle = isDuplicate && duplicateFrom + const title = isDuplicate ? "Duplicate Stop" : "Add Stop"; + const eyebrow = isDuplicate && duplicateFrom ? `From ${duplicateFrom.city}, ${duplicateFrom.state}` - : "Create a new tour stop for your route."; - - const inputStyle = { - background: 'rgba(0, 0, 0, 0.02)', - border: '1px solid rgba(0, 0, 0, 0.06)', - outline: 'none', - }; - - const handleFocus = (e: React.FocusEvent) => { - e.target.style.background = 'rgba(16, 185, 129, 0.04)'; - e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)'; - e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)'; - }; - - const handleBlur = (e: React.FocusEvent) => { - e.target.style.background = 'rgba(0, 0, 0, 0.02)'; - e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)'; - e.target.style.boxShadow = 'none'; - }; + : "New stop on the route"; + const submitLabel = loading + ? isDuplicate ? "Duplicating…" : "Creating…" + : isDuplicate + ? "Duplicate Stop" + : status === "active" + ? "Create & Publish" + : "Save as Draft"; if (!isOpen) return null; return ( - -
+ + {error && ( -
- {error} +
+ + + + + {error}
)} -
-
- + {/* Row 1 — Where: City + State */} +
+
+ setCity(e.target.value)} placeholder="Denver" - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + className="ha-field-input" required + autoComplete="address-level2" />
-
- +
+ setState(e.target.value.toUpperCase())} + value={stateField} + onChange={(e) => setStateField(e.target.value.toUpperCase())} placeholder="CO" maxLength={2} - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + className="ha-field-input ha-field-input-mono text-center" + style={{ textTransform: "uppercase" }} required + autoComplete="address-level1" />
-
- + {/* Row 2 — Where at: Location (full) */} +
+ setLocation(e.target.value)} - placeholder="Whole Foods Market" - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + placeholder="Whole Foods Market — Highlands" + className="ha-field-input" required />
-
-
- + {/* Row 3 — When: Date + Time (with small clock icon) */} +
+
+ setDate(e.target.value)} - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + className="ha-field-input ha-field-input-mono" required />
-
- +
+ setTime(e.target.value)} - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + className="ha-field-input ha-field-input-mono" />
-
-
- + {/* Row 4 — Address + ZIP + Cutoff in 3 columns */} +
+
+ setAddress(e.target.value)} placeholder="123 Main St" - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + className="ha-field-input" + autoComplete="street-address" />
-
- +
+ setZip(e.target.value)} placeholder="80202" - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} + maxLength={10} + className="ha-field-input ha-field-input-mono" + autoComplete="postal-code" + /> +
+
+ + setCutoffTime(e.target.value)} + className="ha-field-input ha-field-input-mono" />
-
- - setCutoffTime(e.target.value)} - className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all" - style={inputStyle} - onFocus={handleFocus} - onBlur={handleBlur} - /> -

Orders close this time the day before pickup

-
- - {/* Status toggle */} -
- -
+ {/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */} +
+
+ + + Draft is hidden from customers + +
+
{/* Footer */} -
- - +
+ + Esc + to close + +
+ + +
@@ -338,4 +404,4 @@ export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicat ), [isOpen, close, brandId, duplicateFrom]); return { open, close, Modal }; -} \ No newline at end of file +} diff --git a/src/components/admin/GlassModal.tsx b/src/components/admin/GlassModal.tsx index e76ccff..c81b258 100644 --- a/src/components/admin/GlassModal.tsx +++ b/src/components/admin/GlassModal.tsx @@ -9,9 +9,22 @@ type Props = { onClose: () => void; children: React.ReactNode; maxWidth?: string; + /** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */ + compact?: boolean; + /** Optional eyebrow text rendered above the title in compact mode. */ + eyebrow?: string; }; -export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) { +export default function GlassModal({ + title, + titleIcon, + subtitle, + onClose, + children, + maxWidth = "max-w-lg", + compact = false, + eyebrow, +}: Props) { // Lock body scroll useEffect(() => { document.body.style.overflow = "hidden"; @@ -33,25 +46,36 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr return (
- {/* Modal card - solid white with shadow for high contrast */}
- {/* Subtle top border accent */} -
+ {/* Accent bar — only for non-compact (compact forms have their own internal accent) */} + {!compact && ( +
+ )} {/* Header */} -
- {titleIcon && ( -
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
)}
-

{eyebrow}

+ )} +

{title}

- {subtitle && ( -

{subtitle}

+ {subtitle && !compact && ( +

+ {subtitle} +

)}
- {/* Content - scrollable */} -
+ {/* Content */} +
{children}
diff --git a/src/components/admin/StopProductAssignment.tsx b/src/components/admin/StopProductAssignment.tsx index c38b026..639ba78 100644 --- a/src/components/admin/StopProductAssignment.tsx +++ b/src/components/admin/StopProductAssignment.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo, useEffect } from "react"; +import Image from "next/image"; import { logAuditEvent } from "@/actions/audit"; type Product = { @@ -8,12 +9,13 @@ type Product = { name: string; type: string; price: number; + image_url?: string | null; }; type AssignedProduct = { id: string; product_id: string; - products: { name: string; type: string; price: number } | null; + products: { name: string; type: string; price: number; image_url?: string | null } | null; }; type StopProductAssignmentProps = { @@ -23,6 +25,20 @@ type StopProductAssignmentProps = { callerUid: string; }; +type Filter = "all" | "available" | "assigned"; + +/* Helpers — monospace price + initial-letter avatar */ +const formatPrice = (n: number) => + `$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase(); + +const TYPE_LABEL: Record = { + pickup: "Pickup", + wholesale: "Wholesale", + subscription: "Sub", +}; + export default function StopProductAssignment({ stopId, allProducts, @@ -30,209 +46,386 @@ export default function StopProductAssignment({ callerUid, }: StopProductAssignmentProps) { const [products, setProducts] = useState(assignedProducts); - const [selected, setSelected] = useState(""); - const [assigning, setAssigning] = useState(false); - const [removing, setRemoving] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [pendingId, setPendingId] = useState(null); + const [removingId, setRemovingId] = useState(null); const [error, setError] = useState(null); - const assignedIds = new Set(products.map((p) => p.product_id)); - const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id)); + const assignedIds = useMemo( + () => new Set(products.map((p) => p.product_id)), + [products] + ); - async function handleAssign(e: React.FormEvent) { - e.preventDefault(); - if (!selected) return; + // Counters for the filter chips + const counts = useMemo(() => { + const assignedCount = products.length; + const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length; + return { + all: allProducts.length, + available: availableCount, + assigned: assignedCount, + }; + }, [allProducts, products, assignedIds]); - setAssigning(true); + // Apply search + filter + const visibleProducts = useMemo(() => { + const q = search.trim().toLowerCase(); + return allProducts.filter((p) => { + const isAssigned = assignedIds.has(p.id); + if (filter === "available" && isAssigned) return false; + if (filter === "assigned" && !isAssigned) return false; + if (!q) return true; + return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q); + }); + }, [allProducts, search, filter, assignedIds]); + + async function assign(productId: string) { + if (!productId || assignedIds.has(productId) || pendingId) return; + + setPendingId(productId); setError(null); - // First run diagnostic to understand the actual state - const diagRes = await fetch( - `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`, - { - method: "POST", - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_stop_id: stopId, - p_product_id: selected, - p_caller_uid: callerUid, - }), + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`, + { + method: "POST", + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + p_stop_id: stopId, + p_product_id: productId, + p_caller_uid: callerUid, + }), + } + ); + const data = await res.json(); + + if (!res.ok || data.success === false) { + const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`; + const errDetail = data?.details ?? data?.hint ?? data?.code ?? ""; + setError(`Failed to assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`); + setPendingId(null); + return; } - ); - const diag = await diagRes.json(); - // Now attempt the actual assignment - const res = await fetch( - `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`, - { - method: "POST", - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_stop_id: stopId, - p_product_id: selected, - p_caller_uid: callerUid, - }), - } - ); + // Optimistic: re-fetch to keep assigned list in sync with server + const refreshRes = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`, + { + method: "POST", + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + "Content-Type": "application/json", + }, + body: JSON.stringify({ p_stop_id: stopId }), + } + ); + const refreshData = await refreshRes.json(); + setProducts(refreshData.products ?? []); + setPendingId(null); - const data = await res.json(); - - if (!res.ok || data.success === false) { - const diagInfo = diag && typeof diag === 'object' && 'reason' in diag - ? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})` - : ''; - const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`; - const errDetail = data?.details ?? data?.hint ?? data?.code ?? ''; - const fullError = diagInfo - ? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}` - : `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`; - setError(fullError); - setAssigning(false); - return; + logAuditEvent({ + table_name: "product_stops", + record_id: data.id, + action: "INSERT", + old_data: null, + new_data: { stop_id: stopId, product_id: productId }, + brand_id: null, + }); + } catch { + setError("Network error while assigning product."); + setPendingId(null); } - - // Refresh product list from get_stop_products RPC - const refreshRes = await fetch( - `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`, - { - method: "POST", - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_stop_id: stopId }), - } - ); - const refreshData = await refreshRes.json(); - setProducts(refreshData.products ?? []); - setSelected(""); - setAssigning(false); - - logAuditEvent({ - table_name: "product_stops", - record_id: data.id, - action: "INSERT", - old_data: null, - new_data: { stop_id: stopId, product_id: selected }, - brand_id: null, - }); } - async function handleRemove(productId: string) { + async function remove(productId: string) { + if (removingId) return; const entry = products.find((p) => p.product_id === productId); if (!entry) return; - setRemoving(productId); + setRemovingId(productId); setError(null); - const res = await fetch( - `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`, - { - method: "POST", - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_stop_id: stopId, - p_product_id: productId, - p_caller_uid: callerUid, - }), + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`, + { + method: "POST", + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + p_stop_id: stopId, + p_product_id: productId, + p_caller_uid: callerUid, + }), + } + ); + const data = await res.json(); + + if (!res.ok || data.success === false) { + const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`; + const errDetail = data?.details ?? data?.hint ?? data?.code ?? ""; + setError(`Failed to remove: ${errMsg}${errDetail ? " — " + errDetail : ""}`); + setRemovingId(null); + return; } - ); - const data = await res.json(); + setProducts(products.filter((p) => p.product_id !== productId)); + setRemovingId(null); - if (!res.ok || data.success === false) { - const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`; - const errDetail = data?.details ?? data?.hint ?? data?.code ?? ''; - setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`); - setRemoving(null); - return; + logAuditEvent({ + table_name: "product_stops", + record_id: entry.id, + action: "DELETE", + old_data: { stop_id: stopId, product_id: productId }, + new_data: null, + brand_id: null, + }); + } catch { + setError("Network error while removing product."); + setRemovingId(null); } - - setProducts(products.filter((p) => p.product_id !== productId)); - setRemoving(null); - - logAuditEvent({ - table_name: "product_stops", - record_id: entry.id, - action: "DELETE", - old_data: { stop_id: stopId, product_id: productId }, - new_data: null, - brand_id: null, - }); } + function toggle(productId: string) { + if (assignedIds.has(productId)) { + remove(productId); + } else { + assign(productId); + } + } + + function clearAll() { + if (!products.length) return; + // Remove sequentially to be safe + (async () => { + for (const p of [...products]) { + await remove(p.product_id); + } + })(); + } + + // Keyboard: pressing / focuses search + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { + e.preventDefault(); + const el = document.getElementById("stop-product-search") as HTMLInputElement | null; + el?.focus(); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const hasAssigned = products.length > 0; + const hasProducts = allProducts.length > 0; + return ( -
+
{error && ( -
- {error} +
+ + + + + {error}
)} - {products.length > 0 ? ( -
-

- Assigned Products ({products.length}) + {/* Header: editorial title + search */} +

+
+ + {String(products.length).padStart(2, "0")} + + + {products.length === 1 ? "Product on this stop" : "Products on this stop"} + +
+
+ + setSearch(e.target.value)} + placeholder="Search products…" + className="ha-picker-search-input" + autoComplete="off" + /> +
+
+ + {/* Filter chips */} +
+ + + + + Press / to search + +
+ + {/* Product grid */} + {!hasProducts ? ( +
+

No products yet

+

+ Create products in the catalog first, then come back here to assign them to this stop. +

+
+ ) : visibleProducts.length === 0 ? ( +
+

+ {filter === "assigned" ? "Nothing assigned" : "No matches"} +

+

+ {search.trim() + ? `No products match "${search.trim()}". Try a different term.` + : filter === "assigned" + ? "This stop has no products assigned yet. Click a card in the All tab to add one." + : "All products are already assigned to this stop."}

- {products.map((ap) => ( -
-
-

- {ap.products?.name ?? "Unknown"} -

-

- {ap.products?.type} ·{" "} - ${Number(ap.products?.price ?? 0).toFixed(2)} -

-
- -
- ))}
) : ( -

No products assigned yet.

+
+ {visibleProducts.map((product) => { + const isAssigned = assignedIds.has(product.id); + const isBusy = pendingId === product.id || removingId === product.id; + return ( + + ); + })} +
)} - {availableProducts.length > 0 && ( -
- + {/* Summary footer when assigned items exist */} + {hasAssigned && ( +
+ + + + + + {products.length}{" "} + {products.length === 1 ? "product is" : "products are"} live at this stop + + - +
)}
); -} \ No newline at end of file +} diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index 44c8cba..4a1ee22 100644 --- a/src/components/admin/StopTableClient.tsx +++ b/src/components/admin/StopTableClient.tsx @@ -34,6 +34,11 @@ type Stop = { type Props = { stops: Stop[]; + /** + * Hide the internal search/filter bar at the top of the table. Use when + * the parent component already renders shared filters (e.g. StopsViewClient). + */ + hideInternalFilterBar?: boolean; }; const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = { @@ -43,7 +48,7 @@ const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "acti draft: "draft", }; -export default function StopTableClient({ stops }: Props) { +export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) { const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); const [, startTransition] = useTransition(); @@ -162,6 +167,7 @@ export default function StopTableClient({ stops }: Props) { return ( <> {/* Filter bar */} + {!hideInternalFilterBar && (
+ )} {/* Bulk actions bar */} {selectedStops.size > 0 && ( diff --git a/src/components/admin/StopsCalendarClient.tsx b/src/components/admin/StopsCalendarClient.tsx new file mode 100644 index 0000000..d7afbfc --- /dev/null +++ b/src/components/admin/StopsCalendarClient.tsx @@ -0,0 +1,754 @@ +"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(null); + const [activePopover, setActivePopover] = useState<{ + stopId: string; + cellRect: DOMRect; + } | null>(null); + const [publishingId, setPublishingId] = useState(null); + + // Bucket stops by YYYY-MM-DD + const stopsByDate = useMemo(() => { + const map = new Map(); + 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.map((s) => s.date).filter(Boolean).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(() => { + 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(() => { + 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 ( + <> +
+ {/* Header */} +
+
+ + Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file + +

+ {MONTH_NAMES_ITALIC[viewMonth.getMonth()]} + {viewMonth.getFullYear()} +

+
+
+ + + +
+
+ + {/* DOW header */} +
+ {DOW_LABELS.map((label) => ( +
+ {label} +
+ ))} +
+ + {/* 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 ( +
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)} + > +
+ {d.getDate()} + {isToday && } +
+
+ {dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => ( + openStop(stop, rect)} + isActive={activePopover?.stopId === stop.id} + /> + ))} + {dayStops.length > MAX_EVENTS_PER_CELL && ( + + )} +
+
+ ); + })} +
+ + {/* Legend */} +
+ + + Active + + + + Draft + + + + Inactive + + + Tip — click any day with stops to see the full route · click an event for details + +
+
+ + {/* Event popover */} + {activeStop && activePopover && popoverStyle && ( + handlePublish(activeStop)} + onOpenRoute={() => { + setActivePopover(null); + setSelectedDate(activeStop.date); + }} + style={popoverStyle} + arrowStyle={popoverArrowStyle} + /> + )} + + {/* Day-route drawer */} + {selectedDate && selectedDayDate && ( + 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(null); + const status = statusOf(stop); + return ( + + ); +} + +/* ================================================================== */ +/* 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 ( +
e.stopPropagation()} + > + {arrowStyle &&
} +
+ + {statusLabel(status)} · {brandName(stop)} +
+

+ {stop.city}, {stop.state} +

+

{stop.location}

+ +
+
+ Pickup + {formatTime12(stop.time)} +
+
+ Cutoff + {stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"} +
+ {stop.address && ( +
+ Address + + {stop.address}{stop.zip ? `, ${stop.zip}` : ""} + +
+ )} +
+ +
+ {status === "draft" && ( + + )} + + + Edit + + + + +
+
+ ); +} + +/* ================================================================== */ +/* 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].sort((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 ( + <> +