From 800ee5373ba552e723ba222c6b7e8efe35a5224c Mon Sep 17 00:00:00 2001 From: default Date: Mon, 1 Jun 2026 20:32:43 +0000 Subject: [PATCH] feat(admin): overhaul stops page with modal-based Add Stop - AddStopModal: new modal component for creating stops inline - ScheduleImportModal: fixed colors to match warm earth-tone theme - StopsHeaderActions: use modals instead of page navigation - Consistent emerald/stone palette with rounded cards --- src/components/admin/AddStopModal.tsx | 374 +++++++++++++++++++ src/components/admin/ScheduleImportModal.tsx | 226 ++++++----- src/components/admin/StopsHeaderActions.tsx | 49 ++- 3 files changed, 544 insertions(+), 105 deletions(-) create mode 100644 src/components/admin/AddStopModal.tsx diff --git a/src/components/admin/AddStopModal.tsx b/src/components/admin/AddStopModal.tsx new file mode 100644 index 0000000..7a2c8c7 --- /dev/null +++ b/src/components/admin/AddStopModal.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { createStop } from "@/actions/stops/create-stop"; +import { formatDate } from "@/lib/format-date"; + +type Props = { + isOpen: boolean; + onClose: () => void; + brandId: string; + duplicateFrom?: { + city: string; + state: string; + location: string; + date: string; + time: string; + address?: string | null; + zip?: string | null; + cutoff_time?: string | null; + } | null; + onSuccess?: (stopId: string) => void; +}; + +export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Form state + const [city, setCity] = useState(duplicateFrom?.city ?? ""); + const [state, setState] = useState(duplicateFrom?.state ?? ""); + const [location, setLocation] = useState(duplicateFrom?.location ?? ""); + const [date, setDate] = useState(duplicateFrom?.date ?? ""); + const [time, setTime] = useState(duplicateFrom?.time ?? ""); + const [address, setAddress] = useState(duplicateFrom?.address ?? ""); + const [zip, setZip] = useState(duplicateFrom?.zip ?? ""); + const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? ""); + const [status, setStatus] = useState<"draft" | "active">("draft"); + + // Reset form when modal opens/closes or duplicate changes + useEffect(() => { + if (isOpen) { + setCity(duplicateFrom?.city ?? ""); + setState(duplicateFrom?.state ?? ""); + setLocation(duplicateFrom?.location ?? ""); + setDate(duplicateFrom?.date ?? ""); + setTime(duplicateFrom?.time ?? ""); + setAddress(duplicateFrom?.address ?? ""); + setZip(duplicateFrom?.zip ?? ""); + setCutoffTime(duplicateFrom?.cutoff_time ?? ""); + setStatus("draft"); + setError(null); + } + }, [isOpen, duplicateFrom]); + + // Lock body scroll + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { document.body.style.overflow = ""; }; + }, [isOpen]); + + // Escape key handler + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) { + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + } + }, [isOpen, onClose]); + + 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"); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + if (!isOpen) return null; + + const isDuplicate = Boolean(duplicateFrom); + + return ( +
+
+ {/* Header */} +
+
+

+ {isDuplicate ? "Duplicate Stop" : "Add New Stop"} +

+

