diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts new file mode 100644 index 0000000..fb11dd7 --- /dev/null +++ b/src/actions/dashboard.ts @@ -0,0 +1,297 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type DashboardStats = { + todayOrders: number; + todayRevenue: number; + pendingStops: number; + activeProducts: number; + weeklyOrders: number[]; + recentOrders: Array<{ + id: string; + customer_name: string; + total: number; + status: string; + created_at: string; + }>; +}; + +export type DashboardSummary = { + total_revenue: number; + total_orders: number; + active_stops: number; + active_products: number; +}; + +// ── Helper ──────────────────────────────────────────────────────────────────── + +async function brandScopedFetch( + endpoint: string, + params?: Record +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) throw new Error("Not authenticated"); + + let url = `${supabaseUrl}/rest/v1${endpoint}`; + if (params) { + const searchParams = new URLSearchParams(params); + url += `?${searchParams.toString()}`; + } + + const response = await fetch(url, { + headers: { + ...svcHeaders(supabaseKey), + }, + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Dashboard fetch failed: ${err}`); + } + + return response.json() as Promise; +} + +// ── Dashboard Stats Actions ───────────────────────────────────────────────── + +export async function getDashboardStats(): Promise { + try { + const adminUser = await getAdminUser(); + if (!adminUser) throw new Error("Not authenticated"); + + const brandId = adminUser.brand_id ?? null; + + // Get today's date range + const today = new Date(); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); + + // Get start of week (7 days ago) - kept for potential weekly comparison + const _weekStart = new Date(startOfDay); + _weekStart.setDate(_weekStart.getDate() - 6); + + // Fetch today's orders + let todayOrdersQuery = `${supabaseUrl}/rest/v1/orders?select=id,subtotal,status`; + todayOrdersQuery += `&created_at=gte.${startOfDay.toISOString()}`; + todayOrdersQuery += `&created_at=lt.${endOfDay.toISOString()}`; + if (brandId) { + todayOrdersQuery += `&brand_id=eq.${brandId}`; + } + + const todayOrdersRes = await fetch(todayOrdersQuery, { + headers: svcHeaders(supabaseKey), + }); + const todayOrders = await todayOrdersRes.json(); + + // Calculate today's revenue and orders + const validOrders = (todayOrders as Array<{subtotal: number; status: string}>) + .filter(o => o.status !== "cancelled"); + const todayRevenue = validOrders + .reduce((sum, o) => sum + (o.subtotal || 0), 0); + const todayOrderCount = validOrders.length; + + // Fetch pending stops (stops where date >= today and status is scheduled/upcoming) + let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; + stopsQuery += `&date=gte.${startOfDay.toISOString().split("T")[0]}`; + stopsQuery += `&status=eq.scheduled`; + if (brandId) { + stopsQuery += `&brand_id=eq.${brandId}`; + } + stopsQuery += `&limit=100`; + + const stopsRes = await fetch(stopsQuery, { + headers: svcHeaders(supabaseKey), + }); + const pendingStopsData = await stopsRes.json(); + const pendingStops = Array.isArray(pendingStopsData) ? pendingStopsData.length : 0; + + // Fetch active products + let productsQuery = `${supabaseUrl}/rest/v1/products?select=id`; + productsQuery += `&active=eq.true`; + if (brandId) { + productsQuery += `&brand_id=eq.${brandId}`; + } + productsQuery += `&limit=1000`; + + const productsRes = await fetch(productsQuery, { + headers: svcHeaders(supabaseKey), + }); + const productsData = await productsRes.json(); + const activeProducts = Array.isArray(productsData) ? productsData.length : 0; + + // Fetch weekly orders for chart (last 7 days) + const weeklyOrders: number[] = []; + for (let i = 6; i >= 0; i--) { + const dayStart = new Date(startOfDay); + dayStart.setDate(dayStart.getDate() - i); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + let dayQuery = `${supabaseUrl}/rest/v1/orders?select=id,status`; + dayQuery += `&created_at=gte.${dayStart.toISOString()}`; + dayQuery += `&created_at=lt.${dayEnd.toISOString()}`; + if (brandId) { + dayQuery += `&brand_id=eq.${brandId}`; + } + dayQuery += `&limit=1`; + + const dayRes = await fetch(dayQuery, { + headers: svcHeaders(supabaseKey), + }); + // Use X-Total-Count header or count from response + const count = dayRes.headers.get("X-Total-Count"); + weeklyOrders.push(count ? parseInt(count) : 0); + } + + // Fetch recent orders (last 10) + let recentQuery = `${supabaseUrl}/rest/v1/orders?select=id,customer_name,subtotal,status,created_at`; + recentQuery += `&order=created_at.desc`; + recentQuery += `&limit=10`; + if (brandId) { + recentQuery += `&brand_id=eq.${brandId}`; + } + + const recentRes = await fetch(recentQuery, { + headers: { + ...svcHeaders(supabaseKey), + Prefer: "count=exact", + }, + }); + const recentOrdersData = await recentRes.json(); + + const recentOrders = (Array.isArray(recentOrdersData) ? recentOrdersData : []) + .filter((o: {status: string}) => o.status !== "cancelled") + .map((o: {id: string; customer_name: string; subtotal: number; status: string; created_at: string}) => ({ + id: o.id || "", + customer_name: o.customer_name || "Guest", + total: o.subtotal || 0, + status: o.status || "unknown", + created_at: formatTimeAgo(o.created_at), + })); + + return { + todayOrders: todayOrderCount, + todayRevenue: Math.round(todayRevenue * 100) / 100, + pendingStops, + activeProducts, + weeklyOrders, + recentOrders, + }; + } catch (error) { + console.error("Failed to fetch dashboard stats:", error); + // Return zeros on error + return { + todayOrders: 0, + todayRevenue: 0, + pendingStops: 0, + activeProducts: 0, + weeklyOrders: [0, 0, 0, 0, 0, 0, 0], + recentOrders: [], + }; + } +} + +export async function getDashboardSummary(): Promise { + try { + const adminUser = await getAdminUser(); + if (!adminUser) throw new Error("Not authenticated"); + + const brandId = adminUser.brand_id ?? null; + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // Get gross sales from reports RPC + const rpcRes = await fetch(`${supabaseUrl}/rest/v1/rpc/get_reports_summary`, { + method: "POST", + headers: { + ...svcHeaders(supabaseKey), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + p_brand_id: brandId, + p_start_date: thirtyDaysAgo.toISOString().split("T")[0], + p_end_date: new Date().toISOString().split("T")[0], + }), + }); + + let total_revenue = 0; + let total_orders = 0; + + if (rpcRes.ok) { + const data = await rpcRes.json(); + total_revenue = data?.gross_sales ?? 0; + total_orders = data?.total_orders ?? 0; + } + + // Get active stops count + let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; + stopsQuery += `&date=gte.${new Date().toISOString().split("T")[0]}`; + stopsQuery += `&status=eq.scheduled`; + if (brandId) { + stopsQuery += `&brand_id=eq.${brandId}`; + } + + const stopsRes = await fetch(stopsQuery, { + headers: { + ...svcHeaders(supabaseKey), + Prefer: "count=exact", + }, + }); + const activeStops = parseInt(stopsRes.headers.get("X-Total-Count") || "0"); + + // Get active products count + let productsQuery = `${supabaseUrl}/rest/v1/products?select=id&active=eq.true`; + if (brandId) { + productsQuery += `&brand_id=eq.${brandId}`; + } + + const productsRes = await fetch(productsQuery, { + headers: { + ...svcHeaders(supabaseKey), + Prefer: "count=exact", + }, + }); + const activeProducts = parseInt(productsRes.headers.get("X-Total-Count") || "0"); + + return { + total_revenue, + total_orders, + active_stops: activeStops, + active_products: activeProducts, + }; + } catch (error) { + console.error("Failed to fetch dashboard summary:", error); + return { + total_revenue: 0, + total_orders: 0, + active_stops: 0, + active_products: 0, + }; + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function formatTimeAgo(dateString: string): string { + if (!dateString) return "Unknown"; + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} \ No newline at end of file diff --git a/src/app/indian-river-direct/faq/FAQClientPage.tsx b/src/app/indian-river-direct/faq/FAQClientPage.tsx index eccf860..5aab97e 100644 --- a/src/app/indian-river-direct/faq/FAQClientPage.tsx +++ b/src/app/indian-river-direct/faq/FAQClientPage.tsx @@ -151,7 +151,7 @@ export default function IndianRiverFAQPage() {
{/* Page header */} -
+

