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:
2026-06-03 02:04:21 +00:00
parent 57da01c786
commit 1fe5ffee8d
95 changed files with 1470 additions and 733 deletions
+5 -4
View File
@@ -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>
+5 -4
View File
@@ -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">
+23 -4
View File
@@ -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>
+3 -2
View File
@@ -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 -4
View File
@@ -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">
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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&apos;s thresholds.</p>
<div className="flex items-center justify-between">
<div>
+1 -1
View File
@@ -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&apos;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
View File
@@ -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>
+2
View File
@@ -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`,
+34 -6
View File
@@ -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&apos;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>
+3 -2
View File
@@ -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>
+56 -55
View File
@@ -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) {
+14 -4
View File
@@ -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 />;
}
+1 -1
View File
@@ -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&apos;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&apos;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&apos;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 &quot;{searchQuery}&quot;</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&apos;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"
+1 -1
View File
@@ -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&apos;t load the FAQ page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
+15 -1
View File
@@ -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">
+1 -1
View File
@@ -435,7 +435,7 @@ export default function IndianRiverDirectPage() {
))}
</div>
<blockquote className="text-stone-600 text-sm leading-relaxed mb-4">
"{t.text}"
&quot;{t.text}&quot;
</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&apos;t load this stop. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
+18 -178
View File
@@ -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"
/>
);
}
}
+1
View File
@@ -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
View File
@@ -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() {
+15 -15
View File
@@ -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"
+18 -4
View File
@@ -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 />;
}
+29 -2
View File
@@ -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
View File
@@ -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&apos;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&apos;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">
+31 -4
View File
@@ -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>
+29 -2
View File
@@ -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() {
+1 -1
View File
@@ -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&apos;t load our story page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
+1 -1
View File
@@ -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&apos;t load the contact page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
+1 -1
View File
@@ -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&apos;t load the FAQ page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
+15 -1
View File
@@ -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">
+144
View File
@@ -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&trade; 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>
);
}
+1 -1
View File
@@ -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&apos;t load this stop. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
+18 -177
View File
@@ -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&trade; 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"
/>
);
}
}
+3 -2
View File
@@ -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
View File
@@ -1,4 +1,5 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
+23 -17
View File
@@ -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();