Files
reui-blocks/blocks/chart-group14.tsx
T

645 lines
22 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 {
addDays,
differenceInDays,
endOfMonth,
endOfWeek,
format as formatDate,
startOfDay,
startOfMonth,
startOfWeek,
subDays,
subMonths,
} from "date-fns";
import { faIR } from "date-fns/locale";
import {
Activity,
CalendarIcon,
ChevronLeft,
Globe,
TrendingDown,
TrendingUp,
Users,
Zap,
} from "lucide-react";
import { useMemo, useState } from "react";
import type { DateRange } from "react-day-picker";
import {
Area,
AreaChart,
CartesianGrid,
Pie,
PieChart,
XAxis,
YAxis,
} from "recharts";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface ChartGroup14Props {
className?: string;
}
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
const currencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const compactCurrencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
notation: "compact",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
const numberFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 0,
});
const oneDecimalFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
const percentChangeFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
signDisplay: "exceptZero",
});
const formatPercent = (value: number) =>
`${oneDecimalFormatter.format(value)}٪`;
const generateDataForRange = (from: Date, to: Date) => {
const data = [];
const days = differenceInDays(to, from) + 1;
for (let i = 0; i < days; i++) {
const date = addDays(startOfDay(from), i);
const seed = date.getTime() / 86400000;
data.push({
date: date,
dateStr: formatDate(date, "d MMM", { locale: faIR }),
revenue: Math.floor(seededRandom(seed) * 15000) + 8000,
sessions: Math.floor(seededRandom(seed + 1000) * 3000) + 1500,
conversions: Math.floor(seededRandom(seed + 2000) * 150) + 50,
bounceRate: Math.floor(seededRandom(seed + 3000) * 30) + 20,
});
}
return data;
};
const channelData = [
{ name: "جستجوی ارگانیک", value: 38, fill: "var(--chart-1)" },
{ name: "مستقیم", value: 26, fill: "var(--chart-2)" },
{ name: "ارجاع", value: 18, fill: "var(--chart-3)" },
{ name: "شبکه‌های اجتماعی", value: 12, fill: "var(--chart-4)" },
{ name: "ایمیل", value: 6, fill: "var(--chart-5)" },
];
const channelConfig = {
"جستجوی ارگانیک": {
label: "جستجوی ارگانیک",
color: "var(--chart-1)",
},
مستقیم: { label: "مستقیم", color: "var(--chart-2)" },
ارجاع: { label: "ارجاع", color: "var(--chart-3)" },
"شبکه‌های اجتماعی": {
label: "شبکه‌های اجتماعی",
color: "var(--chart-4)",
},
ایمیل: { label: "ایمیل", color: "var(--chart-5)" },
} satisfies ChartConfig;
const topPages = [
{ path: "/products", views: 12453, change: 12.5 },
{ path: "/pricing", views: 8234, change: 8.2 },
{ path: "/about", views: 6123, change: -2.1 },
{ path: "/blog/guide", views: 4532, change: 15.8 },
{ path: "/contact", views: 3421, change: 5.4 },
];
const topUsers = [
{
name: "سارا احمدی",
email: "sara@example.com",
sessions: 47,
avatar: "/block-media/shadcnblocks/ui/avatar-1.jpg",
},
{
name: "علی رضایی",
email: "ali@example.com",
sessions: 42,
avatar: "/block-media/shadcnblocks/ui/avatar-2.jpg",
},
{
name: "مریم حسینی",
email: "maryam@example.com",
sessions: 38,
avatar: "/block-media/shadcnblocks/ui/avatar-3.jpg",
},
{
name: "محمد کریمی",
email: "mohammad@example.com",
sessions: 35,
avatar: "/block-media/shadcnblocks/ui/avatar-4.jpg",
},
];
const presetRanges = [
{ label: "امروز", getValue: () => ({ from: new Date(), to: new Date() }) },
{
label: "این هفته",
getValue: () => ({
from: startOfWeek(new Date(), { locale: faIR }),
to: endOfWeek(new Date(), { locale: faIR }),
}),
},
{
label: "این ماه",
getValue: () => ({
from: startOfMonth(new Date()),
to: endOfMonth(new Date()),
}),
},
{
label: "۷ روز اخیر",
getValue: () => ({ from: subDays(new Date(), 6), to: new Date() }),
},
{
label: "۳۰ روز اخیر",
getValue: () => ({ from: subDays(new Date(), 29), to: new Date() }),
},
{
label: "۳ ماه اخیر",
getValue: () => ({ from: subMonths(new Date(), 3), to: new Date() }),
},
];
const revenueConfig = {
revenue: { label: "درآمد", color: "var(--chart-1)" },
} satisfies ChartConfig;
const ChartGroup14 = ({ className }: ChartGroup14Props) => {
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 29),
to: new Date(),
});
const [selectedPreset, setSelectedPreset] = useState("۳۰ روز اخیر");
const filteredData = useMemo(() => {
const from = dateRange?.from ?? subDays(new Date(), 29);
const to = dateRange?.to ?? new Date();
return generateDataForRange(from, to);
}, [dateRange]);
const stats = useMemo(() => {
const totalRevenue = filteredData.reduce(
(sum, item) => sum + item.revenue,
0,
);
const totalSessions = filteredData.reduce(
(sum, item) => sum + item.sessions,
0,
);
const totalConversions = filteredData.reduce(
(sum, item) => sum + item.conversions,
0,
);
const avgBounceRate =
filteredData.length > 0
? Math.round(
filteredData.reduce((sum, item) => sum + item.bounceRate, 0) /
filteredData.length,
)
: 0;
const conversionRate =
totalSessions > 0 ? (totalConversions / totalSessions) * 100 : 0;
return {
totalRevenue,
totalSessions,
totalConversions,
avgBounceRate,
conversionRate,
};
}, [filteredData]);
const handlePresetChange = (value: string) => {
const preset = presetRanges.find((p) => p.label === value);
if (preset) {
setDateRange(preset.getValue());
setSelectedPreset(value);
}
};
return (
<section
dir="rtl"
lang="fa"
className={cn("py-32 text-start", className)}
>
<div className="container space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight">
داشبورد تحلیل‌ها
</h2>
<p className="text-muted-foreground">
بینش لحظه‌ای از کسب‌وکار شما
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={selectedPreset} onValueChange={handlePresetChange}>
<SelectTrigger className="w-[160px] text-start">
<SelectValue />
</SelectTrigger>
<SelectContent>
{presetRanges.map((preset) => (
<SelectItem key={preset.label} value={preset.label}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-[200px] justify-start text-start font-normal"
>
<CalendarIcon className="ms-2 size-4" aria-hidden="true" />
{dateRange?.from ? (
dateRange.to ? (
<>
{formatDate(dateRange.from, "d MMM", { locale: faIR })}{" "}
–{" "}
{formatDate(dateRange.to, "d MMM", { locale: faIR })}
</>
) : (
formatDate(dateRange.from, "d MMM y", { locale: faIR })
)
) : (
"انتخاب تاریخ"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
numberOfMonths={2}
locale={faIR}
selected={dateRange}
onSelect={(range) => {
setDateRange(range);
setSelectedPreset("بازه دلخواه");
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="text-start">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="rounded-lg bg-primary/10 p-2">
<Activity className="size-5 text-primary" />
</div>
<Badge
variant="secondary"
className="bg-green-100 text-green-700 tabular-nums"
>
<TrendingUp className="ms-1 size-3" />
{percentChangeFormatter.format(12.5)}٪
</Badge>
</div>
<div className="mt-3">
<p className="text-sm text-muted-foreground">مجموع درآمد</p>
<p className="text-xl font-bold tabular-nums">
{currencyFormatter.format(stats.totalRevenue)}
</p>
</div>
</CardContent>
</Card>
<Card className="text-start">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="rounded-lg bg-blue-100 p-2">
<Users className="size-5 text-blue-600" />
</div>
<Badge
variant="secondary"
className="bg-green-100 text-green-700 tabular-nums"
>
<TrendingUp className="ms-1 size-3" />
{percentChangeFormatter.format(8.2)}٪
</Badge>
</div>
<div className="mt-3">
<p className="text-sm text-muted-foreground">مجموع جلسات</p>
<p className="text-xl font-bold tabular-nums">
{numberFormatter.format(stats.totalSessions)}
</p>
</div>
</CardContent>
</Card>
<Card className="text-start">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="rounded-lg bg-green-100 p-2">
<Zap className="size-5 text-green-600" />
</div>
<Badge
variant="secondary"
className="bg-green-100 text-green-700 tabular-nums"
>
<TrendingUp className="ms-1 size-3" />
{percentChangeFormatter.format(5.4)}٪
</Badge>
</div>
<div className="mt-3">
<p className="text-sm text-muted-foreground">نرخ تبدیل</p>
<p className="text-xl font-bold tabular-nums">
{formatPercent(stats.conversionRate)}
</p>
</div>
</CardContent>
</Card>
<Card className="text-start">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="rounded-lg bg-orange-100 p-2">
<Globe className="size-5 text-orange-600" />
</div>
<Badge variant="secondary" className="bg-red-100 text-red-700">
<TrendingDown className="ms-1 size-3" />
{percentChangeFormatter.format(2.1)}٪
</Badge>
</div>
<div className="mt-3">
<p className="text-sm text-muted-foreground">نرخ پرش</p>
<p className="text-xl font-bold tabular-nums">
{formatPercent(stats.avgBounceRate)}
</p>
</div>
</CardContent>
</Card>
<Card className="text-start md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base">روند درآمد</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={revenueConfig} className="h-52 w-full">
<AreaChart
data={filteredData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="revGrad14" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-revenue)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--color-revenue)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateStr"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={52}
tickFormatter={(v) =>
compactCurrencyFormatter.format(Number(v))
}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) =>
currencyFormatter.format(Number(value))
}
/>
}
cursor={{ stroke: "var(--border)", strokeDasharray: "4 4" }}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="var(--color-revenue)"
strokeWidth={2}
fill="url(#revGrad14)"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="text-start md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base">کانال‌های ترافیک</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6">
<ChartContainer
config={channelConfig}
className="relative size-32 shrink-0"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
hideLabel
formatter={(value) => formatPercent(Number(value))}
/>
}
cursor={false}
/>
<Pie
data={channelData}
cx="50%"
cy="50%"
innerRadius={35}
outerRadius={55}
paddingAngle={2}
dataKey="value"
nameKey="name"
strokeWidth={0}
/>
</PieChart>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold tabular-nums">
{numberFormatter.format(stats.totalSessions)}
</span>
</div>
</ChartContainer>
<div className="flex-1 space-y-2">
{channelData.map((item, index) => (
<div
key={index}
className="flex items-center justify-between gap-2 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
<div
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: item.fill }}
/>
<span className="truncate text-muted-foreground">
{item.name}
</span>
</div>
<span className="font-medium tabular-nums">
{formatPercent(item.value)}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="text-start md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">پرطرفدارترین صفحات</CardTitle>
<Button variant="ghost" size="sm" className="text-xs">
مشاهده همه
<ChevronLeft className="ms-1 size-3" aria-hidden="true" />
</Button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topPages.map((page, index) => (
<div
key={index}
className="flex items-center justify-between gap-2"
>
<div className="flex min-w-0 items-center gap-3">
<span className="w-5 shrink-0 text-center text-sm text-muted-foreground tabular-nums">
{numberFormatter.format(index + 1)}
</span>
<span className="truncate text-sm font-medium">
{page.path}
</span>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-sm text-muted-foreground tabular-nums">
{numberFormatter.format(page.views)}
</span>
<span
className={cn(
"text-xs tabular-nums",
page.change >= 0 ? "text-green-600" : "text-red-600",
)}
>
{percentChangeFormatter.format(page.change)}٪
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="text-start md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">فعال‌ترین کاربران</CardTitle>
<Button variant="ghost" size="sm" className="text-xs">
مشاهده همه
<ChevronLeft className="ms-1 size-3" aria-hidden="true" />
</Button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topUsers.map((user, index) => (
<div
key={index}
className="flex items-center justify-between gap-2"
>
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-8 shrink-0">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{user.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{numberFormatter.format(user.sessions)} جلسه
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</section>
);
};
export { ChartGroup14 };