Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { ThemeProvider } from "next-themes";
import { CartProvider } from "@/context/CartContext";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import SiteHeader from "@/components/layout/SiteHeader";
import SiteFooter from "@/components/layout/SiteFooter";
import CartToast from "@/components/storefront/CartToast";
import CartRestoredToast from "@/components/cart/CartRestoredToast";
export function Providers({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
const isLandingPage = pathname === "/" || pathname === "/brands";
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
<CartProvider>
{!isBrandRoute && !isLandingPage && <SiteHeader />}
{mounted ? children : <div style={{ visibility: 'hidden' }}>{children}</div>}
{!isBrandRoute && !isLandingPage && <SiteFooter />}
<CartToast />
<CartRestoredToast />
</CartProvider>
</ThemeProvider>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
export default function CTASection() {
return (
<section className="py-20 bg-white relative">
<div className="absolute inset-0 bg-gradient-to-b from-stone-100 to-transparent" />
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="relative text-center"
>
<h2 className="text-3xl md:text-4xl font-black tracking-tight text-stone-950">Ready to Order?</h2>
<p className="mt-4 text-stone-500 text-lg">Experience the difference four decades of excellence makes.</p>
<div className="mt-10 flex flex-wrap justify-center gap-4">
<Link
href="/tuxedo#stops"
className="group relative inline-flex items-center gap-3 rounded-full bg-stone-900 px-8 py-4 font-bold text-white overflow-hidden"
>
<span className="absolute inset-0 bg-gradient-to-r from-emerald-700 to-emerald-600 opacity-0 group-hover:opacity-100 transition-opacity" />
<span className="relative flex items-center gap-2">
Find a Stop
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
</svg>
</span>
</Link>
<Link
href="/tuxedo#products"
className="group relative inline-flex items-center gap-3 rounded-full bg-white px-8 py-4 font-bold text-stone-900 ring-2 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
>
<span className="relative">
Shop Products
<svg className="inline w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
</svg>
</span>
</Link>
</div>
</motion.div>
</LayoutContainer>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
interface ContactSectionProps {
address: string;
}
export default function ContactSection({ address }: ContactSectionProps) {
return (
<section className="py-24 md:py-32 bg-stone-950 text-white relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute top-0 left-1/3 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/3 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<div className="relative">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-16"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-400/60">Get in Touch</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-white leading-tight">
Visit Our Farm
</h2>
</motion.div>
<div className="grid md:grid-cols-3 gap-12 max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Address</p>
<p className="text-stone-300 leading-relaxed">{address}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Phone</p>
<p className="text-stone-300">Shipping: 970-323-5631</p>
<p className="text-stone-300 mt-1">Office: 970-323-6874</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.3, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Hours</p>
<p className="text-stone-300">MF: 7AM6PM</p>
<p className="text-stone-300 mt-1">Sat & Sun: 8AM2PM</p>
</motion.div>
</div>
</div>
</LayoutContainer>
</section>
);
}
@@ -0,0 +1,115 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
const FAMILY_MEMBERS = [
{
initials: "JH",
name: "John Harold",
role: "Founder",
bio: "John Harold began selling Olathe's sweet corn directly from pickups in the 1970s. With unwavering dedication to community, family, and agriculture, he built Tuxedo Corn into a thriving family operation. Prior to farming, John established the first Group Home on the Western Slope and opened Colorow Care Center in Montrose County.",
era: "1970s",
},
{
initials: "DH",
name: "David Harold",
role: "Second Generation",
bio: "David leads sustainability efforts across Harold farms, implementing drip irrigation and joining the Fair Food Program to ensure responsible labor practices. He continues the family's commitment to raising top-quality corn while protecting the land for future generations.",
era: "Present",
},
{
initials: "JW",
name: "John William Harold",
role: "Mexico Operations",
bio: "John William has worked in Guaymas, Sonora, Mexico for over three decades growing sweet corn, onions, wheat, and vegetables. His international operations extend the Harold family farming tradition across borders.",
era: "1990sPresent",
},
{
initials: "JH",
name: "Joseph Harold",
role: "Third Generation",
bio: "Joseph works at the corn shed during summer and handles photography and beverages, carrying the family tradition into the next generation while learning every aspect of the business.",
era: "Present",
},
];
// Family member card with hover effects
function FamilyCard({ member, index }: { member: typeof FAMILY_MEMBERS[0]; index: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.6, delay: index * 0.12, ease: [0.22, 0.61, 0.36, 1] }}
whileHover={{ y: -6 }}
className="group relative"
>
{/* Era badge */}
<div className="absolute -top-3 -left-3 z-10">
<span className="inline-block bg-stone-900 text-stone-200 text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full shadow-lg">
{member.era}
</span>
</div>
<div className="relative bg-gradient-to-b from-white to-stone-50 rounded-3xl p-8 shadow-lg shadow-black/5 ring-1 ring-stone-200/40 overflow-hidden h-full">
{/* Decorative top line */}
<motion.div
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-500 origin-left"
/>
{/* Avatar */}
<div className="mb-6 relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-stone-900 to-stone-800 flex items-center justify-center shadow-xl shadow-stone-900/30">
<span className="text-2xl font-black text-white tracking-tight">{member.initials}</span>
</div>
{/* Decorative corner accent */}
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-emerald-600/20 rounded-full blur-sm" />
</div>
{/* Content */}
<div>
<h3 className="text-xl font-black text-stone-950 tracking-tight">{member.name}</h3>
<p className="mt-2 text-[11px] font-bold uppercase tracking-widest text-emerald-700">{member.role}</p>
<p className="mt-4 text-sm leading-relaxed text-stone-600">{member.bio}</p>
</div>
{/* Bottom accent */}
<div className="absolute bottom-0 left-8 right-8 h-px bg-gradient-to-r from-transparent via-stone-200 to-transparent" />
</div>
</motion.div>
);
}
export default function FamilyTimelineSection() {
return (
<section className="py-24 md:py-32 bg-stone-100 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(16,100,50,0.08)_0%,transparent_50%)]" />
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-20"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">The People Behind the Corn</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
The Harold Family
</h2>
<div className="mt-8 mx-auto h-px w-24 bg-gradient-to-r from-emerald-600 via-emerald-400 to-transparent" />
</motion.div>
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
{FAMILY_MEMBERS.map((member, index) => (
<FamilyCard key={member.name} member={member} index={index} />
))}
</div>
</LayoutContainer>
</section>
);
}
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
export default function IndianRiverCTASection() {
return (
<section className="py-20 bg-blue-600 relative overflow-hidden">
{/* Subtle decorative elements */}
<div className="absolute inset-0 opacity-20">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-700/30 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-emerald-900/10 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="relative text-center backdrop-blur-md bg-white/10 border border-white/20 rounded-3xl p-12"
>
<h2 className="text-3xl md:text-4xl font-black tracking-tight text-white">
Taste the Difference
</h2>
<p className="mt-4 text-blue-100 text-lg leading-relaxed max-w-2xl mx-auto">
We invite you to join us on this sweet journey. Our fruits are guaranteed to be fresh, flavorful, and of the highest quality, straight from the grove to your neighborhood.
</p>
<div className="mt-10 flex flex-wrap justify-center gap-4">
<Link
href="/indian-river-direct#products"
className="group relative inline-flex items-center gap-3 rounded-full bg-white px-8 py-4 font-bold text-blue-600 overflow-hidden hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
>
<span className="absolute inset-0 bg-blue-50 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
<span className="relative flex items-center gap-2">
Shop Products
<svg
className="w-5 h-5 group-hover:translate-x-1 transition-transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
</svg>
</span>
</Link>
<Link
href="/indian-river-direct#stops"
className="group relative inline-flex items-center gap-3 rounded-full bg-blue-700 px-8 py-4 font-bold text-white ring-2 ring-blue-500 hover:ring-white hover:bg-blue-500 hover:-translate-y-0.5 transition-all duration-300"
>
<span className="relative flex items-center gap-2">
Find a Stop
<svg
className="w-5 h-5 group-hover:translate-x-1 transition-transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
</svg>
</span>
</Link>
</div>
</motion.div>
</LayoutContainer>
</section>
);
}
@@ -0,0 +1,216 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
// Custom SVG Icons for Citrus Varieties
function NavelOrangeIcon() {
return (
<svg
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-12 h-12"
aria-hidden="true"
>
{/* Orange body */}
<ellipse cx="24" cy="26" rx="18" ry="17" fill="currentColor" opacity="0.15" />
<ellipse cx="24" cy="26" rx="18" ry="17" stroke="currentColor" strokeWidth="1.2" />
{/* Navel (belly button) */}
<circle cx="24" cy="34" r="3" fill="currentColor" opacity="0.4" />
<circle cx="24" cy="34" r="3" stroke="currentColor" strokeWidth="1" />
{/* Stem */}
<path
d="M24 9 C24 9 22 6 24 4 C26 6 27 7 24 9Z"
stroke="currentColor"
strokeWidth="1.2"
fill="currentColor"
opacity="0.6"
/>
<path d="M24 9 L24 12" stroke="currentColor" strokeWidth="1.2" />
{/* Highlight */}
<ellipse cx="18" cy="20" rx="4" ry="3" fill="currentColor" opacity="0.1" />
{/* Texture dots */}
<circle cx="16" cy="26" r="0.8" fill="currentColor" opacity="0.2" />
<circle cx="32" cy="24" r="0.8" fill="currentColor" opacity="0.2" />
<circle cx="20" cy="32" r="0.8" fill="currentColor" opacity="0.2" />
<circle cx="28" cy="30" r="0.8" fill="currentColor" opacity="0.2" />
{/* Juice droplets */}
<path d="M14 40 Q15 42 14 44 Q13 42 14 40Z" fill="currentColor" opacity="0.3" />
<path d="M34 42 Q35 44 34 46 Q33 44 34 42Z" fill="currentColor" opacity="0.3" />
</svg>
);
}
function TangerineIcon() {
return (
<svg
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-12 h-12"
aria-hidden="true"
>
{/* Tangerine body - slightly flatter */}
<ellipse cx="24" cy="26" rx="17" ry="16" fill="currentColor" opacity="0.15" />
<ellipse cx="24" cy="26" rx="17" ry="16" stroke="currentColor" strokeWidth="1.2" />
{/* Segment lines */}
<path d="M24 10 Q24 26 24 42" stroke="currentColor" strokeWidth="0.6" opacity="0.4" />
<path d="M7 26 Q24 26 41 26" stroke="currentColor" strokeWidth="0.6" opacity="0.4" />
<path d="M11 16 Q24 22 37 32" stroke="currentColor" strokeWidth="0.6" opacity="0.3" />
<path d="M11 36 Q24 30 37 16" stroke="currentColor" strokeWidth="0.6" opacity="0.3" />
{/* Easy peel indication - slightly separated skin line at top */}
<path
d="M12 12 Q24 8 36 12"
stroke="currentColor"
strokeWidth="0.8"
strokeDasharray="2 2"
opacity="0.5"
/>
{/* Stem */}
<path d="M24 10 L24 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path
d="M20 7 Q24 5 28 7"
stroke="currentColor"
strokeWidth="1.2"
fill="none"
/>
{/* Leaf */}
<path
d="M28 6 Q32 4 34 8 Q30 10 28 6Z"
fill="currentColor"
opacity="0.5"
stroke="currentColor"
strokeWidth="0.5"
/>
{/* Highlight */}
<ellipse cx="17" cy="22" rx="5" ry="4" fill="currentColor" opacity="0.1" />
</svg>
);
}
function GrapefruitIcon() {
return (
<svg
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-12 h-12"
aria-hidden="true"
>
{/* Grapefruit body - larger, darker */}
<ellipse cx="24" cy="26" rx="19" ry="18" fill="currentColor" opacity="0.15" />
<ellipse cx="24" cy="26" rx="19" ry="18" stroke="currentColor" strokeWidth="1.2" />
{/* Section lines - more prominent for grapefruit */}
<path d="M24 8 Q24 26 24 44" stroke="currentColor" strokeWidth="0.8" opacity="0.5" />
<path d="M5 26 Q24 26 43 26" stroke="currentColor" strokeWidth="0.8" opacity="0.5" />
<path d="M9 15 Q24 22 39 35" stroke="currentColor" strokeWidth="0.6" opacity="0.4" />
<path d="M9 37 Q24 30 39 15" stroke="currentColor" strokeWidth="0.6" opacity="0.4" />
{/* Inner segments suggestion */}
<path d="M13 20 Q20 23 27 20" stroke="currentColor" strokeWidth="0.4" opacity="0.25" />
<path d="M13 32 Q20 29 27 32" stroke="currentColor" strokeWidth="0.4" opacity="0.25" />
{/* Stem with leaf */}
<path d="M24 8 L24 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<ellipse
cx="27"
cy="6"
rx="5"
ry="3"
transform="rotate(30 27 6)"
fill="currentColor"
opacity="0.4"
stroke="currentColor"
strokeWidth="0.5"
/>
{/* Highlight - subtle */}
<ellipse cx="16" cy="19" rx="5" ry="4" fill="currentColor" opacity="0.08" />
{/* Bottom texture */}
<circle cx="20" cy="36" r="0.6" fill="currentColor" opacity="0.15" />
<circle cx="28" cy="38" r="0.6" fill="currentColor" opacity="0.15" />
</svg>
);
}
const CITRUS_VARIETIES = [
{
name: "Navel Oranges",
description: "Sweet and juicy, perfect for eating or juicing",
icon: NavelOrangeIcon,
},
{
name: "Tangerines",
description: "Easy-to-peel and bursting with flavor",
icon: TangerineIcon,
},
{
name: "Grapefruits",
description: "Bold and tangy, a morning favorite",
icon: GrapefruitIcon,
},
];
export default function IndianRiverCitrusSection() {
return (
<section className="py-24 md:py-32 bg-white relative overflow-hidden">
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="mx-auto max-w-2xl text-center mb-16"
>
<span className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4 block">
Our Products
</span>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
The Fruits of Our Labor
</h2>
<p className="mt-6 text-stone-700 leading-relaxed text-lg">
We are continuously striving for excellence, refining our practices to bring you the best citrus possible. For years, our fruits have been enjoyed by families across the Mid-East from our sweet navel oranges and easy-to-peel tangerines, to our bold, tangy grapefruits.
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-3 max-w-4xl mx-auto">
{CITRUS_VARIETIES.map((citrus, index) => (
<motion.div
key={citrus.name}
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{
duration: 0.8,
delay: index * 0.1,
ease: [0.22, 0.61, 0.36, 1],
}}
className="backdrop-blur-md bg-blue-50/70 border border-blue-200/50 rounded-3xl p-8 shadow-xl shadow-blue-200/50 text-center hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
<div className="w-20 h-20 rounded-2xl bg-white ring-1 ring-blue-100 flex items-center justify-center mx-auto mb-6 text-blue-500">
<citrus.icon />
</div>
<h3 className="text-lg font-bold text-stone-950">{citrus.name}</h3>
<p className="text-stone-600 text-sm mt-2 leading-relaxed">
{citrus.description}
</p>
</motion.div>
))}
</div>
</LayoutContainer>
</section>
);
}
@@ -0,0 +1,160 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
interface IndianRiverContactSectionProps {
phone?: string | null;
email?: string | null;
}
export default function IndianRiverContactSection({
phone = "772-971-4484",
email = "Info@indianriverdirect.com",
}: IndianRiverContactSectionProps) {
return (
<section className="py-24 md:py-32 bg-stone-950 text-white relative overflow-hidden">
{/* Decorative elements */}
<div className="absolute inset-0">
<div className="absolute top-0 left-1/3 w-96 h-96 bg-blue-900/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/3 w-80 h-80 bg-emerald-900/10 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<div className="relative">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-16"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-blue-400/60">
Get in Touch
</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-white leading-tight">
Contact Us
</h2>
</motion.div>
<div className="grid md:grid-cols-3 gap-12 max-w-4xl mx-auto">
{/* Phone */}
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 backdrop-blur-md bg-stone-800/70 border border-stone-700/50 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-blue-900 transition-colors">
<svg
className="w-7 h-7 text-stone-400 group-hover:text-blue-400 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"
/>
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Phone</p>
<p className="text-stone-300">{phone}</p>
</motion.div>
{/* Email */}
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 backdrop-blur-md bg-stone-800/70 border border-stone-700/50 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-blue-900 transition-colors">
<svg
className="w-7 h-7 text-stone-400 group-hover:text-blue-400 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5H4.5A2.25 2.25 0 002.25 6.75m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Email</p>
<p className="text-stone-300">{email}</p>
</motion.div>
{/* Location */}
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.3, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center group"
>
<div className="w-16 h-16 backdrop-blur-md bg-stone-800/70 border border-stone-700/50 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-blue-900 transition-colors">
<svg
className="w-7 h-7 text-stone-400 group-hover:text-blue-400 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
</div>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Location</p>
<p className="text-stone-300">Indian River Region, Florida</p>
</motion.div>
</div>
{/* Business Hours */}
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.4, ease: [0.22, 0.61, 0.36, 1] }}
className="mt-16 max-w-md mx-auto"
>
<div className="backdrop-blur-md bg-stone-800/80 border border-stone-700/50 rounded-2xl p-6">
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-4 text-center">
Business Hours
</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-stone-400">Monday Friday</p>
<p className="text-stone-300 font-medium">8:00 AM 5:00 PM</p>
</div>
<div>
<p className="text-stone-400">Saturday</p>
<p className="text-stone-300 font-medium">9:00 AM 2:00 PM</p>
</div>
<div>
<p className="text-stone-400">Sunday</p>
<p className="text-stone-300 font-medium">Closed</p>
</div>
</div>
</div>
</motion.div>
</div>
</LayoutContainer>
</section>
);
}
@@ -0,0 +1,90 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
export default function IndianRiverMissionSection() {
return (
<>
{/* Origin Story */}
<section className="py-24 md:py-32 backdrop-blur-sm bg-white/80 border border-white/50 rounded-3xl p-10 shadow-xl shadow-stone-200/50 relative overflow-hidden">
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="mx-auto max-w-2xl"
>
<span className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4 block">The Beginning</span>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 mb-8 leading-tight">From Farm to Family</h2>
<div className="space-y-6 text-stone-700 leading-relaxed text-lg">
<p>
At Indian River Direct, quality and freshness are more than just goals they're the foundation of everything we do. We're not just a company that delivers exceptional fruit; we're also the farmers behind the scenes. By acquiring Fort B Groves, the owner of Indian River Direct created a direct and reliable source of premium citrus for our customers.
</p>
<p>
Owning and managing our own grove allows us to oversee every step of the process — from responsible farming practices and careful harvesting to strict quality control — ensuring each fruit box meets our highest standards. With direct access to the farm, we're able to reduce dependence on outside growers and deliver fruit that's consistently fresh, flavorful, and farm-to-table fast.
</p>
</div>
</motion.div>
</LayoutContainer>
</section>
{/* Direct Promise */}
<section className="py-24 md:py-32 backdrop-blur-sm bg-white/60 border border-white/40 rounded-3xl p-12 shadow-xl shadow-stone-200/50 relative overflow-hidden">
{/* Subtle decorative elements */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-900/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-emerald-900/5 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="mx-auto max-w-2xl text-center relative"
>
<span className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4 block">Our Promise</span>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 mb-8 leading-tight">
The Word &ldquo;Direct&rdquo; Means Something
</h2>
<div className="space-y-6 text-stone-700 leading-relaxed text-lg">
<p>
Our journey began with a simple passion: growing the finest citrus fruits and delivering them straight from our groves to your table with care, speed, and integrity.
</p>
<p>
We believe in minimizing the time between harvest and delivery, ensuring that our citrus is hand-picked at peak ripeness and arrives to you bursting with flavor and nutrition. This direct-to-you approach means you enjoy fruit that's fresher, juicier, and more vibrant than anything sitting on store shelves.
</p>
</div>
{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
className="mt-14 flex items-center justify-center gap-6 md:gap-12"
>
<div className="text-center">
<p className="text-5xl md:text-6xl font-black text-blue-600">40+</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-stone-500 mt-2">Years</p>
</div>
<div className="h-16 w-px bg-stone-200" />
<div className="text-center">
<p className="text-5xl md:text-6xl font-black text-blue-600">6</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-stone-500 mt-2">States</p>
</div>
<div className="h-16 w-px bg-stone-200" />
<div className="text-center">
<p className="text-5xl md:text-6xl font-black text-blue-600">100%</p>
<p className="text-[11px] font-semibold uppercase tracking-widest text-stone-500 mt-2">Farm-Direct</p>
</div>
</motion.div>
</motion.div>
</LayoutContainer>
</section>
</>
);
}
@@ -0,0 +1,67 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
const TEAM_MEMBERS = [
{ name: "Dan", role: "Owner", description: "Leading Indian River Direct with a vision for farm-to-table freshness" },
{ name: "Angela", role: "Office Manager", description: "Ensuring every order runs smoothly and customers are cared for" },
{ name: "Mariah", role: "Digital Marketing Manager", description: "Connecting our grove with families across the Mid-East" },
{ name: "Kenny", role: "Driver/Sales Associate", description: "Delivering fresh fruit directly to your neighborhood" },
{ name: "Exzavier", role: "Driver/Sales Associate", description: "Bringing the grove to your doorstep with care" },
{ name: "Jaime", role: "Driver/Sales Associate", description: "Your friendly stop coordinator in the field" },
{ name: "Ponce", role: "Driver/Sales Associate", description: "Ensuring quality delivery across the region" },
];
export default function IndianRiverTeamSection() {
return (
<section className="py-24 md:py-32 bg-stone-50 backdrop-blur-sm bg-white/50 relative overflow-hidden">
{/* Subtle decorative elements */}
<div className="absolute inset-0 opacity-20">
<div className="absolute top-0 right-1/4 w-80 h-80 bg-blue-900/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-1/4 w-72 h-72 bg-blue-900/5 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-16 relative"
>
<span className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4 block">
The People
</span>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
Meet the Team
</h2>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{TEAM_MEMBERS.map((member, index) => (
<motion.div
key={member.name}
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{
duration: 0.8,
delay: index * 0.08,
ease: [0.22, 0.61, 0.36, 1],
}}
className="backdrop-blur-md bg-white/80 border border-white/50 rounded-3xl shadow-xl shadow-stone-200/50 p-6 text-center group hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
>
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-600 to-blue-700 mx-auto mb-4 flex items-center justify-center shadow-md group-hover:scale-105 transition-transform duration-300">
<span className="text-2xl font-bold text-white">{member.name[0]}</span>
</div>
<h3 className="font-bold text-stone-950 text-lg">{member.name}</h3>
<p className="text-blue-600 text-sm font-semibold mt-1">{member.role}</p>
<p className="text-stone-500 text-sm mt-3 leading-relaxed">{member.description}</p>
</motion.div>
))}
</div>
</LayoutContainer>
</section>
);
}
@@ -0,0 +1,99 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
// Custom checkmark icon with blue styling
function CustomCheckIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
aria-hidden="true"
>
<circle cx="12" cy="12" r="11" fill="currentColor" opacity="0.1" stroke="currentColor" strokeWidth="1" />
<path
d="M7 13L10 16L17 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
const VALUES = [
{
title: "Farm Ownership",
description: "We own and operate Fort B Groves, giving us complete control over quality from seed to delivery.",
},
{
title: "Hand-Picked Freshness",
description: "Every piece of fruit is hand-picked at peak ripeness, never machine-harvested.",
},
{
title: "Direct to You",
description: "Our trucks deliver straight from the grove, skipping distribution centers and store shelves.",
},
{
title: "Quality Guaranteed",
description: "Each box meets our strict quality standards before it leaves our farm.",
},
];
export default function IndianRiverValuesSection() {
return (
<section className="py-24 md:py-32 bg-white backdrop-blur-sm bg-white/70 relative overflow-hidden">
{/* Subtle decorative elements */}
<div className="absolute inset-0 opacity-20">
<div className="absolute top-1/3 left-0 w-64 h-64 bg-blue-900/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/3 right-0 w-64 h-64 bg-blue-900/5 rounded-full blur-3xl" />
</div>
<LayoutContainer>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="mx-auto max-w-2xl text-center mb-16 relative"
>
<span className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4 block">
Why Choose Us
</span>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
What Sets Us Apart
</h2>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 max-w-4xl mx-auto">
{VALUES.map((value, index) => (
<motion.div
key={value.title}
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{
duration: 0.8,
delay: index * 0.1,
ease: [0.22, 0.61, 0.36, 1],
}}
className="backdrop-blur-md bg-white/70 border border-white/50 rounded-3xl shadow-xl shadow-stone-200/50 p-8 flex items-start gap-5 group hover:shadow-md hover:-translate-y-0.5 transition-all duration-300"
>
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors duration-300">
<CustomCheckIcon />
</div>
<div>
<h3 className="font-bold text-stone-950 text-lg">{value.title}</h3>
<p className="text-stone-600 text-sm mt-2 leading-relaxed">{value.description}</p>
</div>
</motion.div>
))}
</div>
</LayoutContainer>
</section>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { motion } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
export default function MissionSection() {
return (
<section className="py-24 md:py-32 bg-stone-50 relative overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-emerald-100/30 rounded-full blur-3xl -z-10" />
<LayoutContainer>
<div className="mx-auto max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
className="text-center mb-16"
>
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">Our Mission</span>
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
Raising the Best Corn<br />in the World
</h2>
</motion.div>
<div className="grid md:grid-cols-2 gap-12">
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.15, ease: [0.22, 0.61, 0.36, 1] }}
className="prose prose-lg"
>
<p className="text-stone-600 leading-relaxed text-lg">
Tuxedo Corn is the <strong className="text-stone-900">exclusive grower and shipper</strong> of Olathe Sweet Sweet Corn the only corn that carries that prestigious name. When you order from us, you receive the real thing: grown by the only family that planted roots in Olathe, Colorado over forty years ago.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.25, ease: [0.22, 0.61, 0.36, 1] }}
className="prose prose-lg"
>
<p className="text-stone-600 leading-relaxed text-lg">
Not all sweet corn is equal. Olathe Sweet was developed for our high-altitude mountain climate, where <strong className="text-stone-900">intense summer sun by day meets cool mountain air at night</strong>. That combination coaxes extraordinary sweetness and tenderness out of every single ear.
</p>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 48 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, delay: 0.35, ease: [0.22, 0.61, 0.36, 1] }}
className="mt-16 bg-white rounded-3xl p-10 shadow-lg shadow-stone-900/5 ring-1 ring-stone-200/50"
>
<div className="flex items-start gap-6">
<div className="w-14 h-14 rounded-2xl bg-emerald-100 flex items-center justify-center flex-shrink-0">
<svg className="w-7 h-7 text-emerald-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-stone-950 mb-3">Grown with Future Generations in Mind</h3>
<p className="text-stone-600 leading-relaxed">
We farm regeneratively no-till and minimal-till methods protect soil structure, cover crops naturally cycle nutrients, and reduced chemical inputs keep our mountain water pure. Every ear is hand-picked at peak freshness, inspected by our dedicated crew. Not machine-harvested. Not sorted by strangers. <strong className="text-stone-900">Hand-picked.</strong>
</p>
</div>
</div>
</motion.div>
</div>
</LayoutContainer>
</section>
);
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useState, useEffect } from "react";
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
anthropic: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
google: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
};
const PROVIDERS: { id: AIProvider; name: string; icon: string; description: string; website: string }[] = [
{ id: "openai", name: "OpenAI", icon: "🤖", description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard for general AI tasks.", website: "openai.com" },
{ id: "anthropic", name: "Anthropic Claude", icon: "🧠", description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for long-form reasoning and analysis.", website: "anthropic.com" },
{ id: "google", name: "Google Gemini", icon: "🔶", description: "Gemini 2.0 Flash, Gemini 1.5 Pro. Fast, cost-effective, strong multimodal.", website: "ai.google" },
{ id: "xai", name: "xAI Grok", icon: "🌪️", description: "Grok-2, Grok-1.5. Real-time data, humor, unconventional reasoning.", website: "x.ai" },
{ id: "custom", name: "Custom / Other", icon: "🔧", description: "Connect any OpenAI-compatible API or custom LLM endpoint.", website: "" },
];
type Props = {
brandId: string;
};
export default function AIProviderPanel({ brandId }: Props) {
const [provider, setProvider] = useState<AIProvider>("openai");
const [apiKey, setApiKey] = useState("");
const [orgId, setOrgId] = useState("");
const [model, setModel] = useState("gpt-4o-mini");
const [customEndpoint, setCustomEndpoint] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [showKey, setShowKey] = useState(false);
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/integrations/ai-provider?brandId=${brandId}`);
if (res.ok) {
const data = await res.json();
setProvider(data.provider ?? "openai");
setApiKey(data.apiKey ?? "");
setOrgId(data.orgId ?? "");
setModel(data.model ?? "gpt-4o-mini");
setCustomEndpoint(data.customEndpoint ?? "");
}
} catch {}
setLoading(false);
}
load();
}, [brandId]);
async function handleSave() {
setSaving(true);
setTestResult(null);
try {
const res = await fetch("/api/integrations/ai-provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, provider, apiKey, orgId, model, customEndpoint }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Save failed");
setTestResult({ ok: true, message: "AI provider settings saved" });
} catch (err) {
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
}
setSaving(false);
}
async function handleTest() {
setTestResult(null);
setSaving(true);
try {
const res = await fetch("/api/integrations/ai-provider/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Connection failed");
setTestResult({ ok: true, message: data.message ?? "Connection successful" });
} catch (err) {
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
}
setSaving(false);
}
const models = provider === "custom" ? ["gpt-4o-mini"] : (PROVIDER_MODELS[provider] ?? []);
if (loading) {
return (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700 animate-pulse">
<div className="h-6 bg-slate-200 rounded w-1/3 mb-4" />
<div className="h-4 bg-slate-200 rounded w-2/3 mb-6" />
<div className="grid grid-cols-5 gap-3">
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-zinc-950 rounded-xl" />)}
</div>
</div>
);
}
return (
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-violet-200 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-violet-600 to-indigo-600 px-6 py-5 flex items-center gap-4">
<span className="text-2xl">🤖</span>
<div>
<h2 className="text-base font-bold text-white">AI Provider</h2>
<p className="text-xs text-violet-200 mt-0.5">Choose your AI model provider for all AI tools</p>
</div>
<span className="ml-auto text-xs text-violet-200 bg-zinc-900/20 rounded-full px-3 py-1">
{PROVIDERS.find(p => p.id === provider)?.name}
</span>
</div>
<div className="p-6 space-y-6">
{/* Provider selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-3">
Select Provider
</label>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{PROVIDERS.map((p) => (
<button
key={p.id}
onClick={() => {
setProvider(p.id);
setModel(p.id === "custom" ? "gpt-4o-mini" : PROVIDER_MODELS[p.id as Exclude<AIProvider, "custom">]?.[0] ?? "gpt-4o-mini");
}}
className={`rounded-xl p-3 text-center transition-all border-2 ${
provider === p.id
? "border-violet-500 bg-violet-50 shadow-black/20"
: "border-zinc-800 hover:border-violet-200 hover:bg-zinc-800"
}`}
>
<span className="text-2xl block mb-1">{p.icon}</span>
<p className="text-xs font-semibold text-slate-800">{p.name}</p>
{p.website && (
<p className="text-[10px] text-slate-400 mt-0.5">{p.website}</p>
)}
</button>
))}
</div>
</div>
{/* Credentials — only for non-custom */}
{provider !== "custom" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">
API Key {provider === "anthropic" ? "(Anthropic)" : provider === "google" ? "(Google AI)" : provider === "xai" ? "(xAI)" : "(OpenAI)"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : "sk-..."}
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400"
>
{showKey ? "Hide" : "Show"}
</button>
</div>
</div>
{provider === "openai" && (
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Organization ID (optional)</label>
<input
type="text"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
placeholder="org-..."
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
/>
</div>
)}
</div>
)}
{/* Custom endpoint fields */}
{provider === "custom" && (
<div className="space-y-4 rounded-xl bg-slate-50 border border-zinc-800 p-4">
<div className="flex items-start gap-3">
<span className="text-lg mt-0.5">🔧</span>
<div className="flex-1">
<p className="text-sm font-semibold text-zinc-300">Custom API Endpoint</p>
<p className="text-xs text-zinc-500 mt-0.5">Connect any OpenAI-compatible API (Ollama, LM Studio, custom proxy, etc.)</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">API Key</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
/>
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400">
{showKey ? "Hide" : "Show"}
</button>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Base URL</label>
<input
type="text"
value={customEndpoint}
onChange={(e) => setCustomEndpoint(e.target.value)}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
/>
</div>
</div>
</div>
)}
{/* Model selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-2">
Model
</label>
<div className="flex flex-wrap gap-2">
{models.map((m) => (
<button
key={m}
onClick={() => setModel(m)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
model === m
? "bg-violet-600 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{m}
</button>
))}
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-green-900/30 text-green-400 border border-green-200" : "bg-red-900/30 text-red-400 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2 border-t border-slate-100">
<button
onClick={handleTest}
disabled={saving}
className="rounded-xl border border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-xl bg-violet-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-violet-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Provider Settings"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,252 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
type Props = { brandId: string };
const STATUS_LABELS: Record<string, { en: string; es?: string; color: string }> = {
active: { en: "Active", color: "bg-blue-900/40 text-blue-400 border border-blue-800" },
recovered: { en: "Recovered", color: "bg-emerald-900/40 text-emerald-400 border border-emerald-800" },
expired: { en: "Expired", color: "bg-zinc-800 text-zinc-400 border border-zinc-700" },
manually_closed: { en: "Manually Closed", color: "bg-amber-900/40 text-amber-400 border border-amber-800" },
};
const STEP_LABELS: Record<number, string> = {
0: "Detected",
1: "Email 1 (1h)",
2: "Email 2 (24h)",
3: "Email 3 (48h)",
};
function CartItemRow({ item }: { item: { name: string; quantity: number; unit_price: number } }) {
return (
<tr className="border-t border-zinc-800/60">
<td className="px-4 py-2 text-sm text-zinc-200">{item.name}</td>
<td className="px-4 py-2 text-sm text-zinc-400 text-right">{item.quantity}</td>
<td className="px-4 py-2 text-sm text-zinc-400 text-right">${Number(item.unit_price).toFixed(2)}</td>
</tr>
);
}
export default function AbandonedCartDashboard({ brandId }: Props) {
const [carts, setCarts] = useState<AbandonedCart[]>([]);
const [stats, setStats] = useState({ total: 0, recovered: 0, active: 0, expired: 0 });
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<"all" | "active" | "recovered">("all");
const [page, setPage] = useState(0);
const PAGE_SIZE = 25;
const [selectedCart, setSelectedCart] = useState<AbandonedCart | null>(null);
const [closing, setClosing] = useState(false);
const [resending, setResending] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
const result = await getAbandonedCarts(brandId);
if (result.success) {
setCarts(result.carts);
setStats(result.stats);
}
setLoading(false);
}, [brandId]);
const handleClose = async (cartId: string) => {
setClosing(true);
await manuallyCloseAbandonedCart(cartId, brandId);
await load();
setClosing(false);
};
useEffect(() => { setPage(0); }, [filter]);
const filteredCarts = carts.filter(c => {
if (filter === "active") return c.status === "active";
if (filter === "recovered") return c.status === "recovered";
return true;
});
const paginatedCarts = filteredCarts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filteredCarts.length / PAGE_SIZE);
const handleResend = async (cartId: string) => {
setResending(cartId);
await resendAbandonedCartEmail(cartId, brandId);
await load();
setResending(null);
};
const cart = selectedCart;
return (
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-zinc-100" },
{ label: "Active", value: stats.active, color: "text-blue-400" },
{ label: "Recovered", value: stats.recovered, color: "text-emerald-400" },
{ label: "Expired", value: stats.expired, color: "text-zinc-400" },
].map(s => (
<div key={s.label} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button onClick={load} disabled={loading}
className="text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "recovered"] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-violet-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-zinc-500 px-1">{page + 1}/{totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
{/* Carts table */}
{filteredCarts.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl py-16 text-center">
<p className="text-zinc-600 text-sm">No abandoned carts{filter !== "all" ? ` (${filter})` : ""}</p>
</div>
) : (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-500 uppercase tracking-widest border-b border-zinc-800">
<th className="text-left px-5 py-3 font-medium">Contact</th>
<th className="text-left px-5 py-3 font-medium">Items</th>
<th className="text-left px-5 py-3 font-medium">Total</th>
<th className="text-left px-5 py-3 font-medium">Step</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Detected</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{paginatedCarts.map(c => {
const meta = STATUS_LABELS[c.status] ?? STATUS_LABELS.active;
return (
<tr key={c.id} className="border-t border-zinc-800/60 hover:bg-zinc-800/30 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-zinc-200 font-medium text-sm">{c.contact_name ?? "—"}</p>
<p className="text-zinc-500 text-xs">{c.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5 text-zinc-400 text-xs">{c.cart_snapshot.item_count} items</td>
<td className="px-5 py-3.5 text-zinc-200 font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
<td className="px-5 py-3.5">
<span className="text-xs text-zinc-500">{STEP_LABELS[c.sequence_step] ?? c.sequence_step}</span>
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
{meta.en}
</span>
</td>
<td className="px-5 py-3.5 text-zinc-500 text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setSelectedCart(c)}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded-lg hover:bg-zinc-800 transition-all">View</button>
{c.status === "active" && (
<button onClick={() => handleClose(c.id)} disabled={closing}
className="text-xs text-amber-400 hover:text-amber-300 px-2 py-1 rounded-lg hover:bg-amber-900/20 transition-all">Close</button>
)}
{c.status === "active" && (
<button onClick={() => handleResend(c.id)} disabled={resending === c.id}
className="text-xs text-violet-400 hover:text-violet-300 px-2 py-1 rounded-lg hover:bg-violet-900/20 transition-all">
{resending === c.id ? "..." : "Resend"}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Cart detail modal */}
{cart && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={() => setSelectedCart(null)}>
<div className="bg-zinc-900 border border-zinc-700 rounded-2xl w-full max-w-lg shadow-2xl"
onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-100">Abandoned Cart</h3>
<p className="text-xs text-zinc-500">{cart.contact_email}</p>
</div>
<button onClick={() => setSelectedCart(null)} className="text-zinc-500 hover:text-zinc-200 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start gap-3 bg-zinc-800/50 rounded-xl p-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_LABELS[cart.status]?.color ?? ""}`}>
{STATUS_LABELS[cart.status]?.en ?? cart.status}
</span>
<div className="flex-1">
<p className="text-sm text-zinc-200 font-medium">{cart.contact_name ?? "—"}</p>
<p className="text-xs text-zinc-500">{cart.contact_email}</p>
<p className="text-xs text-zinc-600 mt-1">Step: {STEP_LABELS[cart.sequence_step] ?? cart.sequence_step}</p>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">Cart Items</p>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-600 border-b border-zinc-800">
<th className="text-left pb-2 font-medium">Item</th>
<th className="text-right pb-2 font-medium">Qty</th>
<th className="text-right pb-2 font-medium">Price</th>
</tr>
</thead>
<tbody>
{cart.cart_snapshot.items.map((item, i) => (
<CartItemRow key={i} item={item} />
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="pt-2 text-zinc-200 font-semibold" colSpan={2}>Total</td>
<td className="pt-2 text-zinc-200 font-bold text-right">${Number(cart.cart_snapshot.subtotal).toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
{cart.last_email_sent_at && (
<p className="text-xs text-zinc-500">Last email sent: {new Date(cart.last_email_sent_at).toLocaleString()}</p>
)}
{cart.next_email_at && (
<p className="text-xs text-zinc-500">Next email: {new Date(cart.next_email_at).toLocaleString()}</p>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import Link from "next/link";
type AccessDeniedProps = {
message?: string;
};
export default function AdminAccessDenied({
message = "You do not have permission to view this page.",
}: AccessDeniedProps) {
return (
<main className="min-h-screen flex items-center justify-center px-6 py-12 bg-stone-100">
<div className="w-full max-w-md">
<div className="card p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-red-50 border border-red-200">
<svg
className="h-7 w-7 text-red-500"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<h1 className="mt-6 text-xl font-semibold text-stone-950">
Access Denied
</h1>
<p className="mt-2 text-sm text-stone-500">{message}</p>
<Link
href="/admin"
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm"
>
Back to Admin
</Link>
</div>
</div>
</main>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { supabase } from "@/lib/supabase";
type AdminHeaderProps = {
userRole?: string | null;
canManageUsers?: boolean;
routeTraceEnabled?: boolean;
};
type NavLink = {
href: string;
label: string;
external?: boolean;
};
type SettingsItem = {
href: string;
label: string;
description: string;
};
const SETTINGS_ITEMS: SettingsItem[] = [
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
{ href: "/admin/settings/brand", label: "Brand", description: "Brand profile and preferences" },
{ href: "/admin/users", label: "Users & Permissions", description: "Team members and access roles" },
{ href: "/admin/settings/integrations", label: "Integrations", description: "Stripe, Square, Resend, Twilio" },
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence, recovered carts" },
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
];
const BASE_NAV_LINKS: NavLink[] = [
{ href: "/admin/orders", label: "Orders" },
{ href: "/admin/stops", label: "Stops & Routes" },
{ href: "/admin/products", label: "Products" },
{ href: "/admin/time-tracking", label: "Time Tracking" },
{ href: "/admin/communications", label: "Harvest Reach" },
];
const EMPLOYEE_NAV_LINKS: NavLink[] = [
{ href: "/admin/wholesale", label: "Wholesale" },
{ href: "/admin/pickup", label: "Pickup" },
{ href: "/wholesale/employee", label: "Pickup Portal", external: true },
];
export default function AdminHeader({ userRole, canManageUsers, routeTraceEnabled }: AdminHeaderProps) {
const router = useRouter();
const pathname = usePathname();
const isStoreEmployee = userRole === "store_employee";
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
setSettingsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Close dropdown on route change
useEffect(() => {
setSettingsOpen(false);
}, [pathname]);
const moduleLinks: NavLink[] = routeTraceEnabled
? [{ href: "/admin/route-trace", label: "Route Trace" }]
: [];
const fullNavLinks = BASE_NAV_LINKS.concat(
userRole === "platform_admin" ? [{ href: "/admin/command-center", label: "Command Center" }] : []
).concat(moduleLinks);
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") {
return pathname === "/admin/settings" || pathname.startsWith("/admin/settings/");
}
return pathname.startsWith(href);
};
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
<div className="flex items-center h-16">
{/* Logo mark */}
<Link
href={homeHref}
className="flex items-center gap-3 mr-8 text-white hover:text-emerald-400 transition-colors"
>
<div className="relative">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/30">
<svg className="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l.707-.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
<span className="text-sm font-semibold tracking-tight">{homeLabel}</span>
</Link>
<nav className="flex h-full items-center gap-1">
{navLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200",
active
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
{link.label}
</Link>
);
})}
{/* Settings dropdown */}
<div className="relative ml-2" ref={settingsRef}>
<button
onClick={() => setSettingsOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${settingsOpen ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{settingsOpen && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
</nav>
</div>
<div className="flex items-center gap-6">
{roleLabel && (
<div className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/5">
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
</div>
)}
<button
onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
</header>
);
}
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import { markPickupComplete } from "@/actions/pickup";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
payment_processor: string | null;
stops: {
id?: string;
city: string;
state: string;
date: string;
brand_id?: string;
} | null;
order_items?: OrderItem[];
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
brand_id?: string;
};
type AdminOrdersPanelProps = {
initialOrders: Order[];
initialStops: Stop[];
brandId: string | null;
};
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
function formatDate(iso: string | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export { formatDate };
export default function AdminOrdersPanel({
initialOrders,
initialStops,
brandId,
}: AdminOrdersPanelProps) {
const [orders, setOrders] = useState<Order[]>(initialOrders);
const [stops] = useState<Stop[]>(initialStops);
const [search, setSearch] = useState("");
const [stopFilter, setStopFilter] = useState("");
const [pickingUp, setPickingUp] = useState<string | null>(null);
const [squareFilter, setSquareFilter] = useState<"all" | "square" | "other">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "picked_up">("all");
const [page, setPage] = useState(0);
const [pickupToast, setPickupToast] = useState<string | null>(null);
const PAGE_SIZE = 50;
async function handleMarkPickup(orderId: string) {
setPickingUp(orderId);
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
setPickupToast(orderId);
setTimeout(() => setPickupToast(null), 3000);
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? {
...o,
pickup_complete: true,
pickup_completed_at: result.pickup_completed_at,
pickup_completed_by: result.pickup_completed_by,
}
: o
)
);
}
setPickingUp(null);
}
const filtered = orders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !stopFilter || o.stop_id === stopFilter;
const matchesSquare =
squareFilter === "all" ||
(squareFilter === "square" && o.payment_processor === "square") ||
(squareFilter === "other" && o.payment_processor !== "square");
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "pending" && !o.pickup_complete) ||
(statusFilter === "picked_up" && o.pickup_complete);
return matchesSearch && matchesStop && matchesSquare && matchesStatus;
});
const totalFiltered = filtered.length;
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(totalFiltered / PAGE_SIZE);
const pendingCount = filtered.filter((o) => !o.pickup_complete).length;
const pickedUpCount = filtered.filter((o) => o.pickup_complete).length;
return (
<div className="bg-transparent">
{/* Header */}
<div className="border-b border-stone-200 bg-white px-6 py-5">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Orders</h1>
<p className="mt-0.5 text-sm text-stone-500">
{pendingCount} pending
{pickedUpCount > 0 && ` · ${pickedUpCount} picked up`}
{brandId && (
<span className="ml-2 text-xs text-stone-400">(brand-scoped)</span>
)}
</p>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-6 py-4">
{/* Status filter + pagination */}
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "pending", "picked_up"] as const).map((f) => (
<button
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "pending" ? "Pending" : "Picked Up"}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Previous page"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-2 text-xs text-stone-500">{page + 1} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Next page"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* Search + Stop + Square filter */}
<div className="mb-4 flex gap-3 flex-wrap">
<input
type="search"
placeholder="Search name, phone, order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 font-mono transition-colors"
/>
<select
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors"
>
<option value="">All Stops</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>
{s.city}, {s.state} · {formatDate(s.date)}
</option>
))}
</select>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "square", "other"] as const).map((f) => (
<button
key={f}
onClick={() => setSquareFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
squareFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "square" ? "Square" : "Other"}
</button>
))}
</div>
</div>
{/* Table */}
{paginated.length === 0 ? (
<div className="rounded-lg bg-white border border-stone-200 py-12 text-center text-sm text-stone-500 shadow-sm">
No orders found
</div>
) : (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50 text-left">
<th className="px-4 py-3 font-semibold text-stone-500">Order</th>
<th className="px-4 py-3 font-semibold text-stone-500">Customer</th>
<th className="px-4 py-3 font-semibold text-stone-500 hidden md:table-cell">Stop</th>
<th className="px-4 py-3 font-semibold text-stone-500 hidden lg:table-cell">Date</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-center">Items</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-right">Total</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-center">Status</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((order) => (
<OrderRow
key={order.id}
order={order}
onMarkPickup={handleMarkPickup}
pickingUp={pickingUp}
shortId={shortId(order.id)}
showToast={pickupToast === order.id}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
type OrderRowProps = {
order: Order;
onMarkPickup: (orderId: string) => void;
pickingUp: string | null;
shortId: string;
showToast?: boolean;
};
function OrderRow({ order, onMarkPickup, pickingUp, shortId, showToast }: OrderRowProps) {
const itemCount = order.order_items?.length ?? 0;
return (
<tr className="border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors">
{/* Order ID */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-stone-500">{shortId}</span>
</td>
{/* Customer */}
<td className="px-4 py-3">
<div className="font-medium text-stone-900">{order.customer_name}</div>
{order.customer_phone && (
<div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>
)}
</td>
{/* Stop */}
<td className="px-4 py-3 hidden md:table-cell">
{order.stops ? (
<span className="text-stone-600">{order.stops.city}, {order.stops.state}</span>
) : (
<span className="text-stone-300"></span>
)}
</td>
{/* Date */}
<td className="px-4 py-3 hidden lg:table-cell">
<span className="font-mono text-stone-500">{formatDate(order.created_at)}</span>
</td>
{/* Items */}
<td className="px-4 py-3 text-center">
<span className="font-mono text-stone-600">{itemCount}</span>
</td>
{/* Total */}
<td className="px-4 py-3 text-right">
<span className="font-mono font-semibold text-stone-900">
${Number(order.subtotal).toFixed(2)}
</span>
</td>
{/* Status */}
<td className="px-4 py-3 text-center">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
order.pickup_complete
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-amber-100 text-amber-700 border border-amber-200"
}`}
>
{order.pickup_complete ? "Picked Up" : "Pending"}
</span>
{order.payment_processor === "square" && (
<span className="ml-1.5 inline-block rounded-full bg-purple-100 border border-purple-200 px-1.5 py-0.5 text-xs font-semibold text-purple-700">
Square
</span>
)}
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<a
href={`/admin/orders/${order.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
View / Edit
</a>
{!order.pickup_complete && (
<button
onClick={() => onMarkPickup(order.id)}
disabled={pickingUp === order.id}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 transition-colors shadow-sm"
>
{pickingUp === order.id ? "..." : "Pick Up"}
</button>
)}
{showToast && (
<div className="fixed bottom-6 right-6 z-50 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-xs font-semibold text-emerald-700 shadow-lg">
Done
</div>
)}
</div>
</td>
</tr>
);
}
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
// Warm earth-tone dark sidebar design
// Colors: stone-900 bg, stone-300 text, emerald accent
const TOP_LINKS = [
{ href: "/admin", label: "Dashboard", icon: "grid" },
{ href: "/admin/orders", label: "Orders", icon: "shopping-cart" },
{ href: "/admin/stops", label: "Stops & Routes", icon: "map-pin" },
{ href: "/admin/products", label: "Products", icon: "package" },
{ href: "/admin/route-trace", label: "Route Trace", icon: "clipboard" },
{ href: "/admin/time-tracking", label: "Time Tracking", icon: "clock" },
{ href: "/admin/communications", label: "Communications", icon: "mail" },
];
const SETTINGS_SUB_LINKS = [
{ href: "/admin/settings", label: "General" },
{ href: "/admin/settings#workers", label: "Workers & PINs" },
{ href: "/admin/settings#tasks", label: "Tasks" },
{ href: "/admin/settings#users", label: "Users & Permissions" },
{ href: "/admin/settings#integrations", label: "Integrations" },
];
function GridIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25v-2.25z" />
</svg>
);
}
function CartIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
</svg>
);
}
function MapPinIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
);
}
function PackageIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
);
}
function ClipboardIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
);
}
function ClockIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
function MailIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
);
}
function SettingsCogIcon({ open }: { open: boolean }) {
return (
<svg className={`w-4 h-4 transition-transform ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
}
function HamburgerIcon() {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
const ICON_MAP: Record<string, React.ReactNode> = {
grid: <GridIcon />,
"shopping-cart": <CartIcon />,
"map-pin": <MapPinIcon />,
package: <PackageIcon />,
clipboard: <ClipboardIcon />,
clock: <ClockIcon />,
mail: <MailIcon />,
};
type SidebarProps = {
userRole?: string | null;
};
export default function AdminSidebar({ userRole }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") return pathname === "/admin/settings" || pathname.startsWith("/admin/settings");
return pathname.startsWith(href);
};
const isSettingsPage = pathname.startsWith("/admin/settings");
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
setMobileOpen(false);
router.push("/login");
router.refresh();
}
return (
<>
{/* Mobile hamburger button */}
<button
onClick={() => setMobileOpen(true)}
className="fixed top-4 left-4 z-50 lg:hidden"
aria-label="Open sidebar"
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-md border border-stone-200">
<HamburgerIcon />
</div>
</button>
{/* Mobile overlay backdrop */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Sidebar panel - Warm earth-tone dark */}
<aside className={[
"fixed top-0 left-0 h-full w-60 z-50",
"bg-stone-900 border-r border-stone-800",
"flex flex-col transition-transform duration-300 lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
].join(" ")}>
{/* Logo row */}
<div className="flex items-center h-16 px-5 border-b border-stone-800 flex-shrink-0">
<Link
href="/admin"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 text-stone-100 hover:text-emerald-400 transition-colors"
>
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 shadow-sm">
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold tracking-tight">Admin</span>
<a
href="/"
onClick={(e) => e.stopPropagation()}
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2"
>
Back to Site
</a>
</div>
</Link>
</div>
{/* Nav links */}
<nav className="flex-1 overflow-y-auto px-3 py-5 space-y-1">
{TOP_LINKS.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={[
"group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
active
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
].join(" ")}
>
<span className={[
"flex-shrink-0 transition-colors",
active ? "text-emerald-400" : "text-stone-500 group-hover:text-stone-300"
].join(" ")}>
{ICON_MAP[item.icon]}
</span>
{item.label}
{active && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</Link>
);
})}
{/* Settings collapsible section */}
<div className="pt-4 pb-1">
<div className="h-px bg-stone-800 mb-4" />
<button
onClick={() => setSettingsOpen(v => !v)}
className={[
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
isSettingsPage
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
].join(" ")}
>
<span className="flex items-center gap-3">
<span className={isSettingsPage ? "text-emerald-400" : "text-stone-500"}>
<SettingsCogIcon open={settingsOpen} />
</span>
Settings
</span>
<ChevronIcon open={settingsOpen} />
</button>
{settingsOpen && (
<div className="mt-2 ml-2 space-y-0.5 border-l border-stone-700 pl-3">
{SETTINGS_SUB_LINKS.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={[
"flex items-center px-3 py-2 rounded-lg text-sm transition-all duration-200",
active
? "bg-emerald-900/20 text-emerald-400"
: "text-stone-400 hover:text-stone-200 hover:bg-stone-800/50",
].join(" ")}
>
{item.label}
</Link>
);
})}
</div>
)}
</div>
</nav>
{/* Bottom: role + sign out */}
<div className="px-4 py-5 border-t border-stone-800 flex-shrink-0">
{roleLabel && (
<div className="px-3 py-2 mb-3 rounded-lg bg-stone-800 border border-stone-700">
<p className="text-[10px] text-stone-500 font-medium uppercase tracking-widest mb-0.5">{roleLabel}</p>
<p className="text-xs text-stone-400">Signed in</p>
</div>
)}
<button
onClick={handleLogout}
className="w-full px-3 py-2 rounded-lg text-sm font-medium text-stone-400 hover:text-stone-200 hover:bg-stone-800 transition-all border border-transparent"
>
Sign out
</button>
</div>
</aside>
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
import { toggleBrandFeature } from "@/actions/settings/features";
type Props = {
brandId: string;
initialEnabledFeatures: Record<string, boolean>;
};
export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: Props) {
const router = useRouter();
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
const [toggling, setToggling] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 3000);
};
const handleToggle = async (key: string) => {
const newEnabled = !enabledFeatures[key];
setToggling(key);
setEnabledFeatures((prev) => ({ ...prev, [key]: newEnabled }));
const result = await toggleBrandFeature(brandId, key as BrandFeatureKey, newEnabled);
if (result.success) {
showToast(newEnabled ? `${ADDON_CATALOG[key as BrandFeatureKey].name} enabled` : `${ADDON_CATALOG[key as BrandFeatureKey].name} disabled`);
router.refresh();
} else {
setEnabledFeatures((prev) => ({ ...prev, [key]: !newEnabled }));
showToast(`Error: ${result.error}`);
}
setToggling(null);
};
const keys = Object.keys(ADDON_CATALOG) as BrandFeatureKey[];
return (
<>
{toast && (
<div className="fixed bottom-6 right-6 z-50 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white shadow-lg">
{toast}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{keys.map((key) => {
const addon = ADDON_CATALOG[key];
const enabled = !!enabledFeatures[key];
const busy = toggling === key;
return (
<div
key={key}
className={`rounded-2xl p-6 shadow-black/20 ring-1 ${
enabled
? "bg-gradient-to-br from-white to-green-50 ring-green-200"
: "bg-zinc-900 ring-stone-200"
}`}
>
<div className="flex items-start gap-4">
<span className="text-3xl mt-0.5">{addon.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-zinc-100">{addon.name}</h3>
<span
className={`text-xs font-medium rounded-full px-2 py-0.5 ${
enabled
? "bg-green-900/40 text-green-400"
: "bg-stone-100 text-stone-500"
}`}
>
{enabled ? "Enabled" : "Disabled"}
</span>
</div>
<p className="text-sm text-stone-700 leading-relaxed">
{addon.description}
</p>
{addon.addOnPrice && !enabled && (
<p className="mt-2 text-xs font-medium text-amber-400">
Requires: {addon.addOnPrice}
</p>
)}
</div>
</div>
<div className="mt-5 flex items-center gap-3 justify-end">
{enabled && (
<Link
href={addon.adminRoute}
className="rounded-xl border border-zinc-800 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-zinc-950 transition-colors"
>
Open Module
</Link>
)}
<button
onClick={() => handleToggle(key)}
disabled={busy}
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
enabled
? "border border-red-200 text-red-400 hover:bg-red-900/30"
: "bg-green-600 text-white hover:bg-green-700"
}`}
>
{busy ? "..." : enabled ? "Disable" : "Enable"}
</button>
</div>
</div>
);
})}
</div>
</>
);
}
+989
View File
@@ -0,0 +1,989 @@
"use client";
import NextImage from "next/image";
import { useState, useEffect, useRef } from "react";
import {
getBrandSettings,
saveBrandSettings,
uploadBrandLogo,
uploadOlatheSweetLogo,
uploadOlatheSweetLogoDark,
type BrandSettings,
} from "@/actions/brand-settings";
type Props = {
settings: BrandSettings | null;
brandId: string;
brandName: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
};
export default function BrandSettingsForm({
settings,
brandId: initialBrandId,
brandName: initialBrandName,
brands = [],
isPlatformAdmin = false,
}: Props) {
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
const [activeBrandName, setActiveBrandName] = useState(initialBrandName);
const [legalBusinessName, setLegalBusinessName] = useState(settings?.legal_business_name ?? "");
const [phone, setPhone] = useState(settings?.phone ?? "");
const [email, setEmail] = useState(settings?.email ?? "");
const [websiteUrl, setWebsiteUrl] = useState(settings?.website_url ?? "");
const [streetAddress, setStreetAddress] = useState(settings?.street_address ?? "");
const [city, setCity] = useState(settings?.city ?? "");
const [state, setState] = useState(settings?.state ?? "");
const [postalCode, setPostalCode] = useState(settings?.postal_code ?? "");
const [country, setCountry] = useState(settings?.country ?? "US");
const [logoUrl, setLogoUrl] = useState(settings?.logo_url ?? "");
const [logoUrlDark, setLogoUrlDark] = useState(settings?.logo_url_dark ?? "");
const [olatheSweetLogoUrl, setOlatheSweetLogoUrl] = useState(settings?.olathe_sweet_logo_url ?? "");
const [olatheSweetLogoUrlDark, setOlatheSweetLogoUrlDark] = useState(settings?.olathe_sweet_logo_url_dark ?? "");
const [defaultEmailSignature, setDefaultEmailSignature] = useState(
settings?.default_email_signature ?? ""
);
const [invoiceFooterNotes, setInvoiceFooterNotes] = useState(
settings?.invoice_footer_notes ?? ""
);
// Storefront customization fields
const [heroTagline, setHeroTagline] = useState(settings?.hero_tagline ?? "");
const [aboutHeadline, setAboutHeadline] = useState(settings?.about_headline ?? "");
const [aboutSubheadline, setAboutSubheadline] = useState(settings?.about_subheadline ?? "");
const [customFooterText, setCustomFooterText] = useState(settings?.custom_footer_text ?? "");
const [showWholesaleLink, setShowWholesaleLink] = useState(settings?.show_wholesale_link ?? true);
const [showZipSearch, setShowZipSearch] = useState(settings?.show_zip_search ?? true);
const [showSchedulePdf, setShowSchedulePdf] = useState(settings?.show_schedule_pdf ?? true);
const [showTextAlerts, setShowTextAlerts] = useState(settings?.show_text_alerts ?? false);
const [schedulePdfNotes, setSchedulePdfNotes] = useState(settings?.schedule_pdf_notes ?? "");
const [heroImageUrl, setHeroImageUrl] = useState(settings?.hero_image_url ?? "");
const [brandPrimaryColor, setBrandPrimaryColor] = useState(settings?.brand_primary_color ?? "#16a34a");
const [brandSecondaryColor, setBrandSecondaryColor] = useState(settings?.brand_secondary_color ?? "#f5f5f4");
const [brandBgColor, setBrandBgColor] = useState(settings?.brand_bg_color ?? "#fafaf9");
const [brandTextColor, setBrandTextColor] = useState(settings?.brand_text_color ?? "#1c1917");
// Tax settings
const [collectSalesTax, setCollectSalesTax] = useState(settings?.collect_sales_tax ?? false);
const [nexusStates, setNexusStates] = useState<string[]>(settings?.nexus_states ?? ["CO"]);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Reload settings when brand changes (platform admin)
useEffect(() => {
if (!isPlatformAdmin) return;
setLoading(true);
getBrandSettings(activeBrandId).then((result) => {
setLoading(false);
if (result.success && result.settings) {
const s = result.settings;
setLegalBusinessName(s.legal_business_name ?? "");
setPhone(s.phone ?? "");
setEmail(s.email ?? "");
setWebsiteUrl(s.website_url ?? "");
setStreetAddress(s.street_address ?? "");
setCity(s.city ?? "");
setState(s.state ?? "");
setPostalCode(s.postal_code ?? "");
setCountry(s.country ?? "US");
setLogoUrl(s.logo_url ?? "");
setLogoUrlDark(s.logo_url_dark ?? "");
setOlatheSweetLogoUrl(s.olathe_sweet_logo_url ?? "");
setOlatheSweetLogoUrlDark(s.olathe_sweet_logo_url_dark ?? "");
setDefaultEmailSignature(s.default_email_signature ?? "");
setInvoiceFooterNotes(s.invoice_footer_notes ?? "");
setHeroTagline(s.hero_tagline ?? "");
setAboutHeadline(s.about_headline ?? "");
setAboutSubheadline(s.about_subheadline ?? "");
setCustomFooterText(s.custom_footer_text ?? "");
setShowWholesaleLink(s.show_wholesale_link ?? true);
setShowZipSearch(s.show_zip_search ?? true);
setShowSchedulePdf(s.show_schedule_pdf ?? true);
setShowTextAlerts(s.show_text_alerts ?? false);
setSchedulePdfNotes(s.schedule_pdf_notes ?? "");
setHeroImageUrl(s.hero_image_url ?? "");
setBrandPrimaryColor(s.brand_primary_color ?? "#16a34a");
setBrandSecondaryColor(s.brand_secondary_color ?? "#f5f5f4");
setBrandBgColor(s.brand_bg_color ?? "#fafaf9");
setBrandTextColor(s.brand_text_color ?? "#1c1917");
setCollectSalesTax(s.collect_sales_tax ?? false);
setNexusStates(s.nexus_states ?? ["CO"]);
setActiveBrandName(s.brand_name ?? "");
} else {
setLegalBusinessName("");
setPhone("");
setEmail("");
setWebsiteUrl("");
setStreetAddress("");
setCity("");
setState("");
setPostalCode("");
setCountry("US");
setLogoUrl("");
setLogoUrlDark("");
setOlatheSweetLogoUrl("");
setOlatheSweetLogoUrlDark("");
setDefaultEmailSignature("");
setInvoiceFooterNotes("");
setHeroTagline("");
setAboutHeadline("");
setAboutSubheadline("");
setCustomFooterText("");
setShowWholesaleLink(true);
setShowZipSearch(true);
setShowSchedulePdf(true);
setShowTextAlerts(false);
setSchedulePdfNotes("");
setHeroImageUrl("");
setBrandPrimaryColor("#16a34a");
setBrandSecondaryColor("#f5f5f4");
setBrandBgColor("#fafaf9");
setBrandTextColor("#1c1917");
setCollectSalesTax(false);
setNexusStates(["CO"]);
}
});
}, [activeBrandId, isPlatformAdmin]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
const result = await saveBrandSettings({
brandId: activeBrandId,
legalBusinessName: legalBusinessName || undefined,
phone: phone || undefined,
email: email || undefined,
websiteUrl: websiteUrl || undefined,
streetAddress: streetAddress || undefined,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
logoUrl: logoUrl || undefined,
logoUrlDark: logoUrlDark || undefined,
olatheSweetLogoUrl: olatheSweetLogoUrl || undefined,
olatheSweetLogoUrlDark: olatheSweetLogoUrlDark || undefined,
defaultEmailSignature: defaultEmailSignature || undefined,
invoiceFooterNotes: invoiceFooterNotes || undefined,
heroTagline: heroTagline || undefined,
aboutHeadline: aboutHeadline || undefined,
aboutSubheadline: aboutSubheadline || undefined,
customFooterText: customFooterText || undefined,
showWholesaleLink: showWholesaleLink,
showZipSearch: showZipSearch,
showSchedulePdf: showSchedulePdf,
showTextAlerts: showTextAlerts,
schedulePdfNotes: schedulePdfNotes || undefined,
heroImageUrl: heroImageUrl || undefined,
brandPrimaryColor: brandPrimaryColor || undefined,
brandSecondaryColor: brandSecondaryColor || undefined,
brandBgColor: brandBgColor || undefined,
brandTextColor: brandTextColor || undefined,
collectSalesTax,
nexusStates,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-emerald-500"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
<p className="mt-1 text-xs text-zinc-500">
Viewing <strong className="text-zinc-300">{activeBrandName}</strong>
</p>
</div>
)}
{error && (
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
)}
{saved && (
<div className="rounded-xl bg-green-950/50 border border-green-800 p-4 text-sm text-green-300">
Brand settings saved.
</div>
)}
{/* Company Info */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Company Information</h3>
<p className="mt-1 text-sm text-zinc-400">
Primary contact and business details for this brand.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Legal Business Name</label>
<input
type="text"
value={legalBusinessName}
onChange={(e) => setLegalBusinessName(e.target.value)}
placeholder="Tuxedo Corn LLC"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(772) 555-0100"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="orders@tuxedocorn.com"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Website</label>
<input
type="url"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
placeholder="https://tuxedocorn.com"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</div>
</div>
{/* Address */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Business Address</h3>
<p className="mt-1 text-sm text-zinc-400">
Used on invoices and shipping labels.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
<input
type="text"
value={streetAddress}
onChange={(e) => setStreetAddress(e.target.value)}
placeholder="1234 Farm Road"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-300">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Stuart"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="FL"
maxLength={2}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm uppercase text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">ZIP / Postal Code</label>
<input
type="text"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="34994"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Country</label>
<select
value={country}
onChange={(e) => setCountry(e.target.value)}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
</div>
</div>
</div>
{/* Brand Logos */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Brand Logos</h3>
<p className="mt-1 text-sm text-zinc-400">
Upload your brand logos to Supabase Storage. Recommended: 1200×400px max, 5MB max (ideally under 2MB for faster loading).
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Primary Logo */}
<LogoUploadField
label="Primary Brand Logo"
hint="Light backgrounds — invoices, email headers, storefront header"
value={logoUrl}
previewBg="bg-zinc-800"
brandId={activeBrandId}
onUpload={(url) => setLogoUrl(url)}
/>
{/* Dark Logo */}
<LogoUploadField
label="Dark Background Logo"
hint="Dark backgrounds — storefront nav on dark header"
value={logoUrlDark}
previewBg="bg-zinc-900"
brandId={activeBrandId}
onUpload={(url) => setLogoUrlDark(url)}
isDark
/>
</div>
{/* Olathe Sweet Logos — two variants */}
<div className="mt-2">
<p className="text-sm font-semibold text-zinc-200 mb-4">Olathe Sweet Logo</p>
<p className="text-xs text-zinc-400 mb-4">
The mountain &amp; wagon wheel mark use the light version on dark backgrounds, dark/white version on light backgrounds. Shown in hero, About page, and product cards.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<LogoUploadField
label="Olathe Sweet™ — Dark/White"
hint="Works on light backgrounds (stone, white)"
value={olatheSweetLogoUrl}
previewBg="bg-zinc-700"
brandId={activeBrandId}
onUpload={(url) => setOlatheSweetLogoUrl(url)}
isOlatheSweetLight
/>
<LogoUploadField
label="Olathe Sweet™ — Transparent"
hint="Works on dark backgrounds (hero, dark sections)"
value={olatheSweetLogoUrlDark}
previewBg="bg-zinc-900"
brandId={activeBrandId}
onUpload={(url) => setOlatheSweetLogoUrlDark(url)}
isOlatheSweetDark
/>
</div>
</div>
</div>
{/* Email & Invoice branding */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Email & Invoice Branding</h3>
<p className="mt-1 text-sm text-zinc-400">
Used in email signatures, invoice footers, and customer-facing documents.
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">
Default Email Signature
</label>
<textarea
value={defaultEmailSignature}
onChange={(e) => setDefaultEmailSignature(e.target.value)}
rows={3}
placeholder="The Tuxedo Corn Team&#10;orders@tuxedocorn.com | (772) 555-0100&#10;Fresh from Florida since 1987"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Appears at the bottom of all outbound emails. Supports line breaks.
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">
Invoice Footer Notes
</label>
<textarea
value={invoiceFooterNotes}
onChange={(e) => setInvoiceFooterNotes(e.target.value)}
rows={2}
placeholder="Thank you for your order! Pickup available at our farm stand.&#10;Questions? Call (772) 555-0100 or email orders@tuxedocorn.com"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Printed at the bottom of invoices and order confirmations.
</p>
</div>
</div>
{/* Storefront Customization */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Storefront Customization</h3>
<p className="mt-1 text-sm text-zinc-400">
Controls what appears on your public storefront homepage.
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Tagline</label>
<input
type="text"
value={heroTagline}
onChange={(e) => setHeroTagline(e.target.value)}
placeholder="Fresh citrus delivered to stops near you."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Subtitle below the brand name in the hero section.</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Image URL</label>
<input
type="url"
value={heroImageUrl}
onChange={(e) => setHeroImageUrl(e.target.value)}
placeholder="https://cdn.example.com/hero-corn-field.jpg"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Full-width hero background image. Recommended: 1600×600px, under 1MB. Paste a public image URL.
For Tuxedo Corn: corn field photo. For IRD: citrus orchard.
</p>
{heroImageUrl && (
<div className="relative mt-2 rounded-lg overflow-hidden border border-zinc-700 h-32">
<NextImage src={heroImageUrl} alt="Hero preview" fill style={{ objectFit: "cover" }} />
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Headline</label>
<input
type="text"
value={aboutHeadline}
onChange={(e) => setAboutHeadline(e.target.value)}
placeholder="Our Story"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Subheadline</label>
<input
type="text"
value={aboutSubheadline}
onChange={(e) => setAboutSubheadline(e.target.value)}
placeholder="Growing citrus in the Indian River region since 1985."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Custom Footer Text</label>
<textarea
value={customFooterText}
onChange={(e) => setCustomFooterText(e.target.value)}
rows={2}
placeholder="Fresh from Florida — Ships Sept through May"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Appears above the copyright line in the brand footer.</p>
</div>
</div>
{/* Brand Colors */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Brand Colors</h3>
<p className="mt-1 text-sm text-zinc-400">
Customize your storefront accent and background colors. Use hex codes (e.g. #e11d48).
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#16a34a"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#1c1917"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Headlines, body text</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#fafaf9"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#f5f5f4"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Hover states, highlights</p>
</div>
</div>
<div className="mt-3 p-4 rounded-xl bg-zinc-800 border border-zinc-700">
<p className="text-xs text-zinc-400 mb-2 font-medium">Preview</p>
<div className="flex gap-3 items-center flex-wrap">
<div className="flex gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold" style={{ backgroundColor: brandPrimaryColor }}>
A
</div>
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold border border-zinc-600" style={{ backgroundColor: brandBgColor, color: brandTextColor }}>
A
</div>
<div className="w-8 h-8 rounded-lg border border-zinc-600" style={{ backgroundColor: brandSecondaryColor }} />
</div>
<div className="h-8 w-px bg-zinc-600" />
<div className="flex gap-1.5">
<div className="px-3 py-1.5 rounded-lg text-white text-xs font-semibold" style={{ backgroundColor: brandPrimaryColor }}>
Button
</div>
<div className="px-3 py-1.5 rounded-lg text-xs font-semibold border" style={{ borderColor: brandPrimaryColor, color: brandPrimaryColor }}>
Outline
</div>
<div className="px-3 py-1.5 rounded-lg text-xs font-semibold" style={{ backgroundColor: brandSecondaryColor, color: brandTextColor }}>
Secondary
</div>
</div>
</div>
</div>
</div>
{/* Tax Settings */}
<div className="space-y-5 rounded-xl border border-amber-700 bg-amber-950/30 p-5">
<div>
<h3 className="font-semibold text-amber-200">Tax Settings</h3>
<p className="mt-1 text-sm text-amber-300/70">
Configure automatic sales tax collection via Stripe Tax. Tax is only calculated for ship orders shipping to your nexus states.
</p>
</div>
<FeatureToggle
label="Collect Sales Tax"
description="Automatically calculate and collect sales tax for ship orders via Stripe Tax."
checked={collectSalesTax}
onChange={setCollectSalesTax}
/>
{collectSalesTax && (
<div>
<label className="block text-sm font-medium text-amber-200 mb-1">
Nexus States
</label>
<p className="mt-1 text-xs text-amber-300/60 mb-2">
States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states.
</p>
<NexusStateInput
value={nexusStates}
onChange={setNexusStates}
/>
</div>
)}
</div>
{/* Feature Toggles */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Feature Toggles</h3>
<p className="mt-1 text-sm text-zinc-400">
Show or hide features on your public storefront.
</p>
</div>
<div className="space-y-4">
<FeatureToggle
label="Wholesale Portal link"
description="Show the green Wholesale Portal banner on the homepage and nav link."
checked={showWholesaleLink}
onChange={setShowWholesaleLink}
/>
<FeatureToggle
label="Zip Code Search"
description="Show the ZIP code search box on the IRD homepage."
checked={showZipSearch}
onChange={setShowZipSearch}
/>
<FeatureToggle
label="Schedule PDF Download"
description="Show the 'Download Schedule PDF' button on the stops section."
checked={showSchedulePdf}
onChange={setShowSchedulePdf}
/>
<FeatureToggle
label="Text Alerts Signup"
description="Show a text alert signup section on the homepage."
checked={showTextAlerts}
onChange={setShowTextAlerts}
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Schedule PDF Footer Notes</label>
<textarea
value={schedulePdfNotes}
onChange={(e) => setSchedulePdfNotes(e.target.value)}
rows={1}
placeholder="All orders prepaid. No refunds on unpicked orders."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Printed at the bottom of the generated schedule PDF.</p>
</div>
</div>
{loading ? (
<div className="flex items-center gap-3 text-zinc-400">
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
Loading settings...
</div>
) : (
<button
type="submit"
disabled={saving}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white hover:bg-blue-900/300 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Brand Settings"}
</button>
)}
</form>
);
}
// ── Feature Toggle Component ─────────────────────────────────────────────────
function FeatureToggle({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (val: boolean) => void;
}) {
return (
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
checked ? "bg-green-600" : "bg-zinc-600"
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-zinc-900 transition-transform ${
checked ? "translate-x-4" : "translate-x-1"
}`}
/>
</button>
<div>
<p className="text-sm font-medium text-zinc-200">{label}</p>
<p className="text-xs text-zinc-400">{description}</p>
</div>
</div>
);
}
// ── Nexus States Input Component ──────────────────────────────────────────────
const US_STATES = [
"AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA",
"HI","ID","IL","IN","IA","KS","KY","LA","ME","MD",
"MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ",
"NM","NY","NC","ND","OH","OK","OR","PA","RI","SC",
"SD","TN","TX","UT","VT","VA","WA","WV","WI","WY",
];
function NexusStateInput({
value,
onChange,
}: {
value: string[];
onChange: (v: string[]) => void;
}) {
const [input, setInput] = useState("");
function addState(code: string) {
const upper = code.toUpperCase().trim();
if (US_STATES.includes(upper) && !value.includes(upper)) {
onChange([...value, upper]);
}
setInput("");
}
function removeState(code: string) {
onChange(value.filter((s) => s !== code));
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (input.trim()) addState(input);
}
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{value.map((code) => (
<span
key={code}
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 text-amber-050 px-2 py-1 text-xs font-semibold"
>
{code}
<button
type="button"
onClick={() => removeState(code)}
className="hover:text-amber-200"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type state code (e.g. CO) and press Enter"
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 uppercase"
maxLength={2}
/>
<div className="relative">
<select
value=""
onChange={(e) => { if (e.target.value) addState(e.target.value); }}
className="h-[42px] rounded-xl border border-zinc-600 bg-zinc-800 px-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
>
<option value="">Add state</option>
{US_STATES.filter((s) => !value.includes(s)).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
</div>
);
}
// ── Logo Upload Component ────────────────────────────────────────────────────
type LogoUploadFieldProps = {
label: string;
hint: string;
value: string;
previewBg: string;
brandId: string;
onUpload: (url: string) => void;
isDark?: boolean;
isOlatheSweetLight?: boolean;
isOlatheSweetDark?: boolean;
};
export function LogoUploadField({
label,
hint,
value,
previewBg,
brandId,
onUpload,
isDark = false,
isOlatheSweetLight = false,
isOlatheSweetDark = false,
}: LogoUploadFieldProps) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
setError(null);
setUploading(true);
let result;
if (isOlatheSweetLight) {
result = await uploadOlatheSweetLogo(brandId, file);
} else if (isOlatheSweetDark) {
result = await uploadOlatheSweetLogoDark(brandId, file);
} else {
result = await uploadBrandLogo(brandId, file, isDark);
}
setUploading(false);
if (result.success) {
onUpload(result.logoUrl);
} else {
setError(result.error ?? "Upload failed");
}
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
return (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">{label}</label>
<p
className="text-xs text-zinc-500 mb-2"
dangerouslySetInnerHTML={{ __html: hint }}
/>
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`
relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-600 hover:border-zinc-500 hover:bg-zinc-800/50"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-400">Uploading...</span>
</>
) : value ? (
<>
<span className="relative inline-block h-10 w-auto">
<NextImage src={value} alt={label} fill style={{ objectFit: "contain" }} />
</span>
<span className="text-xs text-zinc-400">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-zinc-400">Drag & drop or click to upload</span>
<span className="text-xs text-zinc-500">PNG, JPEG, WebP · 1200×400px max · max 5MB (ideally under 2MB)</span>
</>
)}
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={handleChange}
/>
</div>
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
{value && (
<div className={`mt-3 rounded-lg p-3 text-center ${previewBg}`}>
<p className="text-xs text-zinc-400 mb-2">Preview</p>
<span className="relative inline-block h-10 w-auto">
<NextImage src={value} alt={label} fill style={{ objectFit: "contain" }} className="mx-auto" />
</span>
</div>
)}
</div>
);
}
+498
View File
@@ -0,0 +1,498 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Campaign, CampaignType, CampaignStatus, AudienceRules } from "@/actions/communications/campaigns";
import { formatDate } from "@/lib/format-date";
import type { Template } from "@/actions/communications/templates";
import type { AudiencePreviewResult } from "@/actions/communications/send";
import { previewCampaignAudience, sendCampaign } from "@/actions/communications/send";
import { upsertCampaign, deleteCampaign } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getCommunicationSegments, type Segment } from "@/actions/communications/segments";
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
{ value: "operational", label: "Operational" },
{ value: "marketing", label: "Marketing" },
{ value: "transactional", label: "Transactional" },
];
const STATUS_COLORS: Record<CampaignStatus, string> = {
draft: "bg-zinc-950 text-zinc-300",
scheduled: "bg-blue-900/40 text-blue-700",
sending: "bg-yellow-100 text-yellow-700",
sent: "bg-green-900/40 text-green-400",
canceled: "bg-red-900/40 text-red-400",
};
export default function CampaignListPanel({ initialCampaigns }: { initialCampaigns: Campaign[] }) {
const router = useRouter();
const [campaigns, setCampaigns] = useState(initialCampaigns);
const [filterType, setFilterType] = useState<CampaignType | "all">("all");
const [filterStatus, setFilterStatus] = useState<CampaignStatus | "all">("all");
const filtered = campaigns.filter((c) => {
if (filterType !== "all" && c.campaign_type !== filterType) return false;
if (filterStatus !== "all" && c.status !== filterStatus) return false;
return true;
});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Campaigns</h2>
<p className="text-sm text-zinc-500">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
</div>
<a
href="/admin/communications/campaigns/new"
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
+ New Campaign
</a>
</div>
<div className="flex gap-4 mb-4">
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterType}
onChange={(e) => setFilterType(e.target.value as CampaignType | "all")}
>
<option value="all">All Types</option>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as CampaignStatus | "all")}
>
<option value="all">All Statuses</option>
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="sent">Sent</option>
<option value="canceled">Canceled</option>
</select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">No campaigns found</div>
) : (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Created</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((c) => (
<tr key={c.id} className="hover:bg-zinc-800 cursor-pointer" onClick={() => router.push(`/admin/communications/campaigns/${c.id}`)}>
<td className="px-4 py-3 font-medium text-zinc-100">{c.name}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
c.campaign_type === "operational" ? "bg-purple-100 text-purple-700" :
c.campaign_type === "marketing" ? "bg-orange-100 text-orange-700" :
"bg-zinc-950 text-zinc-400"
}`}>
{c.campaign_type}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[c.status]}`}>
{c.status}
</span>
</td>
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(c.created_at))}</td>
<td className="px-4 py-3 text-zinc-500">{c.sent_at ? formatDate(new Date(c.sent_at)) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Campaign Edit Panel ──────────────────────────────────────────────────────
export function CampaignEditPanel({
campaign,
templates,
mode,
brandId,
}: {
campaign?: Campaign;
templates: Template[];
mode: "edit" | "new";
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [sending, setSending] = useState(false);
const [name, setName] = useState(campaign?.name ?? "");
const [subject, setSubject] = useState(campaign?.subject ?? "");
const [bodyText, setBodyText] = useState(campaign?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(campaign?.body_html ?? "");
const [templateId, setTemplateId] = useState(campaign?.template_id ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(campaign?.campaign_type ?? "operational");
const [audienceTarget, setAudienceTarget] = useState<string>(campaign?.audience_rules?.target ?? "stop");
const [stopId, setStopId] = useState(campaign?.audience_rules?.stop_id ?? "");
const [dateFrom, setDateFrom] = useState(campaign?.audience_rules?.date_from ?? "");
const [dateTo, setDateTo] = useState(campaign?.audience_rules?.date_to ?? "");
const [scheduleMode, setScheduleMode] = useState<"now" | "later">(
campaign?.scheduled_at ? "later" : "now"
);
const [scheduledAt, setScheduledAt] = useState(
campaign?.scheduled_at
? new Date(campaign.scheduled_at).toISOString().slice(0, 16)
: ""
);
const [segments, setSegments] = useState<Segment[]>([]);
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [preview, setPreview] = useState<AudiencePreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [error, setError] = useState("");
// Load saved segments
useEffect(() => {
getCommunicationSegments(brandId).then((result) => {
if (result.success) setSegments(result.segments);
});
}, []);
// When a segment is selected, populate audience fields
function applySegment(segmentId: string) {
setSelectedSegmentId(segmentId);
if (!segmentId) return;
const seg = segments.find((s) => s.id === segmentId);
if (!seg) return;
const rules = seg.rules;
if (rules.target) setAudienceTarget(rules.target);
if (rules.stop_id) setStopId(rules.stop_id);
if (rules.date_from) setDateFrom(rules.date_from);
if (rules.date_to) setDateTo(rules.date_to);
}
const loadPreview = async () => {
setPreviewLoading(true);
const rules: AudienceRules = {
target: audienceTarget as AudienceRules["target"],
stop_id: stopId || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
};
const result = await previewCampaignAudience(brandId, rules);
setPreview(result);
setPreviewLoading(false);
};
const applyTemplate = (t: Template) => {
setSubject(t.subject);
setBodyText(t.body_text);
setBodyHtml(t.body_html ?? "");
setCampaignType((t.campaign_type as CampaignType) ?? "operational");
};
const handleSave = async () => {
setSaving(true);
setError("");
const rules: AudienceRules = {
target: audienceTarget as AudienceRules["target"],
stop_id: stopId || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
};
const effectiveScheduledAt =
scheduleMode === "later" && scheduledAt
? new Date(scheduledAt).toISOString()
: null;
const result = await upsertCampaign({
id: campaign?.id,
brand_id: brandId,
name,
subject,
body_text: bodyText,
body_html: bodyHtml || undefined,
template_id: templateId || undefined,
campaign_type: campaignType,
status: scheduleMode === "later" ? "scheduled" : (campaign?.status ?? "draft"),
audience_rules: rules,
scheduled_at: effectiveScheduledAt ?? undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
router.push("/admin/communications");
router.refresh();
};
const handleSend = async () => {
setSending(true);
setError("");
if (!campaign?.id) {
setError("Save as draft first");
setSending(false);
return;
}
const result = await sendCampaign(campaign.id, brandId);
setSending(false);
if (!result.success) {
setError(result.error ?? "Failed to send");
return;
}
router.push("/admin/communications");
router.refresh();
};
return (
<div className="max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-zinc-100">
{mode === "new" ? "New Campaign" : "Edit Campaign"}
</h2>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
<div className="space-y-6">
{/* Basic info */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="e.g. Tuxedo Stop 5/15 Reminder"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type *</label>
<select
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template</label>
<select
value={templateId}
onChange={(e) => {
setTemplateId(e.target.value);
const t = templates.find((t) => t.id === e.target.value);
if (t) applyTemplate(t);
}}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
<option value="">No template</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name} ({t.template_type})</option>
))}
</select>
</div>
</div>
</div>
{/* Audience builder */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Audience</h3>
{segments.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Saved segment:</span>
<select
value={selectedSegmentId}
onChange={(e) => applySegment(e.target.value)}
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
>
<option value=""> None </option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Target By</label>
<select
value={audienceTarget}
onChange={(e) => {
setAudienceTarget(e.target.value);
setSelectedSegmentId("");
}}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
<option value="stop">Stop / Date Range</option>
<option value="zip_code">ZIP Code</option>
<option value="customer_history">Customer History</option>
<option value="all_customers">All Customers</option>
</select>
</div>
{audienceTarget === "stop" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Stop ID</label>
<input
type="text"
value={stopId}
onChange={(e) => setStopId(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="UUID"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">From</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">To</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
)}
<button
type="button"
onClick={loadPreview}
disabled={previewLoading}
className="text-sm text-blue-400 hover:text-blue-700 font-medium"
>
{previewLoading ? "Counting..." : "Preview audience count"}
</button>
{preview && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm">
<span className="font-semibold text-blue-700">{preview.count}</span> customers match this audience
{preview.sample_customers.length > 0 && (
<div className="mt-2 text-xs text-blue-400">
Sample: {preview.sample_customers.slice(0, 5).map((c) => c.email).join(", ")}
</div>
)}
</div>
)}
</div>
{/* Message content */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-800">Message Content</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Email subject line"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text)</label>
<textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={6}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Message content..."
/>
</div>
</div>
{/* Scheduling */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-800">Scheduling</h3>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={scheduleMode === "now"}
onChange={() => setScheduleMode("now")}
className="text-blue-400"
/>
<span className="text-zinc-300">Send immediately</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={scheduleMode === "later"}
onChange={() => setScheduleMode("later")}
className="text-blue-400"
/>
<span className="text-zinc-300">Schedule for later</span>
</label>
</div>
{scheduleMode === "later" && (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Send date & time</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2 text-sm"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={handleSave}
disabled={saving || !name || (scheduleMode === "later" && !scheduledAt)}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : scheduleMode === "later" ? "Schedule Campaign" : "Save Draft"}
</button>
{campaign?.id && campaign.status === "draft" && scheduleMode === "now" && (
<button
type="button"
onClick={handleSend}
disabled={sending}
className="inline-flex items-center rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{sending ? "Sending..." : "Send Campaign"}
</button>
)}
{campaign?.id && scheduleMode === "later" && (
<span className="text-sm text-blue-400 font-medium">
Scheduled for {scheduledAt ? new Date(scheduledAt).toLocaleString() : "—"}
</span>
)}
<a href="/admin/communications" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
Cancel
</a>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { useCart } from "@/context/CartContext";
type Props = { userId: string };
export default function CartHydration({ userId }: Props) {
const { loadServerCart } = useCart();
useEffect(() => {
if (!userId) return;
loadServerCart(userId).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
return null;
}
@@ -0,0 +1,621 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getPlatformMetrics,
getPlatformActivityFeed,
getBrandHealthSnapshot,
getPainLog,
resolvePainLogItem,
createPainLogItem,
type PlatformMetrics,
type ActivityEvent,
type BrandHealth,
type PainLogItem,
} from "@/actions/platform/command-center";
// ── Constants ─────────────────────────────────────────────────────────────────
const REFRESH_INTERVAL = 30000;
const EVENT_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
order_placed: { bg: "bg-violet-500/20", text: "text-violet-300", dot: "bg-violet-400" },
order_completed: { bg: "bg-emerald-500/20", text: "text-emerald-300", dot: "bg-emerald-400" },
pickup_completed: { bg: "bg-blue-900/300/20", text: "text-blue-300", dot: "bg-blue-400" },
payment_failed: { bg: "bg-red-900/300/20", text: "text-red-300", dot: "bg-red-400" },
stop_created: { bg: "bg-amber-900/300/20", text: "text-amber-300", dot: "bg-amber-400" },
route_updated: { bg: "bg-cyan-500/20", text: "text-cyan-300", dot: "bg-cyan-400" },
default: { bg: "bg-zinc-900/10", text: "text-white/60", dot: "bg-zinc-900/40" },
};
const SEVERITY_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
critical: { bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/30" },
high: { bg: "bg-orange-500/15",text: "text-orange-300", border: "border-orange-500/30" },
medium: { bg: "bg-amber-900/300/15",text: "text-amber-300", border: "border-amber-500/30" },
low: { bg: "bg-zinc-900/8", text: "text-white/60", border: "border-white/10" },
};
const HEALTH_CONFIG: Record<string, { label: string; bg: string; text: string; border: string; dot: string }> = {
healthy: { label: "Operational", bg: "bg-emerald-500/15", text: "text-emerald-300", border: "border-emerald-500/25", dot: "bg-emerald-400" },
warning: { label: "Attention", bg: "bg-amber-900/300/15", text: "text-amber-300", border: "border-amber-500/25", dot: "bg-amber-400" },
critical: { label: "Critical", bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/25", dot: "bg-red-400" },
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatCurrency(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
minimumFractionDigits: 0, maximumFractionDigits: 0,
}).format(n);
}
function formatCurrencyFull(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(n);
}
function timeAgo(dateStr: string): string {
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400)return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
// ── Metric Card ─────────────────────────────────────────────────────────────────
function MetricCard({
label,
value,
sub,
prefix,
suffix,
status,
delay = 0,
tv = false,
}: {
label: string;
value: string | number;
sub?: string;
prefix?: string;
suffix?: string;
status?: "healthy" | "warning" | "critical";
delay?: number;
tv?: boolean;
}) {
const valueColor = {
healthy: "text-white",
warning: "text-amber-300",
critical: "text-red-300",
}[status ?? "healthy"]!;
return (
<div
className={`
rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5
${status === "critical" ? "border-red-500/20" : ""}
`}
>
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] text-white/25`}>{label}</p>
<div className="mt-3 flex items-baseline gap-2">
{prefix && <span className="text-2xl text-white/25">{prefix}</span>}
<span className={`${tv ? "text-5xl" : "text-4xl"} font-black tracking-tighter leading-none ${valueColor}`}>
{typeof value === "number" ? value.toLocaleString() : value}
</span>
{suffix && <span className="text-2xl text-white/25">{suffix}</span>}
</div>
{sub && <p className={`text-xs mt-2 text-white/20`}>{sub}</p>}
</div>
);
}
// ── Brand Health Card ─────────────────────────────────────────────────────────
function BrandCard({ brand, tv = false }: { brand: BrandHealth; tv?: boolean }) {
const c = HEALTH_CONFIG[brand.health_status as keyof typeof HEALTH_CONFIG] ?? HEALTH_CONFIG.warning;
return (
<div className={`rounded-2xl border ${c.border} ${c.bg} p-6`}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className={`font-bold text-white truncate leading-tight ${tv ? "text-2xl" : "text-lg"}`}>{brand.brand_name}</p>
<p className={`text-sm text-white/20 mt-1`}>{brand.brand_slug}</p>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className={`rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
{c.label}
</span>
<div className={`h-2 w-2 rounded-full ${c.dot}`} />
</div>
</div>
<div className={`mt-6 grid grid-cols-3 gap-6`}>
{[
{ label: "Orders", value: brand.orders_today },
{ label: "Revenue", value: formatCurrency(Number(brand.revenue_today)) },
{ label: "Routes", value: brand.active_stops },
].map(({ label, value }) => (
<div key={label} className="text-center">
<p className={`text-xs font-medium text-white/20`}>{label}</p>
<p className={`${tv ? "text-2xl" : "text-xl"} font-black text-white mt-1 leading-none`}>{value}</p>
</div>
))}
</div>
{brand.open_pain_items > 0 && (
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-900/300/10 border border-red-500/20 px-3 py-2.5">
<span className="h-2 w-2 rounded-full bg-red-400" />
<span className={`text-sm font-semibold text-red-300`}>
{brand.open_pain_items} open issue{brand.open_pain_items !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
);
}
// ── Activity Item ──────────────────────────────────────────────────────────────
function ActivityItem({ event }: { event: ActivityEvent }) {
const c = EVENT_COLORS[event.event_type] ?? EVENT_COLORS.default;
const label = event.event_type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
return (
<div className="flex items-start gap-3.5 py-3.5 border-b border-white/[0.03] last:border-0">
<div className="mt-1.5 flex-shrink-0">
<span className={`block h-2 w-2 rounded-full ${c.dot}`} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`rounded-md px-2 py-0.5 text-xs font-semibold ${c.bg} ${c.text}`}>
{label}
</span>
{event.brand_name && (
<span className={`text-xs font-medium text-white/25`}>{event.brand_name}</span>
)}
</div>
<div className="mt-1 flex items-center gap-3">
<p className={`text-sm text-white/40 truncate`}>
{event.entity_type && <span>{event.entity_type}</span>}
{event.entity_id && <span className="font-mono text-white/15 ml-1">#{event.entity_id.slice(0, 6)}</span>}
</p>
<span className={`text-xs text-white/15 shrink-0`}>
{event.created_at ? timeAgo(event.created_at) : ""}
</span>
</div>
</div>
</div>
);
}
// ── Pain Item ─────────────────────────────────────────────────────────────────
function PainItem({
item,
onResolve,
onSnooze,
}: {
item: PainLogItem;
onResolve: (id: string) => void;
onSnooze: (id: string) => void;
}) {
const c = SEVERITY_CONFIG[item.severity] ?? SEVERITY_CONFIG.low;
return (
<div className={`rounded-xl border ${c.border} ${c.bg} p-4`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 flex-wrap min-w-0">
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
{item.severity}
</span>
<span className={`text-sm font-semibold text-white/80 leading-tight`}>{item.title}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => onSnooze(item.id)}
className="rounded-lg bg-zinc-900/5 border border-white/10 px-2.5 py-1 text-xs font-medium text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
>
Snooze
</button>
<button
onClick={() => onResolve(item.id)}
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 transition-all"
>
Done
</button>
</div>
</div>
<div className={`mt-2 flex items-center gap-2 text-xs text-white/25`}>
{item.brand_name && <span>{item.brand_name}</span>}
<span>·</span>
<span>{item.category}</span>
<span>·</span>
<span>{item.created_at ? timeAgo(item.created_at) + " ago" : ""}</span>
</div>
</div>
);
}
// ── AI Briefing ────────────────────────────────────────────────────────────────
function AIBriefing({ metrics, brandHealth }: {
metrics: PlatformMetrics | null;
brandHealth: BrandHealth[];
}) {
const lines: string[] = [];
if (!metrics) return <div className="h-16 animate-pulse bg-zinc-900/[0.04] rounded-xl" />;
if (metrics.orders_today > 0) {
lines.push(`${metrics.orders_today} order${metrics.orders_today !== 1 ? "s" : ""} placed today.`);
}
if (Number(metrics.revenue_today) > 0) {
lines.push(`${formatCurrencyFull(Number(metrics.revenue_today))} in revenue processed.`);
}
if (metrics.failed_orders_today > 0) {
lines.push(`${metrics.failed_orders_today} failed order${metrics.failed_orders_today !== 1 ? "s" : ""} need attention.`);
}
if (metrics.pending_orders_today > 0) {
lines.push(`${metrics.pending_orders_today} order${metrics.pending_orders_today !== 1 ? "s" : ""} pending confirmation.`);
}
const crit = brandHealth.filter(b => b.health_status === "critical").length;
if (crit > 0) lines.push(`${crit} brand${crit !== 1 ? "s" : ""} in critical state.`);
const warn = brandHealth.filter(b => b.health_status === "warning").length;
if (warn > 0 && crit === 0) lines.push(`${warn} brand${warn !== 1 ? "s" : ""} need attention.`);
if (lines.length === 0) {
lines.push("Platform is running smoothly — no active issues.");
}
return (
<div className="space-y-2">
{lines.map((line, i) => (
<p key={i} className="text-sm text-white/50 leading-relaxed">
{line}
</p>
))}
</div>
);
}
// ── Main ─────────────────────────────────────────────────────────────────────
export default function CommandCenterDashboard() {
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [brandHealth, setBrandHealth] = useState<BrandHealth[]>([]);
const [painLog, setPainLog] = useState<PainLogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [resolving, setResolving] = useState<Set<string>>(new Set());
const [showAddForm, setShowAddForm] = useState(false);
const [tvMode, setTvMode] = useState(false);
const [addForm, setAddForm] = useState({ severity: "medium", category: "operations", title: "", description: "" });
const [snoozed, setSnoozed] = useState<Set<string>>(new Set());
const tv = tvMode;
const fetchAll = useCallback(async () => {
try {
const [m, a, b, p] = await Promise.all([
getPlatformMetrics(),
getPlatformActivityFeed(),
getBrandHealthSnapshot(),
getPainLog(),
]);
setMetrics(m);
setActivity(a);
setBrandHealth(b);
setPainLog(p);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
fetchAll();
const iv = setInterval(fetchAll, REFRESH_INTERVAL);
return () => clearInterval(iv);
}, [fetchAll]);
const handleResolve = async (id: string) => {
setResolving(prev => new Set([...prev, id]));
try {
const r = await resolvePainLogItem(id);
if (r.success) setPainLog(prev => prev.filter(i => i.id !== id));
} finally {
setResolving(prev => { const n = new Set(prev); n.delete(id); return n; });
}
};
const handleSnooze = (id: string) => {
setSnoozed(prev => new Set([...prev, id]));
setTimeout(() => {
setSnoozed(prev => { const n = new Set(prev); n.delete(id); return n; });
}, 3600000);
};
const handleAdd = async () => {
if (!addForm.title.trim()) return;
const r = await createPainLogItem({
severity: addForm.severity as "low" | "medium" | "high" | "critical",
category: addForm.category,
title: addForm.title,
description: addForm.description || null,
});
if (r.success) {
setAddForm({ severity: "medium", category: "operations", title: "", description: "" });
setShowAddForm(false);
fetchAll();
}
};
const openItems = painLog.filter(p => p.status === "open" && !snoozed.has(p.id));
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-6">
<div className="h-14 w-14 rounded-full border-2 border-white/[0.08] border-t-white/25 animate-spin" />
<p className="text-xs text-white/25 tracking-widest uppercase">Initializing</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center max-w-md">
<div className="mb-5 text-5xl"></div>
<h3 className="text-xl font-bold text-white">Connection Lost</h3>
<p className="text-sm text-white/35 mt-2">{error}</p>
<button
onClick={fetchAll}
className="mt-8 rounded-2xl bg-zinc-900/10 border border-white/20 px-8 py-3 text-sm font-semibold text-white hover:bg-zinc-900/20 transition-all"
>
Reconnect
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div className="mb-10 flex items-center justify-between px-8">
<div>
<h1 className={`${tv ? "text-4xl" : "text-3xl"} font-black tracking-tight text-white`}>
Command Center
</h1>
<p className={`${tv ? "text-base" : "text-sm"} text-white/25 mt-2 tracking-wide`}>
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
{" · "}
<span className="text-emerald-400/50 font-semibold">LIVE</span>
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={fetchAll}
className="flex items-center gap-2.5 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2 text-sm text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<button
onClick={() => setTvMode(v => !v)}
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all ${
tv
? "bg-zinc-900/15 border border-white/25 text-white/80"
: "bg-zinc-900/5 border border-white/10 text-white/35 hover:text-white/60"
}`}
>
{tv ? "Exit TV" : "TV Mode"}
</button>
</div>
</div>
{/* ── KPI Row ──────────────────────────────────────────────────────────── */}
<div className={`grid ${tv ? "grid-cols-3" : "grid-cols-2 lg:grid-cols-6"} gap-5 px-8 mb-12`}>
<MetricCard label="Active Brands" value={metrics?.active_brands ?? 0} status="healthy" />
<MetricCard label="Orders Today" value={metrics?.orders_today ?? 0} status={metrics?.failed_orders_today ? "critical" : "healthy"} />
<MetricCard label="Revenue Today" value={formatCurrency(Number(metrics?.revenue_today ?? 0))} status="healthy" />
<MetricCard label="Active Routes" value={metrics?.active_routes ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
<MetricCard label="Failed Orders" value={metrics?.failed_orders_today ?? 0} status={(metrics?.failed_orders_today ?? 0) > 0 ? "critical" : "healthy"} />
<MetricCard label="Pending Orders" value={metrics?.pending_orders_today ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
</div>
{/* ── AI Briefing (full width) ──────────────────────────────────────── */}
<div className="px-8 mb-10">
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-8">
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-white/[0.04]">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-violet-500/15 border border-violet-500/20">
<span className="text-lg">🤖</span>
</div>
<div>
<h2 className="text-xs font-bold uppercase tracking-[0.22em] text-white/40">Daily Briefing</h2>
<p className="text-xs text-white/20 mt-0.5">Platform intelligence summary</p>
</div>
</div>
<AIBriefing metrics={metrics} brandHealth={brandHealth} />
</div>
</div>
{/* ── 2-Column Body ───────────────────────────────────────────────────── */}
<div className={`grid gap-10 px-8 ${tv ? "grid-cols-12" : "grid-cols-1 lg:grid-cols-2"}`}>
{/* ── LEFT: Brand Health + Activity ───────────────────────────── */}
<div className="space-y-10">
{/* Brand Health */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Brand Health
</h2>
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
{brandHealth.length}
</span>
</div>
<div className="space-y-5">
{brandHealth.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
<p className="text-sm text-white/25">No active brands</p>
</div>
) : (
brandHealth.map((b) => (
<BrandCard key={b.brand_id} brand={b} tv={tv} />
))
)}
</div>
</div>
{/* Activity Feed */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Live Activity
</h2>
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
{activity.length}
</span>
</div>
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl overflow-hidden">
<div className="max-h-[420px] overflow-y-auto p-5">
{activity.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-white/20">No recent events</p>
</div>
) : (
activity.slice(0, 30).map((e) => (
<ActivityItem key={e.id} event={e} />
))
)}
</div>
</div>
</div>
</div>
{/* ── RIGHT: Founder Queue ─────────────────────────────── */}
<div className="space-y-10">
<div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Founder Queue
</h2>
{openItems.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-900/300/20 text-xs font-black text-amber-300">
{openItems.length}
</span>
)}
</div>
<button
onClick={() => setShowAddForm(v => !v)}
className="text-xs text-white/25 hover:text-white/50 transition-colors"
>
+ Add
</button>
</div>
{showAddForm && (
<div className="mb-5 space-y-2.5 rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5">
<select
value={addForm.severity}
onChange={e => setAddForm(f => ({ ...f, severity: e.target.value }))}
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 outline-none focus:border-white/20"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<input
value={addForm.title}
onChange={e => setAddForm(f => ({ ...f, title: e.target.value }))}
placeholder="Issue title..."
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<input
value={addForm.category}
onChange={e => setAddForm(f => ({ ...f, category: e.target.value }))}
placeholder="Category (e.g. deployment, payment)"
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<textarea
value={addForm.description}
onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))}
placeholder="Description (optional)"
rows={2}
className="w-full resize-none rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<div className="flex gap-2.5">
<button onClick={handleAdd} className="flex-1 rounded-xl bg-emerald-500/20 border border-emerald-500/30 px-4 py-2.5 text-sm font-semibold text-emerald-300 hover:bg-emerald-500/30 transition-all">
Log Issue
</button>
<button onClick={() => setShowAddForm(false)} className="flex-1 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2.5 text-sm font-medium text-white/35 hover:bg-zinc-900/10 transition-all">
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{openItems.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
<p className="text-base font-semibold text-white/35">All clear</p>
<p className="text-sm text-white/20 mt-1.5">Add an issue to track it here.</p>
</div>
) : (
openItems.map((item) => (
<PainItem
key={item.id}
item={item}
onResolve={handleResolve}
onSnooze={handleSnooze}
/>
))
)}
</div>
</div>
{/* Quick Access */}
<div>
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25 mb-5">
Quick Access
</h2>
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-4 space-y-1">
{[
{ label: "Reports", href: "/admin/reports" },
{ label: "Orders", href: "/admin/orders" },
{ label: "Stops", href: "/admin/stops" },
{ label: "Settings", href: "/admin/settings" },
].map(link => (
<a
key={link.href}
href={link.href}
className="flex items-center justify-between rounded-xl px-4 py-3.5 text-sm text-white/30 hover:bg-zinc-900/5 hover:text-white/70 transition-all"
>
<span className="font-medium">{link.label}</span>
<span className="text-white/10"></span>
</a>
))}
</div>
</div>
</div>
</div>
<div className="h-24" />
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { CommunicationSettings } from "@/actions/communications/settings";
import { upsertCommunicationSettings } from "@/actions/communications/settings";
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function CommunicationSettingsForm({
settings,
brandId,
}: {
settings: CommunicationSettings | null;
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [senderEmail, setSenderEmail] = useState(settings?.default_sender_email ?? "");
const [senderName, setSenderName] = useState(settings?.default_sender_name ?? "");
const [replyTo, setReplyTo] = useState(settings?.reply_to_email ?? "");
const [footerHtml, setFooterHtml] = useState(settings?.email_footer_html ?? "");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleSave = async () => {
setSaving(true);
setError("");
setSuccess(false);
const result = await upsertCommunicationSettings({
brand_id: brandId,
sender_email: senderEmail,
sender_name: senderName,
reply_to_email: replyTo,
provider: "resend",
footer_html: footerHtml,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setSuccess(true);
router.refresh();
};
return (
<div className="max-w-2xl">
<div className="mb-6">
<h2 className="text-xl font-semibold text-zinc-100">Sender Settings</h2>
<p className="text-sm text-zinc-500 mt-1">
Configure the default sender identity for this brand&apos;s email campaigns.
Provider (Resend) is configured via environment variables no credentials stored here.
</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{success && (
<div className="mb-4 rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm text-green-400">
Settings saved successfully.
</div>
)}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Email</label>
<input
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="orders@tuxedocorn.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Name</label>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Route Commerce"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Reply-To Email</label>
<input
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="support@tuxedocorn.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Email Footer HTML (optional)</label>
<textarea
value={footerHtml}
onChange={(e) => setFooterHtml(e.target.value)}
rows={3}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
placeholder="<p>Unsubscribe: <a href='...'>link</a></p>"
/>
<p className="mt-1 text-xs text-slate-400">Include your unsubscribe link here. Add {`{unsubscribe_url}`} as placeholder.</p>
</div>
</div>
<div className="mt-4">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
const TABS = [
{ id: "campaigns", label: "Campaigns", href: "/admin/communications" },
{ id: "compose", label: "Compose", href: "/admin/communications/compose" },
{ id: "segments", label: "Segments", href: "/admin/communications/segments" },
{ id: "analytics", label: "Analytics", href: "/admin/communications/analytics" },
{ id: "templates", label: "Templates", href: "/admin/communications/templates" },
{ id: "contacts", label: "Contacts", href: "/admin/communications/contacts" },
{ id: "logs", label: "Message Logs", href: "/admin/communications/logs" },
{ id: "settings", label: "Settings", href: "/admin/communications/settings" },
];
export default function CommunicationsNav({
activeTab,
}: {
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
}) {
return (
<div className="border-b border-zinc-800 mb-6">
<nav className="flex gap-1 -mb-px">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={tab.href}
className={`px-1 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isActive
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300 hover:border-zinc-600"
}`}
>
{tab.label}
</a>
);
})}
</nav>
</div>
);
}
export { TABS };
+115
View File
@@ -0,0 +1,115 @@
"use client";
import CommunicationsNav from "./CommunicationsNav";
import CampaignListPanel, { CampaignEditPanel } from "./CampaignListPanel";
import { TemplateListPanel, TemplateEditForm } from "./TemplateEditForm";
import MessageLogPanel from "./MessageLogPanel";
import CommunicationSettingsForm from "./CommunicationSettingsForm";
import ContactListPanel from "./ContactListPanel";
import ContactImportForm from "./ContactImportForm";
import SegmentBuilderPage from "@/components/admin/HarvestReach/SegmentBuilderPage";
import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboard";
import CampaignComposerPage from "@/components/admin/HarvestReach/CampaignComposerPage";
import type { Campaign } from "@/actions/communications/campaigns";
import type { Template } from "@/actions/communications/templates";
import type { MessageLogEntry } from "@/actions/communications/send";
import type { CommunicationSettings } from "@/actions/communications/settings";
import type { Contact } from "@/actions/communications/contacts";
import type { Segment } from "@/actions/harvest-reach/segments";
import type { CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
export default function CommunicationsPage({
campaigns,
templates,
activeTab,
brandId,
editCampaign,
editMode,
editTemplate,
initialLogs = [],
initialSettings = null,
initialContacts = [],
initialContactTotal = 0,
initialSegments = [],
initialAnalytics = [],
editCampaignId,
}: {
campaigns: Campaign[];
templates: Template[];
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
brandId: string;
editCampaign?: Campaign | null;
editMode?: "edit" | "new";
editTemplate?: Template | null;
initialLogs?: MessageLogEntry[];
initialSettings?: CommunicationSettings | null;
initialContacts?: Contact[];
initialContactTotal?: number;
initialSegments?: Segment[];
initialAnalytics?: CampaignAnalytics[];
editCampaignId?: string;
}) {
return (
<div>
<CommunicationsNav activeTab={activeTab} />
{activeTab === "campaigns" && (
editCampaign !== undefined || editMode === "new" ? (
<CampaignEditPanel
campaign={editCampaign ?? undefined}
templates={templates}
mode={editMode ?? "edit"}
brandId={brandId}
/>
) : (
<CampaignListPanel initialCampaigns={campaigns} />
)
)}
{activeTab === "templates" && (
editTemplate !== undefined || editMode === "new" ? (
<TemplateEditForm
template={editTemplate ?? undefined}
mode={editMode ?? "edit"}
brandId={brandId}
/>
) : (
<TemplateListPanel templates={templates} />
)
)}
{activeTab === "contacts" && (
<div className="space-y-6">
<ContactListPanel initialContacts={initialContacts} initialTotal={initialContactTotal} brandId={brandId} />
<ContactImportForm brandId={brandId} />
</div>
)}
{activeTab === "logs" && (
<MessageLogPanel initialLogs={initialLogs} />
)}
{activeTab === "settings" && (
<CommunicationSettingsForm settings={initialSettings} brandId={brandId} />
)}
{activeTab === "segments" && (
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
)}
{activeTab === "analytics" && (
<AnalyticsDashboard analytics={initialAnalytics} />
)}
{(activeTab === "compose" || (activeTab === "campaigns" && editCampaignId)) && (
<CampaignComposerPage
brandId={brandId}
campaigns={campaigns}
templates={templates}
segments={initialSegments}
editCampaignId={editCampaignId}
/>
)}
</div>
);
}
+621
View File
@@ -0,0 +1,621 @@
"use client";
import { useState, useRef, useCallback } from "react";
import {
importContactsBatch,
previewContactImport,
type ContactImportEntry,
type ImportPreviewResult,
type ColumnMapping,
type ImportField,
} from "@/actions/communications/contacts";
type Step = "idle" | "preview" | "confirming" | "importing" | "result";
type ImportResult = {
created: number;
updated: number;
skipped: number;
errors: { row: ContactImportEntry; error: string }[];
};
const LARGE_FILE_ROWS = 10_000;
const FIELD_LABELS: Record<string, string> = {
email: "Email",
phone: "Phone",
first_name: "First Name",
last_name: "Last Name",
full_name: "Full Name",
tags: "Tags",
email_opt_in: "Email Opt-In",
sms_opt_in: "SMS Opt-In",
external_id: "External ID",
};
export default function ContactImportForm({ brandId }: { brandId: string }) {
const [step, setStep] = useState<Step>("idle");
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ImportPreviewResult | null>(null);
const [rawHeaders, setRawHeaders] = useState<string[]>([]);
const [rawRows, setRawRows] = useState<string[][]>([]);
const [importRows, setImportRows] = useState<ContactImportEntry[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [globalError, setGlobalError] = useState("");
const [overridingMappings, setOverridingMappings] = useState<
Record<string, ImportField>
>({});
const fileRef = useRef<HTMLInputElement>(null);
// ── CSV parsing + preview ──────────────────────────────────────────────────
const handleFile = useCallback(
async (f: File) => {
setFile(f);
setResult(null);
setGlobalError("");
setOverridingMappings({});
const text = await f.text();
const previewResult = await previewContactImport(text);
if (!previewResult.success) {
setGlobalError(previewResult.error);
setStep("idle");
return;
}
const { csv, totalRows } = await import("@/lib/csv-parser").then(
(m) => m.parseCSVWithLimits(text)
);
setRawHeaders(csv.headers);
setRawRows(csv.rows);
setPreview(previewResult.preview);
setStep("preview");
},
[]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".csv")) handleFile(f);
else setGlobalError("Please upload a .csv file");
},
[handleFile]
);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
},
[handleFile]
);
// ── Mapping override ───────────────────────────────────────────────────────
function applyMappings(
mappings: ColumnMapping[],
overrides: Record<string, ImportField>,
headers: string[],
rows: string[][]
): ContactImportEntry[] {
const effective = mappings.map((m) => ({
...m,
field: overrides[m.csvColumn] ?? m.field,
}));
const emailColIdx = effective.findIndex((m) => m.field === "email");
const phoneColIdx = effective.findIndex((m) => m.field === "phone");
const seenEmails = new Set<string>();
const seenPhones = new Set<string>();
const entries: ContactImportEntry[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const entry: ContactImportEntry = {};
const ignored: Record<string, string> = {};
for (const mapping of effective) {
const colIdx = headers.indexOf(mapping.csvColumn);
if (colIdx === -1) continue;
const raw = row[colIdx] ?? "";
if (mapping.field === null) {
if (raw) ignored[mapping.csvColumn] = raw;
continue;
}
switch (mapping.field) {
case "email": {
const cleaned = raw.trim().toLowerCase();
entry.email = cleaned || undefined;
break;
}
case "phone": {
const stripped = raw.replace(/[\s\-().[\]]/g, "");
entry.phone = raw.trim() || undefined;
break;
}
case "first_name":
entry.first_name = raw || undefined;
break;
case "last_name":
entry.last_name = raw || undefined;
break;
case "full_name":
entry.full_name = raw || undefined;
break;
case "tags":
entry.tags = raw
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
: undefined;
break;
case "email_opt_in":
entry.email_opt_in =
raw === "true" ||
raw === "1" ||
raw === "yes" ||
raw === "y" ||
raw === "on";
break;
case "sms_opt_in":
entry.sms_opt_in =
raw === "true" ||
raw === "1" ||
raw === "yes" ||
raw === "y" ||
raw === "on";
break;
case "external_id":
entry.external_id = raw || undefined;
break;
}
}
const hasEmail = !!entry.email;
const hasPhone = !!entry.phone;
if (!hasEmail && !hasPhone) continue;
// Dedupe within file
let isDup = false;
if (entry.email) {
if (seenEmails.has(entry.email!)) isDup = true;
seenEmails.add(entry.email!);
}
if (entry.phone && !isDup) {
if (seenPhones.has(entry.phone)) isDup = true;
seenPhones.add(entry.phone);
}
if (isDup) continue;
if (Object.keys(ignored).length > 0) {
entry._metadata = ignored;
}
entries.push(entry);
}
return entries;
}
// ── Confirm and import ─────────────────────────────────────────────────────
const handleConfirm = useCallback(
async () => {
if (!preview || rawRows.length === 0) return;
setStep("importing");
const rowsToImport = applyMappings(
preview.mappings,
overridingMappings,
rawHeaders,
rawRows
);
setImportRows(rowsToImport);
setImporting(true);
const importResult = await importContactsBatch({
brandId,
contacts: rowsToImport,
});
setImporting(false);
if (importResult.success) {
setResult(importResult.result);
setStep("result");
} else {
setGlobalError(importResult.error ?? "Import failed");
setStep("result");
}
},
[brandId, preview, overridingMappings, rawHeaders, rawRows]
);
const handleReset = () => {
setStep("idle");
setFile(null);
setPreview(null);
setImportRows([]);
setResult(null);
setGlobalError("");
setOverridingMappings({});
if (fileRef.current) fileRef.current.value = "";
};
// ── Render helpers ────────────────────────────────────────────────────────
function renderMappings() {
if (!preview) return null;
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-xs border border-zinc-800 rounded-lg">
<thead className="bg-zinc-900">
<tr>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
CSV Column
</th>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
Maps To
</th>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
Sample Values
</th>
</tr>
</thead>
<tbody>
{preview.mappings.map((m) => (
<tr key={m.csvColumn} className="border-t border-slate-100">
<td className="px-3 py-2 text-slate-800 font-mono text-xs">
{m.csvColumn}
</td>
<td className="px-3 py-2">
<select
value={
overridingMappings[m.csvColumn] ?? m.field ?? "ignore"
}
onChange={(e) => {
const val = e.target.value;
setOverridingMappings((prev) => ({
...prev,
[m.csvColumn]:
val === "ignore"
? null
: (val as ImportField),
}));
}}
className="text-xs border border-zinc-600 rounded px-2 py-1"
>
<option value="ignore"> ignore </option>
{Object.entries(FIELD_LABELS).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-zinc-500 text-xs">
{m.sampleValues.length > 0
? m.sampleValues.join(", ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{preview.ignoredColumns.length > 0 && (
<p className="text-xs text-zinc-500">
Extra columns (stored in metadata):
<span className="font-mono">
{" "}
{preview.ignoredColumns.join(", ")}
</span>
</p>
)}
</div>
);
}
function renderStats() {
if (!preview) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Total rows" value={preview.totalRows} />
<Stat
label="Importable"
value={preview.validRows}
highlight
/>
<Stat label="Skipped" value={preview.skippedRows} warn={preview.skippedRows > 0} />
<Stat
label="Duplicates"
value={preview.duplicateRows}
warn={preview.duplicateRows > 0}
/>
</div>
);
}
function renderSampleRows() {
if (!preview) return null;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border border-zinc-800 rounded-lg">
<thead className="bg-zinc-900">
<tr>
<th className="px-2 py-1 text-left text-zinc-400">#</th>
<th className="px-2 py-1 text-left text-zinc-400">email</th>
<th className="px-2 py-1 text-left text-zinc-400">phone</th>
<th className="px-2 py-1 text-left text-zinc-400">first_name</th>
<th className="px-2 py-1 text-left text-zinc-400">last_name</th>
<th className="px-2 py-1 text-left text-zinc-400">full_name</th>
<th className="px-2 py-1 text-left text-zinc-400">email_opt_in</th>
<th className="px-2 py-1 text-left text-zinc-400">sms_opt_in</th>
<th className="px-2 py-1 text-left text-zinc-400">tags</th>
</tr>
</thead>
<tbody>
{preview.sampleRows.map((row, i) => (
<tr key={i} className="border-t border-slate-100">
<td className="px-2 py-1 text-slate-400">{i + 1}</td>
<td className="px-2 py-1">{row.email ?? ""}</td>
<td className="px-2 py-1">{row.phone ?? ""}</td>
<td className="px-2 py-1">{row.first_name ?? ""}</td>
<td className="px-2 py-1">{row.last_name ?? ""}</td>
<td className="px-2 py-1">{row.full_name ?? ""}</td>
<td className="px-2 py-1">
{row.email_opt_in === undefined
? ""
: row.email_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1">
{row.sms_opt_in === undefined
? ""
: row.sms_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1">{row.tags?.join("; ") ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Main render ────────────────────────────────────────────────────────────
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Import Contacts</h3>
{step !== "idle" && (
<button
onClick={handleReset}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Start over
</button>
)}
</div>
{globalError && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{globalError}
</div>
)}
{/* ── STEP: idle ─────────────────────────────────────────── */}
{step === "idle" && (
<>
<div
className="border-2 border-dashed border-zinc-600 rounded-xl p-8 text-center"
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={handleDrop}
>
<input
ref={fileRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer">
<div className="text-zinc-400 text-sm">
<span className="font-medium text-blue-400 hover:text-blue-700">
Click to upload
</span>
{" "}or drag and drop a CSV file
</div>
<p className="text-xs text-slate-400 mt-1">
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
generic spreadsheets
</p>
</label>
</div>
</>
)}
{/* ── STEP: preview ──────────────────────────────────────── */}
{step === "preview" && preview && (
<>
{preview.warnings.map((w, i) => (
<div
key={i}
className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
{w}
</div>
))}
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
<p className="font-medium mb-1">{file?.name}</p>
<p>
Column mappings detected review below, adjust if needed, then
confirm import.
</p>
</div>
<div className="space-y-3">
{renderMappings()}
</div>
<div>
<p className="text-xs text-zinc-500 mb-2 font-medium">
Import Summary
</p>
{renderStats()}
</div>
<div>
<p className="text-xs text-zinc-500 mb-2 font-medium">
Sample rows (first {preview.sampleRows.length})
</p>
{renderSampleRows()}
</div>
{preview.skippedReasons.length > 0 && (
<div>
<p className="text-xs text-zinc-500 mb-1 font-medium">
Skipped rows ({preview.skippedReasons.length})
</p>
<div className="max-h-32 overflow-y-auto text-xs text-zinc-500 space-y-1">
{preview.skippedReasons.slice(0, 10).map((s) => (
<p key={s.rowIndex}>
Row {s.rowIndex + 1}: {s.reason}
</p>
))}
{preview.skippedReasons.length > 10 && (
<p className="text-slate-400">
...and{" "}
{preview.skippedReasons.length - 10} more
</p>
)}
</div>
</div>
)}
{preview.totalRows > LARGE_FILE_ROWS && (
<div className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700">
Large file ({preview.totalRows.toLocaleString()} rows) confirm to
proceed.
</div>
)}
<div className="flex gap-3">
<button
onClick={() => handleReset()}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
>
Cancel
</button>
<button
onClick={() => handleConfirm()}
disabled={preview.validRows === 0}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Import {preview.validRows} contacts
</button>
</div>
</>
)}
{/* ── STEP: importing ────────────────────────────────────── */}
{step === "importing" && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-6 text-center text-sm text-blue-700">
Importing {importRows.length} contacts...
</div>
)}
{/* ── STEP: result ────────────────────────────────────────── */}
{step === "result" && result && (
<div className="space-y-3">
<div className="rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm space-y-1">
<p className="font-semibold text-green-400">Import complete</p>
<p className="text-green-600">Created: {result.created}</p>
<p className="text-green-600">Updated: {result.updated}</p>
<p className="text-green-600">Skipped: {result.skipped}</p>
</div>
{result.errors.length > 0 && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm">
<p className="font-medium text-red-400 mb-2">
Errors ({result.errors.length})
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<p key={i} className="text-red-400 text-xs">
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
</p>
))}
{result.errors.length > 10 && (
<p className="text-red-400 text-xs">
...and {result.errors.length - 10} more
</p>
)}
</div>
</div>
)}
<button
onClick={handleReset}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
>
Import more
</button>
</div>
)}
</div>
);
}
function Stat({
label,
value,
highlight,
warn,
}: {
label: string;
value: number;
highlight?: boolean;
warn?: boolean;
}) {
return (
<div
className={`rounded-lg border px-4 py-3 text-center ${
highlight
? "bg-blue-900/30 border-blue-200"
: warn
? "bg-amber-900/30 border-amber-200"
: "bg-zinc-900 border-zinc-800"
}`}
>
<p
className={`text-xl font-semibold ${
highlight
? "text-blue-700"
: warn
? "text-amber-700"
: "text-zinc-300"
}`}
>
{value.toLocaleString()}
</p>
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useState, useCallback } from "react";
import type { Contact, ContactSource } from "@/actions/communications/contacts";
import { getContacts, deleteContact, exportContacts } from "@/actions/communications/contacts";
import { formatDate } from "@/lib/format-date";
const SOURCE_COLORS: Record<ContactSource, string> = {
order: "bg-blue-900/40 text-blue-700",
import: "bg-purple-100 text-purple-700",
manual: "bg-green-900/40 text-green-400",
admin: "bg-zinc-950 text-zinc-300",
};
export default function ContactListPanel({
initialContacts,
initialTotal,
brandId,
}: {
initialContacts: Contact[];
initialTotal: number;
brandId: string;
}) {
const [contacts, setContacts] = useState(initialContacts);
const [total, setTotal] = useState(initialTotal);
const [search, setSearch] = useState("");
const [sourceFilter, setSourceFilter] = useState<ContactSource | "">("");
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const limit = 50;
const loadPage = useCallback(async (searchVal: string, sourceVal: string, pageNum: number) => {
setLoading(true);
const result = await getContacts({
brandId,
search: searchVal || undefined,
source: sourceVal || undefined,
limit,
offset: pageNum * limit,
});
setLoading(false);
if (result.success) {
setContacts(result.contacts);
setTotal(result.total);
}
}, [brandId]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(0);
loadPage(search, sourceFilter, 0);
};
const handleSourceFilter = (val: ContactSource | "") => {
setSourceFilter(val);
setPage(0);
loadPage(search, val, 0);
};
const handlePage = (dir: number) => {
const next = page + dir;
setPage(next);
loadPage(search, sourceFilter, next);
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this contact?")) return;
setDeleting(id);
const result = await deleteContact(id);
setDeleting(null);
if (result.success) {
setContacts((prev) => prev.filter((c) => c.id !== id));
setTotal((t) => t - 1);
}
};
const handleExport = async () => {
setExporting(true);
const result = await exportContacts({
brandId,
brandSlug: "contacts",
search: search || undefined,
source: sourceFilter || undefined,
});
setExporting(false);
if (!result.success) {
alert("Export failed: " + result.error);
return;
}
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Contacts</h2>
<p className="text-sm text-zinc-500">{total} contact{total !== 1 ? "s" : ""}</p>
</div>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{exporting ? "Exporting..." : "Export Contacts"}
</button>
</div>
{/* Search + filters */}
<form onSubmit={handleSearch} className="flex gap-3 mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search email, name, phone..."
className="flex-1 text-sm border border-zinc-600 rounded-lg px-3 py-2"
/>
<select
value={sourceFilter}
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
>
<option value="">All Sources</option>
<option value="order">Order</option>
<option value="import">Import</option>
<option value="manual">Manual</option>
<option value="admin">Admin</option>
</select>
<button
type="submit"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Search
</button>
</form>
{/* Table */}
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : contacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">No contacts found</div>
) : (
<>
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Phone</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Source</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email Opt-in</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Unsubscribed</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{contacts.map((c) => (
<tr key={c.id} className="hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
</td>
<td className="px-4 py-3 text-zinc-400">{c.email || "—"}</td>
<td className="px-4 py-3 text-zinc-400">{c.phone || "—"}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
</td>
<td className="px-4 py-3">
{c.email_opt_in ? (
<span className="text-green-600 text-xs font-medium">Opted in</span>
) : (
<span className="text-red-500 text-xs font-medium">Opted out</span>
)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{c.unsubscribed_at
? formatDate(new Date(c.unsubscribed_at))
: "—"}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
>
{deleting === c.id ? "..." : "Delete"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{contacts.map((c) => (
<div key={c.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="font-medium text-zinc-100 text-sm">
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
</div>
<button
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
>
{deleting === c.id ? "..." : "Delete"}
</button>
</div>
<div className="text-xs text-zinc-400">{c.email || "—"}</div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
{c.phone && <span className="text-xs text-zinc-500">{c.phone}</span>}
{c.email_opt_in ? (
<span className="text-green-600 text-xs font-medium">Opted in</span>
) : (
<span className="text-red-500 text-xs font-medium">Opted out</span>
)}
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-zinc-500">
Page {page + 1} {contacts.length} of {total}
</span>
<div className="flex gap-2">
<button
onClick={() => handlePage(-1)}
disabled={page === 0}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
>
Previous
</button>
<button
onClick={() => handlePage(1)}
disabled={(page + 1) * limit >= total}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
>
Next
</button>
</div>
</div>
</>
)}
</div>
);
}
+419
View File
@@ -0,0 +1,419 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
fulfillment?: string;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
stops: { id?: string; city: string; state: string; date: string; brand_id?: string } | null;
order_items?: OrderItem[];
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
brand_id?: string;
};
type DriverPickupPanelProps = {
initialPendingOrders: Order[];
initialPickedUpOrders: Order[];
initialStops: Stop[];
brandId: string | null;
canManagePickup: boolean;
};
function formatPickupTime(iso: string | null) {
if (!iso) return "";
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
// Same year — show mon/day
if (d.getFullYear() === now.getFullYear()) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
export default function DriverPickupPanel({
initialPendingOrders,
initialPickedUpOrders,
initialStops,
brandId,
canManagePickup,
}: DriverPickupPanelProps) {
const [pendingOrders, setPendingOrders] = useState<Order[]>(initialPendingOrders);
const [pickedUpOrders, setPickedUpOrders] = useState<Order[]>(initialPickedUpOrders);
const [stops] = useState<Stop[]>(initialStops);
const [search, setSearch] = useState("");
const [stopFilter, setStopFilter] = useState("");
const [pickingUp, setPickingUp] = useState<string | null>(null);
const [showPickedUp, setShowPickedUp] = useState(true);
const [pickupToast, setPickupToast] = useState<string | null>(null);
const activeStopFilter = stopFilter;
const filteredPending = pendingOrders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
return matchesSearch && matchesStop;
});
const filteredPickedUp = pickedUpOrders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
return matchesSearch && matchesStop;
});
const hasAnyPickedUp = pickedUpOrders.length > 0;
async function handleMarkPickup(orderId: string) {
if (!canManagePickup) return;
setPickingUp(orderId);
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
const order = pendingOrders.find((o) => o.id === orderId);
if (order) {
setPendingOrders((prev) => prev.filter((o) => o.id !== orderId));
setPickedUpOrders((prev) => [
{
...order,
pickup_complete: true,
pickup_completed_at: result.pickup_completed_at,
pickup_completed_by: result.pickup_completed_by,
},
...prev,
]);
setPickupToast(orderId);
setTimeout(() => setPickupToast(null), 3000);
}
}
setPickingUp(null);
}
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-slate-900 px-4 py-5">
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Driver Pickup</h1>
<p className="mt-1 text-sm text-slate-400">
{filteredPending.length} pending
{hasAnyPickedUp && ` · ${pickedUpOrders.length} picked up`}
</p>
</div>
<Link
href="/admin/orders"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
All Orders
</Link>
</div>
</div>
</div>
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
{/* Stop Filter */}
<select
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base font-medium outline-none focus:border-slate-900"
>
<option value="">All Stops</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>
{s.city}, {s.state} · {s.date}
</option>
))}
</select>
{/* Search */}
<input
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
/>
{/* Pending Orders */}
<div className="space-y-3">
{filteredPending.length === 0 ? (
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
No pending orders
</div>
) : (
filteredPending.map((order) => (
<OrderCard
key={order.id}
order={order}
canManagePickup={canManagePickup}
onMarkPickup={handleMarkPickup}
pickingUp={pickingUp}
shortId={shortId(order.id)}
/>
))
)}
</div>
{/* Picked Up Toggle */}
{hasAnyPickedUp && (
<button
onClick={() => setShowPickedUp((v) => !v)}
className="w-full rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm font-medium text-green-400 hover:bg-green-900/40"
>
{showPickedUp ? "▲ Hide" : "▼"} {pickedUpOrders.length} picked up
</button>
)}
{showPickedUp && (
<div className="space-y-3">
{filteredPickedUp.length === 0 && activeStopFilter ? (
<div className="rounded-xl border border-green-200 bg-green-900/30 py-6 text-center text-sm text-green-600">
No picked-up orders for this stop. {pickedUpOrders.length} picked up at other stops.
</div>
) : (
filteredPickedUp.map((order) => (
<PickedUpCard
key={order.id}
order={order}
shortId={shortId(order.id)}
/>
))
)}
</div>
)}
{/* Success toast */}
{pickupToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-lg animate-in fade-in slide-in-from-bottom-2">
Order picked up
</div>
)}
</div>
</div>
);
}
type OrderCardProps = {
order: Order;
canManagePickup: boolean;
onMarkPickup: (orderId: string) => void;
pickingUp: string | null;
shortId: string;
};
function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }: OrderCardProps) {
const allItems = order.order_items ?? [];
const pickupItems = allItems.filter((i) => i.fulfillment !== "shipping");
const shippingItems = allItems.filter((i) => i.fulfillment === "shipping");
const isMixed = pickupItems.length > 0 && shippingItems.length > 0;
const pickupTotal = pickupItems.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0);
return (
<div className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
{/* Top row: name + order # */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId}</p>
</div>
<div className="text-right shrink-0 flex flex-col items-end gap-1">
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-bold text-yellow-800">
Pending
</span>
{isMixed && (
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-semibold text-purple-700">
Mixed order
</span>
)}
</div>
</div>
{/* Products list — pickup items only */}
{pickupItems.length > 0 && (
<div className="mt-4 rounded-lg bg-slate-50 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
Pickup items ({pickupItems.reduce((s, i) => s + i.quantity, 0)} total)
</p>
<ul className="space-y-1">
{pickupItems.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-zinc-300">
{item.quantity > 1 && (
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
</span>
<span className="text-zinc-500">
${(Number(item.price) * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
<span className="text-sm font-medium text-zinc-400">Pickup subtotal</span>
<span className="text-sm font-bold text-zinc-100">${pickupTotal.toFixed(2)}</span>
</div>
</div>
)}
{/* Shipping items note */}
{isMixed && (
<div className="mt-3 rounded-lg bg-purple-50 border border-purple-100 px-3 py-2 text-sm text-purple-700">
{shippingItems.length} shipping item{shippingItems.length > 1 ? "s" : ""} will also be marked picked up
</div>
)}
{/* Middle: stop + items count */}
<div className="mt-4 flex items-center justify-between">
<div>
{order.stops ? (
<p className="font-medium text-zinc-300">
{order.stops.city}, {order.stops.state}
</p>
) : (
<p className="text-slate-400">No stop</p>
)}
<p className="text-sm text-slate-400">
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
{isMixed && ` · ${shippingItems.length} shipping`}
</p>
</div>
<p className="text-3xl font-bold text-zinc-100">
${pickupTotal.toFixed(2)}
</p>
</div>
{/* Pickup Button */}
{canManagePickup ? (
<div className="mt-4 flex gap-2">
<a
href={`/admin/orders/${order.id}`}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-center text-base font-semibold text-zinc-300 active:bg-slate-50"
style={{ minHeight: "56px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
View Details
</a>
<button
onClick={() => onMarkPickup(order.id)}
disabled={pickingUp === order.id}
className="flex-1 rounded-xl bg-green-600 px-4 py-5 text-xl font-bold text-white active:bg-green-700 disabled:opacity-50"
style={{ minHeight: "56px" }}
>
{pickingUp === order.id ? "..." : "✓ Pick Up"}
</button>
</div>
) : (
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
No pickup permission
</div>
)}
</div>
);
}
type PickedUpCardProps = {
order: Order;
shortId: string;
};
function PickedUpCard({ order, shortId }: PickedUpCardProps) {
const allItems = order.order_items ?? [];
const pickupItems = allItems.filter((i) => i.fulfillment === "pickup");
const pickupTotal = pickupItems.reduce((s, i) => s + Number(i.price) * i.quantity, 0);
const isMixed = allItems.some((i) => i.fulfillment === "shipping");
return (
<div className="rounded-xl border border-green-200 bg-green-900/30 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-bold text-green-900">{order.customer_name}</p>
{order.customer_phone && (
<p className="text-sm text-green-400">{order.customer_phone}</p>
)}
<p className="mt-1 text-xs text-green-600">
{shortId} · {order.stops?.city}, {order.stops?.state}
</p>
{pickupItems.length > 0 && (
<ul className="mt-2 space-y-0.5">
{pickupItems.map((item) => (
<li key={item.id} className="text-xs text-green-400">
{item.quantity > 1 && <span className="font-semibold">{item.quantity}× </span>}
{item.products?.name ?? "Unknown"}
<span className="ml-1 text-green-500">${(Number(item.price) * item.quantity).toFixed(2)}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-xs font-semibold text-green-400">
{pickupItems.reduce((s, i) => s + i.quantity, 0)} pickup items · ${pickupTotal.toFixed(2)}
{isMixed && <span className="text-purple-600 ml-1">· mixed</span>}
</p>
</div>
<div className="text-right shrink-0">
<span className="rounded-full bg-green-200 px-3 py-1 text-xs font-bold text-green-800">
Picked Up
</span>
{order.pickup_completed_at && (
<p className="mt-1 text-xs text-green-600">
{formatPickupTime(order.pickup_completed_at)}
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
type Props = {
analytics: CampaignAnalytics[];
};
type Period = "30" | "90" | "all";
function RateBar({ value }: { value: number }) {
return (
<div className="w-full bg-zinc-950 rounded-full h-1.5">
<div
className="bg-stone-900 h-1.5 rounded-full"
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
);
}
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-1.5">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-zinc-100">{value}</p>
{sub && <p className="text-xs text-slate-400">{sub}</p>}
</div>
);
}
export default function AnalyticsDashboard({ analytics }: Props) {
const [period, setPeriod] = useState<Period>("30");
const totalSent = analytics.reduce((s, a) => s + a.total_sent, 0);
const totalDelivered = analytics.reduce((s, a) => s + a.total_delivered, 0);
const totalOpened = analytics.reduce((s, a) => s + a.total_opened, 0);
const totalClicked = analytics.reduce((s, a) => s + a.total_clicked, 0);
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
return (
<div className="flex flex-col gap-5">
{/* Summary stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Total Sent" value={totalSent.toLocaleString()} sub={`${analytics.length} campaign${analytics.length !== 1 ? "s" : ""}`} />
<StatCard
label="Delivered"
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} emails` : undefined}
/>
<StatCard label="Avg. Open Rate" value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"} sub="of delivered" />
<StatCard label="Avg. Click Rate" value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"} sub="of delivered" />
</div>
{/* Campaign performance table */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Campaign Performance</h3>
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
{(["30", "90", "all"] as const).map((val) => (
<button
key={val}
onClick={() => setPeriod(val as Period)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
period === val ? "bg-stone-900 text-white" : "text-zinc-400 hover:bg-zinc-900"
}`}
>
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
</button>
))}
</div>
</div>
{analytics.length === 0 ? (
<div className="px-6 py-12 text-center">
<p className="text-sm text-slate-400">No campaign analytics yet.</p>
<p className="text-xs text-slate-400 mt-1">Send a campaign to start tracking engagement.</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
{["Campaign", "Sent", "Delivered", "Opened", "Clicked", "Bounced", "Engagement"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Campaign" ? "text-right" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{analytics.map((a) => (
<tr key={a.campaign_id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5">
<div className="flex flex-col">
<span className="font-medium text-slate-800">{a.campaign_name}</span>
<span className="text-xs text-slate-400">
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
</span>
</div>
</td>
<td className="px-6 py-3.5 text-right text-zinc-300">{a.total_sent.toLocaleString()}</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_delivered.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.delivered_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_opened.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.open_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_clicked.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.click_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className={a.total_bounced > 0 ? "text-red-400" : "text-slate-400"}>
{a.total_bounced.toLocaleString()}
</span>
<span className="text-xs text-slate-400 ml-1">({a.bounce_rate}%)</span>
</td>
<td className="px-6 py-3.5 w-36">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-xs">
<span className="text-zinc-500">Open</span>
<span className="font-medium text-zinc-300">{a.open_rate}%</span>
</div>
<RateBar value={Number(a.open_rate)} />
<div className="flex items-center justify-between text-xs">
<span className="text-zinc-500">Click</span>
<span className="font-medium text-zinc-300">{a.click_rate}%</span>
</div>
<RateBar value={Number(a.click_rate)} />
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,505 @@
"use client";
import { useState } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
campaigns: Campaign[];
templates: Template[];
segments: Segment[];
editCampaignId?: string;
};
type Step = 1 | 2 | 3 | 4;
const STEPS = ["Template", "Audience", "Preview", "Schedule"] as const;
const STATUS_COLORS: Record<string, string> = {
draft: "bg-zinc-950 text-zinc-400",
scheduled: "bg-blue-900/40 text-blue-700",
sending: "bg-amber-100 text-amber-700",
sent: "bg-green-900/40 text-green-400",
canceled: "bg-red-900/40 text-red-400",
};
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string }[] = [
{ value: "marketing", label: "Marketing", desc: "Promotions, newsletters" },
{ value: "operational", label: "Operational", desc: "Stop updates, reminders" },
{ value: "transactional", label: "Transactional", desc: "Receipts, confirmations" },
];
function statusBadge(status: string) {
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status] ?? "bg-zinc-950 text-zinc-400"}`}>
{status}
</span>
);
}
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
const editing = campaigns.find((c) => c.id === editCampaignId);
const [step, setStep] = useState<Step>(editing ? 4 : 1);
const [name, setName] = useState(editing?.name ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(editing?.campaign_type ?? "marketing");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(editing?.template_id ?? "");
const [subject, setSubject] = useState(editing?.subject ?? "");
const [bodyText, setBodyText] = useState(editing?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(editing?.body_html ?? "");
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [sendNow, setSendNow] = useState(!editing?.scheduled_at);
const [scheduledAt, setScheduledAt] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
function handleTemplateSelect(template: Template) {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
setBodyText(template.body_text ?? "");
setBodyHtml(template.body_html ?? "");
setStep(2);
}
async function handleCreateOrSchedule() {
setError("");
setSaving(true);
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
const { upsertCampaign } = await import("@/actions/communications/campaigns");
const { sendCampaign } = await import("@/actions/communications/send");
const result = await upsertCampaign({
id: editing?.id,
brand_id: brandId,
name: name || "Untitled Campaign",
subject,
body_text: bodyText,
body_html: bodyHtml,
template_id: selectedTemplateId || undefined,
campaign_type: campaignType,
status: sendNow ? "draft" : "scheduled",
audience_rules: rules as any,
scheduled_at: sendNow ? undefined : scheduledAt || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save campaign");
setSaving(false);
return;
}
if (sendNow) {
const sendResult = await sendCampaign(result.campaign.id, brandId);
if (!sendResult.success) {
setError(sendResult.error ?? "Failed to send campaign");
setSaving(false);
return;
}
}
setSaved(true);
setSaving(false);
setTimeout(() => setSaved(false), 3000);
}
if (saved) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-10 text-center">
<div className="w-12 h-12 rounded-full bg-green-900/40 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold text-zinc-100 mb-2">
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
</h2>
<p className="text-sm text-zinc-500 mb-8">
{sendNow
? "Your campaign has been sent to all matching customers."
: `It will be sent on ${new Date(scheduledAt).toLocaleString()}.`}
</p>
<button
onClick={() => window.location.href = "/admin/communications"}
className="px-6 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Back to Campaigns
</button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Step indicator */}
<div className="flex items-center">
{STEPS.map((label, i) => {
const n = (i + 1) as Step;
const done = step > n;
return (
<div key={label} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
step === n
? "bg-stone-900 text-white"
: done
? "bg-stone-200 text-stone-700"
: "bg-zinc-950 text-slate-400"
}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs border ${
step === n ? "border-white" : done ? "border-stone-400" : "border-zinc-600"
}`}>
{done ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : n}
</span>
<span className="hidden sm:inline">{label}</span>
</div>
{i < STEPS.length - 1 && (
<div className={`h-0.5 w-6 ${done ? "bg-stone-300" : "bg-slate-200"} mx-1`} />
)}
</div>
);
})}
</div>
{/* Step content card */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
{/* Step 1: Template */}
{step === 1 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Choose a Template</h2>
<p className="text-sm text-zinc-500 mt-0.5">Select a template or start from scratch.</p>
</div>
{templates.length === 0 ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-zinc-500">No templates yet. Create your subject and body below.</p>
<div className="flex flex-col gap-3">
<input
type="text"
placeholder="Subject line"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
<textarea
placeholder="Email body text…"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={5}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-slate-900"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => setStep(2)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Continue
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Start from scratch */}
<button
onClick={() => setStep(2)}
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-stone-400 hover:bg-zinc-950 transition-all group"
>
<span className="text-2xl block mb-2">+</span>
<p className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">Start from scratch</p>
<p className="text-xs text-slate-400 mt-0.5">Write your own subject and body</p>
</button>
{/* Template cards */}
{templates.map((t) => (
<button
key={t.id}
onClick={() => handleTemplateSelect(t)}
className={`rounded-xl border p-4 text-left transition-all ${
selectedTemplateId === t.id
? "border-stone-900 bg-zinc-950"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{t.name}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{t.subject}</p>
</div>
<span className="inline-flex items-center rounded-full bg-zinc-950 px-2 py-0.5 text-xs text-zinc-400 flex-shrink-0">
{t.template_type}
</span>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Step 2: Audience */}
{step === 2 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Audience</h2>
<p className="text-sm text-zinc-500 mt-0.5">Name your campaign and pick a segment.</p>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Campaign Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. May Sweet Corn Promotion"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Saved Segment</label>
<div className="flex gap-2">
<select
value={selectedSegmentId}
onChange={(e) => {
setSelectedSegmentId(e.target.value);
}}
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-slate-900"
>
<option value=""> Choose a saved segment </option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{selectedSegmentId && (
<button
onClick={() => setSelectedSegmentId("")}
className="px-3 text-xs text-slate-400 hover:text-red-500 border border-zinc-800 rounded-lg hover:border-red-200"
>
Clear
</button>
)}
</div>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(1)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(3)}
disabled={!name}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 disabled:opacity-40"
>
Continue to Preview
</button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 3 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Preview & Edit</h2>
<p className="text-sm text-zinc-500 mt-0.5">Review how your message will look.</p>
</div>
{/* Mock email frame */}
<div className="rounded-xl border border-zinc-800 bg-slate-50 overflow-hidden">
<div className="px-4 py-3 bg-zinc-900 border-b border-zinc-800 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-stone-300" />
<div>
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
</div>
</div>
<div className="p-5">
<p className="text-base font-semibold text-zinc-100 mb-3">{subject || "(no subject)"}</p>
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{bodyText || "(no body)"}</div>
</div>
</div>
{/* Editable subject */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Subject Line</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
</div>
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(2)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(4)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Continue to Schedule
</button>
</div>
</div>
)}
{/* Step 4: Schedule */}
{step === 4 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Schedule & Send</h2>
<p className="text-sm text-zinc-500 mt-0.5">Set campaign type and timing.</p>
</div>
{/* Campaign type */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Campaign Type</label>
<div className="grid grid-cols-3 gap-3">
{CAMPAIGN_TYPES.map((ct) => (
<button
key={ct.value}
onClick={() => setCampaignType(ct.value)}
className={`rounded-xl border py-3 px-4 text-left transition-all ${
campaignType === ct.value
? "border-stone-900 bg-stone-900 text-white"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<p className={`text-sm font-medium ${campaignType === ct.value ? "text-white" : "text-zinc-300"}`}>{ct.label}</p>
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-stone-300" : "text-slate-400"}`}>{ct.desc}</p>
</button>
))}
</div>
</div>
{/* Send timing */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Send</label>
<div className="flex gap-6">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
checked={sendNow}
onChange={() => setSendNow(true)}
className="accent-stone-900 w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Send now</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
checked={!sendNow}
onChange={() => setSendNow(false)}
className="accent-stone-900 w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
</label>
</div>
</div>
{!sendNow && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Send Date & Time</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
)}
{/* Summary */}
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide mb-3">Summary</p>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<span className="text-zinc-500">Campaign</span>
<span className="text-zinc-100 font-medium">{name || "—"}</span>
<span className="text-zinc-500">Template</span>
<span className="text-zinc-100">{selectedTemplate?.name ?? "Custom"}</span>
<span className="text-zinc-500">Audience</span>
<span className="text-zinc-100">{selectedSegment?.name ?? "—"}</span>
<span className="text-zinc-500">Type</span>
<span className="text-zinc-100 capitalize">{campaignType}</span>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(3)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={handleCreateOrSchedule}
disabled={saving || (!sendNow && !scheduledAt)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50"
>
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
</button>
</div>
</div>
)}
</div>
{/* Recent campaigns */}
{campaigns.length > 0 && (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-slate-800">Recent Campaigns</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
{["Name", "Type", "Status", "Sent"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Name" ? "hidden sm:table-cell" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{campaigns.slice(0, 10).map((c) => (
<tr key={c.id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5 font-medium text-slate-800">{c.name}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-zinc-500 capitalize">{c.campaign_type}</td>
<td className="px-6 py-3.5 hidden sm:table-cell">{statusBadge(c.status)}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-slate-400">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{ href: "/admin/harvest-reach/segments", label: "Segments" },
{ href: "/admin/harvest-reach/campaigns", label: "Campaigns" },
{ href: "/admin/harvest-reach/analytics", label: "Analytics" },
];
export default function HarvestReachNav() {
const pathname = usePathname();
return (
<nav className="flex gap-1 border-b border-zinc-800 mb-6">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
active
? "border-stone-900 text-stone-900"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
};
const DEBOUNCE_MS = 300;
export default function MatchingCustomersPanel({ brandId, rules }: Props) {
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (rules.filters.length === 0) {
setPreview(null);
return;
}
setLoading(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
const { previewSegmentWithCustomers } = await import("@/actions/harvest-reach/segments");
const result = await previewSegmentWithCustomers(brandId, rules);
setPreview(result);
setLoading(false);
setPage(0);
}, DEBOUNCE_MS);
return () => clearTimeout(timerRef.current);
}, [brandId, rules]);
const PAGE_SIZE = 50;
const filtered = preview?.sample_customers ?? [];
const searched = search
? filtered.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase())
)
: filtered;
const total = preview?.count ?? 0;
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Matching Customers</h3>
{preview && (
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700">
{total.toLocaleString()} total
</span>
)}
</div>
{rules.filters.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<p className="text-sm text-slate-400 text-center">
Add filters to see matching customers
</p>
</div>
) : loading ? (
<div className="flex-1 flex items-center justify-center py-12">
<div className="flex items-center gap-2.5 text-slate-400">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading</span>
</div>
</div>
) : (
<>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
{paged.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
) : (
<div className="flex-1 overflow-y-auto max-h-[460px] flex flex-col gap-1">
{paged.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center text-xs font-medium text-stone-600 flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-slate-400 truncate">{c.email}</p>
</div>
{c.tags.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{c.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-blue-900/30 text-blue-400 px-2 py-0.5 text-xs"
>
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
{searched.length > PAGE_SIZE && (
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
>
Prev
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { type Segment, type SegmentRuleV2 } from "@/actions/harvest-reach/segments";
import SegmentBuilderPanel from "./SegmentBuilderPanel";
import MatchingCustomersPanel from "./MatchingCustomersPanel";
import SegmentListSidebar from "./SegmentListSidebar";
import SegmentEditModal from "./SegmentEditModal";
type Props = {
brandId: string;
initialSegments: Segment[];
};
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
const [segments, setSegments] = useState<Segment[]>(initialSegments);
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
const [currentRules, setCurrentRules] = useState<SegmentRuleV2>({
combinator: "AND",
filters: [],
});
const [showEditModal, setShowEditModal] = useState(false);
function handleSegmentSelect(segment: Segment) {
setActiveSegment(segment);
setCurrentRules(segment.rules as SegmentRuleV2);
}
function handleNewSegment() {
setActiveSegment(null);
setCurrentRules({ combinator: "AND", filters: [] });
}
function handleRulesChange(rules: SegmentRuleV2) {
setCurrentRules(rules);
setActiveSegment(null);
}
async function handleSaveSegment(name: string, description: string) {
const { upsertHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
const result = await upsertHarvestReachSegment({
id: activeSegment?.id,
brand_id: brandId,
name,
description,
rules: currentRules,
});
if (result.success) {
if (activeSegment) {
setSegments((prev) =>
prev.map((s) => (s.id === result.segment.id ? result.segment : s))
);
} else {
setSegments((prev) => [...prev, result.segment]);
}
setActiveSegment(result.segment);
setShowEditModal(false);
}
}
async function handleDeleteSegment(segmentId: string) {
const { deleteHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
await deleteHarvestReachSegment(segmentId, brandId);
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
if (activeSegment?.id === segmentId) {
handleNewSegment();
}
}
return (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Segment Builder</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Build filters to define your audience, then save and reuse the segment.
</p>
</div>
</div>
{/* Main layout */}
<div className="flex gap-5">
{/* Left sidebar */}
<div className="w-72 flex-shrink-0">
<SegmentListSidebar
segments={segments}
activeSegmentId={activeSegment?.id}
onSelect={handleSegmentSelect}
onNew={handleNewSegment}
onDelete={handleDeleteSegment}
/>
</div>
{/* Main content: builder + preview */}
<div className="flex-1 grid grid-cols-2 gap-5 min-h-[580px]">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
onChange={handleRulesChange}
onSave={() => setShowEditModal(true)}
hasActiveSegment={!!activeSegment}
/>
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
</div>
</div>
{showEditModal && (
<SegmentEditModal
initialName={activeSegment?.name ?? ""}
initialDescription={activeSegment?.description ?? ""}
onSave={handleSaveSegment}
onClose={() => setShowEditModal(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,343 @@
"use client";
import { useState, useCallback } from "react";
import {
type SegmentRuleV2,
type SegmentFilter,
type SegmentFilterType,
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
onChange: (rules: SegmentRuleV2) => void;
onSave: () => void;
hasActiveSegment: boolean;
};
const FILTER_TYPES: { value: SegmentFilterType; label: string }[] = [
{ value: "all_customers", label: "All Customers" },
{ value: "stop", label: "Past Stop" },
{ value: "upcoming_stop", label: "Upcoming Stop" },
{ value: "product", label: "Product Purchased" },
{ value: "zip_code", label: "ZIP / City" },
{ value: "customer_history", label: "Order History" },
{ value: "tags", label: "Tags" },
];
const ORDER_HISTORY_OPTIONS = [
{ value: "all", label: "All customers" },
{ value: "first_order", label: "First-time buyers" },
{ value: "repeat", label: "Repeat buyers" },
];
function emptyFilter(type: SegmentFilterType): SegmentFilter {
const params: SegmentFilterParams = {};
if (type === "product") params.days_back = 90;
if (type === "customer_history") { params.order_history = "all"; params.days_back = 90; }
if (type === "stop" || type === "upcoming_stop") { params.date_from = ""; params.date_to = ""; }
if (type === "zip_code") { params.zip_codes = []; params.city = ""; }
if (type === "tags") params.tags = [];
return { type, params };
}
export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave, hasActiveSegment }: Props) {
const setCombinator = useCallback((combinator: "AND" | "OR") => {
onChange({ ...rules, combinator });
}, [rules, onChange]);
function updateFilter(index: number, updates: Partial<SegmentFilter>) {
const newFilters = rules.filters.map((f, i) =>
i === index ? { ...f, ...updates, params: { ...f.params, ...(updates.params ?? {}) } } : f
);
onChange({ ...rules, filters: newFilters });
}
function removeFilter(index: number) {
onChange({ ...rules, filters: rules.filters.filter((_, i) => i !== index) });
}
function addFilter(type: SegmentFilterType) {
onChange({ ...rules, filters: [...rules.filters, emptyFilter(type)] });
}
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Filter Rules</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Match</span>
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
{(["AND", "OR"] as const).map((c) => (
<button
key={c}
onClick={() => setCombinator(c)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
rules.combinator === c
? "bg-stone-900 text-white"
: "text-zinc-400 hover:bg-zinc-900"
}`}
>
{c}
</button>
))}
</div>
<span className="text-xs text-zinc-500">of the following</span>
</div>
</div>
{/* Filter blocks */}
<div className="flex flex-col gap-3">
{rules.filters.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-slate-400">
No filters yet. Add a filter below to start building your segment.
</p>
</div>
)}
{rules.filters.map((filter, index) => (
<FilterBlock
key={index}
brandId={brandId}
filter={filter}
onChange={(updates) => updateFilter(index, updates)}
onRemove={() => removeFilter(index)}
/>
))}
</div>
{/* Add filter chips */}
<div className="flex gap-2 flex-wrap">
{FILTER_TYPES.map((ft) => (
<button
key={ft.value}
onClick={() => addFilter(ft.value)}
className="px-3 py-1.5 text-xs font-medium rounded-full border border-zinc-600 text-zinc-400 hover:bg-zinc-800 hover:border-slate-400 transition-colors"
>
+ {ft.label}
</button>
))}
</div>
{/* Save button */}
<div className="pt-1 border-t border-slate-100">
<button
onClick={onSave}
disabled={rules.filters.length === 0}
className="w-full py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</button>
</div>
</div>
);
}
// ─── Filter Block ─────────────────────────────────────────────
type FilterBlockProps = {
brandId: string;
filter: SegmentFilter;
onChange: (updates: Partial<SegmentFilter>) => void;
onRemove: () => void;
};
function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps) {
const [products, setProducts] = useState<{ id: string; name: string }[]>([]);
const [stops, setStops] = useState<{ id: string; city: string; date: string }[]>([]);
const [productsLoaded, setProductsLoaded] = useState(false);
const [stopsLoaded, setStopsLoaded] = useState(false);
function loadProducts() {
if (productsLoaded) return;
import("@/actions/harvest-reach/products").then((m) => {
m.getProductsForSegmentPicker(brandId).then((data) => {
setProducts(data);
setProductsLoaded(true);
});
});
}
function loadStops(past: boolean) {
if (stopsLoaded) return;
import("@/actions/harvest-reach/stops").then((m) => {
m.getStopsForSegmentPicker(brandId).then((data) => {
setStops(data.filter((s) => past ? s.is_past : s.is_upcoming));
setStopsLoaded(true);
});
});
}
return (
<div className="rounded-xl border border-zinc-800 p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<select
value={filter.type}
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
}
className="text-sm font-medium border border-zinc-800 rounded-lg px-2.5 py-1.5 bg-zinc-900 outline-none focus:border-slate-900"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
))}
</select>
<button
onClick={onRemove}
className="text-slate-400 hover:text-red-500 transition-colors"
aria-label="Remove filter"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-col gap-2.5">
{/* Stop / Upcoming Stop */}
{(filter.type === "stop" || filter.type === "upcoming_stop") && (
<>
<select
value={filter.params.stop_id ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
onMouseEnter={() => loadStops(filter.type === "stop")}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>{s.city} {s.date}</option>
))}
</select>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={filter.params.date_from ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<input
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
</>
)}
{/* Product */}
{filter.type === "product" && (
<>
<select
value={filter.params.product_id ?? ""}
onChange={(e) => {
onChange({ params: { ...filter.params, product_id: e.target.value } });
if (e.target.value) loadProducts();
}}
onMouseEnter={loadProducts}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
>
<option value="">Select a product</option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
/>
<span className="text-xs text-zinc-500">days</span>
</div>
</>
)}
{/* ZIP / City */}
{filter.type === "zip_code" && (
<>
<input
type="text"
placeholder="ZIP codes (comma-separated)"
value={(filter.params.zip_codes ?? []).join(", ")}
onChange={(e) =>
onChange({
params: {
...filter.params,
zip_codes: e.target.value.split(",").map((z) => z.trim()).filter(Boolean),
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<input
type="text"
placeholder="City (optional)"
value={filter.params.city ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</>
)}
{/* Customer History */}
{filter.type === "customer_history" && (
<div className="flex flex-col gap-2">
<select
value={filter.params.order_history ?? "all"}
onChange={(e) =>
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 outline-none focus:border-slate-900"
>
{ORDER_HISTORY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
/>
<span className="text-xs text-zinc-500">days</span>
</div>
</div>
)}
{/* Tags */}
{filter.type === "tags" && (
<input
type="text"
placeholder="Tags (comma-separated)"
value={(filter.params.tags ?? []).join(", ")}
onChange={(e) =>
onChange({
params: {
...filter.params,
tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean),
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
)}
{/* All customers */}
{filter.type === "all_customers" && (
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
type Props = {
initialName?: string;
initialDescription?: string;
onSave: (name: string, description: string) => Promise<void>;
onClose: () => void;
};
export default function SegmentEditModal({ initialName = "", initialDescription = "", onSave, onClose }: Props) {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [saving, setSaving] = useState(false);
async function handleSave() {
if (!name.trim()) return;
setSaving(true);
await onSave(name.trim(), description.trim());
setSaving(false);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-zinc-900 rounded-xl border border-zinc-800 w-full max-w-md p-6 flex flex-col gap-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-zinc-100">
{initialName ? "Update Segment" : "Save Segment"}
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-col gap-3">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Segment Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder="e.g. Fort Pierce Regulars"
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Description <span className="text-slate-400 font-normal">(optional)</span></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Customers who pick up at the Fort Pierce stop regularly"
rows={3}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-zinc-600 text-sm font-medium text-zinc-400 hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="flex-1 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Saving…" : "Save Segment"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import type { Segment } from "@/actions/harvest-reach/segments";
type Props = {
segments: Segment[];
activeSegmentId?: string;
onSelect: (segment: Segment) => void;
onNew: () => void;
onDelete: (segmentId: string) => void;
};
export default function SegmentListSidebar({ segments, activeSegmentId, onSelect, onNew, onDelete }: Props) {
const [search, setSearch] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const filtered = segments.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Saved Segments</h3>
<button
onClick={onNew}
className="w-7 h-7 rounded-lg bg-stone-900 text-white flex items-center justify-center text-base leading-none hover:bg-stone-800 transition-colors"
aria-label="New segment"
>
+
</button>
</div>
<input
type="search"
placeholder="Search segments…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="flex flex-col gap-1.5">
{filtered.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">
{search ? "No segments match." : "No saved segments yet."}
</p>
)}
{filtered.map((segment) => (
<div key={segment.id} className="group relative">
{confirmDelete === segment.id ? (
<div className="rounded-xl border border-red-200 bg-red-900/30 p-3 flex flex-col gap-2">
<p className="text-xs text-red-400 font-medium">Delete &ldquo;{segment.name}&rdquo;?</p>
<div className="flex gap-2">
<button
onClick={() => { onDelete(segment.id); setConfirmDelete(null); }}
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-medium"
>
Delete
</button>
<button
onClick={() => setConfirmDelete(null)}
className="flex-1 py-1.5 text-xs rounded-lg border border-zinc-600 bg-zinc-900 hover:bg-zinc-800 font-medium"
>
Cancel
</button>
</div>
</div>
) : (
<div
onClick={() => onSelect(segment)}
className={`px-3 py-2.5 rounded-xl cursor-pointer flex items-center justify-between gap-2 transition-all ${
activeSegmentId === segment.id
? "bg-stone-100 border border-stone-300"
: "hover:bg-zinc-800 border border-transparent"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
{segment.description && (
<p className="text-xs text-slate-400 truncate mt-0.5">{segment.description}</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all p-1"
aria-label="Delete segment"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterHeadgate, deleteWaterHeadgate } from "@/actions/water-log/admin";
type Headgate = {
id: string;
name: string;
active: boolean;
unit: string;
created_at: string;
};
const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
type Props = {
headgate: Headgate;
backHref?: string;
};
export default function HeadgateEditForm({ headgate, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [name, setName] = useState(headgate.name);
const [active, setActive] = useState(headgate.active);
const [unit, setUnit] = useState(headgate.unit);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await updateWaterHeadgate(headgate.id, name.trim(), active, unit);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to save");
setSaving(false);
}
}
async function handleDelete() {
if (!window.confirm(`Delete headgate "${headgate.name}"? Existing log entries will be preserved.`)) return;
setDeleting(true);
const result = await deleteWaterHeadgate(headgate.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
{error}
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Default Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
<select
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{deleting ? "..." : "Delete Headgate"}
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "..." : "Save Changes"}
</button>
</div>
</form>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useState, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
};
type SyncOption = {
key: string;
label: string;
description?: string;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
syncOptions: SyncOption[];
};
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "AI",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "border-violet-200",
credentials: [
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
],
syncOptions: [
{ key: "campaign_writer", label: "Campaign Idea Generator" },
{ key: "product_writer", label: "Product Description Writer" },
{ key: "report_explainer", label: "Report Explainer" },
{ key: "pricing_advisor", label: "Pricing Advisor" },
],
},
{
id: "resend",
name: "Resend",
icon: "Email",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
syncOptions: [
{ key: "transactional", label: "Transactional Email" },
{ key: "marketing", label: "Marketing Campaigns" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "Stripe",
description: "Process online payments for orders.",
accentColor: "border-stone-300",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
],
syncOptions: [
{ key: "checkout", label: "Online Checkout" },
{ key: "refunds", label: "Refund Processing" },
],
},
{
id: "twilio",
name: "Twilio",
icon: "SMS",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
syncOptions: [
{ key: "sms_campaigns", label: "SMS Campaigns" },
{ key: "alerts", label: "Order Alerts" },
],
},
];
const iconColors: Record<string, string> = {
AI: "bg-violet-100 text-violet-700",
Email: "bg-amber-100 text-amber-700",
Stripe: "bg-stone-100 text-stone-700",
SMS: "bg-blue-100 text-blue-700",
};
function IntegrationCard({
integration,
initialCredentials,
brandId,
}: {
integration: Integration;
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTest() {
setTestResult(null);
setError(null);
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div className={`rounded-xl border p-5 bg-white shadow-sm ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-stone-100 text-stone-700"}`}>{integration.icon}</span>
<div>
<h3 className="text-base font-semibold text-stone-900">{integration.name}</h3>
<p className="text-xs text-stone-500 mt-0.5">{integration.description}</p>
</div>
</div>
</div>
<div className="space-y-3 mb-4">
{integration.credentials.map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
<div className="relative">
<input
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm pr-16 text-stone-900 placeholder:text-stone-400 outline-none focus:border-emerald-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
className="text-xs text-stone-400 hover:text-stone-600 px-1"
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</div>
</div>
))}
</div>
{testResult && (
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{error && (
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
)}
<div className="flex gap-2">
<button onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
);
}
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function IntegrationsInner({ brandId, brands }: Props) {
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
return (
<div>
{/* AI Provider — separate panel */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-stone-800">AI Provider</h3>
<a href="/admin/settings/ai" className="text-xs text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Configure AI </a>
</div>
<AIProviderPanel brandId={selectedBrandId} />
</div>
{/* Payment & email integrations grid */}
<div className="space-y-3 mb-4">
{INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={selectedBrandId}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect } from "react";
import { sendStopBlast } from "@/actions/communications/stop-blast";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
type BlastMessage = {
id: string;
type: string;
subject: string | null;
body: string;
created_at: string;
message_recipients: { id: string }[];
};
type MessageCustomersSectionProps = {
stopId: string;
brandId?: string;
};
export default function MessageCustomersSection({
stopId,
brandId,
}: MessageCustomersSectionProps) {
const [orders, setOrders] = useState<Order[]>([]);
const [messages, setMessages] = useState<BlastMessage[]>([]);
const [loadingOrders, setLoadingOrders] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(true);
const [audience, setAudience] = useState<"all" | "pending" | "picked_up">("pending");
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirm, setConfirm] = useState<string | null>(null);
useEffect(() => {
fetchOrders();
fetchMessages();
}, []);
async function fetchOrders() {
setLoadingOrders(true);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
);
const data = await res.json();
if (res.ok) setOrders(data);
setLoadingOrders(false);
}
async function fetchMessages() {
// Legacy messages
const [msgRes, campaignRes] = await Promise.all([
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/messages?stop_id=eq.${stopId}&select=*,message_recipients(id)&order=created_at.desc&limit=10`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
),
// Campaign-based blast logs for this stop
brandId
? fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/communication_campaigns?brand_id=eq.${brandId}&audience_rules->>'stop_id'=eq.${stopId}&order=created_at.desc&limit=5`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
)
: Promise.resolve({ ok: false, json: async () => [] } as Response),
]);
const legacyData = msgRes.ok ? await msgRes.json() : [];
const campaignData = campaignRes.ok ? await campaignRes.json() : [];
// Normalize campaign logs into BlastMessage shape
const campaignMessages: BlastMessage[] = (campaignData as Array<{
id: string; subject: string | null; body_text: string | null;
sent_at: string; created_at: string; status: string;
}>).map((c) => ({
id: c.id,
type: "campaign",
subject: c.subject,
body: c.body_text ?? "",
created_at: c.sent_at,
message_recipients: [],
}));
// Interleave: legacy first, then campaign-based
const merged = [...legacyData, ...campaignMessages].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setMessages(merged.slice(0, 10));
setLoadingMessages(false);
}
const allOrders = orders;
const pendingOrders = orders.filter((o) => !o.pickup_complete);
const pickedUpOrders = orders.filter((o) => o.pickup_complete);
const filteredOrders = (() => {
if (audience === "pending") return pendingOrders;
if (audience === "picked_up") return pickedUpOrders;
return allOrders;
})();
const recipients = filteredOrders.filter((o) => {
if (channel === "sms") return o.customer_phone;
if (channel === "email") return o.customer_email;
return o.customer_phone || o.customer_email;
});
const showSubject = channel === "email" || channel === "both";
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!body.trim()) return;
if (showSubject && !subject.trim()) {
setError("Subject is required for email or both.");
return;
}
if (!brandId) {
setError("Brand ID not available.");
return;
}
setSending(true);
setError(null);
setConfirm(null);
const result = await sendStopBlast({
stopId,
brandId,
channel,
subject: showSubject ? subject : undefined,
body,
audience,
});
setSending(false);
if (!result.success) {
setError(result.error ?? "Failed to send blast");
return;
}
setConfirm(`Blast sent — ${result.messages_logged} message${result.messages_logged !== 1 ? "s" : ""} logged via campaign.`);
setBody("");
setSubject("");
fetchMessages();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{confirm && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
{confirm}
</div>
)}
{loadingOrders ? (
<p className="text-sm text-zinc-500">Loading orders...</p>
) : orders.length === 0 ? (
<p className="text-sm text-zinc-500">No orders for this stop yet.</p>
) : (
<>
{/* Audience selector */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Audience {" "}
<span className="text-zinc-500">
{recipients.length} with contact info
</span>
</label>
<div className="flex gap-2">
{(["all", "pending", "picked_up"] as const).map((a) => {
const count =
a === "all"
? allOrders.length
: a === "pending"
? pendingOrders.length
: pickedUpOrders.length;
return (
<button
key={a}
onClick={() => setAudience(a)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
audience === a
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{a.replace("_", " ")} ({count})
</button>
);
})}
</div>
</div>
{/* Channel selector */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Channel
</label>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
channel === ch
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
</button>
))}
</div>
</div>
<form onSubmit={handleSend} className="space-y-4">
{showSubject && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Subject
</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Important update about your pickup"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Message
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
placeholder="Type your message here..."
/>
<p className="mt-1 text-xs text-slate-400">{body.length} characters</p>
</div>
<button
type="submit"
disabled={!body.trim() || recipients.length === 0 || sending}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</button>
</form>
</>
)}
{/* Recent message history */}
{!loadingMessages && messages.length > 0 && (
<div>
<p className="mb-3 text-sm font-medium text-zinc-300">
Recent Messages
</p>
<div className="space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className="rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-zinc-100">
{msg.type.toUpperCase()}{" "}
{msg.subject && <span className="text-zinc-500"> {msg.subject}</span>}
</p>
<p className="mt-1 truncate text-sm text-zinc-400">{msg.body}</p>
<p className="mt-1 text-xs text-slate-400">
{new Date(msg.created_at).toLocaleString()} ·{" "}
{msg.message_recipients?.length ?? 0} recipients
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import type { MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
const STATUS_COLORS: Record<string, string> = {
queued: "bg-zinc-950 text-zinc-300",
sent: "bg-blue-900/40 text-blue-700",
delivered: "bg-emerald-100 text-emerald-700",
opened: "bg-violet-100 text-violet-700",
clicked: "bg-amber-100 text-amber-700",
bounced: "bg-red-900/40 text-red-400",
failed: "bg-red-900/40 text-red-400",
unsubscribed: "bg-orange-100 text-orange-700",
};
const METHOD_COLORS: Record<string, string> = {
email: "bg-blue-900/40 text-blue-700",
sms: "bg-green-900/40 text-green-400",
push: "bg-purple-100 text-purple-700",
internal: "bg-zinc-950 text-zinc-400",
};
function EngagementBadges({ log }: { log: MessageLogEntry }) {
const badges = [];
if (log.delivered_at) {
badges.push(
<span key="delivered" className="rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-xs font-medium">
Delivered
</span>
);
}
if (log.opened_at) {
badges.push(
<span key="opened" className="rounded-full bg-violet-100 text-violet-700 px-2 py-0.5 text-xs font-medium">
Opened {formatDate(new Date(log.opened_at))}
</span>
);
}
if (log.clicked_at) {
badges.push(
<span key="clicked" className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-xs font-medium">
Clicked {formatDate(new Date(log.clicked_at))}
</span>
);
}
if (log.bounced_at) {
badges.push(
<span key="bounced" className="rounded-full bg-red-900/40 text-red-400 px-2 py-0.5 text-xs font-medium">
Bounced
{log.bounce_reason && <span className="ml-1">{log.bounce_reason}</span>}
</span>
);
}
return badges.length > 0 ? <div className="flex flex-wrap gap-1 mt-1">{badges}</div> : null;
}
export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageLogEntry[] }) {
const [logs] = useState(initialLogs);
const [filterStatus, setFilterStatus] = useState("all");
const [filterMethod, setFilterMethod] = useState("all");
const filtered = logs.filter((l) => {
if (filterStatus !== "all" && l.status !== filterStatus) return false;
if (filterMethod !== "all" && l.delivery_method !== filterMethod) return false;
return true;
});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Message Logs</h2>
<p className="text-sm text-zinc-500">{filtered.length} message{filtered.length !== 1 ? "s" : ""}</p>
</div>
</div>
<div className="flex gap-4 mb-4 flex-wrap">
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<option value="all">All Statuses</option>
<option value="queued">Queued</option>
<option value="sent">Sent</option>
<option value="delivered">Delivered</option>
<option value="opened">Opened</option>
<option value="clicked">Clicked</option>
<option value="bounced">Bounced</option>
<option value="failed">Failed</option>
</select>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterMethod}
onChange={(e) => setFilterMethod(e.target.value)}
>
<option value="all">All Methods</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push</option>
<option value="internal">Internal</option>
</select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">No messages logged yet</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent At</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Recipient</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Method</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Engagement</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((l) => (
<tr key={l.id} className="hover:bg-zinc-800">
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
{l.sent_at ? new Date(l.sent_at).toLocaleString() : "—"}
</td>
<td className="px-4 py-3 text-zinc-300 text-xs">{l.customer_email ?? "—"}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
{l.delivery_method}
</span>
</td>
<td className="px-4 py-3 text-zinc-300 text-xs truncate max-w-[180px]">{l.subject ?? "—"}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
{l.status}
</span>
{l.error_message && (
<p className="text-xs text-red-500 mt-0.5 truncate max-w-[120px]">{l.error_message}</p>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
{l.delivered_at && (
<span className="text-xs text-emerald-600"> {formatDate(new Date(l.delivered_at))}</span>
)}
{l.opened_at && (
<span className="text-xs text-violet-600">Opened {formatDate(new Date(l.opened_at))}</span>
)}
{l.clicked_at && (
<span className="text-xs text-amber-400">Clicked {formatDate(new Date(l.clicked_at))}</span>
)}
{l.bounced_at && (
<span className="text-xs text-red-500"> Bounced{l.bounce_reason ? `: ${l.bounce_reason}` : ""}</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filtered.map((l) => (
<div key={l.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="text-sm font-medium text-zinc-100">{l.customer_email ?? "—"}</div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
{l.status}
</span>
</div>
<div className="text-xs text-zinc-500">{l.subject ?? "—"}</div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
{l.delivery_method}
</span>
<span className="text-xs text-slate-400">
{l.sent_at ? formatDate(new Date(l.sent_at)) : "—"}
</span>
</div>
<EngagementBadges log={l} />
</div>
))}
</div>
</>
)}
</div>
);
}
+314
View File
@@ -0,0 +1,314 @@
"use client";
import NextImage from "next/image";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage } from "@/actions/products/upload-image";
import { createProduct } from "@/actions/products/create-product";
export default function NewProductForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImageUrl, setPendingImageUrl] = useState<string>("");
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
return;
}
// Client-side resize to max 1200px width
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
const result = await uploadProductImage("__NEW__", resizedFile);
setUploading(false);
if (result.success) {
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const price = parseFloat(formData.get("price") as string);
const type = formData.get("type") as string;
const brandId = formData.get("brand_id") as string;
const active = formData.get("active") === "true";
// Use pendingImageUrl if image was uploaded, otherwise null
const imageUrl = pendingImageUrl || null;
const isTaxable = formData.get("is_taxable") === "true";
const pickup_type = formData.get("pickup_type") as string;
const result = await createProduct(brandId, {
name,
description,
price,
type,
active,
image_url: imageUrl,
is_taxable: isTaxable,
pickup_type,
});
if (!result.success) {
setError(result.error ?? "Failed to create product");
setLoading(false);
return;
}
router.push("/admin/products");
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-300">
Product Name
</label>
<input
name="name"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Dozen Sweet Corn"
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Description
</label>
<textarea
name="description"
rows={3}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Fresh-picked Olathe sweet corn."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">
Price ($)
</label>
<input
name="price"
type="number"
step="0.01"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="12.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Type
</label>
<select
name="type"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="Pickup">Pickup</option>
<option value="Shipping">Shipping</option>
<option value="Pickup & Shipping">Pickup & Shipping</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Brand
</label>
<select
name="brand_id"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="">Select brand...</option>
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">
Tuxedo Corn
</option>
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">
Indian River Direct
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Taxable</label>
<p className="text-xs text-zinc-500 mb-2">Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.</p>
<select
name="is_taxable"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes taxable (default)</option>
<option value="false">No non-taxable</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Pickup Type</label>
<p className="text-xs text-zinc-500 mb-2">Shed pickup uses the product description as the location no stop required at checkout.</p>
<select
name="pickup_type"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="scheduled_stop">Scheduled Stop requires stop selection at checkout</option>
<option value="shed">Shed Pickup uses description as location</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Active
</label>
<select
name="active"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-red-400" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-zinc-500">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Creating..." : "Create Product"}
</button>
<a
href="/admin/products"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
>
Cancel
</a>
</div>
</form>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createStop } from "@/actions/stops/create-stop";
type Stop = {
city: string;
state: string;
location: string;
date: string;
time: string;
brand_id: string;
active: boolean;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
};
type Props = {
duplicateFrom?: Stop | null;
};
export default function NewStopForm({ duplicateFrom }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const city = formData.get("city") as string;
const state = formData.get("state") as string;
const location = formData.get("location") as string;
const date = formData.get("date") as string;
const time = formData.get("time") as string;
const brandId = formData.get("brand_id") as string;
const active = formData.get("active") === "true";
const result = await createStop(brandId, {
city,
state,
location,
date,
time,
active,
address: (formData.get("address") as string) || null,
zip: (formData.get("zip") as string) || null,
cutoff_time: (formData.get("cutoff_time") as string) || null,
});
if (!result.success) {
setError(result.error ?? "Failed to create stop");
setLoading(false);
return;
}
if (result.id) {
router.push(`/admin/stops/${result.id}`);
} else {
router.push("/admin/stops");
}
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">City</label>
<input
name="city"
required
defaultValue={duplicateFrom?.city}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Denver"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">State</label>
<input
name="state"
required
maxLength={2}
defaultValue={duplicateFrom?.state}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. CO"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Location Name</label>
<input
name="location"
required
defaultValue={duplicateFrom?.location}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Southwest Plaza Parking Lot"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Date</label>
<input
name="date"
required
type="date"
defaultValue={duplicateFrom?.date}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Time</label>
<input
name="time"
required
type="text"
defaultValue={duplicateFrom?.time}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. 8:00 AM 2:00 PM"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Brand</label>
<select
name="brand_id"
required
defaultValue={defaultBrand}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="">Select brand...</option>
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Active</label>
<select
name="active"
defaultValue="true"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
<input
name="address"
defaultValue={duplicateFrom?.address ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="123 Main St"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">ZIP Code</label>
<input
name="zip"
maxLength={10}
defaultValue={duplicateFrom?.zip ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="80102"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Order Cutoff</label>
<input
name="cutoff_time"
type="datetime-local"
defaultValue={duplicateFrom?.cutoff_time ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
/>
<p className="mt-1 text-xs text-slate-400">
Customers must order before this time to be included at this stop.
</p>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Creating..." : "Create Stop"}
</button>
<a
href="/admin/stops"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
>
Cancel
</a>
</div>
</form>
);
}
+440
View File
@@ -0,0 +1,440 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
discount_amount: number;
tax_amount: number | null;
tax_rate: number | null;
tax_source: string | null;
tax_location: string | null;
internal_notes: string | null;
discount_reason: string | null;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
stops: { city: string; state: string; date: string } | null;
order_items: OrderItem[];
};
type OrderEditFormProps = {
order: Order;
brandId: string | null;
};
type EditableItem = {
id: string;
product_id: string;
quantity: number;
price: number;
productName: string;
removed: boolean;
};
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [customer_name, setCustomer_name] = useState(order.customer_name);
const [customer_email, setCustomer_email] = useState(order.customer_email ?? "");
const [customer_phone, setCustomer_phone] = useState(order.customer_phone ?? "");
const [discount_amount, setDiscount_amount] = useState(order.discount_amount ?? 0);
const [discount_reason, setDiscount_reason] = useState(order.discount_reason ?? "");
const [internal_notes, setInternal_notes] = useState(order.internal_notes ?? "");
const [status, setStatus] = useState(order.status);
const [pickup_complete, setPickup_complete] = useState(order.pickup_complete);
const [items, setItems] = useState<EditableItem[]>(
order.order_items.map((item) => ({
id: item.id,
product_id: item.product_id,
quantity: item.quantity,
price: Number(item.price),
productName: item.products?.name ?? "Unknown",
removed: false,
}))
);
const visibleItems = items.filter((i) => !i.removed);
const subtotal = visibleItems.reduce(
(sum, i) => sum + Number(i.price) * i.quantity,
0
);
const total = Math.max(0, subtotal - discount_amount);
function updateItem(id: string, field: "quantity" | "price", value: number) {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, [field]: value } : i))
);
}
function removeItem(id: string) {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, removed: true } : i))
);
}
async function handleSave() {
setSaving(true);
setError(null);
setSaved(false);
const toSave = items.filter((i) => !i.removed);
const toRemove = items.filter((i) => i.removed);
for (const item of toRemove) {
const result = await deleteOrderItem(item.id);
if (!result.success) {
setError(result.error ?? "Failed to remove item");
setSaving(false);
return;
}
}
for (const item of toSave) {
const orig = order.order_items.find((o) => o.id === item.id);
if (!orig) continue;
if (
Number(orig.quantity) !== item.quantity ||
Number(orig.price) !== item.price
) {
const result = await updateOrderItem(item.id, {
quantity: item.quantity,
price: Number(item.price),
});
if (!result.success) {
setError(result.error ?? "Failed to update item");
setSaving(false);
return;
}
}
}
const result = await updateOrder(order.id, brandId, {
customer_name,
customer_email: customer_email || null,
customer_phone: customer_phone || null,
discount_amount,
discount_reason: discount_reason || null,
internal_notes: internal_notes || null,
status,
pickup_complete,
pickup_completed_at: pickup_complete ? new Date().toISOString() : null,
subtotal,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
return;
}
setSaved(true);
setSaving(false);
router.refresh();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Order updated successfully.
</div>
)}
{/* Order items */}
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">
Order Items ({visibleItems.length})
</p>
{visibleItems.length === 0 ? (
<p className="rounded-xl border border-dashed border-zinc-600 p-4 text-center text-sm text-zinc-500">
No items.
</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<div
key={item.id}
className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium text-zinc-100">{item.productName}</p>
<button
onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-400 hover:bg-red-900/30"
>
Remove
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Qty
</label>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value))
}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Price
</label>
<input
type="number"
step="0.01"
min="0"
value={item.price}
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
</div>
<p className="mt-2 text-right text-sm font-semibold text-zinc-100">
${(Number(item.price) * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
)}
</div>
{/* Pricing — subtotal derived from items */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Subtotal (auto-calculated)
</label>
<input
type="number"
step="0.01"
value={subtotal}
readOnly
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Discount Amount
</label>
<input
type="number"
step="0.01"
min="0"
value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Discount Reason
</label>
<input
type="text"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Tax Amount
</label>
<input
type="number"
step="0.01"
min="0"
value={order.tax_amount ?? 0}
onChange={(e) => {}}
readOnly
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
<p className="mt-1 text-xs text-slate-400 italic">Taxes calculated at payment integration.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Tax Rate
</label>
<input
type="text"
value={order.tax_rate ?? ""}
readOnly
placeholder="e.g. 0.08"
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Tax Location
</label>
<input
type="text"
value={order.tax_location ?? ""}
readOnly
placeholder="e.g. NC"
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div className="flex justify-between">
<span className="text-lg font-medium text-zinc-300">Total</span>
<span className="text-2xl font-bold text-zinc-100">
${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}
</span>
</div>
</div>
</div>
{/* Customer fields */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Name
</label>
<input
type="text"
value={customer_name}
onChange={(e) => setCustomer_name(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={customer_email}
onChange={(e) => setCustomer_email(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Phone
</label>
<input
type="tel"
value={customer_phone}
onChange={(e) => setCustomer_phone(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
</div>
{/* Status & pickup */}
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Status
</label>
<div className="flex gap-3">
{["pending", "confirmed", "cancelled"].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium capitalize transition-colors ${
status === s
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Pickup
</label>
<button
onClick={() => setPickup_complete((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
pickup_complete
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{pickup_complete ? "Picked Up" : "Not Picked Up"}
</button>
{order.pickup_complete && order.pickup_completed_at && (
<p className="mt-1 text-xs text-slate-400">
Completed{" "}
{new Date(order.pickup_completed_at).toLocaleString()}
</p>
)}
</div>
</div>
{/* Internal notes */}
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Internal Notes
</label>
<textarea
value={internal_notes}
onChange={(e) => setInternal_notes(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
placeholder="Private notes for staff..."
/>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
@@ -0,0 +1,268 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/format-date";
import { updateOrder } from "@/actions/orders/update-order";
import { createRefund } from "@/actions/orders/create-refund";
type Refund = {
id: string;
order_id: string;
amount: number;
reason: string | null;
processor: string | null;
processor_refund_id: string | null;
status: string;
created_at: string;
};
type OrderPaymentSectionProps = {
orderId: string;
brandId: string | null;
orderTotal: number;
payment_processor: string | null;
payment_status: string | null;
payment_transaction_id: string | null;
refunded_amount: number;
refund_reason: string | null;
existingRefunds: Refund[];
};
export default function OrderPaymentSection({
orderId,
brandId,
orderTotal,
payment_processor,
payment_status,
payment_transaction_id,
refunded_amount,
refund_reason,
existingRefunds,
}: OrderPaymentSectionProps) {
const router = useRouter();
const [processor, setProcessor] = useState(payment_processor ?? "");
const [status, setStatus] = useState(payment_status ?? "manual");
const [transactionId, setTransactionId] = useState(payment_transaction_id ?? "");
const [refundAmount, setRefundAmount] = useState("");
const [refundReason, setRefundReason] = useState("");
const [saving, setSaving] = useState(false);
const [refunding, setRefunding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [refundSaved, setRefundSaved] = useState(false);
async function handleSavePayment(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
const result = await updateOrder(orderId, brandId, {
payment_processor: processor || null,
payment_status: status,
payment_transaction_id: transactionId || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
}
setSaving(false);
}
async function handleRefund(e: React.FormEvent) {
e.preventDefault();
const amount = Number(refundAmount);
if (!amount || amount <= 0) return;
setRefunding(true);
setError(null);
setRefundSaved(false);
const result = await createRefund(orderId, brandId, {
amount,
reason: refundReason || null,
});
if (!result.success) {
setError(result.error ?? "Failed to record refund");
} else {
setRefundAmount("");
setRefundReason("");
setRefundSaved(true);
}
setRefunding(false);
}
const totalRefunded = existingRefunds
.filter((r) => r.status === "completed")
.reduce((sum, r) => sum + Number(r.amount), 0);
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
)}
{/* Payment details */}
<form onSubmit={handleSavePayment} className="space-y-4">
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Payment details saved.
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Processor
</label>
<select
value={processor}
onChange={(e) => setProcessor(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
<option value=""></option>
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
<option key={p} value={p}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Payment Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Transaction ID
</label>
<input
type="text"
value={transactionId}
onChange={(e) => setTransactionId(e.target.value)}
placeholder="External payment reference"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<button
type="submit"
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Payment Details"}
</button>
</form>
{/* Refund history */}
{existingRefunds.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">
Refunds ({existingRefunds.length})
</p>
<div className="space-y-2">
{existingRefunds.map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div>
<p className="font-medium text-zinc-100">
${Number(r.amount).toFixed(2)}
</p>
{r.reason && (
<p className="text-xs text-zinc-500">{r.reason}</p>
)}
<p className="text-xs text-slate-400">
{formatDate(new Date(r.created_at))}
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
r.status === "completed"
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
>
{r.status}
</span>
</div>
))}
</div>
<div className="mt-2 flex justify-between rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm">
<span className="text-zinc-500">Total refunded</span>
<span className="font-medium text-zinc-100">
${totalRefunded.toFixed(2)}
</span>
</div>
</div>
)}
{/* Record a refund */}
<div className="rounded-xl border border-dashed border-zinc-600 p-4">
<p className="mb-3 text-sm font-medium text-zinc-300">Record a Refund</p>
{refundSaved && (
<div className="mb-3 rounded-xl bg-green-900/30 p-3 text-sm text-green-400">
Refund recorded.
</div>
)}
<form onSubmit={handleRefund} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Amount
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingBalance}
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
placeholder={`Max $${remainingBalance.toFixed(2)}`}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Reason
</label>
<input
type="text"
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
placeholder="Customer request, defective product, etc."
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
<button
type="submit"
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
className="w-full rounded-xl border border-red-300 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{refunding ? "Processing..." : "Record Refund"}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { markPickupComplete } from "@/actions/pickup";
type Props = {
orderId: string;
brandId: string | null;
currentlyPickedUp: boolean;
};
export default function OrderPickupAction({
orderId,
brandId,
currentlyPickedUp,
}: Props) {
const [pickingUp, setPickingUp] = useState(false);
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
async function handlePickup() {
setPickingUp(true);
const result = await markPickupComplete(orderId, brandId);
setPickingUp(false);
if (result.success) {
setToast({ type: "success", msg: "Order marked as picked up." });
setTimeout(() => window.location.reload(), 1200);
} else {
setToast({ type: "error", msg: result.error ?? "Failed to mark picked up." });
}
}
if (currentlyPickedUp) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Picked Up
</span>
);
}
return (
<div className="flex flex-col items-start gap-2">
<button
onClick={handlePickup}
disabled={pickingUp}
className="rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{pickingUp ? "Marking..." : "Mark Picked Up"}
</button>
{toast && (
<div
className={`rounded-lg px-3 py-2 text-sm font-medium ${
toast.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"
}`}
>
{toast.msg}
</div>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
stop_id: string;
status: string;
subtotal: number;
pickup_complete: boolean;
created_at: string;
};
export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
);
async function togglePickup(orderId: string, current: boolean) {
setPickupToggles((prev) => ({ ...prev, [orderId]: !current }));
await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
{
method: "PATCH",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({ pickup_complete: !current }),
}
);
}
const statusColors: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-700",
confirmed: "bg-blue-900/40 text-blue-700",
paid: "bg-green-900/40 text-green-400",
cancelled: "bg-red-900/40 text-red-400",
completed: "bg-zinc-950 text-zinc-400",
};
return (
<tbody className="divide-y divide-slate-200">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-zinc-800">
<td className="px-5 py-4">
<span className="font-mono text-sm text-zinc-500">
{order.id.slice(0, 8)}
</span>
</td>
<td className="px-5 py-4">
<div className="font-medium text-zinc-100">
{order.customer_name}
</div>
<div className="text-sm text-zinc-500">
{order.customer_email}
</div>
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
statusColors[order.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
{order.status ?? "pending"}
</span>
</td>
<td className="px-5 py-4 font-semibold text-zinc-100">
${Number(order.subtotal).toFixed(2)}
</td>
<td className="px-5 py-4">
<button
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className={`rounded-full px-3 py-1 text-xs font-medium ${
pickupToggles[order.id]
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</button>
</td>
<td className="px-5 py-4 text-sm text-zinc-500">
{formatDate(new Date(order.created_at))}
</td>
</tr>
))}
</tbody>
);
}
@@ -0,0 +1,449 @@
"use client";
import { useState, useEffect } from "react";
import { savePaymentSettings, type PaymentProvider, type PaymentSettings } from "@/actions/payments";
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import WebhookLogsSection from "@/components/admin/WebhookLogsSection";
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
type Props = {
settings: {
provider?: PaymentProvider | null;
stripe_publishable_key?: string | null;
stripe_secret_key?: string | null;
square_access_token?: string | null;
square_location_id?: string | null;
square_sync_enabled?: boolean;
square_inventory_mode?: InventoryMode;
square_last_sync_at?: string | null;
square_last_sync_error?: string | null;
} | null;
brandId: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
};
export default function PaymentSettingsForm({ settings, brandId, brands = [], isPlatformAdmin = false }: Props) {
const [activeBrandId, setActiveBrandId] = useState(brandId);
const [provider, setProvider] = useState<PaymentProvider | "">(
(settings?.provider ?? "") as PaymentProvider | ""
);
const [stripePublishableKey, setStripePublishableKey] = useState(
settings?.stripe_publishable_key ?? ""
);
const [stripeSecretKey, setStripeSecretKey] = useState(
settings?.stripe_secret_key ?? ""
);
const [squareAccessToken, setSquareAccessToken] = useState(
settings?.square_access_token ?? ""
);
const [squareLocationId, setSquareLocationId] = useState(
settings?.square_location_id ?? ""
);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showStripe, setShowStripe] = useState(settings?.provider === "stripe" || settings?.provider === "square" ? settings.provider === "stripe" : false);
const [showSquare, setShowSquare] = useState(settings?.provider === "square");
// Square Sync state
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
settings?.square_sync_enabled ?? false
);
const [squareInventoryMode, setSquareInventoryMode] = useState<InventoryMode>(
settings?.square_inventory_mode ?? "none"
);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<{ success: boolean; message: string } | null>(null);
const [syncLog, setSyncLog] = useState<SyncLogEntry[]>([]);
const hasSquareToken = !!settings?.square_access_token;
// Read URL params to show connection success/error
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("square_connected") === "true") {
setSyncResult({ success: true, message: "Square connected successfully!" });
window.history.replaceState({}, "", window.location.pathname);
}
const err = params.get("error");
if (err) {
setSyncResult({ success: false, message: `Square connection failed: ${err}` });
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
// Load sync log on mount
useEffect(() => {
if (hasSquareToken) {
getSyncLog(activeBrandId).then((result) => {
if (result.success) setSyncLog(result.logs);
});
}
}, [hasSquareToken, activeBrandId]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await savePaymentSettings({
brandId: activeBrandId,
provider: provider || null,
stripePublishableKey: stripePublishableKey || undefined,
stripeSecretKey: stripeSecretKey || undefined,
squareAccessToken: squareAccessToken || undefined,
squareLocationId: squareLocationId || undefined,
squareSyncEnabled,
squareInventoryMode,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
async function handleSyncNow(type: string) {
setSyncing(true);
setSyncResult(null);
const result = await syncSquareNow(activeBrandId, type as "products" | "orders" | "all");
setSyncResult({
success: result.success,
message: result.success
? `Synced ${result.synced} item(s).`
: `Sync failed: ${result.errors?.[0] ?? "Unknown error"}`,
});
setSyncing(false);
// Refresh sync log
const logResult = await getSyncLog(brandId);
if (logResult.success) setSyncLog(logResult.logs);
}
async function handleDisconnectSquare() {
if (!confirm("Disconnect Square? This will clear the access token.")) return;
setSaving(true);
setError(null);
const result = await savePaymentSettings({
brandId: activeBrandId,
provider: provider || null,
squareAccessToken: "",
squareLocationId: "",
squareSyncEnabled: false,
squareInventoryMode: "none",
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to disconnect");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
const lastSyncAt = settings?.square_last_sync_at
? new Date(settings.square_last_sync_at).toLocaleString()
: "Never";
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{error && (
<div className="rounded-xl bg-red-900/50 border border-red-700 p-4 text-sm text-red-200">{error}</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/50 border border-green-700 p-4 text-sm text-green-200">
Payment settings saved.
</div>
)}
{syncResult && (
<div className={`rounded-xl p-4 text-sm border ${
syncResult.success ? "bg-green-900/50 border-green-700 text-green-200" : "bg-red-900/50 border-red-700 text-red-200"
}`}>
{syncResult.message}
</div>
)}
{/* Connected status banner */}
{settings?.stripe_publishable_key && (
<div className="flex items-center gap-3 rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-green-900/300 text-white text-xs font-bold"></span>
<div>
<p className="font-semibold text-green-300">Stripe Connected</p>
<p className="text-xs text-green-400/70">Payments are configured for this brand</p>
</div>
</div>
)}
{/* Provider selection */}
<div>
<label className="block text-sm font-semibold text-zinc-300">Payment Provider</label>
<p className="mt-1 text-sm text-zinc-500">
Choose how to process payments for this brand.
</p>
<div className="mt-3 flex gap-3">
{[
{ value: "", label: "None / Manual" },
{ value: "stripe", label: "Stripe" },
{ value: "square", label: "Square" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setProvider(opt.value as PaymentProvider | "");
setShowStripe(opt.value === "stripe");
setShowSquare(opt.value === "square");
}}
className={`rounded-xl border px-4 py-3 text-sm font-medium ${
provider === opt.value
? "border-zinc-400 bg-zinc-700 text-zinc-50"
: "border-zinc-600 text-zinc-400 hover:bg-zinc-800"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Stripe credentials */}
{showStripe && (
<div className="space-y-4 rounded-xl border border-blue-200 bg-blue-900/30 p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-900">Stripe</h3>
{settings?.stripe_publishable_key && (
<span className="flex items-center gap-1.5 rounded-full bg-green-900/40 px-2.5 py-1 text-xs font-semibold text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-900/300"></span>
Connected
</span>
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Publishable Key</label>
<input
type="text"
value={stripePublishableKey}
onChange={(e) => setStripePublishableKey(e.target.value)}
placeholder="pk_live_..."
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Secret Key</label>
<input
type="password"
value={stripeSecretKey}
onChange={(e) => setStripeSecretKey(e.target.value)}
placeholder="sk_live_..."
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<button
type="submit"
disabled={saving}
className="rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Stripe Settings"}
</button>
</div>
)}
{/* Square credentials */}
{showSquare && (
<div className="space-y-4 rounded-xl border border-green-200 bg-green-900/30 p-4">
<h3 className="font-semibold text-green-900">Square</h3>
{!hasSquareToken ? (
<div className="space-y-3">
<p className="text-sm text-green-400">
Connect your Square account via OAuth to enable sync.
</p>
<a
href="/api/square/oauth"
className="inline-block rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700"
>
Connect Square
</a>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-zinc-300">Location ID</label>
<input
type="text"
value={squareLocationId}
onChange={(e) => setSquareLocationId(e.target.value)}
placeholder="L..."
className="mt-1 w-full rounded-xl border border-zinc-600 px-4 py-3 text-sm font-mono outline-none focus:border-slate-900"
/>
</div>
<button
type="button"
onClick={handleDisconnectSquare}
className="text-sm text-red-400 hover:underline"
>
Disconnect Square
</button>
</>
)}
</div>
)}
{/* Square Sync section */}
{hasSquareToken && provider === "square" && (
<div className="space-y-5 rounded-xl border border-purple-200 bg-purple-50 p-5">
<div>
<h3 className="font-semibold text-purple-900">Square Sync</h3>
<p className="mt-1 text-sm text-purple-700">
Keep products, orders, and inventory in sync between Route Commerce and Square.
</p>
</div>
{/* Enable toggle */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSquareSyncEnabled(!squareSyncEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
squareSyncEnabled ? "bg-purple-600" : "bg-slate-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${
squareSyncEnabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-sm font-medium text-purple-900">
{squareSyncEnabled ? "Enabled" : "Disabled"}
</span>
</div>
{squareSyncEnabled && (
<>
{/* Inventory mode */}
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">Inventory sync direction</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ value: "none", label: "None" },
{ value: "rc_to_square", label: "RC → Square" },
{ value: "square_to_rc", label: "Square → RC" },
{ value: "bidirectional", label: "Bidirectional" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSquareInventoryMode(opt.value as InventoryMode)}
className={`rounded-lg border px-3 py-2 text-xs font-medium ${
squareInventoryMode === opt.value
? "border-purple-600 bg-purple-600 text-white"
: "border-zinc-600 text-zinc-400 hover:bg-purple-100"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Sync Now buttons */}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-purple-300 bg-zinc-900 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync Products Now"}
</button>
<button
type="button"
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-purple-300 bg-zinc-900 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync Orders Now"}
</button>
<button
type="button"
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-700 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync All Now"}
</button>
</div>
{/* Last sync info */}
<div className="text-xs text-zinc-500">
Last sync: {lastSyncAt}
{settings?.square_last_sync_error && (
<span className="ml-2 text-red-500"> {settings.square_last_sync_error}</span>
)}
</div>
{/* Sync log preview */}
{syncLog.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
Recent sync activity
</p>
<div className="space-y-1">
{syncLog.map((entry) => (
<div
key={entry.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
entry.status === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}
>
<span>
[{entry.direction ?? "—"}] {entry.event_type} {entry.status}
</span>
<span className="text-slate-400">
{new Date(entry.created_at).toLocaleTimeString()}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Wholesale webhook log */}
<WebhookLogsSection brandId={activeBrandId} />
<button
type="submit"
disabled={saving}
className="rounded-xl bg-zinc-100 px-6 py-3 text-sm font-bold text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Payment Settings"}
</button>
</form>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
export default function ProductAssignmentForm({
stopId,
allProducts,
assignedProductIds,
}: {
stopId: string;
allProducts: Product[];
assignedProductIds: string[];
}) {
const [selected, setSelected] = useState("");
const [loading, setLoading] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set(assignedProductIds));
const [error, setError] = useState<string | null>(null);
const availableProducts = allProducts.filter(
(p) => !assignedIds.has(p.id)
);
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setLoading(true);
setError(null);
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,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error ?? "Failed to assign product");
setLoading(false);
return;
}
setAssignedIds((prev) => new Set(prev).add(selected));
setSelected("");
setLoading(false);
}
async function handleRemove(productId: string) {
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,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error ?? "Failed to remove");
return;
}
setAssignedIds((prev) => {
const next = new Set(prev);
next.delete(productId);
return next;
});
}
const assignedProducts = allProducts.filter((p) => assignedIds.has(p.id));
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Assigned products list */}
{assignedProducts.length > 0 && (
<div>
<p className="text-sm font-medium text-zinc-300 mb-3">
Currently assigned
</p>
<div className="space-y-2">
{assignedProducts.map((product) => (
<div
key={product.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 p-4"
>
<div>
<p className="font-medium text-zinc-100">{product.name}</p>
<p className="text-sm text-zinc-500">
{product.type} · ${product.price}
</p>
</div>
<button
onClick={() => handleRemove(product.id)}
className="text-sm text-red-400 hover:text-red-800"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Assign form */}
<form onSubmit={handleAssign} className="flex gap-3">
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
required
className="flex-1 rounded-xl border border-zinc-600 px-4 py-3 outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.name} {product.type} (${product.price})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || loading}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Assigning..." : "Assign Product"}
</button>
</form>
{availableProducts.length === 0 && assignedProducts.length > 0 && (
<p className="text-sm text-zinc-500">All products already assigned.</p>
)}
{allProducts.length === 0 && (
<p className="text-sm text-zinc-500">
No active products for this brand yet.
</p>
)}
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import NextImage from "next/image";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
import { updateProduct } from "@/actions/products/update-product";
type Brand = {
id: string;
name: string;
slug: string;
};
type ProductEditFormProps = {
product: {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
brand_id: string;
image_url?: string | null;
is_taxable?: boolean;
pickup_type?: string;
};
brands: Brand[];
};
export default function ProductEditForm({ product, brands }: ProductEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [name, setName] = useState(product.name);
const [description, setDescription] = useState(product.description);
const [price, setPrice] = useState(product.price);
const [type, setType] = useState(product.type);
const [active, setActive] = useState(product.active);
const [brand_id, setBrand_id] = useState(product.brand_id);
const [image_url, setImage_url] = useState(product.image_url ?? "");
const [is_taxable, setIs_taxable] = useState(product.is_taxable ?? true);
const [pickup_type, setPickup_type] = useState(product.pickup_type ?? "scheduled_stop");
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(product.image_url ?? null);
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
return;
}
// Client-side resize to max 1200px width
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
const result = await uploadProductImage(product.id, resizedFile);
setUploading(false);
if (result.success) {
setImage_url(result.imageUrl);
setImagePreview(result.imageUrl);
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
async function handleRemoveImage() {
const result = await deleteProductImage(product.id);
if (result.success) {
setImage_url("");
setImagePreview(null);
}
}
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function handleSave() {
if (!name.trim()) {
setError("Product name is required.");
return;
}
setSaving(true);
setError(null);
setSaved(false);
const result = await updateProduct(product.id, brand_id, {
name,
description,
price: Number(price),
type,
active,
image_url: image_url || null,
is_taxable,
pickup_type,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
return;
}
setSaved(true);
setSaving(false);
router.refresh();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Product updated successfully.
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Price</label>
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Type</label>
<input
type="text"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. Sweet Corn"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
<button
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{active ? "Active" : "Inactive"}
</button>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Taxable</label>
<button
onClick={() => setIs_taxable((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200"
}`}
>
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
</button>
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Pickup Type</label>
<select
value={pickup_type}
onChange={(e) => setPickup_type(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
>
<option value="scheduled_stop">Scheduled Stop requires stop selection at checkout</option>
<option value="shed">Shed Pickup uses description as location</option>
</select>
<p className="mt-1.5 text-xs text-zinc-500">Shed pickup uses the product description as the pickup location no stop required at checkout.</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-green-500 bg-green-900/30" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-zinc-500">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={handleRemoveImage}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { Product } from "@/types";
type Props = {
products: Product[];
onSearchChange: (s: string) => void;
onStatusChange: (f: "all" | "active" | "inactive") => void;
search: string;
statusFilter: "all" | "active" | "inactive";
count: number;
};
export default function ProductFilterBar({
products,
onSearchChange,
onStatusChange,
search,
statusFilter,
count,
}: Props) {
return (
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{count}</span>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { syncSquareNow, getSyncLog } from "@/actions/square-sync-ui";
import Link from "next/link";
type Props = {
brandId: string;
hasSquareToken: boolean;
};
export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [logs, setLogs] = useState<{ event_type: string; status: string; created_at: string }[]>([]);
async function handleSyncProducts() {
setSyncing(true);
setMsg(null);
const result = await syncSquareNow(brandId, "products");
setMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Products synced — ${result.synced} item(s).`
: `Failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
const logResult = await getSyncLog(brandId);
if (logResult.success) setLogs(logResult.logs.filter(l => l.entity_type === "product").slice(0, 5));
}
if (!hasSquareToken) {
return (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<span className="text-amber-700">Square not connected.</span>
<Link href="/admin/settings/payments" className="ml-2 font-medium text-emerald-600 hover:text-emerald-700 underline transition-colors">
Connect Square
</Link>
</div>
);
}
return (
<div className="mb-4">
<div className="flex items-center gap-3">
<button
onClick={handleSyncProducts}
disabled={syncing}
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
>
{syncing ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Syncing...
</span>
) : (
<span className="flex items-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sync Products to Square
</span>
)}
</button>
<Link
href="/admin/settings/square-sync"
className="text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Square Sync Settings
</Link>
</div>
{msg && (
<div className={`mt-2 rounded-lg border px-3 py-2 text-sm ${
msg.kind === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-red-200 bg-red-50 text-red-700"
}`}>
{msg.text}
</div>
)}
{logs.length > 0 && (
<div className="mt-2 space-y-1">
{logs.map((log, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-stone-500">
<span className={`rounded px-1.5 py-0.5 font-medium ${
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
}`}>{log.status}</span>
<span className="text-stone-600">{log.event_type}</span>
<span className="text-stone-400">{new Date(log.created_at).toLocaleTimeString()}</span>
</div>
))}
</div>
)}
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteProduct } from "@/actions/products";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
brands: { name: string } | { name: string }[];
is_taxable?: boolean;
};
type ProductTableBodyProps = {
products: Product[];
search: string;
statusFilter: "all" | "active" | "inactive";
onSearchChange: (v: string) => void;
onStatusChange: (v: "all" | "active" | "inactive") => void;
onDeleted: (productId: string) => void;
};
export default function ProductTableBody({
products,
search,
statusFilter,
onSearchChange,
onStatusChange,
onDeleted,
}: ProductTableBodyProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
async function handleDelete(productId: string) {
setDeletingId(productId);
setDeleteError(null);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted(productId);
} else {
setDeleteError(result.error ?? "Delete failed");
}
}
return (
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{filtered.length}</span>
</div>
{/* Delete error banner */}
{deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{deleteError}
<button
onClick={() => setDeleteError(null)}
className="ml-2 underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
<tbody className="divide-y divide-slate-200">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-zinc-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<tr
key={product.id}
className="hover:bg-zinc-800 transition-colors relative"
>
<td className="px-5 py-4">
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-zinc-100 hover:text-zinc-400"
>
{product.name}
</Link>
<div className="text-zinc-500 line-clamp-1 text-sm">
{product.description || <span className="italic text-slate-400">No description</span>}
</div>
</td>
<td className="px-5 py-4 text-zinc-300">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-zinc-300">{product.type}</td>
<td className="px-5 py-4 font-semibold text-zinc-100">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4">
{product.is_taxable === false ? (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">Non-taxable</span>
) : (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700">Taxable</span>
)}
</td>
{/* Actions */}
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-950"
>
Edit
</Link>
<button
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
>
</button>
{openMenu === product.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-zinc-900 shadow-lg ring-1 ring-zinc-700 overflow-hidden">
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-red-900/30"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6">
<p className="text-sm font-semibold text-zinc-100">
Delete "{product.name}"?
</p>
<p className="mt-2 text-xs text-zinc-500">
This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</>
);
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import React, { useState, useTransition } from "react";
import Image from "next/image";
import { deleteProduct } from "@/actions/products";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
image_url?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
products: Product[];
};
export default function ProductTableClient({ products }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
function handleDeleted() {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}
return (
<>
{/* Filter bar */}
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-sm text-stone-500 px-2">{filtered.length} products</span>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 bg-stone-50">
<tr>
<th className="px-5 py-4 font-semibold text-stone-500">Product</th>
<th className="px-5 py-4 font-semibold text-stone-500">Brand</th>
<th className="px-5 py-4 font-semibold text-stone-500">Type</th>
<th className="px-5 py-4 font-semibold text-stone-500">Price</th>
<th className="px-5 py-4 font-semibold text-stone-500">Status</th>
<th className="px-5 py-4 font-semibold text-stone-500" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-16 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<ProductRow
key={product.id}
product={product}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function ProductRowBase({
product,
onDeleted,
onDeleteError,
}: {
product: Product;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
async function handleDelete(productId: string) {
setDeletingId(productId);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{product.image_url ? (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-stone-200">
<Image
src={product.image_url}
alt={product.name}
fill
style={{ objectFit: "cover" }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
) : (
<div className="h-12 w-12 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-stone-200">
<span className="text-stone-400 text-lg"></span>
</div>
)}
<div>
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{product.name}
</Link>
<div className="text-stone-500 line-clamp-1 text-sm">
{product.description || (
<span className="italic text-stone-400">No description</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-stone-600 font-mono text-xs uppercase tracking-wide">{product.type}</td>
<td className="px-5 py-4 font-mono font-semibold text-stone-900">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
</button>
{openMenu === product.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-stone-500">
This will remove the product. If it is attached to any orders,
it will be hidden instead of deleted.
</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const ProductRow = React.memo(ProductRowBase);
+729
View File
@@ -0,0 +1,729 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import {
getReportsSummary,
getOrdersByStopReport,
getSalesByProductReport,
getFulfillmentReport,
getPickupStatusByStop,
getContactGrowthReport,
getCampaignActivityReport,
} from "@/actions/reports";
import {
exportOrdersByStop,
exportSalesByProduct,
exportPickupStatus,
exportContactGrowth,
exportFulfillment,
exportCampaignActivity,
type ReportsSummary,
type OrderByStop,
type SalesByProduct,
type FulfillmentRow,
type PickupStatusByStop,
type ContactGrowthRow,
type CampaignActivityRow,
} from "@/lib/reports-export";
import { formatDate, formatDateRange } from "@/lib/date-utils";
type DatePreset = "week" | "month" | "quarter" | "this_year" | "custom";
type DateRange = { start: string; end: string };
function buildRange(preset: DatePreset, customStart?: string, customEnd?: string): DateRange {
const now = new Date();
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
const today = toDateStr(now);
switch (preset) {
case "week": {
const s = new Date(now);
s.setDate(s.getDate() - 6);
return { start: toDateStr(s), end: today };
}
case "month": {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { start: toDateStr(start), end: today };
}
case "quarter": {
const q = Math.floor(now.getMonth() / 3);
const start = new Date(now.getFullYear(), q * 3, 1);
return { start: toDateStr(start), end: today };
}
case "this_year": {
const start = new Date(now.getFullYear(), 0, 1);
return { start: toDateStr(start), end: today };
}
case "custom":
return { start: customStart ?? today, end: customEnd ?? today };
}
}
// ── Quarter label helper ──────────────────────────────────────────────────────
function quarterLabel(startISO: string): string {
const s = new Date(startISO + "T00:00:00");
const q = Math.floor(s.getMonth() / 3) + 1;
return `Q${q} ${s.getFullYear()}`;
}
function monthLabel(startISO: string): string {
const s = new Date(startISO + "T00:00:00");
return s.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
// ── Summary card ─────────────────────────────────────────────────────────────
function SummaryCard({
label,
value,
prefix,
suffix,
}: {
label: string;
value: number;
prefix?: string;
suffix?: string;
}) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-4">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-100">
{prefix ?? ""}{typeof value === "number" ? value.toLocaleString() : value}{suffix ?? ""}
</p>
</div>
);
}
// ── CSV export button ───────────────────────────────────────────────────────
function ExportBtn({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Export CSV
</button>
);
}
function ExplainBtn({
onClick,
loading,
}: {
onClick: () => void;
loading: boolean;
}) {
return (
<button
onClick={onClick}
disabled={loading}
className="rounded-lg border border-violet-300 bg-violet-50 px-3 py-1.5 text-xs font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-50"
>
{loading ? "Analyzing..." : "🤖 Explain this Report"}
</button>
);
}
// ── Generic table ───────────────────────────────────────────────────────────
function ReportTable({ headers, rows, renderRow }: {
headers: string[];
rows: unknown[];
renderRow: (row: unknown, i: number) => React.ReactNode;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-slate-50">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-zinc-500">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={headers.length} className="px-3 py-6 text-center text-slate-400">
No data
</td>
</tr>
) : (
rows.map((_, i) => renderRow(_, i))
)}
</tbody>
</table>
</div>
);
}
// ── Tab definitions ─────────────────────────────────────────────────────────
type Tab = {
id: string;
label: string;
};
const TABS: Tab[] = [
{ id: "overview", label: "Overview" },
{ id: "orders-by-stop", label: "Orders by Stop" },
{ id: "sales-by-product", label: "Sales by Product" },
{ id: "fulfillment", label: "Fulfillment" },
{ id: "pickup-status", label: "Pickup Status" },
{ id: "contact-growth", label: "Contact Growth" },
{ id: "campaigns", label: "Campaigns" },
];
// ── Main component ──────────────────────────────────────────────────────────
export default function ReportsDashboard({
brands,
initialBrandId,
isPlatformAdmin,
brandId,
}: {
brands: { id: string; name: string }[];
initialBrandId: string | null;
isPlatformAdmin: boolean;
brandId?: string | null;
}) {
const [preset, setPreset] = useState<DatePreset>("quarter");
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
const [activeTab, setActiveTab] = useState("overview");
const [summary, setSummary] = useState<ReportsSummary | null>(null);
const [ordersByStop, setOrdersByStop] = useState<OrderByStop[]>([]);
const [salesByProduct, setSalesByProduct] = useState<SalesByProduct[]>([]);
const [fulfillment, setFulfillment] = useState<FulfillmentRow[]>([]);
const [pickupStatus, setPickupStatus] = useState<PickupStatusByStop[]>([]);
const [contactGrowth, setContactGrowth] = useState<ContactGrowthRow[]>([]);
const [campaigns, setCampaigns] = useState<CampaignActivityRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [explainingTab, setExplainingTab] = useState<string | null>(null);
const [explanation, setExplanation] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
const [explainError, setExplainError] = useState<string | null>(null);
const range: DateRange = buildRange(preset, customStart, customEnd);
// Stable memoized fetchAll — re-fetches when range or brand changes
const fetchAll = useCallback(async () => {
setLoading(true);
setError(null);
const brandId = selectedBrandId || null;
try {
const [sum, obs, sbp, ful, ps, cg, cam] = await Promise.all([
getReportsSummary(range, selectedBrandId || null),
getOrdersByStopReport(range, selectedBrandId || null),
getSalesByProductReport(range, selectedBrandId || null),
getFulfillmentReport(range, selectedBrandId || null),
getPickupStatusByStop(range, selectedBrandId || null),
getContactGrowthReport(range, selectedBrandId || null),
getCampaignActivityReport(range, selectedBrandId || null),
]);
setSummary(sum);
setOrdersByStop(obs);
setSalesByProduct(sbp);
setFulfillment(ful);
setPickupStatus(ps);
setContactGrowth(cg);
setCampaigns(cam);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load reports");
} finally {
setLoading(false);
}
}, [range, selectedBrandId]);
const handleFetch = () => {
fetchAll();
};
// ── AI Report Explainer ────────────────────────────────────────────────────────
async function handleExplain() {
setExplainingTab(activeTab);
setExplanation(null);
setExplainError(null);
const tabDataMap: Record<string, unknown[]> = {
"orders-by-stop": ordersByStop,
"sales-by-product": salesByProduct,
"fulfillment": fulfillment,
"pickup-status": pickupStatus,
"contact-growth": contactGrowth,
"campaigns": campaigns,
};
const data = tabDataMap[activeTab] ?? [];
try {
const res = await fetch("/api/ai/report-explainer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reportType: activeTab,
dateRange: range,
brandId: brandId ?? selectedBrandId,
reportData: data,
}),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error ?? "Analysis failed");
setExplanation(result);
} catch (err) {
setExplainError(err instanceof Error ? err.message : "Analysis failed");
} finally {
setExplainingTab(null);
}
}
// Close explanation panel
function closeExplanation() {
setExplanation(null);
setExplainError(null);
}
function handleExportCSV(exportFn: () => string, filename: string) {
const csv = exportFn();
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Auto-fetch when brand changes
useEffect(() => {
fetchAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBrandId]);
return (
<div className="space-y-6">
{/* ── Error banner ─────────────────────────────────────────────────── */}
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* ── Filters ────────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
{/* Date preset */}
<div className="flex gap-1.5">
{(["week", "month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<button
key={p}
onClick={() => setPreset(p)}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-all ${
preset === p
? "bg-blue-600 text-white shadow-black/20"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{p === "week" ? "This Week" : p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</button>
))}
</div>
{/* Custom date inputs */}
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input
type="date"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
<span className="text-slate-400 text-xs">to</span>
<input
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
</div>
)}
{/* Brand filter (platform_admin only) */}
{isPlatformAdmin && (
<select
value={selectedBrandId}
onChange={(e) => setSelectedBrandId(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
>
<option value="">All Brands</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
)}
<button
onClick={handleFetch}
disabled={loading}
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
<span className="ml-auto text-sm font-medium text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start) :
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
preset === "month" ? monthLabel(range.start) :
preset === "custom" ? `${range.start}${range.end}` :
formatDateRange(range.start, range.end)}
</span>
</div>
{/* ── Tab bar ────────────────────────────────────────────────────────── */}
<div className="flex gap-1 border-b border-zinc-800">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── Tab content ──────────────────────────────────────────────────────── */}
{/* OVERVIEW */}
{activeTab === "overview" && summary && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard label="Gross Sales" value={summary.gross_sales} prefix="$" />
<SummaryCard label="Total Orders" value={summary.total_orders} />
<SummaryCard label="Avg Order Value" value={summary.avg_order_value} prefix="$" />
<SummaryCard label="Contacts Added" value={summary.contacts_added} />
<SummaryCard label="Pickup Orders" value={summary.pickup_orders} />
<SummaryCard label="Shipping Orders" value={summary.shipping_orders} />
<SummaryCard label="Pending Pickups" value={summary.pending_pickups} />
<SummaryCard label="Completed Pickups" value={summary.completed_pickups} />
<SummaryCard label="Campaigns Sent" value={summary.campaigns_sent} />
<SummaryCard label="Messages Logged" value={summary.messages_logged} />
</div>
<p className="text-xs text-slate-400">
{formatDateRange(range.start, range.end)}
{selectedBrandId ? ` · Brand: ${brands.find(b => b.id === selectedBrandId)?.name ?? selectedBrandId}` : " · All Brands"}
</p>
</div>
)}
{activeTab === "overview" && !summary && !loading && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
Click Refresh to load summary data
</div>
)}
{/* ORDERS BY STOP */}
{activeTab === "orders-by-stop" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Orders by Stop</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "orders-by-stop"} />
{ordersByStop.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportOrdersByStop(ordersByStop), `orders-by-stop-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Stop", "City", "State", "Date", "Orders", "Gross Sales", "Pending", "Completed"]}
rows={ordersByStop}
renderRow={(row) => {
const r = row as OrderByStop;
return (
<tr key={r.stop_name + r.date} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.stop_name}</td>
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
<td className="px-3 py-2 text-zinc-400">{formatDate(r.date)}</td>
<td className="px-3 py-2 text-right font-medium">{r.order_count}</td>
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-amber-400">{r.pending_count}</td>
<td className="px-3 py-2 text-right text-green-600">{r.completed_count}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* SALES BY PRODUCT */}
{activeTab === "sales-by-product" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Sales by Product</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "sales-by-product"} />
{salesByProduct.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportSalesByProduct(salesByProduct), `sales-by-product-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Product", "Units Sold", "Gross Revenue", "Avg Price"]}
rows={salesByProduct}
renderRow={(row) => {
const r = row as SalesByProduct;
return (
<tr key={r.product_name} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.product_name}</td>
<td className="px-3 py-2 text-right">{r.units_sold}</td>
<td className="px-3 py-2 text-right font-medium">${r.gross_revenue.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">${r.avg_price}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* FULFILLMENT */}
{activeTab === "fulfillment" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Fulfillment Breakdown</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "fulfillment"} />
{fulfillment.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportFulfillment(fulfillment), `fulfillment-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Fulfillment Type", "Order Count", "Revenue", "% of Total"]}
rows={fulfillment}
renderRow={(row) => {
const r = row as FulfillmentRow;
return (
<tr key={r.fulfillment_type} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
r.fulfillment_type === "pickup" ? "bg-blue-900/40 text-blue-700" :
r.fulfillment_type === "shipping" ? "bg-purple-100 text-purple-700" :
"bg-zinc-950 text-zinc-300"
}`}>{r.fulfillment_type}</span>
</td>
<td className="px-3 py-2 text-right">{r.order_count}</td>
<td className="px-3 py-2 text-right font-medium">${r.revenue.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">{r.pct_of_total}%</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* PICKUP STATUS */}
{activeTab === "pickup-status" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Pickup Status by Stop</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "pickup-status"} />
{pickupStatus.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportPickupStatus(pickupStatus), `pickup-status-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Stop", "City", "Date", "Total Orders", "Pending", "Completed", "Canceled"]}
rows={pickupStatus}
renderRow={(row) => {
const r = row as PickupStatusByStop;
return (
<tr key={r.stop_name + r.date} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.stop_name}</td>
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
<td className="px-3 py-2 text-zinc-400">{formatDate(r.date)}</td>
<td className="px-3 py-2 text-right font-medium">{r.total_orders}</td>
<td className="px-3 py-2 text-right text-amber-400">{r.pending}</td>
<td className="px-3 py-2 text-right text-green-600">{r.completed}</td>
<td className="px-3 py-2 text-right text-slate-400">{r.canceled}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* CONTACT GROWTH */}
{activeTab === "contact-growth" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Contact Growth</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "contact-growth"} />
{contactGrowth.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportContactGrowth(contactGrowth), `contact-growth-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Date", "New Contacts", "Imports", "Total"]}
rows={contactGrowth}
renderRow={(row) => {
const r = row as ContactGrowthRow;
return (
<tr key={r.date} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 text-zinc-300">{r.date}</td>
<td className="px-3 py-2 text-right">{r.new_contacts}</td>
<td className="px-3 py-2 text-right text-blue-400">{r.imports}</td>
<td className="px-3 py-2 text-right font-medium">{r.total}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* CAMPAIGNS */}
{activeTab === "campaigns" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Campaign Activity</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "campaigns"} />
{campaigns.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportCampaignActivity(campaigns), `campaigns-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Campaign", "Status", "Type", "Sent At", "Messages Logged"]}
rows={campaigns}
renderRow={(row) => {
const r = row as CampaignActivityRow;
return (
<tr key={r.campaign_name} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.campaign_name}</td>
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
r.status === "sent" ? "bg-green-900/40 text-green-400" :
r.status === "draft" ? "bg-zinc-950 text-zinc-400" :
"bg-amber-100 text-amber-700"
}`}>{r.status}</span>
</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.campaign_type}</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.sent_at ?? "—"}</td>
<td className="px-3 py-2 text-right">{r.messages_logged}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* ── AI Explanation Panel ──────────────────────────────────────────────── */}
{explanation && (
<div className="rounded-2xl border border-violet-200 bg-gradient-to-br from-violet-50 to-white shadow-lg overflow-hidden">
<div className="bg-violet-600 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">🤖</span>
<h3 className="font-semibold text-white">AI Report Analysis</h3>
<span className="text-xs text-violet-200 ml-1">· {activeTab.replace("-", " ")}</span>
</div>
<button onClick={closeExplanation} className="text-violet-200 hover:text-white p-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Summary</p>
<p className="text-sm text-zinc-300 leading-relaxed">{explanation.summary}</p>
</div>
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Key Insights</p>
<ul className="space-y-2">
{explanation.keyInsights.map((insight, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-violet-500 mt-0.5 flex-shrink-0"></span>
<span>{insight}</span>
</li>
))}
</ul>
</div>
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
<p className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-2">Suggested Actions</p>
<ul className="space-y-2">
{explanation.suggestedActions.map((action, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-green-500 mt-0.5 flex-shrink-0"></span>
<span>{action}</span>
</li>
))}
</ul>
</div>
<div className="rounded-lg bg-amber-900/30 border border-amber-200 p-3 text-xs text-amber-700">
AI-generated suggestions review before use. Not a substitute for business judgment.
</div>
</div>
</div>
)}
{explainError && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{explainError}
</div>
)}
</div>
);
}
@@ -0,0 +1,380 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { createStopsBatch } from "@/actions/stops";
import type { ParsedStopRow } from "@/lib/csv-parsers";
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
type Step = "idle" | "parsing" | "review" | "importing" | "done" | "error";
type Props = {
brandId: string;
onClose: () => void;
onComplete: (count: number) => void;
};
export default function ScheduleImportModal({ brandId, onClose, onComplete }: Props) {
const [step, setStep] = useState<Step>("idle");
const [error, setError] = useState<string | null>(null);
const [parsedStops, setParsedStops] = useState<ParsedStop[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const [dragOver, setDragOver] = useState(false);
const [useAI, setUseAI] = useState(false);
const [created, setCreated] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const processFile = useCallback(async (file: File) => {
setError(null);
setStep("parsing");
let text: string;
try {
text = await file.text();
} catch {
setError("Could not read file. Try a different format.");
setStep("idle");
return;
}
try {
const res = await fetch("/api/stops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, brandId, useAI }),
});
const data = await res.json();
if (!res.ok || (data.error && data.stops?.length === 0)) {
setError(data.error ?? "Parsing failed. Try a CSV file.");
setStep("idle");
return;
}
if (!data.stops || data.stops.length === 0) {
setError("No stops found in file. Check that columns include: city, state, location, date, time.");
setStep("idle");
return;
}
setParsedStops(data.stops);
setWarnings(data.warnings ?? []);
setStep("review");
} catch {
setError("Network error while parsing file.");
setStep("idle");
}
}, [brandId, useAI]);
function handleFile(file: File) {
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (!["csv", "txt", "json"].includes(ext)) {
setError("Unsupported file type. Please upload a CSV, TXT, or JSON file.");
return;
}
processFile(file);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
function updateStop(idx: number, field: keyof ParsedStop, value: string) {
setParsedStops((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
return next;
});
}
function removeStop(idx: number) {
setParsedStops((prev) => prev.filter((_, i) => i !== idx));
}
async function handleImport() {
if (parsedStops.length === 0) return;
setStep("importing");
setError(null);
const result = await createStopsBatch(
brandId,
parsedStops.map(({ city, state, location, date, time, address, zip, notes }) => ({
city, state, location, date, time, address, zip, notes,
}))
);
if (!result.success) {
setError(result.error ?? "Import failed");
setStep("review");
return;
}
setCreated(result.created);
setStep("done");
onComplete(result.created);
}
const hasWarnings = warnings.length > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="relative w-full max-w-2xl rounded-2xl bg-zinc-900 shadow-xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-2xl border-b border-zinc-800 px-6 py-4">
<div>
<h2 className="text-xl font-bold text-zinc-100">Import Schedule</h2>
<p className="mt-0.5 text-sm text-zinc-500">
Upload a CSV to bulk-create stops as drafts.
</p>
</div>
<button
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-slate-400 hover:bg-zinc-950 hover:text-zinc-400"
>
</button>
</div>
{/* Body */}
<div className="p-6">
{step === "idle" && (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* AI toggle */}
<div className="flex items-center gap-3">
<button
onClick={() => setUseAI((v) => !v)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
useAI
? "border-purple-300 bg-purple-50 text-purple-700"
: "border-zinc-800 text-zinc-400 hover:bg-zinc-800"
}`}
>
<span className="text-base">{useAI ? "◉" : "○"}</span>
Use AI for text/PDF parsing
</button>
<span className="text-xs text-slate-400">
{useAI
? "AI will parse unstructured text. Requires OPENAI_API_KEY."
: "Best results with CSV. Text/PDF uses AI if enabled."}
</span>
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-colors ${
dragOver
? "border-slate-900 bg-zinc-900"
: "border-zinc-600 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<p className="text-2xl text-slate-400">📄</p>
<p className="mt-2 font-medium text-zinc-300">
Drop your schedule file here
</p>
<p className="mt-1 text-sm text-zinc-500">
or click to browse CSV, TXT, JSON
</p>
<p className="mt-3 text-xs text-slate-400">
CSV columns: city, state, location, date, time, address, zip, notes
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt,.json"
className="hidden"
onChange={handleChange}
/>
</div>
)}
{step === "parsing" && (
<div className="flex flex-col items-center gap-4 py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-slate-900" />
<p className="text-zinc-400">Parsing file</p>
</div>
)}
{step === "review" && (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{hasWarnings && (
<div className="rounded-xl bg-amber-900/30 border border-yellow-200 px-4 py-3 text-sm text-yellow-700">
<p className="font-medium">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5">
{warnings.slice(0, 5).map((w, i) => (
<li key={i}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
</div>
)}
<p className="text-sm text-zinc-400">
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found review and edit below before importing.
</p>
{/* Review table */}
<div className="max-h-80 overflow-y-auto rounded-xl border border-zinc-800">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-900 text-zinc-400">
<tr>
<th className="px-2 py-2 text-left font-semibold">City</th>
<th className="px-2 py-2 text-left font-semibold">State</th>
<th className="px-2 py-2 text-left font-semibold">Location</th>
<th className="px-2 py-2 text-left font-semibold">Date</th>
<th className="px-2 py-2 text-left font-semibold">Time</th>
<th className="px-2 py-2 text-left font-semibold">Address</th>
<th className="w-8 px-2 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{parsedStops.map((stop, idx) => (
<tr key={idx} className="hover:bg-zinc-800">
<td className="px-1 py-1">
<input
value={stop.city}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.state}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.location}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.date}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.time}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.address ?? ""}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<button
onClick={() => removeStop(idx)}
className="rounded px-1 py-0.5 text-red-400 hover:bg-red-900/30 hover:text-red-400"
>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-500">
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
</p>
<div className="flex gap-3">
<button
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={parsedStops.length === 0}
className="rounded-xl bg-slate-900 px-5 py-2 text-sm font-medium text-white disabled:opacity-50 hover:bg-slate-800"
>
Create {parsedStops.length} Draft Stop{parsedStops.length !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
)}
{step === "importing" && (
<div className="flex flex-col items-center gap-4 py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-slate-900" />
<p className="text-zinc-400">Creating draft stops</p>
</div>
)}
{step === "done" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<p className="text-4xl"></p>
<p className="text-lg font-bold text-zinc-100">
{created} draft stop{created !== 1 ? "s" : ""} created
</p>
<p className="text-zinc-400">
Review them in the stops list and publish when ready.
</p>
<button
onClick={onClose}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white hover:bg-slate-800"
>
Back to Stops
</button>
</div>
)}
{step === "error" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<p className="text-4xl"></p>
<p className="text-red-400">{error}</p>
<button
onClick={() => setStep("idle")}
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Try Again
</button>
</div>
)}
</div>
</div>
</div>
);
}
+678
View File
@@ -0,0 +1,678 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
createTimeWorker,
resetTimeWorkerPin,
updateTimeWorker,
deleteTimeWorker,
createTimeTask,
updateTimeTask,
deleteTimeTask,
getTimeTrackingSettings,
updateTimeTrackingSettings,
getTimeTrackingNotificationLog,
type TimeWorker,
type TimeTask,
type TimeTrackingSettings,
type NotificationLogEntry,
} from "@/actions/time-tracking";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
type AccordionProps = {
title: string;
id?: string;
description?: string;
defaultOpen?: boolean;
accentColor?: "emerald" | "violet" | "amber" | "stone";
children: React.ReactNode;
};
function Accordion({ title, id, description, defaultOpen = false, accentColor = "stone", children }: AccordionProps) {
const [open, setOpen] = useState(defaultOpen);
const accent = {
emerald: "bg-emerald-50 text-emerald-700 border-emerald-200",
violet: "bg-violet-50 text-violet-700 border-violet-200",
amber: "bg-amber-50 text-amber-700 border-amber-200",
stone: "bg-stone-50 text-stone-700 border-stone-200",
}[accentColor];
const borderColor = open ? "border-stone-300" : "border-stone-200";
return (
<div id={id} className={`rounded-xl border ${borderColor} bg-white shadow-sm overflow-hidden transition-all duration-200`}>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-stone-900">{title}</span>
{description && (
<span className="text-xs text-stone-500 hidden sm:block">{description}</span>
)}
</div>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-1 rounded-md ${accent}`}>
{open ? "Open" : "Closed"}
</span>
<svg
className={`w-4 h-4 text-stone-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
</button>
<div className={`transition-all duration-200 ${open ? "block" : "hidden"}`}>
<div className="px-5 pb-5 pt-2">{children}</div>
</div>
</div>
);
}
type Props = {
brandId: string;
};
export default function SettingsSections({ brandId }: Props) {
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
// Settings form state
const [payPeriodStartDay, setPayPeriodStartDay] = useState(0);
const [payPeriodLength, setPayPeriodLength] = useState(7);
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
// Notification form state
const [notificationEmails, setNotificationEmails] = useState<string[]>([]);
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
const [brandName, setBrandName] = useState("Farm");
const [newEmail, setNewEmail] = useState("");
const [newSms, setNewSms] = useState("");
// Worker modal state
const [showWorkerModal, setShowWorkerModal] = useState(false);
const [editingWorker, setEditingWorker] = useState<TimeWorker | null>(null);
const [workerName, setWorkerName] = useState("");
const [workerRole, setWorkerRole] = useState("worker");
const [workerLang, setWorkerLang] = useState("en");
const [workerActive, setWorkerActive] = useState(true);
const [resetPinResult, setResetPinResult] = useState<string | null>(null);
const [workerError, setWorkerError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Task modal state
const [showTaskModal, setShowTaskModal] = useState(false);
const [editingTask, setEditingTask] = useState<TimeTask | null>(null);
const [taskName, setTaskName] = useState("");
const [taskNameEs, setTaskNameEs] = useState("");
const [taskUnit, setTaskUnit] = useState("hours");
const [taskActive, setTaskActive] = useState(true);
const [taskSortOrder, setTaskSortOrder] = useState(0);
const [taskError, setTaskError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
const [w, t, s] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSettings(brandId),
]);
setWorkers(w);
setTasks(t);
if (s) {
setSettings(s);
setPayPeriodStartDay(s.pay_period_start_day);
setPayPeriodLength(s.pay_period_length_days);
setDailyOvertimeThreshold(s.daily_overtime_threshold);
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
setOvertimeMultiplier(s.overtime_multiplier);
setOvertimeNotifications(s.overtime_notifications);
setNotificationEmails(s.notification_emails ?? []);
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
setBrandName(s.brand_name ?? "Farm");
}
const log = await getTimeTrackingNotificationLog(brandId, 50);
setNotificationLog(log);
setLoading(false);
}, [brandId]);
useEffect(() => { load(); }, [load]);
const handleSaveNotifications = async () => {
setSettingsSaving(true);
setSettingsError(null);
setSettingsSaved(false);
const result = await updateTimeTrackingSettings(brandId, {
pay_period_start_day: payPeriodStartDay,
pay_period_length_days: payPeriodLength,
daily_overtime_threshold: dailyOvertimeThreshold,
weekly_overtime_threshold: weeklyOvertimeThreshold,
overtime_multiplier: overtimeMultiplier,
overtime_notifications: overtimeNotifications,
notification_emails: notificationEmails,
notification_sms_numbers: notificationSmsNumbers,
enable_daily_alerts: enableDailyAlerts,
enable_weekly_alerts: enableWeeklyAlerts,
daily_alert_threshold: dailyAlertThreshold / 100,
weekly_alert_threshold: weeklyAlertThreshold / 100,
send_end_of_period_summary: sendEndOfPeriodSummary,
brand_name: brandName,
});
setSettingsSaving(false);
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
setSettingsSaved(true);
setTimeout(() => setSettingsSaved(false), 3000);
load();
};
const addEmail = () => {
const email = newEmail.trim();
if (email && !notificationEmails.includes(email)) {
setNotificationEmails([...notificationEmails, email]);
}
setNewEmail("");
};
const removeEmail = (email: string) => setNotificationEmails(notificationEmails.filter(e => e !== email));
const addSms = () => {
const num = newSms.replace(/[^0-9+]/g, "");
if (num && !notificationSmsNumbers.includes(num)) {
setNotificationSmsNumbers([...notificationSmsNumbers, num]);
}
setNewSms("");
};
const removeSms = (num: string) => setNotificationSmsNumbers(notificationSmsNumbers.filter(n => n !== num));
const openAddWorker = () => {
setEditingWorker(null);
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
setWorkerActive(true); setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const openEditWorker = (w: TimeWorker) => {
setEditingWorker(w); setWorkerName(w.name); setWorkerRole(w.role);
setWorkerLang(w.lang); setWorkerActive(w.active);
setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const handleSaveWorker = async () => {
if (!workerName.trim()) { setWorkerError("Name is required"); return; }
setSubmitting(true); setWorkerError(null);
if (editingWorker) {
const result = await updateTimeWorker(editingWorker.id, workerName.trim(), workerRole, workerLang, workerActive);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeWorker(brandId, workerName.trim(), workerRole, workerLang);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowWorkerModal(false); load();
};
const handleResetPin = async (workerId: string) => {
const result = await resetTimeWorkerPin(workerId);
if (result.success && result.pin) setResetPinResult(result.pin);
else setWorkerError(result.error ?? "Failed to reset PIN");
};
const handleDeleteWorker = async (workerId: string) => {
if (!confirm("Delete this worker? This cannot be undone.")) return;
const result = await deleteTimeWorker(workerId);
if (!result.success) { setWorkerError(result.error ?? "Failed"); return; }
load();
};
const openAddTask = () => {
setEditingTask(null); setTaskName(""); setTaskNameEs("");
setTaskUnit("hours"); setTaskActive(true); setTaskSortOrder(0);
setTaskError(null); setShowTaskModal(true);
};
const openEditTask = (t: TimeTask) => {
setEditingTask(t); setTaskName(t.name); setTaskNameEs(t.name_es ?? "");
setTaskUnit(t.unit); setTaskActive(t.active); setTaskSortOrder(t.sort_order);
setTaskError(null); setShowTaskModal(true);
};
const handleSaveTask = async () => {
if (!taskName.trim()) { setTaskError("Name is required"); return; }
setSubmitting(true); setTaskError(null);
if (editingTask) {
const result = await updateTimeTask(editingTask.id, taskName.trim(), taskNameEs, taskUnit, taskActive, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeTask(brandId, taskName.trim(), taskNameEs || null, taskUnit, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowTaskModal(false); load();
};
const handleDeleteTask = async (taskId: string) => {
if (!confirm("Delete this task?")) return;
const result = await deleteTimeTask(taskId);
if (!result.success) { setTaskError(result.error ?? "Failed"); return; }
load();
};
if (loading) return (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-stone-500 text-sm">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading settings...
</div>
</div>
);
return (
<div className="space-y-4">
{/* ACCORDION 1: General Settings */}
<Accordion
id="general"
title="General Settings"
description="Pay period, overtime, alerts"
defaultOpen={true}
accentColor="stone"
>
<div className="space-y-5">
{/* Pay Period & Overtime */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Pay Period & Overtime</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={31} value={payPeriodLength}
onChange={e => setPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
</div>
</div>
{/* Colorado notice */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<span className="text-amber-600 text-base mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
</div>
</div>
</div>
{/* Alert Settings */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
<p className="text-xs text-stone-500">Notify when worker hits daily limit</p>
</div>
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
<p className="text-xs text-stone-500">Notify when worker hits weekly limit</p>
</div>
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
</div>
</div>
{/* Notification Recipients */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Notification Recipients</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="manager@farm.com" />
<button onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="+1234567890" />
<button onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
</div>
</div>
{settingsError && (
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
)}
{settingsSaved && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved.</div>
)}
<button onClick={handleSaveNotifications} disabled={settingsSaving}
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
{settingsSaving ? "Saving..." : "Save Settings"}
</button>
</div>
</Accordion>
{/* ACCORDION 2: Workers & PINs */}
<Accordion
id="workers"
title="Workers & PINs"
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
defaultOpen={true}
accentColor="emerald"
>
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
<button onClick={openAddWorker}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Role</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Lang</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Last Used</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No workers yet add one to get started.</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-900 font-medium">{w.name}</td>
<td className="px-4 py-3.5 text-stone-500 capitalize text-xs">{w.role}</td>
<td className="px-4 py-3.5 text-stone-400 uppercase text-xs font-mono hidden sm:table-cell">{w.lang}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Accordion>
{/* ACCORDION 3: Tasks */}
<Accordion
id="tasks"
title="Tasks"
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
defaultOpen={false}
accentColor="amber"
>
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
<button onClick={openAddTask}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-4 py-3 font-medium">Name (EN)</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Name (ES)</th>
<th className="text-left px-4 py-3 font-medium">Unit</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Sort</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No tasks yet add one to get started.</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-900 font-medium">{t.name}</td>
<td className="px-4 py-3.5 text-stone-500 text-xs hidden sm:table-cell">{t.name_es ?? "—"}</td>
<td className="px-4 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{t.sort_order}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Accordion>
{/* Worker Modal */}
{showWorkerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
{resetPinResult && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
<p className="text-stone-500 text-xs mt-1">Show this to the worker it will not be shown again.</p>
</div>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
Reset PIN for this worker
</button>
</div>
)}
<div className="flex gap-3 pt-2">
<button onClick={() => setShowWorkerModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveWorker} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Task Modal */}
{showTaskModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowTaskModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveTask} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,517 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { updateShippingStatus } from "@/actions/shipping";
import { getFedExRates, type FedExRate, type FedExServiceType } from "@/actions/shipping/fedex-rates";
import { createFedExShipment } from "@/actions/shipping/fedex-labels";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
fulfillment: string;
products: { name: string; is_perishable: boolean } | null;
};
type ShippingOrder = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
status: string;
subtotal: number;
shipping_status: string;
tracking_number: string | null;
created_at: string;
brand_id: string | null;
order_items: OrderItem[];
};
type ShippingFulfillmentPanelProps = {
initialOrders: ShippingOrder[];
canManageOrders: boolean;
};
const SHIPPING_STATUSES = [
{ value: "pending", label: "Pending" },
{ value: "label_created", label: "Label Created" },
{ value: "shipped", label: "Shipped" },
{ value: "delivered", label: "Delivered" },
{ value: "returned", label: "Returned" },
];
const SERVICE_LABELS: Record<FedExServiceType, string> = {
FEDEX_OVERNIGHT: "FedEx Overnight",
FEDEX_2_DAY_AIR: "FedEx 2-Day Air",
FEDEX_EXPRESS_SAVER: "FedEx Express Saver",
FEDEX_GROUND: "FedEx Ground",
};
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
function statusBadge(status: string) {
const map: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-800",
label_created: "bg-blue-900/40 text-blue-800",
shipped: "bg-indigo-100 text-indigo-800",
delivered: "bg-green-900/40 text-green-800",
returned: "bg-red-900/40 text-red-800",
};
const cls = map[status] ?? "bg-zinc-950 text-zinc-300";
const label = SHIPPING_STATUSES.find((s) => s.value === status)?.label ?? status;
return { cls, label };
}
// ── Rate Modal ────────────────────────────────────────────────────────────────
function RateModal({
order,
onClose,
}: {
order: ShippingOrder;
onClose: () => void;
}) {
const [rates, setRates] = useState<FedExRate[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState<FedExServiceType | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [result, setResult] = useState<{ labelUrl: string; trackingNumber: string } | null>(null);
const loadRates = async () => {
setLoading(true);
setError(null);
const res = await getFedExRates(order.id);
setLoading(false);
if (res.success) {
setRates(res.rates);
} else {
setError(res.error ?? "Failed to load rates");
}
};
const handleSelectRate = async (rate: FedExRate) => {
setCreating(rate.serviceType);
setCreateError(null);
const res = await createFedExShipment(
order.id,
rate.serviceType,
rate.totalCharge,
rate.deliveryDate
);
setCreating(null);
if (res.success) {
setResult({ labelUrl: res.labelUrl, trackingNumber: res.trackingNumber });
} else {
setCreateError(res.error ?? "Failed to create shipment");
}
};
// Auto-load on mount
if (rates === null && !loading && !error) {
loadRates();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 shadow-2xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4 sticky top-0 bg-zinc-900 rounded-t-2xl">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Shipping Rates</h2>
<p className="text-sm text-zinc-500">
{order.customer_name} · {shortId(order.id)}
</p>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-zinc-400 text-2xl leading-none"
>
×
</button>
</div>
<div className="p-6 space-y-4">
{/* Perishable warning */}
{rates !== null && rates[0]?.isPerishableOnly && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
<strong>Perishable shipment.</strong> Only Overnight and 2-Day Air are available
for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not
offered for temperature-sensitive items.
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" />
<span className="ml-3 text-zinc-500">Fetching FedEx rates...</span>
</div>
)}
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
<button
onClick={loadRates}
className="ml-2 underline hover:no-underline"
>
Retry
</button>
</div>
)}
{rates !== null && rates.length === 0 && !loading && (
<div className="text-center py-8 text-zinc-500">
No FedEx rates available for this address. Please verify the shipping address.
</div>
)}
{rates !== null && rates.length > 0 && (
<div className="space-y-2">
{rates.map((rate) => (
<button
key={rate.serviceType}
onClick={() => handleSelectRate(rate)}
disabled={creating !== null}
className="w-full flex items-center justify-between rounded-xl border border-zinc-800 px-4 py-3 text-left hover:border-blue-400 hover:bg-blue-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div>
<div className="font-semibold text-zinc-100">
{SERVICE_LABELS[rate.serviceType] ?? rate.serviceType}
</div>
<div className="text-sm text-zinc-500">
{rate.deliveryDateLabel
? `Arrives ${rate.deliveryDateLabel}`
: rate.deliveryDayOfWeek}
</div>
{rate.isPerishableOnly && (
<span className="mt-1 inline-block rounded-full bg-blue-900/40 text-blue-700 px-2 py-0.5 text-xs font-medium">
Required for perishable
</span>
)}
</div>
<div className="text-right">
<div className="font-bold text-zinc-100">
${(rate.totalCharge / 100).toFixed(2)}
</div>
{creating === rate.serviceType && (
<div className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin mt-1 mx-auto" />
)}
</div>
</button>
))}
</div>
)}
{createError && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{createError}
</div>
)}
{result && (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-4 py-4 space-y-3">
<div className="flex items-center gap-2 text-emerald-700 font-semibold">
<span className="text-lg"></span> Label Created
</div>
<div className="text-sm text-emerald-600">
<div>Tracking: <span className="font-mono font-medium">{result.trackingNumber}</span></div>
</div>
<div className="flex gap-2">
{result.labelUrl && (
<a
href={result.labelUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
Download Label
</a>
)}
<button
onClick={onClose}
className="inline-flex items-center rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Main Panel ────────────────────────────────────────────────────────────────
export default function ShippingFulfillmentPanel({
initialOrders,
canManageOrders,
}: ShippingFulfillmentPanelProps) {
const [orders, setOrders] = useState<ShippingOrder[]>(initialOrders);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [updating, setUpdating] = useState<string | null>(null);
const [trackingInputs, setTrackingInputs] = useState<Record<string, string>>({});
const [rateModalOrder, setRateModalOrder] = useState<ShippingOrder | null>(null);
const filtered = orders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStatus = !statusFilter || o.shipping_status === statusFilter;
return matchesSearch && matchesStatus;
});
const counts = SHIPPING_STATUSES.reduce(
(acc, s) => {
acc[s.value] = orders.filter((o) => o.shipping_status === s.value).length;
return acc;
},
{} as Record<string, number>
);
async function handleStatusChange(orderId: string, newStatus: string) {
if (!canManageOrders) return;
setUpdating(orderId);
const trackingNumber =
newStatus === "shipped" ? trackingInputs[orderId] ?? null : null;
const result = await updateShippingStatus(orderId, newStatus, trackingNumber ?? undefined);
if (result.success) {
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, shipping_status: newStatus, tracking_number: trackingNumber ?? o.tracking_number }
: o
)
);
}
setUpdating(null);
}
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-slate-900 px-4 py-5">
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Shipping Fulfillment</h1>
<p className="mt-1 text-sm text-slate-400">
{orders.length} shipping order{orders.length !== 1 ? "s" : ""}
</p>
</div>
<div className="flex gap-3">
<Link
href="/admin/orders"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
All Orders
</Link>
<Link
href="/admin/pickup"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
Pickup
</Link>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
{/* Status filter pills */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setStatusFilter("")}
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
!statusFilter ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
}`}
>
All ({orders.length})
</button>
{SHIPPING_STATUSES.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
statusFilter === s.value ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
}`}
>
{s.label} ({counts[s.value] ?? 0})
</button>
))}
</div>
{/* Search */}
<input
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
/>
{/* Orders */}
<div className="space-y-3">
{filtered.length === 0 ? (
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
No shipping orders
</div>
) : (
filtered.map((order) => {
const { cls, label } = statusBadge(order.shipping_status);
const hasShipItems = order.order_items.some((i) => i.fulfillment === "ship");
const hasPerishableItems = order.order_items.some(
(i) => i.fulfillment === "ship" && i.products?.is_perishable
);
const isPending = order.shipping_status === "pending" || order.shipping_status === "label_created";
return (
<div key={order.id} className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId(order.id)}</p>
</div>
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold ${cls}`}>
{label}
</span>
</div>
{/* Perishable indicator */}
{hasPerishableItems && isPending && (
<div className="mt-3 rounded-lg bg-blue-900/30 border border-blue-200 px-3 py-2 text-xs text-blue-700 font-medium">
🌽 Perishable Overnight or 2-Day Air only
</div>
)}
{/* Tracking number */}
{order.tracking_number && (
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-sm flex items-center justify-between">
<div>
<span className="text-zinc-500">Tracking: </span>
<span className="font-mono font-medium text-zinc-300">{order.tracking_number}</span>
</div>
{order.shipping_status === "label_created" && (
<button
onClick={() => handleStatusChange(order.id, "shipped")}
disabled={updating === order.id}
className="text-xs rounded-lg bg-indigo-600 px-3 py-1.5 font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
{updating === order.id ? "..." : "Mark Shipped"}
</button>
)}
</div>
)}
{/* Items */}
{order.order_items.filter((i) => i.fulfillment === "ship").length > 0 && (
<div className="mt-4 rounded-lg bg-slate-50 p-3">
<ul className="space-y-1">
{order.order_items
.filter((i) => i.fulfillment === "ship")
.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-zinc-300">
{item.quantity > 1 && (
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
{item.products?.is_perishable && (
<span className="ml-1.5 text-xs text-blue-400">🌽 perishable</span>
)}
</span>
<span className="text-zinc-500">
${(Number(item.price) * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-bold text-zinc-100">${Number(order.subtotal).toFixed(2)}</span>
</div>
</div>
)}
{/* Actions */}
{canManageOrders && hasShipItems && isPending && (
<div className="mt-4 flex flex-wrap gap-2">
{/* Get Rates / Create Label */}
{order.shipping_status === "pending" && (
<button
onClick={() => setRateModalOrder(order)}
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Get FedEx Rates
</button>
)}
{/* Manual tracking input (fallback) */}
{(order.shipping_status === "pending" ||
order.shipping_status === "label_created") && (
<input
type="text"
placeholder="Enter tracking manually"
value={trackingInputs[order.id] ?? ""}
onChange={(e) =>
setTrackingInputs((prev) => ({ ...prev, [order.id]: e.target.value }))
}
className="flex-1 min-w-[160px] rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
)}
{/* Status transitions */}
{SHIPPING_STATUSES.filter(
(s) =>
s.value !== order.shipping_status &&
s.value !== "label_created" // handled by create label flow
).map((s) => (
<button
key={s.value}
onClick={() => handleStatusChange(order.id, s.value)}
disabled={updating === order.id}
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{updating === order.id ? "..." : `Mark ${s.label}`}
</button>
))}
</div>
)}
{!canManageOrders && (
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
No order management permission
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* Rate Modal */}
{rateModalOrder && (
<RateModal order={rateModalOrder} onClose={() => setRateModalOrder(null)} />
)}
</div>
);
}
@@ -0,0 +1,361 @@
"use client";
import { useState, useEffect } from "react";
import {
getShippingSettings,
saveShippingSettings,
testFedExConnection,
type ShippingSettings,
} from "@/actions/shipping/settings";
const SERVICE_OPTIONS = [
{ value: "FEDEX_OVERNIGHT", label: "FedEx Overnight" },
{ value: "FEDEX_2_DAY_AIR", label: "FedEx 2-Day Air" },
{ value: "FEDEX_EXPRESS_SAVER", label: "FedEx Express Saver" },
{ value: "FEDEX_GROUND", label: "FedEx Ground" },
];
type Props = {
settings: ShippingSettings | null;
brandId: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
};
export default function ShippingSettingsForm({
settings,
brandId: initialBrandId,
brands = [],
isPlatformAdmin = false,
}: Props) {
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
const [fedexAccountNumber, setFedexAccountNumber] = useState(settings?.fedex_account_number ?? "");
const [fedexApiKey, setFedexApiKey] = useState(settings?.fedex_api_key ?? "");
const [fedexApiSecret, setFedexApiSecret] = useState(settings?.fedex_api_secret ?? "");
const [fedexUseProduction, setFedexUseProduction] = useState(settings?.fedex_use_production ?? false);
const [defaultServiceType, setDefaultServiceType] = useState(settings?.default_service_type ?? "FEDEX_GROUND");
const [refrigeratedHandlingNotes, setRefrigeratedHandlingNotes] = useState(
settings?.refrigerated_handling_notes ?? "Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions."
);
const [fragileHandlingNotes, setFragileHandlingNotes] = useState(settings?.fragile_handling_notes ?? "");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [showSecret, setShowSecret] = useState(false);
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
// Reload settings when brand changes
useEffect(() => {
if (!isPlatformAdmin) return;
setLoading(true);
setError(null);
getShippingSettings(activeBrandId).then((result) => {
setLoading(false);
if (result.success && result.settings) {
const s = result.settings;
setFedexAccountNumber(s.fedex_account_number ?? "");
setFedexApiKey(s.fedex_api_key ?? "");
setFedexApiSecret(s.fedex_api_secret ?? "");
setFedexUseProduction(s.fedex_use_production);
setDefaultServiceType(s.default_service_type);
setRefrigeratedHandlingNotes(s.refrigerated_handling_notes ?? "");
setFragileHandlingNotes(s.fragile_handling_notes ?? "");
} else {
// Reset form for new brand
setFedexAccountNumber("");
setFedexApiKey("");
setFedexApiSecret("");
setFedexUseProduction(false);
setDefaultServiceType("FEDEX_GROUND");
setRefrigeratedHandlingNotes("Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.");
setFragileHandlingNotes("");
}
});
}, [activeBrandId, isPlatformAdmin]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
setTestResult(null);
const result = await saveShippingSettings({
brandId: activeBrandId,
fedexAccountNumber: fedexAccountNumber || undefined,
fedexApiKey: fedexApiKey || undefined,
fedexApiSecret: fedexApiSecret || undefined,
fedexUseProduction,
defaultServiceType,
refrigeratedHandlingNotes: refrigeratedHandlingNotes || undefined,
fragileHandlingNotes: fragileHandlingNotes || undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
async function handleTestConnection() {
if (!fedexApiKey || !fedexApiSecret) {
setTestResult({ success: false, message: "Enter API Key and API Secret before testing." });
return;
}
setTesting(true);
setTestResult(null);
const result = await testFedExConnection(fedexApiKey, fedexApiSecret, fedexUseProduction);
setTesting(false);
setTestResult({ success: result.success, message: result.success ? result.message : result.error ?? "Connection failed" });
}
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{/* Connection status banner */}
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium ${
isConfigured
? "bg-emerald-900/30 border border-emerald-700 text-emerald-300"
: "bg-amber-900/30 border border-amber-700 text-amber-300"
}`}>
<span className={`h-2.5 w-2.5 rounded-full ${isConfigured ? "bg-emerald-500" : "bg-amber-900/300"}`} />
{isConfigured
? `FedEx Connected — ${settings?.fedex_use_production ? "Production" : "Sandbox"} mode`
: "FedEx Not Configured — enter credentials below to enable shipping rates and label generation"}
</div>
{error && (
<div className="rounded-xl bg-red-900/50 border border-red-700 p-4 text-sm text-red-200">{error}</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/50 border border-green-700 p-4 text-sm text-green-200">
Shipping settings saved.
</div>
)}
{testResult && (
<div className={`rounded-xl p-4 text-sm border ${testResult.success ? "bg-green-900/50 border-green-700 text-green-200" : "bg-red-900/50 border-red-700 text-red-200"}`}>
{testResult.message}
</div>
)}
{/* FedEx credentials */}
<div className="space-y-5 rounded-xl border border-blue-400 bg-blue-900/20 p-5">
<div>
<h3 className="font-semibold text-blue-300">FedEx API Credentials</h3>
<p className="mt-1 text-sm text-blue-400">
Used for shipping fresh sweet corn and onions. Get your credentials from{" "}
<a
href="https://developer.fedex.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
developer.fedex.com
</a>
.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">
FedEx Account Number <span className="text-red-500">*</span>
</label>
<input
type="text"
value={fedexAccountNumber}
onChange={(e) => setFedexAccountNumber(e.target.value)}
placeholder="000000000000 (12 digits)"
maxLength={12}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm font-mono text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
API Key <span className="text-red-500">*</span>
</label>
<input
type="text"
value={fedexApiKey}
onChange={(e) => setFedexApiKey(e.target.value)}
placeholder="Your FedEx API key"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
API Secret <span className="text-red-500">*</span>
<span className="ml-2 text-slate-400 font-normal">(password field)</span>
</label>
<div className="relative mt-1">
<input
type={showSecret ? "text" : "password"}
value={fedexApiSecret}
onChange={(e) => setFedexApiSecret(e.target.value)}
placeholder="Your FedEx API secret"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 pr-12 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
<button
type="button"
onClick={() => setShowSecret(!showSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-zinc-400 text-xs"
>
{showSecret ? "Hide" : "Show"}
</button>
</div>
</div>
{/* Production toggle */}
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3">
<button
type="button"
onClick={() => setFedexUseProduction(!fedexUseProduction)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
fedexUseProduction ? "bg-blue-600" : "bg-zinc-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${
fedexUseProduction ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<div>
<span className="text-sm font-medium text-zinc-200">Use Production Mode</span>
<p className="text-xs text-zinc-400">
{fedexUseProduction
? "Live rates, real labels. Sandbox is currently active."
: "Sandbox/test mode. Use for testing before going live."}
</p>
</div>
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || !fedexApiKey || !fedexApiSecret}
className="rounded-xl border border-blue-400 bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
>
{testing ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" />
Testing...
</span>
) : (
"Test Connection"
)}
</button>
{testResult?.success && (
<span className="text-sm text-green-400 font-medium"> Connection verified</span>
)}
</div>
</div>
{/* Default shipping options */}
<div className="space-y-5 rounded-xl border border-zinc-700 bg-zinc-900 p-5">
<div>
<h3 className="font-semibold text-zinc-200">Default Shipping Service</h3>
<p className="mt-1 text-sm text-zinc-400">
Applied automatically when creating shipments. Perishable orders always require Overnight or 2-Day Air regardless of this setting.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Default for non-perishable orders
</label>
<select
value={defaultServiceType}
onChange={(e) => setDefaultServiceType(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Handling notes */}
<div className="space-y-5 rounded-xl border border-zinc-700 bg-zinc-900 p-5">
<div>
<h3 className="font-semibold text-zinc-200">Handling Instructions</h3>
<p className="mt-1 text-sm text-zinc-400">
These notes are attached to shipments containing perishable or fragile items (sweet corn, onions, etc.). Appear on the carrier label and in the warehouse.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
🌽 Refrigerated / Perishable Notes
</label>
<textarea
value={refrigeratedHandlingNotes}
onChange={(e) => setRefrigeratedHandlingNotes(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
/>
<p className="mt-1 text-xs text-zinc-500">
Applied to all shipments with perishable items (is_perishable = true).
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
📦 Fragile Items Notes
</label>
<textarea
value={fragileHandlingNotes}
onChange={(e) => setFragileHandlingNotes(e.target.value)}
rows={2}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
/>
</div>
</div>
{loading ? (
<div className="flex items-center gap-3 text-zinc-500">
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
Loading settings...
</div>
) : (
<button
type="submit"
disabled={saving}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Shipping Settings"}
</button>
)}
</form>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getSyncLog, syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
import { getPaymentSettings } from "@/actions/payments";
type Props = {
brandId: string;
};
export default function SquareSyncWidget({ brandId }: Props) {
const [settings, setSettings] = useState<{
provider: string | null;
square_access_token: string | null;
square_sync_enabled: boolean;
square_inventory_mode: string;
square_last_sync_at: string | null;
square_last_sync_error: string | null;
} | null>(null);
const [logs, setLogs] = useState<SyncLogEntry[]>([]);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [queueCount, setQueueCount] = useState(0);
useEffect(() => {
getPaymentSettings(brandId).then((r) => {
if (r.success && r.settings) setSettings(r.settings as any);
});
getSyncLog(brandId).then((r) => {
if (r.success) setLogs(r.logs.slice(0, 5));
});
// Poll pending queue count every 30s
const interval = setInterval(() => checkQueueCount(), 30000);
checkQueueCount();
return () => clearInterval(interval);
}, [brandId]);
async function checkQueueCount() {
const { supabase } = await import("@/lib/supabase");
const { count } = await supabase
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
.in("status", ["pending"]);
setQueueCount(count ?? 0);
}
useEffect(() => {
if (!syncMsg) return;
const timer = setTimeout(() => setSyncMsg(null), 4000);
return () => clearTimeout(timer);
}, [syncMsg]);
async function handleSyncNow(type: "products" | "orders" | "all") {
setSyncing(true);
setSyncMsg(null);
const result = await syncSquareNow(brandId, type);
setSyncMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Sync complete — ${result.synced} item(s) synced.`
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
getSyncLog(brandId).then((r) => {
if (r.success) setLogs(r.logs.slice(0, 5));
});
getPaymentSettings(brandId).then((r) => {
if (r.success && r.settings) setSettings(r.settings as any);
});
}
const lastSyncAt = settings?.square_last_sync_at
? timeAgo(settings.square_last_sync_at)
: "Never";
const lastError = settings?.square_last_sync_error;
const hasToken = !!(settings?.square_access_token);
const autoSyncEnabled = settings?.square_sync_enabled && hasToken;
const subheading = hasToken
? autoSyncEnabled
? `Wholesale products · ${lastSyncAt}`
: "Connected — auto-sync disabled"
: "Not connected";
return (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-900/40">
<svg className="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-zinc-100">Square Inventory</h3>
{autoSyncEnabled && (
<span className="rounded-full bg-green-900/40 px-2 py-0.5 text-xs font-medium text-green-400">
Auto-sync
</span>
)}
{!autoSyncEnabled && hasToken && (
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-xs font-medium text-zinc-500">
Sync off
</span>
)}
</div>
<p className="text-sm text-zinc-500">
{subheading}
</p>
</div>
</div>
{lastError && (
<span className="rounded-full bg-red-900/40 px-2.5 py-0.5 text-xs font-medium text-red-400">
Sync Error
</span>
)}
</div>
{lastError && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-900/30 px-3 py-2 text-xs text-red-400">
{lastError}
</div>
)}
{syncMsg && (
<div className={`mb-3 rounded-lg border px-3 py-2 text-sm ${
syncMsg.kind === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}>
{syncMsg.text}
</div>
)}
{hasToken ? (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Products"}
</button>
<button
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Orders"}
</button>
<button
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{syncing ? "..." : "Sync All"}
</button>
</div>
{!autoSyncEnabled && hasToken && (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-900/30 px-3 py-2 text-xs text-amber-700">
Square sync is turned off. Wholesale products will not be pushed to Square automatically.
</div>
)}
{logs.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Recent activity
</p>
{logs.slice(0, 4).map((log) => (
<div
key={log.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
log.status === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}
>
<span className="truncate">
[{log.direction ?? "—"}] {log.event_type}
</span>
<span className="ml-2 text-slate-400">
{timeAgo(log.created_at)}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex gap-2">
<Link
href="/admin/settings/square-sync"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
View Settings
</Link>
<Link
href="/admin/orders?square=1"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Square Orders
</Link>
</div>
</>
) : (
<Link
href="/admin/settings/payments"
className="inline-block rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
>
Connect Square
</Link>
)}
</div>
);
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
@@ -0,0 +1,9 @@
"use client";
import SquareSyncWidget from "@/components/admin/SquareSyncWidget";
type Props = { brandId: string };
export default function SquareSyncWidgetWrapper({ brandId }: Props) {
return <SquareSyncWidget brandId={brandId} />;
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateStop } from "@/actions/stops/update-stop";
type Brand = {
id: string;
name: string;
slug: string;
};
type StopEditFormProps = {
stop: {
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: Brand[];
};
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [city, setCity] = useState(stop.city);
const [state, setState] = useState(stop.state);
const [date, setDate] = useState(stop.date);
const [time, setTime] = useState(stop.time);
const [location, setLocation] = useState(stop.location);
const [active, setActive] = useState(stop.active);
const [brand_id, setBrand_id] = useState(stop.brand_id);
const [address, setAddress] = useState(stop.address ?? "");
const [zip, setZip] = useState(stop.zip ?? "");
const [cutoff_time, setCutoff_time] = useState(stop.cutoff_time ?? "");
async function handleSave() {
if (!city.trim() || !state.trim() || !date.trim()) {
setError("City, state, and date are required.");
return;
}
setSaving(true);
setError(null);
setSaved(false);
const slug = city.toLowerCase().replace(/\s+/g, "-") + "-" + date;
const result = await updateStop(stop.id, brand_id, {
city,
state,
location,
date,
time,
active,
address: address || null,
zip: zip || null,
cutoff_time: cutoff_time || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
return;
}
setSaved(true);
setSaving(false);
router.refresh();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Stop updated successfully.
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Time</label>
<input
type="text"
value={time}
onChange={(e) => setTime(e.target.value)}
placeholder="e.g. 8:00 AM 2:00 PM"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Location</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Street address or intersection"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Street Address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">ZIP Code</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80102"
maxLength={10}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Order Cutoff</label>
<input
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
<p className="mt-1 text-xs text-slate-400">
Customers must order before this time to be included at this stop.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
<button
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{active ? "Active" : "Inactive"}
</button>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
type Customer = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
type StopMessagingFormProps = {
stopId: string;
};
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
];
export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [message, setMessage] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(0);
const [error, setError] = useState<string | null>(null);
async function loadCustomers() {
setLoading(true);
setError(null);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&pickup_complete=eq.false&pickup_complete=eq.false&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
);
const data = await res.json();
if (!res.ok) {
setError(data.message);
} else {
setCustomers(data);
setLoaded(true);
}
setLoading(false);
}
function useQuickMessage(msg: string) {
setMessage(msg);
setCustomMessage(msg);
}
async function handleSend() {
if (!message.trim()) return;
setSending(true);
setError(null);
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
// Call Supabase Edge Function to send messages
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/send-messages`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel,
message,
recipients: recipients.map((r) => ({
phone: r.customer_phone,
email: r.customer_email,
name: r.customer_name,
})),
}),
}
);
if (!res.ok) {
const err = await res.json();
setError(err.message ?? "Failed to send messages");
setSending(false);
return;
}
setSent(recipients.length);
setSending(false);
}
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-zinc-100">
Send Message
</h2>
<p className="mt-1 text-zinc-400">
Notify all customers with pending pickups at this stop.
</p>
</div>
{!loaded ? (
<button
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-zinc-600 px-6 py-4 text-lg font-medium text-zinc-500 disabled:opacity-50"
>
{loading ? "Loading customers..." : "Load Pending Customers"}
</button>
) : (
<>
{customers.length === 0 ? (
<div className="rounded-xl bg-slate-50 p-6 text-center text-zinc-500">
No pending orders for this stop yet.
</div>
) : (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span>
)}
</div>
)}
{/* Channel */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Send via
</label>
<div className="flex gap-3">
{(["sms", "email", "both"] as const).map((ch) => (
<button
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
</button>
))}
</div>
</div>
{/* Quick messages */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Quick messages
</label>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
<button
key={qm.label}
onClick={() => useQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{qm.label}
</button>
))}
</div>
</div>
{/* Message preview */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Message
</label>
<textarea
value={message}
onChange={(e) => {
setMessage(e.target.value);
setCustomMessage(e.target.value);
}}
rows={4}
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-slate-900"
placeholder="Type your message..."
/>
<p className="mt-1 text-xs text-slate-400">
{message.length} characters
</p>
</div>
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
{error}
</div>
)}
{sent > 0 && (
<div className="rounded-xl bg-green-900/30 p-4 text-green-400 text-sm">
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div>
)}
{/* Recipients */}
{recipients.length > 0 && message && (
<div className="rounded-xl border border-zinc-800 p-4">
<p className="mb-3 text-sm font-medium text-zinc-300">
Recipients ({recipients.length}):
</p>
<div className="space-y-2">
{recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span className="text-zinc-300">{c.customer_name}</span>
<span className="text-slate-400">
{c.customer_phone ?? c.customer_email ?? "no contact"}
</span>
</div>
))}
</div>
</div>
)}
<button
onClick={handleSend}
disabled={!message || recipients.length === 0 || sending}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</button>
</>
)}
</div>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { logAuditEvent } from "@/actions/audit";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number } | null;
};
type StopProductAssignmentProps = {
stopId: string;
allProducts: Product[];
assignedProducts: AssignedProduct[];
callerUid: string;
};
export default function StopProductAssignment({
stopId,
allProducts,
assignedProducts,
callerUid,
}: StopProductAssignmentProps) {
const [products, setProducts] = useState(assignedProducts);
const [selected, setSelected] = useState("");
const [assigning, setAssigning] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const assignedIds = new Set(products.map((p) => p.product_id));
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setAssigning(true);
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,
}),
}
);
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,
}),
}
);
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;
}
// 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) {
const entry = products.find((p) => p.product_id === productId);
if (!entry) return;
setRemoving(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,
}),
}
);
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 : ''}`);
setRemoving(null);
return;
}
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,
});
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{products.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-zinc-300">
Assigned Products ({products.length})
</p>
{products.map((ap) => (
<div
key={ap.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div>
<p className="font-medium text-zinc-100">
{ap.products?.name ?? "Unknown"}
</p>
<p className="text-xs text-zinc-500">
{ap.products?.type} ·{" "}
${Number(ap.products?.price ?? 0).toFixed(2)}
</p>
</div>
<button
onClick={() => handleRemove(ap.product_id)}
disabled={removing === ap.product_id}
className="shrink-0 rounded-lg px-3 py-1 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{removing === ap.product_id ? "..." : "Remove"}
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">No products assigned yet.</p>
)}
{availableProducts.length > 0 && (
<form onSubmit={handleAssign} className="flex gap-3">
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.type})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || assigning}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{assigning ? "..." : "Assign"}
</button>
</form>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brands: { name: string } | { name: string }[];
};
export default function StopTableBody({ stops }: { stops: Stop[] }) {
return (
<tbody className="divide-y divide-slate-200">
{stops?.map((stop) => (
<tr
key={stop.id}
onClick={() =>
(window.location.href = `/admin/stops/${stop.id}`)
}
className="cursor-pointer hover:bg-zinc-800"
>
<td className="px-5 py-4">
<span className="font-medium text-zinc-100">
{stop.city}, {stop.state}
</span>
</td>
<td className="px-5 py-4 text-zinc-300">
{stop.location}
</td>
<td className="px-5 py-4 text-zinc-300">
{stop.date}
</td>
<td className="px-5 py-4 text-zinc-300">
{stop.time}
</td>
<td className="px-5 py-4 text-zinc-300">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-5 py-4">
<span className="rounded-full bg-zinc-950 px-3 py-1 text-xs font-medium text-zinc-300">
{stop.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
);
}
+322
View File
@@ -0,0 +1,322 @@
"use client";
import React, { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
deleted_at?: string | null;
brand_id: string;
status?: string;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: Stop[];
};
export default function StopTableClient({ stops }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
function handleDeleted() {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}
return (
<>
{/* Filter bar */}
<div className="border-b border-stone-200 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search stops..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="flex-1 min-w-40 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : f === "inactive" ? "Inactive" : "Draft"}
</button>
))}
</div>
<span className="text-xs text-stone-500">{filtered.length} stops</span>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-stone-500 px-1">{page + 1}/{totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-stone-500">
<tr>
<th className="px-5 py-4 font-semibold">City</th>
<th className="px-5 py-4 font-semibold">Location</th>
<th className="px-5 py-4 font-semibold">Date</th>
<th className="px-5 py-4 font-semibold">Time</th>
<th className="px-5 py-4 font-semibold">Brand</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No stops match your search."
: "No stops found. Create one to get started."}
</td>
</tr>
) : (
paginatedStops.map((stop) => (
<StopRow
key={stop.id}
stop={stop}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function StopRowBase({
stop,
onDeleted,
onDeleteError,
}: {
stop: Stop;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
const result = await publishStop(stopId, stop.brand_id);
setPublishingId(null);
if (result.success) {
startTransition(() => router.refresh());
}
}
async function handleDelete(stopId: string) {
setDeletingId(stopId);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
}
);
const data = await res.json();
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
onDeleted();
} else {
onDeleteError(data.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<Link
href={`/admin/stops/${stop.id}`}
className="font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{stop.city}, {stop.state}
</Link>
</td>
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.date}</td>
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.time}</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === stop.id ? null : stop.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
</button>
{openMenu === stop.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50 transition-colors"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
)}
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
>
Duplicate
</a>
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{stop.city}, {stop.state}&quot;?
</p>
<p className="mt-1.5 text-xs text-stone-500">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(stop.id)}
disabled={deletingId === stop.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === stop.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const StopRow = React.memo(StopRowBase);
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import { useRouter } from "next/navigation";
type Props = {
brandId: string;
};
export default function StopsHeaderActions({ brandId }: Props) {
const [open, setOpen] = useState(false);
const router = useRouter();
function handleComplete(count: number) {
router.refresh();
}
return (
<>
<div className="flex gap-3">
<button
onClick={() => setOpen(true)}
className="rounded-xl border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Upload Schedule
</button>
<a
href="/admin/stops/new"
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 text-sm font-semibold text-white transition-colors shadow-lg shadow-emerald-900/30"
>
+ Add Stop
</a>
</div>
{open && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setOpen(false)}
onComplete={handleComplete}
/>
)}
</>
);
}
+461
View File
@@ -0,0 +1,461 @@
"use client";
import { useState, useCallback } from "react";
import { formatDate } from "@/lib/date-utils";
import {
exportTaxReport,
exportTaxByState,
type TaxOrderRow,
type TaxByStateRow,
} from "@/lib/reports-export";
type DatePreset = "month" | "quarter" | "this_year" | "custom";
type DateRange = { start: string; end: string };
function buildRange(preset: DatePreset, customStart?: string, customEnd?: string): DateRange {
const now = new Date();
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
const today = toDateStr(now);
switch (preset) {
case "month": {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { start: toDateStr(start), end: today };
}
case "quarter": {
const q = Math.floor(now.getMonth() / 3);
const start = new Date(now.getFullYear(), q * 3, 1);
return { start: toDateStr(start), end: today };
}
case "this_year": {
const start = new Date(now.getFullYear(), 0, 1);
return { start: toDateStr(start), end: today };
}
case "custom":
return { start: customStart ?? today, end: customEnd ?? today };
}
}
function quarterLabel(start: string, end: string): string {
const s = new Date(start + "T00:00:00");
const q = Math.floor(s.getMonth() / 3) + 1;
return `Q${q} ${s.getFullYear()}`;
}
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
tax_by_state: Array<{
state: string;
total_tax: number;
gross_sales: number;
order_count: number;
}>;
};
function SummaryCard({
label,
value,
prefix,
suffix,
highlight,
}: {
label: string;
value: number;
prefix?: string;
suffix?: string;
highlight?: boolean;
}) {
return (
<div className={`rounded-xl border px-4 py-4 ${highlight ? "border-amber-300 bg-amber-900/30" : "border-zinc-800 bg-zinc-900"}`}>
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</p>
<p className={`mt-1 text-2xl font-semibold ${highlight ? "text-amber-700" : "text-zinc-100"}`}>
{prefix ?? ""}{typeof value === "number" ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : value}{suffix ?? ""}
</p>
</div>
);
}
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Export CSV
</button>
);
}
function ReportTable({ headers, rows, renderRow }: {
headers: string[];
rows: unknown[];
renderRow: (row: unknown, i: number) => React.ReactNode;
}) {
return (
<div className="overflow-x-auto rounded-xl border border-zinc-800 bg-zinc-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-slate-50">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-zinc-500">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={headers.length} className="px-3 py-6 text-center text-slate-400">
No data
</td>
</tr>
) : (
rows.map((_, i) => renderRow(_, i))
)}
</tbody>
</table>
</div>
);
}
const TABS = [
{ id: "summary", label: "Tax Summary" },
{ id: "orders", label: "Taxable Orders" },
];
export default function TaxDashboard({
brands,
initialBrandId,
isPlatformAdmin,
}: {
brands: { id: string; name: string }[];
initialBrandId: string | null;
isPlatformAdmin: boolean;
}) {
const [preset, setPreset] = useState<DatePreset>("quarter");
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
const [activeTab, setActiveTab] = useState("summary");
const [summary, setSummary] = useState<TaxSummaryData | null>(null);
const [orders, setOrders] = useState<TaxOrderRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const range: DateRange = buildRange(preset, customStart, customEnd);
const fetchTaxSummary = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setSummary({
total_tax_collected: Number(data?.total_tax_collected ?? 0),
total_gross_sales: Number(data?.total_gross_sales ?? 0),
order_count: Number(data?.order_count ?? 0),
tax_by_state: data?.tax_by_state ?? [],
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load tax summary");
} finally {
setLoading(false);
}
}, [selectedBrandId, range]);
const fetchTaxableOrders = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_taxable_orders`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setOrders(
(data ?? []).map((row: Record<string, unknown>) => ({
order_id: String(row.order_id ?? ""),
date: String(row.date ?? ""),
customer_name: String(row.customer_name ?? ""),
city: String(row.city ?? ""),
state: String(row.state ?? ""),
taxable_amount: Number(row.taxable_amount ?? 0),
tax_amount: Number(row.tax_amount ?? 0),
tax_rate: Number(row.tax_rate ?? 0),
tax_location: String(row.tax_location ?? ""),
}))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load orders");
} finally {
setLoading(false);
}
}, [selectedBrandId, range]);
function handleFetch() {
if (activeTab === "summary") fetchTaxSummary();
else fetchTaxableOrders();
}
// Auto-fetch when tab/brand/range changes
if (summary === null && activeTab === "summary" && selectedBrandId && !loading) {
fetchTaxSummary();
}
if (orders.length === 0 && activeTab === "orders" && selectedBrandId && !loading) {
fetchTaxableOrders();
}
function handleExportCSV() {
if (activeTab === "summary" && summary) {
const rows: TaxByStateRow[] = summary.tax_by_state.map((s) => ({
state: s.state,
total_tax: s.total_tax,
gross_sales: s.gross_sales,
order_count: s.order_count,
}));
const csv = exportTaxByState(rows);
downloadCSV(csv, `tax-by-state-${range.start}.csv`);
} else if (activeTab === "orders" && orders.length > 0) {
const csv = exportTaxReport(orders);
downloadCSV(csv, `taxable-orders-${range.start}.csv`);
}
}
function downloadCSV(csv: string, filename: string) {
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const effectiveRate = summary && summary.total_gross_sales > 0
? (summary.total_tax_collected / summary.total_gross_sales) * 100
: 0;
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* ── Filters ── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
<div className="flex gap-1">
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<button
key={p}
onClick={() => setPreset(p)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${
preset === p
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</button>
))}
</div>
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input
type="date"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
<span className="text-slate-400 text-xs">to</span>
<input
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
</div>
)}
{isPlatformAdmin && (
<select
value={selectedBrandId}
onChange={(e) => {
setSelectedBrandId(e.target.value);
setSummary(null);
setOrders([]);
}}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
>
<option value="">All Brands</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
)}
<button
onClick={handleFetch}
disabled={loading || !selectedBrandId}
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
<span className="ml-auto text-xs text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start, range.end) :
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
`${range.start}${range.end}`}
</span>
</div>
{/* ── Tab bar ── */}
<div className="flex gap-1 border-b border-zinc-800">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── TAX SUMMARY TAB ── */}
{activeTab === "summary" && (
<div className="space-y-4">
{summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard
label="Total Tax Collected"
value={summary.total_tax_collected}
prefix="$"
highlight
/>
<SummaryCard
label="Total Gross Sales"
value={summary.total_gross_sales}
prefix="$"
/>
<SummaryCard
label="Effective Tax Rate"
value={effectiveRate}
suffix="%"
/>
<SummaryCard
label="Taxable Orders"
value={summary.order_count}
/>
</div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Tax by State</h3>
{summary.tax_by_state.length > 0 && (
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
)}
</div>
<ReportTable
headers={["State", "Total Tax", "Gross Sales", "Orders", "Eff. Rate"]}
rows={summary.tax_by_state}
renderRow={(row) => {
const r = row as { state: string; total_tax: number; gross_sales: number; order_count: number };
const rate = r.gross_sales > 0 ? (r.total_tax / r.gross_sales) * 100 : 0;
return (
<tr key={r.state} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.state}</td>
<td className="px-3 py-2 text-right text-amber-700 font-medium">${r.total_tax.toFixed(2)}</td>
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{r.order_count}</td>
<td className="px-3 py-2 text-right text-zinc-500">{rate.toFixed(2)}%</td>
</tr>
);
}}
/>
</>
)}
{!summary && !loading && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
Click Refresh to load tax summary
</div>
)}
</div>
)}
{/* ── TAXABLE ORDERS TAB ── */}
{activeTab === "orders" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Taxable Orders</h3>
{orders.length > 0 && (
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
)}
</div>
<ReportTable
headers={["Order ID", "Date", "Customer", "City", "State", "Taxable Amt", "Tax", "Rate", "Location"]}
rows={orders}
renderRow={(row) => {
const r = row as TaxOrderRow;
return (
<tr key={r.order_id} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-mono text-xs text-zinc-400">{r.order_id.slice(0, 8)}</td>
<td className="px-3 py-2 text-zinc-400">{r.date}</td>
<td className="px-3 py-2 font-medium text-zinc-100">{r.customer_name}</td>
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
<td className="px-3 py-2 text-right">${Number(r.taxable_amount).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-amber-700 font-medium">${Number(r.tax_amount).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">{Number(r.tax_rate).toFixed(4)}</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.tax_location}</td>
</tr>
);
}}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
};
function quarterLabel(): string {
const now = new Date();
const q = Math.floor(now.getMonth() / 3) + 1;
return `Q${q} ${now.getFullYear()}`;
}
function quarterDateRange(): { start: string; end: string } {
const now = new Date();
const q = Math.floor(now.getMonth() / 3);
const startDate = new Date(now.getFullYear(), q * 3, 1);
const end = now.toISOString().slice(0, 10);
return { start: startDate.toISOString().slice(0, 10), end };
}
export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
const [data, setData] = useState<TaxSummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const range = quarterDateRange();
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
)
.then((r) => r.json())
.then((d) => {
setData({
total_tax_collected: Number(d?.total_tax_collected ?? 0),
total_gross_sales: Number(d?.total_gross_sales ?? 0),
order_count: Number(d?.order_count ?? 0),
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-amber-400 border-t-transparent animate-spin" />
<span className="text-xs text-amber-700">Loading tax summary...</span>
</div>
);
}
if (!data || data.order_count === 0) return null;
const effectiveRate = data.total_gross_sales > 0
? (data.total_tax_collected / data.total_gross_sales) * 100
: 0;
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-amber-700">
{quarterLabel()} Tax Collected
</span>
</div>
<Link
href="/admin/taxes"
className="text-xs text-amber-600 hover:text-amber-800 font-medium"
>
View Details
</Link>
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-xl font-bold text-amber-800">
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
<span className="text-xs text-amber-600">
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
</span>
</div>
</div>
);
}
+446
View File
@@ -0,0 +1,446 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import type { Template, TemplateType, CampaignType } from "@/actions/communications/templates";
import { formatDate } from "@/lib/format-date";
import { upsertTemplate } from "@/actions/communications/templates";
import {
BUILT_IN_TEMPLATES,
renderTemplate,
TEMPLATE_VARIABLES,
type EmailTemplate,
} from "@/lib/email-templates";
const TEMPLATE_TYPES: { value: TemplateType; label: string }[] = [
{ value: "email_template", label: "Email" },
{ value: "sms_template", label: "SMS" },
{ value: "internal_note", label: "Internal Note" },
];
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
{ value: "operational", label: "Operational" },
{ value: "marketing", label: "Marketing" },
{ value: "transactional", label: "Transactional" },
];
// Sample variables for preview rendering
const SAMPLE_VARS: Record<string, string> = {
first_name: "Jane",
company_name: "Route Commerce",
stop_name: "Tuxedo Warehouse",
pickup_date: "May 15, 2026",
order_total: "$450.00",
balance_due: "$225.00",
due_date: "May 10, 2026",
item_summary: "10× Tuxedo Set, 5× Dress Shirt",
custom_message: "We look forward to seeing you!",
cta_text: "Shop Now",
cta_url: "https://routecommerce.com",
tag: "New Arrivals",
headline: "Spring Collection is Here!",
unsubscribe_url: "#",
};
export function TemplateListPanel({ templates }: { templates: Template[] }) {
const router = useRouter();
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Templates</h2>
<p className="text-sm text-zinc-500">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
</div>
<a
href="/admin/communications/templates/new"
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
+ New Template
</a>
</div>
{templates.length === 0 ? (
<div className="text-center py-12 text-slate-400">No templates yet</div>
) : (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-zinc-900 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{templates.map((t) => (
<tr key={t.id} className="hover:bg-zinc-900 cursor-pointer" onClick={() => router.push(`/admin/communications/templates/${t.id}`)}>
<td className="px-4 py-3 font-medium text-zinc-100">{t.name}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
t.template_type === "email_template" ? "bg-blue-900/40 text-blue-700" :
t.template_type === "sms_template" ? "bg-green-900/40 text-green-400" :
"bg-zinc-950 text-zinc-400"
}`}>
{t.template_type}
</span>
</td>
<td className="px-4 py-3 text-zinc-400 truncate max-w-xs">{t.subject}</td>
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(t.updated_at))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export function TemplateEditForm({
template,
mode = "edit",
brandId,
}: {
template?: Template;
mode?: "edit" | "new";
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [name, setName] = useState(template?.name ?? "");
const [subject, setSubject] = useState(template?.subject ?? "");
const [bodyText, setBodyText] = useState(template?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? "");
const [templateType, setTemplateType] = useState<TemplateType>(template?.template_type ?? "email_template");
const [campaignType, setCampaignType] = useState<CampaignType | "">(template?.campaign_type ?? "");
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
const [previewDevice, setPreviewDevice] = useState<"desktop" | "mobile">("desktop");
const [htmlMode, setHtmlMode] = useState(!!template?.body_html);
// Apply a built-in template to the editor fields
function applyBuiltIn(tpl: EmailTemplate) {
const rendered = renderTemplate(tpl, SAMPLE_VARS);
setName(tpl.name);
setSubject(rendered.subject);
setBodyText(rendered.body_text);
setBodyHtml(rendered.body_html);
setHtmlMode(true);
}
function insertVariable(key: string) {
const token = `{{${key}}}`;
const ta = document.getElementById("body-textarea") as HTMLTextAreaElement;
if (ta) {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const next = bodyText.slice(0, start) + token + bodyText.slice(end);
setBodyText(next);
setTimeout(() => {
ta.focus();
ta.setSelectionRange(start + token.length, start + token.length);
}, 0);
} else {
setBodyText((prev) => prev + "\n" + token);
}
}
const preview = {
subject: subject || "Your Email Subject",
body_text: bodyText,
body_html: bodyHtml || `<p style="white-space:pre-wrap">${bodyText}</p>`,
};
const handleSave = async () => {
setSaving(true);
setError("");
const result = await upsertTemplate({
id: template?.id,
brand_id: brandId,
name,
subject,
body_text: bodyText,
body_html: (htmlMode && bodyHtml) ? bodyHtml : undefined,
template_type: templateType,
campaign_type: (campaignType as CampaignType) || undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
router.push("/admin/communications/templates");
router.refresh();
};
return (
<div className="max-w-5xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-zinc-100">
{mode === "new" ? "New Template" : "Edit Template"}
</h2>
<div className="flex gap-2">
<button
type="button"
onClick={() => setActiveTab("edit")}
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "edit" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
>
Edit
</button>
<button
type="button"
onClick={() => setActiveTab("preview")}
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "preview" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
>
Preview
</button>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{activeTab === "edit" ? (
<div className="space-y-6">
{/* Basic info */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Template Info</h3>
{/* Built-in template picker */}
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Start from:</span>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
onChange={(e) => {
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
if (tpl) applyBuiltIn(tpl);
e.target.value = "";
}}
defaultValue=""
>
<option value=""> Pre-built template </option>
{BUILT_IN_TEMPLATES.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="e.g. Pickup Reminder"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Type *</label>
<select
value={templateType}
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
{TEMPLATE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type</label>
<select
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
<option value="">Any</option>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Subject */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-800">Subject Line</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject *</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Email subject line"
/>
<p className="mt-1 text-xs text-slate-400">
Variables:{" "}
{TEMPLATE_VARIABLES.slice(0, 6).map((v) => (
<button
key={v.key}
type="button"
onClick={() => {
const tok = `{{${v.key}}}`;
const start = subject.length;
setSubject((s) => s + tok);
}}
className="ml-1 text-blue-400 hover:text-blue-800"
>
{`{{${v.key}}}`}
</button>
))}
</p>
</div>
</div>
{/* Body editor */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Message Body</h3>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={htmlMode}
onChange={(e) => setHtmlMode(e.target.checked)}
className="rounded border-slate-400"
/>
<span className="text-zinc-400">HTML mode</span>
</label>
</div>
{/* Variable toolbar */}
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-slate-400 self-center mr-1">Insert:</span>
{TEMPLATE_VARIABLES.map((v) => (
<button
key={v.key}
type="button"
title={`${v.label}: ${v.example}`}
onClick={() => insertVariable(v.key)}
className="rounded bg-zinc-950 px-2 py-1 text-xs text-zinc-400 hover:bg-blue-900/40 hover:text-blue-700"
>
{`{{${v.key}}}`}
</button>
))}
</div>
{htmlMode ? (
<>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">HTML Body</label>
<textarea
value={bodyHtml}
onChange={(e) => setBodyHtml(e.target.value)}
rows={12}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
placeholder="<p>HTML version with variables like {{first_name}}...</p>"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Plain Text Fallback *</label>
<textarea
id="body-textarea"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={6}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Plain text version..."
/>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text) *</label>
<textarea
id="body-textarea"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={10}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Message body with variables like {{first_name}}..."
/>
</div>
)}
<p className="text-xs text-slate-400">
Tip: Click any variable button above to insert it at the cursor position. Variables are replaced when the email is sent.
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={saving || !name || !subject || !bodyText}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Template"}
</button>
<a href="/admin/communications/templates" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
Cancel
</a>
</div>
</div>
) : (
/* Preview Tab */
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className="text-sm text-zinc-400">Preview:</span>
<div className="flex rounded-lg border border-zinc-600 overflow-hidden">
<button
onClick={() => setPreviewDevice("desktop")}
className={`px-4 py-2 text-sm font-medium ${previewDevice === "desktop" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
>
Desktop
</button>
<button
onClick={() => setPreviewDevice("mobile")}
className={`px-4 py-2 text-sm font-medium ${previewDevice === "mobile" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
>
Mobile
</button>
</div>
</div>
{/* Email preview */}
<div className={`mx-auto transition-all ${previewDevice === "mobile" ? "max-w-[375px]" : "max-w-[600px]"}`}>
<div className="bg-zinc-900 rounded-xl border border-zinc-800 shadow-black/20 overflow-hidden">
{/* Mock email header */}
<div className="bg-zinc-950 border-b border-zinc-800 px-4 py-2 flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
</div>
<div className="flex-1 text-center text-xs text-zinc-500 truncate">
{preview.subject}
</div>
</div>
{/* Email body */}
<div
className="overflow-hidden"
dangerouslySetInnerHTML={{ __html: preview.body_html }}
/>
{/* Plain text toggle */}
{bodyText && (
<details className="border-t border-zinc-800">
<summary className="px-4 py-2 text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
View plain text version
</summary>
<pre className="px-4 pb-4 text-xs text-zinc-400 whitespace-pre-wrap">{preview.body_text}</pre>
</details>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { getTimeTrackingWorkers, getTimeTrackingTasks, getWorkerTimeLogs, getTimeTrackingSummary, type TimeWorker, type TimeTask, type TimeLog, type TimeSummary } from "@/actions/time-tracking";
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
function formatHours(h: number): string {
return h < 1 ? `${(h * 60).toFixed(0)}m` : `${h.toFixed(1)}h`;
}
function formatMinutes(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }) {
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [summary, setSummary] = useState<TimeSummary | null>(null);
const [logs, setLogs] = useState<TimeLog[]>([]);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const end = now.toISOString().slice(0, 10);
return { start, end };
});
const [selectedWorker, setSelectedWorker] = useState("");
const [selectedTask, setSelectedTask] = useState("");
const [tab, setTab] = useState<"summary" | "logs">("summary");
const [logPage, setLogPage] = useState(0);
const PAGE_SIZE = 50;
const load = useCallback(async () => {
if (!brandId) return;
setLoading(true);
const [w, t, s, l] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSummary(brandId, dateRange.start, dateRange.end),
getWorkerTimeLogs(brandId, {
workerId: selectedWorker || undefined,
taskId: selectedTask || undefined,
start: dateRange.start,
end: dateRange.end,
limit: PAGE_SIZE,
offset: logPage * PAGE_SIZE,
}),
]);
setWorkers(w);
setTasks(t);
setSummary(s);
setLogs(l);
setLoading(false);
}, [brandId, dateRange, selectedWorker, selectedTask, logPage]);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Data fetching pattern
useEffect(() => { load(); }, [load]);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset pagination on filter change
useEffect(() => { setLogPage(0); }, [dateRange, selectedWorker, selectedTask]);
if (!brandId) {
return (
<div className="text-center py-20 text-stone-500">
<p className="text-sm">Select a brand to view time tracking.</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Time Tracking</h1>
<p className="text-sm text-stone-500 mt-0.5">
{dateRange.start} {dateRange.end}
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/time-tracking/settings?tab=export"
className="flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 border border-stone-200 hover:border-stone-300 bg-white hover:bg-stone-50 rounded-xl px-4 py-2 transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export CSV
</Link>
<Link
href="/admin/time-tracking/settings"
className="flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 border border-stone-200 hover:border-stone-300 bg-white hover:bg-stone-50 rounded-xl px-4 py-2 transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</Link>
</div>
</div>
{/* Filters */}
<div className="rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">From</label>
<input
type="date"
value={dateRange.start}
onChange={e => setDateRange(r => ({ ...r, start: e.target.value }))}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">To</label>
<input
type="date"
value={dateRange.end}
onChange={e => setDateRange(r => ({ ...r, end: e.target.value }))}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">Worker</label>
<select
value={selectedWorker}
onChange={e => setSelectedWorker(e.target.value)}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors min-w-[160px]"
>
<option value="">All Workers</option>
{workers.map(w => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">Task</label>
<select
value={selectedTask}
onChange={e => setSelectedTask(e.target.value)}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors min-w-[160px]"
>
<option value="">All Tasks</option>
{tasks.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<button
onClick={load}
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-all shadow-sm"
>
Refresh
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-stone-200">
<button
onClick={() => setTab("summary")}
className={`px-4 py-2.5 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "summary" ? "text-emerald-600 border-emerald-600" : "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"}`}
>
Summary
</button>
<button
onClick={() => setTab("logs")}
className={`px-4 py-2.5 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "logs" ? "text-emerald-600 border-emerald-600" : "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"}`}
>
All Logs ({loading ? "…" : logs.length})
</button>
</div>
{loading ? (
<div className="text-center py-16 text-stone-500 text-sm">Loading...</div>
) : tab === "summary" && summary ? (
<div className="space-y-6">
{/* Totals */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Total Hours</p>
<p className="text-3xl font-black text-stone-950">{formatHours(summary.totals.total_hours)}</p>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Entries</p>
<p className="text-3xl font-black text-stone-950">{summary.totals.entry_count}</p>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Open Clock-ins</p>
<p className="text-3xl font-black text-amber-600">{summary.totals.open_count}</p>
</div>
</div>
{/* By Worker */}
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100">
<h3 className="text-sm font-bold text-stone-950">By Worker</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest bg-stone-50">
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-right px-5 py-3 font-medium">Entries</th>
<th className="text-right px-5 py-3 font-medium">Total Hours</th>
</tr>
</thead>
<tbody>
{(summary.by_worker ?? []).length === 0 ? (
<tr><td colSpan={3} className="px-5 py-8 text-center text-stone-400">No data</td></tr>
) : (summary.by_worker ?? []).map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-900 font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-stone-600 text-right">{w.entry_count}</td>
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatHours(w.total_hours)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* By Task */}
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100">
<h3 className="text-sm font-bold text-stone-950">By Task</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest bg-stone-50">
<th className="text-left px-5 py-3 font-medium">Task</th>
<th className="text-right px-5 py-3 font-medium">Entries</th>
<th className="text-right px-5 py-3 font-medium">Total Hours</th>
</tr>
</thead>
<tbody>
{(summary.by_task ?? []).length === 0 ? (
<tr><td colSpan={3} className="px-5 py-8 text-center text-stone-400">No data</td></tr>
) : (summary.by_task ?? []).map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-900 font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-stone-600 text-right">{t.entry_count}</td>
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatHours(t.total_hours)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
/* Logs tab */
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
{/* Pagination header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-xs text-stone-500">
{logs.length === PAGE_SIZE
? `Showing page ${logPage + 1}`
: `${logs.length} entries`}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setLogPage(p => Math.max(0, p - 1))}
disabled={logPage === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-xs text-stone-500 px-1">Page {logPage + 1}</span>
<button
onClick={() => setLogPage(p => p + 1)}
disabled={logs.length < PAGE_SIZE}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-200 bg-stone-50">
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-left px-5 py-3 font-medium">Task</th>
<th className="text-left px-5 py-3 font-medium">Clock In</th>
<th className="text-left px-5 py-3 font-medium">Clock Out</th>
<th className="text-right px-5 py-3 font-medium">Lunch</th>
<th className="text-right px-5 py-3 font-medium">Total</th>
<th className="text-left px-5 py-3 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-stone-400">No entries found</td></tr>
) : logs.map(log => (
<tr key={log.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-900 font-medium">{log.worker_name}</td>
<td className="px-5 py-3.5 text-stone-600">{log.task_name}</td>
<td className="px-5 py-3.5 text-stone-600 font-mono text-xs">
<span>{formatDate(log.clock_in)}</span>
<span className="ml-2 text-stone-400">{formatTime(log.clock_in)}</span>
</td>
<td className="px-5 py-3.5 font-mono text-xs">
{log.clock_out ? (
<>
<span className="text-stone-600">{formatDate(log.clock_out)}</span>
<span className="ml-2 text-stone-400">{formatTime(log.clock_out)}</span>
</>
) : (
<span className="text-amber-600 font-semibold">OPEN</span>
)}
</td>
<td className="px-5 py-3.5 text-stone-600 text-right">{log.lunch_break_minutes > 0 ? `${log.lunch_break_minutes}m` : "—"}</td>
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatMinutes(log.total_minutes)}</td>
<td className="px-5 py-3.5 text-stone-500 text-xs max-w-[160px] truncate">{log.notes ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,766 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
createTimeWorker,
resetTimeWorkerPin,
updateTimeWorker,
deleteTimeWorker,
createTimeTask,
updateTimeTask,
deleteTimeTask,
getTimeTrackingSettings,
updateTimeTrackingSettings,
getTimeTrackingNotificationLog,
type TimeWorker,
type TimeTask,
type TimeTrackingSettings,
type NotificationLogEntry,
} from "@/actions/time-tracking";
type Tab = "dashboard" | "settings";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
interface TimeTrackingSettingsClientProps {
brandId: string;
}
export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSettingsClientProps) {
const [tab, setTab] = useState<Tab>("dashboard");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = params.get("tab");
if (t === "settings" || t === "export") setTab(t as Tab);
}, []);
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [payPeriodStartDay, setPayPeriodStartDay] = useState(0);
const [payPeriodLength, setPayPeriodLength] = useState(7);
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [notificationEmails, setNotificationEmails] = useState<string[]>([]);
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
const [brandName, setBrandName] = useState("Farm");
const [newEmail, setNewEmail] = useState("");
const [newSms, setNewSms] = useState("");
const [showWorkerModal, setShowWorkerModal] = useState(false);
const [editingWorker, setEditingWorker] = useState<TimeWorker | null>(null);
const [workerName, setWorkerName] = useState("");
const [workerRole, setWorkerRole] = useState("worker");
const [workerLang, setWorkerLang] = useState("en");
const [workerActive, setWorkerActive] = useState(true);
const [resetPinResult, setResetPinResult] = useState<string | null>(null);
const [workerError, setWorkerError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editingTask, setEditingTask] = useState<TimeTask | null>(null);
const [taskName, setTaskName] = useState("");
const [taskNameEs, setTaskNameEs] = useState("");
const [taskUnit, setTaskUnit] = useState("hours");
const [taskActive, setTaskActive] = useState(true);
const [taskSortOrder, setTaskSortOrder] = useState(0);
const [taskError, setTaskError] = useState<string | null>(null);
const [exportStart, setExportStart] = useState("");
const [exportEnd, setExportEnd] = useState("");
const [exportBrand, setExportBrand] = useState<"all" | "tuxedo" | "ird">("all");
const [includeOvertime, setIncludeOvertime] = useState(true);
const [includeNotes, setIncludeNotes] = useState(true);
const load = useCallback(async () => {
setLoading(true);
const [w, t, s] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSettings(brandId),
]);
setWorkers(w);
setTasks(t);
if (s) {
setSettings(s);
setPayPeriodStartDay(s.pay_period_start_day);
setPayPeriodLength(s.pay_period_length_days);
setDailyOvertimeThreshold(s.daily_overtime_threshold);
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
setOvertimeMultiplier(s.overtime_multiplier);
setOvertimeNotifications(s.overtime_notifications);
setNotificationEmails(s.notification_emails ?? []);
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
setBrandName(s.brand_name ?? "Farm");
}
const log = await getTimeTrackingNotificationLog(brandId, 50);
setNotificationLog(log);
setLoading(false);
}, [brandId]);
useEffect(() => { load(); }, [load]);
const handleSaveNotifications = async () => {
setSettingsSaving(true);
setSettingsError(null);
setSettingsSaved(false);
const result = await updateTimeTrackingSettings(brandId, {
pay_period_start_day: payPeriodStartDay,
pay_period_length_days: payPeriodLength,
daily_overtime_threshold: dailyOvertimeThreshold,
weekly_overtime_threshold: weeklyOvertimeThreshold,
overtime_multiplier: overtimeMultiplier,
overtime_notifications: overtimeNotifications,
notification_emails: notificationEmails,
notification_sms_numbers: notificationSmsNumbers,
enable_daily_alerts: enableDailyAlerts,
enable_weekly_alerts: enableWeeklyAlerts,
daily_alert_threshold: dailyAlertThreshold / 100,
weekly_alert_threshold: weeklyAlertThreshold / 100,
send_end_of_period_summary: sendEndOfPeriodSummary,
brand_name: brandName,
});
setSettingsSaving(false);
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
setSettingsSaved(true);
setTimeout(() => setSettingsSaved(false), 3000);
load();
};
const addEmail = () => {
const email = newEmail.trim();
if (email && !notificationEmails.includes(email)) {
setNotificationEmails([...notificationEmails, email]);
}
setNewEmail("");
};
const removeEmail = (email: string) => setNotificationEmails(notificationEmails.filter(e => e !== email));
const addSms = () => {
const num = newSms.replace(/[^0-9+]/g, "");
if (num && !notificationSmsNumbers.includes(num)) {
setNotificationSmsNumbers([...notificationSmsNumbers, num]);
}
setNewSms("");
};
const removeSms = (num: string) => setNotificationSmsNumbers(notificationSmsNumbers.filter(n => n !== num));
function buildExportUrl(type: "quickbooks" | "payroll" | "detailed" | "summary") {
const params = new URLSearchParams({
type,
start: exportStart,
end: exportEnd,
brand: exportBrand,
overtime: String(includeOvertime),
notes: String(includeNotes),
});
return `/api/time-tracking/export?${params}`;
}
function triggerDownload(url: string) {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
const openAddWorker = () => {
setEditingWorker(null);
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
setWorkerActive(true); setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const openEditWorker = (w: TimeWorker) => {
setEditingWorker(w); setWorkerName(w.name); setWorkerRole(w.role);
setWorkerLang(w.lang); setWorkerActive(w.active);
setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const handleSaveWorker = async () => {
if (!workerName.trim()) { setWorkerError("Name is required"); return; }
setSubmitting(true); setWorkerError(null);
if (editingWorker) {
const result = await updateTimeWorker(editingWorker.id, workerName.trim(), workerRole, workerLang, workerActive);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeWorker(brandId, workerName.trim(), workerRole, workerLang);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowWorkerModal(false); load();
};
const handleResetPin = async (workerId: string) => {
const result = await resetTimeWorkerPin(workerId);
if (result.success && result.pin) setResetPinResult(result.pin);
else setWorkerError(result.error ?? "Failed to reset PIN");
};
const handleDeleteWorker = async (workerId: string) => {
if (!confirm("Delete this worker? This cannot be undone.")) return;
const result = await deleteTimeWorker(workerId);
if (!result.success) { setWorkerError(result.error ?? "Failed"); return; }
load();
};
const openAddTask = () => {
setEditingTask(null); setTaskName(""); setTaskNameEs("");
setTaskUnit("hours"); setTaskActive(true); setTaskSortOrder(0);
setTaskError(null); setShowTaskModal(true);
};
const openEditTask = (t: TimeTask) => {
setEditingTask(t); setTaskName(t.name); setTaskNameEs(t.name_es ?? "");
setTaskUnit(t.unit); setTaskActive(t.active); setTaskSortOrder(t.sort_order);
setTaskError(null); setShowTaskModal(true);
};
const handleSaveTask = async () => {
if (!taskName.trim()) { setTaskError("Name is required"); return; }
setSubmitting(true); setTaskError(null);
if (editingTask) {
const result = await updateTimeTask(editingTask.id, taskName.trim(), taskNameEs, taskUnit, taskActive, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeTask(brandId, taskName.trim(), taskNameEs || null, taskUnit, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowTaskModal(false); load();
};
const handleDeleteTask = async (taskId: string) => {
if (!confirm("Delete this task?")) return;
const result = await deleteTimeTask(taskId);
if (!result.success) { setTaskError(result.error ?? "Failed"); return; }
load();
};
const triggerLabel: Record<string, { en: string; color: string }> = {
daily_approaching: { en: "Daily OT Approaching", color: "bg-amber-100 text-amber-700" },
daily_reached: { en: "Daily OT Reached", color: "bg-red-100 text-red-700" },
weekly_approaching: { en: "Weekly OT Approaching", color: "bg-amber-100 text-amber-700" },
weekly_reached: { en: "Weekly OT Reached", color: "bg-red-100 text-red-700" },
end_of_period_summary:{ en: "Period Summary", color: "bg-emerald-100 text-emerald-700" },
};
if (loading) return <div className="text-center py-16 text-stone-500 text-sm">Loading...</div>;
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-stone-900">Time Tracking</h2>
<p className="text-sm text-stone-500 mt-0.5">
{tab === "dashboard" ? "Summary, exports & recent activity" : "Workers, tasks & overtime rules"}
</p>
</div>
</div>
{/* Two-tab nav */}
<div className="flex gap-1 border-b border-stone-200">
<button onClick={() => setTab("dashboard")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "dashboard" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
Dashboard
</button>
<button onClick={() => setTab("settings")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "settings" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
Settings
</button>
</div>
{/* Dashboard tab */}
{tab === "dashboard" && (
<div className="space-y-6">
{/* Stat row */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-stone-900">{workers.length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Workers</p>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-stone-900">{tasks.length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Tasks</p>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-amber-600">{notificationLog.filter(n => n.email_sent || n.sms_sent).length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Alerts Sent</p>
</div>
</div>
{/* Quick export section */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Export Data</h3>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-emerald-500 transition-all text-left">
<span className="text-lg">📒</span>
<div>
<p className="text-sm font-semibold text-stone-800">QuickBooks</p>
<p className="text-xs text-stone-500">Time import CSV</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("payroll"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-blue-500 transition-all text-left">
<span className="text-lg">💰</span>
<div>
<p className="text-sm font-semibold text-stone-800">Payroll</p>
<p className="text-xs text-stone-500">ADP / Gusto / Generic</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("detailed"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-violet-500 transition-all text-left">
<span className="text-lg">📋</span>
<div>
<p className="text-sm font-semibold text-stone-800">Detailed Log</p>
<p className="text-xs text-stone-500">Full audit report</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("summary"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-amber-500 transition-all text-left">
<span className="text-lg">📊</span>
<div>
<p className="text-sm font-semibold text-stone-800">Period Summary</p>
<p className="text-xs text-stone-500">Per-worker totals</p>
</div>
</button>
</div>
<div className="flex items-center gap-4 text-xs text-stone-500">
<span>From:</span>
<input type="date" value={exportStart} onChange={e => setExportStart(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
<span>To:</span>
<input type="date" value={exportEnd} onChange={e => setExportEnd(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
</div>
</div>
{/* Notification log */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Recent Alerts</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-5 py-3 font-medium">Time</th>
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-left px-5 py-3 font-medium">Alert</th>
<th className="text-left px-5 py-3 font-medium">Email</th>
<th className="text-left px-5 py-3 font-medium">SMS</th>
</tr>
</thead>
<tbody>
{notificationLog.length === 0 ? (
<tr><td colSpan={5} className="px-5 py-8 text-center text-stone-400">No alerts sent yet</td></tr>
) : notificationLog.slice(0, 20).map(entry => {
const meta = triggerLabel[entry.trigger_type] ?? { en: entry.trigger_type, color: "bg-stone-100 text-stone-500" };
return (
<tr key={entry.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-500 text-xs">{new Date(entry.created_at).toLocaleString()}</td>
<td className="px-5 py-3.5 text-stone-800 font-medium">{entry.worker_name ?? "—"}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>{meta.en}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className={`text-sm font-bold ${entry.email_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.email_sent ? "✓" : "—"}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className={`text-sm font-bold ${entry.sms_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.sms_sent ? "✓" : "—"}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Settings tab */}
{tab === "settings" && (
<div className="space-y-6">
{/* Workers section */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Workers & PINs</h3>
<button onClick={() => { setEditingWorker(null); setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en"); setWorkerActive(true); setShowWorkerModal(true); setWorkerError(null); setResetPinResult(null); }}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Role</th>
<th className="text-left px-5 py-3 font-medium">Lang</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Last Used</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No workers yet</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-800 font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-stone-500 capitalize">{w.role}</td>
<td className="px-5 py-3.5 text-stone-400 uppercase text-xs font-mono">{w.lang}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-stone-400 text-xs">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Tasks section */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Tasks</h3>
<button onClick={() => { setEditingTask(null); setTaskName(""); setTaskNameEs(""); setTaskUnit("hours"); setTaskSortOrder(0); setTaskActive(true); setShowTaskModal(true); setTaskError(null); }}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Name (ES)</th>
<th className="text-left px-5 py-3 font-medium">Unit</th>
<th className="text-left px-5 py-3 font-medium">Sort</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No tasks yet</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-800 font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-stone-500">{t.name_es ?? "—"}</td>
<td className="px-5 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
<td className="px-5 py-3.5 text-stone-400 text-xs">{t.sort_order}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pay Period & Overtime section */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Pay Period & Overtime</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={31} value={payPeriodLength}
onChange={e => setPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
</div>
</div>
{/* Colorado notice */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
<div className="flex items-start gap-3">
<span className="text-amber-600 text-lg mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
</div>
</div>
</div>
{/* Alert Settings */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
<p className="text-xs text-stone-500">Send notification when worker hits daily threshold</p>
</div>
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
<p className="text-xs text-stone-500">Send notification when worker hits weekly threshold</p>
</div>
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Show overtime warning on employee screen</p>
<p className="text-xs text-stone-500">Display alert when worker approaches thresholds</p>
</div>
<button onClick={() => setOvertimeNotifications(!overtimeNotifications)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${overtimeNotifications ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
</div>
</div>
{/* Notification Recipients */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Notification Recipients</h3>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="manager@farm.com" />
<button onClick={addEmail} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="+1234567890" />
<button onClick={addSms} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
</div>
{settingsError && (
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
)}
{settingsSaved && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved successfully.</div>
)}
<button onClick={handleSaveNotifications} disabled={settingsSaving}
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
{settingsSaving ? "Saving..." : "Save Settings"}
</button>
</div>
)}
{/* Worker Modal */}
{showWorkerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
{resetPinResult && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
<p className="text-stone-500 text-xs mt-1">Show this to the worker it will not be shown again.</p>
</div>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
Reset PIN for this worker
</button>
</div>
)}
<div className="flex gap-3 pt-2">
<button onClick={() => setShowWorkerModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveWorker} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Task Modal */}
{showTaskModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowTaskModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveTask} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+656
View File
@@ -0,0 +1,656 @@
"use client";
import { useState } from "react";
import { AdminUserRow, CreateAdminUserInput, UpdateAdminUserInput } from "@/actions/admin/users";
import { formatDate } from "@/lib/format-date";
type UsersPageProps = {
initialUsers: AdminUserRow[];
brands: { id: string; name: string }[];
currentUser: {
id: string;
role: string;
can_manage_users?: boolean;
};
};
type PasswordModal = {
email: string;
tempPassword: string | null;
loading: boolean;
error: string | null;
};
const FLAG_LABELS: Record<string, string> = {
can_manage_products: "Products",
can_manage_stops: "Stops",
can_manage_orders: "Orders",
can_manage_pickup: "Pickup",
can_manage_messages: "Messages",
can_manage_refunds: "Refunds",
can_manage_users: "Users",
can_manage_water_log: "Water Log",
can_manage_reports: "Reports",
};
const ALL_FLAGS = Object.keys(FLAG_LABELS);
function RoleBadge({ role }: { role: string }) {
const colors: Record<string, string> = {
platform_admin: "bg-purple-100 text-purple-700",
brand_admin: "bg-blue-100 text-blue-700",
store_employee: "bg-emerald-100 text-emerald-700",
staff: "bg-stone-100 text-stone-600",
};
return (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors[role] ?? "bg-stone-100 text-stone-600"}`}>
{role.replace("_", " ")}
</span>
);
}
type EditingUser = {
id?: string;
email?: string;
password?: string;
display_name?: string;
phone_number?: string;
role: "platform_admin" | "brand_admin" | "store_employee";
brand_id: string | null;
flags: Record<string, boolean>;
active?: boolean;
isNew: boolean;
};
const defaultFlags: Record<string, boolean> = {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: false,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
};
function emptyEditing(isNew: boolean): EditingUser {
return {
isNew,
role: "store_employee",
brand_id: null,
display_name: "",
phone_number: "",
password: "",
flags: { ...defaultFlags },
};
}
function editingFromRow(row: AdminUserRow): EditingUser {
return {
id: row.id,
email: row.email,
display_name: row.display_name ?? "",
phone_number: row.phone_number ?? "",
role: row.role as "platform_admin" | "brand_admin" | "store_employee",
brand_id: row.brand_id,
flags: {
can_manage_products: row.can_manage_products,
can_manage_stops: row.can_manage_stops,
can_manage_orders: row.can_manage_orders,
can_manage_pickup: row.can_manage_pickup,
can_manage_messages: row.can_manage_messages,
can_manage_refunds: row.can_manage_refunds,
can_manage_users: row.can_manage_users,
can_manage_water_log: row.can_manage_water_log,
can_manage_reports: row.can_manage_reports,
},
active: row.active,
isNew: false,
};
}
export default function UsersPage({ initialUsers, brands, currentUser }: UsersPageProps) {
const [users, setUsers] = useState(initialUsers);
const [panelOpen, setPanelOpen] = useState(false);
const [editing, setEditing] = useState<EditingUser>(emptyEditing(true));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [actionMenuId, setActionMenuId] = useState<string | null>(null);
const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null);
const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null);
const canManageUsers =
currentUser.role === "platform_admin" ||
(currentUser.role === "brand_admin" && currentUser.can_manage_users);
function openCreate() {
setEditing(emptyEditing(true));
setError(null);
setPanelOpen(true);
}
function openEdit(row: AdminUserRow) {
setEditing(editingFromRow(row));
setError(null);
setPanelOpen(true);
}
function closePanel() {
setPanelOpen(false);
setEditing(emptyEditing(true));
setError(null);
}
function setRole(role: EditingUser["role"]) {
setEditing((prev) => ({ ...prev, role }));
}
function setBrand(brand_id: string | null) {
setEditing((prev) => ({ ...prev, brand_id }));
}
function toggleFlag(flag: string) {
setEditing((prev) => ({
...prev,
flags: { ...prev.flags, [flag]: !prev.flags[flag] },
}));
}
function setActive(active: boolean) {
setEditing((prev) => ({ ...prev, active }));
}
async function handleSave() {
setSaving(true);
setError(null);
try {
if (editing.isNew) {
const res = await import("@/actions/admin/users").then((m) =>
m.createAdminUser({
email: editing.email!,
password: editing.password!,
display_name: editing.display_name || undefined,
phone_number: editing.phone_number || undefined,
role: editing.role,
brand_id: editing.brand_id,
flags: editing.flags,
mustChangePassword: true,
})
);
if (res.error) {
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
setError("Your session may have expired. Please log in again.");
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
setError("You do not have permission to edit this user.");
} else {
setError(res.error);
}
return;
}
if (res.user) setUsers((prev) => [res.user!, ...prev]);
} else {
const res = await import("@/actions/admin/users").then((m) =>
m.updateAdminUser({
id: editing.id!,
role: editing.role,
brand_id: editing.brand_id,
flags: editing.flags,
active: editing.active,
display_name: editing.display_name === "" ? null : editing.display_name || undefined,
phone_number: editing.phone_number === "" ? null : editing.phone_number || undefined,
})
);
if (res.error) {
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
setError("Your session may have expired. Please log in again.");
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
setError("You do not have permission to edit this user.");
} else {
setError(res.error);
}
return;
}
if (res.user) setUsers((prev) => prev.map((u) => (u.id === res.user!.id ? res.user! : u)));
}
closePanel();
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
const res = await import("@/actions/admin/users").then((m) => m.deleteAdminUser(id));
if (res.error) { setError(res.error); return; }
setUsers((prev) => prev.filter((u) => u.id !== id));
setDeleteConfirmId(null);
}
async function handleResetPassword(email: string) {
setPasswordModal({ email, tempPassword: null, loading: true, error: null });
try {
const { resetAdminPassword } = await import("@/actions/admin/reset-admin");
const result = await resetAdminPassword(email, "Tuxedo2026!");
if (result.success) {
setPasswordModal({ email, tempPassword: result.tempPassword, loading: false, error: null });
} else {
setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" });
}
} catch (e: any) {
setPasswordModal({ email, tempPassword: null, loading: false, error: e?.message ?? "Error" });
}
}
async function handleForcePasswordChange(id: string) {
try {
const { setMustChangePassword } = await import("@/actions/admin/users");
const res = await setMustChangePassword(id);
if (res.error) throw new Error(res.error);
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, must_change_password: true } : u));
} catch (e: any) {
setError(e?.message ?? "Failed to require password change");
}
}
async function handleSendResetLink(email: string) {
setResetLinkLoading(email);
try {
const { sendPasswordResetEmail } = await import("@/actions/admin/users");
const result = await sendPasswordResetEmail(email);
setResetLinkLoading(null);
if (result.error) throw new Error(result.error);
setError(null);
} catch (e: any) {
setResetLinkLoading(null);
setError(e?.message ?? "Failed to send reset email");
}
}
function copyTempPassword() {
if (passwordModal?.tempPassword) {
navigator.clipboard.writeText(passwordModal.tempPassword).catch(() => {});
}
}
const showBrandSelect = editing.role === "brand_admin" || editing.role === "store_employee";
const availableRoles: EditingUser["role"][] =
currentUser.role === "platform_admin"
? ["platform_admin", "brand_admin", "store_employee"]
: ["brand_admin", "store_employee"];
return (
<>
{/* User list */}
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-stone-900">{users.length} user{users.length !== 1 ? "s" : ""}</h2>
</div>
{canManageUsers && (
<button
onClick={openCreate}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
>
+ Create User
</button>
)}
</div>
{/* Error banner */}
{error && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0"></button>
</div>
)}
<div className="overflow-visible rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-stone-500">
<tr>
<th className="px-5 py-4 font-semibold">Name</th>
<th className="px-5 py-4 font-semibold">Phone</th>
<th className="px-5 py-4 font-semibold">Role</th>
<th className="px-5 py-4 font-semibold">Brand</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold">Created</th>
{canManageUsers && <th className="px-5 py-4 font-semibold" />}
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-stone-50">
<td className="px-5 py-4">
<div className="font-medium text-stone-900">{user.display_name || user.email}</div>
{user.display_name && user.display_name !== "No Name" && (
<div className="text-xs text-stone-500">{user.email}</div>
)}
</td>
<td className="px-5 py-4 text-stone-500 text-sm">{user.phone_number ?? "—"}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-4 text-stone-500">{user.brand_name ?? "—"}</td>
<td className="px-5 py-4">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${user.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{user.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-stone-400 text-xs">
{formatDate(new Date(user.created_at))}
</td>
{canManageUsers && (
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<button
onClick={() => openEdit(user)}
className="text-xs font-medium text-stone-500 hover:text-stone-900"
>
Edit
</button>
<div className="relative overflow-visible">
<button
onClick={() => setActionMenuId(actionMenuId === user.id ? null : user.id)}
className="text-base font-medium text-stone-400 hover:text-stone-600 px-2 py-1"
title="More actions"
>
</button>
{actionMenuId === user.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setActionMenuId(null)} />
<div className="absolute right-0 z-50 mt-1 w-48 rounded-xl bg-white shadow-xl ring-1 ring-stone-200 border border-stone-100 py-1 text-xs overflow-visible">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
Actions
</div>
<button
onClick={() => { setActionMenuId(null); handleResetPassword(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
🔑 Reset Password
</button>
<button
onClick={() => { setActionMenuId(null); handleForcePasswordChange(user.id); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Require Password Change
</button>
<button
onClick={() => { setActionMenuId(null); handleSendResetLink(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Send Reset Email
</button>
{user.id !== currentUser.id && (
<button
onClick={() => { setActionMenuId(null); setDeleteConfirmId(user.id); }}
className="w-full text-left px-3 py-2.5 text-red-600 hover:bg-red-50 flex items-center gap-2 border-t border-stone-100 mt-1 pt-1"
>
🗑 Delete
</button>
)}
</div>
</>
)}
</div>
</div>
</td>
)}
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="px-5 py-12 text-center text-stone-400">
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Delete confirm modal */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
<h3 className="text-lg font-bold text-stone-900">Delete user?</h3>
<p className="mt-2 text-sm text-stone-600">
This will remove their admin access. Their auth account will remain active.
</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* Slide-in panel */}
{panelOpen && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/20" onClick={closePanel}>
<div
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<h2 className="text-lg font-bold text-stone-900">
{editing.isNew ? "Create User" : "Edit User"}
</h2>
<button onClick={closePanel} className="text-stone-400 hover:text-stone-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Saving overlay */}
{saving && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
<p className="text-sm text-stone-500">Saving</p>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-6">
{/* Email (create only) */}
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Email</label>
<input
type="email"
value={editing.email ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, email: e.target.value }))}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
/>
</div>
)}
{/* Password (create only) */}
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Password</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Minimum 6 characters.</p>
<input
type="password"
value={editing.password ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, password: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="Choose a password"
minLength={6}
/>
</div>
)}
{/* Display Name */}
<div>
<label className="block text-sm font-medium text-stone-700">Display Name</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Shown in the admin user list.</p>
<input
type="text"
value={editing.display_name ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, display_name: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="Kyle Martinez"
/>
</div>
{/* Phone Number */}
<div>
<label className="block text-sm font-medium text-stone-700">Phone Number</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Optional.</p>
<input
type="tel"
value={editing.phone_number ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, phone_number: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="+1 (555) 000-0000"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-stone-700">Role</label>
<div className="mt-2 space-y-2">
{availableRoles.map((r) => (
<label key={r} className="flex items-center gap-3 rounded-lg border border-stone-200 px-3 py-2.5 cursor-pointer hover:bg-stone-50">
<input
type="radio"
name="role"
value={r}
checked={editing.role === r}
onChange={() => setRole(r)}
className="accent-stone-900"
/>
<div>
<span className="font-medium text-stone-900 capitalize">{r.replace("_", " ")}</span>
<p className="text-xs text-stone-500">
{r === "platform_admin" ? "Full platform access" :
r === "brand_admin" ? "Brand-level admin" :
"Pickup and order operations only"}
</p>
</div>
</label>
))}
</div>
</div>
{/* Brand */}
{showBrandSelect && (
<div>
<label className="block text-sm font-medium text-stone-700">Brand</label>
<select
value={editing.brand_id ?? ""}
onChange={(e) => setBrand(e.target.value || null)}
className="mt-1 w-full rounded-lg border border-stone-200 px-3 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
<option value="">No brand (full platform scope)</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{/* Active toggle (edit only) */}
{!editing.isNew && (
<div className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-3">
<div>
<p className="font-medium text-stone-900">Active</p>
<p className="text-xs text-stone-500">Deactivated users cannot log in</p>
</div>
<button
onClick={() => setActive(!editing.active)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.active ? "bg-emerald-600" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.active ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
)}
{/* Permissions */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-3">Permissions</label>
<div className="space-y-2">
{ALL_FLAGS.map((flag) => (
<label key={flag} className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-2.5 hover:bg-stone-50 cursor-pointer">
<span className="text-sm text-stone-700">{FLAG_LABELS[flag]}</span>
<button
type="button"
onClick={() => toggleFlag(flag)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.flags[flag] ? "bg-emerald-600" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.flags[flag] ? "translate-x-6" : "translate-x-1"}`} />
</button>
</label>
))}
</div>
</div>
</div>
</div>
<div className="border-t px-6 py-4">
<div className="flex justify-end gap-3">
<button
onClick={closePanel}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || (editing.isNew && (!editing.email?.includes("@") || !editing.password || editing.password.length < 6))}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800 disabled:opacity-50"
>
{saving ? "Saving…" : editing.isNew ? "Create User" : "Save Changes"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Password reset modal */}
{passwordModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
<h3 className="text-lg font-bold text-stone-900">Reset Password</h3>
{passwordModal.loading ? (
<div className="mt-4 flex items-center gap-3 text-sm text-stone-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
Resetting password for {passwordModal.email}...
</div>
) : passwordModal.error ? (
<div>
<p className="mt-3 text-sm text-red-600">{passwordModal.error}</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50">Close</button>
</div>
) : passwordModal.tempPassword ? (
<div>
<p className="mt-3 text-sm text-stone-600">Temporary password for <span className="font-medium text-stone-900">{passwordModal.email}</span>:</p>
<div className="mt-3 flex items-center gap-2 rounded-xl bg-stone-50 p-4 ring-1 ring-stone-200">
<span className="font-mono text-lg font-bold text-stone-900 select-all">{passwordModal.tempPassword}</span>
<button onClick={copyTempPassword} className="shrink-0 rounded-lg bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300">Copy</button>
</div>
<p className="mt-2 text-xs text-stone-500">Share this password securely with the user. They will be required to change it on next login.</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
</div>
) : null}
</div>
</div>
)}
</>
);
}
+758
View File
@@ -0,0 +1,758 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import {
createWaterHeadgate,
createWaterUser,
getWaterEntries,
} from "@/actions/water-log/admin";
import { downloadWaterLogCSV, filterWaterLogEntries, shapeWaterLogEntry, formatDailyWaterReport, isInIrrigationSeason, type WaterLogFilter, type WaterLogReportRow, type IrrigationSeasonSettings } from "@/lib/water-log-reporting";
type WaterUser = {
id: string;
name: string;
role: "irrigator" | "water_admin";
active: boolean;
language_preference: string;
last_used_at: string | null;
created_at: string;
};
type Headgate = {
id: string;
name: string;
active: boolean;
unit: string;
created_at: string;
};
type WaterEntry = {
id: string;
headgate_id: string;
user_id: string;
headgate_name: string;
user_name: string;
measurement: number;
unit: string;
notes: string | null;
submitted_via: string;
logged_at: string;
};
type WaterLogAdminPanelProps = {
initialUsers: WaterUser[];
initialHeadgates: Headgate[];
initialEntries: WaterEntry[];
brandId: string;
canManage: boolean;
};
function formatDateTime(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export default function WaterLogAdminPanel({
initialUsers,
initialHeadgates,
initialEntries,
brandId,
canManage,
}: WaterLogAdminPanelProps) {
const router = useRouter();
const [users] = useState<WaterUser[]>(initialUsers);
const [headgates, setHeadgates] = useState<Headgate[]>(initialHeadgates);
const [entries] = useState<WaterEntry[]>(initialEntries);
// ── Headgate add state ────────────────────────────
const [showAddHg, setShowAddHg] = useState(false);
const [newHgName, setNewHgName] = useState("");
const [newHgUnit, setNewHgUnit] = useState("CFS");
const [savingHg, setSavingHg] = useState(false);
// ── User add state ─────────────────────────────
const [showAddUser, setShowAddUser] = useState(false);
const [newUserName, setNewUserName] = useState("");
const [newUserRole, setNewUserRole] = useState<"irrigator" | "water_admin">("irrigator");
const [newUserLang, setNewUserLang] = useState("en");
const [savingUser, setSavingUser] = useState(false);
const [newPin, setNewPin] = useState<{ name: string; pin: string } | null>(null);
// ── CSV filter state ─────────────────────────────
const [filterDateFrom, setFilterDateFrom] = useState("");
const [filterDateTo, setFilterDateTo] = useState("");
const [filterHeadgate, setFilterHeadgate] = useState("");
const [filterUser, setFilterUser] = useState("");
const [filterVia, setFilterVia] = useState("");
const [csvLoading, setCsvLoading] = useState(false);
// ── Report / messaging settings (localStorage-persisted) ──
const [showReportSettings, setShowReportSettings] = useState(false);
const [messagingEnabled, setMessagingEnabled] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("wl_messaging_enabled") === "true";
});
const [seasonStart, setSeasonStart] = useState<IrrigationSeasonSettings>(() => {
if (typeof window === "undefined") return { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 };
const saved = localStorage.getItem("wl_season_settings");
return saved ? JSON.parse(saved) : { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 };
});
const [sendEvenIfEmpty, setSendEvenIfEmpty] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("wl_send_even_if_empty") === "true";
});
const [recipientName, setRecipientName] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : ""
);
const [recipientPhone, setRecipientPhone] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_phone") ?? "" : ""
);
// Persist to localStorage on change
function persistMessaging(val: boolean) {
setMessagingEnabled(val);
localStorage.setItem("wl_messaging_enabled", String(val));
}
function persistSendEvenIfEmpty(val: boolean) {
setSendEvenIfEmpty(val);
localStorage.setItem("wl_send_even_if_empty", String(val));
}
function persistSeason(s: IrrigationSeasonSettings) {
setSeasonStart(s);
localStorage.setItem("wl_season_settings", JSON.stringify(s));
}
function persistRecipientName(v: string) {
setRecipientName(v);
localStorage.setItem("wl_recipient_name", v);
}
function persistRecipientPhone(v: string) {
setRecipientPhone(v);
localStorage.setItem("wl_recipient_phone", v);
}
// ── Preview report ─────────────────────────────
async function handlePreviewReport() {
const allEntries = await getWaterEntries(brandId, 500);
const today = new Date().toISOString().split("T")[0];
const todayRows: WaterLogReportRow[] = allEntries
.filter((e) => e.logged_at.startsWith(today))
.map(shapeWaterLogEntry);
const inSeason = isInIrrigationSeason(new Date(), seasonStart);
const report = formatDailyWaterReport(todayRows, {
recipientName: recipientName || undefined,
sendEvenIfEmpty,
previewMode: true,
});
if (!report) {
alert(`[Preview] No entries today — report would be skipped (outside season: ${inSeason ? "in" : "out"}).`);
} else {
alert(`[Daily Report Preview — ${inSeason ? "in season" : "out of season"}]\n\n${report}`);
}
}
// ── Headgate handlers ────────────────────────────
async function handleAddHg(e: React.FormEvent) {
e.preventDefault();
if (!newHgName.trim()) return;
setSavingHg(true);
const result = await createWaterHeadgate(brandId, newHgName.trim(), newHgUnit);
if (result.success && result.headgate) {
setHeadgates((prev) => [...prev, result.headgate!]);
setNewHgName("");
setNewHgUnit("CFS");
setShowAddHg(false);
}
setSavingHg(false);
}
// ── User handlers ─────────────────────────────
async function handleAddUser(e: React.FormEvent) {
e.preventDefault();
if (!newUserName.trim()) return;
setSavingUser(true);
const result = await createWaterUser(brandId, newUserName.trim(), newUserRole, newUserLang);
if (result.success && result.user) {
setNewPin({ name: result.user!.name, pin: result.pin! });
setNewUserName("");
setNewUserRole("irrigator");
setNewUserLang("en");
setShowAddUser(false);
}
setSavingUser(false);
}
// ── CSV export ──────────────────────────────────
async function handleExportCSV() {
setCsvLoading(true);
try {
const allEntries = await getWaterEntries(brandId, 500);
const shaped: WaterLogReportRow[] = allEntries.map(shapeWaterLogEntry);
const filters: WaterLogFilter = {
dateFrom: filterDateFrom || undefined,
dateTo: filterDateTo || undefined,
headgateId: filterHeadgate || undefined,
userId: filterUser || undefined,
submittedVia: filterVia || undefined,
};
const filtered = filterWaterLogEntries(shaped, filters);
const date = new Date().toISOString().split("T")[0];
downloadWaterLogCSV(`water-log-${date}.csv`, filtered);
} finally {
setCsvLoading(false);
}
}
// Derived filtered entries for display
const filteredEntries = entries.filter((e) => {
if (filterDateFrom && e.logged_at < filterDateFrom) return false;
if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false;
if (filterHeadgate && e.headgate_id !== filterHeadgate) return false;
if (filterUser && e.user_id !== filterUser) return false;
if (filterVia && e.submitted_via !== filterVia) return false;
return true;
});
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-4xl flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Water Log</h1>
<p className="mt-0.5 text-sm text-zinc-500">
PIN-based access · Tuxedo only · separate from site admin
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => router.push("/admin/water-log/headgates")}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
📱 QR Codes
</button>
<button
onClick={() => router.push("/admin/water-log/settings")}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
Settings
</button>
<a
href="/water/admin"
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
Field Admin
</a>
</div>
</div>
</div>
<div className="mx-auto max-w-4xl px-6 py-6 space-y-8">
{/* ── Today's Summary + Report Settings ── */}
<section>
{/* Summary card */}
{(() => {
const today = new Date().toISOString().split("T")[0];
const todayEntries = entries.filter((e) => e.logged_at.startsWith(today));
const inSeason = isInIrrigationSeason(new Date(), seasonStart);
const totalMeasurement = todayEntries.reduce((s, e) => s + e.measurement, 0);
const lastEntry = todayEntries.length > 0
? todayEntries.reduce((a, b) => (a.logged_at > b.logged_at ? a : b))
: null;
return (
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 mb-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-bold text-zinc-100">Today&apos;s Summary</h2>
<p className="text-xs text-zinc-500 mt-0.5">
{inSeason
? <span className="text-green-600 font-medium">In irrigation season</span>
: <span className="text-orange-500 font-medium">Outside irrigation season</span>
} &nbsp;·&nbsp; {todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"}
</p>
</div>
<div className="flex gap-2">
<button
onClick={handlePreviewReport}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
Preview Report
</button>
<button
onClick={() => setShowReportSettings(!showReportSettings)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${showReportSettings ? "bg-slate-800 text-white" : "border border-zinc-600 bg-zinc-900 text-zinc-300 hover:bg-zinc-800"}`}
>
Report Settings
</button>
</div>
</div>
{todayEntries.length > 0 ? (
<div className="mt-3 grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-zinc-500">Total measurement</p>
<p className="mt-0.5 text-xl font-bold text-zinc-100">{totalMeasurement.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-zinc-500">Last entry</p>
<p className="mt-0.5 text-sm font-semibold text-zinc-300">{lastEntry?.headgate_name}</p>
<p className="text-xs text-slate-400">{lastEntry ? formatDateTime(lastEntry.logged_at) : "—"}</p>
</div>
<div>
<p className="text-xs text-zinc-500">Messaging</p>
<p className={`mt-0.5 text-sm font-semibold ${messagingEnabled ? "text-green-600" : "text-slate-400"}`}>
{messagingEnabled ? "Enabled" : "Disabled"}
</p>
</div>
</div>
) : (
<p className="mt-2 text-sm text-slate-400">No entries recorded today.</p>
)}
</div>
);
})()}
{/* Report settings panel */}
{showReportSettings && (
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-semibold text-zinc-300">Report / Messaging Settings</p>
<button onClick={() => setShowReportSettings(false)} className="text-xs text-slate-400 hover:text-zinc-400">Close</button>
</div>
<div className="p-3 mb-3 rounded-lg bg-blue-900/30 border border-blue-100">
<p className="text-xs text-blue-700">
<strong>Preview only.</strong> These settings are local to this browser. Live scheduled WhatsApp/SMS delivery requires a future database settings table and messaging integration.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Messaging enabled</label>
<button
onClick={() => persistMessaging(!messagingEnabled)}
className={`w-full rounded-lg border px-3 py-2 text-sm font-medium ${messagingEnabled ? "bg-green-900/40 border-green-300 text-green-800" : "bg-zinc-900 border-zinc-600 text-zinc-500"}`}
>
{messagingEnabled ? "ON" : "OFF"}
</button>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Send even if empty</label>
<button
onClick={() => persistSendEvenIfEmpty(!sendEvenIfEmpty)}
className={`w-full rounded-lg border px-3 py-2 text-sm font-medium ${sendEvenIfEmpty ? "bg-green-900/40 border-green-300 text-green-800" : "bg-zinc-900 border-zinc-600 text-zinc-500"}`}
>
{sendEvenIfEmpty ? "ON" : "OFF"}
</button>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Season start (month/day)</label>
<div className="flex gap-1">
<select
value={seasonStart.seasonStartMonth}
onChange={(e) => persistSeason({ ...seasonStart, seasonStartMonth: parseInt(e.target.value) })}
className="flex-1 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString("en", { month: "short" })}</option>
))}
</select>
<input
type="number"
min="1"
max="31"
value={seasonStart.seasonStartDay}
onChange={(e) => persistSeason({ ...seasonStart, seasonStartDay: parseInt(e.target.value) || 1 })}
className="w-16 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Season end (month/day)</label>
<div className="flex gap-1">
<select
value={seasonStart.seasonEndMonth}
onChange={(e) => persistSeason({ ...seasonStart, seasonEndMonth: parseInt(e.target.value) })}
className="flex-1 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString("en", { month: "short" })}</option>
))}
</select>
<input
type="number"
min="1"
max="31"
value={seasonStart.seasonEndDay}
onChange={(e) => persistSeason({ ...seasonStart, seasonEndDay: parseInt(e.target.value) || 1 })}
className="w-16 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Recipient name</label>
<input
type="text"
value={recipientName}
onChange={(e) => persistRecipientName(e.target.value)}
placeholder="David"
className="w-full rounded-lg border border-zinc-600 px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Recipient phone</label>
<input
type="text"
value={recipientPhone}
onChange={(e) => persistRecipientPhone(e.target.value)}
placeholder="+1..."
className="w-full rounded-lg border border-zinc-600 px-3 py-1.5 text-sm"
/>
</div>
</div>
</div>
)}
</section>
{/* ── Headgates ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold text-zinc-100">Headgates</h2>
{!showAddHg && (
<button
onClick={() => setShowAddHg(true)}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800"
>
+ Add Headgate
</button>
)}
</div>
{showAddHg && (
<form onSubmit={handleAddHg} className="mb-4 flex gap-2">
<input
type="text"
value={newHgName}
onChange={(e) => setNewHgName(e.target.value)}
placeholder="Headgate name"
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
autoFocus
/>
<select
value={newHgUnit}
onChange={(e) => setNewHgUnit(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="CFS">CFS</option>
<option value="GPM">GPM</option>
<option value="gal">gal</option>
<option value="ac-in">ac-in</option>
<option value="ac-ft">ac-ft</option>
</select>
<button type="submit" disabled={savingHg || !newHgName.trim()} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{savingHg ? "..." : "Add"}
</button>
<button type="button" onClick={() => { setShowAddHg(false); setNewHgName(""); }} className="rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300">
Cancel
</button>
</form>
)}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
{headgates.length === 0 ? (
<p className="p-6 text-center text-sm text-zinc-500">No headgates yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50 text-left">
<th className="px-4 py-3 font-semibold text-zinc-400">Name</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Unit</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Status</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Created</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{headgates.map((hg) => (
<tr key={hg.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">{hg.name}</td>
<td className="px-4 py-3 text-zinc-300">{hg.unit}</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${hg.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"}`}>
{hg.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-slate-400 text-xs">{formatDateTime(hg.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => router.push(`/admin/water-log/headgates/${hg.id}`)}
className="text-xs text-zinc-500 hover:text-zinc-100"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* ── Water Users ── */}
<section>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-bold text-zinc-100">Water Users</h2>
<p className="text-xs text-zinc-500 mt-0.5">
<span className="font-medium text-blue-400">Admin</span> = manage headgates, users, entries &nbsp;|&nbsp;
<span className="font-medium text-green-600">Irrigator</span> = submit entries only
</p>
</div>
{!showAddUser && (
<button
onClick={() => setShowAddUser(true)}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800"
>
+ Add User
</button>
)}
</div>
{showAddUser && (
<form onSubmit={handleAddUser} className="mb-4 rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-4 space-y-3">
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="block text-xs font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Full name"
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
<select
value={newUserRole}
onChange={(e) => setNewUserRole(e.target.value as "irrigator" | "water_admin")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="irrigator">Irrigator</option>
<option value="water_admin">Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Language</label>
<select
value={newUserLang}
onChange={(e) => setNewUserLang(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={savingUser || !newUserName.trim()} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{savingUser ? "..." : "Add User"}
</button>
<button type="button" onClick={() => { setShowAddUser(false); setNewUserName(""); setNewUserRole("irrigator"); setNewUserLang("en"); }}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300">
Cancel
</button>
</div>
</form>
)}
{/* PIN display banner */}
{newPin && (
<div className="mb-4 rounded-xl bg-amber-900/30 border border-yellow-200 p-4">
<p className="font-semibold text-yellow-800">New PIN for {newPin.name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin.pin}</p>
<p className="mt-1 text-xs text-yellow-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
</div>
)}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
{users.length === 0 ? (
<p className="p-6 text-center text-sm text-zinc-500">No water users yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50 text-left">
<th className="px-4 py-3 font-semibold text-zinc-400">Name</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Role</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Status</th>
<th className="px-4 py-3 font-semibold text-zinc-400 hidden md:table-cell">Language</th>
<th className="px-4 py-3 font-semibold text-zinc-400 hidden lg:table-cell">Last Used</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">{user.name}</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
user.role === "water_admin" ? "bg-blue-900/40 text-blue-700" : "bg-green-900/40 text-green-400"
}`}>
{user.role === "water_admin" ? "Admin" : "Irrigator"}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${user.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"}`}>
{user.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 hidden md:table-cell text-zinc-500 text-xs">
{user.language_preference === "es" ? "Español" : "English"}
</td>
<td className="px-4 py-3 hidden lg:table-cell text-slate-400 text-xs">{formatDateTime(user.last_used_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => router.push(`/admin/water-log/users/${user.id}`)}
className="text-xs text-zinc-500 hover:text-zinc-100"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* ── Entries + CSV Export ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold text-zinc-100">Recent Entries</h2>
<button
onClick={handleExportCSV}
disabled={csvLoading}
className="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{csvLoading ? "..." : "Export CSV"}
</button>
</div>
{/* Filters */}
<div className="mb-3 flex flex-wrap gap-2">
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
placeholder="From"
/>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
placeholder="To"
/>
<select
value={filterHeadgate}
onChange={(e) => setFilterHeadgate(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
<option value="">All headgates</option>
{headgates.map((hg) => (
<option key={hg.id} value={hg.id}>{hg.name}</option>
))}
</select>
<select
value={filterUser}
onChange={(e) => setFilterUser(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
<option value="">All users</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<select
value={filterVia}
onChange={(e) => setFilterVia(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
<option value="">All via</option>
<option value="field">Field</option>
<option value="admin">Admin</option>
</select>
{(filterDateFrom || filterDateTo || filterHeadgate || filterUser || filterVia) && (
<button
onClick={() => { setFilterDateFrom(""); setFilterDateTo(""); setFilterHeadgate(""); setFilterUser(""); setFilterVia(""); }}
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
>
Clear filters
</button>
)}
</div>
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
{filteredEntries.length === 0 ? (
<p className="p-6 text-center text-sm text-zinc-500">No entries{entries.length > 0 ? " match filters" : ""}</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50 text-left">
<th className="px-4 py-3 font-semibold text-zinc-400">When</th>
<th className="px-4 py-3 font-semibold text-zinc-400">User</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Headgate</th>
<th className="px-4 py-3 font-semibold text-zinc-400 text-right">Measurement</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Via</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((e) => (
<tr key={e.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
<td className="px-4 py-3 text-zinc-500 text-xs">{formatDateTime(e.logged_at)}</td>
<td className="px-4 py-3 font-medium text-zinc-100">{e.user_name}</td>
<td className="px-4 py-3 text-zinc-300">{e.headgate_name}</td>
<td className="px-4 py-3 text-right font-semibold text-zinc-100">{e.measurement} {e.unit}</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${e.submitted_via === "field" ? "bg-blue-900/40 text-blue-700" : "bg-zinc-950 text-zinc-400"}`}>
{e.submitted_via}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => router.push(`/admin/water-log/entries/${e.id}`)}
className="text-xs text-zinc-500 hover:text-zinc-100"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</div>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterEntry, deleteWaterEntry } from "@/actions/water-log/admin";
type WaterEntry = {
id: string;
headgate_id: string;
user_id: string;
headgate_name: string;
user_name: string;
measurement: number;
unit: string;
notes: string | null;
submitted_via: string;
logged_at: string;
headgate_unit?: string;
};
const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
type Props = {
entry: WaterEntry;
brandId: string;
backHref?: string;
};
export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [measurement, setMeasurement] = useState(entry.measurement);
const [unit, setUnit] = useState(entry.unit);
const [notes, setNotes] = useState(entry.notes ?? "");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await updateWaterEntry(entry.id, measurement, notes || null, unit);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to save");
setSaving(false);
}
}
async function handleDelete() {
if (!window.confirm(`Delete this entry from ${entry.user_name} at ${entry.headgate_name}? This cannot be undone.`)) return;
setDeleting(true);
const result = await deleteWaterEntry(entry.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Headgate</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.headgate_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">User</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.user_name}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Measurement</label>
<input
type="number"
step="any"
value={measurement}
onChange={(e) => setMeasurement(parseFloat(e.target.value) || 0)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Submitted Via</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.submitted_via}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Logged</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">
{new Date(entry.logged_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900 resize-none"
placeholder="Optional notes..."
/>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{deleting ? "..." : "Delete Entry"}
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "..." : "Save Changes"}
</button>
</div>
</form>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
type WaterUser = {
id: string;
name: string;
role: "irrigator" | "water_admin";
active: boolean;
language_preference: string;
last_used_at: string | null;
created_at: string;
};
type Props = {
waterUser: WaterUser;
backHref?: string;
};
export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [name, setName] = useState(waterUser.name);
const [role, setRole] = useState<"irrigator" | "water_admin">(waterUser.role);
const [active, setActive] = useState(waterUser.active);
const [lang, setLang] = useState(waterUser.language_preference);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [resetting, setResetting] = useState(false);
const [newPin, setNewPin] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await updateWaterIrrigator(waterUser.id, name.trim(), active, lang, role);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to save");
setSaving(false);
}
}
async function handleResetPin() {
setResetting(true);
const result = await resetWaterIrrigatorPin(waterUser.id);
if (result.success && result.pin) {
setNewPin(result.pin);
} else {
setError(result.error ?? "Failed to reset PIN");
}
setResetting(false);
}
async function handleDelete() {
if (!window.confirm(`Delete user "${waterUser.name}"? Their log entries will be preserved.`)) return;
setDeleting(true);
const result = await deleteWaterUser(waterUser.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
{error}
</div>
)}
{newPin && (
<div className="rounded-xl bg-yellow-50 border border-yellow-200 p-4">
<p className="font-semibold text-yellow-800">New PIN for {name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin}</p>
<p className="mt-1 text-xs text-yellow-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="irrigator">Irrigator</option>
<option value="water_admin">Admin</option>
</select>
<p className="text-xs text-slate-400 mt-0.5">
{role === "water_admin"
? "Can manage headgates, users, and entries"
: "Can only submit water log entries"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Language</label>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
<select
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div className="pt-4">
<button
type="button"
onClick={handleResetPin}
disabled={resetting}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 disabled:opacity-50"
>
{resetting ? "..." : "Reset PIN"}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{deleting ? "..." : "Delete User"}
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "..." : "Save Changes"}
</button>
</div>
</form>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState, useEffect } from "react";
import { getRecentWebhookActivity } from "@/actions/wholesale";
type WebhookLogEntry = {
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
};
type Props = {
brandId: string;
};
const EVENT_LABELS: Record<string, string> = {
order_created: "Order Created",
order_fulfilled: "Order Fulfilled",
deposit_recorded: "Deposit Recorded",
order_paid: "Order Paid",
};
const STATUS_STYLES: Record<string, string> = {
sent: "bg-green-900/40 text-green-400",
success: "bg-green-900/40 text-green-400",
failed: "bg-red-900/40 text-red-400",
pending: "bg-yellow-100 text-yellow-700",
retrying: "bg-yellow-100 text-yellow-700",
};
export default function WebhookLogsSection({ brandId }: Props) {
const [logs, setLogs] = useState<WebhookLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
getRecentWebhookActivity(brandId, 25).then((entries) => {
setLogs(entries);
setLoading(false);
});
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-zinc-800 bg-slate-50 p-4 text-sm text-zinc-500">
Loading webhook logs
</div>
);
}
if (logs.length === 0) {
return (
<div className="rounded-xl border border-zinc-800 bg-slate-50 p-4 text-sm text-zinc-500">
No webhook events yet. Deposits, order updates, and fulfillments will appear here.
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-zinc-300">
Wholesale Webhook Events ({logs.length})
</p>
<button
onClick={() => setExpanded((v) => !v)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
{expanded ? "Collapse" : "Expand"}
</button>
</div>
<div className="space-y-1.5">
{(expanded ? logs : logs.slice(0, 5)).map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800">
{EVENT_LABELS[entry.event_type] ?? entry.event_type}
</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
STATUS_STYLES[entry.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
{entry.status}
</span>
{entry.attempts > 1 && (
<span className="text-xs text-slate-400">
{entry.attempts} attempts
</span>
)}
</div>
{entry.response && (
<p className="mt-1 truncate text-xs text-zinc-500">{entry.response}</p>
)}
<p className="mt-0.5 text-xs text-slate-400">
{new Date(entry.created_at).toLocaleString()}
</p>
</div>
</div>
))}
</div>
{!expanded && logs.length > 5 && (
<button
onClick={() => setExpanded(true)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Show {logs.length - 5} more
</button>
)}
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { getWelcomeSequence, resendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
type Props = { brandId: string };
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
active: { en: "Active", color: "bg-blue-900/40 text-blue-400 border border-blue-800" },
completed: { en: "Completed", color: "bg-emerald-900/40 text-emerald-400 border border-emerald-800" },
unsubscribed: { en: "Unsubscribed", color: "bg-red-900/40 text-red-400 border border-red-800" },
bounced: { en: "Bounced", color: "bg-amber-900/40 text-amber-400 border border-amber-800" },
};
const STEP_LABELS: Record<number, string> = {
0: "Enrolled",
1: "Welcome (1h)",
2: "How it works (24h)",
3: "First order (48h)",
4: "Final (72h)",
};
export default function WelcomeSequenceDashboard({ brandId }: Props) {
const [entries, setEntries] = useState<WelcomeSequenceEntry[]>([]);
const [stats, setStats] = useState({ total: 0, completed: 0, active: 0, unsubscribed: 0 });
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
const [page, setPage] = useState(0);
const PAGE_SIZE = 25;
const [resending, setResending] = useState<string | null>(null);
const handleResend = async (entryId: string) => {
setResending(entryId);
await resendWelcomeEmail(entryId, brandId);
await load();
setResending(null);
};
const load = useCallback(async () => {
setLoading(true);
const result = await getWelcomeSequence(brandId);
if (result.success) {
setEntries(result.entries);
setStats(result.stats);
}
setLoading(false);
}, [brandId]);
useEffect(() => { setPage(0); }, [filter]);
const filtered = entries.filter(e => {
if (filter === "active") return e.status === "active";
if (filter === "completed") return e.status === "completed";
return true;
});
const paginatedEntries = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-zinc-100" },
{ label: "Active", value: stats.active, color: "text-blue-400" },
{ label: "Completed", value: stats.completed, color: "text-emerald-400" },
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-400" },
].map(s => (
<div key={s.label} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button onClick={load} disabled={loading}
className="text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "completed"] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-violet-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-zinc-500 px-1">{page + 1}/{totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
{/* Table */}
{filtered.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl py-16 text-center">
<p className="text-zinc-600 text-sm">No entries{filter !== "all" ? ` (${filter})` : ""}</p>
</div>
) : (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-500 uppercase tracking-widest border-b border-zinc-800">
<th className="text-left px-5 py-3 font-medium">Contact</th>
<th className="text-left px-5 py-3 font-medium">Language</th>
<th className="text-left px-5 py-3 font-medium">Step</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Enrolled</th>
<th className="text-left px-5 py-3 font-medium">Last Email</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{paginatedEntries.map(e => {
const meta = STATUS_LABELS[e.status] ?? { en: e.status, color: "bg-zinc-800 text-zinc-400" };
return (
<tr key={e.id} className="border-t border-zinc-800/60 hover:bg-zinc-800/30 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-zinc-200 font-medium text-sm">{e.contact_name ?? "—"}</p>
<p className="text-zinc-500 text-xs">{e.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5">
<span className="text-xs font-mono text-zinc-400 uppercase">{e.locale}</span>
</td>
<td className="px-5 py-3.5 text-xs text-zinc-400">
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
{e.next_email_at && e.status === "active" && (
<span className="block text-zinc-600 text-[10px]">Next: {new Date(e.next_email_at).toLocaleString()}</span>
)}
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
{meta.en}
</span>
</td>
<td className="px-5 py-3.5 text-zinc-500 text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-zinc-500 text-xs">
{e.last_email_sent_at ? new Date(e.last_email_sent_at).toLocaleString() : "—"}
</td>
<td className="px-5 py-3.5 text-right">
{e.status !== "unsubscribed" && (
<button onClick={() => handleResend(e.id)} disabled={resending === e.id}
className="text-xs text-violet-400 hover:text-violet-300 px-2 py-1 rounded-lg hover:bg-violet-900/20 transition-all">
{resending === e.id ? "..." : "Resend"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
type Column<T> = {
key: keyof T | string;
header: string;
render?: (item: T) => React.ReactNode;
className?: string;
align?: "left" | "right" | "center";
};
type DataTableProps<T> = {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowClassName?: string;
paginate?: boolean;
pageSize?: number;
};
export default function DataTable<T>({
data,
columns,
keyExtractor,
emptyMessage = "No data found",
onRowClick,
rowClassName,
paginate = false,
pageSize = 50,
}: DataTableProps<T>) {
const [page, setPage] = useState(0);
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
const totalPages = Math.ceil(data.length / pageSize);
const alignClass = (align?: "left" | "right" | "center") => {
switch (align) {
case "right": return "text-right";
case "center": return "text-center";
default: return "text-left";
}
};
return (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50">
{columns.map((col) => (
<th
key={String(col.key)}
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{displayed.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
{emptyMessage}
</td>
</tr>
) : (
displayed.map((item) => (
<tr
key={keyExtractor(item)}
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
onRowClick ? "cursor-pointer" : ""
} ${rowClassName ?? ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<td
key={String(col.key)}
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{paginate && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
<span className="text-xs text-stone-500">
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
type FilterOption = {
value: string;
label: string;
};
type FilterBarProps = {
searchPlaceholder?: string;
searchValue: string;
onSearchChange: (value: string) => void;
filters?: {
label?: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}[];
tabs?: {
label: string;
value: string;
count?: number;
}[];
activeTab?: string;
onTabChange?: (value: string) => void;
actions?: React.ReactNode;
resultCount?: number;
showCount?: boolean;
};
export default function FilterBar({
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
tabs = [],
activeTab,
onTabChange,
actions,
resultCount,
showCount = true,
}: FilterBarProps) {
return (
<div className="space-y-3">
{/* Search + Filters Row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<input
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
</div>
{filters.map((filter, i) => (
<div key={i} className="space-y-1">
{filter.label && (
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
)}
<select
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
{actions && <div className="flex items-center gap-2">{actions}</div>}
{showCount && resultCount !== undefined && (
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
)}
</div>
{/* Tabs Row */}
{tabs.length > 0 && (
<div className="flex gap-1 border-b border-stone-200 pb-0">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => onTabChange?.(tab.value)}
className={`
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
${activeTab === tab.value
? "text-emerald-600 border-emerald-600"
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
}
`}
>
{tab.label}
{tab.count !== undefined && (
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import Link from "next/link";
type PageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
title: string;
description?: string;
action?: React.ReactNode;
};
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
return (
<div className="mb-8">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
{breadcrumb.map((crumb, i) => (
<span key={i} className="flex items-center gap-2">
{crumb.href ? (
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-stone-600">{crumb.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
{description && (
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
type StatusBadgeProps = {
status: string;
size?: "sm" | "md";
};
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
// Active/Enabled states
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
// Pending/Warning states
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
// Inactive/Disabled states
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
// Info states
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
// Special states
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
};
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
const config = STATUS_STYLES[status] ?? {
bg: "bg-stone-100",
text: "text-stone-600",
label: status.replace(/_/g, " "),
};
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
return (
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
{config.label}
</span>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as PageHeader } from "./PageHeader";
export { default as StatusBadge } from "./StatusBadge";
export { default as FilterBar } from "./FilterBar";
export { default as DataTable } from "./DataTable";
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { useCart } from "@/context/CartContext";
export default function CartRestoredToast() {
const { cartRestored, dismissRestoredToast } = useCart();
const [visible, setVisible] = useState(false);
useEffect(() => {
if (cartRestored) {
setVisible(true);
const timer = setTimeout(() => {
setVisible(false);
dismissRestoredToast();
}, 5000);
return () => clearTimeout(timer);
}
}, [cartRestored, dismissRestoredToast]);
if (!visible) return null;
return (
<div
className="fixed bottom-6 right-6 z-50 animate-slide-up"
onClick={() => { setVisible(false); dismissRestoredToast(); }}
>
<div className="flex items-center gap-3 rounded-2xl border border-green-200 bg-green-50 px-5 py-3.5 shadow-lg cursor-pointer hover:bg-green-100 transition-colors">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
<svg className="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-sm font-semibold text-green-800">Cart restored</p>
<p className="text-xs text-green-600">Your previous cart has been restored.</p>
</div>
</div>
</div>
);
}
+690
View File
@@ -0,0 +1,690 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ReactElement } from "react";
interface Feature {
icon: ReactElement;
title: string;
description: string;
bgColor: string;
borderColor: string;
hoverBg: string;
}
interface Stat {
prefix: string;
number: number;
suffix: string;
label: string;
}
const features: Feature[] = [
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
title: "Product Catalogs",
description:
"Showcase your seasonal produce with rich media galleries, detailed specs, and real-time availability updates.",
bgColor: "#f0f5f1",
borderColor: "#6b8f71",
hoverBg: "#e8f0e9",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
),
title: "Route Planning",
description:
"Optimize delivery routes across your farm network with intelligent mapping and scheduling tools.",
bgColor: "#fdf6f0",
borderColor: "#c97a3e",
hoverBg: "#fbeeda",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
),
title: "Order Management",
description:
"Track orders from placement through delivery with automated status updates and seamless integrations.",
bgColor: "#f5f0f8",
borderColor: "#7a5c9e",
hoverBg: "#ece6f5",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3h18v18H3zM21 9H3M9 21V9" />
</svg>
),
title: "Wholesale Portal",
description:
"Give buyers a dedicated space to browse pricing, request quotes, and place bulk orders on their schedule.",
bgColor: "#f0f5f5",
borderColor: "#5c8a8f",
hoverBg: "#e5f0f2",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
),
title: "Harvest Reach",
description:
"Send email and SMS campaigns that keep buyers informed about seasonal availability and new harvests.",
bgColor: "#faf5f0",
borderColor: "#c97a3e",
hoverBg: "#f5ebe0",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
title: "Smart Analytics",
description:
"Uncover sales trends, buyer behavior, and operational insights with real-time dashboards and reports.",
bgColor: "#f5f8f0",
borderColor: "#6b8f71",
hoverBg: "#eaf3e5",
},
];
const stats: Stat[] = [
{ prefix: "", number: 500, suffix: "+", label: "Produce Brands" },
{ prefix: "", number: 50000, suffix: "+", label: "Orders Delivered" },
{ prefix: "", number: 98, suffix: "%", label: "On-Time Delivery" },
{ prefix: "$", number: 2, suffix: "M+", label: "Weekly Sales" },
];
export default function FeaturesAndStats() {
const [countersStarted, setCountersStarted] = useState(false);
const [counters, setCounters] = useState(stats.map(() => 0));
const statsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !countersStarted) {
setCountersStarted(true);
}
},
{ threshold: 0.3 }
);
if (statsRef.current) {
observer.observe(statsRef.current);
}
return () => observer.disconnect();
}, [countersStarted]);
useEffect(() => {
if (!countersStarted) return;
const duration = 2000;
const steps = 60;
const interval = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = step / steps;
const easeOut = 1 - Math.pow(1 - progress, 3);
setCounters(
stats.map((stat) => {
if (stat.number >= 1000) {
return Math.floor(stat.number * easeOut);
} else if (stat.number >= 100) {
return Math.floor(stat.number * easeOut);
} else if (stat.number >= 10) {
return Math.floor(stat.number * easeOut);
} else {
return Math.floor(stat.number * easeOut);
}
})
);
if (step >= steps) {
clearInterval(timer);
setCounters(stats.map((stat) => stat.number));
}
}, interval);
return () => clearInterval(timer);
}, [countersStarted]);
return (
<div
style={{
fontFamily: "'Plus Jakarta Sans', sans-serif",
background: "#faf8f5",
color: "#1a1a1a",
minHeight: "100vh",
}}
>
<style jsx>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
:global(body) {
margin: 0;
padding: 0;
}
.section-label {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #c97a3e;
margin-bottom: 0.75rem;
}
.section-title {
font-family: "Cormorant Garamond", serif;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 600;
color: #1a4d2e;
line-height: 1.15;
margin-bottom: 1rem;
}
.section-subtitle {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 1.125rem;
color: #4a4a4a;
line-height: 1.6;
max-width: 540px;
}
.feature-card {
border-radius: 16px;
padding: 2rem;
border: 1px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: default;
opacity: 0;
transform: translateY(24px);
animation: fadeInUp 0.6s ease forwards;
}
.feature-card:hover {
transform: translateY(-6px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
}
.feature-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
transition: transform 0.3s ease;
}
.feature-card:hover .feature-icon {
transform: scale(1.05);
}
.feature-title {
font-family: "Cormorant Garamond", serif;
font-size: 1.375rem;
font-weight: 600;
color: #1a4d2e;
margin-bottom: 0.625rem;
line-height: 1.3;
}
.feature-description {
font-size: 0.9375rem;
color: #555;
line-height: 1.65;
}
.stat-block {
text-align: center;
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.5s ease forwards;
}
.stat-number {
font-family: "Cormorant Garamond", serif;
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
color: #1a4d2e;
line-height: 1;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9375rem;
color: #6b8f71;
font-weight: 500;
letter-spacing: 0.02em;
}
.stats-divider {
width: 1px;
background: linear-gradient(
to bottom,
transparent,
#c97a3e40,
transparent
);
align-self: stretch;
margin: 0.1rem 0;
}
.section-divider {
width: 48px;
height: 2px;
background: linear-gradient(
to right,
#c97a3e,
#1a4d2e
);
border-radius: 1px;
margin: 0;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-left {
animation: slideInLeft 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-right {
animation: slideInRight 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-scale {
animation: scaleIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
.stagger-6 { animation-delay: 0.6s; }
.stat-stagger-1 { animation-delay: 0.15s; }
.stat-stagger-2 { animation-delay: 0.3s; }
.stat-stagger-3 { animation-delay: 0.45s; }
.stat-stagger-4 { animation-delay: 0.6s; }
`}</style>
{/* ─── FEATURES SECTION ─── */}
<section
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "6rem 1.5rem",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
marginBottom: "4rem",
}}
>
<div
className="animate-scale"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<span className="section-label" style={{ marginBottom: "0.75rem" }}>
Platform Features
</span>
<div className="section-divider" style={{ marginBottom: "1.25rem" }} />
<h2 className="section-title" style={{ marginBottom: "1rem" }}>
Everything You Need
</h2>
<p
className="section-subtitle"
style={{
maxWidth: "520px",
marginBottom: "0",
color: "#5a5a5a",
fontSize: "1.0625rem",
}}
>
From farm to table, manage your entire produce supply chain in one
place.
</p>
</div>
</div>
{/* Feature Cards Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: "1.25rem",
}}
>
{features.map((feature, index) => (
<div
key={index}
className={`feature-card stagger-${index + 1}`}
style={{
backgroundColor: feature.bgColor,
borderColor: `rgba(${index % 2 === 0 ? "107,143,113" : "201,122,62"}, 0.15)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = feature.hoverBg;
e.currentTarget.style.borderColor = feature.borderColor;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = feature.bgColor;
e.currentTarget.style.borderColor = `rgba(${
index % 2 === 0 ? "107,143,113" : "201,122,62"
}, 0.15)`;
}}
>
<div
className="feature-icon"
style={{
background: `linear-gradient(135deg, ${feature.borderColor}18, ${feature.borderColor}10)`,
color: feature.borderColor,
border: `1px solid ${feature.borderColor}20`,
}}
>
{feature.icon}
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-description">{feature.description}</p>
</div>
))}
</div>
</section>
{/* ─── STATS SECTION ─── */}
<section
ref={statsRef}
style={{
background: "#1a4d2e",
padding: "5rem 1.5rem",
position: "relative",
overflow: "hidden",
}}
>
{/* Subtle background pattern */}
<div
style={{
position: "absolute",
inset: 0,
opacity: 0.04,
backgroundImage:
"radial-gradient(circle at 25% 25%, #faf8f5 1px, transparent 1px), radial-gradient(circle at 75% 75%, #faf8f5 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
{/* Decorative top line */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "3px",
background:
"linear-gradient(to right, #c97a3e, #faf8f5, #c97a3e)",
}}
/>
<div
style={{
maxWidth: "1000px",
margin: "0 auto",
position: "relative",
zIndex: 1,
}}
>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: "3.5rem" }}>
<span
style={{
display: "block",
fontFamily: "'Plus Jakarta Sans', sans-serif",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "#c97a3e",
marginBottom: "0.75rem",
}}
>
Our Reach
</span>
<h2
style={{
fontFamily: "'Cormorant Garamond', serif",
fontSize: "clamp(2rem, 4vw, 2.75rem)",
fontWeight: 600,
color: "#faf8f5",
lineHeight: 1.2,
margin: "0 0 0.75rem",
}}
>
Growing Together
</h2>
<div
style={{
width: "48px",
height: "2px",
background: "linear-gradient(to right, transparent, #c97a3e, transparent)",
borderRadius: "1px",
margin: "0 auto",
}}
/>
</div>
{/* Stats Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
alignItems: "center",
}}
>
{stats.map((stat, index) => (
<div
key={index}
className={`stat-block stat-stagger-${index + 1}`}
>
<div
style={{
fontFamily: "'Cormorant Garamond', serif",
fontSize: "clamp(2.25rem, 4.5vw, 3.75rem)",
fontWeight: 700,
color: "#faf8f5",
lineHeight: 1,
marginBottom: "0.5rem",
display: "flex",
alignItems: "baseline",
justifyContent: "center",
gap: "0.1em",
}}
>
{stat.prefix && (
<span
style={{
fontSize: "0.55em",
fontWeight: 500,
color: "#c97a3e",
marginRight: "0.05em",
}}
>
{stat.prefix}
</span>
)}
<span>
{counters[index].toLocaleString()}
</span>
<span
style={{
fontSize: "0.55em",
fontWeight: 500,
color: "#c97a3e",
marginLeft: "0.05em",
}}
>
{stat.suffix}
</span>
</div>
<p
style={{
fontFamily: "'Plus Jakarta Sans', sans-serif",
fontSize: "0.875rem",
fontWeight: 500,
color: "#6b8f71",
letterSpacing: "0.02em",
margin: 0,
}}
>
{stat.label}
</p>
{/* Divider between stats */}
{index < stats.length - 1 && (
<div
style={{
display: "none",
}}
className="stats-divider"
/>
)}
</div>
))}
</div>
</div>
</section>
</div>
);
}
+773
View File
@@ -0,0 +1,773 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
export default function HeroSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<>
<style jsx>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&display=swap");
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(201, 122, 62, 0.4);
}
50% {
box-shadow: 0 0 20px 5px rgba(201, 122, 62, 0.2);
}
}
@keyframes draw-path {
from {
stroke-dashoffset: 1000;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes bounce-down {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0) translateX(-50%);
}
40% {
transform: translateY(-8px) translateX(-50%);
}
60% {
transform: translateY(-4px) translateX(-50%);
}
}
@keyframes badge-enter {
0% {
opacity: 0;
transform: scale(0.8) translateY(10px);
}
50% {
transform: scale(1.05) translateY(-5px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out forwards;
opacity: 0;
}
.animate-fade-in-left {
animation: fadeInLeft 0.9s ease-out forwards;
opacity: 0;
}
.animate-fade-in-right {
animation: fadeInRight 0.9s ease-out forwards;
opacity: 0;
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-delayed {
animation: float 6s ease-in-out infinite;
animation-delay: -2s;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-bounce-down {
animation: bounce-down 2s ease-in-out infinite;
}
.animate-badge {
animation: badge-enter 0.6s ease-out forwards;
opacity: 0;
}
.path-animate {
stroke-dasharray: 1000;
animation: draw-path 2.5s ease-out forwards;
}
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.delay-400 { animation-delay: 400ms; }
.delay-500 { animation-delay: 500ms; }
.delay-600 { animation-delay: 600ms; }
.delay-700 { animation-delay: 700ms; }
.delay-800 { animation-delay: 800ms; }
.font-display {
font-family: "Cormorant Garamond", Georgia, serif;
}
.text-gradient {
background: linear-gradient(
135deg,
#1a4d2e 0%,
#6b8f71 25%,
#c97a3e 50%,
#1a4d2e 75%,
#1a4d2e 100%
);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient-shift 8s ease-in-out infinite;
}
@keyframes gradient-shift {
0%,
100% {
background-position: 0% center;
}
50% {
background-position: 100% center;
}
}
.btn-primary {
background: linear-gradient(135deg, #1a4d2e 0%, #2d6b42 100%);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s ease;
}
.btn-primary:hover::before {
left: 100%;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(26, 77, 46, 0.3);
}
.btn-secondary {
border: 2px solid #1a4d2e;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: #1a4d2e;
color: #faf8f5;
transform: translateY(-2px);
}
.trust-card {
backdrop-filter: blur(10px);
background: rgba(250, 248, 245, 0.9);
transition: all 0.3s ease;
}
.trust-card:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 15px 40px rgba(26, 77, 46, 0.15);
}
`}</style>
<section
className="min-h-screen relative overflow-hidden flex items-center"
style={{ backgroundColor: "#faf8f5" }}
>
{/* Background Pattern - Subtle Grid */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `
linear-gradient(#1a4d2e 1px, transparent 1px),
linear-gradient(90deg, #1a4d2e 1px, transparent 1px)
`,
backgroundSize: "60px 60px",
}}
/>
{/* Decorative Elements */}
<div
className="absolute top-20 left-10 w-32 h-32 rounded-full opacity-10"
style={{ backgroundColor: "#6b8f71" }}
/>
<div
className="absolute bottom-40 right-20 w-48 h-48 rounded-full opacity-5"
style={{ backgroundColor: "#c97a3e" }}
/>
<div className="container mx-auto px-6 lg:px-12 py-20 relative z-10">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
{/* Left Column - Content */}
<div className="space-y-8">
{/* Animated Badge */}
<div
className={`inline-flex items-center gap-3 px-5 py-2.5 rounded-full border animate-badge ${
mounted ? "delay-100" : ""
}`}
style={{
borderColor: "#c97a3e",
backgroundColor: "rgba(201, 122, 62, 0.08)",
}}
>
<span
className="w-2 h-2 rounded-full animate-pulse"
style={{ backgroundColor: "#c97a3e" }}
/>
<span
className="text-sm font-medium tracking-wide"
style={{ color: "#c97a3e" }}
>
Farm-Fresh Delivery Platform
</span>
</div>
{/* Main Headline */}
<div className={mounted ? "animate-fade-in-up delay-200" : ""}>
<h1
className="font-display text-5xl sm:text-6xl lg:text-7xl xl:text-8xl font-bold leading-[0.95] tracking-tight"
style={{ color: "#1a1a1a" }}
>
<span className="text-gradient">Harvest</span>
<br />
<span
className="text-gradient"
style={{ animationDelay: "0.5s" }}
>
Fresh Routes
</span>
</h1>
</div>
{/* Subtext */}
<p
className={`text-lg sm:text-xl max-w-xl leading-relaxed ${
mounted ? "animate-fade-in-up" : ""
}`}
style={{
color: "#6b8f71",
animationDelay: mounted ? "400ms" : "0ms",
}}
>
We connect produce farms with reliable trucking partners,
ensuring your harvest reaches markets faster, fresher, and more
efficiently than ever before.
</p>
{/* CTAs */}
<div
className={`flex flex-wrap gap-4 pt-4 ${mounted ? "animate-fade-in-up" : ""}`}
style={{ animationDelay: mounted ? "500ms" : "0ms" }}
>
<Link
href="/login"
className="btn-primary inline-flex items-center gap-2 px-8 py-4 rounded-xl font-semibold text-white text-lg shadow-lg"
style={{
background: "linear-gradient(135deg, rgba(26, 77, 46, 0.95) 0%, rgba(45, 107, 79, 0.95) 100%)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
Start Your Route
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
<Link
href="/brands"
className="btn-secondary inline-flex items-center gap-2 px-8 py-4 rounded-xl font-semibold text-lg"
style={{
color: "#1a4d2e",
background: "rgba(250, 248, 245, 0.8)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(26, 77, 46, 0.3)",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.08)",
}}
>
Browse Farms
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</Link>
</div>
{/* Trust Indicators */}
<div
className={`flex flex-wrap gap-6 pt-6 ${
mounted ? "animate-fade-in-up" : ""
}`}
style={{ animationDelay: mounted ? "600ms" : "0ms" }}
>
{[
{ stat: "500+", label: "Farm Brands" },
{ stat: "98%", label: "On-Time" },
{ stat: "50K+", label: "Deliveries" },
].map((item, index) => (
<div
key={index}
className="trust-card rounded-2xl p-4 border"
style={{ borderColor: "rgba(107, 143, 113, 0.2)" }}
>
<div
className="font-display text-2xl sm:text-3xl font-bold"
style={{ color: "#1a4d2e" }}
>
{item.stat}
</div>
<div
className="text-sm font-medium"
style={{ color: "#6b8f71" }}
>
{item.label}
</div>
</div>
))}
</div>
</div>
{/* Right Column - Visual */}
<div
className={`relative lg:h-[600px] ${
mounted ? "animate-fade-in-right" : ""
}`}
style={{ animationDelay: mounted ? "300ms" : "0ms" }}
>
{/* Main Visual Container */}
<div className="relative h-full w-full">
{/* SVG Map/Routes Visual */}
<svg
viewBox="0 0 500 500"
className="w-full h-full"
style={{ maxHeight: "600px" }}
>
{/* Background Map */}
<rect
x="50"
y="50"
width="400"
height="400"
rx="20"
fill="rgba(107, 143, 113, 0.08)"
stroke="rgba(107, 143, 113, 0.3)"
strokeWidth="1"
/>
{/* Grid Lines */}
<g stroke="rgba(107, 143, 113, 0.15)" strokeWidth="0.5">
<line x1="50" y1="150" x2="450" y2="150" />
<line x1="50" y1="250" x2="450" y2="250" />
<line x1="50" y1="350" x2="450" y2="350" />
<line x1="150" y1="50" x2="150" y2="450" />
<line x1="250" y1="50" x2="250" y2="450" />
<line x1="350" y1="50" x2="350" y2="450" />
</g>
{/* Route Path */}
<path
d="M 100 380 Q 150 350, 180 280 T 250 200 Q 290 150, 350 120 T 400 100"
fill="none"
stroke="#c97a3e"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="1000"
className="path-animate"
style={{ animationDelay: "800ms" }}
/>
{/* Secondary Route */}
<path
d="M 100 380 Q 200 420, 300 380 T 420 300"
fill="none"
stroke="#6b8f71"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="800"
className="path-animate"
style={{ animationDelay: "1200ms" }}
opacity="0.6"
/>
{/* Farm Location */}
<g className="animate-fade-in-right" style={{ animationDelay: "1000ms" }}>
<circle
cx="100"
cy="380"
r="25"
fill="#1a4d2e"
opacity="0.2"
/>
<circle
cx="100"
cy="380"
r="18"
fill="#1a4d2e"
opacity="0.4"
/>
<circle cx="100" cy="380" r="10" fill="#1a4d2e" />
{/* Farm Icon */}
<path
d="M 100 372 L 100 388 M 95 377 L 100 372 L 105 377"
stroke="#faf8f5"
strokeWidth="2"
strokeLinecap="round"
/>
<text
x="100"
y="420"
textAnchor="middle"
fill="#1a4d2e"
fontSize="12"
fontWeight="600"
>
Farm Origin
</text>
</g>
{/* Distribution Hub */}
<g className="animate-fade-in-up" style={{ animationDelay: "1100ms" }}>
<circle
cx="250"
cy="200"
r="30"
fill="#c97a3e"
opacity="0.2"
/>
<circle
cx="250"
cy="200"
r="22"
fill="#c97a3e"
opacity="0.4"
/>
<circle cx="250" cy="200" r="14" fill="#c97a3e" />
{/* Hub Icon */}
<rect
x="243"
y="195"
width="14"
height="10"
fill="#faf8f5"
rx="2"
/>
<text
x="250"
y="250"
textAnchor="middle"
fill="#c97a3e"
fontSize="12"
fontWeight="600"
>
Distribution Hub
</text>
</g>
{/* Stop Points */}
{[
{ x: 180, y: 280, label: "Stop 1" },
{ x: 350, y: 120, label: "Stop 2" },
{ x: 400, y: 100, label: "Market" },
].map((stop, i) => (
<g
key={i}
className="animate-fade-in-up"
style={{ animationDelay: `${1300 + i * 200}ms` }}
>
<circle
cx={stop.x}
cy={stop.y}
r="12"
fill="#faf8f5"
stroke="#6b8f71"
strokeWidth="2"
/>
<circle
cx={stop.x}
cy={stop.y}
r="6"
fill="#6b8f71"
/>
<text
x={stop.x}
y={stop.y + 28}
textAnchor="middle"
fill="#6b8f71"
fontSize="10"
fontWeight="500"
>
{stop.label}
</text>
</g>
))}
{/* Decorative Trees/Fields */}
{([[80, 400], [420, 350], [70, 150], [430, 200], [150, 430], [380, 400]] as [number, number][]).map((coords, i) => (
<circle
key={i}
cx={coords[0]}
cy={coords[1]}
r="8"
fill="rgba(107, 143, 113, 0.4)"
/>
))}
{/* Compass Rose */}
<g transform="translate(420, 420)">
<circle
cx="0"
cy="0"
r="25"
fill="rgba(26, 77, 46, 0.1)"
stroke="#1a4d2e"
strokeWidth="1"
/>
<path
d="M 0 -18 L 4 0 L 0 4 L -4 0 Z"
fill="#c97a3e"
/>
<path
d="M 0 18 L 4 0 L 0 -4 L -4 0 Z"
fill="#6b8f71"
/>
<text
x="0"
y="-22"
textAnchor="middle"
fill="#1a4d2e"
fontSize="10"
fontWeight="600"
>
N
</text>
</g>
</svg>
{/* Floating Stat Cards */}
<div
className={`absolute top-8 -left-4 lg:left-8 animate-float ${
mounted ? "" : "opacity-0"
}`}
style={{ animationDelay: mounted ? "800ms" : "0ms" }}
>
<div
className="animate-pulse-glow rounded-2xl p-4 shadow-xl border"
style={{
backgroundColor: "#faf8f5",
borderColor: "rgba(201, 122, 62, 0.3)",
}}
>
<div
className="text-2xl font-bold"
style={{ color: "#c97a3e" }}
>
2.4T
</div>
<div className="text-sm" style={{ color: "#6b8f71" }}>
Miles Saved
</div>
</div>
</div>
<div
className={`absolute bottom-16 -right-4 lg:right-12 animate-float-delayed ${
mounted ? "" : "opacity-0"
}`}
style={{ animationDelay: mounted ? "1000ms" : "0ms" }}
>
<div
className="rounded-2xl p-4 shadow-xl border"
style={{
backgroundColor: "#faf8f5",
borderColor: "rgba(26, 77, 46, 0.3)",
}}
>
<div
className="text-2xl font-bold"
style={{ color: "#1a4d2e" }}
>
12hrs
</div>
<div className="text-sm" style={{ color: "#6b8f71" }}>
Avg Delivery
</div>
</div>
</div>
<div
className={`absolute top-1/2 -right-8 lg:-right-16 animate-float ${
mounted ? "" : "opacity-0"
}`}
style={{ animationDelay: mounted ? "1200ms" : "0ms" }}
>
<div
className="rounded-2xl p-3 shadow-lg border"
style={{
backgroundColor: "rgba(107, 143, 113, 0.15)",
borderColor: "rgba(107, 143, 113, 0.4)",
}}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: "#1a4d2e" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "#1a4d2e" }}
>
Live Tracking
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Scroll Indicator */}
<div
className={`absolute bottom-8 left-1/2 transform -translate-x-1/2 cursor-pointer ${
mounted ? "animate-bounce-down" : "opacity-0"
}`}
style={{ animationDelay: mounted ? "1500ms" : "0ms" }}
onClick={() =>
window.scrollTo({
top: window.innerHeight,
behavior: "smooth",
})
}
>
<div
className="flex flex-col items-center gap-2 px-4 py-2 rounded-full"
style={{
backgroundColor: "rgba(26, 77, 46, 0.1)",
border: "1px solid rgba(26, 77, 46, 0.2)",
}}
>
<span
className="text-xs font-medium tracking-wider uppercase"
style={{ color: "#1a4d2e" }}
>
Scroll
</span>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#1a4d2e"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
</div>
</div>
{/* Decorative Leaf Elements */}
<svg
className="absolute bottom-20 left-10 opacity-20"
width="60"
height="60"
viewBox="0 0 60 60"
fill="none"
>
<path
d="M30 5 C45 15, 50 35, 30 55 C10 35, 15 15, 30 5"
fill="#6b8f71"
/>
<path d="M30 5 L30 55" stroke="#1a4d2e" strokeWidth="2" />
</svg>
<svg
className="absolute top-32 right-20 opacity-20"
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
>
<path
d="M20 3 C30 10, 33 23, 20 37 C7 23, 10 10, 20 3"
fill="#c97a3e"
/>
<path d="M20 3 L20 37" stroke="#1a4d2e" strokeWidth="1.5" />
</svg>
</section>
</>
);
}
@@ -0,0 +1,430 @@
"use client";
import React, { useState } from "react";
// ============================================
// HEADER COMPONENT
// ============================================
interface HeaderProps {
className?: string;
}
export function Header({ className = "" }: HeaderProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navLinks = [
{ label: "Features", href: "#features" },
{ label: "Stats", href: "#stats" },
{ label: "Reviews", href: "#reviews" },
];
return (
<header
className={`sticky top-0 z-50 backdrop-blur-md bg-[#faf8f5]/90 border-b border-[#6b8f71]/20 shadow-sm ${className}`}
style={{ scrollBehavior: "smooth" }}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 md:h-20">
{/* Logo */}
<a
href="#"
className="flex items-center gap-3 group"
style={{ textDecoration: "none" }}
>
<div
className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105"
style={{ backgroundColor: "#1a4d2e" }}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z"
fill="#faf8f5"
stroke="#faf8f5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<span
className="text-xl font-semibold tracking-tight"
style={{
fontFamily: "'Cormorant Garamond', Georgia, serif",
color: "#1a1a1a",
}}
>
Route Commerce
</span>
</a>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="text-sm font-medium transition-colors hover:opacity-70"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#1a1a1a",
}}
>
{link.label}
</a>
))}
</nav>
{/* Desktop CTA */}
<div className="hidden md:flex items-center gap-4">
<a
href="/login"
className="text-sm font-medium transition-opacity hover:opacity-70"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#1a4d2e",
}}
>
Sign In
</a>
<a
href="/admin"
className="px-5 py-2.5 rounded-full text-sm font-semibold transition-all hover:opacity-90 active:scale-95"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: "rgba(26, 77, 46, 0.9)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
color: "#faf8f5",
boxShadow: "0 4px 16px rgba(26, 77, 46, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
Get Started
</a>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2 rounded-lg transition-colors"
style={{ backgroundColor: "transparent" }}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{mobileMenuOpen ? (
<path
d="M6 18L18 6M6 6l12 12"
stroke="#1a1a1a"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path
d="M4 6h16M4 12h16M4 18h16"
stroke="#1a1a1a"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div
className="md:hidden py-4 border-t"
style={{
borderColor: "#6b8f71/20",
backgroundColor: "#faf8f5",
}}
>
<nav className="flex flex-col gap-2">
{navLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-[#6b8f71]/10"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#1a1a1a",
}}
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</a>
))}
<div
className="flex flex-col gap-2 mt-2 pt-4 px-4"
style={{ borderTop: "1px solid #6b8f71/20" }}
>
<a
href="/login"
className="py-3 text-base font-medium"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#1a4d2e",
}}
>
Sign In
</a>
<a
href="/admin"
className="py-3 rounded-full text-base font-semibold text-center"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
background: "rgba(26, 77, 46, 0.9)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
color: "#faf8f5",
boxShadow: "0 4px 16px rgba(26, 77, 46, 0.2)",
}}
>
Get Started
</a>
</div>
</nav>
</div>
)}
</div>
</header>
);
}
// ============================================
// FOOTER COMPONENT
// ============================================
interface FooterProps {
className?: string;
}
export function Footer({ className = "" }: FooterProps) {
const footerLinks = [
{ label: "Privacy", href: "#" },
{ label: "Terms", href: "#" },
{ label: "Contact", href: "#" },
];
return (
<footer
className={`w-full py-8 border-t bg-[#faf8f5] ${className}`}
style={{ borderColor: "#6b8f71/20" }}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{/* Logo + Copyright */}
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "#1a4d2e" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z"
fill="#faf8f5"
stroke="#faf8f5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<span
className="text-sm"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#1a1a1a",
opacity: 0.7,
}}
>
© 2025 Route Commerce. All rights reserved.
</span>
</div>
{/* Links */}
<nav className="flex items-center gap-6">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="text-sm font-medium transition-opacity hover:opacity-70"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#6b8f71",
}}
>
{link.label}
</a>
))}
</nav>
</div>
</div>
</footer>
);
}
// ============================================
// WRAPPER COMPONENT
// ============================================
interface SectionProps {
children?: React.ReactNode;
}
interface WrapperProps {
children?: React.ReactNode;
className?: string;
}
export function LandingPageWrapper({ children, className = "" }: WrapperProps) {
return (
<>
{/* Google Fonts Import */}
<style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
`}</style>
<div
className={`min-h-screen flex flex-col ${className}`}
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
scrollBehavior: "smooth",
}}
>
<Header />
{/* Main Content with Organic Background */}
<main
className="flex-1 relative overflow-hidden"
style={{
backgroundColor: "#faf8f5",
}}
>
{/* Decorative organic shapes */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
{/* Top-right blob */}
<div
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-30"
style={{
background:
"radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)",
filter: "blur(40px)",
}}
/>
{/* Bottom-left blob */}
<div
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-20"
style={{
background:
"radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)",
filter: "blur(60px)",
}}
/>
{/* Center accent blob */}
<div
className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10"
style={{
background:
"radial-gradient(circle, #1a4d2e15 0%, transparent 70%)",
filter: "blur(30px)",
}}
/>
{/* Organic shape 1 - leaf-like */}
<svg
className="absolute top-20 right-20 w-40 h-40 opacity-10"
viewBox="0 0 100 100"
fill="none"
>
<path
d="M50 10 C70 30, 80 50, 70 80 C60 95, 40 95, 30 80 C20 50, 30 30, 50 10"
fill="#1a4d2e"
/>
</svg>
{/* Organic shape 2 - wavy line */}
<svg
className="absolute bottom-32 left-10 w-60 h-20 opacity-10"
viewBox="0 0 200 40"
fill="none"
>
<path
d="M0 20 Q25 0, 50 20 T100 20 T150 20 T200 20"
stroke="#6b8f71"
strokeWidth="2"
fill="none"
/>
</svg>
{/* Organic shape 3 - circle cluster */}
<svg
className="absolute top-1/2 right-1/4 w-32 h-32 opacity-5"
viewBox="0 0 100 100"
fill="none"
>
<circle cx="50" cy="50" r="45" fill="#c97a3e" />
<circle cx="35" cy="35" r="25" fill="#1a4d2e" />
<circle cx="65" cy="60" r="20" fill="#6b8f71" />
</svg>
</div>
{/* Content area */}
<div className="relative z-10">{children}</div>
</main>
<Footer />
</div>
</>
);
}
// ============================================
// UTILITY: Section Wrapper
// ============================================
export function Section({
children,
className = "",
id,
}: {
children?: React.ReactNode;
className?: string;
id?: string;
}) {
return (
<section id={id} className={`relative z-10 ${className}`}>
{children}
</section>
);
}
// ============================================
// DEFAULT EXPORT: Complete Landing Page
// ============================================
export default function LandingPage({
children,
}: {
children?: React.ReactNode;
}) {
return (
<LandingPageWrapper>{children}</LandingPageWrapper>
);
}
@@ -0,0 +1,672 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
const testimonials = [
{
initials: "TC",
name: "Tuxedo Corn Co.",
location: "Olathe, Colorado",
quote:
"Route Commerce transformed how we manage our wholesale operations. Our pickup efficiency increased by 40% in the first month.",
},
{
initials: "IR",
name: "Indian River Citrus",
location: "Indian River, Florida",
quote:
"The Harvest Reach feature alone has recovered thousands in abandoned cart revenue. Game changer for our business.",
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: "easeOut" as const,
},
},
};
const cardHoverVariants = {
rest: { y: 0, boxShadow: "0 4px 24px rgba(26, 77, 46, 0.08)" },
hover: {
y: -4,
boxShadow: "0 12px 40px rgba(26, 77, 46, 0.12)",
transition: { duration: 0.3, ease: "easeOut" },
},
};
export default function TestimonialsAndCTA() {
return (
<>
{/* ─── Testimonials Section ─────────────────────────────────────────────── */}
<section
style={{
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
fontFamily: "'Plus Jakarta Sans', sans-serif",
}}
>
{/* Decorative botanical accent */}
<div
style={{
position: "absolute",
left: "5%",
top: "20%",
width: "120px",
height: "120px",
opacity: 0.06,
background: "radial-gradient(ellipse at center, #1a4d2e 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div
style={{
position: "absolute",
right: "8%",
bottom: "15%",
width: "80px",
height: "80px",
opacity: 0.05,
background: "radial-gradient(ellipse at center, #c97a3e 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div className="relative mx-auto max-w-6xl px-6 py-24">
{/* Section header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.7, ease: [0.22, 0.61, 0.36, 1] }}
className="mb-16 text-center"
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#6b8f71",
marginBottom: "12px",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Testimonials
</span>
<h2
style={{
fontFamily: "'Cormorant Garamond', serif",
fontSize: "clamp(2.5rem, 5vw, 3.5rem)",
fontWeight: 600,
lineHeight: 1.1,
color: "#1a1a1a",
letterSpacing: "-0.02em",
}}
>
Trusted by Farms
</h2>
<p
style={{
marginTop: "16px",
fontSize: "1.125rem",
color: "#555555",
maxWidth: "480px",
marginLeft: "auto",
marginRight: "auto",
}}
>
Produce operations across the country rely on Route Commerce to power their farm-fresh distribution.
</p>
</motion.div>
{/* Testimonial cards */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-40px" }}
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
gap: "24px",
maxWidth: "800px",
margin: "0 auto",
}}
>
{testimonials.map((testimonial, index) => (
<motion.div
key={index}
variants={itemVariants}
style={{
background: "linear-gradient(145deg, #ffffff 0%, #fdfcfa 100%)",
borderRadius: "20px",
padding: "32px",
border: "1px solid rgba(26, 77, 46, 0.08)",
position: "relative",
overflow: "hidden",
}}
whileHover="hover"
initial="rest"
animate="rest"
>
{/* Decorative quote mark */}
<div
style={{
position: "absolute",
top: "-8px",
right: "20px",
fontSize: "80px",
fontFamily: "'Cormorant Garamond', serif",
color: "rgba(26, 77, 46, 0.06)",
lineHeight: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
&ldquo;
</div>
{/* Avatar with gradient */}
<div
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
background: `linear-gradient(135deg, #1a4d2e 0%, #6b8f71 50%, #c97a3e 100%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
boxShadow: "0 4px 12px rgba(26, 77, 46, 0.15)",
}}
>
<span
style={{
color: "#ffffff",
fontSize: "1rem",
fontWeight: 700,
letterSpacing: "0.05em",
textShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
>
{testimonial.initials}
</span>
</div>
{/* Quote text */}
<blockquote
style={{
fontSize: "1rem",
lineHeight: 1.7,
color: "#1a1a1a",
margin: 0,
marginBottom: "20px",
position: "relative",
zIndex: 1,
}}
>
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
{/* Attribution */}
<div style={{ borderTop: "1px solid rgba(0,0,0,0.06)", paddingTop: "16px" }}>
<p
style={{
fontWeight: 700,
fontSize: "0.9375rem",
color: "#1a1a1a",
margin: 0,
}}
>
{testimonial.name}
</p>
<p
style={{
fontSize: "0.8125rem",
color: "#6b8f71",
margin: 0,
marginTop: "4px",
fontWeight: 500,
}}
>
{testimonial.location}
</p>
</div>
</motion.div>
))}
</motion.div>
{/* Trust indicators */}
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
style={{
display: "flex",
justifyContent: "center",
gap: "40px",
marginTop: "48px",
flexWrap: "wrap",
}}
>
{[
{ value: "200+", label: "Farms & Co-ops" },
{ value: "40%", label: "Avg. Efficiency Gain" },
{ value: "94%", label: "Customer Retention" },
].map((stat, i) => (
<div key={i} style={{ textAlign: "center" }}>
<div
style={{
fontFamily: "'Cormorant Garamond', serif",
fontSize: "2.5rem",
fontWeight: 600,
color: "#1a4d2e",
lineHeight: 1,
}}
>
{stat.value}
</div>
<div
style={{
fontSize: "0.8125rem",
color: "#6b8f71",
marginTop: "6px",
fontWeight: 500,
}}
>
{stat.label}
</div>
</div>
))}
</motion.div>
</div>
</section>
{/* ─── CTA Section ──────────────────────────────────────────────────────── */}
<section
style={{
background: "linear-gradient(180deg, #f5f2ed 0%, #faf8f5 50%, #faf8f5 100%)",
position: "relative",
overflow: "hidden",
fontFamily: "'Plus Jakarta Sans', sans-serif",
}}
>
{/* Decorative gradient orbs */}
<div
style={{
position: "absolute",
top: "10%",
left: "10%",
width: "300px",
height: "300px",
background: "radial-gradient(circle, rgba(26, 77, 46, 0.08) 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div
style={{
position: "absolute",
bottom: "0",
right: "5%",
width: "250px",
height: "250px",
background: "radial-gradient(circle, rgba(201, 122, 62, 0.06) 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
{/* Botanical line decoration */}
<svg
style={{
position: "absolute",
top: "20px",
right: "15%",
width: "60px",
height: "100px",
opacity: 0.15,
}}
viewBox="0 0 60 100"
fill="none"
aria-hidden="true"
>
<path
d="M30 100 C30 60, 10 50, 30 20 C50 50, 30 60, 30 100"
stroke="#1a4d2e"
strokeWidth="2"
fill="none"
/>
<path
d="M30 70 C20 60, 15 55, 25 45"
stroke="#1a4d2e"
strokeWidth="1.5"
fill="none"
/>
<path
d="M30 70 C40 60, 45 55, 35 45"
stroke="#1a4d2e"
strokeWidth="1.5"
fill="none"
/>
<circle cx="30" cy="15" r="3" fill="#6b8f71" />
<circle cx="22" cy="42" r="2" fill="#c97a3e" />
<circle cx="38" cy="42" r="2" fill="#c97a3e" />
</svg>
<div className="relative mx-auto max-w-4xl px-6 py-28 text-center">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
>
{/* Section label */}
<span
style={{
display: "inline-block",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#c97a3e",
marginBottom: "16px",
padding: "6px 14px",
background: "rgba(201, 122, 62, 0.1)",
borderRadius: "20px",
}}
>
Get Started Today
</span>
{/* Headline */}
<h2
style={{
fontFamily: "'Cormorant Garamond', serif",
fontSize: "clamp(2.75rem, 6vw, 4rem)",
fontWeight: 600,
lineHeight: 1.1,
color: "#1a1a1a",
letterSpacing: "-0.02em",
marginBottom: "20px",
}}
>
Ready to Grow Your Routes?
</h2>
{/* Subtitle */}
<p
style={{
fontSize: "1.125rem",
lineHeight: 1.7,
color: "#555555",
maxWidth: "560px",
margin: "0 auto 36px",
}}
>
Join hundreds of produce brands who trust Route Commerce to power their farm-fresh operations.
</p>
{/* CTA Buttons */}
<div
style={{
display: "flex",
gap: "16px",
justifyContent: "center",
flexWrap: "wrap",
}}
>
{/* Primary button */}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Link
href="/admin"
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "16px 32px",
background: "rgba(26, 77, 46, 0.9)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.25)",
color: "#ffffff",
fontSize: "1rem",
fontWeight: 600,
borderRadius: "12px",
cursor: "pointer",
textDecoration: "none",
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.1)",
transition: "all 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(45, 107, 79, 0.95)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(26, 77, 46, 0.45), inset 0 1px 0 rgba(255,255,255, 0.2)";
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.borderColor = "rgba(255,255,255, 0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(26, 77, 46, 0.9)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255,255,255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.1)";
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.borderColor = "rgba(255,255,255, 0.25)";
}}
>
Start Free Trial
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</motion.div>
{/* Secondary button */}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Link
href="/brands"
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "16px 32px",
background: "rgba(250, 248, 245, 0.8)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(26, 77, 46, 0.2)",
color: "#1a4d2e",
fontSize: "1rem",
fontWeight: 600,
borderRadius: "12px",
cursor: "pointer",
textDecoration: "none",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)",
transition: "all 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.9)";
e.currentTarget.style.borderColor = "rgba(26, 77, 46, 0.4)";
e.currentTarget.style.boxShadow = "0 8px 24px rgba(26, 77, 46, 0.12), inset 0 1px 0 rgba(255,255,255, 0.6)";
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(250, 248, 245, 0.8)";
e.currentTarget.style.borderColor = "rgba(26, 77, 46, 0.2)";
e.currentTarget.style.boxShadow = "0 4px 16px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)";
e.currentTarget.style.transform = "translateY(0)";
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
View Farms
</Link>
</motion.div>
</div>
{/* Disclaimer */}
<p
style={{
marginTop: "24px",
fontSize: "0.8125rem",
color: "#888888",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.6 }}
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
No credit card required · 14-day free trial · Cancel anytime
</p>
</motion.div>
{/* Bottom decorative element */}
<div
style={{
marginTop: "60px",
display: "flex",
justifyContent: "center",
gap: "8px",
opacity: 0.3,
}}
>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
width: i === 2 ? "24px" : "6px",
height: "6px",
borderRadius: "3px",
background: i === 2 ? "#1a4d2e" : "#1a4d2e",
opacity: i === 2 ? 1 : 0.4,
}}
/>
))}
</div>
</div>
</section>
<style jsx>{`
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes pulse-soft {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Scroll-triggered animations */
section {
scroll-margin-top: 80px;
}
/* Ensure smooth transitions */
a, button {
-webkit-tap-highlight-color: transparent;
}
`}</style>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { ReactNode } from "react";
type LayoutContainerProps = {
children: ReactNode;
};
export default function LayoutContainer({
children,
}: LayoutContainerProps) {
return (
<div className="relative mx-auto max-w-7xl px-6">
{children}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
export default function SiteFooter() {
return (
<footer className="glass border-t border-white/10 mt-auto">
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-zinc-500">
© {new Date().getFullYear()} Route Commerce. All rights reserved.
</p>
<nav className="flex gap-6 text-sm text-zinc-500">
<Link href="/privacy-policy" className="hover:text-white transition-colors">
Privacy Policy
</Link>
<Link href="/terms-and-conditions" className="hover:text-white transition-colors">
Terms & Conditions
</Link>
<a
href="https://cielohermosa.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
Powered by Route Commerce
</a>
</nav>
</div>
</div>
</footer>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCart } from "@/context/CartContext";
import ThemeToggle from "@/components/shared/ThemeToggle";
const BRAND_NAMES: Record<string, string> = {
tuxedo: "Tuxedo Corn",
"indian-river-direct": "Indian River Direct",
};
export default function SiteHeader() {
const { cart } = useCart();
const pathname = usePathname();
const showBrandName = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
const brandKey = pathname?.split("/")[1];
const brandName = brandKey ? BRAND_NAMES[brandKey] : null;
return (
<header className="glass border-b border-white/10 sticky top-0 z-50">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
{showBrandName && brandName ? (
<Link href={`/${brandKey}`} className="text-lg font-semibold tracking-tight text-white hover:text-emerald-400 transition-colors">
{brandName}
</Link>
) : (
<Link href="/" className="text-lg font-semibold tracking-tight text-white hover:text-emerald-400 transition-colors">
Route Commerce
</Link>
)}
<nav className="flex items-center gap-6 text-sm font-medium text-zinc-400">
<Link href="/tuxedo" className="hover:text-white transition-colors">
Tuxedo
</Link>
<Link href="/indian-river-direct" className="hover:text-white transition-colors">
IRD
</Link>
<Link href="/admin" className="hover:text-white transition-colors">
Admin
</Link>
<ThemeToggle />
<Link href="/cart" className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 px-4 py-2 text-sm font-semibold text-white hover:from-emerald-400 hover:to-emerald-300 transition-all shadow-lg shadow-emerald-500/20">
Cart ({cart.reduce((total, item) => total + item.quantity, 0)})
</Link>
</nav>
</div>
</header>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
import StatusBadge from "./StatusBadge";
import Link from "next/link";
export default function AdminLookupPage({ brandId }: { brandId: string }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<HarvestLot[]>([]);
const [searched, setSearched] = useState(false);
const [loading, setLoading] = useState(false);
const [scanMode, setScanMode] = useState(false);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setScanMode(false);
const res = await searchHarvestLots(brandId, query.trim());
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
}
function handleScanResult(lotNumber: string) {
setQuery(lotNumber);
setScanMode(false);
// Trigger search immediately
setLoading(true);
searchHarvestLots(brandId, lotNumber).then((res) => {
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
});
}
return (
<div className="space-y-5">
{/* Search card */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="border-b border-stone-100 px-6 py-5">
<h2 className="text-lg font-semibold text-stone-900">Trace Lookup</h2>
<p className="mt-1 text-sm text-stone-500">Search by lot number or crop type to find a harvest lot.</p>
</div>
<form onSubmit={handleSearch} className="flex gap-3 p-6">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. TC-20260519-001 or Sweet Corn"
className="flex-1 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="rounded-xl bg-stone-900 px-5 py-3 text-sm font-bold text-white hover:bg-stone-800 disabled:opacity-50"
>
{loading ? "Searching..." : "🔍 Search"}
</button>
</form>
{scanMode && (
<div className="px-6 pb-6">
<div className="rounded-xl border-2 border-dashed border-stone-300 bg-stone-50 p-8 text-center">
<div className="text-4xl mb-3">📷</div>
<p className="text-sm font-semibold text-stone-700">Camera scan ready</p>
<p className="text-xs text-stone-400 mt-1">Point camera at a Route Trace QR code to look up a lot</p>
<div className="mt-4 flex justify-center gap-3">
<button
onClick={() => setScanMode(false)}
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => {
// Simulate scan with manual entry fallback
const input = prompt("Enter lot number from QR scan:");
if (input) handleScanResult(input.trim());
}}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
>
Enter Manually
</button>
</div>
</div>
</div>
)}
{!scanMode && (
<div className="px-6 pb-6">
<button
onClick={() => setScanMode(true)}
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors flex items-center gap-2"
>
📷 Scan QR Code
</button>
</div>
)}
</div>
{/* Results */}
{searched && (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
{results.length === 0 ? (
<div className="p-10 text-center">
<div className="text-3xl mb-3">🔍</div>
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
</div>
) : (
<>
<div className="px-5 py-3 bg-stone-50 border-b border-stone-100">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">
{results.length} result{results.length !== 1 ? "s" : ""}
</p>
</div>
<table className="w-full">
<tbody>
{results.map((lot) => (
<tr key={lot.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors">
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-4 text-sm text-stone-700">{lot.crop_type}</td>
<td className="px-5 py-4 text-xs text-stone-400">{lot.harvest_date}</td>
<td className="px-5 py-4"><StatusBadge status={lot.status} /></td>
<td className="px-5 py-4 text-right">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
type Props = {
brandId: string;
};
export default function FsmaReportModal({ brandId }: Props) {
const [open, setOpen] = useState(false);
const [startDate, setStartDate] = useState(() => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().split("T")[0];
});
const [endDate, setEndDate] = useState(() => new Date().toISOString().split("T")[0]);
const [loading, setLoading] = useState(false);
async function handleDownload() {
setLoading(true);
try {
const url = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}&format=csv`;
window.location.href = url;
} finally {
setLoading(false);
setOpen(false);
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-xl border border-stone-200 bg-white px-5 py-3 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
📋 FSMA Report
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setOpen(false)}>
<div className="w-full max-w-sm rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-bold text-stone-900">📋 FSMA Compliance Report</h3>
<p className="text-xs text-stone-400 mt-0.5">One-up / one-down traceability for a date range</p>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1.5">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1.5">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
</div>
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-3">
<p className="text-xs text-stone-500">Includes all lots harvested between the selected dates with full trace events.</p>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button
onClick={() => setOpen(false)}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleDownload}
disabled={loading}
className="rounded-xl bg-stone-900 px-5 py-2 text-sm font-bold text-white hover:bg-stone-800 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Preparing..." : "📥 Download CSV"}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,291 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createHarvestLot } from "@/actions/route-trace/lots";
export default function LotCreateForm({ brandId }: { brandId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [notesOpen, setNotesOpen] = useState(false);
const [crop_type, setCropType] = useState("");
const [variety, setVariety] = useState("");
const [harvest_date, setHarvestDate] = useState(new Date().toISOString().split("T")[0]);
const [field_location, setFieldLocation] = useState("");
const [field_block, setFieldBlock] = useState("");
const [worker_name, setWorkerName] = useState("");
const [packer_name, setPackerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
const [yield_estimate_lbs, setYieldEstimateLbs] = useState("");
const [yield_unit, setYieldUnit] = useState("lbs");
const [customYieldUnit, setCustomYieldUnit] = useState("");
const [bin_id, setBinId] = useState("");
const [container_id, setContainerId] = useState("");
const [pallets, setPallets] = useState("");
const [notes, setNotes] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type,
variety: variety || undefined,
harvest_date,
field_location: field_location || undefined,
worker_name: worker_name || undefined,
packer_name: packer_name || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
notes: notes || undefined,
bin_id: bin_id || undefined,
container_id: container_id || undefined,
field_block: field_block || undefined,
yield_estimate_lbs: yield_estimate_lbs ? Number(yield_estimate_lbs) : undefined,
yield_unit: yield_unit === "custom" ? customYieldUnit.trim() || undefined : yield_unit,
pallets: pallets ? Number(pallets) : undefined,
});
if (result.success && result.lot) {
router.push(`/admin/route-trace/lots/${result.lot.id}`);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-green-100">
<span className="text-base">🌱</span>
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">New Harvest Lot</h2>
<p className="text-sm text-stone-500">Quick entry scan or fill in the fields below</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-700">{error}</div>
)}
{/* Required — large touch targets for field use */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-stone-800 mb-2">Crop Type *</label>
<input
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-800 mb-2">Variety</label>
<input
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
placeholder="e.g. Golden Bantam"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-stone-800 mb-2">Harvest Date *</label>
<input
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
{/* Field & Location */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Field &amp; Location</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Field / Location *</label>
<input
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
placeholder="e.g. North Field"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Field Block</label>
<input
type="text"
value={field_block}
onChange={(e) => setFieldBlock(e.target.value)}
placeholder="e.g. Block A"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Worker / Harvest Lead</label>
<input
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
placeholder="e.g. Maria S."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
</div>
{/* Quantity + Yield */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Weight &amp; Yield</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Actual Qty</label>
<input
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
placeholder="e.g. 4800"
min="0"
step="0.01"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3.5 text-base outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Yield Est.</label>
<div className="flex rounded-xl border border-stone-200 bg-stone-50 overflow-hidden focus-within:border-stone-900 focus-within:bg-white transition-colors">
<input
type="number"
value={yield_estimate_lbs}
onChange={(e) => setYieldEstimateLbs(e.target.value)}
placeholder="e.g. 5000"
min="0"
step="0.01"
className="flex-1 px-3 py-3.5 text-base outline-none bg-transparent"
/>
<select
value={yield_unit}
onChange={(e) => setYieldUnit(e.target.value)}
className="px-2 py-3.5 text-sm font-semibold text-stone-600 bg-stone-100 outline-none border-l border-stone-200 cursor-pointer"
>
<option value="lbs">lbs</option>
<option value="bushel">bu</option>
<option value="box">bx</option>
<option value="sack">sk</option>
<option value="crate">cr</option>
<option value="bin">bn</option>
<option value="pallet">plt</option>
<option value="custom">custom</option>
</select>
</div>
{yield_unit === "custom" && (
<input
type="text"
placeholder="Unit name…"
value={customYieldUnit}
onChange={(e) => setCustomYieldUnit(e.target.value)}
className="mt-1.5 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
)}
</div>
</div>
</div>
{/* Bin + Container */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Bin, Container &amp; Pallets</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Bin ID</label>
<input
type="text"
value={bin_id}
onChange={(e) => setBinId(e.target.value)}
placeholder="e.g. BIN-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Container ID</label>
<input
type="text"
value={container_id}
onChange={(e) => setContainerId(e.target.value)}
placeholder="e.g. CONT-A42"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Pallets</label>
<input
type="number"
value={pallets}
onChange={(e) => setPallets(e.target.value)}
placeholder="e.g. 4"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5">Packer Name</label>
<input
type="text"
value={packer_name}
onChange={(e) => setPackerName(e.target.value)}
placeholder="e.g. Jose R."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
{/* Notes — collapsible */}
<div className="border-t border-stone-100 pt-5">
<button
type="button"
onClick={() => setNotesOpen(!notesOpen)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-stone-400 hover:text-stone-600"
>
<span>Notes</span>
<span>{notesOpen ? "▲" : "▼"}</span>
</button>
{notesOpen && (
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Spray records, weather conditions, or other harvest details..."
rows={3}
className="mt-3 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 focus:bg-white transition-colors resize-none"
/>
)}
</div>
{/* Save — big button */}
<div className="flex items-center justify-end border-t border-stone-100 pt-5">
<button
type="submit"
disabled={isPending || !crop_type || !harvest_date || !field_location}
className="rounded-xl bg-green-600 px-8 py-4 text-base font-bold text-white hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm"
>
{isPending ? "Creating..." : "🌱 Create Lot"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,550 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import StatusBadge from "./StatusBadge";
import StickerPreviewModal from "./StickerPreviewModal";
import { LotDetail, LotOrder, updateHarvestLotStatus } from "@/actions/route-trace/lots";
const STATUS_FLOW = ["active", "in_transit", "at_shed", "packed", "delivered"];
const EVENT_CONFIG: Record<string, { icon: string; bg: string; label: string }> = {
harvested: { icon: "🌱", bg: "bg-green-100 text-green-700", label: "Harvested" },
field_packed: { icon: "📦", bg: "bg-green-100 text-green-700", label: "Field Packed" },
bin_tagged: { icon: "🏷️", bg: "bg-amber-100 text-amber-700", label: "Bin Tagged" },
in_transit: { icon: "🚚", bg: "bg-amber-100 text-amber-700", label: "In Transit" },
at_shed: { icon: "🏭", bg: "bg-blue-100 text-blue-700", label: "At Shed" },
packed: { icon: "📋", bg: "bg-purple-100 text-purple-700", label: "Packed" },
delivered: { icon: "✅", bg: "bg-stone-100 text-stone-600", label: "Delivered" },
};
function CheckIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-3.5 h-3.5"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}
export default function LotDetailPanel({
lot,
brandId,
orders = [],
}: {
lot: LotDetail;
brandId: string;
orders?: LotOrder[];
}) {
const [showSticker, setShowSticker] = useState(false);
const [showStatusModal, setShowStatusModal] = useState(false);
const [showBinModal, setShowBinModal] = useState(false);
const [showUsedModal, setShowUsedModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [newStatus, setNewStatus] = useState("");
const [location, setLocation] = useState("");
const [notes, setNotes] = useState("");
const [binId, setBinId] = useState("");
const [usedOrderId, setUsedOrderId] = useState("");
const [usedQty, setUsedQty] = useState("");
const [usedNotes, setUsedNotes] = useState("");
const nextStatuses = STATUS_FLOW.slice(STATUS_FLOW.indexOf(lot.status) + 1);
const isInTransit = lot.status === "in_transit";
const canMarkLoaded = lot.status === "at_shed" || lot.status === "packed";
function handleUpdateStatus(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, newStatus, location || undefined, notes || undefined);
window.location.reload();
});
}
function handleAddBin(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, "bin_tagged", lot.field_location ?? undefined, notes || undefined, binId || undefined);
window.location.reload();
});
}
function handleMarkLoaded() {
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, "in_transit", lot.field_location ?? undefined);
window.location.reload();
});
}
function handleMarkAtShed() {
startTransition(async () => {
await updateHarvestLotStatus(lot.lot_id, "at_shed", lot.field_location ?? undefined);
window.location.reload();
});
}
function handleMarkUsedInOrder(e: React.FormEvent) {
e.preventDefault();
if (!usedOrderId.trim()) return;
startTransition(async () => {
const { markLotUsedInOrder } = await import("@/actions/route-trace/lots");
await markLotUsedInOrder(
lot.lot_id,
usedOrderId.trim(),
usedQty ? Number(usedQty) : undefined,
usedNotes || undefined
);
window.location.reload();
});
}
return (
<div className="space-y-5">
{/* Header card */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="flex items-start justify-between border-b border-stone-100 px-6 py-5">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-black text-stone-900">{lot.lot_number}</h1>
<StatusBadge status={lot.status} />
</div>
<p className="mt-1.5 text-sm text-stone-500">
{lot.crop_type}{lot.variety ? ` · ${lot.variety}` : ""} · Harvested {lot.harvest_date}
</p>
</div>
<div className="flex gap-2 flex-wrap flex-col items-end">
{(isInTransit || canMarkLoaded) && (
<div className="flex gap-2 mb-1">
{isInTransit && (
<button
onClick={handleMarkAtShed}
disabled={isPending}
className="rounded-xl border-2 border-blue-300 bg-blue-50 px-4 py-2.5 text-sm font-bold text-blue-700 hover:bg-blue-100 transition-colors disabled:opacity-50 flex items-center gap-1.5 shadow-sm"
>
{isPending ? "..." : "🏭"} Mark at Shed
</button>
)}
{canMarkLoaded && (
<button
onClick={handleMarkLoaded}
disabled={isPending}
className="rounded-xl border-2 border-amber-300 bg-amber-50 px-4 py-2.5 text-sm font-bold text-amber-700 hover:bg-amber-100 transition-colors disabled:opacity-50 flex items-center gap-1.5 shadow-sm"
>
{isPending ? "..." : "🚚"} Mark Loaded
</button>
)}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowSticker(true)}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
🖨 Print Sticker
</button>
{nextStatuses.length > 0 && (
<button
onClick={() => { setNewStatus(nextStatuses[0]); setShowStatusModal(true); }}
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
>
Update Status
</button>
)}
</div>
</div>
</div>
{/* Info grid */}
<div className="grid grid-cols-2 md:grid-cols-4">
{[
{ label: "Field / Location", value: lot.field_location ?? "—" },
{ label: "Field Block", value: lot.field_block ?? "—" },
{ label: "Worker", value: lot.worker_name ?? "—" },
{ label: "Packer", value: lot.packer_name ?? "—" },
{ label: "Quantity", value: lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—" },
{ label: "Yield Est.", value: lot.yield_estimate_lbs != null ? `${lot.yield_estimate_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—" },
{ label: "Pallets", value: lot.pallets != null ? String(lot.pallets) : "—" },
{ label: "Bin ID", value: lot.bin_id ?? "—" },
{ label: "Container ID", value: lot.container_id ?? "—" },
{ label: "Variety", value: lot.variety ?? "—" },
].map((item) => (
<div key={item.label} className="border-r border-b border-stone-100 px-5 py-4 last:border-r-0 even:right-0">
<p className="text-xs font-medium text-stone-400 uppercase tracking-wide">{item.label}</p>
<p className="mt-1 text-sm font-bold text-stone-800">{item.value}</p>
</div>
))}
</div>
{lot.notes && (
<div className="border-t border-stone-100 px-5 py-4">
<p className="text-xs font-medium text-stone-400 uppercase tracking-wide mb-1">Notes</p>
<p className="text-sm text-stone-600">{lot.notes}</p>
</div>
)}
{/* Quick actions */}
<div className="flex gap-3 border-t border-stone-100 px-6 py-4 flex-wrap">
<button
onClick={() => setShowBinModal(true)}
className="rounded-xl border border-amber-200 bg-white px-4 py-2 text-sm font-semibold text-amber-700 hover:bg-amber-50 transition-colors"
>
🏷 Add Bin / Tag
</button>
<a
href={`/api/route-trace/trace-report?lotId=${lot.lot_id}&format=csv`}
download
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors flex items-center gap-1.5"
>
📄 Download Report
</a>
<Link
href={`/trace/${lot.lot_number}`}
target="_blank"
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
🔍 View Public Trace
</Link>
</div>
</div>
{/* Fulfilled orders */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-stone-100 px-6 py-4 bg-stone-50/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<span>📦</span>
<h2 className="text-base font-semibold text-stone-900">Order Fulfillment</h2>
</div>
{orders.length > 0 && (
<div className="flex items-center gap-2">
<span className="rounded-full bg-green-100 text-green-700 text-xs font-bold px-2.5 py-0.5">
{orders.length} {orders.length === 1 ? "order" : "orders"}
</span>
<span className="text-xs text-stone-400">
{orders.reduce((sum, o) => sum + Number(o.item_quantity ?? 0), 0).toLocaleString()} lbs total
</span>
</div>
)}
</div>
{/* Used / Remaining balance */}
{lot.quantity_lbs != null && lot.quantity_lbs > 0 && (
<div className="px-6 py-4 border-b border-stone-100 bg-gradient-to-r from-stone-50 to-white">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-700">Lot Balance</span>
{lot.quantity_used_lbs != null && lot.quantity_used_lbs > 0 ? (
<span className="rounded-full bg-amber-100 text-amber-700 text-xs font-bold px-2 py-0.5">
Partially Used
</span>
) : (
<span className="rounded-full bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5">
Fully Available
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm">
{lot.quantity_used_lbs != null && lot.quantity_used_lbs > 0 && (
<span className="text-stone-500">
<span className="font-bold text-stone-800">{Number(lot.quantity_used_lbs).toLocaleString()}</span> lbs used
</span>
)}
<span className="font-bold text-stone-800">
{Math.max(0, Number(lot.quantity_lbs) - Number(lot.quantity_used_lbs ?? 0)).toLocaleString()}
</span>
<span className="text-stone-400">lbs remaining</span>
</div>
</div>
<div className="w-full bg-stone-100 rounded-full h-3 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, ((Number(lot.quantity_used_lbs ?? 0) / Number(lot.quantity_lbs)) * 100))}%`,
background: Number(lot.quantity_used_lbs ?? 0) > 0
? "linear-gradient(90deg, #f59e0b 0%, #d97706 100%)"
: "linear-gradient(90deg, #22c55e 0%, #16a34a 100%)",
}}
/>
</div>
</div>
)}
{orders.length === 0 ? (
<div className="px-6 py-5 text-center">
<p className="text-sm text-stone-400">This lot has not been assigned to any orders yet.</p>
<button
onClick={() => setShowUsedModal(true)}
className="mt-3 rounded-xl bg-stone-100 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-200 transition-colors"
>
Mark as Used in Order
</button>
</div>
) : (
<div className="p-5 space-y-3">
{orders.map((order) => (
<Link
key={order.id}
href={`/admin/orders/${order.id}`}
className="flex items-center justify-between rounded-xl border border-stone-100 bg-stone-50/50 px-5 py-4 hover:bg-stone-100/60 transition-colors"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 flex-shrink-0">
<span className="text-base">📦</span>
</div>
<div>
<p className="text-sm font-bold text-stone-900">{order.customer_name}</p>
<p className="text-xs text-stone-400 mt-0.5">{order.stop_name} · {order.order_date}</p>
</div>
</div>
<div className="text-right flex items-center gap-3">
{order.item_quantity != null && (
<div>
<p className="text-lg font-black text-stone-900">{Number(order.item_quantity).toLocaleString()}</p>
<p className="text-xs text-stone-400">lbs allocated</p>
</div>
)}
{order.lot_quantity_used != null && order.lot_quantity_used > 0 && (
<div className="border-l border-stone-200 pl-3">
<p className="text-lg font-black text-amber-700">{Number(order.lot_quantity_used).toLocaleString()}</p>
<p className="text-xs text-stone-400">lbs used</p>
</div>
)}
<span className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold ${
order.fulfillment === "pickup" ? "bg-blue-50 text-blue-600" :
order.fulfillment === "ship" ? "bg-green-50 text-green-600" :
"bg-stone-100 text-stone-500"
}`}>
{order.fulfillment ?? "mixed"}
</span>
</div>
</Link>
))}
<button
onClick={() => setShowUsedModal(true)}
className="w-full mt-2 rounded-xl border border-dashed border-stone-300 py-2.5 text-xs font-semibold text-stone-500 hover:bg-stone-50 transition-colors"
>
+ Mark as Used in Another Order
</button>
</div>
)}
</div>
{/* Event timeline */}
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-stone-100 px-6 py-4 bg-stone-50/50">
<h2 className="text-base font-semibold text-stone-900">Trace Timeline</h2>
<p className="mt-0.5 text-xs text-stone-400">Complete history field to delivery</p>
</div>
{lot.events.length === 0 ? (
<div className="p-8 text-center text-sm text-stone-400">No events recorded yet.</div>
) : (
<div className="p-6">
<div className="relative">
{lot.events.map((event, idx) => {
const cfg = EVENT_CONFIG[event.event_type] ?? { icon: "📋", bg: "bg-stone-100 text-stone-600", label: event.event_type };
const isLast = idx === lot.events.length - 1;
return (
<div key={event.id} className="flex gap-4">
{/* Connector line */}
{!isLast && (
<div className="absolute left-[18px] top-9 w-px bg-stone-200" style={{ height: `calc(100% - 36px)` }} />
)}
<div className="relative flex flex-col items-center">
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-base ring-2 ring-white ${cfg.bg}`}>
{cfg.icon}
</div>
</div>
<div className={`pb-6 pt-0.5 ${isLast ? "!pb-0" : ""}`}>
<div className="flex items-baseline gap-2">
<span className="text-sm font-semibold text-stone-800">{cfg.label}</span>
<span className="text-xs text-stone-400">
{new Date(event.event_time).toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
</span>
</div>
{event.location && (
<p className="mt-0.5 text-xs text-stone-500">📍 {event.location}</p>
)}
{event.bin_id && (
<p className="mt-0.5 text-xs font-bold text-amber-700">🏷 Bin: {event.bin_id}</p>
)}
{event.notes && (
<p className="mt-0.5 text-xs text-stone-400 italic">{event.notes}</p>
)}
{event.created_by_name && (
<p className="mt-0.5 text-xs text-stone-300">by {event.created_by_name}</p>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Status update modal */}
{showStatusModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl">
<form onSubmit={handleUpdateStatus}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-semibold text-stone-900">Update Status</h3>
</div>
<div className="space-y-4 p-6">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">New Status</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
>
<option value="">Select status...</option>
{STATUS_FLOW.filter((s) => s !== lot.status).map((s) => {
const c = EVENT_CONFIG[s];
return (
<option key={s} value={s}>
{c?.icon} {c?.label ?? s}
</option>
);
})}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Location</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Warehouse A"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes..."
rows={2}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button" onClick={() => setShowStatusModal(false)} className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50">
Cancel
</button>
<button type="submit" disabled={isPending || !newStatus} className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50">
{isPending ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Add Bin modal */}
{showBinModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl">
<form onSubmit={handleAddBin}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-semibold text-stone-900">🏷 Add Bin / Tag</h3>
<p className="mt-0.5 text-xs text-stone-400">Log a bin or pallet tag event for this lot</p>
</div>
<div className="space-y-4 p-6">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Bin or Container ID *</label>
<input
type="text"
value={binId}
onChange={(e) => setBinId(e.target.value)}
placeholder="e.g. BIN-042"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Weight, count, or other details..."
rows={2}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button" onClick={() => setShowBinModal(false)} className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50">
Cancel
</button>
<button type="submit" disabled={isPending || !binId} className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700 disabled:opacity-50">
{isPending ? "Adding..." : "Add Bin Tag"}
</button>
</div>
</form>
</div>
</div>
)}
{showSticker && <StickerPreviewModal lot={lot} onClose={() => setShowSticker(false)} />}
{/* Mark as Used in Order modal */}
{showUsedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl">
<form onSubmit={handleMarkUsedInOrder}>
<div className="border-b border-stone-100 px-6 py-4">
<h3 className="text-base font-semibold text-stone-900">📦 Mark as Used in Order</h3>
<p className="mt-0.5 text-xs text-stone-400">Record which order this lot was assigned to</p>
</div>
<div className="space-y-4 p-6">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Order ID *</label>
<input
type="text"
value={usedOrderId}
onChange={(e) => setUsedOrderId(e.target.value.toUpperCase())}
placeholder="e.g. 4f3e2a1b-..."
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm font-mono outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Quantity Used (lbs)</label>
<input
type="number"
value={usedQty}
onChange={(e) => setUsedQty(e.target.value)}
placeholder="Leave blank to auto-detect from order items"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Notes</label>
<textarea
value={usedNotes}
onChange={(e) => setUsedNotes(e.target.value)}
placeholder="Any notes..."
rows={2}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-stone-900 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button" onClick={() => setShowUsedModal(false)} className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50">
Cancel
</button>
<button type="submit" disabled={isPending || !usedOrderId.trim()} className="rounded-xl bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-800 disabled:opacity-50">
{isPending ? "Saving..." : "Mark as Used"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import StatusBadge from "./StatusBadge";
import { HarvestLot, updateHarvestLotStatus } from "@/actions/route-trace/lots";
const STATUS_FILTERS = [
{ value: "", label: "All" },
{ value: "active", label: "Active" },
{ value: "in_transit", label: "In Transit" },
{ value: "at_shed", label: "At Shed" },
{ value: "packed", label: "Packed" },
{ value: "delivered", label: "Delivered" },
];
function getAgeStatus(harvestDate: string): { label: string; className: string; days: number } | null {
const harvested = new Date(harvestDate + "T00:00:00");
const now = new Date();
const days = Math.floor((now.getTime() - harvested.getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return null;
if (days <= 3) return { label: `${days}d`, className: "bg-green-100 text-green-700", days };
if (days <= 7) return { label: `${days}d`, className: "bg-amber-100 text-amber-700", days };
if (days <= 14) return { label: `${days}d`, className: "bg-orange-100 text-orange-700", days };
return { label: `${days}d`, className: "bg-red-100 text-red-700", days };
}
export default function LotListTable({
initialLots,
brandId,
}: {
initialLots: HarvestLot[];
brandId: string;
}) {
const [filter, setFilter] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showBulk, setShowBulk] = useState(false);
const filtered = initialLots.filter((lot) => {
if (filter && lot.status !== filter) return false;
if (query) {
const q = query.toLowerCase();
if (
!lot.lot_number.toLowerCase().includes(q) &&
!lot.crop_type.toLowerCase().includes(q) &&
!(lot.field_location ?? "").toLowerCase().includes(q) &&
!(lot.bin_id ?? "").toLowerCase().includes(q)
)
return false;
}
return true;
});
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleSelectAll() {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((l) => l.id)));
}
}
async function handleBulkStickers(type: "field" | "shed") {
if (selected.size === 0) return;
const ids = [...selected];
for (const id of ids) {
window.open(`/api/route-trace/sticker-pdf?lotId=${id}&type=${type}&size=4x2&copies=1`, "_blank");
}
}
function handleBulkExport() {
if (selected.size === 0) return;
const ids = [...selected];
ids.forEach((id) => {
window.location.href = `/api/route-trace/trace-report?lotId=${id}&format=csv`;
});
}
function handleBulkMarkUsed() {
if (selected.size === 0) return;
const ids = [...selected];
if (ids.length === 1) {
window.location.href = `/admin/route-trace/lots/${ids[0]}`;
} else {
window.location.href = `/admin/route-trace/lots?bulk_mark=${ids.join(",")}`;
}
}
async function handleBulkMarkLoaded() {
if (selected.size === 0) return;
const ids = [...selected];
for (const id of ids) {
await updateHarvestLotStatus(id, "in_transit");
}
window.location.reload();
}
return (
<div className="space-y-4">
{/* Filter bar */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex gap-1 rounded-xl border border-stone-200 bg-white p-1">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
filter === f.value
? "bg-stone-900 text-white"
: "text-stone-600 hover:bg-stone-50"
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-56 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-stone-900"
/>
<button
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
className={`rounded-xl border px-4 py-2.5 text-sm font-semibold transition-colors ${
showBulk ? "border-stone-900 bg-stone-900 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
Bulk {showBulk ? "On" : "Off"}
</button>
</div>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="rounded-2xl border-2 border-stone-900 bg-stone-900 px-5 py-3 flex items-center justify-between">
<span className="text-sm font-bold text-white">{selected.size} lot{selected.size !== 1 ? "s" : ""} selected</span>
<div className="flex gap-2 flex-wrap">
<button
onClick={handleBulkMarkLoaded}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors"
>
🚚 Mark Loaded
</button>
<button
onClick={handleBulkMarkUsed}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors"
>
📦 Mark as Used
</button>
<button
onClick={() => handleBulkStickers("field")}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
>
🖨 Field Stickers
</button>
<button
onClick={() => handleBulkStickers("shed")}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
>
🖨 Shed Stickers
</button>
<button
onClick={handleBulkExport}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
>
📄 {selected.size === 1 ? "Report" : `${selected.size} Reports`}
</button>
<button
onClick={() => setSelected(new Set())}
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
>
Clear
</button>
</div>
</div>
)}
{/* Table */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
<p className="text-sm text-stone-400">No lots found</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
{showBulk && (
<th className="px-5 py-3.5 w-10">
<input
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300"
/>
</th>
)}
{["Lot #", "Crop / Variety", "Harvest Date", "Age", "Field", "Bins", "Qty", "Status"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-stone-400 uppercase tracking-wider">
{h}
</th>
))}
<th className="px-5 py-3.5" />
</tr>
</thead>
<tbody>
{filtered.map((lot) => (
<tr key={lot.id} className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${selected.has(lot.id) ? "bg-green-50" : ""}`}>
{showBulk && (
<td className="px-5 py-4">
<input
type="checkbox"
checked={selected.has(lot.id)}
onChange={() => toggleSelect(lot.id)}
className="h-4 w-4 rounded border-stone-300"
/>
</td>
)}
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="font-mono text-sm font-black text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-4">
<div className="text-sm font-semibold text-stone-700">{lot.crop_type}</div>
{lot.variety && <div className="text-xs text-stone-400">{lot.variety}</div>}
</td>
<td className="px-5 py-4">
{(() => {
const age = getAgeStatus(lot.harvest_date);
return age ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-bold ${age.className}`}>
{age.label}
</span>
) : (
<span className="text-stone-300 text-xs"></span>
);
})()}
</td>
<td className="px-5 py-4">
<div className="text-sm text-stone-700">{lot.field_location ?? "—"}</div>
{lot.field_block && <div className="text-xs text-stone-400">Block: {lot.field_block}</div>}
</td>
<td className="px-5 py-4">
<div className="flex gap-1 flex-wrap">
{lot.bin_id && (
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
{lot.bin_id}
</span>
)}
{lot.pallets != null && (
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
{lot.pallets} PLT
</span>
)}
{!lot.bin_id && lot.pallets == null && <span className="text-stone-300 text-xs"></span>}
</div>
</td>
<td className="px-5 py-4 text-sm text-stone-600">
{lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—"}
</td>
<td className="px-5 py-4">
<StatusBadge status={lot.status} />
</td>
<td className="px-5 py-4 text-right">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,12 @@
"use client";
import { useState } from "react";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
export default function PublicTraceActions({ lotNumber }: { lotNumber: string }) {
return (
<div className="flex items-center justify-center gap-2 mb-4">
<ShareTraceButton lotNumber={lotNumber} />
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
// BarcodeDetector is not yet in TypeScript's lib — declare it inline
declare global {
interface Window {
BarcodeDetector?: new (options: { formats: string[] }) => {
detect(source: HTMLVideoElement | HTMLCanvasElement | ImageBitmap): Promise<Array<{ rawValue: string }>>;
};
}
}
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
type ScanMode = "camera" | "manual";
export default function QRScanModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [mode, setMode] = useState<ScanMode>("camera");
const [manualInput, setManualInput] = useState("");
const [cameraError, setCameraError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [cameraStarting, setCameraStarting] = useState(true);
const [detected, setDetected] = useState(false);
const [scanSuccess, setScanSuccess] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detectorRef = useRef<any>(null);
const scanRef = useRef<number>(0);
// Start camera on mount for camera mode
useEffect(() => {
if (mode !== "camera") return;
let mounted = true;
async function startCamera() {
setCameraStarting(true);
setCameraError(null);
// Check for BarcodeDetector support
if (!window.BarcodeDetector) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
if (!mounted) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
? "Camera access denied. Please allow camera permission."
: err instanceof DOMException && err.name === "NotFoundError"
? "No camera found. Use manual entry."
: "Camera not available. Use manual entry.";
setCameraError(msg);
setCameraStarting(false);
setTimeout(() => { if (mounted) setMode("manual"); }, 800);
}
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
if (!mounted) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
? "Camera access denied. Please allow camera permission."
: err instanceof DOMException && err.name === "NotFoundError"
? "No camera found. Use manual entry."
: "Camera not available. Use manual entry.";
setCameraError(msg);
setCameraStarting(false);
setTimeout(() => { if (mounted) setMode("manual"); }, 800);
return;
}
// Initialize BarcodeDetector
try {
detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] });
} catch {
detectorRef.current = null;
}
// Scan loop
const scan = async () => {
if (!mounted || detected || !detectorRef.current || !videoRef.current) return;
if (videoRef.current.readyState < 2) {
scanRef.current = requestAnimationFrame(scan);
return;
}
try {
const barcodes = await detectorRef.current.detect(videoRef.current);
if (barcodes.length > 0 && !detected) {
setDetected(true);
setScanSuccess(true);
const raw = barcodes[0].rawValue;
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
setTimeout(() => {
onClose();
router.push(`/trace/${encodeURIComponent(raw)}`);
}, 800);
return;
}
} catch {
// Ignore detection errors
}
scanRef.current = requestAnimationFrame(scan);
};
scanRef.current = requestAnimationFrame(scan);
}
startCamera();
return () => {
mounted = false;
if (scanRef.current) cancelAnimationFrame(scanRef.current);
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
};
}, [mode, detected, onClose, router]);
function handleManualSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = manualInput.trim();
if (!trimmed) return;
setIsLoading(true);
onClose();
router.push(`/trace/${encodeURIComponent(trimmed)}`);
}
function handleClose() {
if (scanRef.current) cancelAnimationFrame(scanRef.current);
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
onClose();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={handleClose}>
<div className="w-full max-w-sm mx-4 rounded-2xl bg-white shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
<div>
<h3 className="text-base font-bold text-stone-900">Scan QR Code</h3>
<p className="text-xs text-stone-400 mt-0.5">Lot sticker trace page</p>
</div>
<button onClick={handleClose} className="text-stone-400 hover:text-stone-600 text-xl leading-none"></button>
</div>
{/* Mode toggle */}
<div className="flex border-b border-stone-100">
<button
onClick={() => setMode("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-stone-900 border-b-2 border-stone-900" : "text-stone-400"
}`}
>
📷 Camera
</button>
<button
onClick={() => setMode("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-stone-900 border-b-2 border-stone-900" : "text-stone-400"
}`}
>
Manual
</button>
</div>
<div className="p-5">
{mode === "camera" ? (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-stone-900 aspect-square">
{cameraStarting ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center">
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin mb-3" />
<p className="text-xs text-white/70">Starting camera...</p>
</div>
) : cameraError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center px-6">
<span className="text-3xl mb-3">📷</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Viewfinder overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{scanSuccess ? (
<div className="w-44 h-44 rounded-2xl bg-green-500 flex items-center justify-center">
<span className="text-5xl"></span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-400">
Hold steady QR will scan automatically
</p>
)}
</div>
) : (
<form onSubmit={handleManualSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input
type="text"
value={manualInput}
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
autoFocus
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-900"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim() || isLoading}
className="w-full rounded-xl bg-stone-900 py-3.5 text-base font-bold text-white hover:bg-stone-800 disabled:opacity-50 transition-colors"
>
{isLoading ? "Loading..." : "🔍 Trace Lot"}
</button>
</form>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,405 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { createHarvestLot } from "@/actions/route-trace/lots";
type Props = {
brandId: string;
onCreated: (lotId: string) => void;
onClose: () => void;
};
const K = {
fields: "rt_recent_fields",
crops: "rt_recent_crops",
lots: "rt_recent_lots",
defaults: "rt_last_lot_defaults",
};
function get<T>(key: string, fallback: T): T {
try { return JSON.parse(localStorage.getItem(key) ?? "null") ?? fallback; } catch { return fallback; }
}
function save(key: string, val: unknown) {
localStorage.setItem(key, JSON.stringify(val));
}
function pushUnique<T>(arr: T[], val: T, max = 5): T[] {
const filtered = arr.filter((x) => x !== val);
return [val, ...filtered].slice(0, max);
}
type LastDefaults = {
worker_name?: string;
variety?: string;
quantity_lbs?: string;
};
function getLastDefaults(): LastDefaults {
return get<LastDefaults>(K.defaults, {});
}
const TODAY = new Date().toISOString().split("T")[0];
export default function QuickNewLotDrawer({ brandId, onCreated, onClose }: Props) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [recentFields] = useState<string[]>(() => get(K.fields, []));
const [recentCrops] = useState<string[]>(() => get(K.crops, []));
const lastDefaults = getLastDefaults();
const [crop_type, setCropType] = useState("");
const [harvest_date, setHarvestDate] = useState(TODAY);
const [field_location, setFieldLocation] = useState("");
const [worker_name, setWorkerName] = useState(lastDefaults.worker_name ?? "");
const [quantity_lbs, setQuantityLbs] = useState(lastDefaults.quantity_lbs ?? "");
const [yield_estimate_lbs, setYieldEstimateLbs] = useState("");
const [yield_unit, setYieldUnit] = useState("lbs");
const [customYieldUnit, setCustomYieldUnit] = useState("");
const [bin_id, setBinId] = useState("");
const [container_id, setContainerId] = useState("");
const [field_block, setFieldBlock] = useState("");
const [variety, setVariety] = useState(lastDefaults.variety ?? "");
const cropRef = useRef<HTMLInputElement>(null);
const fieldRef = useRef<HTMLInputElement>(null);
const workerRef = useRef<HTMLInputElement>(null);
const qtyRef = useRef<HTMLInputElement>(null);
const yieldRef = useRef<HTMLInputElement>(null);
const yieldUnitRef = useRef<HTMLSelectElement>(null);
const customUnitRef = useRef<HTMLInputElement>(null);
const binRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLInputElement>(null);
const blockRef = useRef<HTMLInputElement>(null);
const varietyRef = useRef<HTMLInputElement>(null);
useEffect(() => { cropRef.current?.focus(); }, []);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!crop_type.trim()) return;
setError(null);
if (field_location.trim()) save(K.fields, pushUnique(get<string[]>(K.fields, []), field_location.trim()));
save(K.crops, pushUnique(get<string[]>(K.crops, []), crop_type.trim()));
save(K.defaults, {
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs || undefined,
} as LastDefaults);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type: crop_type.trim(),
harvest_date: harvest_date || TODAY,
field_location: field_location.trim() || undefined,
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
yield_estimate_lbs: yield_estimate_lbs ? Number(yield_estimate_lbs) : undefined,
yield_unit: yield_unit === "custom" ? customYieldUnit.trim() || undefined : yield_unit,
bin_id: bin_id.trim() || undefined,
container_id: container_id.trim() || undefined,
field_block: field_block.trim() || undefined,
});
if (result.success && result.lot) {
const cached: Array<{ id: string; lot_number: string; crop_type: string; harvest_date: string; status: string }> = get(K.lots, []);
save(K.lots, [{ id: result.lot.id, lot_number: result.lot.lot_number, crop_type: crop_type.trim(), harvest_date: harvest_date, status: "active" }, ...cached].slice(0, 20));
onCreated(result.lot.id);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
// Enter on field → jump to worker; Enter on worker → jump to qty; etc.
function handleFieldKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); workerRef.current?.focus(); }
}
function handleWorkerKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); qtyRef.current?.focus(); }
}
function handleYieldKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") { e.preventDefault(); yieldUnitRef.current?.focus(); }
}
function handleYieldUnitKeyDown(e: React.KeyboardEvent<HTMLSelectElement>) {
if (e.key === "Enter") { e.preventDefault(); binRef.current?.focus(); }
}
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="absolute inset-0 bg-black/40" />
<div
className="relative ml-auto w-full max-w-md bg-white shadow-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-5">
<div className="flex items-center gap-3.5">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-green-600 shadow-sm">
<span className="text-base">🌱</span>
</div>
<div>
<h2 className="text-lg font-black text-stone-900">New Lot</h2>
<p className="text-xs text-stone-400">
{recentCrops.length > 0 ? `${recentCrops[0]} — quick reuse` : "3 fields to save"}
</p>
</div>
</div>
<button
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-xl text-stone-400 hover:bg-stone-100 hover:text-stone-600 transition-colors"
>
</button>
</div>
<form
id="qnl-form"
onSubmit={submit}
className="flex-1 overflow-y-auto px-6 py-5 space-y-5"
>
{error && (
<div className="rounded-2xl bg-red-50 border-2 border-red-200 p-4 text-sm text-red-700 font-medium">
{error}
</div>
)}
{/* Offline badge */}
<div className="flex items-center gap-2.5 rounded-2xl bg-blue-50 border border-blue-200 px-4 py-3">
<span className="text-sm">📶</span>
<p className="text-xs text-blue-600 leading-snug font-medium">
Works offline recent crops &amp; fields saved on device
</p>
</div>
{/* ── Primary: Crop ── */}
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Crop Type <span className="text-red-500">*</span>
</label>
<input
ref={cropRef}
list="recent-crops"
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); fieldRef.current?.focus(); }
}}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-2xl border-2 border-stone-200 bg-stone-50 px-5 py-5 text-2xl font-black text-stone-900 placeholder:text-stone-300 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
<datalist id="recent-crops">
{recentCrops.map((c) => <option key={c} value={c} />)}
</datalist>
</div>
{/* ── Primary: Harvest Date ── */}
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Harvest Date
</label>
<input
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
className="w-full rounded-2xl border-2 border-stone-200 bg-stone-50 px-5 py-5 text-xl font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
{/* ── Primary: Field ── */}
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Field / Location
</label>
<input
ref={fieldRef}
list="recent-fields"
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
onKeyDown={handleFieldKeyDown}
placeholder="e.g. North Field"
className="w-full rounded-2xl border-2 border-stone-200 bg-stone-50 px-5 py-5 text-xl font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
<datalist id="recent-fields">
{recentFields.map((f) => <option key={f} value={f} />)}
</datalist>
</div>
{/* ── Advanced toggle — big touch target ── */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between rounded-2xl border-2 border-dashed border-stone-300 px-5 py-4 text-sm font-bold text-stone-500 hover:border-green-400 hover:text-green-700 hover:bg-green-50 transition-all"
>
<span className="flex items-center gap-2">
<span className="text-base">{showAdvanced ? "▲" : "▼"}</span>
{showAdvanced ? "Hide optional fields" : "+ Yield, Worker, Bin, Block…"}
</span>
{showAdvanced && (
<span className="text-xs text-stone-400 font-medium">collapse</span>
)}
</button>
{showAdvanced && (
<div className="space-y-4 rounded-2xl border-2 border-stone-100 bg-stone-50 p-5">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Worker / Harvest Lead
</label>
<input
ref={workerRef}
type="text"
placeholder="Maria S."
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
onKeyDown={handleWorkerKeyDown}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-semibold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Variety
</label>
<input
ref={varietyRef}
type="text"
placeholder="Golden Bantam"
value={variety}
onChange={(e) => setVariety(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-semibold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Qty
</label>
<input
ref={qtyRef}
type="number"
placeholder="4800"
min="0"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Yield Est.
</label>
<div className="flex rounded-xl border-2 border-stone-200 bg-white overflow-hidden focus-within:border-green-600 transition-colors">
<input
ref={yieldRef}
type="number"
placeholder="5000"
min="0"
value={yield_estimate_lbs}
onChange={(e) => setYieldEstimateLbs(e.target.value)}
onKeyDown={handleYieldKeyDown}
className="flex-1 px-3 py-4 text-base font-bold text-stone-800 outline-none bg-transparent"
/>
<select
ref={yieldUnitRef}
value={yield_unit}
onChange={(e) => setYieldUnit(e.target.value)}
onKeyDown={handleYieldUnitKeyDown}
className="px-2 py-4 text-sm font-bold text-stone-600 bg-stone-50 outline-none border-l-2 border-stone-200 cursor-pointer"
>
<option value="lbs">lbs</option>
<option value="bushel">bu</option>
<option value="box">bx</option>
<option value="sack">sk</option>
<option value="crate">cr</option>
<option value="bin">bn</option>
<option value="pallet">plt</option>
<option value="custom">custom</option>
</select>
</div>
{yield_unit === "custom" && (
<input
ref={customUnitRef}
type="text"
placeholder="Unit name…"
value={customYieldUnit}
onChange={(e) => setCustomYieldUnit(e.target.value)}
className="mt-1.5 w-full rounded-xl border-2 border-stone-200 bg-white px-3 py-2 text-sm font-semibold text-stone-700 outline-none focus:border-green-600 transition-colors"
/>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Bin ID
</label>
<input
ref={binRef}
type="text"
placeholder="BIN-001"
value={bin_id}
onChange={(e) => setBinId(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Container ID
</label>
<input
ref={containerRef}
type="text"
placeholder="CONT-001"
value={container_id}
onChange={(e) => setContainerId(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase tracking-widest mb-2">
Field Block
</label>
<input
ref={blockRef}
type="text"
placeholder="e.g. A-3"
value={field_block}
onChange={(e) => setFieldBlock(e.target.value)}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-4 py-4 text-base font-bold text-stone-800 outline-none focus:border-green-600 focus:bg-white transition-colors"
/>
</div>
</div>
)}
</form>
{/* Big save button — always visible */}
<div className="border-t border-stone-100 px-6 py-5 bg-white">
<button
type="submit"
form="qnl-form"
disabled={isPending || !crop_type.trim()}
className="w-full rounded-2xl bg-green-600 py-5 text-lg font-black text-white hover:bg-green-700 active:bg-green-800 transition-colors disabled:opacity-40 flex items-center justify-center gap-3 shadow-sm"
>
{isPending ? (
<>
<span className="text-base"></span>
Saving
</>
) : (
<>
<span className="text-xl">🌱</span>
Create Lot
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,660 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import RouteTraceNav from "./RouteTraceNav";
import QRScanModal from "./QRScanModal";
import QuickNewLotDrawer from "./QuickNewLotDrawer";
import FsmaReportModal from "./FsmaReportModal";
import {
RouteTraceStats,
HaulingLot,
FieldYieldSummary,
InventoryByCrop,
RecentLotEvent,
} from "@/actions/route-trace/lots";
const STATUS_CONFIG: Record<string, { label: string; icon: string; bg: string; text: string }> = {
active_count: { label: "Active Lots", icon: "🌱", bg: "bg-green-50", text: "text-green-700" },
in_transit_count: { label: "In Transit", icon: "🚚", bg: "bg-amber-50", text: "text-amber-700" },
at_shed_count: { label: "At Shed", icon: "🏭", bg: "bg-blue-50", text: "text-blue-700" },
total_lots_today: { label: "Harvested Today", icon: "🌾", bg: "bg-stone-50", text: "text-stone-700" },
};
const EVENT_ICON_SHORT: Record<string, string> = {
harvested: "🌱",
field_packed: "📦",
bin_tagged: "🏷",
in_transit: "🚚",
at_shed: "🏭",
packed: "📋",
delivered: "✅",
marked_used: "📋",
};
const EVENT_LABEL: Record<string, string> = {
harvested: "Harvested",
field_packed: "Field Packed",
bin_tagged: "Bin Tagged",
in_transit: "In Transit",
at_shed: "Received at Shed",
packed: "Packed",
delivered: "Delivered",
marked_used: "Used in Order",
};
type LotWithAction = HaulingLot & { suggestedAction: string; suggestedIcon: string };
function buildHaulingBoard(lots: HaulingLot[]): Record<string, LotWithAction[]> {
const board: Record<string, LotWithAction[]> = {
in_transit: [],
at_shed: [],
packed: [],
};
for (const lot of lots) {
let suggestedAction = "";
let suggestedIcon = "";
if (lot.status === "active" || lot.status === "in_transit") {
suggestedAction = "Mark Loaded";
suggestedIcon = "🚚";
} else if (lot.status === "at_shed") {
suggestedAction = "Mark Received";
suggestedIcon = "🏭";
} else if (lot.status === "packed") {
suggestedAction = "Mark Delivered";
suggestedIcon = "✅";
}
const entry: LotWithAction = { ...lot, suggestedAction, suggestedIcon };
if (board[lot.status]) {
board[lot.status].push(entry);
}
}
return board;
}
// ── Activity Feed ────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function ActivityFeed({ events }: { events: RecentLotEvent[] }) {
if (events.length === 0) return null;
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden">
<div className="border-b border-stone-100 px-5 py-3.5 flex items-center justify-between bg-stone-50/40">
<div className="flex items-center gap-2">
<span className="text-sm"></span>
<h2 className="text-sm font-semibold text-stone-900">Recent Activity</h2>
</div>
<span className="text-[10px] text-stone-400 font-medium">Last 10 events</span>
</div>
<div className="divide-y divide-stone-50">
{events.map((evt) => (
<div key={evt.event_id} className="px-5 py-3.5 hover:bg-stone-50/40 transition-colors flex items-start gap-3.5">
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm ${
evt.event_type === "harvested" || evt.event_type === "field_packed" ? "bg-green-100 text-green-700" :
evt.event_type === "in_transit" || evt.event_type === "bin_tagged" ? "bg-amber-100 text-amber-700" :
evt.event_type === "at_shed" ? "bg-blue-100 text-blue-700" :
evt.event_type === "packed" ? "bg-purple-100 text-purple-700" :
"bg-stone-100 text-stone-600"
}`}>
{EVENT_ICON_SHORT[evt.event_type] ?? "📋"}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-semibold text-stone-800 leading-tight">
{EVENT_LABEL[evt.event_type] ?? evt.event_type}
</p>
<span className="text-[10px] text-stone-400 flex-shrink-0 font-medium">
{timeAgo(evt.event_time)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs font-bold text-stone-600">{evt.crop_type}</span>
<span className="text-stone-300">·</span>
<Link
href={`/admin/route-trace/lots/${evt.lot_id}`}
className="text-xs font-mono font-bold text-blue-600 hover:text-blue-800"
>
{evt.lot_number}
</Link>
{evt.location && (
<>
<span className="text-stone-300">·</span>
<span className="text-xs text-stone-400 truncate">{evt.location}</span>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
// ── Hauling Column ─────────────────────────────────────────────────────────────
function HaulingColumn({
title,
icon,
bgHeader,
lots,
emptyMessage,
}: {
title: string;
icon: string;
bgHeader: string;
lots: LotWithAction[];
emptyMessage: string;
}) {
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm overflow-hidden flex-1 min-w-[260px]">
<div className={`px-4 py-3 border-b border-stone-100 flex items-center gap-2 ${bgHeader}`}>
<span className="text-lg">{icon}</span>
<h3 className="text-sm font-semibold text-stone-900">{title}</h3>
<span className="ml-auto rounded-full bg-white/80 text-stone-600 text-xs font-bold px-2 py-0.5">
{lots.length}
</span>
</div>
{lots.length === 0 ? (
<div className="p-5 text-center text-xs text-stone-400">{emptyMessage}</div>
) : (
<div className="divide-y divide-stone-100">
{lots.map((lot) => (
<div key={lot.lot_id} className="px-4 py-3 hover:bg-stone-50/40 transition-colors">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/admin/route-trace/lots/${lot.lot_id}`}
className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600 truncate block"
>
{lot.lot_number}
</Link>
<p className="text-xs text-stone-500 mt-0.5 truncate">
{lot.crop_type} · {lot.field_location ?? "No field"}
</p>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{lot.bin_id && (
<span className="inline-flex rounded-full bg-amber-50 text-amber-700 text-[10px] font-bold px-1.5 py-0.5">
BIN {lot.bin_id}
</span>
)}
{lot.pallets && (
<span className="inline-flex text-[10px] text-stone-400 font-medium">{lot.pallets} PLT</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0">
{lot.quantity_lbs && (
<p className="text-sm font-semibold text-stone-700">
{Number(lot.quantity_lbs).toLocaleString()}
<span className="text-[10px] text-stone-400 font-normal ml-0.5">{lot.yield_unit ?? "lbs"}</span>
</p>
)}
</div>
</div>
{lot.suggestedAction && (
<div className="mt-2">
<Link
href={`/admin/route-trace/lots/${lot.lot_id}`}
className="inline-flex items-center gap-1 rounded-lg bg-stone-100 px-2.5 py-1.5 text-xs font-semibold text-stone-600 hover:bg-stone-200 transition-colors"
>
{lot.suggestedIcon} {lot.suggestedAction}
</Link>
</div>
)}
{lot.destination_stop_name && (
<p className="mt-1 text-[10px] text-stone-400"> {lot.destination_stop_name}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── Inventory by Crop ──────────────────────────────────────────────────────────
function InventoryByCropSection({ inventoryByCrop }: { inventoryByCrop: InventoryByCrop[] }) {
if (inventoryByCrop.length === 0) return null;
const byCrop: Record<string, InventoryByCrop[]> = {};
for (const row of inventoryByCrop) {
if (!byCrop[row.crop_type]) byCrop[row.crop_type] = [];
byCrop[row.crop_type].push(row);
}
const cropEntries = Object.entries(byCrop).slice(0, 12);
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="border-b border-stone-100 px-6 py-4 flex items-center justify-between bg-stone-50/40">
<div className="flex items-center gap-2">
<span>📦</span>
<h2 className="text-sm font-semibold text-stone-900">Inventory by Crop</h2>
</div>
<span className="text-[10px] text-stone-400">Harvested · In Transit · At Shed · Packed</span>
</div>
<div className="divide-y divide-stone-100">
{cropEntries.map(([crop, rows]) => {
const totalActual = rows.reduce((s, r) => s + Number(r.total_lbs ?? 0), 0);
const totalEstimate = rows.reduce((s, r) => s + Number(r.total_estimate ?? 0), 0);
const lotCount = rows.reduce((s, r) => s + Number(r.lot_count ?? 0), 0);
const variance = totalEstimate > 0 ? Math.round(((totalActual - totalEstimate) / totalEstimate) * 100) : null;
const byStatus: Record<string, number> = {};
for (const r of rows) byStatus[r.status] = Number(r.total_lbs ?? 0);
const active = byStatus["active"] ?? 0;
const inTransit = byStatus["in_transit"] ?? 0;
const atShed = byStatus["at_shed"] ?? 0;
const packed = byStatus["packed"] ?? 0;
return (
<div key={crop} className="px-6 py-4">
<div className="flex items-center justify-between mb-1">
<div>
<p className="text-sm font-bold text-stone-900">{crop}</p>
<p className="text-[10px] text-stone-400">{lotCount} lot{lotCount !== 1 ? "s" : ""}</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xl font-black text-stone-900">
{totalActual > 0 ? Number(totalActual).toLocaleString() : "—"}
</p>
<p className="text-[10px] text-stone-400">{rows[0]?.yield_unit ?? "lbs"} actual</p>
</div>
{totalEstimate > 0 && (
<div className="text-right">
<p className="text-sm font-semibold text-stone-600">
{Number(totalEstimate).toLocaleString()}
<span className="text-[10px] text-stone-400 ml-0.5">est</span>
</p>
<p className={`text-xs font-bold ${variance !== null ? (variance >= 0 ? "text-green-600" : "text-red-600") : "text-stone-400"}`}>
{variance !== null ? `${variance > 0 ? "+" : ""}${variance}%` : "—"}
</p>
</div>
)}
</div>
</div>
<div className="flex gap-1">
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{active > 0 ? Number(active).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">🌱</p>
</div>
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{inTransit > 0 ? Number(inTransit).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">🚚</p>
</div>
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{atShed > 0 ? Number(atShed).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">🏭</p>
</div>
<div className="flex-1 bg-stone-50 rounded-lg px-2 py-1.5 text-center min-w-0">
<p className="text-sm font-black text-stone-800">{packed > 0 ? Number(packed).toLocaleString() : "—"}</p>
<p className="text-[9px] text-stone-400">📦</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Field Yield Table ─────────────────────────────────────────────────────────
function FieldYieldTable({ fieldYield }: { fieldYield: FieldYieldSummary[] }) {
if (fieldYield.length === 0) return null;
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="border-b border-stone-100 px-6 py-4 flex items-center justify-between bg-stone-50/40">
<div className="flex items-center gap-2">
<span>📊</span>
<h2 className="text-sm font-semibold text-stone-900">Field Yield Summary</h2>
</div>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-stone-100">
<th className="px-6 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Field</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-stone-400 uppercase tracking-widest">Est. Yield</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-stone-400 uppercase tracking-widest">Actual</th>
<th className="px-6 py-3 text-right text-[10px] font-bold text-stone-400 uppercase tracking-widest">Lots</th>
</tr>
</thead>
<tbody>
{fieldYield.map((row, idx) => (
<tr key={idx} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/30 transition-colors">
<td className="px-6 py-3.5">
<p className="text-sm font-semibold text-stone-800">{row.field_location}</p>
{row.field_block !== "N/A" && <p className="text-[10px] text-stone-400">Block {row.field_block}</p>}
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-sm font-semibold text-stone-600">
{row.total_yield_estimate > 0 ? `${Number(row.total_yield_estimate).toLocaleString()} ${row.yield_unit ?? "lbs"}` : "—"}
</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-sm text-stone-700">
{row.total_quantity_lbs > 0 ? `${Number(row.total_quantity_lbs).toLocaleString()} ${row.yield_unit ?? "lbs"}` : "—"}
</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="inline-flex items-center rounded-full bg-green-50 text-green-700 px-2.5 py-0.5 text-xs font-bold">
{row.active_lots}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Main Dashboard ────────────────────────────────────────────────────────────
export default function RouteTraceDashboard({
stats,
recentLots,
haulingLots,
fieldYield,
inventoryByCrop,
recentActivity,
brandId,
}: {
stats: RouteTraceStats;
recentLots: Array<{ lot_number: string; crop_type: string; status: string; harvest_date: string; id: string }>;
haulingLots: HaulingLot[];
fieldYield: FieldYieldSummary[];
inventoryByCrop: InventoryByCrop[];
recentActivity: RecentLotEvent[];
brandId: string;
}) {
const [search, setSearch] = useState("");
const [showScan, setShowScan] = useState(false);
const [showQuickNew, setShowQuickNew] = useState(false);
const [searchCrop, setSearchCrop] = useState("");
const haulingBoard = buildHaulingBoard(haulingLots);
const totalHaulingLots = haulingBoard.in_transit.length + haulingBoard.at_shed.length + haulingBoard.packed.length;
const hasAnyLots = totalHaulingLots > 0;
const statEntries = [
{ key: "active_count", value: stats.active_count ?? 0 },
{ key: "in_transit_count", value: stats.in_transit_count ?? 0 },
{ key: "at_shed_count", value: stats.at_shed_count ?? 0 },
{ key: "total_lots_today", value: stats.total_lots_today ?? 0 },
] as const;
return (
<div className="space-y-5">
<RouteTraceNav activeTab="dashboard" />
{/* ── Stat cards ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{statEntries.map(({ key, value }) => {
const cfg = STATUS_CONFIG[key];
return (
<div key={key} className={`rounded-2xl ${cfg.bg} border border-transparent px-5 py-4`}>
<div className="flex items-center gap-1.5">
<span className="text-lg">{cfg.icon}</span>
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-wider">{cfg.label}</span>
</div>
<p className={`mt-1.5 text-3xl font-black ${cfg.text}`}>{value}</p>
</div>
);
})}
{/* Total lots — spans across */}
<div className="rounded-2xl bg-stone-100 border border-stone-200 px-5 py-4">
<div className="flex items-center gap-1.5">
<span className="text-lg">📋</span>
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-wider">Total Lots</span>
</div>
<p className="mt-1.5 text-3xl font-black text-stone-700">{stats.total_lots ?? 0}</p>
</div>
</div>
{/* ── Activity feed ── */}
<ActivityFeed events={recentActivity} />
{/* ── Compliance summary ── */}
{inventoryByCrop.length > 0 && (
<div className="rounded-2xl border border-blue-200 bg-blue-50/50 px-5 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-100">
<span className="text-sm">📋</span>
</div>
<div>
<p className="text-sm font-bold text-blue-900">Compliance Summary</p>
<p className="text-[10px] text-blue-600/70">This period</p>
</div>
</div>
<div className="flex items-center gap-6">
{(() => {
const totalLots = inventoryByCrop.reduce((s, r) => s + Number(r.lot_count ?? 0), 0);
const totalLbs = inventoryByCrop.reduce((s, r) => s + Number(r.total_lbs ?? 0), 0);
const cropCount = inventoryByCrop.length;
return [
{ label: "Total Lots", value: totalLots.toLocaleString() },
{ label: "Total lbs", value: totalLbs > 0 ? totalLbs.toLocaleString() : "—" },
{ label: "Crops", value: String(cropCount) },
];
})().map((m) => (
<div key={m.label} className="text-center">
<p className="text-lg font-black text-blue-800">{m.value}</p>
<p className="text-[10px] text-blue-500/70 font-bold">{m.label}</p>
</div>
))}
</div>
</div>
)}
{/* ── Inventory by crop ── */}
<InventoryByCropSection inventoryByCrop={inventoryByCrop} />
{/* ── Quick actions ── */}
<div className="flex gap-2.5 flex-wrap">
<button
onClick={() => setShowQuickNew(true)}
className="rounded-xl bg-green-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-green-700 active:bg-green-800 transition-colors flex items-center gap-2 shadow-sm"
>
🌱 Quick New Lot
</button>
<button
onClick={() => setShowScan(true)}
className="rounded-xl bg-stone-900 px-4 py-2.5 text-sm font-bold text-white hover:bg-stone-800 transition-colors flex items-center gap-2"
>
📷 Scan QR
</button>
<button
onClick={() => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
const fmt = (d: Date) => d.toISOString().split("T")[0];
window.location.href = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${fmt(start)}&endDate=${fmt(end)}&format=csv`;
}}
className="rounded-xl border border-blue-200 bg-blue-50 px-4 py-2.5 text-sm font-bold text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-2"
>
📊 Compliance Snapshot
</button>
<Link
href="/admin/route-trace/lots/new"
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
+ New Lot
</Link>
<Link
href="/admin/route-trace/lots"
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
All Lots
</Link>
<FsmaReportModal brandId={brandId} />
</div>
{/* ── Hauling board ── */}
{hasAnyLots && (
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-bold text-stone-700 uppercase tracking-wider">Hauling Board</h2>
<span className="rounded-full bg-stone-100 text-stone-600 text-[10px] font-bold px-2.5 py-0.5">
{totalHaulingLots} lots
</span>
</div>
<div className="flex items-center gap-2">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-xs">🔍</span>
<input
type="text"
placeholder="Lot, crop, field…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-3 py-1.5 rounded-lg border border-stone-200 bg-white text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 w-40"
/>
<select
value={searchCrop}
onChange={(e) => setSearchCrop(e.target.value)}
className="rounded-lg border border-stone-200 bg-white text-xs text-stone-700 px-2 py-1.5 focus:outline-none focus:border-stone-400"
>
<option value="">All Crops</option>
{[...new Set(haulingLots.map((l) => l.crop_type))].sort().map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<HaulingColumn
title="In Transit"
icon="🚚"
bgHeader="bg-amber-50"
lots={haulingBoard.in_transit.filter(
(l) =>
(!search ||
l.lot_number.toLowerCase().includes(search.toLowerCase()) ||
l.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(l.field_location ?? "").toLowerCase().includes(search.toLowerCase())) &&
(!searchCrop || l.crop_type === searchCrop)
)}
emptyMessage="No lots in transit"
/>
<HaulingColumn
title="At Shed"
icon="🏭"
bgHeader="bg-blue-50"
lots={haulingBoard.at_shed.filter(
(l) =>
(!search ||
l.lot_number.toLowerCase().includes(search.toLowerCase()) ||
l.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(l.field_location ?? "").toLowerCase().includes(search.toLowerCase())) &&
(!searchCrop || l.crop_type === searchCrop)
)}
emptyMessage="No lots at shed"
/>
<HaulingColumn
title="Packed"
icon="📦"
bgHeader="bg-purple-50"
lots={haulingBoard.packed.filter(
(l) =>
(!search ||
l.lot_number.toLowerCase().includes(search.toLowerCase()) ||
l.crop_type.toLowerCase().includes(search.toLowerCase()) ||
(l.field_location ?? "").toLowerCase().includes(search.toLowerCase())) &&
(!searchCrop || l.crop_type === searchCrop)
)}
emptyMessage="No lots packed"
/>
</div>
</div>
)}
{/* ── Field Yield Summary ── */}
<FieldYieldTable fieldYield={fieldYield} />
{/* ── Recent Lots ── */}
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="border-b border-stone-100 px-5 py-3.5 flex items-center justify-between bg-stone-50/40">
<h2 className="text-sm font-semibold text-stone-900">Recent Lots</h2>
<Link href="/admin/route-trace/lots" className="text-[11px] text-blue-600 hover:text-blue-800 font-bold">
View all
</Link>
</div>
{recentLots.length === 0 ? (
<div className="p-10 text-center">
<div className="text-3xl mb-3">🌱</div>
<p className="text-sm text-stone-500">No lots yet. Create your first lot to get started.</p>
<Link
href="/admin/route-trace/lots/new"
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
>
+ Create First Lot
</Link>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-stone-100">
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Lot #</th>
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Crop</th>
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Harvest Date</th>
<th className="px-5 py-3 text-left text-[10px] font-bold text-stone-400 uppercase tracking-widest">Status</th>
</tr>
</thead>
<tbody>
{recentLots.map((lot) => (
<tr key={lot.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
<td className="px-5 py-3.5">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-3.5 text-sm font-semibold text-stone-700">{lot.crop_type}</td>
<td className="px-5 py-3.5 text-sm text-stone-500">{lot.harvest_date}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${
lot.status === "active" ? "bg-green-50 text-green-700" :
lot.status === "in_transit" ? "bg-amber-50 text-amber-700" :
lot.status === "at_shed" ? "bg-blue-50 text-blue-700" :
lot.status === "packed" ? "bg-purple-50 text-purple-700" :
"bg-stone-50 text-stone-600"
}`}>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{lot.status.replace("_", " ")}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{showScan && <QRScanModal onClose={() => setShowScan(false)} />}
{showQuickNew && (
<QuickNewLotDrawer
brandId=""
onCreated={(lotId) => {
setShowQuickNew(false);
window.location.href = `/admin/route-trace/lots/${lotId}`;
}}
onClose={() => setShowQuickNew(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
const TABS = [
{ id: "dashboard", label: "Dashboard", href: "/admin/route-trace" },
{ id: "lots", label: "Lots", href: "/admin/route-trace/lots" },
{ id: "lookup", label: "Lookup", href: "/admin/route-trace/lookup" },
{ id: "settings", label: "Settings", href: "/admin/route-trace/settings" },
];
export default function RouteTraceNav({
activeTab,
}: {
activeTab: "dashboard" | "lots" | "lookup" | "settings";
}) {
return (
<div className="border-b border-stone-200 mb-6">
<nav className="flex gap-1 -mb-px">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={tab.href}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap rounded-t-lg ${
isActive
? "border-stone-900 text-stone-900 bg-white"
: "border-transparent text-stone-500 hover:text-stone-700 hover:border-stone-300"
}`}
>
{tab.label}
</a>
);
})}
</nav>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More