Initial commit - Route Commerce platform
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)} · {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 & 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 & 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 & 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 · Fort B Groves, Florida
|
||||
</motion.p>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default WhyIndianRiverDirect;
|
||||
@@ -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)} · {stop.time} · {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user