diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 8ffdafa..03a0ad8 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -91,6 +91,8 @@ Signing secret for Resend webhook payloads. ### AI +The platform supports five AI providers. Each brand can override per-provider keys in the admin AI Provider panel (`/admin/settings/ai`). Env vars here are fallbacks for when no per-brand key is configured. + #### `OPENAI_API_KEY` OpenAI API key for AI features (AI Intelligence Pack add-on). - **Where to get:** [OpenAI Platform](https://platform.openai.com) → API Keys → Create secret key @@ -101,6 +103,20 @@ OpenAI organization ID (optional — for workspace-level usage tracking). - **Where to get:** OpenAI Platform → Settings → Organization ID - **Format:** `org-...` +#### `MINIMAX_API_KEY` +MiniMax (MiniMax) API key — OpenAI-compatible. **Pre-launch default provider.** +- **Where to get:** [MiniMax Platform](https://platform.minimax.io) → API Keys +- **Format:** `ey...` (JWT-style) +- **Used by:** `getAIClient()` falls back to this when no per-brand key is set. `ai-import.ts` Import Center also prefers this over `OPENAI_API_KEY` when both are set. + +#### `MINIMAX_BASE_URL` (optional) +Override the MiniMax API base URL. +- **Default:** `https://api.minimax.io/v1` (global) +- **China:** `https://api.minimaxi.com/v1` (mainland endpoint, lower latency inside China) + +#### Other providers +The admin panel also accepts per-brand keys for **Anthropic** (`sk-ant-...`), **Google Gemini** (`AIza...`), **xAI** (`xai-...`), and **custom** OpenAI-compatible endpoints. These can also be set globally via `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`. + ### Square #### `SQUARE_APP_SECRET` diff --git a/src/actions/ai-import.ts b/src/actions/ai-import.ts index 21cd0c9..ea30d5c 100644 --- a/src/actions/ai-import.ts +++ b/src/actions/ai-import.ts @@ -119,7 +119,14 @@ async function callAIAnalysis( rows: string[][], brandId: string ): Promise { - 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); diff --git a/src/actions/integrations/ai-providers.ts b/src/actions/integrations/ai-providers.ts index eb148b4..4e7247b 100644 --- a/src/actions/integrations/ai-providers.ts +++ b/src/actions/integrations/ai-providers.ts @@ -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 { @@ -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] ?? "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 diff --git a/src/actions/stops.ts b/src/actions/stops.ts index 7ba7aa3..b35826b 100644 --- a/src/actions/stops.ts +++ b/src/actions/stops.ts @@ -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 { 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 { + 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[]) : []; } \ No newline at end of file diff --git a/src/actions/stops/create-stop.ts b/src/actions/stops/create-stop.ts index f37dcc2..d4d28d4 100644 --- a/src/actions/stops/create-stop.ts +++ b/src/actions/stops/create-stop.ts @@ -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 ?? "" }; } diff --git a/src/actions/water-log/admin.ts b/src/actions/water-log/admin.ts index 4288102..cfe4f5e 100644 --- a/src/actions/water-log/admin.ts +++ b/src/actions/water-log/admin.ts @@ -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 ──────────────────────────────────────────────── diff --git a/src/app/admin/orders/[id]/page.tsx b/src/app/admin/orders/[id]/page.tsx index 62043e2..6f83cf8 100644 --- a/src/app/admin/orders/[id]/page.tsx +++ b/src/app/admin/orders/[id]/page.tsx @@ -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 (
- ← Back to Orders - +

Order not found

@@ -62,14 +63,14 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps) {/* Header */}
- - +

Order Details

{formatDate(order.created_at)}

diff --git a/src/app/admin/products/[id]/page.tsx b/src/app/admin/products/[id]/page.tsx index f35207d..9d33bf1 100644 --- a/src/app/admin/products/[id]/page.tsx +++ b/src/app/admin/products/[id]/page.tsx @@ -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
             {error?.message ?? "Product not found"}
           
