diff --git a/src/actions/stops/get-stop-details.ts b/src/actions/stops/get-stop-details.ts new file mode 100644 index 0000000..c31bc78 --- /dev/null +++ b/src/actions/stops/get-stop-details.ts @@ -0,0 +1,119 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { createClient } from "@supabase/supabase-js"; + +export type StopDetail = { + id: string; + city: string; + state: string; + date: string; + time: string; + location: string; + slug: string; + active: boolean; + brand_id: string; + address: string | null; + zip: string | null; + cutoff_time: string | null; + brands: { name: string; slug: string } | { name: string; slug: string }[] | null; +}; + +export type AssignedProduct = { + id: string; + product_id: string; + products: { name: string; type: string; price: number } | null; +}; + +export type StopDetailsResult = + | { + success: true; + stop: StopDetail; + allProducts: { id: string; name: string; type: string; price: number }[]; + assignedProducts: AssignedProduct[]; + brands: { id: string; name: string; slug: string }[]; + } + | { success: false; error: string }; + +/** + * Fetch a single stop with its brand, all candidate products, currently + * assigned products, and the list of brands (for the brand switcher in the + * edit form). Mirrors the data the old `/admin/stops/[id]` page server + * component loaded, so the modal can be a drop-in replacement. + */ +export async function getStopDetails(stopId: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_stops) { + return { success: false, error: "Not authorized" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; + + // Use a fresh server-side client (cookie-less) so RLS doesn't block reads + // for platform_admin dev sessions. The auth check above has already gated + // access. + const server = useMockData + ? null + : createClient(supabaseUrl, supabaseKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + // 1. Stop + brand + let stop: StopDetail | null = null; + let stopErr: string | null = null; + + if (server) { + const { data, error } = await server + .from("stops") + .select("*, brands(name, slug)") + .eq("id", stopId) + .single(); + if (error) stopErr = error.message; + else stop = (data ?? null) as StopDetail | null; + } else { + // Mock fallback — empty + stopErr = "Stop not found"; + } + + if (!stop) { + return { success: false, error: stopErr ?? "Stop not found" }; + } + + // Brand-scope check for brand_admin + if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) { + return { success: false, error: "Not authorized for this brand" }; + } + + // 2. Candidate products for this brand + const { data: allProducts } = server + ? await server + .from("products") + .select("id, name, type, price") + .eq("brand_id", stop.brand_id) + .eq("active", true) + : { data: [] as { id: string; name: string; type: string; price: number }[] }; + + // 3. Assigned products (joined with product info) + const { data: productStops } = server + ? await server + .from("product_stops") + .select("id, product_id, products(id, name, type, price)") + .eq("stop_id", stopId) + : { data: [] as AssignedProduct[] }; + + // 4. Brands for the brand switcher + const { data: brands } = server + ? await server.from("brands").select("id, name, slug") + : { data: [] as { id: string; name: string; slug: string }[] }; + + return { + success: true, + stop, + allProducts: allProducts ?? [], + assignedProducts: (productStops ?? []) as AssignedProduct[], + brands: brands ?? [], + }; +} diff --git a/src/components/admin/StopDetailModal.tsx b/src/components/admin/StopDetailModal.tsx new file mode 100644 index 0000000..2cd16e8 --- /dev/null +++ b/src/components/admin/StopDetailModal.tsx @@ -0,0 +1,307 @@ +"use client"; +/* eslint-disable react-hooks/set-state-in-effect */ + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import GlassModal from "@/components/admin/GlassModal"; +import StopEditForm from "@/components/admin/StopEditForm"; +import StopProductAssignment from "@/components/admin/StopProductAssignment"; +import MessageCustomersSection from "@/components/admin/MessageCustomersSection"; +import { getStopDetails } from "@/actions/stops/get-stop-details"; +import { useToast } from "@/components/admin/design-system"; +import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details"; + +type Tab = "details" | "products" | "message"; + +type Props = { + stopId: string; + /** Optional: when the user clicks Duplicate from inside the modal. */ + onDuplicate?: (stopId: string) => void; + onClose: () => void; +}; + +export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) { + const router = useRouter(); + const { success: showSuccess } = useToast(); + const [, startTransition] = useTransition(); + + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [stop, setStop] = useState(null); + const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]); + const [assignedProducts, setAssignedProducts] = useState([]); + const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]); + const [tab, setTab] = useState("details"); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setLoadError(null); + getStopDetails(stopId) + .then((res) => { + if (cancelled) return; + if (!res.success) { + setLoadError(res.error); + setLoading(false); + return; + } + setStop(res.stop); + setAllProducts(res.allProducts); + setAssignedProducts(res.assignedProducts); + setBrands(res.brands); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + setLoadError(err?.message ?? "Failed to load stop"); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [stopId]); + + function refresh() { + getStopDetails(stopId).then((res) => { + if (!res.success) return; + setStop(res.stop); + setAllProducts(res.allProducts); + setAssignedProducts(res.assignedProducts); + setBrands(res.brands); + }); + } + + function handleEditSaved() { + refresh(); + showSuccess("Stop updated", "Changes have been saved"); + startTransition(() => router.refresh()); + } + + const subtitle = stop?.brands + ? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined + : undefined; + + return ( + + {loading ? ( +
+
+
+
+
+ ) : loadError ? ( +
+ {loadError} +
+ ) : stop ? ( +
+ {/* Tabs */} +
+ setTab("details")}> + Details + + setTab("products")}> + + Products + {assignedProducts.length > 0 && ( + + {assignedProducts.length} + + )} + + + setTab("message")}> + Message + +
+ + {tab === "details" && ( + + )} + + {tab === "products" && ( +
+

+ Assigned Products +

+

+ Manage which products are available at this stop. +

+
+ +
+
+ )} + + {tab === "message" && ( +
+

+ Message Customers +

+

+ Send updates to customers with pending pickups at this stop. +

+
+ +
+
+ )} +
+ ) : null} + + ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function DetailsPanel({ + stop, + brands, + onDuplicate, + onSaved, +}: { + stop: StopDetail; + brands: { id: string; name: string; slug: string }[]; + onDuplicate?: (stopId: string) => void; + onSaved: () => void; +}) { + return ( +
+
+
+
+

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

+

{stop.location}

+
+ + {stop.active ? "Active" : "Inactive"} + +
+ +
+
+
Date
+
{stop.date}
+
+
+
Time
+
{stop.time}
+
+ {stop.address && ( +
+
Address
+
+ {stop.address} + {stop.zip ? `, ${stop.zip}` : ""} +
+
+ )} + {stop.cutoff_time && ( +
+
Cutoff
+
+ {new Date(stop.cutoff_time).toLocaleString()} +
+
+ )} +
+
+ +
+ {onDuplicate ? ( + + ) : ( + + Duplicate Stop + + )} +
+ +
+

Edit Stop

+

+ Update stop details, location, and availability. +

+
+ +
+
+
+ ); +} diff --git a/src/components/admin/StopEditForm.tsx b/src/components/admin/StopEditForm.tsx index f4cc5e3..b3e1d0d 100644 --- a/src/components/admin/StopEditForm.tsx +++ b/src/components/admin/StopEditForm.tsx @@ -27,9 +27,11 @@ type StopEditFormProps = { cutoff_time?: string | null; }; brands: Brand[]; + /** Optional callback fired after a successful save. */ + onSaved?: () => void; }; -export default function StopEditForm({ stop, brands }: StopEditFormProps) { +export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) { const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); const [saving, setSaving] = useState(false); @@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) { showSuccess("Stop updated", `${city}, ${state} has been saved`); setSaved(true); setSaving(false); + onSaved?.(); router.refresh(); } diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index 0c4cac1..44c8cba 100644 --- a/src/components/admin/StopTableClient.tsx +++ b/src/components/admin/StopTableClient.tsx @@ -15,6 +15,7 @@ import { } from "@/components/admin/design-system"; import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; import AddStopModal from "@/components/admin/AddStopModal"; +import StopDetailModal from "@/components/admin/StopDetailModal"; type Stop = { id: string; @@ -55,6 +56,7 @@ export default function StopTableClient({ stops }: Props) { const [bulkPublishing, setBulkPublishing] = useState(false); const [showImport, setShowImport] = useState(false); const [showAdd, setShowAdd] = useState(false); + const [openStopId, setOpenStopId] = useState(null); const PAGE_SIZE = 50; useEffect(() => { @@ -349,6 +351,7 @@ export default function StopTableClient({ stops }: Props) { e.stopPropagation(); toggleStopSelection(stop.id); }} + onOpen={() => setOpenStopId(stop.id)} /> )) )} @@ -369,6 +372,13 @@ export default function StopTableClient({ stops }: Props) { brandId={stops[0]?.brand_id ?? ""} onSuccess={refresh} /> + + {openStopId && ( + setOpenStopId(null)} + /> + )} ); } @@ -400,16 +410,16 @@ function StopRowBase({ onDeleteError, isSelected, onToggleSelect, + onOpen, }: { stop: Stop; onDeleted: () => void; onDeleteError: (msg: string) => void; isSelected: boolean; onToggleSelect: (e: React.MouseEvent) => void; + onOpen: () => void; }) { - const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); - const [, startTransition] = useTransition(); const [openMenu, setOpenMenu] = useState(null); const [deletingId, setDeletingId] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); @@ -418,10 +428,6 @@ function StopRowBase({ const { date, weekday } = formatDate(stop.date); const time = formatTime(stop.time); - function goToEdit() { - startTransition(() => router.push(`/admin/stops/${stop.id}`)); - } - async function handlePublish() { setPublishingId(stop.id); const result = await publishStop(stop.id, stop.brand_id); @@ -461,7 +467,7 @@ function StopRowBase({ return ( e.stopPropagation()}>