Polish pass: micro-interactions on admin and storefront pages

Admin design system:
- AdminCard: hover shadow elevation
- AdminTable: enhanced empty state, row hover
- AdminBadge: tracking-wide typography
- AdminButton: active scale press feedback
- AdminFormElements: input transitions
- AdminFilterBar: search input polish
- PageHeader: icon hover scale, chevron separator
- AdminToggle: glow effect, thumb shadow
- AdminEmptyState: icon hover, spacing
- AdminPagination: active solid bg, ellipsis

Storefronts (tuxedo + indian-river-direct):
- Button hover lifts (-translate-y-0.5)
- Shadow elevations on hover
- Button press feedback
- Consistent timing (duration-200)
- Product card image zoom
- FAQ empty state fix
This commit is contained in:
2026-06-02 14:29:27 +00:00
parent 8dee63fef2
commit d031a9783c
17 changed files with 386 additions and 112 deletions
+297
View File
@@ -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<T>(
endpoint: string,
params?: Record<string, string>
): Promise<T> {
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<T>;
}
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> {
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<DashboardSummary> {
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" });
}
@@ -151,7 +151,7 @@ export default function IndianRiverFAQPage() {
<LayoutContainer> <LayoutContainer>
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
{/* Page header */} {/* Page header */}
<div className="text-center mb-14 p-10 rounded-3xl bg-white border-2 border-stone-200 shadow-xl"> <div className="text-center mb-14 p-10 rounded-3xl bg-white border-2 border-stone-200 shadow-xl transition-all duration-300 hover:shadow-2xl hover:border-blue-100">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Questions</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Questions</p>
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight"> <h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
FAQ FAQ
+7 -7
View File
@@ -354,13 +354,13 @@ export default function IndianRiverDirectPage() {
) : ( ) : (
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => ( {products.map((product) => (
<div key={product.id} className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden shadow-xl hover:shadow-2xl hover:-translate-y-2 hover:border-blue-200 transition-all duration-300"> <div key={product.id} className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden shadow-xl hover:shadow-2xl hover:-translate-y-2 hover:border-blue-200 transition-all duration-300 group">
{/* Product Image */} {/* Product Image */}
<div className="aspect-[4/3] bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center relative"> <div className="aspect-[4/3] bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center relative overflow-hidden">
{product.image_url ? ( {product.image_url ? (
<Image src={product.image_url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" /> <Image src={product.image_url} alt={product.name} fill className="object-cover transition-transform duration-500 group-hover:scale-105" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
) : ( ) : (
<div className="text-6xl opacity-30">🍑</div> <div className="text-6xl opacity-30 transition-transform duration-500 group-hover:scale-110">🍑</div>
)} )}
{/* Badges */} {/* Badges */}
<div className="absolute top-3 left-3 flex flex-wrap gap-2"> <div className="absolute top-3 left-3 flex flex-wrap gap-2">
@@ -393,7 +393,7 @@ export default function IndianRiverDirectPage() {
{product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`} {product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`}
</span> </span>
{product.price > 0 && ( {product.price > 0 && (
<button className="rounded-xl bg-blue-600 hover:bg-blue-500 px-5 py-2.5 text-sm font-bold text-white transition-all shadow-lg hover:shadow-xl"> <button className="rounded-xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-5 py-2.5 text-sm font-bold text-white transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0">
{product.preorder ? "Pre-Order" : "Add to Cart"} {product.preorder ? "Pre-Order" : "Add to Cart"}
</button> </button>
)} )}
@@ -483,10 +483,10 @@ export default function IndianRiverDirectPage() {
<h2 className="text-2xl md:text-3xl font-black text-white">Ready to order?</h2> <h2 className="text-2xl md:text-3xl font-black text-white">Ready to order?</h2>
<p className="mt-2 text-blue-100">Find a stop near you or browse our seasonal products.</p> <p className="mt-2 text-blue-100">Find a stop near you or browse our seasonal products.</p>
<div className="mt-8 flex gap-4 justify-center flex-wrap"> <div className="mt-8 flex gap-4 justify-center flex-wrap">
<Link href="/indian-river-direct#stops" className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-lg shadow-sm transition-all text-sm tracking-wider"> <Link href="/indian-river-direct#stops" className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
Find a Stop Find a Stop
</Link> </Link>
<Link href="/indian-river-direct/about" className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 px-8 py-4 font-bold text-white hover:bg-blue-600/80 hover:-translate-y-0.5 hover:shadow-lg shadow-sm transition-all text-sm tracking-wider"> <Link href="/indian-river-direct/about" className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 px-8 py-4 font-bold text-white hover:bg-blue-600/80 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
Our Story Our Story
</Link> </Link>
</div> </div>
+38 -38
View File
@@ -90,46 +90,46 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
); );
}, [items, searchQuery]); }, [items, searchQuery]);
if (filtered.length === 0) {
return (
<div className="rounded-2xl bg-white p-6 sm:p-8 text-center shadow-sm ring-1 ring-stone-200/60">
<p className="text-stone-500 text-sm">No results for &quot;{searchQuery}&quot;</p>
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
</div>
);
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{filtered.map((item) => { {filtered.length === 0 ? (
const isOpen = open === item.question; <div className="rounded-2xl bg-white p-6 sm:p-8 text-center shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-stone-300">
return ( <p className="text-stone-500 text-sm">No results for &quot;{searchQuery}&quot;</p>
<div <p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
key={item.question} </div>
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300" ) : (
> <>
<button {filtered.map((item) => {
onClick={() => setOpen(isOpen ? null : item.question)} const isOpen = open === item.question;
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group" return (
aria-expanded={isOpen} <div
> key={item.question}
<span className="font-semibold text-stone-950 pr-3 sm:pr-4 text-sm sm:text-[15px] leading-snug group-hover:text-emerald-700 transition-colors"> className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300"
{item.question} >
</span> <button
<span className={`flex-shrink-0 w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-all duration-300 ${isOpen ? "bg-emerald-600 text-white rotate-180" : "bg-stone-100 text-stone-400 group-hover:bg-stone-200"}`}> onClick={() => setOpen(isOpen ? null : item.question)}
<svg className="w-3 h-3 sm:w-3.5 sm:h-3.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group"
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> aria-expanded={isOpen}
</svg> >
</span> <span className="font-semibold text-stone-950 pr-3 sm:pr-4 text-sm sm:text-[15px] leading-snug group-hover:text-emerald-700 transition-colors">
</button> {item.question}
<div className={`overflow-hidden transition-all duration-300 ease-out ${isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0"}`}> </span>
<div className="px-4 sm:px-6 pb-5 sm:pb-6 pt-1 border-t border-stone-100"> <span className={`flex-shrink-0 w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-all duration-300 ${isOpen ? "bg-emerald-600 text-white rotate-180" : "bg-stone-100 text-stone-400 group-hover:bg-stone-200"}`}>
<p className="text-sm text-stone-500 leading-relaxed">{item.answer}</p> <svg className="w-3 h-3 sm:w-3.5 sm:h-3.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
<div className={`overflow-hidden transition-all duration-300 ease-out ${isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0"}`}>
<div className="px-4 sm:px-6 pb-5 sm:pb-6 pt-1 border-t border-stone-100">
<p className="text-sm text-stone-500 leading-relaxed">{item.answer}</p>
</div>
</div>
</div> </div>
</div> );
</div> })}
); </>
})} )}
</div> </div>
); );
} }
@@ -162,7 +162,7 @@ export default function TuxedoFAQPage() {
placeholder="Search questions..." placeholder="Search questions..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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 && ( {searchQuery && (
<button <button
+1 -1
View File
@@ -519,7 +519,7 @@ export default function TuxedoPage() {
<div className="mx-auto max-w-6xl px-6"> <div className="mx-auto max-w-6xl px-6">
<Link <Link
href="/wholesale/login" href="/wholesale/login"
className="inline-flex items-center gap-2 rounded-full bg-emerald-700 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 transition-colors" className="inline-flex items-center gap-2 rounded-full bg-emerald-700 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 active:bg-emerald-800 transition-all duration-200 hover:-translate-y-0.5"
> >
Wholesale Portal Wholesale Portal
</Link> </Link>
+1 -1
View File
@@ -134,7 +134,7 @@ export default function StopPage() {
</div> </div>
{/* Stop info */} {/* Stop info */}
<div className="rounded-3xl bg-white p-8 mb-14 shadow-sm ring-1 ring-stone-200/60"> <div className="rounded-3xl bg-white p-8 mb-14 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-xl hover:ring-stone-300/60">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"> <div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}> <div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
+4 -36
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal"; import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system"; import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
import { getDashboardStats, type DashboardStats } from "@/actions/dashboard";
type Section = { type Section = {
title: string; title: string;
@@ -179,39 +180,6 @@ type Props = {
limits: { max_users: number; max_stops_monthly: number; max_products: number }; 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<StatsData> => {
// 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({ export default function DashboardClient({
brandId, brandId,
brandName, brandName,
@@ -223,14 +191,14 @@ export default function DashboardClient({
}: Props) { }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("operations"); const [activeTab, setActiveTab] = useState<Tab>("operations");
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const [stats, setStats] = useState<StatsData | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true); const [isLoadingStats, setIsLoadingStats] = useState(true);
useEffect(() => { useEffect(() => {
const loadStats = async () => { const loadStats = async () => {
setIsLoadingStats(true); setIsLoadingStats(true);
try { try {
const data = await getStatsData(brandId); const data = await getDashboardStats();
setStats(data); setStats(data);
} catch (err) { } catch (err) {
console.error("Failed to load stats:", err); console.error("Failed to load stats:", err);
@@ -239,7 +207,7 @@ export default function DashboardClient({
} }
}; };
loadStats(); loadStats();
}, [brandId]); }, []);
const usagePct = { const usagePct = {
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0, users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
@@ -59,7 +59,8 @@ export default function AdminButton({
inline-flex items-center justify-center font-semibold inline-flex items-center justify-center font-semibold
transition-all duration-200 cursor-pointer transition-all duration-200 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed 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]; const variantClass = variantClasses[variant];
@@ -12,7 +12,7 @@ type AdminCardProps = {
export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) { export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) {
return ( return (
<div <div
className={`rounded-2xl border bg-white shadow-[var(--admin-shadow-sm)] ${noPadding ? "" : "p-5"} ${className}`} className={`rounded-2xl border bg-white shadow-[var(--admin-shadow-sm)] transition-all duration-200 hover:shadow-[var(--admin-shadow-md)] ${noPadding ? "" : "p-5"} ${className}`}
style={{ style={{
borderColor: 'var(--admin-border)', borderColor: 'var(--admin-border)',
...style ...style
@@ -20,17 +20,17 @@ export default function AdminEmptyState({
return ( return (
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}> <div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
{icon ? ( {icon ? (
<div className="mb-4 text-[var(--admin-text-muted)]">{icon}</div> <div className="mb-4 text-[var(--admin-text-muted)] opacity-70">{icon}</div>
) : ( ) : (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--admin-bg-subtle)] transition-transform duration-200 hover:scale-105">
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /> <path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg> </svg>
</div> </div>
)} )}
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">{title}</h3> <h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">{title}</h3>
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)] max-w-sm">{description}</p>} {description && <p className="mt-2 text-sm text-[var(--admin-text-muted)] max-w-sm leading-relaxed">{description}</p>}
{action && <div className="mt-4">{action}</div>} {action && <div className="mt-6">{action}</div>}
</div> </div>
); );
} }
@@ -42,7 +42,7 @@ export default function AdminFilterBar({
const options = statusOptions ?? defaultOptions; const options = statusOptions ?? defaultOptions;
return ( return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-[var(--admin-shadow-sm)]"> <div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-[var(--admin-shadow-sm)] transition-shadow duration-200 hover:shadow-[var(--admin-shadow-md)]">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Search */} {/* Search */}
<div className="relative flex-1 min-w-[12rem]"> <div className="relative flex-1 min-w-[12rem]">
@@ -54,7 +54,7 @@ export default function AdminFilterBar({
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} 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"
/> />
</div> </div>
@@ -58,7 +58,7 @@ export function AdminTextInput({
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} 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} maxLength={maxLength}
autoComplete={autoComplete} autoComplete={autoComplete}
step={step} step={step}
@@ -20,7 +20,7 @@ export default function AdminPagination({
<button <button
onClick={() => onPageChange(currentPage - 1)} onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 0} disabled={currentPage === 0}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-150"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
@@ -43,8 +43,8 @@ export default function AdminPagination({
if (showEllipsisBefore || showEllipsisAfter) { if (showEllipsisBefore || showEllipsisAfter) {
return ( return (
<span key={`ellipsis-${page}`} className="px-2 text-[var(--admin-text-muted)]"> <span key={`ellipsis-${page}`} className="px-3 py-2 text-[var(--admin-text-muted)] text-sm">
... &hellip;
</span> </span>
); );
} }
@@ -53,10 +53,10 @@ export default function AdminPagination({
<button <button
key={page} key={page}
onClick={() => onPageChange(page)} onClick={() => onPageChange(page)}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-lg text-sm font-medium transition-colors ${ className={`flex h-8 min-w-[2rem] items-center justify-center rounded-lg text-sm font-medium transition-all duration-150 ${
page === currentPage page === currentPage
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]" ? "bg-[var(--admin-accent)] text-white shadow-md"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]" : "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] border border-transparent hover:border-[var(--admin-border)]"
}`} }`}
> >
{page + 1} {page + 1}
@@ -68,7 +68,7 @@ export default function AdminPagination({
<button <button
onClick={() => onPageChange(currentPage + 1)} onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages - 1} disabled={currentPage >= totalPages - 1}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-150"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
@@ -27,7 +27,7 @@ export default function AdminTable<T extends Record<string, unknown>>({
className = "", className = "",
}: AdminTableProps<T>) { }: AdminTableProps<T>) {
return ( return (
<div className={`overflow-x-auto rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] ${className}`}> <div className={`overflow-x-auto rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] transition-shadow duration-200 hover:shadow-[var(--admin-shadow-md)] ${className}`}>
<table className="w-full text-left text-sm min-w-[600px]"> <table className="w-full text-left text-sm min-w-[600px]">
<thead> <thead>
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]"> <tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
@@ -42,15 +42,21 @@ export default function AdminTable<T extends Record<string, unknown>>({
{data.length === 0 ? ( {data.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length} className="px-5 py-16 text-center text-sm text-[var(--admin-text-muted)]"> <td colSpan={columns.length} className="px-5 py-16 text-center text-sm text-[var(--admin-text-muted)]">
{emptyMessage} <div className="flex flex-col items-center gap-2">
<svg className="w-8 h-8 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
{emptyMessage}
</div>
</td> </td>
</tr> </tr>
) : ( ) : (
data.map((item) => ( data.map((item, index) => (
<tr <tr
key={keyExtractor(item)} key={keyExtractor(item)}
className={`hover:bg-[var(--admin-bg-subtle)]/40 transition-colors ${onRowClick ? "cursor-pointer" : ""}`} className={`group hover:bg-[var(--admin-bg-subtle)]/40 transition-all duration-150 ${onRowClick ? "cursor-pointer" : ""}`}
onClick={() => onRowClick?.(item)} onClick={() => onRowClick?.(item)}
style={{ animationDelay: `${index * 30}ms` }}
> >
{columns.map((col) => ( {columns.map((col) => (
<td key={col.key} className={`px-5 py-3.5 ${col.className ?? ""}`}> <td key={col.key} className={`px-5 py-3.5 ${col.className ?? ""}`}>
@@ -42,14 +42,14 @@ export function AdminToggle({
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
onClick={() => !disabled && onChange(!checked)} onClick={() => !disabled && onChange(!checked)}
className={`relative inline-flex shrink-0 items-center rounded-full transition-colors ${ className={`relative inline-flex shrink-0 items-center rounded-full transition-all duration-200 ease-out ${
checked checked
? "bg-[var(--admin-accent)]" ? "bg-[var(--admin-accent)] shadow-[0_0_8px_rgba(202,117,67,0.4)]"
: "bg-[var(--admin-text-muted)]" : "bg-[var(--admin-text-muted)]"
} ${classes.track}`} } ${classes.track}`}
> >
<span <span
className={`inline-block rounded-full bg-white shadow transition-transform ${classes.thumb} ${classes.translate}`} className={`inline-block rounded-full bg-white shadow-md transition-transform duration-200 ease-out ${classes.thumb} ${classes.translate}`}
/> />
</button> </button>
{(label || description) && ( {(label || description) && (
@@ -36,7 +36,7 @@ export default function PageHeader({
{crumb.href ? ( {crumb.href ? (
<Link <Link
href={crumb.href} href={crumb.href}
className="hover:text-[var(--admin-text-secondary)] transition-colors" className="hover:text-[var(--admin-text-secondary)] transition-colors duration-150"
> >
{crumb.label} {crumb.label}
</Link> </Link>
@@ -44,7 +44,9 @@ export default function PageHeader({
<span className="text-[var(--admin-text-secondary)]">{crumb.label}</span> <span className="text-[var(--admin-text-secondary)]">{crumb.label}</span>
)} )}
{index < breadcrumb.length - 1 && ( {index < breadcrumb.length - 1 && (
<span className="text-[var(--admin-text-muted)]">/</span> <svg className="w-3 h-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)} )}
</span> </span>
))} ))}
@@ -56,7 +58,7 @@ export default function PageHeader({
{/* Left: Icon + Title + Subtitle */} {/* Left: Icon + Title + Subtitle */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{icon && ( {icon && (
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]"> <div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)] transition-transform duration-200 hover:scale-105">
{icon} {icon}
</div> </div>
)} )}
@@ -58,11 +58,11 @@ export default function StorefrontFooter({
type="email" type="email"
placeholder="your@email.com" placeholder="your@email.com"
aria-label="Email address" aria-label="Email address"
className="w-full sm:w-52 lg:w-64 rounded-l-xl sm:rounded-l-lg border border-stone-600 bg-stone-900 px-4 py-3 sm:py-2.5 text-sm text-stone-200 placeholder-stone-500 focus:border-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/50" className="w-full sm:w-52 lg:w-64 rounded-l-xl sm:rounded-l-lg border border-stone-600 bg-stone-900 px-4 py-3 sm:py-2.5 text-sm text-stone-200 placeholder-stone-500 focus:border-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-400/50 transition-all duration-200"
/> />
<button <button
type="submit" type="submit"
className={`rounded-r-xl sm:rounded-r-lg ${subscribeBtnClass} px-4 sm:px-5 py-3 sm:py-2.5 text-sm font-medium text-white transition-colors active:scale-95`} className={`rounded-r-xl sm:rounded-r-lg ${subscribeBtnClass} px-4 sm:px-5 py-3 sm:py-2.5 text-sm font-medium text-white transition-all duration-200 active:scale-95 hover:-translate-y-0.5`}
> >
Subscribe Subscribe
</button> </button>