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:
@@ -119,7 +119,14 @@ async function callAIAnalysis(
|
||||
rows: string[][],
|
||||
brandId: string
|
||||
): Promise<ImportAnalysis> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
// Prefer MiniMax (env-level) — the team is using it pre-launch. Fall back to OpenAI.
|
||||
const provider: "minimax" | "openai" =
|
||||
process.env.MINIMAX_API_KEY ? "minimax" : process.env.OPENAI_API_KEY ? "openai" : "openai";
|
||||
const apiKey = provider === "minimax" ? process.env.MINIMAX_API_KEY! : process.env.OPENAI_API_KEY!;
|
||||
const baseURL = provider === "minimax"
|
||||
? (process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1")
|
||||
: "https://api.openai.com/v1";
|
||||
const model = provider === "minimax" ? "MiniMax-M3" : "gpt-4o-mini";
|
||||
|
||||
// Build sample rows (first 30 for token economy)
|
||||
const sampleRows = rows.slice(0, 30);
|
||||
@@ -174,21 +181,23 @@ Rules:
|
||||
- Do NOT add __entity_type to cleanedRows — only in columnMappings output`;
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
// Note: response_format is OpenAI-specific. MiniMax /v1/chat/completions supports
|
||||
// JSON-style prompting but may not honor `json_object`. We omit it for MiniMax.
|
||||
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`${provider} error: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
const parsed = JSON.parse(data.choices[0].message.content as string);
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
export type AIProvider = AIProviderType;
|
||||
|
||||
export type AIProviderSettings = {
|
||||
provider: AIProvider;
|
||||
@@ -25,6 +26,10 @@ export type CustomIntegration = {
|
||||
testEndpoint?: string;
|
||||
};
|
||||
|
||||
// MiniMax (MiniMax) is OpenAI-compatible. Default to the global endpoint; allow
|
||||
// override via MINIMAX_BASE_URL env var (e.g. https://api.minimaxi.com/v1 for CN).
|
||||
const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
|
||||
|
||||
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
||||
@@ -111,9 +116,26 @@ export async function getAIClient(brandId: string): Promise<{
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
|
||||
if (!settings.apiKey) {
|
||||
// Fall back to env var
|
||||
const envKey = process.env.OPENAI_API_KEY;
|
||||
if (!envKey) return { provider: "openai", model: "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||
// Fall back to env var. Check the saved provider first, then universal env keys.
|
||||
const envKey =
|
||||
process.env.MINIMAX_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.XAI_API_KEY ||
|
||||
process.env.GOOGLE_API_KEY;
|
||||
if (!envKey) {
|
||||
return { provider: settings.provider || "openai", model: DEFAULT_MODELS[settings.provider as Exclude<AIProvider, "custom">] ?? "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||
}
|
||||
// If the saved provider is minimax, use MiniMax even from env
|
||||
if (settings.provider === "minimax" || process.env.MINIMAX_API_KEY) {
|
||||
const { OpenAI } = await import("openai");
|
||||
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
|
||||
return {
|
||||
provider: "minimax",
|
||||
model: settings.model || DEFAULT_MODELS.minimax,
|
||||
client: new OpenAI({ apiKey: process.env.MINIMAX_API_KEY ?? envKey, baseURL }),
|
||||
};
|
||||
}
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "openai",
|
||||
@@ -147,6 +169,16 @@ export async function getAIClient(brandId: string): Promise<{
|
||||
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
|
||||
};
|
||||
}
|
||||
case "minimax": {
|
||||
// MiniMax (MiniMax) is OpenAI-compatible — swap baseURL
|
||||
const { OpenAI } = await import("openai");
|
||||
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
|
||||
return {
|
||||
provider: "minimax",
|
||||
model: settings.model || DEFAULT_MODELS.minimax,
|
||||
client: new OpenAI({ apiKey: settings.apiKey, baseURL }),
|
||||
};
|
||||
}
|
||||
case "custom": {
|
||||
if (!settings.customEndpoint) {
|
||||
// @ts-expect-error - intentionally returning extra property for error signaling
|
||||
|
||||
+73
-19
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
@@ -30,29 +31,27 @@ export async function createStopsBatch(
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
|
||||
// bypassed. This fixes the prior 42501 RLS violation that came from doing
|
||||
// direct REST inserts against the RLS-blocked `stops` table.
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const rows = stops.map((s) => {
|
||||
const slug = `${s.city.toLowerCase().replace(/\s+/g, "-")}-${s.date || new Date().toISOString().slice(0, 10)}`;
|
||||
return {
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: s.date || "",
|
||||
time: s.time || "",
|
||||
address: s.address || null,
|
||||
zip: s.zip || null,
|
||||
brand_id: effectiveBrandId,
|
||||
slug,
|
||||
status: "draft",
|
||||
active: false,
|
||||
};
|
||||
});
|
||||
const rows = stops.map((s) => ({
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: s.date || "",
|
||||
time: s.time || "",
|
||||
address: s.address || null,
|
||||
zip: s.zip || null,
|
||||
cutoff_time: null,
|
||||
active: false,
|
||||
}));
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify(rows),
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -60,6 +59,9 @@ export async function createStopsBatch(
|
||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
||||
|
||||
const inserted = await res.json();
|
||||
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
|
||||
}
|
||||
@@ -86,6 +88,9 @@ export async function publishStop(
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
||||
}
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -116,4 +121,53 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? stops : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active stops for a brand by slug.
|
||||
* Public action used by the storefront stop pages (e.g. /tuxedo/stops,
|
||||
* /indian-river-direct/stops) — replaces the previous client-side
|
||||
* `supabase.from("stops")` query so supabase-js no longer ships to
|
||||
* the browser for those routes.
|
||||
*
|
||||
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
|
||||
* from any stop mutation (see createStopsBatch, publishStop, etc.).
|
||||
*/
|
||||
export type PublicStop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
address: string | null;
|
||||
slug: string;
|
||||
cutoff_time: string | null;
|
||||
};
|
||||
|
||||
export async function getPublicStopsForBrand(
|
||||
brandSlug: string
|
||||
): Promise<PublicStop[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: {
|
||||
revalidate: 300,
|
||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const stops = await response.json();
|
||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||
}
|
||||
@@ -38,26 +38,25 @@ export async function createStop(
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
// Use anon key — the RPC is SECURITY DEFINER so it bypasses RLS regardless
|
||||
// of caller. This also means a missing SUPABASE_SERVICE_ROLE_KEY in
|
||||
// production no longer breaks stop creation.
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date || new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
location: data.location,
|
||||
date: data.date,
|
||||
time: data.time,
|
||||
slug,
|
||||
brand_id: brandId,
|
||||
active: data.active ?? false,
|
||||
address: data.address ?? null,
|
||||
zip: data.zip ?? null,
|
||||
cutoff_time: data.cutoff_time ?? null,
|
||||
status: "draft",
|
||||
p_brand_id: brandId,
|
||||
p_city: data.city,
|
||||
p_state: data.state,
|
||||
p_location: data.location,
|
||||
p_date: data.date,
|
||||
p_time: data.time,
|
||||
p_address: data.address ?? null,
|
||||
p_zip: data.zip ?? null,
|
||||
p_cutoff_time: data.cutoff_time ?? null,
|
||||
p_active: data.active ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -67,5 +66,5 @@ export async function createStop(
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
return { success: true, id: inserted[0]?.id ?? "" };
|
||||
return { success: true, id: inserted?.id ?? "" };
|
||||
}
|
||||
|
||||
@@ -268,11 +268,35 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete headgate" };
|
||||
// The RPC returns JSONB on success: { success: true } or { success: false, error: "..." }.
|
||||
// On failure (HTTP non-2xx) Supabase returns { message: "...", code: "...", details: ... }.
|
||||
// We try to extract the most useful message in both cases.
|
||||
let data: { success?: boolean; error?: string; message?: string } | null = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
// Non-JSON body — leave data as null, fall through to default error
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
if (response.ok && data?.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Prefer the RPC's own error if it set one
|
||||
const errorMessage =
|
||||
data?.error ??
|
||||
data?.message ??
|
||||
(response.ok ? "Unknown error" : `HTTP ${response.status}: ${response.statusText || "request failed"}`);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error("[deleteWaterHeadgate] failed", {
|
||||
headgateId,
|
||||
status: response.status,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
// ── Entries ────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { CartProvider } from "@/context/CartContext";
|
||||
|
||||
@@ -20,10 +20,10 @@ export default function IndianRiverMissionSection() {
|
||||
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 mb-8 leading-tight">From Farm to Family</h2>
|
||||
<div className="space-y-6 text-stone-700 leading-relaxed text-lg">
|
||||
<p>
|
||||
At Indian River Direct, quality and freshness are more than just goals — they're the foundation of everything we do. We're not just a company that delivers exceptional fruit; we're also the farmers behind the scenes. By acquiring Fort B Groves, the owner of Indian River Direct created a direct and reliable source of premium citrus for our customers.
|
||||
At Indian River Direct, quality and freshness are more than just goals — they're the foundation of everything we do. We're not just a company that delivers exceptional fruit; we're also the farmers behind the scenes. By acquiring Fort B Groves, the owner of Indian River Direct created a direct and reliable source of premium citrus for our customers.
|
||||
</p>
|
||||
<p>
|
||||
Owning and managing our own grove allows us to oversee every step of the process — from responsible farming practices and careful harvesting to strict quality control — ensuring each fruit box meets our highest standards. With direct access to the farm, we're able to reduce dependence on outside growers and deliver fruit that's consistently fresh, flavorful, and farm-to-table fast.
|
||||
Owning and managing our own grove allows us to oversee every step of the process — from responsible farming practices and careful harvesting to strict quality control — ensuring each fruit box meets our highest standards. With direct access to the farm, we're able to reduce dependence on outside growers and deliver fruit that's consistently fresh, flavorful, and farm-to-table fast.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -55,7 +55,7 @@ export default function IndianRiverMissionSection() {
|
||||
Our journey began with a simple passion: growing the finest citrus fruits and delivering them straight from our groves to your table with care, speed, and integrity.
|
||||
</p>
|
||||
<p>
|
||||
We believe in minimizing the time between harvest and delivery, ensuring that our citrus is hand-picked at peak ripeness and arrives to you bursting with flavor and nutrition. This direct-to-you approach means you enjoy fruit that's fresher, juicier, and more vibrant than anything sitting on store shelves.
|
||||
We believe in minimizing the time between harvest and delivery, ensuring that our citrus is hand-picked at peak ripeness and arrives to you bursting with flavor and nutrition. This direct-to-you approach means you enjoy fruit that's fresher, juicier, and more vibrant than anything sitting on store shelves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "minimax" | "custom";
|
||||
|
||||
const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
|
||||
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
|
||||
anthropic: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
|
||||
anthropic: ["claude-sonnet-4-5", "claude-sonnet-4-20250514", "claude-opus-4-1", "claude-opus-4-20250514", "claude-3-7-sonnet-20250219", "claude-3-5-haiku-20241022", "claude-3-haiku-20240307"],
|
||||
google: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
|
||||
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
|
||||
minimax: ["MiniMax-M3", "MiniMax-M3-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.7", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
};
|
||||
|
||||
// Icons
|
||||
@@ -87,6 +88,7 @@ const PROVIDERS: { id: AIProvider; name: string; icon: React.ReactNode; descript
|
||||
{ id: "anthropic", name: "Anthropic", icon: <BrainIcon className="h-5 w-5" />, description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.", website: "anthropic.com", color: "bg-amber-500" },
|
||||
{ id: "google", name: "Google", icon: <CircleIcon className="h-5 w-5" />, description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.", website: "ai.google", color: "bg-blue-500" },
|
||||
{ id: "xai", name: "xAI", icon: <TornadoIcon className="h-5 w-5" />, description: "Grok-2, Grok-1.5. Real-time data, humor.", website: "x.ai", color: "bg-orange-500" },
|
||||
{ id: "minimax", name: "MiniMax", icon: <SparkleIcon className="h-5 w-5" />, description: "MiniMax-M3, M2.5, M2. OpenAI-compatible.", website: "minimax.io", color: "bg-violet-500" },
|
||||
{ id: "custom", name: "Custom", icon: <WrenchIcon className="h-5 w-5" />, description: "Any OpenAI-compatible API endpoint.", website: "", color: "bg-stone-500" },
|
||||
];
|
||||
|
||||
@@ -232,7 +234,7 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : "sk-..."}
|
||||
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : provider === "minimax" ? "ey..." : "sk-..."}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
const [address, setAddress] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
@@ -37,6 +38,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset form when modal opens with optional duplicate source
|
||||
setCity(duplicateFrom?.city ?? "");
|
||||
setState(duplicateFrom?.state ?? "");
|
||||
setLocation(duplicateFrom?.location ?? "");
|
||||
@@ -48,6 +50,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
setStatus("draft");
|
||||
setError(null);
|
||||
}
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [isOpen, duplicateFrom]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
|
||||
// Close dropdown on route change
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSettingsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export default function AdminOrdersPanel({
|
||||
|
||||
// Simulate loading when orders change
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
@@ -382,7 +382,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
|
||||
{/* Back to site link */}
|
||||
<div className="px-5 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<a
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
|
||||
style={{ color: "var(--admin-sidebar-text)" }}
|
||||
@@ -391,7 +391,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
Back to Site
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav links with keyboard navigation */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function CommunicationSettingsForm({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Sender Settings</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Configure the default sender identity for this brand's email campaigns</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Configure the default sender identity for this brand's email campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
||||
<div key={segment.id} className="group relative">
|
||||
{confirmDelete === segment.id ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-red-600 font-medium">Delete "{segment.name}"?</p>
|
||||
<p className="text-xs text-red-600 font-medium">Delete "{segment.name}"?</p>
|
||||
<div className="flex gap-2">
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
|
||||
|
||||
export default function NewProductForm() {
|
||||
type Props = {
|
||||
defaultBrandId?: string;
|
||||
brands?: { id: string; name: string }[];
|
||||
lockBrand?: boolean;
|
||||
};
|
||||
|
||||
export default function NewProductForm({ defaultBrandId = "", brands = [], lockBrand = false }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -22,11 +29,22 @@ export default function NewProductForm() {
|
||||
const [description, setDescription] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [type, setType] = useState("Pickup");
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const [brandId, setBrandId] = useState(defaultBrandId);
|
||||
const [isTaxable, setIsTaxable] = useState("true");
|
||||
const [pickupType, setPickupType] = useState("scheduled_stop");
|
||||
const [active, setActive] = useState("true");
|
||||
|
||||
// Build the brand options. If the server provided a list, use it; otherwise
|
||||
// fall back to the historical hardcoded list so the form still works in
|
||||
// environments where getBrands() failed or returned empty.
|
||||
const brandOptions = brands.length > 0
|
||||
? brands
|
||||
: [
|
||||
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
|
||||
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
|
||||
];
|
||||
const brandSelectOptions = brandOptions.map((b) => ({ value: b.id, label: b.name }));
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
@@ -162,15 +180,27 @@ export default function NewProductForm() {
|
||||
</div>
|
||||
|
||||
<AdminInput label="Brand" required>
|
||||
<AdminSelect
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "Select brand..." },
|
||||
{ value: "64294306-5f42-463d-a5e8-2ad6c81a96de", label: "Tuxedo Corn" },
|
||||
{ value: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", label: "Indian River Direct" },
|
||||
]}
|
||||
/>
|
||||
{lockBrand ? (
|
||||
// brand_admin / store_employee — brand is fixed by their admin_users record
|
||||
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
|
||||
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
|
||||
</div>
|
||||
) : (
|
||||
<AdminSelect
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: brands.length > 0 ? "Select brand..." : "Loading brands..." },
|
||||
...brandSelectOptions,
|
||||
]}
|
||||
disabled={brands.length === 0 && defaultBrandId === ""}
|
||||
/>
|
||||
)}
|
||||
{!lockBrand && brands.length === 0 && (
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
Loading available brands — if this persists, check the admin Brands settings.
|
||||
</p>
|
||||
)}
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Taxable" helpText="Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.">
|
||||
@@ -278,12 +308,12 @@ export default function NewProductForm() {
|
||||
{loading ? "Creating..." : "Create Product"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import { AdminInput, AdminTextInput, AdminSelect, AdminButton, useToast } from "./design-system";
|
||||
|
||||
@@ -336,12 +337,12 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
{loading ? "Creating..." : "Create Stop"}
|
||||
</AdminButton>
|
||||
|
||||
<a
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="rounded-xl border border-[var(--admin-border)] px-6 py-3 font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { savePaymentSettings, type PaymentProvider, type PaymentSettings } from "@/actions/payments";
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function ProductTableBody({
|
||||
/>
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6">
|
||||
<p className="text-sm font-semibold text-zinc-100">
|
||||
Delete "{product.name}"?
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
This will remove the product. If it is attached to any
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import SettingsSections from "@/components/admin/SettingsSections";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import React, { useState, useTransition, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Template, TemplateType, CampaignType } from "@/actions/communications/templates";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { upsertTemplate } from "@/actions/communications/templates";
|
||||
@@ -597,9 +598,9 @@ export function TemplateEditForm({
|
||||
>
|
||||
{saving ? "Saving..." : "Save Template"}
|
||||
</button>
|
||||
<a href="/admin/communications/templates" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
|
||||
<Link href="/admin/communications/templates" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
|
||||
Cancel
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { getWelcomeSequence, resendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
@@ -250,19 +250,19 @@ export default function HeroSection() {
|
||||
{/* ─── HERO CONTENT ────────────────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="container mx-auto px-6 lg:px-16 py-32 relative z-10"
|
||||
className="container mx-auto px-4 sm:px-6 lg:px-16 py-16 sm:py-24 lg:py-32 relative z-10"
|
||||
>
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center min-h-[80vh]">
|
||||
<div className="grid lg:grid-cols-2 gap-8 lg:gap-16 items-center min-h-[70vh] lg:min-h-[80vh]">
|
||||
|
||||
{/* Left Column - Animated Content */}
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6 sm:space-y-8 lg:space-y-10 order-2 lg:order-1">
|
||||
{/* Animated Badge */}
|
||||
<div className="hero-reveal hero-badge inline-flex items-center gap-3 px-6 py-3 rounded-full border" style={{
|
||||
<div className="hero-reveal hero-badge inline-flex items-center gap-2 sm:gap-3 px-3 sm:px-6 py-2 sm:py-3 rounded-full border text-xs sm:text-sm" style={{
|
||||
borderColor: "#c97a3e",
|
||||
background: "rgba(201, 122, 62, 0.08)",
|
||||
}}>
|
||||
<span className="w-3 h-3 rounded-full animate-pulse-glow" style={{ backgroundColor: "#c97a3e" }} />
|
||||
<span className="text-sm font-semibold tracking-wide" style={{ color: "#c97a3e" }}>
|
||||
<span className="w-2 h-2 sm:w-3 sm:h-3 rounded-full animate-pulse-glow" style={{ backgroundColor: "#c97a3e" }} />
|
||||
<span className="text-xs sm:text-sm font-semibold tracking-wide" style={{ color: "#c97a3e" }}>
|
||||
Farm-Fresh Delivery Platform
|
||||
</span>
|
||||
</div>
|
||||
@@ -270,7 +270,7 @@ export default function HeroSection() {
|
||||
{/* Main Headline - Apple-style reveal */}
|
||||
<div className="hero-reveal hero-title space-y-2">
|
||||
<h1
|
||||
className="text-6xl sm:text-7xl lg:text-8xl xl:text-9xl font-bold leading-[0.9] tracking-tighter"
|
||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl xl:text-9xl font-bold leading-[0.9] tracking-tighter"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', 'Georgia', serif",
|
||||
color: "#1a1a1a",
|
||||
@@ -283,7 +283,7 @@ export default function HeroSection() {
|
||||
|
||||
{/* Subtext */}
|
||||
<p
|
||||
className="hero-reveal hero-subtitle text-xl sm:text-2xl max-w-xl leading-relaxed"
|
||||
className="hero-reveal hero-subtitle text-lg sm:text-xl md:text-2xl max-w-xl leading-relaxed"
|
||||
style={{
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
color: "#4a6d56",
|
||||
@@ -295,10 +295,10 @@ export default function HeroSection() {
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="hero-reveal hero-cta flex flex-wrap gap-5">
|
||||
<div className="hero-reveal hero-cta flex flex-col sm:flex-row flex-wrap gap-4 sm:gap-5">
|
||||
<Link
|
||||
href="/login"
|
||||
className="group relative inline-flex items-center gap-3 px-8 py-5 rounded-2xl font-semibold text-lg overflow-hidden"
|
||||
className="group relative inline-flex items-center justify-center gap-3 px-6 sm:px-8 py-4 sm:py-5 rounded-2xl font-semibold text-base sm:text-lg overflow-hidden"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
|
||||
color: "white",
|
||||
@@ -321,7 +321,7 @@ export default function HeroSection() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/brands"
|
||||
className="group inline-flex items-center gap-3 px-8 py-5 rounded-2xl font-semibold text-lg border-2"
|
||||
className="group inline-flex items-center justify-center gap-3 px-6 sm:px-8 py-4 sm:py-5 rounded-2xl font-semibold text-base sm:text-lg border-2"
|
||||
style={{
|
||||
color: "#1a4d2e",
|
||||
borderColor: "#1a4d2e",
|
||||
@@ -345,7 +345,7 @@ export default function HeroSection() {
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hero-reveal hero-stats flex flex-wrap gap-8 pt-8">
|
||||
<div className="hero-reveal hero-stats flex flex-wrap gap-6 sm:gap-8 pt-6 sm:pt-8">
|
||||
{[
|
||||
{ stat: "500+", label: "Farm Brands" },
|
||||
{ stat: "98%", label: "On-Time" },
|
||||
@@ -353,7 +353,7 @@ export default function HeroSection() {
|
||||
].map((item, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div
|
||||
className="text-3xl sm:text-4xl font-bold"
|
||||
className="text-2xl sm:text-3xl lg:text-4xl font-bold"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
color: "#1a4d2e",
|
||||
@@ -361,7 +361,7 @@ export default function HeroSection() {
|
||||
>
|
||||
{item.stat}
|
||||
</div>
|
||||
<div className="text-sm font-medium" style={{ color: "#6b8f71" }}>
|
||||
<div className="text-xs sm:text-sm font-medium" style={{ color: "#6b8f71" }}>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,12 +370,12 @@ export default function HeroSection() {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Hero Visual */}
|
||||
<div className="hero-visual relative lg:h-[700px]">
|
||||
<div className="hero-visual relative lg:h-[500px] xl:h-[700px]">
|
||||
{/* SVG Route Map Visual */}
|
||||
<svg
|
||||
viewBox="0 0 500 500"
|
||||
className="w-full h-full"
|
||||
style={{ maxHeight: "700px", filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.08))" }}
|
||||
style={{ maxHeight: "500px", maxWidth: "100%", filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.08))" }}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
@@ -477,45 +477,45 @@ export default function HeroSection() {
|
||||
</svg>
|
||||
|
||||
{/* Floating Cards */}
|
||||
<div className="absolute top-12 -left-8 parallax-float" style={{ animation: "float 8s ease-in-out infinite" }}>
|
||||
<div className="absolute top-4 sm:top-12 -left-2 sm:-left-8 parallax-float hidden sm:block" style={{ animation: "float 8s ease-in-out infinite" }}>
|
||||
<div
|
||||
className="rounded-2xl p-5 shadow-2xl border"
|
||||
className="rounded-2xl p-4 sm:p-5 shadow-2xl border"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(201, 122, 62, 0.95) 0%, rgba(247, 129, 130, 0.9) 100%)",
|
||||
borderColor: "rgba(247, 129, 130, 0.5)",
|
||||
boxShadow: "0 12px 40px rgba(232, 168, 124, 0.4), 0 0 30px rgba(247, 129, 130, 0.2)",
|
||||
}}
|
||||
>
|
||||
<div className="text-3xl font-bold text-white">2.4T</div>
|
||||
<div className="text-sm text-white/90">Miles Saved</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-white">2.4T</div>
|
||||
<div className="text-xs sm:text-sm text-white/90">Miles Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-24 -right-4 parallax-float" style={{ animation: "float-delayed 10s ease-in-out infinite" }}>
|
||||
<div className="absolute bottom-16 sm:bottom-24 -right-2 sm:-right-4 parallax-float hidden sm:block" style={{ animation: "float-delayed 10s ease-in-out infinite" }}>
|
||||
<div
|
||||
className="rounded-2xl p-5 shadow-2xl border"
|
||||
className="rounded-2xl p-4 sm:p-5 shadow-2xl border"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(107, 185, 165, 0.95) 0%, rgba(133, 210, 197, 0.9) 100%)",
|
||||
borderColor: "rgba(133, 210, 197, 0.5)",
|
||||
boxShadow: "0 12px 40px rgba(107, 185, 165, 0.4), 0 0 30px rgba(133, 210, 197, 0.2)",
|
||||
}}
|
||||
>
|
||||
<div className="text-3xl font-bold text-white">12hrs</div>
|
||||
<div className="text-sm text-white/90">Avg Delivery</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-white">12hrs</div>
|
||||
<div className="text-xs sm:text-sm text-white/90">Avg Delivery</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/2 -right-12 parallax-float" style={{ animation: "float 9s ease-in-out infinite 1s" }}>
|
||||
<div className="absolute top-1/3 sm:top-1/2 -right-4 sm:-right-12 parallax-float hidden sm:block" style={{ animation: "float 9s ease-in-out infinite 1s" }}>
|
||||
<div
|
||||
className="rounded-2xl p-4 shadow-xl border flex items-center gap-3"
|
||||
className="rounded-2xl p-3 sm:p-4 shadow-xl border flex items-center gap-2 sm:gap-3"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, rgba(245, 199, 126, 0.95) 0%, rgba(201, 162, 80, 0.9) 100%)",
|
||||
borderColor: "rgba(245, 199, 126, 0.6)",
|
||||
boxShadow: "0 8px 32px rgba(245, 199, 126, 0.4)",
|
||||
}}
|
||||
>
|
||||
<span className="w-4 h-4 rounded-full bg-white animate-pulse" />
|
||||
<span className="text-sm font-bold text-white/90">Live Tracking</span>
|
||||
<span className="w-3 h-3 sm:w-4 sm:h-4 rounded-full bg-white animate-pulse" />
|
||||
<span className="text-xs sm:text-sm font-bold text-white/90">Live Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
// Toast Notification System - Slide-in notifications from top-right
|
||||
"use client";
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
{results.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
|
||||
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
|
||||
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useTransition, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
|
||||
{/* Brand row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[6px] font-bold uppercase tracking-widest text-stone-400">Route Trace</span>
|
||||
<span className="text-[5px] text-stone-300">{stickerSize}"</span>
|
||||
<span className="text-[5px] text-stone-300">{stickerSize}"</span>
|
||||
</div>
|
||||
|
||||
{/* Lot number — dominant */}
|
||||
@@ -273,7 +273,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
|
||||
{/* Print specs */}
|
||||
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-2.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-700">Direct Thermal · {stickerSize}" · Black on White · Large QR</p>
|
||||
<p className="text-xs font-semibold text-stone-700">Direct Thermal · {stickerSize}" · Black on White · Large QR</p>
|
||||
<p className="text-[10px] text-stone-400 mt-0.5">Helvetica Bold · High-contrast QR · No margins · 2 per sheet</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function StorefrontFooter({
|
||||
Our Farm
|
||||
</Link>
|
||||
<Link href={`/${brandSlug}#products`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
|
||||
What's in Season
|
||||
What's in Season
|
||||
</Link>
|
||||
<Link href={`/${brandSlug}/contact`} className="block text-sm text-stone-500 hover:text-stone-900 transition-colors">
|
||||
Visit Us
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { gsap } from "gsap";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useState, useEffect, Suspense, useMemo } from "react";
|
||||
import {
|
||||
verifyWaterPin,
|
||||
submitWaterEntry,
|
||||
@@ -120,8 +120,17 @@ function WaterFieldInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const qrHeadgateToken = searchParams.get("h");
|
||||
|
||||
const [lang, setLang] = useState<Language>("en");
|
||||
const [step, setStep] = useState<"loading" | "lang" | "role" | "pin" | "form">("loading");
|
||||
// Read cookie preferences on first render. Component is client-only via
|
||||
// <Suspense>, so accessing document.cookie in the lazy initializer is safe.
|
||||
const [lang, setLang] = useState<Language>(() => {
|
||||
if (typeof document === "undefined") return "en";
|
||||
const saved = document.cookie.match(/wl_lang=(en|es)/)?.[1];
|
||||
return (saved as Language) || "en";
|
||||
});
|
||||
const [step, setStep] = useState<"loading" | "lang" | "role" | "pin" | "form">(() => {
|
||||
if (typeof document === "undefined") return "loading";
|
||||
return document.cookie.match(/wl_session=([^;]+)/) ? "form" : "lang";
|
||||
});
|
||||
const [pin, setPin] = useState("");
|
||||
const [irrigatorName, setIrrigatorName] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<"irrigator" | "water_admin" | null>(null);
|
||||
@@ -146,32 +155,30 @@ function WaterFieldInner() {
|
||||
|
||||
const t = LABELS[lang];
|
||||
|
||||
// Detect saved language preference + check session
|
||||
// Restore headgates on first render if user is already logged in
|
||||
// (wl_session cookie present). Done in an effect so the initial step
|
||||
// state can be set synchronously and headgates load asynchronously.
|
||||
useEffect(() => {
|
||||
const saved = document.cookie.match(/wl_lang=(en|es)/)?.[1] as Language | undefined;
|
||||
if (saved) setLang(saved);
|
||||
|
||||
const match = document.cookie.match(/wl_session=([^;]+)/);
|
||||
if (match) {
|
||||
// Already logged in
|
||||
if (step === "form" && headgates.length === 0) {
|
||||
loadHeadgates();
|
||||
setStep("form");
|
||||
} else {
|
||||
setStep("lang");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Handle QR-locked headgate after headgates load
|
||||
useEffect(() => {
|
||||
if (qrHeadgateToken && headgates.length > 0) {
|
||||
const matched = headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken);
|
||||
if (matched) {
|
||||
setSelectedHeadgate(matched.id);
|
||||
setHeadgateLocked(true);
|
||||
}
|
||||
}
|
||||
// QR-locked headgate: derive during render instead of syncing in an effect.
|
||||
// When qrHeadgateToken matches a loaded headgate, override the user's
|
||||
// selection and lock the dropdown. Otherwise fall back to whatever the
|
||||
// user picked (or none).
|
||||
const qrMatchedHeadgate = useMemo(() => {
|
||||
if (!qrHeadgateToken || headgates.length === 0) return null;
|
||||
return (
|
||||
headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken) ?? null
|
||||
);
|
||||
}, [qrHeadgateToken, headgates]);
|
||||
|
||||
const effectiveSelectedHeadgate = qrMatchedHeadgate?.id ?? selectedHeadgate;
|
||||
const isHeadgateLocked = qrMatchedHeadgate ? true : headgateLocked;
|
||||
|
||||
async function loadHeadgates() {
|
||||
const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true);
|
||||
setHeadgates(hgs.filter((h) => h.active));
|
||||
@@ -260,7 +267,7 @@ function WaterFieldInner() {
|
||||
|
||||
async function handleSubmitEntry(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedHeadgate || !measurement) return;
|
||||
if (!effectiveSelectedHeadgate || !measurement) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
@@ -284,14 +291,14 @@ function WaterFieldInner() {
|
||||
}
|
||||
|
||||
const result = await submitWaterEntry(
|
||||
selectedHeadgate,
|
||||
effectiveSelectedHeadgate,
|
||||
parseFloat(measurement),
|
||||
unit,
|
||||
notes,
|
||||
photoUrl,
|
||||
latitude ?? undefined,
|
||||
longitude ?? undefined,
|
||||
headgateLocked
|
||||
isHeadgateLocked
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -450,7 +457,7 @@ function WaterFieldInner() {
|
||||
}
|
||||
|
||||
// Main log form
|
||||
const selectedHg = headgates.find((hg) => hg.id === selectedHeadgate);
|
||||
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
@@ -509,13 +516,13 @@ function WaterFieldInner() {
|
||||
<label className="block text-base font-semibold text-stone-700 mb-2">
|
||||
{t.selectHeadgate}
|
||||
</label>
|
||||
{headgateLocked ? (
|
||||
{isHeadgateLocked ? (
|
||||
<div className="w-full rounded-xl border-2 border-amber-300 bg-amber-50 px-4 py-4 text-lg font-bold text-stone-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
{selectedHg?.name ?? selectedHeadgate}
|
||||
{selectedHg?.name ?? effectiveSelectedHeadgate}
|
||||
<span className="ml-1 inline-flex items-center gap-0.5 rounded bg-amber-200 px-2 py-0.5 text-xs font-bold text-amber-800">
|
||||
🔒 {t.locked}
|
||||
</span>
|
||||
@@ -531,7 +538,7 @@ function WaterFieldInner() {
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedHeadgate}
|
||||
value={effectiveSelectedHeadgate}
|
||||
onChange={(e) => setSelectedHeadgate(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg outline-none focus:border-stone-900 min-h-[56px] appearance-none"
|
||||
@@ -548,7 +555,7 @@ function WaterFieldInner() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedHg && !headgateLocked && (
|
||||
{selectedHg && !isHeadgateLocked && (
|
||||
<ThresholdBadge high={selectedHg.high_threshold} low={selectedHg.low_threshold} t={t} />
|
||||
)}
|
||||
</div>
|
||||
@@ -661,7 +668,7 @@ function WaterFieldInner() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedHeadgate || !measurement || loading}
|
||||
disabled={!effectiveSelectedHeadgate || !measurement || loading}
|
||||
className="w-full rounded-xl bg-green-600 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 active:bg-green-700 min-h-[64px] shadow-lg shadow-green-900/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
+59
-56
@@ -63,24 +63,69 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [cartRestored, setCartRestored] = useState(false); // shown once per session
|
||||
const [restoredDismissed, setRestoredDismissed] = useState(false);
|
||||
|
||||
// ── Single mount-only effect: hydrate from localStorage + validate ────────
|
||||
// Reads cart + stop from localStorage, then repairs stale state:
|
||||
// - empty cart with stale stop → drop stop
|
||||
// - cart items missing brand_id (legacy pre-fix data) → clear everything
|
||||
// - stop brand != cart brand → drop stop, keep cart
|
||||
//
|
||||
// This MUST run in useEffect (not in useState's lazy initializer) because
|
||||
// localStorage isn't available during SSR, and reading it on the first
|
||||
// client render would cause a hydration mismatch vs. the server-rendered
|
||||
// empty cart. The setState calls are intentional post-hydration setup.
|
||||
useEffect(() => {
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- legitimate browser-storage hydration on mount */
|
||||
let storedCart: CartItem[] = [];
|
||||
let storedStop: StopInfo | null = null;
|
||||
try {
|
||||
const storedCart = localStorage.getItem(CART_KEY);
|
||||
if (storedCart) {
|
||||
const parsed = JSON.parse(storedCart);
|
||||
if (Array.isArray(parsed)) setCart(parsed);
|
||||
const rawCart = localStorage.getItem(CART_KEY);
|
||||
if (rawCart) {
|
||||
const parsed = JSON.parse(rawCart);
|
||||
if (Array.isArray(parsed)) storedCart = parsed;
|
||||
}
|
||||
const storedStop = localStorage.getItem(STOP_KEY);
|
||||
if (storedStop) {
|
||||
const parsed = JSON.parse(storedStop);
|
||||
if (parsed && typeof parsed === "object" && parsed.id) {
|
||||
setSelectedStopState(parsed);
|
||||
}
|
||||
const rawStop = localStorage.getItem(STOP_KEY);
|
||||
if (rawStop) {
|
||||
const parsed = JSON.parse(rawStop);
|
||||
if (parsed && typeof parsed === "object" && parsed.id) storedStop = parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore — fall through with empty values
|
||||
}
|
||||
|
||||
const cartBrandId = storedCart[0]?.brand_id;
|
||||
|
||||
if (storedCart.length === 0) {
|
||||
// Empty cart — keep it empty, but clear any stale stop
|
||||
setCart([]);
|
||||
if (storedStop) setSelectedStopState(null);
|
||||
} else if (!cartBrandId) {
|
||||
// Legacy cart without brand_id — nuke both to avoid cross-brand pollution
|
||||
try {
|
||||
localStorage.removeItem(CART_KEY);
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setCart([]);
|
||||
setSelectedStopState(null);
|
||||
} else {
|
||||
// Healthy cart — apply it
|
||||
setCart(storedCart);
|
||||
if (storedStop?.brand_id && storedStop.brand_id !== cartBrandId) {
|
||||
// Stop belongs to a different brand — drop it
|
||||
try {
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSelectedStopState(null);
|
||||
} else if (storedStop) {
|
||||
setSelectedStopState(storedStop);
|
||||
}
|
||||
}
|
||||
|
||||
setHydrated(true);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,47 +165,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, [hydrated]);
|
||||
|
||||
// ── Validate / repair stale session data on hydration ─────────────────
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
|
||||
// Retrieve current cart and stop from localStorage
|
||||
let storedCart: CartItem[] = [];
|
||||
let storedStop: StopInfo | null = null;
|
||||
try {
|
||||
const rawCart = localStorage.getItem(CART_KEY);
|
||||
if (rawCart) storedCart = JSON.parse(rawCart);
|
||||
const rawStop = localStorage.getItem(STOP_KEY);
|
||||
if (rawStop) storedStop = JSON.parse(rawStop);
|
||||
} catch {
|
||||
// ignore — treat as empty
|
||||
}
|
||||
|
||||
if (storedCart.length === 0) {
|
||||
// Empty cart — ensure no stale stop either
|
||||
if (storedStop) setSelectedStop(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cartBrandId = storedCart[0]?.brand_id;
|
||||
|
||||
// Cart items from before the fix lack brand_id — clear everything
|
||||
if (!cartBrandId) {
|
||||
localStorage.removeItem(CART_KEY);
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
setCart([]);
|
||||
setSelectedStop(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop exists but brand doesn't match cart brand — clear stop
|
||||
if (storedStop?.brand_id && storedStop.brand_id !== cartBrandId) {
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
setSelectedStop(null);
|
||||
}
|
||||
}, [hydrated]);
|
||||
|
||||
function setSelectedStop(stop: StopInfo | null) {
|
||||
const setSelectedStop = useCallback((stop: StopInfo | null) => {
|
||||
setSelectedStopState(stop);
|
||||
try {
|
||||
if (stop) {
|
||||
@@ -171,9 +176,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedStopStable = useCallback(setSelectedStop, []);
|
||||
}, []);
|
||||
|
||||
function addToCart(item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") {
|
||||
setCart((prev) => {
|
||||
@@ -286,7 +289,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
cartBrandSlug,
|
||||
justAdded,
|
||||
cartRestored: showRestoredToast,
|
||||
setSelectedStop: setSelectedStopStable,
|
||||
setSelectedStop: setSelectedStop,
|
||||
addToCart,
|
||||
increaseQuantity,
|
||||
decreaseQuantity,
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
// AI provider model definitions — shared between client and server
|
||||
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "minimax" | "custom";
|
||||
|
||||
export const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
|
||||
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
|
||||
anthropic: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
|
||||
// Note: claude-3-5-sonnet-* model IDs have been retired by Anthropic.
|
||||
// Current IDs are claude-sonnet-4-5, claude-sonnet-4-*, claude-opus-4-*, etc.
|
||||
// See https://docs.anthropic.com/en/docs/about-claude/models/overview
|
||||
anthropic: [
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
google: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
|
||||
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
|
||||
minimax: [
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M3-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2",
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_MODELS: Record<Exclude<AIProvider, "custom">, string> = {
|
||||
openai: "gpt-4o-mini",
|
||||
anthropic: "claude-sonnet-4-5",
|
||||
google: "gemini-2.0-flash",
|
||||
xai: "grok-2",
|
||||
minimax: "MiniMax-M3",
|
||||
};
|
||||
|
||||
export function getModelsForProvider(provider: Exclude<AIProvider, "custom">): string[] {
|
||||
|
||||
Reference in New Issue
Block a user