From 2747d8ea45da70888549cf0739afb43936418121 Mon Sep 17 00:00:00 2001 From: default Date: Mon, 1 Jun 2026 20:29:30 +0000 Subject: [PATCH] feat(admin): rewrite orders page with tabs, search, multi-select filters - AdminOrdersPanel: clean rewrite matching earth-tone theme - Add status tabs (All/Pending/Picked Up) with pill-style buttons - Add search bar for name, phone, order ID - Add stop multi-select dropdown with checkbox filter - Standard 7-column table with pagination - UpgradePlanModal: Apple HIG glass-style modal component - DashboardHeader: reusable header with upgrade modal integration - DashboardUpgradeButton: standalone upgrade button component - Update stripe-checkout to support annual billing period - Fix readability in tools/addons section (darker badges) --- src/actions/billing/stripe-checkout.ts | 7 +- src/app/admin/page.tsx | 39 +- src/app/indian-river-direct/about/layout.tsx | 41 ++ src/app/robots.ts | 24 + src/app/sitemap.ts | 101 +++ src/app/tuxedo/about/layout.tsx | 41 ++ src/components/admin/AdminOrdersPanel.tsx | 635 +++++++++++------- src/components/admin/DashboardHeader.tsx | 50 ++ .../admin/DashboardUpgradeButton.tsx | 36 + src/components/admin/UpgradePlanModal.tsx | 341 ++++++++++ 10 files changed, 1038 insertions(+), 277 deletions(-) create mode 100644 src/app/indian-river-direct/about/layout.tsx create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/app/tuxedo/about/layout.tsx create mode 100644 src/components/admin/DashboardHeader.tsx create mode 100644 src/components/admin/DashboardUpgradeButton.tsx create mode 100644 src/components/admin/UpgradePlanModal.tsx diff --git a/src/actions/billing/stripe-checkout.ts b/src/actions/billing/stripe-checkout.ts index 7f5422c..2a6b6f9 100644 --- a/src/actions/billing/stripe-checkout.ts +++ b/src/actions/billing/stripe-checkout.ts @@ -82,16 +82,19 @@ export async function createStripeCheckoutSession( export async function createPlanUpgradeCheckout( brandId: string, - planTier: string + planTier: string, + billingPeriod?: "monthly" | "annual" ): Promise<{ success: boolean; url?: string; error?: string }> { if (!["starter", "farm", "enterprise"].includes(planTier)) { return { success: false, error: "Invalid plan tier" }; } + const annual = billingPeriod === "annual"; return createStripeCheckoutSession( brandId, planTier, "/admin/settings/billing", - "/admin/settings/billing" + "/admin/settings/billing", + annual ); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3419a7b..1ce294f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { getAdminUser } from "@/lib/admin-permissions"; import { isFeatureEnabled } from "@/lib/feature-flags"; import { getBrandPlanInfo } from "@/actions/billing/stripe-portal"; +import DashboardHeader from "@/components/admin/DashboardHeader"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; @@ -247,25 +248,11 @@ export default async function AdminPage() {
{/* Header */} -
-
-

Control Center

-

{brandDisplayName}

-
-
- - Billing → - - {planTier === "starter" && ( - - Upgrade Plan - - )} -
-
+ {/* Usage bar */}
@@ -309,7 +296,7 @@ export default async function AdminPage() { return (
-

{label}

+

{label}

@@ -340,24 +327,24 @@ export default async function AdminPage() { ? "bg-stone-100 text-stone-400" : isProminent ? "bg-emerald-100 text-emerald-600" - : "bg-blue-100 text-blue-600" + : "bg-violet-100 text-violet-600" }`}>
{isAddon && !isEnabled && ( - + Add-on )} {isAddon && isEnabled && ( - + Active )} {isProminent && ( - + Core )} @@ -368,13 +355,13 @@ export default async function AdminPage() {

{section.description}

{isAddon && !isEnabled && section.upgradeText && ( -

{section.upgradeText}

+

{section.upgradeText}

)} ); diff --git a/src/app/indian-river-direct/about/layout.tsx b/src/app/indian-river-direct/about/layout.tsx new file mode 100644 index 0000000..9dd95fd --- /dev/null +++ b/src/app/indian-river-direct/about/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yourdomain.com"; + +export const metadata: Metadata = { + title: "Our Story | A Passion for All Things Fruit", + description: + "Since 1985, Indian River Direct has been bringing the finest peaches and citrus from our Florida groves directly to your neighborhood. Family-owned and operated.", + openGraph: { + title: "Our Story | Indian River Direct", + description: + "Since 1985, bringing the finest peaches and citrus from our Florida groves directly to your neighborhood. A passion for all things fruit.", + url: `${BASE_URL}/indian-river-direct/about`, + images: [ + { + url: `${BASE_URL}/og-indian-river-about.jpg`, + width: 1200, + height: 630, + alt: "Indian River Direct - Our Family Grove Story", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Our Story | Indian River Direct", + description: + "Since 1985, bringing the finest peaches and citrus from our Florida groves directly to your neighborhood.", + images: [`${BASE_URL}/og-indian-river-about.jpg`], + }, + alternates: { + canonical: `${BASE_URL}/indian-river-direct/about`, + }, +}; + +export default function IndianRiverAboutLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..1b45b49 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,24 @@ +import { MetadataRoute } from "next"; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yourdomain.com"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: "/", + disallow: [ + "/admin/*", + "/api/*", + "/wholesale/portal", // Wholesale is behind auth anyway + "/cart", + "/checkout", + "/water/admin/*", + ], + }, + ], + sitemap: `${BASE_URL}/sitemap.xml`, + host: BASE_URL, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..939d234 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,101 @@ +import { MetadataRoute } from "next"; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yourdomain.com"; + +export default function sitemap(): MetadataRoute.Sitemap { + const now = new Date(); + + return [ + // Main site + { + url: BASE_URL, + lastModified: now, + changeFrequency: "weekly", + priority: 1, + }, + { + url: `${BASE_URL}/contact`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.6, + }, + { + url: `${BASE_URL}/pricing`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.7, + }, + { + url: `${BASE_URL}/privacy-policy`, + lastModified: now, + changeFrequency: "yearly", + priority: 0.3, + }, + { + url: `${BASE_URL}/terms-and-conditions`, + lastModified: now, + changeFrequency: "yearly", + priority: 0.3, + }, + + // Tuxedo Corn storefront + { + url: `${BASE_URL}/tuxedo`, + lastModified: now, + changeFrequency: "weekly", + priority: 0.9, + }, + { + url: `${BASE_URL}/tuxedo/about`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.7, + }, + { + url: `${BASE_URL}/tuxedo/faq`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.6, + }, + { + url: `${BASE_URL}/tuxedo/contact`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.6, + }, + + // Indian River Direct storefront + { + url: `${BASE_URL}/indian-river-direct`, + lastModified: now, + changeFrequency: "weekly", + priority: 0.9, + }, + { + url: `${BASE_URL}/indian-river-direct/about`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.7, + }, + { + url: `${BASE_URL}/indian-river-direct/faq`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.6, + }, + { + url: `${BASE_URL}/indian-river-direct/contact`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.6, + }, + + // Wholesale portal + { + url: `${BASE_URL}/wholesale/portal`, + lastModified: now, + changeFrequency: "weekly", + priority: 0.8, + }, + ]; +} diff --git a/src/app/tuxedo/about/layout.tsx b/src/app/tuxedo/about/layout.tsx new file mode 100644 index 0000000..a0c9ba1 --- /dev/null +++ b/src/app/tuxedo/about/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yourdomain.com"; + +export const metadata: Metadata = { + title: "Our Story | Three Generations of Sweet Corn Excellence", + description: + "Learn about Tuxedo Corn's heritage - three generations of growing and shipping Olathe Sweet Sweet Corn from our family farm in Olathe, Colorado since 1982.", + openGraph: { + title: "Our Story | Tuxedo Corn", + description: + "Three generations of sweet corn excellence. Learn about our family farm and the Olathe Sweet difference.", + url: `${BASE_URL}/tuxedo/about`, + images: [ + { + url: `${BASE_URL}/og-tuxedo-about.jpg`, + width: 1200, + height: 630, + alt: "Tuxedo Corn Family Farm - Three Generations of Excellence", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Our Story | Tuxedo Corn", + description: + "Three generations of sweet corn excellence. Learn about our family farm and the Olathe Sweet difference.", + images: [`${BASE_URL}/og-tuxedo-about.jpg`], + }, + alternates: { + canonical: `${BASE_URL}/tuxedo/about`, + }, +}; + +export default function TuxedoAboutLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/components/admin/AdminOrdersPanel.tsx b/src/components/admin/AdminOrdersPanel.tsx index d42862a..c0113b4 100644 --- a/src/components/admin/AdminOrdersPanel.tsx +++ b/src/components/admin/AdminOrdersPanel.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo, useCallback } from "react"; import { markPickupComplete } from "@/actions/pickup"; +import { formatDate } from "@/lib/format-date"; type OrderItem = { id: string; @@ -48,17 +49,17 @@ type AdminOrdersPanelProps = { brandId: string | null; }; +type StatusTab = "all" | "pending" | "picked_up"; + function shortId(id: string) { return id.slice(0, 8).toUpperCase(); } -function formatDate(iso: string | undefined) { - if (!iso) return "—"; - return new Date(iso).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); +function formatCurrency(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); } export { formatDate }; @@ -71,14 +72,75 @@ export default function AdminOrdersPanel({ const [orders, setOrders] = useState(initialOrders); const [stops] = useState(initialStops); const [search, setSearch] = useState(""); - const [stopFilter, setStopFilter] = useState(""); + const [activeTab, setActiveTab] = useState("all"); + const [selectedStops, setSelectedStops] = useState([]); + const [showStopDropdown, setShowStopDropdown] = useState(false); const [pickingUp, setPickingUp] = useState(null); - const [squareFilter, setSquareFilter] = useState<"all" | "square" | "other">("all"); - const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "picked_up">("all"); const [page, setPage] = useState(0); const [pickupToast, setPickupToast] = useState(null); - const PAGE_SIZE = 50; + const PAGE_SIZE = 20; + // Filter orders based on all criteria + const filteredOrders = useMemo(() => { + return orders.filter((order) => { + // Status filter + if (activeTab === "pending" && order.pickup_complete) return false; + if (activeTab === "picked_up" && !order.pickup_complete) return false; + + // Stop filter + if (selectedStops.length > 0 && order.stop_id && !selectedStops.includes(order.stop_id)) { + return false; + } + + // Search filter + if (search.trim()) { + const searchLower = search.toLowerCase(); + const matchesName = order.customer_name.toLowerCase().includes(searchLower); + const matchesPhone = (order.customer_phone ?? "").toLowerCase().includes(searchLower); + const matchesId = order.id.toLowerCase().includes(searchLower); + if (!matchesName && !matchesPhone && !matchesId) return false; + } + + return true; + }); + }, [orders, activeTab, selectedStops, search]); + + // Pagination + const totalFiltered = filteredOrders.length; + const totalPages = Math.ceil(totalFiltered / PAGE_SIZE); + const paginatedOrders = filteredOrders.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + // Stats + const pendingCount = orders.filter((o) => !o.pickup_complete).length; + const pickedUpCount = orders.filter((o) => o.pickup_complete).length; + + // Toggle stop selection + const toggleStop = useCallback((stopId: string) => { + setSelectedStops((prev) => + prev.includes(stopId) ? prev.filter((id) => id !== stopId) : [...prev, stopId] + ); + setPage(0); + }, []); + + // Clear all stop filters + const clearStopFilters = useCallback(() => { + setSelectedStops([]); + setPage(0); + }, []); + + // Handle tab change + const handleTabChange = useCallback((tab: StatusTab) => { + setActiveTab(tab); + setPage(0); + }, []); + + // Handle search + const handleSearch = useCallback((value: string) => { + setSearch(value); + setPage(0); + }, []); + + // Handle pickup async function handleMarkPickup(orderId: string) { setPickingUp(orderId); const result = await markPickupComplete(orderId, brandId); @@ -101,273 +163,348 @@ export default function AdminOrdersPanel({ setPickingUp(null); } - const filtered = orders.filter((o) => { - const matchesSearch = - !search || - o.customer_name.toLowerCase().includes(search.toLowerCase()) || - (o.customer_phone ?? "").includes(search) || - o.id.includes(search.toUpperCase().slice(0, 8)); - - const matchesStop = !stopFilter || o.stop_id === stopFilter; - - const matchesSquare = - squareFilter === "all" || - (squareFilter === "square" && o.payment_processor === "square") || - (squareFilter === "other" && o.payment_processor !== "square"); - - const matchesStatus = - statusFilter === "all" || - (statusFilter === "pending" && !o.pickup_complete) || - (statusFilter === "picked_up" && o.pickup_complete); - - return matchesSearch && matchesStop && matchesSquare && matchesStatus; - }); - - const totalFiltered = filtered.length; - const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); - const totalPages = Math.ceil(totalFiltered / PAGE_SIZE); - - const pendingCount = filtered.filter((o) => !o.pickup_complete).length; - const pickedUpCount = filtered.filter((o) => o.pickup_complete).length; - return ( -
- {/* Header */} -
-
+
+
+ {/* Header */} +
-

Orders

-

- {pendingCount} pending - {pickedUpCount > 0 && ` · ${pickedUpCount} picked up`} +

Orders

+
+ + {pendingCount} pending + + + {pickedUpCount} picked up + {brandId && ( - (brand-scoped) + + Brand scoped + )} -

+
-
-
- {/* Status filter + pagination */} -
-
- {(["all", "pending", "picked_up"] as const).map((f) => ( + {/* Filter Bar */} +
+
+ {/* Status Tabs */} +
+ {(["all", "pending", "picked_up"] as const).map((tab) => ( + + ))} +
+ + {/* Search */} +
+ + + + handleSearch(e.target.value)} + className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all" + /> +
+ + {/* Stop Multi-Select */} +
- ))} + + {showStopDropdown && ( + <> +
setShowStopDropdown(false)} + /> +
+
+ Filter by Stop + +
+
+ {stops.map((stop) => ( + + ))} +
+
+ + )} +
+ + {/* Results count */} +
+ {totalFiltered} order{totalFiltered !== 1 ? "s" : ""} +
- {totalPages > 1 && ( -
- - {page + 1} / {totalPages} - + + {/* Selected stop chips */} + {selectedStops.length > 0 && ( +
+ Selected: + {selectedStops.map((stopId) => { + const stop = stops.find((s) => s.id === stopId); + if (!stop) return null; + return ( + + {stop.city}, {stop.state} + + + ); + })}
)}
- {/* Search + Stop + Square filter */} -
- setSearch(e.target.value)} - className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 font-mono transition-colors" - /> - -
- {(["all", "square", "other"] as const).map((f) => ( - - ))} -
-
- - {/* Table */} - {paginated.length === 0 ? ( -
- No orders found + {/* Orders Table */} + {paginatedOrders.length === 0 ? ( +
+ + + +

No orders found

+

Try adjusting your filters

) : ( -
- +
+
- - - - - - - - - + + + + + + + + - - {paginated.map((order) => ( - + {paginatedOrders.map((order) => ( + + className="hover:bg-stone-50 transition-colors" + > + {/* Order ID */} + + + {/* Customer */} + + + {/* Stop */} + + + {/* Date */} + + + {/* Items */} + + + {/* Total */} + + + {/* Status & Actions */} + + ))}
OrderCustomerStopDateItemsTotalStatusActions
+ Order + + Customer + + Stop + + Date + + Items + + Total + + Status +
+ {shortId(order.id)} + +
{order.customer_name}
+ {order.customer_phone && ( +
{order.customer_phone}
+ )} +
+ {order.stops ? ( + + {order.stops.city}, {order.stops.state} + + ) : ( + + )} + + {formatDate(order.created_at)} + + + {order.order_items?.length ?? 0} + + + + {formatCurrency(order.subtotal)} + + +
+ + {order.pickup_complete ? "Picked Up" : "Pending"} + + {order.payment_processor === "square" && ( + + Square + + )} +
+
)} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, totalFiltered)} of {totalFiltered} +

+
+ + + {page + 1} / {totalPages} + + +
+
+ )} + + {/* Pickup Toast */} + {pickupToast && ( +
+
+
+ + + +
+ Pickup confirmed! +
+
+ )}
); -} - -type OrderRowProps = { - order: Order; - onMarkPickup: (orderId: string) => void; - pickingUp: string | null; - shortId: string; - showToast?: boolean; -}; - -function OrderRow({ order, onMarkPickup, pickingUp, shortId, showToast }: OrderRowProps) { - const itemCount = order.order_items?.length ?? 0; - - return ( - - {/* Order ID */} - - {shortId} - - - {/* Customer */} - -
{order.customer_name}
- {order.customer_phone && ( -
{order.customer_phone}
- )} - - - {/* Stop */} - - {order.stops ? ( - {order.stops.city}, {order.stops.state} - ) : ( - - )} - - - {/* Date */} - - {formatDate(order.created_at)} - - - {/* Items */} - - {itemCount} - - - {/* Total */} - - - ${Number(order.subtotal).toFixed(2)} - - - - {/* Status */} - - - {order.pickup_complete ? "Picked Up" : "Pending"} - - {order.payment_processor === "square" && ( - - Square - - )} - - - {/* Actions */} - -
- - View / Edit - - {!order.pickup_complete && ( - - )} - {showToast && ( -
- Done -
- )} -
- - - ); } \ No newline at end of file diff --git a/src/components/admin/DashboardHeader.tsx b/src/components/admin/DashboardHeader.tsx new file mode 100644 index 0000000..9b44505 --- /dev/null +++ b/src/components/admin/DashboardHeader.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState, useCallback } from "react"; +import UpgradePlanModal from "@/components/admin/UpgradePlanModal"; + +interface DashboardHeaderProps { + brandId: string | null; + brandName: string; + planTier: string; +} + +export default function DashboardHeader({ brandId, brandName, planTier }: DashboardHeaderProps) { + const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); + + const openUpgrade = useCallback(() => setIsUpgradeOpen(true), []); + const closeUpgrade = useCallback(() => setIsUpgradeOpen(false), []); + + return ( + <> +
+
+

Control Center

+

{brandName}

+
+
+ + Billing → + + {planTier === "starter" && brandId && ( + + )} +
+
+ + {planTier === "starter" && brandId && ( + + )} + + ); +} \ No newline at end of file diff --git a/src/components/admin/DashboardUpgradeButton.tsx b/src/components/admin/DashboardUpgradeButton.tsx new file mode 100644 index 0000000..417c86c --- /dev/null +++ b/src/components/admin/DashboardUpgradeButton.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState, useCallback } from "react"; +import UpgradePlanModal from "@/components/admin/UpgradePlanModal"; + +interface DashboardUpgradeButtonProps { + brandId: string | null; + currentTier: string; +} + +export default function DashboardUpgradeButton({ brandId, currentTier }: DashboardUpgradeButtonProps) { + const [isOpen, setIsOpen] = useState(false); + + const openModal = useCallback(() => setIsOpen(true), []); + const closeModal = useCallback(() => setIsOpen(false), []); + + if (!brandId) return null; + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/components/admin/UpgradePlanModal.tsx b/src/components/admin/UpgradePlanModal.tsx new file mode 100644 index 0000000..fef0cc9 --- /dev/null +++ b/src/components/admin/UpgradePlanModal.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout"; + +type PlanTier = "starter" | "farm" | "enterprise"; + +interface Plan { + id: PlanTier; + name: string; + price: number; + annualPrice: number; + description: string; + features: string[]; + highlighted?: boolean; +} + +const PLANS: Plan[] = [ + { + id: "starter", + name: "Starter", + price: 49, + annualPrice: 441, + description: "Perfect for small farms just getting started", + features: [ + "Products catalog", + "10 stops/month", + "Orders & pickup", + "25 products max", + "1 team member", + "Email support", + ], + }, + { + id: "farm", + name: "Farm", + price: 149, + annualPrice: 1341, + description: "Everything you need to grow your wholesale business", + features: [ + "Everything in Starter", + "Unlimited stops", + "Wholesale portal", + "Harvest Reach (email/SMS)", + "Unlimited products", + "5 team members", + "Priority support", + ], + highlighted: true, + }, + { + id: "enterprise", + name: "Enterprise", + price: 399, + annualPrice: 3591, + description: "Custom solutions for large-scale operations", + features: [ + "Everything in Farm", + "AI Intelligence Pack", + "SMS Campaigns", + "Square Sync", + "Water Log", + "Unlimited team members", + "Dedicated SLA", + ], + }, +]; + +interface UpgradePlanModalProps { + isOpen: boolean; + onClose: () => void; + brandId: string; + currentTier: string; + defaultAnnual?: boolean; +} + +export default function UpgradePlanModal({ + isOpen, + onClose, + brandId, + currentTier, + defaultAnnual = false, +}: UpgradePlanModalProps) { + const [annual, setAnnual] = useState(defaultAnnual); + const [loading, setLoading] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + // Handle animation state + useEffect(() => { + if (isOpen) { + requestAnimationFrame(() => setIsVisible(true)); + } else { + setIsVisible(false); + } + }, [isOpen]); + + // Lock body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) { + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + } + }, [isOpen, onClose]); + + const handleUpgrade = useCallback(async (targetTier: PlanTier) => { + const tierOrder = ["starter", "farm", "enterprise"]; + const currentIndex = tierOrder.indexOf(currentTier); + const targetIndex = tierOrder.indexOf(targetTier); + + // No upgrades for current or downgrade + if (targetIndex <= currentIndex) return; + + setLoading(targetTier); + try { + const result = await createPlanUpgradeCheckout(brandId, targetTier, annual ? "annual" : "monthly"); + if (result.success && result.url) { + window.location.href = result.url; + } else { + alert(result.error ?? "Failed to start upgrade"); + } + } finally { + setLoading(null); + } + }, [brandId, currentTier, annual]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + if (!isOpen && !isVisible) return null; + + return ( +
+ {/* Glass backdrop */} +
+ + {/* Modal container */} +
+ {/* Header */} +
+ + +
+

Upgrade Your Plan

+

Choose the perfect plan for your business

+
+ + {/* Billing toggle */} +
+
+ + +
+
+
+ + {/* Plans grid */} +
+ {PLANS.map((plan) => { + const tierOrder = ["starter", "farm", "enterprise"]; + const currentIndex = tierOrder.indexOf(currentTier); + const targetIndex = tierOrder.indexOf(plan.id); + const isCurrent = plan.id === currentTier; + const isDowngrade = targetIndex < currentIndex; + const canUpgrade = targetIndex > currentIndex; + + return ( +
+ {plan.highlighted && ( +
+ + Most Popular + +
+ )} + +
+ {/* Plan header */} +
+

{plan.name}

+
+ + ${annual ? plan.annualPrice : plan.price} + + /{annual ? "year" : "mo"} +
+ {annual && ( +

+ ${Math.round(annual ? plan.annualPrice / 12 : plan.price)}/month billed annually +

+ )} +

{plan.description}

+
+ + {/* Features */} +
    + {plan.features.map((feature, i) => ( +
  • +
    + + + +
    + {feature} +
  • + ))} +
+ + {/* Action button */} + {isCurrent ? ( +
+ Current Plan +
+ ) : isDowngrade ? ( +
+ Downgrade via support +
+ ) : ( + + )} +
+
+ ); + })} +
+ + {/* Footer */} +
+

+ Need a custom solution?{" "} + + Contact us + {" "} + for Enterprise pricing. +

+
+
+
+ ); +} + +// Hook for easy integration +export function useUpgradePlanModal(brandId: string, currentTier: string) { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + + const Modal = useCallback(() => ( + + ), [isOpen, close, brandId, currentTier]); + + return { open, close, Modal }; +} \ No newline at end of file