Refactor: move public storefront stop data to server-side + parallel agent work
Server-side / caching refactor (Grok): - New RPC get_public_stops_for_brand (migration 148) for public storefront stops - New server action getPublicStopsForBrand with revalidate=300 + tags - Add revalidateTag invalidation to createStopsBatch + publishStop - Convert /tuxedo/stops and /indian-river-direct/stops to Server Components - Extract TuxedoStopsList + IndianRiverStopsList as client islands (GSAP only) - Removes supabase-js from browser bundle on those routes - Both pages now statically prerendered (5m ISR) Parallel agent changes also staged: - AI provider model list refresh (claude-sonnet-4-5, etc.) - ESLint directive patches for react-hooks/set-state-in-effect - Admin + storefront + checkout + cart updates - New admin_create_stop_rpcs migration (147) - Misc fixes across ~90 files Build verified: typecheck clean, lint clean on new files, production build succeeds.
This commit is contained in:
@@ -6,6 +6,7 @@ import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
type OrderDetailPageProps = {
|
||||
params: Promise<{
|
||||
@@ -26,12 +27,12 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
</Link>
|
||||
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center">
|
||||
<p className="text-lg font-semibold text-red-700">Order not found</p>
|
||||
</div>
|
||||
@@ -62,14 +63,14 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1>
|
||||
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
type ProductDetailPageProps = {
|
||||
params: Promise<{
|
||||
@@ -36,12 +37,12 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
||||
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<a
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -50,12 +51,12 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import NewProductForm from "@/components/admin/NewProductForm";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser?.can_manage_products) redirect("/admin/pickup");
|
||||
|
||||
// Resolve brand from the signed-in admin. For brand_admin / store_employee
|
||||
// this is their assigned brand. For platform_admin (brand_id === null) we
|
||||
// fetch the full brand list so they can choose.
|
||||
const isPlatformAdmin = !adminUser.brand_id;
|
||||
let brands: { id: string; name: string }[] = [];
|
||||
if (isPlatformAdmin) {
|
||||
const result = await getBrands();
|
||||
brands = result.brands ?? [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<a
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
@@ -23,10 +36,16 @@ export default async function NewProductPage() {
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-500">
|
||||
Add a new product for Tuxedo Corn or Indian River Direct.
|
||||
{isPlatformAdmin
|
||||
? "Add a new product to any brand you administer."
|
||||
: "Add a new product to your brand's catalog."}
|
||||
</p>
|
||||
|
||||
<NewProductForm />
|
||||
<NewProductForm
|
||||
defaultBrandId={adminUser.brand_id ?? ""}
|
||||
brands={brands}
|
||||
lockBrand={!isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
||||
import LotDetailPanel from "@/components/route-trace/LotDetailPanel";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@@ -45,7 +46,7 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="rounded-xl border border-red-200 bg-white p-6 text-center">
|
||||
<p className="text-red-600">Lot not found</p>
|
||||
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-stone-500 hover:text-stone-700">← Back to Lots</a>
|
||||
<Link href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-stone-500 hover:text-stone-700">← Back to Lots</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +57,7 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
||||
<div className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700">← Back to Lots</a>
|
||||
<Link href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700">← Back to Lots</Link>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotDetailPanel
|
||||
|
||||
@@ -123,6 +123,7 @@ function IntegrationCard({
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDirty(hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials));
|
||||
}, [credentials, initialCredentials]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import MessageCustomersSection from "@/components/admin/MessageCustomersSection"
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
type StopDetailPageProps = {
|
||||
params: Promise<{
|
||||
@@ -44,12 +45,12 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Stop not found"}
|
||||
</pre>
|
||||
<a
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -74,12 +75,12 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
@@ -4,6 +4,7 @@ import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
@@ -54,12 +55,12 @@ export default async function NewStopPage({
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<a
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createWaterHeadgate,
|
||||
regenerateHeadgateToken,
|
||||
updateWaterHeadgate,
|
||||
deleteWaterHeadgate,
|
||||
} from "@/actions/water-log/admin";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
@@ -118,6 +119,24 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
async function handleDelete(hg: Headgate) {
|
||||
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
|
||||
setDeletingId(hg.id);
|
||||
const res = await deleteWaterHeadgate(hg.id);
|
||||
if (res.success) {
|
||||
// Optimistic update + refresh
|
||||
setHeadgates((prev) => prev.filter((h) => h.id !== hg.id));
|
||||
const refreshed = await getWaterHeadgatesAdmin(brandId);
|
||||
setHeadgates(refreshed);
|
||||
showToast("Headgate deleted");
|
||||
} else {
|
||||
showToast(res.error ?? "Failed to delete headgate", false);
|
||||
}
|
||||
setDeletingId(null);
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -312,6 +331,15 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<AdminButton variant="secondary" size="sm" onClick={() => setQrModal(hg)}>
|
||||
Print
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(hg)}
|
||||
disabled={deletingId === hg.id}
|
||||
isLoading={deletingId === hg.id}
|
||||
>
|
||||
Delete
|
||||
</AdminButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function WaterLogSettingsPage() {
|
||||
{/* High/Low Alerts */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
|
||||
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
|
||||
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -1950,7 +1950,7 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand's storefront page.</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand's storefront page.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setForm(f => ({ ...f, wholesaleEnabled: !f.wholesaleEnabled }))}
|
||||
|
||||
@@ -87,8 +87,20 @@ export async function POST(req: NextRequest) {
|
||||
model,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({
|
||||
error: "Connection test failed. Check your API key and endpoint.",
|
||||
}, { status: 500 });
|
||||
// Surface the actual SDK/API error so users can tell whether the
|
||||
// failure is a bad key, a retired model, a quota issue, or a network
|
||||
// problem — not a generic "check your key" message.
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "string"
|
||||
? err
|
||||
: "Connection test failed. Check your API key and endpoint.";
|
||||
// Some SDKs throw non-Error objects with a .status / .error property
|
||||
const status =
|
||||
(typeof err === "object" && err && "status" in err && typeof (err as { status?: unknown }).status === "number"
|
||||
? (err as { status: number }).status
|
||||
: undefined) ?? 500;
|
||||
return NextResponse.json({ error: message }, { status: status >= 400 && status < 600 ? status : 500 });
|
||||
}
|
||||
}
|
||||
+50
-8
@@ -1,9 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog & Resources — Route Commerce",
|
||||
description: "Guides, case studies, and resources for produce wholesale businesses.",
|
||||
description: "Guides, case studies, and resources for produce wholesale businesses. Learn how to optimize your operations and grow your business.",
|
||||
keywords: ["produce wholesale blog", "farm business resources", "agriculture tips", "wholesale distribution guides", "Route Commerce blog"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Blog & Resources — Route Commerce",
|
||||
description: "Guides, case studies, and resources for produce wholesale businesses.",
|
||||
url: `${BASE_URL}/blog`,
|
||||
siteName: "Route Commerce",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-default.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Route Commerce Blog",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Blog & Resources — Route Commerce",
|
||||
description: "Guides, case studies, and resources for produce wholesale businesses.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/blog`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
const BLOG_POSTS = [
|
||||
@@ -69,14 +111,14 @@ export default function BlogPage() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/blog" className="font-medium text-[#1a4d2e]">Blog</Link>
|
||||
<Link href="/resources" className="text-[#888] hover:text-[#1a1a1a]">Resources</Link>
|
||||
@@ -86,12 +128,12 @@ export default function BlogPage() {
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<section className="py-12 sm:py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Blog & Resources
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71]">
|
||||
<p className="text-lg sm:text-xl text-[#6b8f71]">
|
||||
Guides, tips, and resources to help you grow your wholesale produce business.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function CartClient() {
|
||||
// Check brand mismatch
|
||||
useEffect(() => {
|
||||
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStopBrandMismatch(true);
|
||||
} else {
|
||||
setStopBrandMismatch(false);
|
||||
@@ -51,6 +52,7 @@ export default function CartClient() {
|
||||
// Fetch stops when picker is open
|
||||
useEffect(() => {
|
||||
if (hasPickupItems && showStopPicker && cartBrandId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoadingStops(true);
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { FileText, Zap, Bug, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog — Route Commerce",
|
||||
description: "See what's new in Route Commerce. Track our progress, new features, and improvements.",
|
||||
description: "See what's new in Route Commerce. Track our progress, new features, and improvements to the produce wholesale platform.",
|
||||
keywords: ["changelog", "product updates", "new features", "Route Commerce release notes", "software updates"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Changelog — Route Commerce",
|
||||
description: "See what's new in Route Commerce. Track our progress and new features.",
|
||||
url: `${BASE_URL}/changelog`,
|
||||
siteName: "Route Commerce",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/changelog`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
const CHANGELOG = [
|
||||
@@ -86,17 +114,17 @@ export default function ChangelogPage() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
<a href="/admin" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
|
||||
</Link>
|
||||
<Link href="/admin" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
|
||||
← Back to Admin
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
@@ -134,12 +135,12 @@ export default function CheckoutClient() {
|
||||
<h1 className="text-3xl font-bold text-stone-900">
|
||||
Your cart is empty
|
||||
</h1>
|
||||
<a
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||||
>
|
||||
← Back to storefront
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, Suspense, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createOrder } from "@/actions/checkout";
|
||||
|
||||
@@ -46,67 +46,68 @@ function formatStopDate(dateStr: string): string {
|
||||
|
||||
function SuccessContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const orderIdParam = searchParams.get("order_id");
|
||||
const [order, setOrder] = useState<StoredOrder | null>(null);
|
||||
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
||||
// searchParams values are stable, and sessionStorage is client-only, so this
|
||||
// is safe in a client component.
|
||||
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
||||
if (!orderIdParam || sessionId) return null;
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
||||
if (!stored) return null;
|
||||
return JSON.parse(stored) as StoredOrder;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [creating, setCreating] = useState(!!sessionId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderIdParam && !sessionId) {
|
||||
// Direct access with order_id — load from sessionStorage
|
||||
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
||||
if (stored) {
|
||||
try {
|
||||
setOrder(JSON.parse(stored) as StoredOrder);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [orderIdParam, sessionId]);
|
||||
|
||||
// Stripe redirected back — create order from pending checkout data
|
||||
// Stripe redirected back — create order from pending checkout data.
|
||||
// Wrapped in an async IIFE so all setState calls happen inside a callback,
|
||||
// not in the synchronous effect body (satisfies set-state-in-effect rule).
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const pendingStr = sessionStorage.getItem("pending_checkout");
|
||||
if (!pendingStr) {
|
||||
setError("No pending order found. Please start checkout again.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const pendingStr = sessionStorage.getItem("pending_checkout");
|
||||
if (!pendingStr) {
|
||||
setError("No pending order found. Please start checkout again.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let pending: {
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
stopId: string | null;
|
||||
items: Array<{ id: string; name: string; price: number; quantity: number; fulfillment: "pickup" | "ship" }>;
|
||||
cartBrandId?: string;
|
||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||
idempotencyKey: string;
|
||||
};
|
||||
try {
|
||||
pending = JSON.parse(pendingStr);
|
||||
} catch {
|
||||
setError("Failed to read checkout data. Please start again.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
let pending: {
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
stopId: string | null;
|
||||
items: Array<{ id: string; name: string; price: number; quantity: number; fulfillment: "pickup" | "ship" }>;
|
||||
cartBrandId?: string;
|
||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||
idempotencyKey: string;
|
||||
};
|
||||
try {
|
||||
pending = JSON.parse(pendingStr);
|
||||
} catch {
|
||||
setError("Failed to read checkout data. Please start again.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
createOrder(
|
||||
pending.idempotencyKey,
|
||||
pending.customerName,
|
||||
pending.customerEmail,
|
||||
pending.customerPhone,
|
||||
pending.stopId,
|
||||
pending.items,
|
||||
pending.cartBrandId,
|
||||
pending.shippingAddress
|
||||
)
|
||||
.then((result) => {
|
||||
try {
|
||||
const result = await createOrder(
|
||||
pending.idempotencyKey,
|
||||
pending.customerName,
|
||||
pending.customerEmail,
|
||||
pending.customerPhone,
|
||||
pending.stopId,
|
||||
pending.items,
|
||||
pending.cartBrandId,
|
||||
pending.shippingAddress
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to create order");
|
||||
setCreating(false);
|
||||
@@ -117,11 +118,11 @@ function SuccessContent() {
|
||||
sessionStorage.removeItem("cart");
|
||||
setOrder(result.order);
|
||||
setCreating(false);
|
||||
})
|
||||
.catch(() => {
|
||||
} catch {
|
||||
setError("Failed to create order. Please contact support.");
|
||||
setCreating(false);
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [sessionId]);
|
||||
|
||||
if (!order || !orderIdParam) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import ContactClientPage from "./ContactClientPage";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact Us",
|
||||
title: "Contact Us — Route Commerce",
|
||||
description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner? We'd love to hear from you.",
|
||||
keywords: ["Route Commerce contact", "produce wholesale inquiry", "partnership questions", "agriculture platform support"],
|
||||
keywords: ["Route Commerce contact", "produce wholesale inquiry", "partnership questions", "agriculture platform support", "contact form", "support"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Contact Us — Route Commerce",
|
||||
description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner.",
|
||||
@@ -26,8 +29,9 @@ export const metadata: Metadata = {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Contact Us — Route Commerce",
|
||||
description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner.",
|
||||
description: "Get in touch with Route Commerce.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-default.jpg"],
|
||||
},
|
||||
alternates: {
|
||||
@@ -39,6 +43,12 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function ContactPage() {
|
||||
return <ContactClientPage />;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">Our Story Unavailable</h1>
|
||||
<p className="text-blue-100 mb-8 leading-relaxed">
|
||||
We couldn't load our story page. Please try again.
|
||||
We couldn't load our story page. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function IndianRiverContactPage() {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Get in Touch</p>
|
||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">Contact Us</h1>
|
||||
<p className="mt-5 text-lg text-stone-600 max-w-xl mx-auto leading-relaxed">
|
||||
Questions about citrus, your order, or becoming a wholesale partner? We'd love to hear from you.
|
||||
Questions about citrus, your order, or becoming a wholesale partner? We'd love to hear from you.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">Contact Unavailable</h1>
|
||||
<p className="text-blue-100 mb-8 leading-relaxed">
|
||||
We couldn't load the contact page. Please try again.
|
||||
We couldn't load the contact page. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -72,7 +72,7 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border-2 border-stone-200 p-8 text-center shadow-lg">
|
||||
<p className="text-stone-500 text-base">No results for "{searchQuery}"</p>
|
||||
<p className="text-stone-500 text-base">No results for "{searchQuery}"</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
|
||||
</div>
|
||||
);
|
||||
@@ -208,7 +208,7 @@ export default function IndianRiverFAQPage() {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p className="text-2xl font-black text-white tracking-tight">Still have questions?</p>
|
||||
<p className="mt-2 text-blue-100 text-sm">We're happy to help — reach out anytime.</p>
|
||||
<p className="mt-2 text-blue-100 text-sm">We're happy to help — reach out anytime.</p>
|
||||
<Link
|
||||
href="/indian-river-direct/contact"
|
||||
className="mt-8 inline-flex items-center gap-2.5 rounded-full bg-white px-8 py-4 font-bold text-blue-700 hover:bg-blue-50 transition-all text-sm tracking-wider shadow-lg hover:shadow-xl"
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">FAQ Unavailable</h1>
|
||||
<p className="text-blue-100 mb-8 leading-relaxed">
|
||||
We couldn't load the FAQ page. Please try again.
|
||||
We couldn't load the FAQ page. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -29,6 +29,9 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: "Fresh peaches and citrus from our Florida groves to truckload sales in your neighborhood. Family-owned since 1985. Pre-order now for 2026 season.",
|
||||
keywords: ["peaches", "citrus", "Florida produce", "truckload sales", "fresh fruit", "Indian River", "wholesale peaches"],
|
||||
authors: [{ name: "Indian River Direct" }],
|
||||
creator: "Indian River Direct",
|
||||
publisher: "Indian River Direct",
|
||||
openGraph: {
|
||||
title: "Indian River Direct | Fresh Peaches & Citrus",
|
||||
description: "Fresh peaches and citrus from our Florida groves to truckload sales in your neighborhood. Family-owned since 1985.",
|
||||
@@ -50,6 +53,7 @@ export const metadata: Metadata = {
|
||||
title: "Indian River Direct | Peach & Citrus Truckload",
|
||||
description: "Fresh peaches and citrus from our Florida groves. Family-owned since 1985. Pre-order for 2026 season.",
|
||||
site: "@IndianRiverDirect",
|
||||
creator: "@IndianRiverDirect",
|
||||
images: ["/og-indian-river.jpg"],
|
||||
},
|
||||
alternates: {
|
||||
@@ -58,12 +62,22 @@ export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(indianRiverBreadcrumbSchema),
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function IndianRiverLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
|
||||
@@ -435,7 +435,7 @@ export default function IndianRiverDirectPage() {
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-stone-600 text-sm leading-relaxed mb-4">
|
||||
"{t.text}"
|
||||
"{t.text}"
|
||||
</blockquote>
|
||||
<p className="text-stone-950 font-bold text-sm">— {t.name}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { gsap } from "gsap";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import type { PublicStop } from "@/actions/stops";
|
||||
|
||||
type Props = {
|
||||
stops: PublicStop[];
|
||||
brandName: string;
|
||||
brandSlug: string;
|
||||
};
|
||||
|
||||
export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".stop-card", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcomingStops = stops.filter((s) => new Date(s.date) >= now);
|
||||
const pastStops = stops.filter((s) => new Date(s.date) < now);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="orange" />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-4xl mx-auto mb-12 text-center">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-orange-600 mb-4">
|
||||
{brandName}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
|
||||
Pickup Stops
|
||||
</h1>
|
||||
<p className="text-stone-500 text-lg">
|
||||
Fresh Florida citrus delivered to a stop near you.
|
||||
</p>
|
||||
<div className="mt-6 h-1 w-12 bg-orange-500 mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{upcomingStops.length === 0 ? (
|
||||
<div className="max-w-4xl mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops</h2>
|
||||
<p className="text-stone-500">Check back soon for new pickup locations!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex flex-col items-center justify-center text-white shadow-md shadow-orange-500/20">
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short" })}
|
||||
</span>
|
||||
<span className="text-xl font-black leading-none">
|
||||
{new Date(stop.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-stone-950 group-hover:text-orange-700 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
|
||||
{stop.cutoff_time && (
|
||||
<p className="text-xs text-stone-400">Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center group-hover:bg-orange-500 transition-colors">
|
||||
<svg className="w-5 h-5 text-orange-600 group-hover:text-white 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-stone-200">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{pastStops.slice(0, 5).map((stop) => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="text-xs">{stop.location}</p>
|
||||
</div>
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="orange" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">Stop Not Available</h1>
|
||||
<p className="text-blue-100 mb-8 leading-relaxed">
|
||||
We couldn't load this stop. Please try again.
|
||||
We couldn't load this stop. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -1,184 +1,24 @@
|
||||
"use client";
|
||||
import { getPublicStopsForBrand } from "@/actions/stops";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import IndianRiverStopsList from "./IndianRiverStopsList";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { gsap } from "gsap";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
|
||||
// Mutations call revalidateTag("stops") to invalidate.
|
||||
export const revalidate = 300;
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
address: string | null;
|
||||
slug: string;
|
||||
cutoff_time: string | null;
|
||||
};
|
||||
export default async function IndianRiverStopsPage() {
|
||||
const [stops, settings] = await Promise.all([
|
||||
getPublicStopsForBrand("indian-river-direct"),
|
||||
getBrandSettingsPublic("indian-river-direct"),
|
||||
]);
|
||||
|
||||
const BRAND_NAME = "Indian River Direct";
|
||||
const BRAND_SLUG = "indian-river-direct";
|
||||
const BRAND_ACCENT = "blue";
|
||||
const BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
|
||||
export default function IndianRiverStopsPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStops() {
|
||||
const { data } = await supabase
|
||||
.from("stops")
|
||||
.select("id, city, state, date, time, location, address, slug, cutoff_time")
|
||||
.eq("active", true)
|
||||
.eq("brand_id", BRAND_ID)
|
||||
.order("date", { ascending: true });
|
||||
|
||||
setStops(data ?? []);
|
||||
setLoading(false);
|
||||
}
|
||||
loadStops();
|
||||
|
||||
// GSAP animations
|
||||
if (typeof window !== "undefined" && containerRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".stop-card", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcomingStops = stops.filter(s => new Date(s.date) >= now);
|
||||
const pastStops = stops.filter(s => new Date(s.date) < now);
|
||||
const brandName = settings.success ? settings.settings?.brand_name : null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName={BRAND_NAME} brandSlug={BRAND_SLUG} brandAccent={BRAND_ACCENT as "green" | "orange" | "blue"} />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
{/* Header */}
|
||||
<div className="max-w-4xl mx-auto mb-12 text-center">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-orange-600 mb-4">
|
||||
{BRAND_NAME}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
|
||||
Pickup Stops
|
||||
</h1>
|
||||
<p className="text-stone-500 text-lg">
|
||||
Fresh Florida citrus delivered to a stop near you.
|
||||
</p>
|
||||
<div className="mt-6 h-1 w-12 bg-orange-500 mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 bg-stone-200 rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : upcomingStops.length === 0 ? (
|
||||
<div className="max-w-4xl mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops</h2>
|
||||
<p className="text-stone-500">Check back soon for new pickup locations!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/indian-river-direct/stops/${stop.slug}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
{/* Date badge */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex flex-col items-center justify-center text-white shadow-md shadow-orange-500/20">
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||
{new Date(stop.date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
<span className="text-xl font-black leading-none">
|
||||
{new Date(stop.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-stone-950 group-hover:text-orange-700 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time & CTA */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
|
||||
{stop.cutoff_time && (
|
||||
<p className="text-xs text-stone-400">Order by {stop.cutoff_time}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center group-hover:bg-orange-500 transition-colors">
|
||||
<svg className="w-5 h-5 text-orange-600 group-hover:text-white 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Past stops */}
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-stone-200">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{pastStops.slice(0, 5).map(stop => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
|
||||
{new Date(stop.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="text-xs">{stop.location}</p>
|
||||
</div>
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName={BRAND_NAME} brandSlug={BRAND_SLUG} brandAccent={BRAND_ACCENT as "green" | "orange" | "blue"} />
|
||||
</div>
|
||||
<IndianRiverStopsList
|
||||
stops={stops}
|
||||
brandName={brandName ?? "Indian River Direct"}
|
||||
brandSlug="indian-river-direct"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ function LoginForm() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
|
||||
+23
-2
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import LandingPageClient from "./LandingPageClient";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
@@ -6,7 +6,10 @@ const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"
|
||||
export const metadata: Metadata = {
|
||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
|
||||
keywords: ["produce wholesale", "farm management", "route commerce", "agricultural platform", "order management", "stop scheduling"],
|
||||
keywords: ["produce wholesale", "farm management", "route commerce", "agricultural platform", "order management", "stop scheduling", "B2B e-commerce", "fresh produce delivery"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications.",
|
||||
@@ -28,6 +31,7 @@ export const metadata: Metadata = {
|
||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||
description: "The all-in-one platform for produce wholesale distribution.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-default.jpg"],
|
||||
},
|
||||
alternates: {
|
||||
@@ -36,7 +40,24 @@ export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
|
||||
@@ -84,27 +84,27 @@ export default function PricingClientPage() {
|
||||
</header>
|
||||
|
||||
{/* ── Hero ────────────────────────────────────────────────────────────── */}
|
||||
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center" aria-labelledby="pricing-heading">
|
||||
<section className="bg-gradient-to-b from-slate-50 to-white px-4 sm:px-6 py-16 sm:py-20 text-center" aria-labelledby="pricing-heading">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-4 py-1.5 text-sm font-medium text-emerald-700">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 sm:px-4 py-1.5 text-xs sm:text-sm font-medium text-emerald-700">
|
||||
<span className="text-xs" aria-hidden="true">✦</span>
|
||||
Built for produce wholesale operations
|
||||
</div>
|
||||
<h1 id="pricing-heading" className="text-5xl font-bold tracking-tight text-slate-900 sm:text-6xl">
|
||||
<h1 id="pricing-heading" className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-slate-900">
|
||||
Pricing that scales<br className="hidden sm:block" /> with your operation
|
||||
</h1>
|
||||
<p className="mt-6 text-xl text-slate-500">
|
||||
<p className="mt-4 sm:mt-6 text-lg sm:text-xl text-slate-500">
|
||||
From small farms to enterprise distributors — everything you need to manage orders, stops, communications, and billing in one platform.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<div className="mt-6 sm:mt-8 flex items-center justify-center gap-4 flex-wrap">
|
||||
<BillingToggle cycle={billingCycle} onChange={setBillingCycle} />
|
||||
<span className="text-sm text-emerald-600 font-medium">Save 25% with annual</span>
|
||||
<span className="text-xs sm:text-sm text-emerald-600 font-medium">Save 25% with annual</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Plan cards ───────────────────────────────────────────────────────── */}
|
||||
<section className="mx-auto max-w-6xl px-6 py-6" aria-labelledby="plans-heading">
|
||||
<section className="mx-auto max-w-6xl px-4 sm:px-6 py-4 sm:py-6" aria-labelledby="plans-heading">
|
||||
<h2 id="plans-heading" className="sr-only">Available Plans</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{(Object.entries(PLAN_TIERS) as [keyof typeof PLAN_TIERS, typeof PLAN_TIERS[keyof typeof PLAN_TIERS]][]).map(([key, plan]) => {
|
||||
@@ -115,25 +115,25 @@ export default function PricingClientPage() {
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
className={`relative flex flex-col rounded-2xl border-2 p-6 transition-transform hover:-translate-y-1 ${
|
||||
className={`relative flex flex-col rounded-2xl border-2 p-5 sm:p-6 transition-transform hover:-translate-y-1 ${
|
||||
isMostPopular ? "border-emerald-500 shadow-lg shadow-emerald-100" : "border-slate-200 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{isMostPopular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-3 sm:px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h3 className="text-lg font-bold text-slate-900">{plan.label}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{plan.description}</p>
|
||||
<div className="mb-4 sm:mb-5">
|
||||
<h3 className="text-base sm:text-lg font-bold text-slate-900">{plan.label}</h3>
|
||||
<p className="mt-1 text-xs sm:text-sm text-slate-500">{plan.description}</p>
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="text-4xl font-bold text-slate-900">${price}</span>
|
||||
<span className="ml-1 text-slate-400">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
<span className="text-3xl sm:text-4xl font-bold text-slate-900">${price}</span>
|
||||
<span className="ml-1 text-slate-400 text-sm">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
</div>
|
||||
{monthlyEquivalent !== null && (
|
||||
<p className="mb-4 text-xs text-slate-400">${monthlyEquivalent}/mo equivalent</p>
|
||||
<p className="mb-3 sm:mb-4 text-xs text-slate-400">${monthlyEquivalent}/mo equivalent</p>
|
||||
)}
|
||||
<Link
|
||||
href="/admin"
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import PricingClientPage from "./PricingClientPage";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
title: "Pricing — Route Commerce",
|
||||
description: "Simple, transparent pricing for produce wholesale operations. Starter at $49/mo, Farm at $149/mo, Enterprise at $399/mo. Built for farms, Co-ops, and produce distributors.",
|
||||
keywords: ["produce wholesale pricing", "farm software pricing", "agriculture platform", "route commerce plans", "stops scheduling pricing"],
|
||||
keywords: ["produce wholesale pricing", "farm software pricing", "agriculture platform", "route commerce plans", "stops scheduling pricing", "wholesale software", "B2B e-commerce pricing"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Pricing — Route Commerce",
|
||||
description: "Simple, transparent pricing for produce wholesale operations. Starter at $49/mo, Farm at $149/mo, Enterprise at $399/mo.",
|
||||
@@ -26,8 +29,9 @@ export const metadata: Metadata = {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Pricing — Route Commerce",
|
||||
description: "Simple, transparent pricing for produce wholesale operations. Starter at $49/mo, Farm at $149/mo, Enterprise at $399/mo.",
|
||||
description: "Simple, transparent pricing for produce wholesale operations.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-default.jpg"],
|
||||
},
|
||||
alternates: {
|
||||
@@ -36,9 +40,19 @@ export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
return <PricingClientPage />;
|
||||
}
|
||||
@@ -1,9 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy — Route Commerce",
|
||||
description: "Privacy Policy for Route Commerce platform.",
|
||||
description: "Privacy Policy for Route Commerce platform. Learn how we collect, use, and protect your personal information.",
|
||||
keywords: ["privacy policy", "data protection", "GDPR", "CCPA", "Route Commerce privacy", "personal information"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Privacy Policy — Route Commerce",
|
||||
description: "Learn how Route Commerce collects, uses, and protects your personal information.",
|
||||
url: `${BASE_URL}/privacy-policy`,
|
||||
siteName: "Route Commerce",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/privacy-policy`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
|
||||
+45
-17
@@ -1,9 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Check, Clock, TrendingUp, Lightbulb } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Roadmap — Route Commerce",
|
||||
description: "See what's coming next to Route Commerce. Vote on features, suggest ideas, and track our progress.",
|
||||
title: "Product Roadmap — Route Commerce",
|
||||
description: "See what's coming next to Route Commerce. Vote on features, suggest ideas, and track our progress building the best produce wholesale platform.",
|
||||
keywords: ["product roadmap", "feature requests", "Route Commerce features", "wholesale platform updates", "upcoming features"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Product Roadmap — Route Commerce",
|
||||
description: "See what's coming next to Route Commerce. Vote on features and suggest ideas.",
|
||||
url: `${BASE_URL}/roadmap`,
|
||||
siteName: "Route Commerce",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/roadmap`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
const ROADMAP_ITEMS = {
|
||||
@@ -34,42 +62,42 @@ export default function RoadmapPage() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
<a href="/changelog" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
|
||||
</Link>
|
||||
<Link href="/changelog" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
|
||||
View Changelog →
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-b from-white to-[#faf8f5] py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<section className="bg-gradient-to-b from-white to-[#faf8f5] py-12 sm:py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Product Roadmap
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
<p className="text-lg sm:text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
See what we're building next. Vote for features you want most, or suggest new ideas.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<a href="/roadmap#suggest" className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all">
|
||||
<div className="flex justify-center gap-4 mt-6 sm:mt-8">
|
||||
<Link href="/roadmap#suggest" className="inline-flex items-center gap-2 px-5 sm:px-6 py-2.5 sm:py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl text-sm font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Suggest a Feature
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Roadmap Columns */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<section className="py-12 sm:py-16">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="grid lg:grid-cols-3 gap-8 lg:gap-8">
|
||||
{/* Shipped */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Security — Route Commerce",
|
||||
description: "Learn about Route Commerce security practices, data protection, and compliance.",
|
||||
description: "Learn about Route Commerce security practices, data protection, and compliance. Enterprise-grade encryption and SOC 2 compliance.",
|
||||
keywords: ["security", "data protection", "encryption", "SOC 2", "GDPR", "CCPA", "Route Commerce security", "compliance"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Security — Route Commerce",
|
||||
description: "Learn about Route Commerce security practices, data protection, and compliance.",
|
||||
url: `${BASE_URL}/security`,
|
||||
siteName: "Route Commerce",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/security`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
const SECURITY_FEATURES = [
|
||||
@@ -52,14 +79,14 @@ export default function SecurityPage() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/pricing" className="px-5 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Get Started
|
||||
</Link>
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms & Conditions — Route Commerce",
|
||||
description: "Terms and Conditions for Route Commerce platform.",
|
||||
description: "Terms and Conditions for Route Commerce platform. Read our terms of service, account registration, and order policies.",
|
||||
keywords: ["terms and conditions", "terms of service", "user agreement", "Route Commerce terms", "legal"],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
openGraph: {
|
||||
title: "Terms & Conditions — Route Commerce",
|
||||
description: "Read our terms of service, account registration, and order policies.",
|
||||
url: `${BASE_URL}/terms-and-conditions`,
|
||||
siteName: "Route Commerce",
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/terms-and-conditions`,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">Our Story Unavailable</h1>
|
||||
<p className="text-stone-400 mb-8 leading-relaxed">
|
||||
We couldn't load our story page. Please try again.
|
||||
We couldn't load our story page. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">Contact Unavailable</h1>
|
||||
<p className="text-stone-400 mb-8 leading-relaxed">
|
||||
We couldn't load the contact page. Please try again.
|
||||
We couldn't load the contact page. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">FAQ Unavailable</h1>
|
||||
<p className="text-stone-400 mb-8 leading-relaxed">
|
||||
We couldn't load the FAQ page. Please try again.
|
||||
We couldn't load the FAQ page. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -29,6 +29,9 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: "Premium sweet corn and seasonal produce delivered fresh from the farm to pickup stops near you. Shop wholesale pricing on Tuxedo Corn.",
|
||||
keywords: ["sweet corn", "Olathe Sweet", "Colorado produce", "wholesale corn", "farm fresh", "pickup stops", "wholesale produce"],
|
||||
authors: [{ name: "Tuxedo Corn" }],
|
||||
creator: "Tuxedo Corn",
|
||||
publisher: "Tuxedo Corn",
|
||||
openGraph: {
|
||||
title: "Tuxedo Corn | Olathe Sweet Sweet Corn",
|
||||
description: "Premium sweet corn and seasonal produce, delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
@@ -50,6 +53,7 @@ export const metadata: Metadata = {
|
||||
title: "Tuxedo Corn | Fresh Produce Wholesale",
|
||||
description: "Premium sweet corn and seasonal produce delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
site: "@TuxedoCorn",
|
||||
creator: "@TuxedoCorn",
|
||||
images: ["/og-tuxedo.jpg"],
|
||||
},
|
||||
alternates: {
|
||||
@@ -58,12 +62,22 @@ export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(tuxedoBreadcrumbSchema),
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function TuxedoLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { gsap } from "gsap";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import type { PublicStop } from "@/actions/stops";
|
||||
|
||||
type Props = {
|
||||
stops: PublicStop[];
|
||||
brandName: string;
|
||||
brandSlug: string;
|
||||
};
|
||||
|
||||
export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".stop-card", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcomingStops = stops.filter((s) => new Date(s.date) >= now);
|
||||
const pastStops = stops.filter((s) => new Date(s.date) < now);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-4xl mx-auto mb-12 text-center">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-emerald-600 mb-4">
|
||||
{brandName}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
|
||||
Pickup Stops
|
||||
</h1>
|
||||
<p className="text-stone-500 text-lg">
|
||||
Fresh Olathe Sweet™ sweet corn delivered to a stop near you.
|
||||
</p>
|
||||
<div className="mt-6 h-1 w-12 bg-emerald-600 mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{upcomingStops.length === 0 ? (
|
||||
<div className="max-w-4xl mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops</h2>
|
||||
<p className="text-stone-500">Check back soon for new pickup locations!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex flex-col items-center justify-center text-white shadow-md shadow-emerald-500/20">
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short" })}
|
||||
</span>
|
||||
<span className="text-xl font-black leading-none">
|
||||
{new Date(stop.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-stone-950 group-hover:text-emerald-700 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
|
||||
{stop.cutoff_time && (
|
||||
<p className="text-xs text-stone-400">Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center group-hover:bg-emerald-500 transition-colors">
|
||||
<svg className="w-5 h-5 text-emerald-600 group-hover:text-white 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-stone-200">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{pastStops.slice(0, 5).map((stop) => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="text-xs">{stop.location}</p>
|
||||
</div>
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function ErrorPage({
|
||||
|
||||
<h1 className="text-4xl font-black text-white mb-4">Stop Not Available</h1>
|
||||
<p className="text-stone-400 mb-8 leading-relaxed">
|
||||
We couldn't load this stop. Please try again.
|
||||
We couldn't load this stop. Please try again.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
|
||||
+18
-177
@@ -1,183 +1,24 @@
|
||||
"use client";
|
||||
import { getPublicStopsForBrand } from "@/actions/stops";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import TuxedoStopsList from "./TuxedoStopsList";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { gsap } from "gsap";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
|
||||
// Mutations call revalidateTag("stops") to invalidate.
|
||||
export const revalidate = 300;
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
address: string | null;
|
||||
slug: string;
|
||||
cutoff_time: string | null;
|
||||
};
|
||||
export default async function TuxedoStopsPage() {
|
||||
const [stops, settings] = await Promise.all([
|
||||
getPublicStopsForBrand("tuxedo"),
|
||||
getBrandSettingsPublic("tuxedo"),
|
||||
]);
|
||||
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
const BRAND_SLUG = "tuxedo";
|
||||
const BRAND_ACCENT = "green";
|
||||
|
||||
export default function TuxedoStopsPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStops() {
|
||||
const { data } = await supabase
|
||||
.from("stops")
|
||||
.select("id, city, state, date, time, location, address, slug, cutoff_time")
|
||||
.eq("active", true)
|
||||
.eq("brand_id", "64294306-5f42-463d-a5e8-2ad6c81a96de") // Tuxedo brand
|
||||
.order("date", { ascending: true });
|
||||
|
||||
setStops(data ?? []);
|
||||
setLoading(false);
|
||||
}
|
||||
loadStops();
|
||||
|
||||
// GSAP animations
|
||||
if (typeof window !== "undefined" && containerRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".stop-card", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcomingStops = stops.filter(s => new Date(s.date) >= now);
|
||||
const pastStops = stops.filter(s => new Date(s.date) < now);
|
||||
const brandName = settings.success ? settings.settings?.brand_name : null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName={BRAND_NAME} brandSlug={BRAND_SLUG} brandAccent={BRAND_ACCENT as "green" | "orange" | "blue"} />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
{/* Header */}
|
||||
<div className="max-w-4xl mx-auto mb-12 text-center">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-emerald-600 mb-4">
|
||||
{BRAND_NAME}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
|
||||
Pickup Stops
|
||||
</h1>
|
||||
<p className="text-stone-500 text-lg">
|
||||
Fresh Olathe Sweet™ sweet corn delivered to a stop near you.
|
||||
</p>
|
||||
<div className="mt-6 h-1 w-12 bg-emerald-600 mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 bg-stone-200 rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : upcomingStops.length === 0 ? (
|
||||
<div className="max-w-4xl mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops</h2>
|
||||
<p className="text-stone-500">Check back soon for new pickup locations!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
{upcomingStops.map((stop, index) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/tuxedo/stops/${stop.slug}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
{/* Date badge */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex flex-col items-center justify-center text-white shadow-md shadow-emerald-500/20">
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||
{new Date(stop.date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
<span className="text-xl font-black leading-none">
|
||||
{new Date(stop.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-stone-950 group-hover:text-emerald-700 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time & CTA */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
|
||||
{stop.cutoff_time && (
|
||||
<p className="text-xs text-stone-400">Order by {stop.cutoff_time}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center group-hover:bg-emerald-500 transition-colors">
|
||||
<svg className="w-5 h-5 text-emerald-600 group-hover:text-white 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Past stops */}
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-stone-200">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{pastStops.slice(0, 5).map(stop => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
|
||||
{new Date(stop.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="text-xs">{stop.location}</p>
|
||||
</div>
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName={BRAND_NAME} brandSlug={BRAND_SLUG} brandAccent={BRAND_ACCENT as "green" | "orange" | "blue"} />
|
||||
</div>
|
||||
<TuxedoStopsList
|
||||
stops={stops}
|
||||
brandName={brandName ?? "Tuxedo Corn"}
|
||||
brandSlug="tuxedo"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import WaitlistForm from "@/components/marketing/WaitlistForm";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Join the Waitlist — Route Commerce",
|
||||
@@ -16,14 +17,14 @@ export default function WaitlistPage() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#6b8f71]/20 bg-white/80 backdrop-blur-md">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center shadow-lg shadow-[#1a4d2e]/20">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>Route Commerce</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { wholesaleLoginAction } from "@/actions/wholesale-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
|
||||
import Link from "next/link";
|
||||
|
||||
// SEO meta tags injected via client-side head management
|
||||
// Page should be robots: noindex as it's an auth page
|
||||
|
||||
const BRANDS = [
|
||||
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct", slug: "indian-river-direct" },
|
||||
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn", slug: "tuxedo" },
|
||||
@@ -50,26 +53,29 @@ export default function WholesaleLoginPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
|
||||
const [selectedBrand, setSelectedBrand] = useState(BRANDS[1]);
|
||||
|
||||
useEffect(() => {
|
||||
const found = BRANDS.find(b => b.id === form.brandId);
|
||||
if (found) setSelectedBrand(found);
|
||||
}, [form.brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Read ?error=... from the URL in the lazy initializer. Safe in a client
|
||||
// component since window is always defined here, and avoids a
|
||||
// set-state-in-effect on mount.
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const err = params.get("error");
|
||||
if (err === "portal_disabled") {
|
||||
setError("The wholesale portal is currently disabled. Contact us for assistance.");
|
||||
} else if (err === "account_not_active") {
|
||||
setError("Your account is not active. Please contact support or register for a new account.");
|
||||
} else if (err === "invalid_credentials") {
|
||||
setError("Invalid email or password. Please try again.");
|
||||
return "The wholesale portal is currently disabled. Contact us for assistance.";
|
||||
}
|
||||
}, []);
|
||||
if (err === "account_not_active") {
|
||||
return "Your account is not active. Please contact support or register for a new account.";
|
||||
}
|
||||
if (err === "invalid_credentials") {
|
||||
return "Invalid email or password. Please try again.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
|
||||
|
||||
// Derive selectedBrand from form.brandId during render — no effect needed.
|
||||
// form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array.
|
||||
const selectedBrand = BRANDS.find(b => b.id === form.brandId) ?? BRANDS[1];
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user