Files
reui-blocks/blocks/dashboard11.tsx
T

1592 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMotionValueEvent, useSpring } from "framer-motion";
import {
ArrowDownRight,
ArrowUpRight,
BarChart3,
Bell,
Box,
ChevronRight,
ChevronsUpDown,
ClipboardList,
Facebook,
Globe,
HelpCircle,
Instagram,
LayoutDashboard,
LogOut,
MessageSquare,
Package,
RotateCcw,
Search,
Settings,
Truck,
Twitter,
User,
Users,
Wallet,
} from "lucide-react";
import * as React from "react";
import type { TooltipProps } from "recharts";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarInset,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import {
Tooltip as ShadTooltip,
TooltipContent as ShadTooltipContent,
TooltipProvider as ShadTooltipProvider,
TooltipTrigger as ShadTooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
// ============================================================================
// Color Palette
// ============================================================================
const mixBase = "var(--background)";
const palette = {
primary: "var(--primary)",
secondary: {
light: `color-mix(in oklch, var(--primary) 75%, ${mixBase})`,
dark: `color-mix(in oklch, var(--primary) 85%, ${mixBase})`,
},
tertiary: {
light: `color-mix(in oklch, var(--primary) 55%, ${mixBase})`,
dark: `color-mix(in oklch, var(--primary) 65%, ${mixBase})`,
},
quaternary: {
light: `color-mix(in oklch, var(--primary) 40%, ${mixBase})`,
dark: `color-mix(in oklch, var(--primary) 45%, ${mixBase})`,
},
quinary: {
light: `color-mix(in oklch, var(--primary) 25%, ${mixBase})`,
dark: `color-mix(in oklch, var(--primary) 30%, ${mixBase})`,
},
};
const getRetentionColor = (percent: number): string => {
const mix = Math.max(5, Math.round(percent * 0.85));
return `color-mix(in oklch, var(--primary) ${mix}%, ${mixBase})`;
};
// ============================================================================
// Types
// ============================================================================
type NavItem = {
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
href: string;
isActive?: boolean;
children?: NavItem[];
};
type NavGroup = {
title: string;
items: NavItem[];
defaultOpen?: boolean;
};
type UserData = {
name: string;
email: string;
avatar: string;
};
type SidebarData = {
logo: {
src: string;
alt: string;
title: string;
description: string;
};
navGroups: NavGroup[];
user?: UserData;
};
type KPIStat = {
title: string;
value: number;
change: number;
format: "currency" | "percent" | "number";
};
type Segment = {
name: string;
value: number;
color: string;
change: number;
};
type Channel = {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
name: string;
count: number;
change: number;
};
type CohortRow = {
label: string;
retentionByMonth: number[];
};
// ============================================================================
// Formatters
// ============================================================================
const currencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
maximumFractionDigits: 0,
});
const compactCurrencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
notation: "compact",
maximumFractionDigits: 1,
});
const numberFormatter = new Intl.NumberFormat("fa-IR");
const compactNumberFormatter = new Intl.NumberFormat("fa-IR", {
notation: "compact",
maximumFractionDigits: 2,
});
const percentFormatter = new Intl.NumberFormat("fa-IR", {
style: "percent",
maximumFractionDigits: 1,
});
const oneDecimalFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 1,
});
const monthFormatter = new Intl.DateTimeFormat("fa-IR", { month: "short" });
const monthLabel = (monthIndex: number) =>
monthFormatter.format(new Date(2025, monthIndex, 1));
const weekdayFormatter = new Intl.DateTimeFormat("fa-IR", {
weekday: "short",
});
const formatChangePercent = (change: number, signed = true) => {
const formatted = Math.abs(change).toLocaleString("fa-IR", {
minimumFractionDigits: change % 1 !== 0 ? 1 : 0,
maximumFractionDigits: 1,
});
if (!signed) return `${formatted}٪`;
return `${change >= 0 ? "+" : "-"}${formatted}٪`;
};
// ============================================================================
// Hooks
// ============================================================================
function useHoverHighlight<T extends string | number>() {
const [active, setActive] = React.useState<T | null>(null);
const handleHover = React.useCallback((value: T | null) => {
setActive(value);
}, []);
return { active, handleHover };
}
// ============================================================================
// Mock Data
// ============================================================================
const sidebarData: SidebarData = {
logo: {
src: "/block-media/shadcnblocks/block/logos/NovaSales-logo.svg",
alt: "فروشگاه آکمه",
title: "ری یو آی Store",
description: "داشبورد",
},
navGroups: [
{
title: "اصلی",
defaultOpen: true,
items: [
{
label: "داشبورد",
icon: LayoutDashboard,
href: "#",
isActive: true,
},
{ label: "سفارش‌ها", icon: ClipboardList, href: "#" },
{ label: "مرجوعی‌ها", icon: RotateCcw, href: "#" },
],
},
{
title: "کاتالوگ",
defaultOpen: true,
items: [
{
label: "محصولات",
icon: Box,
href: "#",
children: [
{ label: "همه محصولات", icon: Package, href: "#" },
{ label: "دسته‌بندی‌ها", icon: Package, href: "#" },
{ label: "موجودی", icon: Package, href: "#" },
],
},
{ label: "ارسال", icon: Truck, href: "#" },
],
},
{
title: "مشتریان",
defaultOpen: false,
items: [
{ label: "همه مشتریان", icon: Users, href: "#" },
{ label: "پیام‌ها", icon: MessageSquare, href: "#" },
],
},
{
title: "تحلیل فروش",
defaultOpen: false,
items: [
{ label: "نمای کلی", icon: Globe, href: "#" },
{ label: "گزارش‌ها", icon: BarChart3, href: "#" },
{ label: "مالی", icon: Wallet, href: "#" },
],
},
{
title: "تنظیمات",
defaultOpen: false,
items: [{ label: "تنظیمات", icon: Settings, href: "#" }],
},
],
user: {
name: "علی رضایی",
email: "john@acmestore.com",
avatar:
"/block-media/shadcnblocks/block/avatar-5.webp",
},
};
const kpiStats: KPIStat[] = [
{ title: "درآمد کل", value: 485000, change: -12.5, format: "currency" },
{ title: "نرخ تبدیل", value: 18.5, change: -12.5, format: "percent" },
{ title: "کل سفارش‌ها", value: 92, change: 25, format: "number" },
{ title: "مشتریان فعال", value: 38, change: 25, format: "number" },
{ title: "مشتریان جدید", value: 12, change: 25, format: "number" },
];
const monthlyRevenueData = [
{ month: monthLabel(0), revenue: 72000 },
{ month: monthLabel(1), revenue: 128000 },
{ month: monthLabel(2), revenue: 103000 },
{ month: monthLabel(3), revenue: 176000 },
{ month: monthLabel(4), revenue: 230000 },
{ month: monthLabel(5), revenue: 142000 },
{ month: monthLabel(6), revenue: 310000 },
{ month: monthLabel(7), revenue: 640000 },
{ month: monthLabel(8), revenue: 410000 },
{ month: monthLabel(9), revenue: 210000 },
{ month: monthLabel(10), revenue: 98000 },
{ month: monthLabel(11), revenue: 260000 },
];
const segments: Segment[] = [
{ name: "سازمانی", value: 1074, color: palette.primary, change: 3.2 },
{ name: "کسب‌وکار متوسط", value: 836, color: palette.secondary.light, change: -1.5 },
{
name: "شخصی",
value: 478,
color: palette.tertiary.light,
change: 5.8,
},
];
const customerTrendData = [
{ day: weekdayFormatter.format(new Date(2025, 0, 6)), customers: 1850 },
{ day: weekdayFormatter.format(new Date(2025, 0, 7)), customers: 2150 },
{ day: weekdayFormatter.format(new Date(2025, 0, 8)), customers: 1720 },
{ day: weekdayFormatter.format(new Date(2025, 0, 9)), customers: 2480 },
{ day: weekdayFormatter.format(new Date(2025, 0, 10)), customers: 1890 },
{ day: weekdayFormatter.format(new Date(2025, 0, 11)), customers: 2320 },
{ day: weekdayFormatter.format(new Date(2025, 0, 12)), customers: 2388 },
];
const channelsData: Channel[] = [
{ icon: Facebook, name: "فیس‌بوک", count: 2341, change: 25 },
{ icon: Twitter, name: "توییتر", count: 1231, change: -25 },
{ icon: Globe, name: "گوگل", count: 1123, change: -25 },
{ icon: Instagram, name: "اینستاگرام", count: 125, change: 25 },
];
const cohortRetentionData: CohortRow[] = [
{ label: monthLabel(5), retentionByMonth: [100, 65, 51, 43, 37, 32, 28] },
{ label: monthLabel(6), retentionByMonth: [100, 71, 59, 50, 43, 37] },
{ label: monthLabel(7), retentionByMonth: [100, 73, 60, 51, 44] },
{ label: monthLabel(8), retentionByMonth: [100, 69, 55, 46] },
{ label: monthLabel(9), retentionByMonth: [100, 75, 62] },
{ label: monthLabel(10), retentionByMonth: [100, 67] },
{ label: monthLabel(11), retentionByMonth: [100] },
];
const storeVisitsData = [
{ date: monthLabel(0), visits: 245 },
{ date: monthLabel(1), visits: 654 },
{ date: monthLabel(2), visits: 387 },
{ date: monthLabel(3), visits: 521 },
{ date: monthLabel(4), visits: 412 },
{ date: monthLabel(5), visits: 598 },
{ date: monthLabel(6), visits: 312 },
{ date: monthLabel(7), visits: 743 },
{ date: monthLabel(8), visits: 489 },
{ date: monthLabel(9), visits: 476 },
{ date: monthLabel(10), visits: 687 },
{ date: monthLabel(11), visits: 198 },
];
// ============================================================================
// Chart Configs
// ============================================================================
const revenueChartConfig = {
revenue: {
label: "درآمد",
color: palette.secondary.light,
},
} satisfies ChartConfig;
const visitsChartConfig = {
visits: {
label: "بازدید",
color: palette.secondary.light,
},
} satisfies ChartConfig;
const customerChartConfig = {
customers: {
label: "مشتریان",
color: palette.secondary.light,
},
} satisfies ChartConfig;
// ============================================================================
// Sidebar Components
// ============================================================================
const SidebarLogo = ({ logo }: { logo: SidebarData["logo"] }) => {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip={logo.title}>
<div className="flex aspect-square size-8 items-center justify-center bg-primary">
<img
src={logo.src}
alt={logo.alt}
width={24}
height={24}
className="size-6 text-primary-foreground invert dark:invert-0"
/>
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">{logo.title}</span>
<span className="text-xs text-muted-foreground">
{logo.description}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
};
const NavMenuItem = ({ item }: { item: NavItem }) => {
const Icon = item.icon;
const hasChildren = item.children && item.children.length > 0;
if (!hasChildren) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={item.isActive}
tooltip={item.label}
>
<a href={item.href}>
<Icon className="size-4" aria-hidden="true" />
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
return (
<Collapsible asChild defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton isActive={item.isActive} tooltip={item.label}>
<Icon className="size-4" aria-hidden="true" />
<span>{item.label}</span>
<ChevronRight
className="ms-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180"
aria-hidden="true"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.children!.map((child) => (
<SidebarMenuSubItem key={child.label}>
<SidebarMenuSubButton
asChild
isActive={child.isActive}
className={
child.isActive
? "relative overflow-visible before:absolute before:top-0 before:-start-2.5 before:z-10 before:h-full before:w-px before:bg-primary before:content-['']"
: ""
}
>
<a href={child.href}>{child.label}</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
};
const NavUser = ({ user }: { user: UserData }) => {
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("");
const userAvatar = (
<Avatar className="size-8 rounded-none">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-none">{initials}</AvatarFallback>
</Avatar>
);
const userInfo = (
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
);
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{userAvatar}
{userInfo}
<ChevronsUpDown className="ms-auto size-4" aria-hidden="true" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-none"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
{userAvatar}
{userInfo}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="me-2 size-4" aria-hidden="true" />
حساب کاربری
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut className="me-2 size-4" aria-hidden="true" />
خروج
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
};
const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
return (
<Sidebar side="right" variant="inset" collapsible="icon" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2 group-data-[collapsible=icon]:flex-col">
<SidebarLogo logo={sidebarData.logo} />
<SidebarTrigger className="ms-auto group-data-[collapsible=icon]:ms-0" />
</div>
</SidebarHeader>
<SidebarContent>
<ScrollArea className="h-full">
{sidebarData.navGroups.map((group) => (
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<NavMenuItem key={item.label} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</ScrollArea>
</SidebarContent>
<SidebarFooter>
{sidebarData.user && <NavUser user={sidebarData.user} />}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
};
// ============================================================================
// Dashboard Header
// ============================================================================
const DashboardHeader = () => {
return (
<header className="flex w-full items-center gap-3 border-b bg-background px-4 py-4 sm:px-6">
<LayoutDashboard className="size-5" aria-hidden="true" />
<h1 className="text-base font-medium text-pretty">داشبورد</h1>
<div className="ms-auto flex items-center gap-2">
<div className="relative w-full max-w-[220px] sm:max-w-[260px]">
<Search
className="absolute top-1/2 start-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
name="header-search"
inputMode="search"
autoComplete="off"
aria-label="جستجوی داشبورد"
placeholder="جستجو…"
className="h-9 w-full pe-14 ps-9 text-sm"
/>
<kbd className="pointer-events-none absolute top-1/2 end-2 hidden -translate-y-1/2 border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline-flex">
{"\u2318"}
{"\u00a0"}K
</kbd>
</div>
<Button
variant="outline"
size="icon"
className="size-9"
aria-label="اعلان‌ها"
>
<Bell className="size-4" aria-hidden="true" />
</Button>
<Button
variant="outline"
size="icon"
className="size-9"
aria-label="راهنما"
>
<HelpCircle className="size-4" aria-hidden="true" />
</Button>
</div>
</header>
);
};
// ============================================================================
// Dashboard Content Components
// ============================================================================
// Growth section component (vertical layout for SalesRevenueCard header)
const GrowthSection = ({
label,
change,
}: {
label: string;
change: number;
}) => {
const isPositive = change >= 0;
return (
<div className="flex items-center gap-1.5 border bg-muted/30 px-2 py-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"font-mono",
"flex items-center gap-0.5 text-xs font-medium",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{isPositive ? (
<ArrowUpRight className="size-3" aria-hidden="true" />
) : (
<ArrowDownRight className="size-3" aria-hidden="true" />
)}
{formatChangePercent(change)}
</span>
</div>
);
};
// KPI Stat Card
const KPIStatCard = ({ stat }: { stat: KPIStat }) => {
const isPositive = stat.change >= 0;
const formatValue = (value: number, format: KPIStat["format"]) => {
switch (format) {
case "currency":
return currencyFormatter.format(value);
case "percent":
return percentFormatter.format(value / 100);
case "number":
return numberFormatter.format(value);
}
};
return (
<div className="flex flex-col gap-1 border bg-card p-4">
<span className="text-xs text-muted-foreground">{stat.title}</span>
<div className="flex items-baseline gap-2">
<span className={cn("font-mono", "text-xl font-semibold")}>
{formatValue(stat.value, stat.format)}
</span>
</div>
<div className="flex items-center gap-1 text-xs">
{isPositive ? (
<ArrowUpRight
className="size-3.5 text-emerald-600"
aria-hidden="true"
/>
) : (
<ArrowDownRight
className="size-3.5 text-red-600"
aria-hidden="true"
/>
)}
<span
className={cn(
"font-mono",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{formatChangePercent(stat.change)}
</span>
<span className="text-muted-foreground">نسبت به ماه قبل</span>
</div>
</div>
);
};
// KPI Stats Row
const KPIStatsRow = () => {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{kpiStats.map((stat) => (
<KPIStatCard key={stat.title} stat={stat} />
))}
</div>
);
};
// ============================================================================
// Animated Reference Line Components
// ============================================================================
interface CustomReferenceLabelProps {
viewBox?: { x?: number; y?: number };
value: number;
}
const CustomReferenceLabel: React.FC<CustomReferenceLabelProps> = (props) => {
const { viewBox, value } = props;
const y = viewBox?.y ?? 0;
const width = React.useMemo(() => {
const characterWidth = 8;
const padding = 10;
return value.toString().length * characterWidth + padding;
}, [value]);
return (
<>
<rect
x={0}
y={y - 9}
width={width}
height={18}
fill="var(--secondary-foreground)"
rx={0}
/>
<text
className="font-mono"
fontWeight={600}
x={6}
y={y + 4}
fill="var(--primary-foreground)"
>
{compactCurrencyFormatter.format(value)}
</text>
</>
);
};
// Revenue Bar Chart Tooltip
function RevenueBarTooltip({
active,
payload,
label,
}: any) {
if (!active || !payload?.length) return null;
const value = payload[0]?.value || 0;
return (
<div className="border border-border bg-popover p-2 shadow-lg sm:p-3">
<p className="mb-1.5 text-xs font-medium text-foreground sm:mb-2 sm:text-sm">
{label}
</p>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="size-2 rounded-full bg-primary sm:size-2.5" />
<span className="text-[10px] text-muted-foreground sm:text-sm">
درآمد:
</span>
<span
className={cn(
"font-mono",
"text-[10px] font-medium text-foreground sm:text-sm",
)}
>
{currencyFormatter.format(Number(value))}
</span>
</div>
</div>
);
}
function CustomersAreaTooltip({
active,
payload,
label,
}: any) {
if (!active || !payload?.length) return null;
const value = Number(payload[0]?.value ?? 0);
const pointIndex = customerTrendData.findIndex(
(entry) => entry.day === label,
);
const previousValue =
pointIndex > 0 ? customerTrendData[pointIndex - 1].customers : value;
const delta =
previousValue === 0 ? 0 : ((value - previousValue) / previousValue) * 100;
const isPositive = delta >= 0;
return (
<div className="border border-border bg-popover p-2 shadow-lg sm:p-3">
<p className="mb-1.5 text-xs font-medium text-foreground sm:mb-2 sm:text-sm">
{label}
</p>
<div className="flex items-center gap-1.5 sm:gap-2">
<div
className="size-2 rounded-full sm:size-2.5"
style={{ backgroundColor: "var(--color-customers)" }}
/>
<span className="text-[10px] text-muted-foreground sm:text-sm">
مشتریان:
</span>
<span
className={cn(
"font-mono",
"text-[10px] font-medium text-foreground sm:text-sm",
)}
>
{numberFormatter.format(value)}
</span>
</div>
<p
className={cn(
"font-mono",
"mt-1.5 text-[10px] sm:text-xs",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{formatChangePercent(delta)}
</p>
</div>
);
}
// Sales Revenue Card with Animated Value Line Bar Chart
const SalesRevenueCard = () => {
const changePercent = 8.2;
// Track which bar is being hovered
const [activeIndex, setActiveIndex] = React.useState<number | undefined>(
undefined,
);
// Calculate the currently tracked value (hovered bar or max bar)
const maxValueData = React.useMemo(() => {
if (activeIndex !== undefined) {
return {
index: activeIndex,
value: monthlyRevenueData[activeIndex].revenue,
};
}
return monthlyRevenueData.reduce(
(max, data, index) =>
data.revenue > max.value ? { index, value: data.revenue } : max,
{ index: 0, value: 0 },
);
}, [activeIndex]);
const isPositive = changePercent >= 0;
// Spring animation for the reference line value
const valueSpring = useSpring(maxValueData.value, {
stiffness: 100,
damping: 20,
});
const [springyValue, setSpringyValue] = React.useState(maxValueData.value);
useMotionValueEvent(valueSpring, "change", (latest) => {
setSpringyValue(Number(latest.toFixed(0)));
});
React.useEffect(() => {
valueSpring.set(maxValueData.value);
}, [maxValueData.value, valueSpring]);
return (
<div className="flex min-w-0 flex-1 flex-col gap-4 border bg-card p-4 sm:gap-5 sm:p-5 lg:col-span-2">
<div className="flex items-start justify-between gap-4">
{/* Left: Title + Value + Change */}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">درآمد فروش</span>
<span
className={cn(
"font-mono",
"text-xl font-semibold tabular-nums sm:text-3xl",
)}
>
{currencyFormatter.format(maxValueData.value)}
</span>
<div className="flex items-center gap-1">
<span
className={cn(
"font-mono",
"text-sm font-medium",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{formatChangePercent(changePercent)}
</span>
{isPositive ? (
<ArrowUpRight
className="size-3.5 text-emerald-600"
aria-hidden="true"
/>
) : (
<ArrowDownRight
className="size-3.5 text-red-600"
aria-hidden="true"
/>
)}
<span className="text-sm text-muted-foreground">نسبت به ماه قبل</span>
</div>
</div>
{/* Right: Growth sections */}
<div className="flex flex-wrap gap-2">
<GrowthSection label="۴ هفته" change={11.6} />
<GrowthSection label="۱۳ هفته" change={7.9} />
<GrowthSection label="۱۲ ماه" change={-3.4} />
</div>
</div>
<div className="h-[180px] w-full min-w-0 sm:h-[220px]">
<ChartContainer
config={revenueChartConfig}
className="aspect-auto h-full w-full"
>
<BarChart
data={monthlyRevenueData}
onMouseLeave={() => setActiveIndex(undefined)}
>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
dy={8}
/>
<YAxis hide />
<Tooltip
content={<RevenueBarTooltip />}
cursor={{ fillOpacity: 0.05 }}
/>
<Bar dataKey="revenue" radius={0}>
{monthlyRevenueData.map((_, index) => (
<Cell
key={`cell-${index}`}
className="duration-200"
opacity={index === maxValueData.index ? 1 : 0.2}
fill={
index === maxValueData.index
? palette.secondary.light
: palette.tertiary.light
}
onMouseEnter={() => setActiveIndex(index)}
/>
))}
</Bar>
<ReferenceLine
opacity={0.4}
y={springyValue}
stroke="var(--secondary-foreground)"
strokeWidth={1}
strokeDasharray="3 3"
label={<CustomReferenceLabel value={maxValueData.value} />}
/>
</BarChart>
</ChartContainer>
</div>
</div>
);
};
// Segmentation Card (Conversion Rate Style)
const SegmentationCard = () => {
const [activeCustomerIndex, setActiveCustomerIndex] = React.useState<
number | undefined
>(undefined);
const trackedCustomerPoint = React.useMemo(() => {
if (
activeCustomerIndex !== undefined &&
activeCustomerIndex >= 0 &&
activeCustomerIndex < customerTrendData.length
) {
return {
index: activeCustomerIndex,
...customerTrendData[activeCustomerIndex],
};
}
const fallbackIndex = customerTrendData.length - 1;
return { index: fallbackIndex, ...customerTrendData[fallbackIndex] };
}, [activeCustomerIndex]);
const previousCustomerPoint = React.useMemo(() => {
const previousIndex = Math.max(0, trackedCustomerPoint.index - 1);
return customerTrendData[previousIndex];
}, [trackedCustomerPoint.index]);
const totalCustomers = trackedCustomerPoint.customers;
const totalChange =
previousCustomerPoint.customers === 0
? 0
: ((totalCustomers - previousCustomerPoint.customers) /
previousCustomerPoint.customers) *
100;
const comparisonLabel =
trackedCustomerPoint.index === 0
? "نسبت به ابتدای هفته"
: `نسبت به ${previousCustomerPoint.day}`;
const isPositive = totalChange >= 0;
return (
<div className="flex min-w-0 flex-col gap-4 border bg-card p-4 sm:gap-5 sm:p-5">
{/* Header - matches Sales Revenue layout */}
<div className="flex items-start justify-between gap-4">
{/* Left: Label + Value + Change */}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">کل مشتریان</span>
<span
className={cn(
"font-mono",
"text-xl font-semibold tabular-nums sm:text-3xl",
)}
>
{numberFormatter.format(totalCustomers)}
</span>
<span
className={cn(
"font-mono",
"text-[11px] tracking-wide text-muted-foreground uppercase",
)}
>
{trackedCustomerPoint.day}
</span>
<div className="flex items-center gap-1">
<span
className={cn(
"font-mono",
"text-sm font-medium",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{formatChangePercent(totalChange)}
</span>
{isPositive ? (
<ArrowUpRight
className="size-3.5 text-emerald-600"
aria-hidden="true"
/>
) : (
<ArrowDownRight
className="size-3.5 text-red-600"
aria-hidden="true"
/>
)}
<span className="text-sm text-muted-foreground">
{comparisonLabel}
</span>
</div>
</div>
{/* Right: Details button */}
<Button variant="outline" size="sm" className="h-7 px-2 text-xs">
جزئیات
</Button>
</div>
{/* Segment metrics rows */}
<div className="flex flex-col gap-3">
{segments.map((segment) => {
const isPositive = segment.change >= 0;
return (
<div
key={segment.name}
className="flex items-center justify-between"
>
<span className="text-sm text-muted-foreground">
{segment.name}
</span>
<div className="flex items-center gap-4">
<span
className={cn(
"font-mono",
"text-sm font-medium tabular-nums",
)}
>
{numberFormatter.format(segment.value)}
</span>
<div
className={cn(
"font-mono",
"flex items-center gap-0.5 text-xs",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{isPositive ? (
<ArrowUpRight className="size-3" aria-hidden="true" />
) : (
<ArrowDownRight className="size-3" aria-hidden="true" />
)}
{formatChangePercent(segment.change)}
</div>
</div>
</div>
);
})}
</div>
{/* Area chart */}
<div className="h-[120px] w-full">
<ChartContainer config={customerChartConfig} className="h-full w-full">
<AreaChart
data={customerTrendData}
onMouseMove={(state) => {
if (typeof state.activeTooltipIndex === "number") {
setActiveCustomerIndex(state.activeTooltipIndex);
}
}}
onMouseLeave={() => setActiveCustomerIndex(undefined)}
>
<defs>
<linearGradient id="customerGradient" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-customers)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--color-customers)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={true}
horizontal={true}
stroke="hsl(var(--muted-foreground) / 0.3)"
/>
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
dy={5}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 9 }}
width={30}
tickFormatter={(v) => compactNumberFormatter.format(v)}
/>
<Tooltip
content={<CustomersAreaTooltip />}
cursor={{
stroke: "var(--color-customers)",
strokeDasharray: "3 3",
strokeOpacity: 0.35,
}}
/>
<ReferenceLine
x={trackedCustomerPoint.day}
stroke="var(--color-customers)"
strokeDasharray="3 3"
strokeOpacity={0.2}
/>
<Area
type="linear"
dataKey="customers"
stroke="var(--color-customers)"
strokeWidth={1}
fill="url(#customerGradient)"
activeDot={{
r: 3.5,
fill: "var(--color-customers)",
strokeWidth: 0,
}}
/>
</AreaChart>
</ChartContainer>
</div>
</div>
);
};
// User Retention Cohort Heatmap Card
const UserRetentionCard = () => {
const maxMonths = 7;
return (
<div className="flex min-w-0 flex-col gap-4 border bg-card p-4 sm:gap-5 sm:p-5">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">نگهداشت کاربر</span>
<span
className={cn("font-mono", "text-xl font-semibold sm:text-3xl")}
>
{percentFormatter.format(0.24)}
</span>
<div className="flex items-center gap-1">
<span
className={cn(
"font-mono",
"text-sm font-medium text-emerald-600",
)}
>
{formatChangePercent(2.0)}
</span>
<ArrowUpRight
className="size-3.5 text-emerald-600"
aria-hidden="true"
/>
</div>
</div>
<Button variant="outline" size="sm" className="h-7 px-2 text-xs">
جزئیات
</Button>
</div>
{/* Heatmap */}
<div className="flex flex-col gap-px">
{cohortRetentionData.map((cohort) => (
<div
key={cohort.label}
className="grid gap-px"
style={{
gridTemplateColumns: `repeat(${maxMonths}, minmax(0, 1fr))`,
}}
>
{cohort.retentionByMonth.map((value, monthIndex) => (
<ShadTooltipProvider key={monthIndex}>
<ShadTooltip>
<ShadTooltipTrigger asChild>
<div
className="h-5 cursor-default transition-shadow hover:ring-1 hover:ring-foreground/20"
style={{
backgroundColor: getRetentionColor(value),
}}
/>
</ShadTooltipTrigger>
<ShadTooltipContent>
<span className={cn("font-mono", "text-xs")}>
{cohort.label} ماه{" "}
{(monthIndex + 1).toLocaleString("fa-IR")}:{" "}
{value.toLocaleString("fa-IR")}٪
</span>
</ShadTooltipContent>
</ShadTooltip>
</ShadTooltipProvider>
))}
</div>
))}
</div>
{/* X-axis labels */}
<div
className="grid gap-px"
style={{
gridTemplateColumns: `repeat(${maxMonths}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: maxMonths }, (_, i) => (
<span
key={i}
className={cn(
"font-mono",
"text-center text-[10px] text-muted-foreground",
)}
>
{(i + 1).toLocaleString("fa-IR")}
</span>
))}
</div>
</div>
);
};
const StoreVisitsTargetLineChart = () => {
const chartWrapperRef = React.useRef<HTMLDivElement>(null);
const [chartWidth, setChartWidth] = React.useState(0);
const [axis, setAxis] = React.useState(0);
const lastVisitValue =
storeVisitsData[storeVisitsData.length - 1]?.visits ?? 0;
const axisSpring = useSpring(0, {
stiffness: 100,
damping: 30,
});
const valueSpring = useSpring(lastVisitValue, {
stiffness: 100,
damping: 30,
});
const [springyValue, setSpringyValue] = React.useState(lastVisitValue);
useMotionValueEvent(axisSpring, "change", (latest) => {
setAxis(Number(latest));
});
useMotionValueEvent(valueSpring, "change", (latest) => {
setSpringyValue(Number(Number(latest).toFixed(0)));
});
React.useEffect(() => {
const el = chartWrapperRef.current;
if (!el) return;
const updateWidth = () => {
const nextWidth = Math.max(
0,
Math.floor(el.getBoundingClientRect().width),
);
setChartWidth(nextWidth);
setAxis((prev) => (prev === 0 ? nextWidth : prev));
};
updateWidth();
const ro = new ResizeObserver(updateWidth);
ro.observe(el);
return () => {
ro.disconnect();
};
}, []);
React.useEffect(() => {
valueSpring.set(lastVisitValue);
}, [lastVisitValue, valueSpring]);
const safeAxis =
chartWidth > 0 ? Math.min(Math.max(axis, 0), chartWidth) : axis;
const rightInset = chartWidth > 0 ? Math.max(0, chartWidth - safeAxis) : 0;
const clipPath =
chartWidth > 0 ? `inset(0px ${rightInset}px 0px 0px)` : undefined;
const tagWidth = 56;
const tagHeight = 18;
const tagHalfWidth = tagWidth / 2;
const tagX =
chartWidth > 0
? Math.min(Math.max(safeAxis - tagWidth, 0), chartWidth - tagWidth)
: 0;
const textX =
chartWidth > 0
? Math.min(
Math.max(safeAxis - tagHalfWidth, tagHalfWidth),
chartWidth - tagHalfWidth,
)
: 0;
return (
<div ref={chartWrapperRef} className="h-full w-full min-w-0">
<ChartContainer config={visitsChartConfig} className="h-full w-full">
<AreaChart
className="overflow-visible"
data={storeVisitsData}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
onMouseMove={(state: any) => {
const x = state.activeCoordinate?.x;
const dataValue = state.activePayload?.[0]?.value;
if (typeof x === "number" && dataValue !== undefined) {
axisSpring.set(x);
valueSpring.set(Number(dataValue));
}
}}
onMouseLeave={() => {
if (chartWidth > 0) axisSpring.set(chartWidth);
valueSpring.set(lastVisitValue);
}}
>
<defs>
<linearGradient id="visitsGradient" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-visits)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--color-visits)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
dy={8}
interval={0}
padding={{ left: 12, right: 12 }}
/>
<Area
type="monotone"
dataKey="visits"
stroke="var(--color-visits)"
strokeWidth={1}
fill="url(#visitsGradient)"
fillOpacity={0.4}
clipPath={clipPath}
/>
<line
x1={safeAxis}
y1={0}
x2={safeAxis}
y2="85%"
stroke="var(--color-visits)"
strokeDasharray="3 3"
strokeLinecap="round"
strokeOpacity={0.2}
/>
<rect
x={tagX}
y={0}
width={tagWidth}
height={tagHeight}
fill="var(--secondary-foreground)"
/>
<text
x={textX}
fontWeight={600}
y={13}
textAnchor="middle"
fill="var(--primary-foreground)"
>
{compactNumberFormatter.format(springyValue)}
</text>
<Area
type="monotone"
dataKey="visits"
stroke="var(--color-visits)"
strokeOpacity={0.1}
fill="none"
/>
</AreaChart>
</ChartContainer>
</div>
);
};
// Store Visits Card with Area Chart
const StoreVisitsCard = () => {
const totalVisits = 701340000;
const changePercent = -12.5;
return (
<div className="flex min-w-0 flex-col gap-4 border bg-card p-4 sm:gap-5 sm:p-5 lg:col-span-2">
<div className="flex items-start justify-between gap-4">
{/* Left: Title + Value + Change */}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">بازدید فروشگاه</span>
<span
className={cn("font-mono", "text-xl font-semibold sm:text-3xl")}
>
{compactNumberFormatter.format(totalVisits)}
</span>
<div className="flex items-center gap-1">
<span
className={cn("font-mono", "text-sm font-medium text-red-600")}
>
{formatChangePercent(changePercent, false)}
</span>
<ArrowDownRight
className="size-3.5 text-red-600"
aria-hidden="true"
/>
<span className="text-sm text-muted-foreground">نسبت به ماه قبل</span>
</div>
</div>
{/* Right: Growth sections */}
<div className="flex flex-wrap gap-2">
<GrowthSection label="۳ ماه" change={25} />
<GrowthSection label="۶ ماه" change={25} />
<GrowthSection label="۱ سال" change={-12.5} />
</div>
</div>
<div className="h-[140px] w-full min-w-0 sm:h-[160px]">
<StoreVisitsTargetLineChart />
</div>
</div>
);
};
// Main Dashboard Content
const DashboardContent = () => {
return (
<div
id="dashboard-main"
className="flex h-full w-full flex-1 flex-col gap-4 overflow-auto p-4 sm:gap-6 sm:p-6"
>
{/* KPI Stats Row */}
<KPIStatsRow />
{/* Middle Row: Sales Revenue + Segmentation */}
<div className="grid gap-4 sm:gap-6 lg:grid-cols-3">
<SalesRevenueCard />
<SegmentationCard />
</div>
{/* Bottom Row: User Retention + Store Visits */}
<div className="grid gap-4 sm:gap-6 lg:grid-cols-3">
<UserRetentionCard />
<StoreVisitsCard />
</div>
</div>
);
};
// ============================================================================
// Main Dashboard Component
// ============================================================================
const Dashboard11 = ({ className }: { className?: string }) => {
return (
<ShadTooltipProvider>
<SidebarProvider
dir="rtl"
lang="fa"
className={cn("min-h-svh w-full overflow-hidden bg-sidebar", className)}
>
<a
href="#dashboard-main"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:start-4 focus:z-50 focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:text-foreground focus:ring-2 focus:ring-ring"
>
رفتن به محتوای اصلی
</a>
<AppSidebar />
<SidebarInset className="min-h-svh min-w-0 w-full overflow-hidden md:peer-data-[variant=inset]:m-0 md:peer-data-[variant=inset]:rounded-none md:peer-data-[variant=inset]:shadow-none md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:me-0">
<DashboardHeader />
<DashboardContent />
</SidebarInset>
</SidebarProvider>
</ShadTooltipProvider>
);
};
export { Dashboard11 };