+ {isDuplicate && duplicateFrom + ? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}` + : "Create a new tour stop for your route."} +

+
+ +
+ + {/* Form */} +
+
+ {error && ( +
+ {error} +
+ )} + + {/* Location row */} +
+
+ + setCity(e.target.value)} + placeholder="Denver" + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + required + /> +
+
+ + setState(e.target.value.toUpperCase())} + placeholder="CO" + maxLength={2} + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + required + /> +
+
+ + {/* Location name */} +
+ + setLocation(e.target.value)} + placeholder="Whole Foods Market" + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + required + /> +
+ + {/* Date & Time row */} +
+
+ + setDate(e.target.value)} + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + required + /> +
+
+ + setTime(e.target.value)} + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + /> +
+
+ + {/* Address & ZIP */} +
+
+ + setAddress(e.target.value)} + placeholder="123 Main St" + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + /> +
+
+ + setZip(e.target.value)} + placeholder="80202" + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + /> +
+
+ + {/* Cutoff time */} +
+ + setCutoffTime(e.target.value)} + className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + /> +

+ Customers can only order until this time on the day before pickup. +

+
+ + {/* Status toggle */} +
+ +
+ + +
+
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} + +// Hook for easy integration +export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicateFrom"]) { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + + const Modal = useCallback(() => ( + + ), [isOpen, close, brandId, duplicateFrom]); + + return { open, close, Modal }; +} \ No newline at end of file diff --git a/src/components/admin/ScheduleImportModal.tsx b/src/components/admin/ScheduleImportModal.tsx index 5f4b000..6af481b 100644 --- a/src/components/admin/ScheduleImportModal.tsx +++ b/src/components/admin/ScheduleImportModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { createStopsBatch } from "@/actions/stops"; import type { ParsedStopRow } from "@/lib/csv-parsers"; @@ -22,8 +22,29 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr const [dragOver, setDragOver] = useState(false); const [useAI, setUseAI] = useState(false); const [created, setCreated] = useState(0); + const [isVisible, setIsVisible] = useState(false); const fileInputRef = useRef(null); + // Animate in + useEffect(() => { + requestAnimationFrame(() => setIsVisible(true)); + }, []); + + // Lock body scroll + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = ""; }; + }, []); + + // Escape key handler + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [onClose]); + const processFile = useCallback(async (file: File) => { setError(null); setStep("parsing"); @@ -125,49 +146,64 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr const hasWarnings = warnings.length > 0; + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + return ( -
-
+
+
+ +
{/* Header */} -
+
-

Import Schedule

-

+

Import Schedule

+

Upload a CSV to bulk-create stops as drafts.

{/* Body */} -
+
{step === "idle" && ( -
+
{error && ( -
+
{error}
)} {/* AI toggle */} -
+
- + {useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV. Text/PDF uses AI if enabled."} @@ -180,20 +216,26 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr onDragLeave={() => setDragOver(false)} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} - className={`cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-colors ${ + className={`cursor-pointer rounded-2xl border-2 border-dashed p-12 text-center transition-all ${ dragOver - ? "border-slate-900 bg-zinc-900" - : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-800" + ? "border-emerald-500 bg-emerald-50" + : "border-stone-300 hover:border-emerald-400 hover:bg-stone-50" }`} > -

📄

-

+

+
+ + + +
+
+

Drop your schedule file here

-

+

or click to browse — CSV, TXT, JSON

-

+

CSV columns: city, state, location, date, time, address, zip, notes

@@ -209,24 +251,24 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr )} {step === "parsing" && ( -
-
-

Parsing file…

+
+
+

Parsing file…

)} {step === "review" && ( -
+
{error && ( -
+
{error}
)} {hasWarnings && ( -
-

Parsing warnings:

-
    +
    +

    Parsing warnings:

    +
      {warnings.slice(0, 5).map((w, i) => (
    • {w}
    • ))} @@ -235,76 +277,72 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
    )} -

    - {parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit below before importing. -

    +
    +

    + {parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit below +

    + All will be created as drafts +
    {/* Review table */} -
    - - +
    +
    + - - - - - - - + + + + + - + {parsedStops.map((stop, idx) => ( - - + - - - - - - @@ -313,21 +351,21 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
    CityStateLocationDateTimeAddress + CityStateLocationDateTime
    +
    updateStop(idx, "city", e.target.value)} - className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400" + className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white" /> + updateStop(idx, "state", e.target.value)} - className="w-12 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400" + className="w-14 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white" /> + updateStop(idx, "location", e.target.value)} - className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400" + className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white" /> + updateStop(idx, "date", e.target.value)} - className="w-24 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400" + className="w-28 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white" /> + updateStop(idx, "time", e.target.value)} - className="w-20 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400" + className="w-20 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white" /> - updateStop(idx, "address", e.target.value)} - placeholder="—" - className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400" - /> - +
    -
    -

    +

    +

    {parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created

    @@ -337,24 +375,30 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr )} {step === "importing" && ( -
    -
    -

    Creating draft stops…

    +
    +
    +

    Creating draft stops…

    )} {step === "done" && ( -
    -

    -

    - {created} draft stop{created !== 1 ? "s" : ""} created -

    -

    - Review them in the stops list and publish when ready. -

    +
    +
    + + + +
    +
    +

    + {created} draft stop{created !== 1 ? "s" : ""} created +

    +

    + Review them in the stops list and publish when ready. +

    +
    @@ -362,12 +406,16 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr )} {step === "error" && ( -
    -

    -

    {error}

    +
    +
    + + + +
    +

    {error}

    @@ -377,4 +425,4 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
    ); -} +} \ No newline at end of file diff --git a/src/components/admin/StopsHeaderActions.tsx b/src/components/admin/StopsHeaderActions.tsx index 5c64866..4045f1d 100644 --- a/src/components/admin/StopsHeaderActions.tsx +++ b/src/components/admin/StopsHeaderActions.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; +import AddStopModal from "@/components/admin/AddStopModal"; import { useRouter } from "next/navigation"; type Props = { @@ -9,10 +10,15 @@ type Props = { }; export default function StopsHeaderActions({ brandId }: Props) { - const [open, setOpen] = useState(false); + const [showImport, setShowImport] = useState(false); + const [showAdd, setShowAdd] = useState(false); const router = useRouter(); - function handleComplete(count: number) { + function handleImportComplete(count: number) { + router.refresh(); + } + + function handleAddSuccess(stopId: string) { router.refresh(); } @@ -20,26 +26,37 @@ export default function StopsHeaderActions({ brandId }: Props) { <>
    - setShowAdd(true)} + className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white transition-colors shadow-sm shadow-emerald-200" > - + Add Stop - + + + + Add Stop +
    - {open && ( - setOpen(false)} - onComplete={handleComplete} - /> - )} + setShowImport(false)} + onComplete={handleImportComplete} + /> + + setShowAdd(false)} + brandId={brandId} + onSuccess={handleAddSuccess} + /> ); } \ No newline at end of file