Questions

FAQ diff --git a/src/app/indian-river-direct/page.tsx b/src/app/indian-river-direct/page.tsx index fd23d70..0727845 100644 --- a/src/app/indian-river-direct/page.tsx +++ b/src/app/indian-river-direct/page.tsx @@ -354,13 +354,13 @@ export default function IndianRiverDirectPage() { ) : (
{products.map((product) => ( -
+
{/* Product Image */} -
+
{product.image_url ? ( - {product.name} + {product.name} ) : ( -
🍑
+
🍑
)} {/* Badges */}
@@ -393,7 +393,7 @@ export default function IndianRiverDirectPage() { {product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`} {product.price > 0 && ( - )} @@ -483,10 +483,10 @@ export default function IndianRiverDirectPage() {

Ready to order?

Find a stop near you or browse our seasonal products.

- + Find a Stop - + Our Story
diff --git a/src/app/tuxedo/faq/FAQClientPage.tsx b/src/app/tuxedo/faq/FAQClientPage.tsx index d03262c..9e96e2c 100644 --- a/src/app/tuxedo/faq/FAQClientPage.tsx +++ b/src/app/tuxedo/faq/FAQClientPage.tsx @@ -90,46 +90,46 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s ); }, [items, searchQuery]); - if (filtered.length === 0) { - return ( -
-

No results for "{searchQuery}"

-

Try a different term or browse all categories below.

-
- ); - } - return (
- {filtered.map((item) => { - const isOpen = open === item.question; - return ( -
- -
-
-

{item.answer}

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

No results for "{searchQuery}"

+

Try a different term or browse all categories below.

+
+ ) : ( + <> + {filtered.map((item) => { + const isOpen = open === item.question; + return ( +
+ +
+
+

{item.answer}

+
+
-
-
- ); - })} + ); + })} + + )}
); } @@ -162,7 +162,7 @@ export default function TuxedoFAQPage() { placeholder="Search questions..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="w-full rounded-2xl border border-stone-200 bg-white pl-10 sm:pl-11 pr-5 py-3.5 sm:py-4 text-sm sm:text-base text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors shadow-sm" + className="w-full rounded-2xl border border-stone-200 bg-white pl-10 sm:pl-11 pr-5 py-3.5 sm:py-4 text-sm sm:text-base text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900/20 transition-all duration-200 shadow-sm hover:shadow-md focus:shadow-lg" /> {searchQuery && (
{/* Stop info */} -
+
diff --git a/src/components/admin/DashboardClient.tsx b/src/components/admin/DashboardClient.tsx index 4515c61..a1c7512 100644 --- a/src/components/admin/DashboardClient.tsx +++ b/src/components/admin/DashboardClient.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, type ReactNode } from "react"; import Link from "next/link"; import UpgradePlanModal from "@/components/admin/UpgradePlanModal"; import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system"; +import { getDashboardStats, type DashboardStats } from "@/actions/dashboard"; type Section = { title: string; @@ -179,39 +180,6 @@ type Props = { limits: { max_users: number; max_stops_monthly: number; max_products: number }; }; -// Stats data type -interface StatsData { - todayOrders: number; - todayRevenue: number; - pendingStops: number; - activeProducts: number; - weeklyOrders: number[]; - recentOrders: Array<{ - id: string; - customer_name: string; - total: number; - status: string; - created_at: string; - }>; -} - -// Simulated stats for demo -const getStatsData = async (brandId: string | null): Promise => { - // In production, fetch from API - return { - todayOrders: 12, - todayRevenue: 847.50, - pendingStops: 3, - activeProducts: 45, - weeklyOrders: [8, 15, 12, 22, 18, 25, 12], - recentOrders: [ - { id: "ord-001", customer_name: "Fresh Market Co", total: 125.00, status: "pending", created_at: "2h ago" }, - { id: "ord-002", customer_name: "Green Valley Grocery", total: 89.50, status: "processing", created_at: "4h ago" }, - { id: "ord-003", customer_name: "Farm Fresh Direct", total: 234.00, status: "shipped", created_at: "6h ago" }, - ], - }; -}; - export default function DashboardClient({ brandId, brandName, @@ -223,14 +191,14 @@ export default function DashboardClient({ }: Props) { const [activeTab, setActiveTab] = useState("operations"); const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); - const [stats, setStats] = useState(null); + const [stats, setStats] = useState(null); const [isLoadingStats, setIsLoadingStats] = useState(true); useEffect(() => { const loadStats = async () => { setIsLoadingStats(true); try { - const data = await getStatsData(brandId); + const data = await getDashboardStats(); setStats(data); } catch (err) { console.error("Failed to load stats:", err); @@ -239,7 +207,7 @@ export default function DashboardClient({ } }; loadStats(); - }, [brandId]); + }, []); const usagePct = { users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0, diff --git a/src/components/admin/design-system/AdminButton.tsx b/src/components/admin/design-system/AdminButton.tsx index c3bff21..237a589 100644 --- a/src/components/admin/design-system/AdminButton.tsx +++ b/src/components/admin/design-system/AdminButton.tsx @@ -59,7 +59,8 @@ export default function AdminButton({ inline-flex items-center justify-center font-semibold transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed - shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 + focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 + active:scale-[0.98] `; const variantClass = variantClasses[variant]; diff --git a/src/components/admin/design-system/AdminCard.tsx b/src/components/admin/design-system/AdminCard.tsx index 03d5b76..e8f0816 100644 --- a/src/components/admin/design-system/AdminCard.tsx +++ b/src/components/admin/design-system/AdminCard.tsx @@ -12,7 +12,7 @@ type AdminCardProps = { export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) { return (
{icon ? ( -
{icon}
+
{icon}
) : ( -
+
)}

{title}

- {description &&

{description}

} - {action &&
{action}
} + {description &&

{description}

} + {action &&
{action}
}
); } \ No newline at end of file diff --git a/src/components/admin/design-system/AdminFilterBar.tsx b/src/components/admin/design-system/AdminFilterBar.tsx index 40dc455..d1e0bea 100644 --- a/src/components/admin/design-system/AdminFilterBar.tsx +++ b/src/components/admin/design-system/AdminFilterBar.tsx @@ -42,7 +42,7 @@ export default function AdminFilterBar({ const options = statusOptions ?? defaultOptions; return ( -
+
{/* Search */}
@@ -54,7 +54,7 @@ export default function AdminFilterBar({ placeholder={searchPlaceholder} value={search} onChange={(e) => onSearchChange(e.target.value)} - className="w-full rounded-xl border border-[var(--admin-border)] bg-white py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)]" + className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:bg-white focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-all duration-150" />
diff --git a/src/components/admin/design-system/AdminFormElements.tsx b/src/components/admin/design-system/AdminFormElements.tsx index 45393f5..5272636 100644 --- a/src/components/admin/design-system/AdminFormElements.tsx +++ b/src/components/admin/design-system/AdminFormElements.tsx @@ -58,7 +58,7 @@ export function AdminTextInput({ value={value} onChange={onChange} disabled={disabled} - className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] disabled:opacity-50 ${className}`} + className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] disabled:opacity-50 transition-all duration-150 ${className}`} maxLength={maxLength} autoComplete={autoComplete} step={step} diff --git a/src/components/admin/design-system/AdminPagination.tsx b/src/components/admin/design-system/AdminPagination.tsx index 61ed13f..9319020 100644 --- a/src/components/admin/design-system/AdminPagination.tsx +++ b/src/components/admin/design-system/AdminPagination.tsx @@ -20,7 +20,7 @@ export default function AdminPagination({