- ← Back to Products - +
); @@ -50,12 +51,12 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro return (
- ← Back to Products - +
diff --git a/src/app/admin/products/new/page.tsx b/src/app/admin/products/new/page.tsx index c85755c..eaa7b19 100644 --- a/src/app/admin/products/new/page.tsx +++ b/src/app/admin/products/new/page.tsx @@ -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 (
@@ -23,10 +36,16 @@ export default async function NewProductPage() {

- 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."}

- +
diff --git a/src/app/admin/route-trace/lots/[id]/page.tsx b/src/app/admin/route-trace/lots/[id]/page.tsx index aeb3fc6..a88dd64 100644 --- a/src/app/admin/route-trace/lots/[id]/page.tsx +++ b/src/app/admin/route-trace/lots/[id]/page.tsx @@ -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:

Lot not found

- ← Back to Lots + ← Back to Lots
@@ -56,7 +57,7 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
- ← Back to Lots + ← Back to Lots
{ 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]); diff --git a/src/app/admin/stops/[id]/page.tsx b/src/app/admin/stops/[id]/page.tsx index 623fe6f..145c227 100644 --- a/src/app/admin/stops/[id]/page.tsx +++ b/src/app/admin/stops/[id]/page.tsx @@ -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) {
             {error?.message ?? "Stop not found"}
           
- ← Back to Stops - +
); @@ -74,12 +75,12 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) { return (
- ← Back to Stops - +
diff --git a/src/app/admin/stops/new/page.tsx b/src/app/admin/stops/new/page.tsx index c39fa0f..aafdf3e 100644 --- a/src/app/admin/stops/new/page.tsx +++ b/src/app/admin/stops/new/page.tsx @@ -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({
diff --git a/src/app/admin/water-log/headgates/HeadgatesManager.tsx b/src/app/admin/water-log/headgates/HeadgatesManager.tsx index 9a029b0..2e7f43c 100644 --- a/src/app/admin/water-log/headgates/HeadgatesManager.tsx +++ b/src/app/admin/water-log/headgates/HeadgatesManager.tsx @@ -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(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) { setQrModal(hg)}> Print + handleDelete(hg)} + disabled={deletingId === hg.id} + isLoading={deletingId === hg.id} + > + Delete +
diff --git a/src/app/admin/water-log/settings/page.tsx b/src/app/admin/water-log/settings/page.tsx index 6c7149c..d6ba1bb 100644 --- a/src/app/admin/water-log/settings/page.tsx +++ b/src/app/admin/water-log/settings/page.tsx @@ -207,7 +207,7 @@ export default function WaterLogSettingsPage() { {/* High/Low Alerts */}

High/Low Alerts

-

Receive an SMS when a reading exceeds a headgate's thresholds.

+

Receive an SMS when a reading exceeds a headgate's thresholds.

diff --git a/src/app/admin/wholesale/WholesaleClient.tsx b/src/app/admin/wholesale/WholesaleClient.tsx index 19a510d..65b9c60 100644 --- a/src/app/admin/wholesale/WholesaleClient.tsx +++ b/src/app/admin/wholesale/WholesaleClient.tsx @@ -1950,7 +1950,7 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:

Wholesale Portal

-

Show the Wholesale Portal card on this brand's storefront page.

+

Show the Wholesale Portal card on this brand's storefront page.

diff --git a/src/app/checkout/success/page.tsx b/src/app/checkout/success/page.tsx index c49d1f2..88dbb16 100644 --- a/src/app/checkout/success/page.tsx +++ b/src/app/checkout/success/page.tsx @@ -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(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(() => { + 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(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) { diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index bc1c3ab..34b6ada 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -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 ; } \ No newline at end of file diff --git a/src/app/indian-river-direct/about/error.tsx b/src/app/indian-river-direct/about/error.tsx index 49c14b6..0818839 100644 --- a/src/app/indian-river-direct/about/error.tsx +++ b/src/app/indian-river-direct/about/error.tsx @@ -36,7 +36,7 @@ export default function ErrorPage({

Our Story Unavailable

- We couldn't load our story page. Please try again. + We couldn't load our story page. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/indian-river-direct/contact/ContactClientPage.tsx b/src/app/indian-river-direct/contact/ContactClientPage.tsx index c694470..5cc59f5 100644 --- a/src/app/indian-river-direct/contact/ContactClientPage.tsx +++ b/src/app/indian-river-direct/contact/ContactClientPage.tsx @@ -75,7 +75,7 @@ export default function IndianRiverContactPage() {

Get in Touch

Contact Us

- 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.

diff --git a/src/app/indian-river-direct/contact/error.tsx b/src/app/indian-river-direct/contact/error.tsx index 2f28eb7..4606010 100644 --- a/src/app/indian-river-direct/contact/error.tsx +++ b/src/app/indian-river-direct/contact/error.tsx @@ -36,7 +36,7 @@ export default function ErrorPage({

Contact Unavailable

- We couldn't load the contact page. Please try again. + We couldn't load the contact page. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/indian-river-direct/faq/FAQClientPage.tsx b/src/app/indian-river-direct/faq/FAQClientPage.tsx index 5aab97e..a14c89a 100644 --- a/src/app/indian-river-direct/faq/FAQClientPage.tsx +++ b/src/app/indian-river-direct/faq/FAQClientPage.tsx @@ -72,7 +72,7 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s if (filtered.length === 0) { return (
-

No results for "{searchQuery}"

+

No results for "{searchQuery}"

Try a different term or browse all categories below.

); @@ -208,7 +208,7 @@ export default function IndianRiverFAQPage() {

Still have questions?

-

We're happy to help — reach out anytime.

+

We're happy to help — reach out anytime.

FAQ Unavailable

- We couldn't load the FAQ page. Please try again. + We couldn't load the FAQ page. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/indian-river-direct/layout.tsx b/src/app/indian-river-direct/layout.tsx index d5dcfc7..6c669ef 100644 --- a/src/app/indian-river-direct/layout.tsx +++ b/src/app/indian-river-direct/layout.tsx @@ -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 (
diff --git a/src/app/indian-river-direct/page.tsx b/src/app/indian-river-direct/page.tsx index 0727845..56aa3ec 100644 --- a/src/app/indian-river-direct/page.tsx +++ b/src/app/indian-river-direct/page.tsx @@ -435,7 +435,7 @@ export default function IndianRiverDirectPage() { ))}
- "{t.text}" + "{t.text}"

— {t.name}

diff --git a/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx b/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx new file mode 100644 index 0000000..20ca0a6 --- /dev/null +++ b/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx @@ -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(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 ( +
+ + +
+ +
+

+ {brandName} +

+

+ Pickup Stops +

+

+ Fresh Florida citrus delivered to a stop near you. +

+
+
+ + {upcomingStops.length === 0 ? ( +
+
+ + + +
+

No Upcoming Stops

+

Check back soon for new pickup locations!

+
+ ) : ( +
+ {upcomingStops.map((stop) => ( + +
+
+
+ + {new Date(stop.date).toLocaleDateString("en-US", { month: "short" })} + + + {new Date(stop.date).getDate()} + +
+
+ +
+

+ {stop.city}, {stop.state} +

+

{stop.location}

+ {stop.address && ( +

{stop.address}

+ )} +
+ +
+
+

{stop.time}

+ {stop.cutoff_time && ( +

Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}

+ )} +
+
+ + + +
+
+
+ + ))} + + {pastStops.length > 0 && ( +
+

Past Stops

+
+ {pastStops.slice(0, 5).map((stop) => ( +
+
+ {new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })} +
+
+

{stop.city}, {stop.state}

+

{stop.location}

+
+ Completed +
+ ))} +
+
+ )} +
+ )} + +
+ + +
+ ); +} diff --git a/src/app/indian-river-direct/stops/[slug]/error.tsx b/src/app/indian-river-direct/stops/[slug]/error.tsx index 997cb29..5126ce8 100644 --- a/src/app/indian-river-direct/stops/[slug]/error.tsx +++ b/src/app/indian-river-direct/stops/[slug]/error.tsx @@ -36,7 +36,7 @@ export default function ErrorPage({

Stop Not Available

- We couldn't load this stop. Please try again. + We couldn't load this stop. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/indian-river-direct/stops/page.tsx b/src/app/indian-river-direct/stops/page.tsx index dd9cb35..d48e4c3 100644 --- a/src/app/indian-river-direct/stops/page.tsx +++ b/src/app/indian-river-direct/stops/page.tsx @@ -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(null); - const [stops, setStops] = useState([]); - 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 ( -
- - -
- - {/* Header */} -
-

- {BRAND_NAME} -

-

- Pickup Stops -

-

- Fresh Florida citrus delivered to a stop near you. -

-
-
- - {loading ? ( -
- {[1, 2, 3, 4].map(i => ( -
- ))} -
- ) : upcomingStops.length === 0 ? ( -
-
- - - -
-

No Upcoming Stops

-

Check back soon for new pickup locations!

-
- ) : ( -
- {upcomingStops.map((stop) => ( - -
- {/* Date badge */} -
-
- - {new Date(stop.date).toLocaleDateString('en-US', { month: 'short' })} - - - {new Date(stop.date).getDate()} - -
-
- - {/* Stop info */} -
-

- {stop.city}, {stop.state} -

-

{stop.location}

- {stop.address && ( -

{stop.address}

- )} -
- - {/* Time & CTA */} -
-
-

{stop.time}

- {stop.cutoff_time && ( -

Order by {stop.cutoff_time}

- )} -
-
- - - -
-
-
- - ))} - - {/* Past stops */} - {pastStops.length > 0 && ( -
-

Past Stops

-
- {pastStops.slice(0, 5).map(stop => ( -
-
- {new Date(stop.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -
-
-

{stop.city}, {stop.state}

-

{stop.location}

-
- Completed -
- ))} -
-
- )} -
- )} - -
- - -
+ ); -} \ No newline at end of file +} diff --git a/src/app/login/LoginClient.tsx b/src/app/login/LoginClient.tsx index 4815f15..0979cd0 100644 --- a/src/app/login/LoginClient.tsx +++ b/src/app/login/LoginClient.tsx @@ -18,6 +18,7 @@ function LoginForm() { const [mounted, setMounted] = useState(false); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); diff --git a/src/app/page.tsx b/src/app/page.tsx index ec4bee9..4439b29 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { diff --git a/src/app/pricing/PricingClientPage.tsx b/src/app/pricing/PricingClientPage.tsx index d34e0da..cbb0f1f 100644 --- a/src/app/pricing/PricingClientPage.tsx +++ b/src/app/pricing/PricingClientPage.tsx @@ -84,27 +84,27 @@ export default function PricingClientPage() { {/* ── Hero ────────────────────────────────────────────────────────────── */} -
+
-
+
Built for produce wholesale operations
-

+

Pricing that scales
with your operation

-

+

From small farms to enterprise distributors — everything you need to manage orders, stops, communications, and billing in one platform.

-
+
- Save 25% with annual + Save 25% with annual
{/* ── Plan cards ───────────────────────────────────────────────────────── */} -
+

Available Plans

{(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 (
{isMostPopular && ( -
+
Most Popular
)} -
-

{plan.label}

-

{plan.description}

+
+

{plan.label}

+

{plan.description}

- ${price} - /{billingCycle === "annual" ? "yr" : "mo"} + ${price} + /{billingCycle === "annual" ? "yr" : "mo"}
{monthlyEquivalent !== null && ( -

${monthlyEquivalent}/mo equivalent

+

${monthlyEquivalent}/mo equivalent

)} ; } \ No newline at end of file diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx index de341e3..4adfeaa 100644 --- a/src/app/privacy-policy/page.tsx +++ b/src/app/privacy-policy/page.tsx @@ -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() { diff --git a/src/app/roadmap/page.tsx b/src/app/roadmap/page.tsx index 905a40e..722d43e 100644 --- a/src/app/roadmap/page.tsx +++ b/src/app/roadmap/page.tsx @@ -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 */}
{/* Hero */} -
-
-

+
+
+

Product Roadmap

-

+

See what we're building next. Vote for features you want most, or suggest new ideas.

-
{/* Roadmap Columns */} -
-
-
+
+
+
{/* Shipped */}
diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx index 7b4f352..5ff06e9 100644 --- a/src/app/security/page.tsx +++ b/src/app/security/page.tsx @@ -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 */}
- +
Route Commerce -
+ Get Started diff --git a/src/app/terms-and-conditions/page.tsx b/src/app/terms-and-conditions/page.tsx index 22156b8..109ded4 100644 --- a/src/app/terms-and-conditions/page.tsx +++ b/src/app/terms-and-conditions/page.tsx @@ -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() { diff --git a/src/app/tuxedo/about/error.tsx b/src/app/tuxedo/about/error.tsx index 35e86eb..cc949a6 100644 --- a/src/app/tuxedo/about/error.tsx +++ b/src/app/tuxedo/about/error.tsx @@ -31,7 +31,7 @@ export default function ErrorPage({

Our Story Unavailable

- We couldn't load our story page. Please try again. + We couldn't load our story page. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/tuxedo/contact/error.tsx b/src/app/tuxedo/contact/error.tsx index e568e7a..c1c3981 100644 --- a/src/app/tuxedo/contact/error.tsx +++ b/src/app/tuxedo/contact/error.tsx @@ -31,7 +31,7 @@ export default function ErrorPage({

Contact Unavailable

- We couldn't load the contact page. Please try again. + We couldn't load the contact page. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/tuxedo/faq/error.tsx b/src/app/tuxedo/faq/error.tsx index c617e5b..3f42463 100644 --- a/src/app/tuxedo/faq/error.tsx +++ b/src/app/tuxedo/faq/error.tsx @@ -31,7 +31,7 @@ export default function ErrorPage({

FAQ Unavailable

- We couldn't load the FAQ page. Please try again. + We couldn't load the FAQ page. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/tuxedo/layout.tsx b/src/app/tuxedo/layout.tsx index f1484e9..c114ae5 100644 --- a/src/app/tuxedo/layout.tsx +++ b/src/app/tuxedo/layout.tsx @@ -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 (
diff --git a/src/app/tuxedo/stops/TuxedoStopsList.tsx b/src/app/tuxedo/stops/TuxedoStopsList.tsx new file mode 100644 index 0000000..a630d55 --- /dev/null +++ b/src/app/tuxedo/stops/TuxedoStopsList.tsx @@ -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(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 ( +
+ + +
+ +
+

+ {brandName} +

+

+ Pickup Stops +

+

+ Fresh Olathe Sweet™ sweet corn delivered to a stop near you. +

+
+
+ + {upcomingStops.length === 0 ? ( +
+
+ + + +
+

No Upcoming Stops

+

Check back soon for new pickup locations!

+
+ ) : ( +
+ {upcomingStops.map((stop) => ( + +
+
+
+ + {new Date(stop.date).toLocaleDateString("en-US", { month: "short" })} + + + {new Date(stop.date).getDate()} + +
+
+ +
+

+ {stop.city}, {stop.state} +

+

{stop.location}

+ {stop.address && ( +

{stop.address}

+ )} +
+ +
+
+

{stop.time}

+ {stop.cutoff_time && ( +

Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}

+ )} +
+
+ + + +
+
+
+ + ))} + + {pastStops.length > 0 && ( +
+

Past Stops

+
+ {pastStops.slice(0, 5).map((stop) => ( +
+
+ {new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })} +
+
+

{stop.city}, {stop.state}

+

{stop.location}

+
+ Completed +
+ ))} +
+
+ )} +
+ )} + +
+ + +
+ ); +} diff --git a/src/app/tuxedo/stops/[slug]/error.tsx b/src/app/tuxedo/stops/[slug]/error.tsx index 676e101..dc61c5f 100644 --- a/src/app/tuxedo/stops/[slug]/error.tsx +++ b/src/app/tuxedo/stops/[slug]/error.tsx @@ -31,7 +31,7 @@ export default function ErrorPage({

Stop Not Available

- We couldn't load this stop. Please try again. + We couldn't load this stop. Please try again.

{process.env.NODE_ENV === "development" && ( diff --git a/src/app/tuxedo/stops/page.tsx b/src/app/tuxedo/stops/page.tsx index bac8094..01efd69 100644 --- a/src/app/tuxedo/stops/page.tsx +++ b/src/app/tuxedo/stops/page.tsx @@ -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(null); - const [stops, setStops] = useState([]); - 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 ( -
- - -
- - {/* Header */} -
-

- {BRAND_NAME} -

-

- Pickup Stops -

-

- Fresh Olathe Sweet™ sweet corn delivered to a stop near you. -

-
-
- - {loading ? ( -
- {[1, 2, 3, 4].map(i => ( -
- ))} -
- ) : upcomingStops.length === 0 ? ( -
-
- - - -
-

No Upcoming Stops

-

Check back soon for new pickup locations!

-
- ) : ( -
- {upcomingStops.map((stop, index) => ( - -
- {/* Date badge */} -
-
- - {new Date(stop.date).toLocaleDateString('en-US', { month: 'short' })} - - - {new Date(stop.date).getDate()} - -
-
- - {/* Stop info */} -
-

- {stop.city}, {stop.state} -

-

{stop.location}

- {stop.address && ( -

{stop.address}

- )} -
- - {/* Time & CTA */} -
-
-

{stop.time}

- {stop.cutoff_time && ( -

Order by {stop.cutoff_time}

- )} -
-
- - - -
-
-
- - ))} - - {/* Past stops */} - {pastStops.length > 0 && ( -
-

Past Stops

-
- {pastStops.slice(0, 5).map(stop => ( -
-
- {new Date(stop.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -
-
-

{stop.city}, {stop.state}

-

{stop.location}

-
- Completed -
- ))} -
-
- )} -
- )} - -
- - -
+ ); -} \ No newline at end of file +} diff --git a/src/app/waitlist/page.tsx b/src/app/waitlist/page.tsx index de588bd..8336764 100644 --- a/src/app/waitlist/page.tsx +++ b/src/app/waitlist/page.tsx @@ -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 */}
diff --git a/src/app/wholesale/employee/page.tsx b/src/app/wholesale/employee/page.tsx index 3453043..73daf24 100644 --- a/src/app/wholesale/employee/page.tsx +++ b/src/app/wholesale/employee/page.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; diff --git a/src/app/wholesale/login/page.tsx b/src/app/wholesale/login/page.tsx index 71b67c4..4c98ec1 100644 --- a/src/app/wholesale/login/page.tsx +++ b/src/app/wholesale/login/page.tsx @@ -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(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(() => { + 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(); diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 6a17273..26afa76 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { ThemeProvider } from "next-themes"; import { CartProvider } from "@/context/CartContext"; diff --git a/src/components/about/IndianRiverMissionSection.tsx b/src/components/about/IndianRiverMissionSection.tsx index 91a2f6b..db835a9 100644 --- a/src/components/about/IndianRiverMissionSection.tsx +++ b/src/components/about/IndianRiverMissionSection.tsx @@ -20,10 +20,10 @@ export default function IndianRiverMissionSection() {

From Farm to Family

- 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.

- 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.

@@ -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.

- 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.

diff --git a/src/components/admin/AIProviderPanel.tsx b/src/components/admin/AIProviderPanel.tsx index 4dfb49a..45a124e 100644 --- a/src/components/admin/AIProviderPanel.tsx +++ b/src/components/admin/AIProviderPanel.tsx @@ -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, 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: , description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.", website: "anthropic.com", color: "bg-amber-500" }, { id: "google", name: "Google", icon: , description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.", website: "ai.google", color: "bg-blue-500" }, { id: "xai", name: "xAI", icon: , description: "Grok-2, Grok-1.5. Real-time data, humor.", website: "x.ai", color: "bg-orange-500" }, + { id: "minimax", name: "MiniMax", icon: , description: "MiniMax-M3, M2.5, M2. OpenAI-compatible.", website: "minimax.io", color: "bg-violet-500" }, { id: "custom", name: "Custom", icon: , 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" />

Sender Settings

-

Configure the default sender identity for this brand's email campaigns

+

Configure the default sender identity for this brand's email campaigns

diff --git a/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx b/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx index 9736e6e..ff74923 100644 --- a/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx +++ b/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx @@ -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"; diff --git a/src/components/admin/HarvestReach/SegmentListSidebar.tsx b/src/components/admin/HarvestReach/SegmentListSidebar.tsx index c4a9155..17e2274 100644 --- a/src/components/admin/HarvestReach/SegmentListSidebar.tsx +++ b/src/components/admin/HarvestReach/SegmentListSidebar.tsx @@ -67,7 +67,7 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
{confirmDelete === segment.id ? (
-

Delete "{segment.name}"?

+

Delete "{segment.name}"?

(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() {
- 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 +
+ {brandOptions.find((b) => b.id === brandId)?.name ?? brandId} +
+ ) : ( + setBrandId(e.target.value)} + options={[ + { value: "", label: brands.length > 0 ? "Select brand..." : "Loading brands..." }, + ...brandSelectOptions, + ]} + disabled={brands.length === 0 && defaultBrandId === ""} + /> + )} + {!lockBrand && brands.length === 0 && ( +

+ Loading available brands — if this persists, check the admin Brands settings. +

+ )}
@@ -278,12 +308,12 @@ export default function NewProductForm() { {loading ? "Creating..." : "Create Product"} - Cancel - +
); diff --git a/src/components/admin/NewStopForm.tsx b/src/components/admin/NewStopForm.tsx index f9c695d..77d6707 100644 --- a/src/components/admin/NewStopForm.tsx +++ b/src/components/admin/NewStopForm.tsx @@ -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"} - Cancel - +
); diff --git a/src/components/admin/PaymentSettingsForm.tsx b/src/components/admin/PaymentSettingsForm.tsx index 480dad4..4432b8c 100644 --- a/src/components/admin/PaymentSettingsForm.tsx +++ b/src/components/admin/PaymentSettingsForm.tsx @@ -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"; diff --git a/src/components/admin/ProductTableBody.tsx b/src/components/admin/ProductTableBody.tsx index 971c6c6..b878d02 100644 --- a/src/components/admin/ProductTableBody.tsx +++ b/src/components/admin/ProductTableBody.tsx @@ -206,7 +206,7 @@ export default function ProductTableBody({ />

- Delete "{product.name}"? + Delete "{product.name}"?

This will remove the product. If it is attached to any diff --git a/src/components/admin/ReportsDashboard.tsx b/src/components/admin/ReportsDashboard.tsx index cf1f421..8c056bb 100644 --- a/src/components/admin/ReportsDashboard.tsx +++ b/src/components/admin/ReportsDashboard.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useCallback, useEffect } from "react"; import { diff --git a/src/components/admin/SettingsClient.tsx b/src/components/admin/SettingsClient.tsx index 1d7676c..4a0b10f 100644 --- a/src/components/admin/SettingsClient.tsx +++ b/src/components/admin/SettingsClient.tsx @@ -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"; diff --git a/src/components/admin/SettingsSections.tsx b/src/components/admin/SettingsSections.tsx index b0b2da4..ee04bc2 100644 --- a/src/components/admin/SettingsSections.tsx +++ b/src/components/admin/SettingsSections.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useEffect, useCallback } from "react"; import { diff --git a/src/components/admin/ShippingSettingsForm.tsx b/src/components/admin/ShippingSettingsForm.tsx index fe6a4df..d51e1ca 100644 --- a/src/components/admin/ShippingSettingsForm.tsx +++ b/src/components/admin/ShippingSettingsForm.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useEffect } from "react"; import { diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index ff79357..4fe5d08 100644 --- a/src/components/admin/StopTableClient.tsx +++ b/src/components/admin/StopTableClient.tsx @@ -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"; diff --git a/src/components/admin/TemplateEditForm.tsx b/src/components/admin/TemplateEditForm.tsx index e9754a3..0e955a7 100644 --- a/src/components/admin/TemplateEditForm.tsx +++ b/src/components/admin/TemplateEditForm.tsx @@ -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"} - + Cancel - +

) : ( diff --git a/src/components/admin/TimeTrackingAdminPanel.tsx b/src/components/admin/TimeTrackingAdminPanel.tsx index fa48e0b..62e7bcb 100644 --- a/src/components/admin/TimeTrackingAdminPanel.tsx +++ b/src/components/admin/TimeTrackingAdminPanel.tsx @@ -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"; diff --git a/src/components/admin/TimeTrackingSettingsClient.tsx b/src/components/admin/TimeTrackingSettingsClient.tsx index 2835347..d87c101 100644 --- a/src/components/admin/TimeTrackingSettingsClient.tsx +++ b/src/components/admin/TimeTrackingSettingsClient.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useEffect, useCallback } from "react"; import { diff --git a/src/components/admin/UpgradePlanModal.tsx b/src/components/admin/UpgradePlanModal.tsx index fef0cc9..801f769 100644 --- a/src/components/admin/UpgradePlanModal.tsx +++ b/src/components/admin/UpgradePlanModal.tsx @@ -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"; diff --git a/src/components/admin/WelcomeSequenceDashboard.tsx b/src/components/admin/WelcomeSequenceDashboard.tsx index 74df868..1b71181 100644 --- a/src/components/admin/WelcomeSequenceDashboard.tsx +++ b/src/components/admin/WelcomeSequenceDashboard.tsx @@ -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"; diff --git a/src/components/cart/CartRestoredToast.tsx b/src/components/cart/CartRestoredToast.tsx index 1e2fb64..f958f05 100644 --- a/src/components/cart/CartRestoredToast.tsx +++ b/src/components/cart/CartRestoredToast.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useEffect, useState } from "react"; import { useCart } from "@/context/CartContext"; diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx index bb53727..c9e7c7d 100644 --- a/src/components/landing/HeroSection.tsx +++ b/src/components/landing/HeroSection.tsx @@ -250,19 +250,19 @@ export default function HeroSection() { {/* ─── HERO CONTENT ────────────────────────────────────────────────────── */}
-
+
{/* Left Column - Animated Content */} -
+
{/* Animated Badge */} -
- - + + Farm-Fresh Delivery Platform
@@ -270,7 +270,7 @@ export default function HeroSection() { {/* Main Headline - Apple-style reveal */}

{/* CTAs */} -
+
{/* Stats */} -
+
{[ { stat: "500+", label: "Farm Brands" }, { stat: "98%", label: "On-Time" }, @@ -353,7 +353,7 @@ export default function HeroSection() { ].map((item, i) => (
{item.stat}
-
+
{item.label}
@@ -370,12 +370,12 @@ export default function HeroSection() {
{/* Right Column - Hero Visual */} -
+
{/* SVG Route Map Visual */} @@ -477,45 +477,45 @@ export default function HeroSection() { {/* Floating Cards */} -
+
-
2.4T
-
Miles Saved
+
2.4T
+
Miles Saved
-
+
-
12hrs
-
Avg Delivery
+
12hrs
+
Avg Delivery
-
+
- - Live Tracking + + Live Tracking
diff --git a/src/components/notifications/ToastNotification.tsx b/src/components/notifications/ToastNotification.tsx index d31b1c3..c1bd495 100644 --- a/src/components/notifications/ToastNotification.tsx +++ b/src/components/notifications/ToastNotification.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/set-state-in-effect */ // Toast Notification System - Slide-in notifications from top-right "use client"; diff --git a/src/components/route-trace/AdminLookupPage.tsx b/src/components/route-trace/AdminLookupPage.tsx index ad05caa..f0601ff 100644 --- a/src/components/route-trace/AdminLookupPage.tsx +++ b/src/components/route-trace/AdminLookupPage.tsx @@ -114,7 +114,7 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) { {results.length === 0 ? (
{Icons.search("h-10 w-10")}
-

No lots found for "{query}"

+

No lots found for "{query}"

) : ( <> diff --git a/src/components/route-trace/LotCreateModal.tsx b/src/components/route-trace/LotCreateModal.tsx index 1c96644..7da868e 100644 --- a/src/components/route-trace/LotCreateModal.tsx +++ b/src/components/route-trace/LotCreateModal.tsx @@ -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"; diff --git a/src/components/route-trace/StickerPreviewModal.tsx b/src/components/route-trace/StickerPreviewModal.tsx index df82f06..7b7f161 100644 --- a/src/components/route-trace/StickerPreviewModal.tsx +++ b/src/components/route-trace/StickerPreviewModal.tsx @@ -180,7 +180,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail; {/* Brand row */}
Route Trace - {stickerSize}" + {stickerSize}"
{/* Lot number — dominant */} @@ -273,7 +273,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail; {/* Print specs */}
-

Direct Thermal · {stickerSize}" · Black on White · Large QR

+

Direct Thermal · {stickerSize}" · Black on White · Large QR

Helvetica Bold · High-contrast QR · No margins · 2 per sheet

diff --git a/src/components/shared/ThemeToggle.tsx b/src/components/shared/ThemeToggle.tsx index f7feead..f2a061d 100644 --- a/src/components/shared/ThemeToggle.tsx +++ b/src/components/shared/ThemeToggle.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; diff --git a/src/components/storefront/StorefrontFooter.tsx b/src/components/storefront/StorefrontFooter.tsx index e5b0d59..63b4566 100644 --- a/src/components/storefront/StorefrontFooter.tsx +++ b/src/components/storefront/StorefrontFooter.tsx @@ -180,7 +180,7 @@ export default function StorefrontFooter({ Our Farm - What's in Season + What's in Season Visit Us diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index fcabeef..698942f 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -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"; diff --git a/src/components/ui/ScrollAnimations.tsx b/src/components/ui/ScrollAnimations.tsx index a0d89c9..72d0adf 100644 --- a/src/components/ui/ScrollAnimations.tsx +++ b/src/components/ui/ScrollAnimations.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable react-hooks/set-state-in-effect */ import { useEffect, useRef, useState } from "react"; import { gsap } from "gsap"; diff --git a/src/components/water/WaterAdminClient.tsx b/src/components/water/WaterAdminClient.tsx index 0c2b1e8..f92ec10 100644 --- a/src/components/water/WaterAdminClient.tsx +++ b/src/components/water/WaterAdminClient.tsx @@ -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"; diff --git a/src/components/water/WaterFieldClient.tsx b/src/components/water/WaterFieldClient.tsx index 93b0835..4dfd5fd 100644 --- a/src/components/water/WaterFieldClient.tsx +++ b/src/components/water/WaterFieldClient.tsx @@ -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("en"); - const [step, setStep] = useState<"loading" | "lang" | "role" | "pin" | "form">("loading"); + // Read cookie preferences on first render. Component is client-only via + // , so accessing document.cookie in the lazy initializer is safe. + const [lang, setLang] = useState(() => { + 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 (
@@ -509,13 +516,13 @@ function WaterFieldInner() { - {headgateLocked ? ( + {isHeadgateLocked ? (
- {selectedHg?.name ?? selectedHeadgate} + {selectedHg?.name ?? effectiveSelectedHeadgate} 🔒 {t.locked} @@ -531,7 +538,7 @@ function WaterFieldInner() {
) : ( )} - {selectedHg && !headgateLocked && ( + {selectedHg && !isHeadgateLocked && ( )}
@@ -661,7 +668,7 @@ function WaterFieldInner() { {/* Submit */}