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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user