2026-07-11 21:21:29 +03:30
|
|
|
|
"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: {
|
2026-07-15 20:22:14 +02:00
|
|
|
|
src: "/block-media/shadcnblocks/block/logos/NovaSales-logo.svg",
|
2026-07-11 21:21:29 +03:30
|
|
|
|
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: "#" },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-07-15 20:22:14 +02:00
|
|
|
|
title: "تحلیل فروش",
|
2026-07-11 21:21:29 +03:30
|
|
|
|
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 };
|