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:
@@ -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>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* 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>
|
||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
FAQ
|
||||
|
||||
@@ -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">
|
||||
{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 */}
|
||||
<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 ? (
|
||||
<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 */}
|
||||
<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)}`}
|
||||
</span>
|
||||
{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"}
|
||||
</button>
|
||||
)}
|
||||
@@ -483,10 +483,10 @@ export default function IndianRiverDirectPage() {
|
||||
<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>
|
||||
<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
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -90,46 +90,46 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
|
||||
);
|
||||
}, [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 "{searchQuery}"</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((item) => {
|
||||
const isOpen = open === item.question;
|
||||
return (
|
||||
<div
|
||||
key={item.question}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : item.question)}
|
||||
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<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">
|
||||
{item.question}
|
||||
</span>
|
||||
<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"}`}>
|
||||
<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>
|
||||
{filtered.length === 0 ? (
|
||||
<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">
|
||||
<p className="text-stone-500 text-sm">No results for "{searchQuery}"</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((item) => {
|
||||
const isOpen = open === item.question;
|
||||
return (
|
||||
<div
|
||||
key={item.question}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : item.question)}
|
||||
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<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">
|
||||
{item.question}
|
||||
</span>
|
||||
<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"}`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<button
|
||||
|
||||
@@ -519,7 +519,7 @@ export default function TuxedoPage() {
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<Link
|
||||
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 →
|
||||
</Link>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function StopPage() {
|
||||
</div>
|
||||
|
||||
{/* 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="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"}`}>
|
||||
|
||||
@@ -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<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({
|
||||
brandId,
|
||||
brandName,
|
||||
@@ -223,14 +191,14 @@ export default function DashboardClient({
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("operations");
|
||||
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -12,7 +12,7 @@ type AdminCardProps = {
|
||||
export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) {
|
||||
return (
|
||||
<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={{
|
||||
borderColor: 'var(--admin-border)',
|
||||
...style
|
||||
|
||||
@@ -20,17 +20,17 @@ export default function AdminEmptyState({
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
|
||||
{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}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
{description && <p className="mt-2 text-sm text-[var(--admin-text-muted)] max-w-sm leading-relaxed">{description}</p>}
|
||||
{action && <div className="mt-6">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default function AdminFilterBar({
|
||||
const options = statusOptions ?? defaultOptions;
|
||||
|
||||
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">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[12rem]">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function AdminPagination({
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
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}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
@@ -43,8 +43,8 @@ export default function AdminPagination({
|
||||
|
||||
if (showEllipsisBefore || showEllipsisAfter) {
|
||||
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">
|
||||
…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -53,10 +53,10 @@ export default function AdminPagination({
|
||||
<button
|
||||
key={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
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
? "bg-[var(--admin-accent)] text-white shadow-md"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] border border-transparent hover:border-[var(--admin-border)]"
|
||||
}`}
|
||||
>
|
||||
{page + 1}
|
||||
@@ -68,7 +68,7 @@ export default function AdminPagination({
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 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}>
|
||||
<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 = "",
|
||||
}: AdminTableProps<T>) {
|
||||
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]">
|
||||
<thead>
|
||||
<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 ? (
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
data.map((item, index) => (
|
||||
<tr
|
||||
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)}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-5 py-3.5 ${col.className ?? ""}`}>
|
||||
|
||||
@@ -42,14 +42,14 @@ export function AdminToggle({
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
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
|
||||
? "bg-[var(--admin-accent)]"
|
||||
? "bg-[var(--admin-accent)] shadow-[0_0_8px_rgba(202,117,67,0.4)]"
|
||||
: "bg-[var(--admin-text-muted)]"
|
||||
} ${classes.track}`}
|
||||
>
|
||||
<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>
|
||||
{(label || description) && (
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function PageHeader({
|
||||
{crumb.href ? (
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
@@ -44,7 +44,9 @@ export default function PageHeader({
|
||||
<span className="text-[var(--admin-text-secondary)]">{crumb.label}</span>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
@@ -56,7 +58,7 @@ export default function PageHeader({
|
||||
{/* Left: Icon + Title + Subtitle */}
|
||||
<div className="flex items-center gap-4">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -58,11 +58,11 @@ export default function StorefrontFooter({
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
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
|
||||
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
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user