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
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
type BrandColors = {
primaryColor?: string | null;
secondaryColor?: string | null;
bgColor?: string | null;
textColor?: string | null;
};
export default function BrandStylesProvider({
primaryColor,
secondaryColor,
bgColor,
textColor,
}: BrandColors) {
useEffect(() => {
if (primaryColor || bgColor || textColor) {
const root = document.documentElement;
if (primaryColor) root.style.setProperty("--brand-primary", primaryColor);
if (secondaryColor) root.style.setProperty("--brand-secondary", secondaryColor);
if (bgColor) root.style.setProperty("--brand-bg", bgColor);
if (textColor) root.style.setProperty("--brand-text", textColor);
}
}, [primaryColor, secondaryColor, bgColor, textColor]);
return null;
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { useCart } from "@/context/CartContext";
export default function CartToast() {
const { justAdded, dismissToast } = useCart();
useEffect(() => {
if (!justAdded) return;
const timer = setTimeout(dismissToast, 4000);
return () => clearTimeout(timer);
}, [justAdded, dismissToast]);
if (!justAdded) return null;
return (
<div className="fixed bottom-6 right-6 z-50 max-w-sm rounded-2xl bg-white p-5 shadow-2xl ring-1 ring-slate-200 pointer-events-auto">
<div className="flex items-start gap-3">
<span className="mt-0.5 text-2xl"></span>
<div className="flex-1">
<p className="font-semibold text-slate-900">
{justAdded.name}
</p>
<p className="mt-0.5 text-sm text-slate-600">
{justAdded.fulfillment === "pickup" ? "📦 Pickup" : "🚚 Shipping"} ·{" "}
Qty {justAdded.quantity}
</p>
</div>
<button
onClick={dismissToast}
className="ml-2 text-slate-400 hover:text-slate-600 text-xl leading-none"
>
×
</button>
</div>
<div className="mt-4 flex gap-2">
<Link
href="/cart"
onClick={dismissToast}
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-center text-sm font-medium text-white hover:bg-slate-800"
>
View Cart
</Link>
<button
onClick={dismissToast}
className="flex-1 rounded-xl border border-slate-300 px-4 py-2 text-center text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Continue Shopping
</button>
</div>
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
type HeroSectionProps = {
eyebrow?: string;
title: string;
description: string;
heroImageUrl?: string | null;
heroVideoUrl?: string | null;
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick?: () => void;
onSecondaryClick?: () => void;
brandAccent?: "green" | "orange" | "blue";
};
export default function HeroSection({
eyebrow,
title,
description,
heroImageUrl,
heroVideoUrl,
primaryButton,
secondaryButton,
onPrimaryClick,
onSecondaryClick,
brandAccent = "blue",
}: HeroSectionProps) {
const hasHeroImage = !!heroImageUrl;
const hasHeroVideo = !!heroVideoUrl;
const isBlue = brandAccent === "blue";
const btnBg = isBlue ? "bg-blue-600 hover:bg-blue-500 active:bg-blue-700" : "bg-stone-900 hover:bg-stone-800 active:bg-stone-950";
if (hasHeroImage) {
return (
<section
className="relative flex min-h-[560px] items-center overflow-hidden py-28"
style={{
backgroundImage: `url(${heroImageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center top",
}}
>
<div className="absolute inset-0" style={{
background: isBlue
? "linear-gradient(135deg, rgba(0,119,190,0.25) 0%, rgba(0,119,190,0.08) 40%, transparent 70%), linear-gradient(to top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.12) 55%, transparent 100%)"
: "linear-gradient(135deg, rgba(0,0,0,0.20) 0%, transparent 45%), linear-gradient(to top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.15) 55%, transparent 100%)"
}} />
<div className="mx-auto w-full max-w-6xl px-6 relative z-10">
{eyebrow && (
<p className={`text-xs font-bold uppercase tracking-[0.25em] mb-6 ${isBlue ? "text-blue-200" : "text-white/60"}`}>
{eyebrow}
</p>
)}
<h1 className="text-7xl md:text-8xl font-black tracking-tight text-white leading-[1.0] max-w-3xl">
{title}
</h1>
<p className="mt-8 text-2xl text-white/70 leading-relaxed max-w-lg font-light">
{description}
</p>
{(primaryButton || secondaryButton) && (
<div className="mt-12 flex flex-wrap gap-4">
{primaryButton && (
<button
onClick={onPrimaryClick}
className={`rounded-full ${btnBg} px-10 py-4 text-sm font-bold tracking-wider text-white transition-all hover:-translate-y-0.5`}
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button
onClick={onSecondaryClick}
className="rounded-full bg-white/10 backdrop-blur-sm border border-white/25 px-10 py-4 text-sm font-bold tracking-wider text-white hover:bg-white/20 hover:border-white/40 transition-all"
>
{secondaryButton}
</button>
)}
</div>
)}
</div>
</section>
);
}
if (hasHeroVideo) {
return (
<section className="relative flex min-h-[560px] items-center overflow-hidden py-28">
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-cover"
src={heroVideoUrl}
/>
<div className="absolute inset-0" style={{
background: "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 35%, rgba(0,0,0,0.6) 72%, rgba(0,0,0,0.88) 100%)"
}} />
<div className="mx-auto w-full max-w-6xl px-6 relative z-10">
{eyebrow && (
<p className="text-xs font-bold uppercase tracking-[0.25em] mb-6 text-blue-200">
{eyebrow}
</p>
)}
<h1 className="text-7xl md:text-8xl font-black tracking-tight text-white leading-[1.0] max-w-3xl">
{title}
</h1>
<p className="mt-8 text-2xl text-white/70 leading-relaxed max-w-lg font-light">
{description}
</p>
{(primaryButton || secondaryButton) && (
<div className="mt-12 flex flex-wrap gap-4">
{primaryButton && (
<button
onClick={onPrimaryClick}
className="rounded-full bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-10 py-4 text-sm font-bold tracking-wider text-white transition-all hover:-translate-y-0.5"
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button
onClick={onSecondaryClick}
className="rounded-full bg-white/10 backdrop-blur-sm border border-white/25 px-10 py-4 text-sm font-bold tracking-wider text-white hover:bg-white/20 hover:border-white/40 transition-all"
>
{secondaryButton}
</button>
)}
</div>
)}
</div>
</section>
);
}
return (
<section className="py-28 px-6 bg-gradient-to-br from-stone-100 via-stone-50 to-stone-100">
<div className="mx-auto max-w-6xl">
{eyebrow && (
<p className={`text-xs font-bold uppercase tracking-[0.25em] mb-6 ${isBlue ? "text-blue-500" : "text-emerald-600"}`}>
{eyebrow}
</p>
)}
<h1 className="text-7xl md:text-8xl font-black tracking-tight leading-[1.0] max-w-3xl text-stone-950">
{title}
</h1>
<p className="mt-8 text-2xl leading-relaxed max-w-lg font-light text-stone-600">
{description}
</p>
{(primaryButton || secondaryButton) && (
<div className="mt-12 flex flex-wrap gap-4">
{primaryButton && (
<button
onClick={onPrimaryClick}
className={`rounded-full ${btnBg} px-10 py-4 text-sm font-bold tracking-wider text-white transition-all hover:-translate-y-0.5`}
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button
onClick={onSecondaryClick}
className="rounded-full bg-white px-10 py-4 text-sm font-bold tracking-wider text-stone-950 ring-1 ring-stone-300 hover:bg-stone-50 transition-all"
>
{secondaryButton}
</button>
)}
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,21 @@
"use client";
/**
* IndianRiverGrainOverlay
*
* Atmospheric grain texture component for Indian River Direct storefront.
* Provides the subtle film-grain texture that creates a premium, organic feel
* reminiscent of high-end agricultural photography.
*
* Uses a blue/emerald color scheme to complement the citrus brand identity.
*/
export default function IndianRiverGrainOverlay() {
return (
<div
className="fixed inset-0 pointer-events-none z-50 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useState, useMemo } from "react";
import StopCard from "./StopCard";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
brand_id: string;
};
type Props = {
stops: Stop[];
brandSlug: string;
};
const THIS_WEEK_LIMIT = 10;
const TWO_WEEKS_LIMIT = 10;
const FULL_SEASON_PAGE_SIZE = 20;
type Tab = "this_week" | "next_two_weeks" | "full_season";
function getDateRange(tab: Tab): { start: Date; end: Date } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const addDays = (d: Date, n: number) => {
const copy = new Date(d);
copy.setDate(copy.getDate() + n);
return copy;
};
if (tab === "this_week") return { start: today, end: addDays(new Date(today), 7) };
if (tab === "next_two_weeks") return { start: today, end: addDays(new Date(today), 14) };
return { start: new Date(0), end: new Date("2100-01-01") };
}
export default function IndianRiverPaginatedStops({ stops, brandSlug }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("this_week");
const [fullSeasonPage, setFullSeasonPage] = useState(0);
const [fullSeasonSearch, setFullSeasonSearch] = useState("");
const { start, end } = getDateRange(activeTab);
const filtered = useMemo(() => {
if (activeTab === "full_season") {
if (!fullSeasonSearch.trim()) return stops;
const q = fullSeasonSearch.toLowerCase();
return stops.filter(
(s) =>
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q)
);
}
return stops.filter((s) => {
const d = new Date(s.date);
return d >= start && d <= end;
});
}, [activeTab, stops, start, end, fullSeasonSearch]);
const paginatedStops = useMemo(() => {
if (activeTab === "this_week") return filtered.slice(0, THIS_WEEK_LIMIT);
if (activeTab === "next_two_weeks") return filtered.slice(0, TWO_WEEKS_LIMIT);
const offset = fullSeasonPage * FULL_SEASON_PAGE_SIZE;
return filtered.slice(offset, offset + FULL_SEASON_PAGE_SIZE);
}, [activeTab, filtered, fullSeasonPage]);
const totalCount = filtered.length;
const totalStops = stops.length;
const totalPages = Math.ceil(totalCount / FULL_SEASON_PAGE_SIZE);
const handleTabChange = (tab: Tab) => {
setActiveTab(tab);
setFullSeasonPage(0);
setFullSeasonSearch("");
};
return (
<div>
{/* Header row */}
<div className="mb-8 flex items-center justify-between">
<p className="text-sm text-stone-400 font-medium">
{totalStops} stop{totalStops !== 1 ? "s" : ""} this season
</p>
</div>
{/* Tabs */}
<div className="mb-10 flex gap-2 flex-wrap">
{(["this_week", "next_two_weeks", "full_season"] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-2xl px-5 py-3 text-sm font-semibold transition-colors ${
activeTab === tab
? "bg-blue-600 text-white"
: "bg-white text-stone-500 ring-1 ring-stone-200 hover:bg-stone-50"
}`}
>
{tab === "this_week"
? "This Week"
: tab === "next_two_weeks"
? "Next 2 Weeks"
: "Full Season"}
</button>
))}
</div>
{/* Search — full season */}
{activeTab === "full_season" && (
<div className="mb-10 flex items-center gap-4 flex-col sm:flex-row">
<input
type="text"
placeholder="Search by city, location, or state..."
value={fullSeasonSearch}
onChange={(e) => {
setFullSeasonSearch(e.target.value);
setFullSeasonPage(0);
}}
className="flex-1 rounded-2xl border border-stone-200 bg-white px-5 py-3.5 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
/>
<span className="text-sm text-stone-500 whitespace-nowrap">
{totalCount > 0
? `${paginatedStops.length} of ${totalCount} stops`
: `${totalCount} stops`}
</span>
</div>
)}
{/* Empty state */}
{filtered.length === 0 ? (
<div className="rounded-3xl bg-white p-14 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No stops found for this period.</p>
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-3">
{paginatedStops.map((stop) => (
<StopCard
key={stop.id}
{...stop}
brandSlug={brandSlug}
brandAccent="blue"
/>
))}
</div>
{/* Pagination */}
{activeTab === "full_season" && totalPages > 1 && (
<div className="mt-10 flex items-center justify-between flex-col sm:flex-row gap-6">
<span className="text-sm text-stone-500">
Page {fullSeasonPage + 1} of {totalPages}
</span>
<div className="flex gap-3">
<button
onClick={() => setFullSeasonPage((p) => Math.max(0, p - 1))}
disabled={fullSeasonPage === 0}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Previous
</button>
<button
onClick={() => setFullSeasonPage((p) => p + 1)}
disabled={fullSeasonPage >= totalPages - 1}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,180 @@
"use client";
import { useState, useMemo } from "react";
import StopCard from "./StopCard";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
brand_id: string;
};
type Props = {
stops: Stop[];
brandSlug: string;
brandAccent?: "green" | "orange" | "blue";
};
const THIS_WEEK_LIMIT = 10;
const TWO_WEEKS_LIMIT = 10;
const FULL_SEASON_PAGE_SIZE = 20;
type Tab = "this_week" | "next_two_weeks" | "full_season";
function getDateRange(tab: Tab): { start: Date; end: Date } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const addDays = (d: Date, n: number) => {
const copy = new Date(d);
copy.setDate(copy.getDate() + n);
return copy;
};
if (tab === "this_week") return { start: today, end: addDays(new Date(today), 7) };
if (tab === "next_two_weeks") return { start: today, end: addDays(new Date(today), 14) };
return { start: new Date(0), end: new Date("2100-01-01") };
}
export default function PaginatedStops({ stops, brandSlug, brandAccent = "green" }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("this_week");
const [fullSeasonPage, setFullSeasonPage] = useState(0);
const [fullSeasonSearch, setFullSeasonSearch] = useState("");
const { start, end } = getDateRange(activeTab);
const filtered = useMemo(() => {
if (activeTab === "full_season") {
if (!fullSeasonSearch.trim()) return stops;
const q = fullSeasonSearch.toLowerCase();
return stops.filter(
(s) =>
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q)
);
}
return stops.filter((s) => {
const d = new Date(s.date);
return d >= start && d <= end;
});
}, [activeTab, stops, start, end, fullSeasonSearch]);
const paginatedStops = useMemo(() => {
if (activeTab === "this_week") return filtered.slice(0, THIS_WEEK_LIMIT);
if (activeTab === "next_two_weeks") return filtered.slice(0, TWO_WEEKS_LIMIT);
const offset = fullSeasonPage * FULL_SEASON_PAGE_SIZE;
return filtered.slice(offset, offset + FULL_SEASON_PAGE_SIZE);
}, [activeTab, filtered, fullSeasonPage]);
const totalCount = filtered.length;
const totalStops = stops.length;
const totalPages = Math.ceil(totalCount / FULL_SEASON_PAGE_SIZE);
const handleTabChange = (tab: Tab) => {
setActiveTab(tab);
setFullSeasonPage(0);
setFullSeasonSearch("");
};
return (
<div>
{/* Header row */}
<div className="mb-8 flex items-center justify-between">
<p className="text-sm text-stone-400 font-medium">
{totalStops} stop{totalStops !== 1 ? "s" : ""} this season
</p>
</div>
{/* Tabs */}
<div className="mb-10 flex gap-2 flex-wrap">
{(["this_week", "next_two_weeks", "full_season"] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-2xl px-5 py-3 text-sm font-semibold transition-colors ${
activeTab === tab
? "bg-stone-950 text-white"
: "bg-white text-stone-500 ring-1 ring-stone-200 hover:bg-stone-50"
}`}
>
{tab === "this_week"
? "This Week"
: tab === "next_two_weeks"
? "Next 2 Weeks"
: "Full Season"}
</button>
))}
</div>
{/* Search — full season */}
{activeTab === "full_season" && (
<div className="mb-10 flex items-center gap-4 flex-col sm:flex-row">
<input
type="text"
placeholder="Search by city, location, or state..."
value={fullSeasonSearch}
onChange={(e) => {
setFullSeasonSearch(e.target.value);
setFullSeasonPage(0);
}}
className="flex-1 rounded-2xl border border-stone-200 bg-white px-5 py-3.5 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
/>
<span className="text-sm text-stone-500 whitespace-nowrap">
{totalCount > 0
? `${paginatedStops.length} of ${totalCount} stops`
: `${totalCount} stops`}
</span>
</div>
)}
{/* Empty state */}
{filtered.length === 0 ? (
<div className="rounded-3xl bg-white p-14 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No stops found for this period.</p>
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-3">
{paginatedStops.map((stop) => (
<StopCard
key={stop.id}
{...stop}
brandSlug={brandSlug}
brandAccent={brandAccent}
/>
))}
</div>
{/* Pagination */}
{activeTab === "full_season" && totalPages > 1 && (
<div className="mt-10 flex items-center justify-between flex-col sm:flex-row gap-6">
<span className="text-sm text-stone-500">
Page {fullSeasonPage + 1} of {totalPages}
</span>
<div className="flex gap-3">
<button
onClick={() => setFullSeasonPage((p) => Math.max(0, p - 1))}
disabled={fullSeasonPage === 0}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Previous
</button>
<button
onClick={() => setFullSeasonPage((p) => p + 1)}
disabled={fullSeasonPage >= totalPages - 1}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useCart } from "@/context/CartContext";
type ProductCardProps = {
id: string;
name: string;
description?: string | null;
price: string;
type: string;
imageUrl?: string | null;
brandSlug?: string;
brandName?: string;
brandId?: string;
brandAccent?: "green" | "orange" | "blue";
is_taxable?: boolean;
pickup_type?: "scheduled_stop" | "shed";
olatheSweetLogoUrlDark?: string | null;
badge?: "best-seller" | "new" | "limited" | "organic";
inStock?: boolean;
};
type FulfillmentChoice = "pickup" | "ship";
// Loading skeleton component
function ImageSkeleton() {
return (
<div className="absolute inset-0 bg-gradient-to-br from-stone-100 via-stone-50 to-stone-100">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-stone-200/60 to-transparent" />
</div>
);
}
// Badge component
function Badge({ type }: { type: "best-seller" | "new" | "limited" | "organic" }) {
const badgeConfig = {
"best-seller": {
label: "Best Seller",
bg: "bg-amber-50",
text: "text-amber-700",
border: "border-amber-200",
},
"new": {
label: "New",
bg: "bg-emerald-50",
text: "text-emerald-700",
border: "border-emerald-200",
},
"limited": {
label: "Limited",
bg: "bg-orange-50",
text: "text-orange-700",
border: "border-orange-200",
},
"organic": {
label: "Organic",
bg: "bg-green-50",
text: "text-green-700",
border: "border-green-200",
},
};
const config = badgeConfig[type];
return (
<span className={`rounded-full ${config.bg} border ${config.border} px-3 py-1 text-[10px] font-bold uppercase tracking-wider ${config.text}`}>
{config.label}
</span>
);
}
function FulfilmentModal({
name,
onChoice,
onCancel,
}: {
name: string;
onChoice: (choice: FulfillmentChoice) => void;
onCancel: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="w-full max-w-sm rounded-3xl bg-white p-8 shadow-2xl"
>
<h3 className="text-xl font-bold text-stone-950 tracking-tight">
How would you like to receive {name}?
</h3>
<p className="mt-2 text-sm text-stone-500">Choose your preferred fulfillment method.</p>
<div className="mt-6 space-y-3">
<button
onClick={() => onChoice("pickup")}
className="w-full rounded-2xl border border-stone-200 bg-white px-5 py-5 text-left hover:bg-stone-50 transition-colors group flex items-center justify-between"
>
<div>
<p className="font-semibold text-stone-950">Pick up at a stop</p>
<p className="mt-1 text-sm text-stone-500">Collect at a local delivery stop</p>
</div>
<svg className="h-5 w-5 text-stone-300 group-hover:text-stone-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => onChoice("ship")}
className="w-full rounded-2xl border border-stone-200 bg-white px-5 py-5 text-left hover:bg-stone-50 transition-colors group flex items-center justify-between"
>
<div>
<p className="font-semibold text-stone-950">Ship to my door</p>
<p className="mt-1 text-sm text-stone-500">Cooler boxes shipped after season ends</p>
</div>
<svg className="h-5 w-5 text-stone-300 group-hover:text-stone-500 transition-colors" 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>
<button onClick={onCancel} className="mt-5 w-full text-center text-sm text-stone-400 hover:text-stone-700 transition-colors">
Cancel
</button>
</motion.div>
</div>
);
}
export default function ProductCard({
id, name, description, price, type, imageUrl,
brandSlug, brandName, brandId,
brandAccent = "green",
is_taxable = true,
pickup_type = "scheduled_stop",
badge,
inStock = true,
}: ProductCardProps) {
const { addToCart } = useCart();
const [showChoice, setShowChoice] = useState(false);
const [added, setAdded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [buttonScale, setButtonScale] = useState(1);
const isPickupOnly = type === "Pickup";
const isShippingOnly = type === "Shipping";
const isBoth = type === "Pickup & Shipping";
function handleAddToCart() {
const baseItem = { id, name, price, brand_id: brandId ?? "", brand_slug: brandSlug ?? "", is_taxable, pickup_type, description: description ?? "" };
if (isPickupOnly || isShippingOnly) {
addToCart(baseItem, isPickupOnly ? "pickup" : "ship");
triggerAdded();
} else if (isBoth) {
setShowChoice(true);
}
}
function handleChoice(choice: FulfillmentChoice) {
setShowChoice(false);
const baseItem = { id, name, price, brand_id: brandId ?? "", brand_slug: brandSlug ?? "", is_taxable, pickup_type, description: description ?? "" };
addToCart(baseItem, choice);
triggerAdded();
}
function triggerAdded() {
setAdded(true);
setTimeout(() => setAdded(false), 2000);
}
function handleButtonPress() {
setButtonScale(0.92);
}
function handleButtonRelease() {
setButtonScale(1);
}
return (
<>
<AnimatePresence>
{showChoice && (
<FulfilmentModal name={name} onChoice={handleChoice} onCancel={() => setShowChoice(false)} />
)}
</AnimatePresence>
<motion.div
className="group relative flex flex-col bg-white rounded-3xl overflow-hidden ring-1 ring-stone-200/60 transition-shadow duration-300 hover:ring-stone-300 hover:shadow-xl hover:shadow-black/8"
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
{/* Image Container */}
<div className="relative overflow-hidden bg-stone-100" style={{ height: "17rem" }}>
{/* Loading skeleton */}
{isLoading && <ImageSkeleton />}
{imageUrl ? (
<Image
src={imageUrl}
alt={name}
fill
style={{ objectFit: "cover" }}
className={`transition-transform duration-500 ease-out group-hover:scale-105 ${isLoading ? "opacity-0" : "opacity-100"}`}
onLoad={() => setIsLoading(false)}
/>
) : (
<div className="flex w-full h-full items-center justify-center bg-gradient-to-br from-stone-100 to-stone-50">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-stone-200">
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/[0.06] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
{/* Badges */}
<div className="absolute top-4 left-4 flex flex-col gap-2">
{badge && <Badge type={badge} />}
<span className="rounded-full bg-white/95 backdrop-blur-sm px-3.5 py-1 text-[10px] font-bold text-stone-600 uppercase tracking-widest">
{type}
</span>
</div>
{/* Out of stock overlay */}
{!inStock && (
<div className="absolute inset-0 bg-stone-900/40 flex items-center justify-center">
<span className="rounded-full bg-white px-4 py-2 text-sm font-bold text-stone-700">
Out of Stock
</span>
</div>
)}
</div>
{/* Content */}
<div className="flex flex-col flex-1 p-7 pb-8">
<div className="flex-1 min-h-0">
<h3 className="text-xl font-bold text-stone-950 leading-tight tracking-tight">
{name}
</h3>
{description && (
<p className="mt-2.5 text-sm text-stone-500 leading-relaxed line-clamp-2">
{description}
</p>
)}
</div>
{/* Price + CTA */}
<div className="flex items-end justify-between mt-6 pt-5 border-t border-stone-100">
{/* Enhanced price treatment */}
<div className="relative">
<motion.div
className="bg-gradient-to-br from-stone-50 to-white rounded-2xl px-4 py-2 -ml-2"
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<p className="text-3xl font-black text-stone-950 tracking-tight leading-none">
{price}
</p>
</motion.div>
{is_taxable && (
<p className="mt-1 text-[10px] text-stone-400 uppercase tracking-wide ml-1">Tax included</p>
)}
</div>
{/* Enhanced Add to Cart button with animation */}
<motion.button
onClick={handleAddToCart}
onTapStart={handleButtonPress}
onTap={handleButtonRelease}
onTapCancel={handleButtonRelease}
disabled={!inStock}
className={`rounded-2xl px-7 py-3.5 text-sm font-bold tracking-wider transition-all duration-200 ${
!inStock
? "bg-stone-200 text-stone-400 cursor-not-allowed"
: added
? "bg-emerald-600 text-white"
: "bg-stone-900 text-white hover:bg-stone-800 active:bg-stone-950"
}`}
animate={{ scale: buttonScale }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
whileTap={{ scale: 0.95 }}
>
<AnimatePresence mode="wait">
{added ? (
<motion.span
key="added"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-1.5"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Added
</motion.span>
) : (
<motion.span
key="add"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
{inStock ? "Add to Cart" : "Unavailable"}
</motion.span>
)}
</AnimatePresence>
</motion.button>
</div>
</div>
</motion.div>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { formatDate } from "@/lib/format-date";
type StopCardProps = {
slug: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brandSlug?: string;
brandAccent?: "green" | "orange" | "blue";
};
export default function StopCard({
slug, city, state, date, time, location,
brandSlug = "tuxedo", brandAccent = "green",
}: StopCardProps) {
const ctaBg =
brandAccent === "blue"
? "bg-blue-600 hover:bg-blue-700 active:bg-blue-800"
: brandAccent === "green"
? "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800"
: "bg-orange-500 hover:bg-orange-600 active:bg-orange-700";
return (
<div className="group overflow-hidden rounded-3xl bg-white ring-1 ring-stone-200/60 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/8 hover:ring-stone-300">
<Link href={`/${brandSlug}/stops/${slug}`} className="block p-7">
{/* Location */}
<div className="flex items-start gap-4 mb-5">
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${brandAccent === "blue" ? "bg-blue-50 group-hover:bg-blue-100" : brandAccent === "green" ? "bg-emerald-50 group-hover:bg-emerald-100" : "bg-orange-50 group-hover:bg-orange-100"}`}>
<svg className={`h-5 w-5 ${brandAccent === "blue" ? "text-blue-600" : brandAccent === "green" ? "text-emerald-600" : "text-orange-500"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-2xl font-black text-stone-950 leading-tight tracking-tight">
{city}, {state}
</h3>
<p className="mt-1 text-sm text-stone-500 leading-relaxed line-clamp-1">{location}</p>
</div>
</div>
{/* Date & time */}
<div className="flex items-center gap-2 pl-[2.75rem]">
<svg className="h-4 w-4 text-stone-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<span className="text-sm font-semibold text-stone-600">
{formatDate(date)} &middot; {time}
</span>
</div>
</Link>
{/* CTA */}
<div className="px-7 pb-7 pt-1">
<Link
href={`/${brandSlug}/stops/${slug}`}
className={`block w-full rounded-2xl ${ctaBg} text-white px-5 py-3.5 text-center font-bold text-sm uppercase tracking-wider transition-colors`}
>
Shop This Stop
</Link>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import { useCart } from "@/context/CartContext";
type StopInfo = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export default function StopSetEffect({ stop }: { stop: StopInfo }) {
const { setSelectedStop } = useCart();
useEffect(() => {
setSelectedStop({ ...stop, time: stop.time });
}, [stop, setSelectedStop]);
return null;
}
@@ -0,0 +1,220 @@
import Link from "next/link";
import Image from "next/image";
export default function StorefrontFooter({
brandName,
brandSlug,
logoUrl,
logoUrlDark,
customFooterText,
contactEmail,
contactPhone,
isAdmin,
brandAccent = "green",
}: {
brandName: string;
brandSlug: string;
logoUrl?: string | null;
logoUrlDark?: string | null;
customFooterText?: string | null;
contactEmail?: string | null;
contactPhone?: string | null;
isAdmin?: boolean;
brandAccent?: "green" | "orange" | "blue";
}) {
const accentClass =
brandAccent === "orange"
? "text-orange-500 hover:text-orange-600"
: brandAccent === "blue"
? "text-blue-500 hover:text-blue-600"
: "text-emerald-600 hover:text-emerald-700";
const subscribeBtnClass =
brandAccent === "blue"
? "bg-blue-600 hover:bg-blue-700"
: brandAccent === "orange"
? "bg-orange-600 hover:bg-orange-700"
: "bg-emerald-600 hover:bg-emerald-700";
return (
<>
{/* Newsletter band */}
<div className="bg-stone-950 border-t border-stone-800">
<div className="mx-auto max-w-6xl px-6 py-14">
<div className="flex flex-col items-center justify-between gap-8 md:flex-row">
<div className="text-center md:text-left">
<p className="text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">
Stay in the loop
</p>
<h3 className="text-xl font-light text-stone-400">
Harvest updates, new stops &amp; seasonal news
</h3>
</div>
<form
className="flex w-full shrink-0 gap-0 sm:w-auto"
onSubmit={(e) => e.preventDefault()}
>
<input
type="email"
placeholder="your@email.com"
className="w-52 rounded-l-lg border border-stone-600 bg-stone-900 px-4 py-2.5 text-sm text-stone-200 placeholder-stone-500 focus:border-stone-400 focus:outline-none focus:ring-1 focus:ring-stone-400 sm:w-64"
/>
<button
type="submit"
className={`rounded-r-lg ${subscribeBtnClass} px-5 py-2.5 text-sm font-medium text-white transition-colors`}
>
Subscribe
</button>
</form>
</div>
</div>
</div>
<footer className="border-t border-stone-200 bg-stone-50">
<div className="mx-auto max-w-6xl px-6 py-16">
<div className="grid gap-12 md:grid-cols-4">
{/* Brand info + story */}
<div className="md:col-span-1">
{logoUrlDark ? (
<span className="relative inline-block h-8 w-[140px] mb-5">
<Image src={logoUrlDark} alt={brandName} fill style={{ objectFit: "contain", objectPosition: "left" }} />
</span>
) : logoUrl ? (
<span className="relative inline-block h-8 w-[140px] mb-5">
<Image src={logoUrl} alt={brandName} fill style={{ objectFit: "contain", objectPosition: "left" }} />
</span>
) : (
<p className="text-lg font-bold text-stone-950 mb-3">{brandName}</p>
)}
<p className="text-sm text-stone-500 leading-relaxed mb-5">
Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust.
</p>
{/* Social links */}
<div className="flex items-center gap-4">
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
className={`flex h-8 w-8 items-center justify-center rounded-full border border-stone-200 transition-colors ${accentClass}`}
aria-label="Instagram"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
</a>
<a
href="https://facebook.com"
target="_blank"
rel="noopener noreferrer"
className={`flex h-8 w-8 items-center justify-center rounded-full border border-stone-200 transition-colors ${accentClass}`}
aria-label="Facebook"
>
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
</a>
</div>
{contactPhone && (
<a
href={`tel:${contactPhone.replace(/\D/g, "")}`}
className="mt-5 block text-sm text-stone-500 hover:text-stone-900 transition-colors"
>
{contactPhone}
</a>
)}
{contactEmail && (
<a
href={`mailto:${contactEmail}`}
className="mt-1.5 block text-sm text-stone-500 hover:text-stone-900 transition-colors"
>
{contactEmail}
</a>
)}
</div>
{/* Navigation */}
<div>
<p className="mb-5 text-xs font-semibold uppercase tracking-widest text-stone-400">Shop</p>
<nav className="space-y-3">
<Link href={`/${brandSlug}`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Home
</Link>
<Link href={`/${brandSlug}#stops`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Upcoming Stops
</Link>
<Link href={`/${brandSlug}#products`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Products
</Link>
<Link href={`/${brandSlug}/about`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Our Story
</Link>
</nav>
</div>
{/* Help */}
<div>
<p className="mb-5 text-xs font-semibold uppercase tracking-widest text-stone-400">Help</p>
<nav className="space-y-3">
<Link href="/cart" className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Cart
</Link>
<Link href="/wholesale/login" className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Wholesale Portal
</Link>
<Link href={`/${brandSlug}/contact`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Contact Us
</Link>
<Link href={`/${brandSlug}/faq`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
FAQ
</Link>
</nav>
</div>
{/* Fresh & contact */}
<div>
<p className="mb-5 text-xs font-semibold uppercase tracking-widest text-stone-400">Fresh &amp; Local</p>
<nav className="space-y-3">
<Link href={`/${brandSlug}/about`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Our Farm
</Link>
<Link href={`/${brandSlug}#products`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
What's in Season
</Link>
<Link href={`/${brandSlug}/contact`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Visit Us
</Link>
<Link href="/wholesale/register" className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
Sell With Us
</Link>
</nav>
</div>
</div>
{customFooterText && (
<p className="mt-12 text-sm text-stone-500 leading-relaxed max-w-2xl">{customFooterText}</p>
)}
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-stone-200 pt-8 sm:flex-row">
<p className="text-sm text-stone-400">
© {new Date().getFullYear()} {brandName}. All rights reserved.
</p>
<div className="flex items-center gap-6">
{isAdmin && (
<Link href="/admin" className="text-sm text-stone-400 hover:text-stone-600 transition-colors">
Admin Dashboard
</Link>
)}
<Link href="/privacy-policy" className="text-sm text-stone-400 hover:text-stone-600 transition-colors">
Privacy Policy
</Link>
<Link href="/terms-and-conditions" className="text-sm text-stone-400 hover:text-stone-600 transition-colors">
Terms &amp; Conditions
</Link>
</div>
</div>
</div>
</footer>
</>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useCart } from "@/context/CartContext";
type StorefrontHeaderProps = {
brandName: string;
brandSlug: string;
logoUrl?: string | null;
logoUrlDark?: string | null;
showWholesaleLink?: boolean;
isAdmin?: boolean;
brandAccent?: "green" | "orange" | "blue";
};
const ACCENT_CONFIG = {
green: {
wholesaleBg: "bg-emerald-700 hover:bg-emerald-800",
wholesaleText: "text-white",
cartBg: "bg-stone-900 hover:bg-stone-800",
cartText: "text-white",
cartBadge: "bg-emerald-600",
},
orange: {
wholesaleBg: "bg-orange-500 hover:bg-orange-600",
wholesaleText: "text-white",
cartBg: "bg-stone-800 hover:bg-stone-700",
cartText: "text-white",
cartBadge: "bg-orange-500",
},
blue: {
wholesaleBg: "bg-blue-600 hover:bg-blue-500",
wholesaleText: "text-white",
cartBg: "bg-stone-800 hover:bg-stone-700",
cartText: "text-white",
cartBadge: "bg-blue-600",
},
};
export default function StorefrontHeader({
brandName,
brandSlug,
logoUrl,
logoUrlDark,
showWholesaleLink = true,
isAdmin = false,
brandAccent = "green",
}: StorefrontHeaderProps) {
const { cart } = useCart();
const [menuOpen, setMenuOpen] = useState(false);
const accent = ACCENT_CONFIG[brandAccent] ?? ACCENT_CONFIG.green;
const isBlue = brandAccent === "blue";
const isOrange = brandAccent === "orange";
const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0);
const navLinks = [
{ label: "Home", href: `/${brandSlug}` },
{ label: "Stops", href: `/${brandSlug}#stops` },
{ label: "Products", href: `/${brandSlug}#products` },
{ label: "Our Story", href: `/${brandSlug}/about` },
{ label: "FAQ", href: `/${brandSlug}/faq` },
];
const headerBg = isBlue
? "bg-white/95 border-stone-200 shadow-sm"
: isOrange
? "bg-white/95 border-stone-200 shadow-sm"
: "bg-white/95 border-stone-200 shadow-sm";
const navColor = "text-stone-600";
const navHover = "hover:text-blue-600";
return (
<header className={`sticky top-0 z-40 border-b backdrop-blur-md ${headerBg}`}>
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
{/* Brand */}
<Link href={`/${brandSlug}`} className="flex items-center gap-3 group">
{logoUrlDark ? (
<span className="relative h-10 w-[160px]">
<Image src={logoUrlDark} alt={brandName} fill className="object-contain object-left" />
</span>
) : logoUrl ? (
<span className="relative h-10 w-[160px]">
<Image src={logoUrl} alt={brandName} fill className="object-contain object-left" />
</span>
) : (
<span className="text-xl font-bold tracking-tight text-stone-800">{brandName}</span>
)}
</Link>
{/* Desktop nav */}
<nav className={`hidden items-center gap-7 text-sm font-medium ${navColor} md:flex`}>
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className={`${navHover} transition-colors duration-150`}>
{link.label}
</Link>
))}
{showWholesaleLink && (
<Link
href="/wholesale/login"
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${accent.wholesaleBg} ${accent.wholesaleText}`}
>
Wholesale
</Link>
)}
</nav>
{/* Right side */}
<div className="flex items-center gap-2">
<Link
href="/cart"
className={`relative rounded-full px-4 py-2 text-sm font-semibold transition-colors ${accent.cartBg} ${accent.cartText}`}
>
Cart
{cartCount > 0 && (
<span className={`absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full ${accent.cartBadge} text-[10px] font-bold text-white`}>
{cartCount > 9 ? "9+" : cartCount}
</span>
)}
</Link>
{/* Mobile menu button */}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-400 hover:text-stone-700 transition-colors md:hidden"
>
{menuOpen ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{/* Mobile nav */}
{menuOpen && (
<div className="border-t border-stone-100 bg-white px-6 py-6 md:hidden">
<nav className="flex flex-col gap-1 text-sm font-medium text-stone-600">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMenuOpen(false)}
className="py-2.5 transition-colors hover:text-stone-900"
>
{link.label}
</Link>
))}
{showWholesaleLink && (
<Link
href="/wholesale/login"
onClick={() => setMenuOpen(false)}
className="mt-2 py-2.5 font-semibold text-emerald-700 hover:text-emerald-800 transition-colors"
>
Wholesale Portal
</Link>
)}
</nav>
</div>
)}
</header>
);
}
@@ -0,0 +1,247 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
type TuxedoVideoHeroProps = {
eyebrow?: string;
title: string;
description: string;
olatheSweetLogoUrl?: string | null;
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick?: () => void;
onSecondaryClick?: () => void;
};
const VIDEO_URL =
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
const OLATHE_SWEET_LOGO_DARK =
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
// Staggered animation variants
const fadeUpVariants = {
hidden: { opacity: 0, y: 24 },
visible: (delay: number = 0) => ({
opacity: 1,
y: 0,
transition: {
duration: 0.8,
ease: [0.25, 0.1, 0.25, 1] as [number, number, number, number],
delay,
},
}),
};
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.15,
delayChildren: 0.3,
},
},
};
// Scroll indicator animation
const bounceVariants = {
animate: {
y: [0, 8, 0],
transition: {
duration: 1.6,
repeat: Infinity,
ease: "easeInOut" as const,
},
},
};
export default function TuxedoVideoHero({
eyebrow,
title,
description,
olatheSweetLogoUrl,
primaryButton,
secondaryButton,
onPrimaryClick,
onSecondaryClick,
}: TuxedoVideoHeroProps) {
const logoSrc = olatheSweetLogoUrl ?? OLATHE_SWEET_LOGO_DARK;
const sectionRef = useRef<HTMLElement>(null);
const [isVisible, setIsVisible] = useState(false);
// Trigger entrance animation on mount
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 100);
return () => clearTimeout(timer);
}, []);
// Smooth scroll to stops section
const handlePrimaryClick = () => {
const stopsSection = document.getElementById("stops");
if (stopsSection) {
stopsSection.scrollIntoView({ behavior: "smooth", block: "start" });
} else if (onPrimaryClick) {
onPrimaryClick();
}
};
return (
<section
ref={sectionRef}
className="relative min-h-[600px] md:min-h-[780px] flex items-center"
>
{/* Background video */}
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-[110%] object-cover"
style={{ zIndex: 0 }}
src={VIDEO_URL}
/>
{/* Gold/Warm gradient overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 1,
background: "linear-gradient(135deg, rgba(255,215,0,0.1) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
}}
/>
{/* Vignette overlay - black to yellow gradient */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 2,
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.1) 0%, rgba(0,0,0,0.5) 50%, rgba(0,0,0,0.95) 100%)",
}}
/>
{/* Dark gradient overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 3,
background: "linear-gradient(to top, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.1) 100%)",
}}
/>
{/* Content container */}
<div
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-20 md:pb-28 pt-28 md:pt-36 flex flex-col justify-end"
style={{ minHeight: "600px" }}
>
<motion.div
className="max-w-2xl"
variants={containerVariants}
initial="hidden"
animate={isVisible ? "visible" : "hidden"}
>
{/* Logo */}
<motion.div
className="mb-8 relative h-16 md:h-20 w-[280px] md:w-[350px]"
variants={fadeUpVariants}
>
<Image
src={logoSrc}
alt="Olathe Sweet"
fill
style={{ objectFit: "contain" }}
className="opacity-95"
/>
</motion.div>
{/* Eyebrow */}
{eyebrow && (
<motion.p
className="text-[11px] font-semibold uppercase tracking-[0.25em] text-emerald-400/80 mb-5"
variants={fadeUpVariants}
custom={0.1}
>
{eyebrow}
</motion.p>
)}
{/* Title */}
<motion.h1
className="text-5xl md:text-7xl font-black tracking-tight text-white leading-[1.05]"
variants={fadeUpVariants}
custom={0.25}
>
{title}
</motion.h1>
{/* Description */}
<motion.p
className="mt-5 md:mt-6 text-lg md:text-2xl text-white/60 leading-relaxed font-light max-w-lg"
variants={fadeUpVariants}
custom={0.4}
>
{description}
</motion.p>
{/* CTAs */}
<motion.div
className="mt-8 md:mt-10 flex flex-wrap items-center gap-4"
variants={fadeUpVariants}
custom={0.55}
>
{primaryButton && (
<button
onClick={handlePrimaryClick}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-8 md:px-10 py-3.5 md:py-4 text-sm font-bold tracking-widest uppercase transition-all duration-200 hover:shadow-xl hover:shadow-emerald-900/40 hover:-translate-y-0.5 active:translate-y-0"
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button
onClick={onSecondaryClick}
className="flex items-center gap-2 text-sm font-semibold text-white/70 hover:text-white transition-colors duration-200 group"
>
<span className="uppercase tracking-widest text-[11px]">{secondaryButton}</span>
<svg
className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</motion.div>
</motion.div>
{/* Animated scroll indicator */}
<motion.div
className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20"
variants={bounceVariants}
animate="animate"
>
<button
onClick={handlePrimaryClick}
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
aria-label="Scroll to stops"
>
<span className="text-[10px] uppercase tracking-[0.2em] font-medium">Scroll</span>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
</motion.div>
</div>
</section>
);
}
@@ -0,0 +1,342 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import LayoutContainer from "@/components/layout/LayoutContainer";
// ── Feature type & data ──────────────────────────────────────────────
type FeatureColor = "blue";
interface Feature {
color: FeatureColor;
label: string;
headline: string;
story: string;
accentColor: string;
accentGlow: string;
accentHover: string;
size: "tall" | "normal";
icon: React.ReactNode;
}
const FEATURES: Feature[] = [
{
color: "blue",
label: "Since 1985",
headline: "Four Decades of Florida Sunshine",
story: "Our family's been growing in the Indian River citrus district since before your parents were born. That's not marketing — it's memory.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<circle cx="24" cy="24" r="14" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 10v4M24 34v4M10 24h4M34 24h4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14.5 14.5l2.8 2.8M30.7 30.7l2.8 2.8M33.5 14.5l-2.8 2.8M17.3 30.7l-2.8 2.8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 18v12l6 4" />
</svg>
),
},
{
color: "blue",
label: "Farm Ownership",
headline: "From Our Grove to Your Table",
story: "We own Fort B Groves, so we control every step — growing, harvesting, packing, delivering.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 40h32" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 40l6-12h20l6 12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14 28v12M34 28v12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M20 28v12M28 28v12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M20 16h8v12h-8z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20l12-8 12 8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M22 10v6M26 10v6" />
</svg>
),
},
{
color: "blue",
label: "Hand-Picked",
headline: "Every Piece Hand-Selected",
story: "No machine-harvested citrus. Every orange, tangerine, and grapefruit is hand-picked at peak ripeness.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<circle cx="24" cy="22" r="12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 10c-2 0-4 1-4 3 0-2-2-3-4-3" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 10c2 0 4 1 4 3 0-2 2-3 4-3" />
<path strokeLinecap="round" strokeLinejoin="round" d="M20 22h8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16 18c3 2 6 3 8 4M32 18c-3 2-6 3-8 4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 34v8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M20 40h8" />
</svg>
),
},
{
color: "blue",
label: "Direct Delivery",
headline: "Skip the Store Shelf",
story: "Our trucks go from the grove directly to your neighborhood. Nothing sits in a warehouse.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<rect x="4" y="18" width="28" height="16" rx="2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M32 24h8l4 6v4H32" />
<circle cx="12" cy="38" r="4" />
<circle cx="36" cy="38" r="4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 18v-4a4 4 0 014-4h12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 24h32" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16 26h16M16 30h10" />
</svg>
),
},
{
color: "blue",
label: "Peak Season",
headline: "Harvested at the Perfect Moment",
story: "We pick when the sugar content is highest — not when it ships best. That's the difference.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<circle cx="24" cy="24" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 14v10l6 4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M18 12l-2-2 2-2 2 2-2 2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M30 12l2-2-2-2-2 2 2 2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M18 36l-2 2 2 2 2-2-2-2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M30 36l2 2-2 2-2-2 2-2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18l-2-2-2 2 2 2 2-2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 30l-2 2-2-2 2-2 2 2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M36 18l2-2 2 2-2 2-2-2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M36 30l2 2 2-2-2-2-2 2" />
</svg>
),
},
{
color: "blue",
label: "Regional Stops",
headline: "Your Neighborhood Pickup",
story: "From Ohio to Florida, we come to you. Find a stop near your ZIP code.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<path strokeLinecap="round" strokeLinejoin="round" d="M24 6v6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 36v6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 24h6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M36 24h6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10.1 10.1l4.2 4.2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M33.7 33.7l4.2 4.2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10.1 37.9l4.2-4.2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M33.7 14.3l4.2-4.2" />
<circle cx="24" cy="24" r="8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M24 20v4l3 2" />
</svg>
),
},
{
color: "blue",
label: "Quality First",
headline: "We Reject What Doesn't Cut It",
story: "Any fruit that doesn't meet our standard doesn't leave the grove. Period.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8l4 12H36l4-12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 8h32" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16 20h16M16 26h12M16 32h8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M20 8v-2a4 4 0 018 0v2" />
<circle cx="32" cy="36" r="8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M29 36l2 2 4-4" />
</svg>
),
},
{
color: "blue",
label: "Family Farm",
headline: "Three Generations of Care",
story: "The same family. The same land. The same commitment to excellence you can taste.",
accentColor: "bg-blue-500",
accentGlow: "hover:shadow-blue-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-blue-500 group-hover:to-blue-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
<circle cx="24" cy="14" r="6" />
<circle cx="12" cy="22" r="5" />
<circle cx="36" cy="22" r="5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 27c0 5 5 9 12 9s12-4 12-9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 36c0 4 4 8 4 8h24s4-4 4-8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14 34v-6M34 34v-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M22 36v-8M26 36v-8" />
</svg>
),
},
];
// Single feature card with scroll-triggered entrance and hover effects
function FeatureCard({ feature, index }: { feature: Feature; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: "-60px" });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 48 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 48 }}
transition={{
duration: 0.7,
delay: (index % 4) * 0.1 + Math.floor(index / 4) * 0.08,
ease: [0.22, 0.61, 0.36, 1],
}}
className={`relative flex flex-col gap-5 rounded-2xl backdrop-blur-md bg-white/70 border border-white/50 p-7 overflow-hidden group transition-all duration-500 hover:shadow-xl hover:shadow-stone-200/50 hover:-translate-y-1 ${feature.accentGlow}`}
>
{/* Animated top accent line */}
<div className="absolute top-0 left-7 right-7 h-px overflow-hidden">
<motion.div
className={`h-full ${feature.accentColor}`}
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
style={{ originX: 0 }}
/>
</div>
{/* Icon with glow on hover */}
<div className="relative w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-500 bg-blue-50 text-blue-600 group-hover:bg-blue-100 group-hover:text-blue-700 shadow-sm">
{feature.icon}
</div>
{/* Label */}
<p className="hidden sm:block text-[10px] font-bold uppercase tracking-[0.2em] text-blue-600 group-hover:text-blue-800 transition-colors duration-300">
{feature.label}
</p>
{/* Headline */}
<h3 className="text-stone-950 font-bold text-[15px] leading-snug tracking-tight">
{feature.headline}
</h3>
{/* Story */}
<p className="text-stone-500 text-[12px] leading-relaxed mt-auto">
{feature.story}
</p>
{/* Corner decoration */}
<div className="absolute bottom-0 right-0 w-24 h-24 rounded-full blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-blue-500/10" />
</motion.div>
);
}
function WhyIndianRiverDirect() {
const headerRef = useRef<HTMLDivElement>(null);
const headerInView = useInView(headerRef, { once: true, margin: "-80px" });
return (
<section className="relative backdrop-blur-xl bg-stone-50/80 py-32 overflow-hidden border-y border-white/50">
{/* Very subtle blue glow */}
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-50/60 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-80 h-80 bg-blue-50/40 rounded-full blur-3xl translate-y-1/2 -translate-x-1/4" />
<LayoutContainer>
{/* Header */}
<div ref={headerRef} className="text-center mb-20 max-w-xl mx-auto">
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={headerInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.6 }}
className="text-[11px] font-bold uppercase tracking-[0.3em] text-blue-600 mb-6"
>
Why Indian River Direct
</motion.p>
<motion.h2
initial={{ opacity: 0, y: 16 }}
animate={headerInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.7, delay: 0.1 }}
className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05] mb-6"
>
Why Choose<br />Indian River Direct
</motion.h2>
<motion.div
initial={{ scaleX: 0 }}
animate={headerInView ? { scaleX: 1 } : { scaleX: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mx-auto mt-1 mb-7 h-px w-16 bg-blue-600/60"
style={{ originX: 0 }}
/>
<motion.p
initial={{ opacity: 0 }}
animate={headerInView ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="text-stone-600 text-base leading-relaxed"
>
Florida has trusted Indian River citrus at their tables for over four decades. Here is why.
</motion.p>
</div>
{/* Asymmetric masonry grid with staggered offsets */}
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
{/* Row 1: tall, normal, tall, normal — with vertical offset stagger */}
<div>
<FeatureCard feature={FEATURES[0]} index={0} />
</div>
<div className="lg:mt-10">
<FeatureCard feature={FEATURES[1]} index={1} />
</div>
<div>
<FeatureCard feature={FEATURES[2]} index={2} />
</div>
<div className="lg:mt-10">
<FeatureCard feature={FEATURES[3]} index={3} />
</div>
{/* Row 2: normal, tall, normal, tall — offset in opposite direction */}
<div className="lg:mt-[-60px] hidden lg:block">
<FeatureCard feature={FEATURES[4]} index={4} />
</div>
<div>
<FeatureCard feature={FEATURES[5]} index={5} />
</div>
<div className="lg:mt-[-60px] hidden lg:block">
<FeatureCard feature={FEATURES[6]} index={6} />
</div>
<div>
<FeatureCard feature={FEATURES[7]} index={7} />
</div>
</div>
{/* Footer */}
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center text-[10px] font-medium uppercase tracking-[0.25em] text-stone-400 mt-16"
>
Indian River Direct &nbsp;·&nbsp; Fort B Groves, Florida
</motion.p>
</LayoutContainer>
</section>
);
}
export default WhyIndianRiverDirect;
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/format-date";
type ZipCodeSearchProps = {
allStops?: Array<{
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
}>;
brandAccent?: "green" | "orange" | "blue";
};
export default function ZipCodeSearch({ allStops = [], brandAccent = "blue" }: ZipCodeSearchProps) {
const router = useRouter();
const [zipCode, setZipCode] = useState("");
const [filteredStops, setFilteredStops] = useState<typeof allStops>([]);
const [searched, setSearched] = useState(false);
const isBlue = brandAccent === "blue";
return (
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${isBlue ? "bg-blue-50" : "bg-stone-100"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-stone-500"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold tracking-tight text-stone-950">Find Stops Near You</h2>
<p className="text-sm text-stone-500 mt-0.5">Search by ZIP code or city name</p>
</div>
</div>
{/* Search row */}
<div className="flex gap-3">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="ZIP code or city..."
value={zipCode}
onChange={(e) => setZipCode(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { if (!zipCode.trim()) return; const normalized = zipCode.trim().replace(/\D/g, ""); setFilteredStops(allStops.filter((s) => s.city.toLowerCase().includes(normalized) || s.location.toLowerCase().includes(normalized) || s.state.toLowerCase().includes(normalized.toUpperCase()))); setSearched(true); } }}
className="w-full rounded-xl border border-stone-200 pl-11 pr-4 py-3.5 text-sm text-stone-900 placeholder-stone-400 bg-white outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors shadow-sm"
/>
</div>
<button
onClick={() => {
if (!zipCode.trim()) return;
const normalized = zipCode.trim().replace(/\D/g, "");
setFilteredStops(allStops.filter((s) =>
s.city.toLowerCase().includes(normalized) ||
s.location.toLowerCase().includes(normalized) ||
s.state.toLowerCase().includes(normalized.toUpperCase())
));
setSearched(true);
}}
className="rounded-xl bg-stone-900 px-7 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-all hover:-translate-y-0.5 shadow-sm"
>
Search
</button>
</div>
{/* Results */}
{searched && (
<div className="mt-6 pt-6 border-t border-stone-100">
{filteredStops.length > 0 ? (
<div className="space-y-3">
<p className="text-sm font-semibold text-stone-700">
{filteredStops.length} stop{filteredStops.length !== 1 ? "s" : ""} found
</p>
{filteredStops.map((stop) => (
<button
key={stop.id}
onClick={() => router.push(`#stops`)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-5 py-4 text-left hover:bg-stone-100 group transition-all"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-base text-stone-950">{stop.city}, {stop.state}</p>
<p className="mt-1 text-sm text-stone-500">{formatDate(stop.date)} &middot; {stop.time} &middot; {stop.location}</p>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-stone-200/60 mt-0.5">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-stone-100 mb-4">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="font-medium text-stone-600">No stops found for that location.</p>
<p className="text-sm mt-1 text-stone-400">Try a nearby ZIP code or browse all stops below.</p>
</div>
)}
</div>
)}
</div>
);
}