@@ -1,6 +1,7 @@
"use client" ;
import * as React from "react" ;
import { usePathname , useRouter , useSearchParams } from "next/navigation" ;
import { useQuery } from "@tanstack/react-query" ;
import DateObject from "react-date-object" ;
import persian from "react-date-object/calendars/persian" ;
@@ -11,6 +12,7 @@ import {
BarChart3 ,
BookOpen ,
CalendarDays ,
Eraser ,
GraduationCap ,
Heart ,
LibraryBig ,
@@ -28,17 +30,16 @@ import {
Cell ,
Line ,
LineChart ,
Scatter ,
ScatterChart ,
XAxis ,
YAxis ,
ZAxis ,
} from "recharts" ;
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import { Card , CardContent , CardDescription , CardHeader , CardTitle } from "@/components/ui/card" ;
import { ChartContainer , ChartTooltip , ChartTooltipContent } from "@/components/ui/chart" ;
import { Dialog , DialogContent , DialogHeader , DialogTitle } from "@/components/ui/dialog" ;
import { Input } from "@/components/ui/input" ;
import { Label } from "@/components/ui/label" ;
import { Tabs , TabsContent , TabsList , TabsTrigger } from "@/components/ui/tabs" ;
import { api } from "@/lib/api" ;
@@ -81,6 +82,26 @@ type SectionState = DateRangeState & {
eventId? : string | null ;
} ;
type DashboardTab = "users" | "events" | "blog" ;
const DASHBOARD_TABS : DashboardTab [ ] = [ "users" , "events" , "blog" ] ;
function parseTab ( value : string | null ) : DashboardTab {
return DASHBOARD_TABS . includes ( value as DashboardTab ) ? ( value as DashboardTab ) : "users" ;
}
function readDateRange ( params : Pick < URLSearchParams , "get" > , prefix : "users" | "events" | "blog" ) : DateRangeState {
return {
from : params . get ( ` ${ prefix } _from ` ) || "" ,
to : params.get ( ` ${ prefix } _to ` ) || "" ,
} ;
}
function setParam ( params : URLSearchParams , key : string , value? : string | null ) {
if ( value ) params . set ( key , value ) ;
else params . delete ( key ) ;
}
function toApiDate ( date : DateObject | null ) {
if ( ! date ) return "" ;
const gregorian = date . toDate ( ) ;
@@ -118,7 +139,7 @@ function truncateLabel(value: string, max = 18) {
}
function chartHeight ( count : number , min = 280 ) {
return Math . max ( min , count * 38 + 9 0) ;
return Math . max ( min , count * 32 + 8 0) ;
}
function axisWidth ( items : AnalyticsPointSchema [ ] ) {
@@ -130,6 +151,10 @@ function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
return group . top_items ;
}
function fullGroupItems ( group : AnalyticsPointGroupSchema ) {
return group . items ? . length ? group.items : group.top_items ;
}
function StatCard ( {
title ,
value ,
@@ -145,14 +170,14 @@ function StatCard({
} ) {
return (
< Card className = "overflow-hidden" >
< CardContent className = "flex items-center justify-between gap-4 p-5" >
< CardContent className = "flex items-center justify-between gap-3 p-4 sm:gap-4 sm: p-5" >
< div className = "space-y-2 text-right" >
< p className = "text-sm text-muted-foreground" > { title } < / p >
< p className = "text-2 xl font-black leading-tight tracking-tight" > { value } < / p >
< p className = "text-xl font-black leading-tight tracking-tight sm:text-2xl " > { value } < / p >
< p className = "text-xs leading-6 text-muted-foreground" > { description } < / p >
< / div >
< div className = "rounded-2xl p-3" style = { { backgroundColor : ` ${ PALETTE [ tone ] } 22 ` , color : PALETTE [ tone ] } } >
< Icon className = "h-6 w-6" / >
< div className = "rounded-2xl p-2.5 sm: p-3" style = { { backgroundColor : ` ${ PALETTE [ tone ] } 22 ` , color : PALETTE [ tone ] } } >
< Icon className = "h-5 w-5 sm:h-6 sm: w-6" / >
< / div >
< / CardContent >
< / Card >
@@ -177,7 +202,7 @@ function SectionLoading() {
function EmptyChart ( { label = "دادهای برای نمایش وجود ندارد." } : { label? : string } ) {
return (
< div className = "flex h-[24 0px] items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground " >
< div className = "flex h-[20 0px] items-center justify-center rounded-xl border border-dashed px-4 text-center text-xs text-muted-foreground sm:h-[240px] sm:text-sm " >
{ label }
< / div >
) ;
@@ -201,6 +226,7 @@ function DateRangeFilter({
onChange = { ( next ) = > onChange ( { . . . value , from : toApiDate ( next instanceof DateObject ? next : null ) } ) }
calendar = { persian }
locale = { persian_fa }
onOpenPickNewDate = { false }
calendarPosition = "bottom-right"
inputClass = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder = "تاریخ شروع"
@@ -214,6 +240,7 @@ function DateRangeFilter({
onChange = { ( next ) = > onChange ( { . . . value , to : toApiDate ( next instanceof DateObject ? next : null ) } ) }
calendar = { persian }
locale = { persian_fa }
onOpenPickNewDate = { false }
calendarPosition = "bottom-right"
inputClass = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder = "تاریخ پایان"
@@ -221,7 +248,8 @@ function DateRangeFilter({
/ >
< / div >
< div className = "flex items-end" >
< Button variant = "outlin e" className = "w-full md:w-auto" onClick = { onReset } >
< Button variant = "destructiv e" className = "w-full gap-2 md:w-auto" onClick = { onReset } >
< Eraser className = "h-4 w-4" / >
پ ا ک ک ر د ن
< / Button >
< / div >
@@ -240,21 +268,21 @@ function FilterCard({
} ) {
return (
< Card >
< CardHeader >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "flex items-center gap-2" >
< BarChart3 className = "h-5 w-5 text-primary" / >
{ title }
< / CardTitle >
< CardDescription > { description } < / CardDescription >
< / CardHeader >
< CardContent > { children } < / CardContent >
< CardContent className = "p-4 pt-0 sm:p-6 sm:pt-0" > { children } < / CardContent >
< / Card >
) ;
}
function ChartViewport ( { children , minWidth = 56 0 } : { children : React.ReactNode ; minWidth? : number } ) {
function ChartViewport ( { children , minWidth = 42 0 } : { children : React.ReactNode ; minWidth? : number } ) {
return (
< div className = "overflow-x-auto overflow-y-hidden pb-2" >
< div className = "overflow-x-auto overflow-y-hidden pb-2" dir = "ltr" >
< div style = { { minWidth } } > { children } < / div >
< / div >
) ;
@@ -284,13 +312,285 @@ function CustomValueTooltip({
) ;
}
function HighCardinalityNotice ( { group } : { group : AnalyticsPointGroupSchema } ) {
function FullResultsAction ( {
group ,
onOpen ,
} : {
group : AnalyticsPointGroupSchema ;
onOpen : ( ) = > void ;
} ) {
if ( group . total_count <= group . top_items . length ) return null ;
return (
< p className = "mt-2 text-xs text-muted-foreground " >
ن م ا ی ش { formatNumberPersian ( group . top_items . length ) } م و ر د ب ر ت ر ا ز { formatNumberPersian ( group . total_count ) } م و ر د ؛ { " " }
{ formatNumberPersian ( group . other_count ) } م و ر د د ی گ ر د ر ن م و د ا ر ف ش ر د ه ن ش د ه ا ن د .
< / p >
< div className = "mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between " >
< span >
ن م و د ا ر خ ل ا ص ه { formatNumberPersian ( group . top_items . length ) } م و ر د ا و ل ر ا ن ش ا ن م ی د ه د ؛ ه م ه { " " }
{ formatNumberPersian ( group . total_count ) } م و ر د د ر ن م ا ی ک ا م ل ق ا ب ل ب ر ر س ی ا س ت .
< / span >
< Button type = "button" size = "sm" variant = "secondary" className = "shrink-0" onClick = { onOpen } >
م ش ا ه د ه ه م ه
< / Button >
< / div >
) ;
}
function DashboardPointDetailModal ( {
open ,
onOpenChange ,
title ,
description ,
data ,
color ,
unit ,
formatter ,
} : {
open : boolean ;
onOpenChange : ( open : boolean ) = > void ;
title : string ;
description : string ;
data : AnalyticsPointSchema [ ] ;
color : string ;
unit? : string ;
formatter ? : ( value : number ) = > string ;
} ) {
const [ search , setSearch ] = React . useState ( "" ) ;
const [ sortBy , setSortBy ] = React . useState < "value" | "label" > ( "value" ) ;
const filteredData = React . useMemo ( ( ) = > {
const normalizedSearch = search . trim ( ) . toLocaleLowerCase ( "fa-IR" ) ;
const filtered = normalizedSearch
? data . filter ( ( item ) = > toPersianDigits ( item . label ) . toLocaleLowerCase ( "fa-IR" ) . includes ( normalizedSearch ) )
: data ;
return [ . . . filtered ] . sort ( ( a , b ) = > {
if ( sortBy === "label" ) return String ( a . label ) . localeCompare ( String ( b . label ) , "fa" ) ;
return Number ( b . value ) - Number ( a . value ) || String ( a . label ) . localeCompare ( String ( b . label ) , "fa" ) ;
} ) ;
} , [ data , search , sortBy ] ) ;
return (
< Dialog open = { open } onOpenChange = { onOpenChange } >
< DialogContent className = "max-h-[90vh] max-w-5xl overflow-y-auto rounded-3xl" dir = "rtl" >
< DialogHeader className = "mt-6 text-right md:text-right" >
< DialogTitle > { title } < / DialogTitle >
< p className = "text-sm text-muted-foreground" > { description } < / p >
< / DialogHeader >
< div className = "grid gap-3 sm:grid-cols-[1fr_auto_auto]" >
< Input
value = { search }
onChange = { ( event ) = > setSearch ( event . target . value ) }
placeholder = "جستجو در عنوانها..."
className = "text-right"
/ >
< Button type = "button" variant = { sortBy === "value" ? "default" : "outline" } onClick = { ( ) = > setSortBy ( "value" ) } >
م ر ت ب س ا ز ی ب ا م ق د ا ر
< / Button >
< Button type = "button" variant = { sortBy === "label" ? "default" : "outline" } onClick = { ( ) = > setSortBy ( "label" ) } >
م ر ت ب س ا ز ی ا ل ف ب ا ی ی
< / Button >
< / div >
{ ! filteredData . length ? (
< EmptyChart label = "موردی با این جستجو پیدا نشد." / >
) : (
< >
< ChartViewport minWidth = { 680 } >
< ChartContainer
config = { { value : { label : "مقدار" , color } } }
className = "w-full"
style = { { height : Math.min ( Math . max ( filteredData . length * 34 + 100 , 360 ) , 780 ) } }
>
< BarChart
data = { filteredData }
layout = "vertical"
margin = { { top : 12 , right : axisWidth ( filteredData ) + 16 , bottom : 24 , left : 20 } }
>
< CartesianGrid horizontal = { false } strokeDasharray = "3 3" / >
< XAxis
type = "number"
reversed
tickLine = { false }
axisLine = { false }
tickMargin = { 8 }
tickFormatter = { ( value ) = > formatter ? formatter ( Number ( value ) ) : formatNumberPersian ( Number ( value ) ) }
/ >
< YAxis
dataKey = "label"
type = "category"
orientation = "right"
width = { axisWidth ( filteredData ) }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > truncateLabel ( String ( value ) , 24 ) }
/ >
< ChartTooltip content = { < CustomValueTooltip unit = { unit } formatter = { formatter } / > } / >
< Bar dataKey = "value" radius = { [ 8 , 8 , 8 , 8 ] } fill = "var(--color-value)" / >
< / BarChart >
< / ChartContainer >
< / ChartViewport >
< ValuesTable data = { filteredData } unit = { unit } formatter = { formatter } / >
< / >
) }
< / DialogContent >
< / Dialog >
) ;
}
type PostEngagementDatum = AnalyticsPostPopularitySchema & {
label : string ;
score : number ;
} ;
function postScore ( post : AnalyticsPostPopularitySchema ) {
return Number ( post . likes || 0 ) + Number ( post . saves || 0 ) + Number ( post . comments || 0 ) ;
}
function toPostEngagementData ( posts : AnalyticsPostPopularitySchema [ ] ) : PostEngagementDatum [ ] {
return posts . map ( ( post ) = > ( {
. . . post ,
label : post.title ,
score : postScore ( post ) ,
} ) ) ;
}
function PostEngagementTooltip ( {
active ,
payload ,
} : {
active? : boolean ;
payload? : Array < { payload? : PostEngagementDatum } > ;
} ) {
if ( ! active || ! payload ? . length || ! payload [ 0 ] . payload ) return null ;
const item = payload [ 0 ] . payload ;
return (
< div className = "min-w-56 rounded-lg border bg-background p-3 text-xs shadow-xl" dir = "rtl" >
< p className = "mb-2 max-w-72 font-semibold" > { item . title } < / p >
< div className = "space-y-1 text-muted-foreground" >
< p > ل ا ی ک : { formatNumberPersian ( item . likes ) } < / p >
< p > ذ خ ی ر ه : { formatNumberPersian ( item . saves ) } < / p >
< p > ک ا م ن ت : { formatNumberPersian ( item . comments ) } < / p >
< p > ت ع ا م ل ک ل : { formatNumberPersian ( item . score ) } < / p >
< / div >
< / div >
) ;
}
function BlogPostEngagementModal ( {
open ,
onOpenChange ,
posts ,
} : {
open : boolean ;
onOpenChange : ( open : boolean ) = > void ;
posts : AnalyticsPostPopularitySchema [ ] ;
} ) {
const [ search , setSearch ] = React . useState ( "" ) ;
const [ sortBy , setSortBy ] = React . useState < "score" | "likes" | "saves" | "comments" | "title" > ( "score" ) ;
const data = React . useMemo ( ( ) = > {
const normalizedSearch = search . trim ( ) . toLocaleLowerCase ( "fa-IR" ) ;
const filtered = normalizedSearch
? posts . filter ( ( post ) = > post . title . toLocaleLowerCase ( "fa-IR" ) . includes ( normalizedSearch ) )
: posts ;
return toPostEngagementData ( filtered ) . sort ( ( a , b ) = > {
if ( sortBy === "title" ) return a . title . localeCompare ( b . title , "fa" ) ;
return Number ( b [ sortBy ] ) - Number ( a [ sortBy ] ) || a . title . localeCompare ( b . title , "fa" ) ;
} ) ;
} , [ posts , search , sortBy ] ) ;
return (
< Dialog open = { open } onOpenChange = { onOpenChange } >
< DialogContent className = "max-h-[90vh] max-w-6xl overflow-y-auto rounded-3xl" dir = "rtl" >
< DialogHeader className = "mt-6 text-right md:text-right" >
< DialogTitle > م ح ب و ب ی ت ن و ش ت ه ه ا < / DialogTitle >
< p className = "text-sm text-muted-foreground" > ن م ا ی ک ا م ل ت ع ا م ل ن و ش ت ه ه ا ب ا ا م ک ا ن ج س ت ج و و م ر ت ب س ا ز ی < / p >
< / DialogHeader >
< div className = "grid gap-3 lg:grid-cols-[1fr_repeat(5,auto)]" >
< Input
value = { search }
onChange = { ( event ) = > setSearch ( event . target . value ) }
placeholder = "جستجو در عنوان نوشته..."
className = "text-right"
/ >
{ [
[ "score" , "تعامل کل" ] ,
[ "likes" , "لایک" ] ,
[ "saves" , "ذخیره" ] ,
[ "comments" , "کامنت" ] ,
[ "title" , "عنوان" ] ,
] . map ( ( [ key , label ] ) = > (
< Button
key = { key }
type = "button"
variant = { sortBy === key ? "default" : "outline" }
onClick = { ( ) = > setSortBy ( key as typeof sortBy ) }
>
{ label }
< / Button >
) ) }
< / div >
{ ! data . length ? (
< EmptyChart label = "نوشتهای با این جستجو پیدا نشد." / >
) : (
< >
< ChartViewport minWidth = { 760 } >
< ChartContainer
config = { {
likes : { label : "لایک" , color : PALETTE.rose } ,
saves : { label : "ذخیره" , color : PALETTE.cyan } ,
comments : { label : "کامنت" , color : PALETTE.amber } ,
} }
className = "w-full"
style = { { height : Math.min ( Math . max ( data . length * 34 + 100 , 380 ) , 820 ) } }
>
< BarChart
data = { data }
layout = "vertical"
margin = { { top : 12 , right : axisWidth ( data . map ( ( post ) = > ( { label : post.title , value : post.score } ) ) ) + 16 , bottom : 24 , left : 20 } }
>
< CartesianGrid horizontal = { false } strokeDasharray = "3 3" / >
< XAxis type = "number" reversed tickLine = { false } axisLine = { false } tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) } / >
< YAxis
dataKey = "label"
type = "category"
orientation = "right"
width = { axisWidth ( data . map ( ( post ) = > ( { label : post.title , value : post.score } ) ) ) }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > truncateLabel ( String ( value ) , 26 ) }
/ >
< ChartTooltip content = { < PostEngagementTooltip / > } / >
< Bar dataKey = "likes" stackId = "engagement" fill = "var(--color-likes)" radius = { [ 0 , 0 , 0 , 0 ] } / >
< Bar dataKey = "saves" stackId = "engagement" fill = "var(--color-saves)" radius = { [ 0 , 0 , 0 , 0 ] } / >
< Bar dataKey = "comments" stackId = "engagement" fill = "var(--color-comments)" radius = { [ 8 , 8 , 8 , 8 ] } / >
< / BarChart >
< / ChartContainer >
< / ChartViewport >
< div className = "mt-4 max-h-72 overflow-auto rounded-xl border" >
< table className = "w-full min-w-[620px] text-sm" >
< thead className = "bg-muted/40 text-muted-foreground" >
< tr >
< th className = "px-3 py-2 text-right" > ن و ش ت ه < / th >
< th className = "px-3 py-2 text-right" > ل ا ی ک < / th >
< th className = "px-3 py-2 text-right" > ذ خ ی ر ه < / th >
< th className = "px-3 py-2 text-right" > ک ا م ن ت < / th >
< th className = "px-3 py-2 text-right" > ت ع ا م ل ک ل < / th >
< / tr >
< / thead >
< tbody >
{ data . map ( ( post ) = > (
< tr key = { post . id } className = "border-t" >
< td className = "px-3 py-2 font-medium" > { post . title } < / td >
< td className = "px-3 py-2" > { formatNumberPersian ( post . likes ) } < / td >
< td className = "px-3 py-2" > { formatNumberPersian ( post . saves ) } < / td >
< td className = "px-3 py-2" > { formatNumberPersian ( post . comments ) } < / td >
< td className = "px-3 py-2 font-semibold" > { formatNumberPersian ( post . score ) } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
< / >
) }
< / DialogContent >
< / Dialog >
) ;
}
@@ -306,7 +606,7 @@ function ValuesTable({
if ( ! data . length ) return null ;
return (
< div className = "mt-4 max-h-56 overflow-auto rounded-xl border" >
< table className = "w-full text-sm" >
< table className = "w-full text-xs sm: text-sm" >
< tbody >
{ data . map ( ( item , index ) = > (
< tr key = { ` ${ item . label } - ${ index } ` } className = "border-b last:border-b-0" >
@@ -340,13 +640,15 @@ function HorizontalBarCard({
valueFormatter ? : ( value : number ) = > string ;
} ) {
const data = dataWithOtherNotice ( group ) ;
const allData = fullGroupItems ( group ) ;
const [ detailsOpen , setDetailsOpen ] = React . useState ( false ) ;
return (
< Card >
< CardHeader >
< CardTitle > { title } < / CardTitle >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "text-base sm:text-lg" > { title } < / CardTitle >
< CardDescription > { description } < / CardDescription >
< / CardHeader >
< CardContent >
< CardContent className = "p-4 pt-0 sm:p-6 sm:pt-0" >
{ ! data . length ? (
< EmptyChart / >
) : (
@@ -357,15 +659,24 @@ function HorizontalBarCard({
className = "w-full"
style = { { height : chartHeight ( data . length ) } }
>
< BarChart data = { data } layout = "vertical" margin = { { top : 12 , right : 16 , bottom : 24 , left : 20 } } >
< BarChart data = { data } layout = "vertical" margin = { { top : 12 , right : axisWidth ( data ) + 10 , bottom : 24 , left : 20 } } >
< CartesianGrid horizontal = { false } strokeDasharray = "3 3" / >
< XAxis type = "number" tickFormatter = { ( value ) = > valueFormatter ? valueFormatter ( Number ( value ) ) : formatNumberPersian ( Number ( value ) ) } / >
< XAxis
type = "number"
reversed
tickLine = { false }
axisLine = { false }
tickMargin = { 8 }
tickFormatter = { ( value ) = > valueFormatter ? valueFormatter ( Number ( value ) ) : formatNumberPersian ( Number ( value ) ) }
/ >
< YAxis
dataKey = "label"
type = "category"
orientation = "right"
width = { axisWidth ( data ) }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > truncateLabel ( String ( value ) ) }
/ >
< ChartTooltip content = { < CustomValueTooltip unit = { unit } formatter = { valueFormatter } / > } / >
@@ -373,8 +684,18 @@ function HorizontalBarCard({
< / BarChart >
< / ChartContainer >
< / ChartViewport >
< HighCardinalityNotice group = { group } / >
< FullResultsAction group = { group } onOpen = { ( ) = > setDetailsOpen ( true ) } / >
< ValuesTable data = { data } unit = { unit } formatter = { valueFormatter } / >
< DashboardPointDetailModal
open = { detailsOpen }
onOpenChange = { setDetailsOpen }
title = { title }
description = { ` ${ description } ؛ نمایش کامل ${ formatNumberPersian ( allData . length ) } مورد ` }
data = { allData }
color = { color }
unit = { unit }
formatter = { valueFormatter }
/ >
< / >
) }
< / CardContent >
@@ -397,29 +718,37 @@ function TrendLineCard({
} ) {
return (
< Card >
< CardHeader >
< CardTitle className = "flex items-center gap-2" >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "flex items-center gap-2 text-base sm:text-lg " >
< LineChartIcon className = "h-5 w-5 text-primary" / >
{ title }
< / CardTitle >
< CardDescription > { description } < / CardDescription >
< / CardHeader >
< CardContent >
< CardContent className = "p-4 pt-0 sm:p-6 sm:pt-0" >
{ ! data . length ? (
< EmptyChart / >
) : (
< ChartViewport >
< ChartContainer config = { { value : { label : "مقدار" , color } } } className = "h-[30 0px] w-full" >
< LineChart data = { data } margin = { { top : 16 , right : 18 , bottom : 32 , left : 26 } } >
< ChartContainer config = { { value : { label : "مقدار" , color } } } className = "h-[26 0px] w-full sm:h-[300px] " >
< LineChart data = { data } margin = { { top : 16 , right : 76 , bottom : 32 , left : 18 } } >
< CartesianGrid vertical = { false } strokeDasharray = "3 3" / >
< XAxis
dataKey = "date"
tickLine = { false }
axisLine = { false }
minTickGap = { 34 }
tickMargin = { 10 }
tickFormatter = { formatJalaliTick }
/ >
< YAxis width = { 76 } tickFormatter = { ( value ) = > valueFormatter ( Number ( value ) ) } / >
< YAxis
orientation = "right"
width = { 76 }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > valueFormatter ( Number ( value ) ) }
/ >
< ChartTooltip
content = { ( { active , payload } ) = > {
if ( ! active || ! payload ? . length ) return null ;
@@ -453,23 +782,39 @@ function StatusChartCard({
} ) {
return (
< Card >
< CardHeader >
< CardTitle > { title } < / CardTitle >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "text-base sm:text-lg" > { title } < / CardTitle >
< CardDescription > { description } < / CardDescription >
< / CardHeader >
< CardContent >
< CardContent className = "p-4 pt-0 sm:p-6 sm:pt-0" >
{ ! data . length ? (
< EmptyChart / >
) : (
< >
< ChartViewport minWidth = { 46 0 } >
< ChartContainer config = { { value : { label : "تعداد" , color : PALETTE.teal } } } className = "h-[30 0px] w-full" >
< BarChart data = { data } margin = { { top : 14 , right : 14 , bottom : 44 , left : 22 } } >
< CartesianGrid vertic al= { false } strokeDasharray = "3 3" / >
< XAxis dataKey = "label" tickLine = { false } axisLine = { false } tickFormatter = { ( value ) = > truncateLabel ( String ( value ) , 14 ) } / >
< YAxis width = { 64 } tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) } / >
< ChartTooltip content = { < ChartTooltipContent hideLabel / > } / >
< Bar dataKey = "value" radius = { [ 8 , 8 , 0 , 0 ] } >
< ChartViewport minWidth = { 40 0 } >
< ChartContainer config = { { value : { label : "تعداد" , color : PALETTE.teal } } } className = "h-[26 0px] w-full sm:h-[300px] " >
< BarChart data = { data } layout = "vertical" margin= { { top : 14 , right : 118 , bottom : 28 , left : 18 } } >
< CartesianGrid horizont al= { false } strokeDasharray = "3 3" / >
< XAxis
type = "number"
reversed
tickLine = { false }
axisLine = { false }
tickMargin = { 8 }
tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) }
/ >
< YAxis
dataKey = "label"
type = "category"
orientation = "right"
width = { 108 }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > truncateLabel ( String ( value ) , 14 ) }
/ >
< ChartTooltip content = { < CustomValueTooltip / > } / >
< Bar dataKey = "value" radius = { [ 8 , 8 , 8 , 8 ] } >
{ data . map ( ( _ , index ) = > (
< Cell key = { index } fill = { STATUS_COLORS [ index % STATUS_COLORS . length ] } / >
) ) }
@@ -488,11 +833,11 @@ function StatusChartCard({
function ActivityTrendCard ( { data } : { data : BlogAnalyticsSchema [ "activity_trend" ] } ) {
return (
< Card >
< CardHeader >
< CardTitle > ر و ن د ت ع ا م ل ا ت ب ل ا گ < / CardTitle >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "text-base sm:text-lg" > ر و ن د ت ع ا م ل ا ت ب ل ا گ < / CardTitle >
< CardDescription > ل ا ی ک ، ذ خ ی ر ه و ک ا م ن ت د ر ب ا ز ه ا ن ت خ ا ب ی < / CardDescription >
< / CardHeader >
< CardContent >
< CardContent className = "p-4 pt-0 sm:p-6 sm:pt-0" >
{ ! data . length ? (
< EmptyChart / >
) : (
@@ -503,12 +848,19 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
saves : { label : "ذخیره" , color : PALETTE.cyan } ,
comments : { label : "کامنت" , color : PALETTE.amber } ,
} }
className = "h-[3 20px] w-full"
className = "h-[28 0px] w-full sm:h-[320px] "
>
< LineChart data = { data } margin = { { top : 16 , right : 18 , bottom : 32 , left : 26 } } >
< LineChart data = { data } margin = { { top : 16 , right : 64 , bottom : 32 , left : 18 } } >
< CartesianGrid vertical = { false } strokeDasharray = "3 3" / >
< XAxis dataKey = "date" tickLine = { false } axisLine = { false } tickFormatter = { formatJalaliTick } minTickGap = { 34 } / >
< YAxis width = { 64 } tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) } / >
< XAxis dataKey = "date" tickLine = { false } axisLine = { false } tickFormatter = { formatJalaliTick } minTickGap = { 34 } tickMargin = { 10 } / >
< YAxis
orientation = "right"
width = { 64 }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) }
/ >
< ChartTooltip content = { < ChartTooltipContent / > } / >
< Line type = "monotone" dataKey = "likes" stroke = "var(--color-likes)" strokeWidth = { 3 } dot = { false } / >
< Line type = "monotone" dataKey = "saves" stroke = "var(--color-saves)" strokeWidth = { 3 } dot = { false } / >
@@ -522,68 +874,67 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
) ;
}
function BlogScatter Card ( { group } : { group : BlogAnalyticsSchema [ "post_popularity" ] } ) {
const data = group . top_items ;
function BlogEngagement Card ( { group } : { group : BlogAnalyticsSchema [ "post_popularity" ] } ) {
const data = toPostEngagementData ( group. top_items ) ;
const allPosts = group . items ? . length ? group.items : group.top_items ;
const [ detailsOpen , setDetailsOpen ] = React . useState ( false ) ;
const labelAxisWidth = axisWidth ( data . map ( ( post ) = > ( { label : post.title , value : post.score } ) ) ) ;
return (
< Card >
< CardHeader >
< CardTitle > م ح ب و ب ی ت ن و ش ت ه ه ا < / CardTitle >
< CardDescription > ل ا ی ک د ر بر ا ب ر ذ خ ی ر ه ؛ ا ن د ا ز ه ن ق ط ه ب ر ا س ا س ت ع د ا د ک ا م ن ت < / CardDescription >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "text-base sm:text-lg" > م ح ب و ب ی ت ن و ش ت ه ه ا < / CardTitle >
< CardDescription > ر ت ب ه ب ن د ی ب ر ا س ا س م ج م و ع ل ا ی ک ، ذ خ ی ر ه و ک ا م ن ت < / CardDescription >
< / CardHeader >
< CardContent >
< CardContent className = "p-4 pt-0 sm:p-6 sm:pt-0" >
{ ! data . length ? (
< EmptyChart / >
) : (
< >
< ChartViewport minWidth = { 62 0 } >
< ChartViewport minWidth = { 4 60} >
< ChartContainer
config = { {
saves : { label : "ذخیره" , color : PALETTE.cyan } ,
likes : { label : "لایک" , color : PALETTE.rose } ,
saves : { label : "ذخیره" , color : PALETTE.cyan } ,
comments : { label : "کامنت" , color : PALETTE.amber } ,
} }
className = "h-[340px] w-full"
className = "w-full"
style = { { height : chartHeight ( data . length , 320 ) } }
>
< Scatte rChart margin = { { top : 18 , right : 18 , bottom : 36 , left : 3 0 } } >
< CartesianGrid strokeDasharray = "3 3" / >
< Ba rChart data = { data } layout = "vertical" margin= { { top : 12 , right : labelAxisWidth + 16 , bottom : 24 , left : 2 0 } } >
< CartesianGrid horizontal = { false } strokeDasharray= "3 3" / >
< XAxis
type = "number"
dataKey = "likes"
name = "لایک"
reversed
tickLine = { false }
axisLine = { false }
tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) }
/ >
< YAxis
type = "num ber "
dataKey = "saves "
name = "ذخیره "
tickFormatter = { ( value ) = > formatNumberPersian ( Number ( value ) ) }
dataKey = "la bel "
type = "category "
orientation = "right "
width = { labelAxisWidth }
tickLine = { false }
axisLine = { false }
tickMargin = { 10 }
tickFormatter = { ( value ) = > truncateLabel ( String ( value ) , 22 ) }
/ >
< ZAxis type = "number" dataKey = "comments" range = { [ 70 , 380 ] } / >
< ChartTooltip
cursor = { { strokeDasharray : "3 3" } }
content = { ( { active , payload } ) = > {
if ( ! active || ! payload ? . length ) return null ;
const item = payload [ 0 ] . payload as AnalyticsPostPopularitySchema ;
return (
< div className = "min-w-52 rounded-lg border bg-background p-3 text-xs shadow-xl" dir = "rtl" >
< p className = "mb-2 max-w-72 font-semibold" > { item . title } < / p >
< div className = "space-y-1 text-muted-foreground" >
< p > ل ا ی ک : { formatNumberPersian ( item . likes ) } < / p >
< p > ذ خ ی ر ه : { formatNumberPersian ( item . saves ) } < / p >
< p > ک ا م ن ت : { formatNumberPersian ( item . comments ) } < / p >
< / div >
< / div >
) ;
} }
/ >
< Scatter data = { data } fill = "var(--color-saves)" / >
< / ScatterChart >
< ChartTooltip content = { < PostEngagementTooltip / > } / >
< Bar dataKey = "likes" stackId = "engagement" fill = "var(--color-likes)" radius = { [ 0 , 0 , 0 , 0 ] } / >
< Bar dataKey = "saves" stackId = "engagement" fill = "var(--color-saves)" radius = { [ 0 , 0 , 0 , 0 ] } / >
< Bar dataKey = "comments" stackId = "engagement" fill = "var(--color-comments)" radius = { [ 8 , 8 , 8 , 8 ] } / >
< / BarChart >
< / ChartContainer >
< / ChartViewport >
{ group . total_count > data . length ? (
< p className = "mt-2 text-xs text-muted-foreground " >
ن م ا ی ش { formatNumberPersian ( data . length ) } ن و ش ت ه ب ر ت ر ا ز { formatNumberPersian ( group . total_count ) } ن و شت ه د ا ر ا ی ت ع ا م ل .
< / p >
< div className = "mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between " >
< span > ن م و د ا ر خ ل ا ص ه { formatNumberPersian ( data . length ) } ن و ش ت ه ا و ل ر ا ن ش ا ن م ی د ه د . < / span >
< Button type = "button" size = "sm" variant = "secondary" className = "shrink-0" onClick = { ( ) = > setDetailsOpen ( true ) } >
م ش ا ه د ه ه م ه
< / Button >
< / div >
) : null }
< BlogPostEngagementModal open = { detailsOpen } onOpenChange = { setDetailsOpen } posts = { allPosts } / >
< / >
) }
< / CardContent >
@@ -594,11 +945,11 @@ function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularit
function TopEventsCard ( { group } : { group : EventAnalyticsSchema [ "top_events" ] } ) {
return (
< Card >
< CardHeader >
< CardTitle > ر و ی د ا د ه ا ی ب ر ت ر < / CardTitle >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "text-base sm:text-lg" > ر و ی د ا د ه ا ی ب ر ت ر < / CardTitle >
< CardDescription > ب ر ا س ا س ش ر ک ت ک ن ن د ه ت ا ی ی د ش د ه ، د ر آ م د و ز م ا ن ب ر گ ز ا ر ی < / CardDescription >
< / CardHeader >
< CardContent className = "space-y-3" >
< CardContent className = "space-y-3 p-4 pt-0 sm:p-6 sm:pt-0 " >
{ group . top_items . length ? (
group . top_items . map ( ( event , index ) = > (
< div key = { event . id } className = "flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3" >
@@ -629,11 +980,11 @@ function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] })
function TopPostsCard ( { posts } : { posts : BlogAnalyticsSchema [ "top_posts" ] } ) {
return (
< Card >
< CardHeader >
< CardTitle > ن و ش ت ه ه ا ی ب ر ت ر < / CardTitle >
< CardHeader className = "p-4 sm:p-6" >
< CardTitle className = "text-base sm:text-lg" > ن و ش ت ه ه ا ی ب ر ت ر < / CardTitle >
< CardDescription > ب ر ا س ا س ج م ع ل ا ی ک ، ذ خ ی ر ه و ک ا م ن ت < / CardDescription >
< / CardHeader >
< CardContent className = "space-y-3" >
< CardContent className = "space-y-3 p-4 pt-0 sm:p-6 sm:pt-0 " >
{ posts . length ? (
posts . map ( ( post , index ) = > (
< div key = { post . id } className = "flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3" >
@@ -655,17 +1006,22 @@ function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
) ;
}
function UsersSection() {
const [ filters , setFilters ] = React . useState < DateRangeState > ( { from : "" , to : "" } ) ;
function UsersSection( {
filters ,
onFiltersChange ,
} : {
filters : DateRangeState ;
onFiltersChange : ( filters : DateRangeState ) = > void ;
} ) {
const query = useQuery ( {
queryKey : [ "admin" , "analytics" , "users" , filters ] ,
queryFn : ( ) = > api . getAdminUserAnalytics ( { date_from : filters.from || undefined , date_to : filters.to || undefined } ) ,
} ) ;
return (
< div className = "space-y-6" >
< div className = "space-y-6" dir = "rtl" >
< FilterCard title = "فیلتر کاربران" description = "این فیلتر فقط روی کاربران و تاریخ عضویت آنها اعمال میشود." >
< DateRangeFilter value = { filters } onChange = { set Filters} onReset = { ( ) = > set Filters( { from : "" , to : "" } ) } / >
< DateRangeFilter value = { filters } onChange = { on FiltersChange } onReset = { ( ) = > on FiltersChange ( { from : "" , to : "" } ) } / >
< / FilterCard >
{ query . isLoading ? < SectionLoading / > : null }
{ query . isError ? < SectionError error = { query . error } / > : null }
@@ -701,8 +1057,13 @@ function UsersContent({ data }: { data: UserAnalyticsSchema }) {
) ;
}
function EventsSection() {
const [ filters , setFilters ] = React . useState < SectionState > ( { from : "" , to : "" , eventId : null } ) ;
function EventsSection( {
filters ,
onFiltersChange ,
} : {
filters : SectionState ;
onFiltersChange : ( filters : SectionState ) = > void ;
} ) {
const query = useQuery ( {
queryKey : [ "admin" , "analytics" , "events" , filters ] ,
queryFn : ( ) = >
@@ -713,16 +1074,16 @@ function EventsSection() {
} ) ,
} ) ;
const reset = ( ) = > set Filters( { from : "" , to : "" , eventId : null } ) ;
const reset = ( ) = > on FiltersChange ( { from : "" , to : "" , eventId : null } ) ;
return (
< div className = "space-y-6" >
< FilterCard title = "فیلتر رویدادها" description = "این فیلتر فقط روی آمار رویداد، ثبتنام، درآمد و تنوع شرکتکنندگان اعمال میشود." >
< div className = "grid gap-3 xl:grid-cols-[1fr_1 fr_1.2fr_auto ]" >
< div className = "xl:col-span-2" >
< div className = "grid gap-3 xl:grid-cols-[2 fr_1.2fr]" >
< div >
< DateRangeFilter
value = { filters }
onChange = { ( next ) = > set Filters( ( current ) = > ( { . . . current , . . . next } ) ) }
onChange = { ( next ) = > on FiltersChange ( { . . . filters , . . . next } ) }
onReset = { reset }
/ >
< / div >
@@ -730,7 +1091,7 @@ function EventsSection() {
< Label > ر و ی د ا د < / Label >
< AsyncSearchableCombobox
value = { filters . eventId ? ? null }
onChange = { ( eventId ) = > set Filters( ( current ) = > ( { . . . current , eventId } ) ) }
onChange = { ( eventId ) = > on FiltersChange ( { . . . filters , eventId } ) }
loadOptions = { async ( { search , limit , offset } ) = > {
const data = await api . getAdminDashboardEventOptions ( { search , limit , offset } ) ;
return {
@@ -746,11 +1107,6 @@ function EventsSection() {
emptyText = "رویدادی پیدا نشد."
/ >
< / div >
< div className = "hidden items-end xl:flex" >
< Button variant = "outline" onClick = { reset } >
پ ا ک ک ر د ن
< / Button >
< / div >
< / div >
< / FilterCard >
{ query . isLoading ? < SectionLoading / > : null }
@@ -799,8 +1155,13 @@ function EventsContent({ data }: { data: EventAnalyticsSchema }) {
) ;
}
function BlogSection() {
const [ filters , setFilters ] = React . useState < DateRangeState > ( { from : "" , to : "" } ) ;
function BlogSection( {
filters ,
onFiltersChange ,
} : {
filters : DateRangeState ;
onFiltersChange : ( filters : DateRangeState ) = > void ;
} ) {
const query = useQuery ( {
queryKey : [ "admin" , "analytics" , "blog" , filters ] ,
queryFn : ( ) = > api . getAdminBlogAnalytics ( { date_from : filters.from || undefined , date_to : filters.to || undefined } ) ,
@@ -809,7 +1170,7 @@ function BlogSection() {
return (
< div className = "space-y-6" >
< FilterCard title = "فیلتر بلاگ" description = "این فیلتر فقط روی نوشتهها و تعاملات بلاگ اعمال میشود و به رویدادها وابسته نیست." >
< DateRangeFilter value = { filters } onChange = { set Filters} onReset = { ( ) = > set Filters( { from : "" , to : "" } ) } / >
< DateRangeFilter value = { filters } onChange = { on FiltersChange } onReset = { ( ) = > on FiltersChange ( { from : "" , to : "" } ) } / >
< / FilterCard >
{ query . isLoading ? < SectionLoading / > : null }
{ query . isError ? < SectionError error = { query . error } / > : null }
@@ -829,7 +1190,7 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
< / div >
< div className = "grid gap-4 xl:grid-cols-3" >
< div className = "xl:col-span-2" >
< BlogScatter Card group = { data . post_popularity } / >
< BlogEngagement Card group = { data . post_popularity } / >
< / div >
< ActivityTrendCard data = { data . activity_trend } / >
< / div >
@@ -843,19 +1204,46 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
}
export default function AdminDashboard() {
const router = useRouter ( ) ;
const pathname = usePathname ( ) ;
const searchParams = useSearchParams ( ) ;
const [ activeTab , setActiveTab ] = React . useState < DashboardTab > ( ( ) = > parseTab ( searchParams . get ( "tab" ) ) ) ;
const [ usersFilters , setUsersFilters ] = React . useState < DateRangeState > ( ( ) = > readDateRange ( searchParams , "users" ) ) ;
const [ eventsFilters , setEventsFilters ] = React . useState < SectionState > ( ( ) = > ( {
. . . readDateRange ( searchParams , "events" ) ,
eventId : searchParams.get ( "event_id" ) ,
} ) ) ;
const [ blogFilters , setBlogFilters ] = React . useState < DateRangeState > ( ( ) = > readDateRange ( searchParams , "blog" ) ) ;
React . useEffect ( ( ) = > {
const params = new URLSearchParams ( ) ;
params . set ( "tab" , activeTab ) ;
setParam ( params , "users_from" , usersFilters . from ) ;
setParam ( params , "users_to" , usersFilters . to ) ;
setParam ( params , "events_from" , eventsFilters . from ) ;
setParam ( params , "events_to" , eventsFilters . to ) ;
setParam ( params , "event_id" , eventsFilters . eventId ) ;
setParam ( params , "blog_from" , blogFilters . from ) ;
setParam ( params , "blog_to" , blogFilters . to ) ;
const nextSearch = params . toString ( ) ;
const currentSearch = searchParams . toString ( ) ;
if ( nextSearch !== currentSearch ) {
router . replace ( ` ${ pathname } ? ${ nextSearch } ` , { scroll : false } ) ;
}
} , [ activeTab , blogFilters , eventsFilters , pathname , router , searchParams , usersFilters ] ) ;
return (
< div className = "space-y-6" dir = "rtl" >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between ">
< div >
< h2 className = "text-2xl font-black tracking-tight" > د ا ش ب و ر د د س ت ا و ر د ه ا < / h2 >
< p className = "mt-1 text-sm text-muted-foreground" >
گ ز ا ر ش ه ا ی ج د ا گ ا ن ه ب ر ا ی ک ا ر ب ر ا ن ، ر و ی د ا د ه ا و ب ل ا گ ب ا ف ی ل ت ر ه ا ی م س ت ق ل و خ و ا ن ا
< / p >
< / div >
< / div >
< Tabs defaultValue = "users" className = "space-y-6" >
< TabsList className = "grid h-auto w-full grid-cols-3 rounded-2xl p-1 sm:w-fit" >
< Tabs dir = "rtl" value = { activeTab } onValueChange = { ( value ) = > setActiveTab ( parseTab ( value ) ) } className = "space-y-6 ">
< div className = "flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between" >
< div >
< h2 className = "text-xl font-black tracking-tight sm:text-2xl" > د ا ش ب و ر د د س ت ا و ر د ه ا < / h2 >
< p className = "mt-1 text-xs leading-6 text-muted-foreground sm:text-sm" >
گ ز ا ر ش ه ا ی ج د ا گ ا ن ه ب ر ا ی ک ا ر ب ر ا ن ، ر و ی د ا د ه ا و ب ل ا گ ب ا ف ی ل ت ر ه ا ی م س ت ق ل و خ و ا ن ا
< / p >
< / div >
< TabsList className = "grid h-auto w-full grid-cols-3 rounded-2xl p-1 lg:w-fit" >
< TabsTrigger value = "users" className = "gap-2 rounded-xl py-2" >
< UsersRound className = "h-4 w-4" / >
ک ا ر ب ر ا ن
@@ -868,15 +1256,16 @@ export default function AdminDashboard() {
< Tags className = "h-4 w-4" / >
ب ل ا گ
< / TabsTrigger >
< / TabsList >
< / TabsList >
< / div >
< TabsContent value = "users" >
< UsersSection / >
< UsersSection filters = { usersFilters } onFiltersChange = { setUsersFilters } / >
< / TabsContent >
< TabsContent value = "events" >
< EventsSection / >
< EventsSection filters = { eventsFilters } onFiltersChange = { setEventsFilters } / >
< / TabsContent >
< TabsContent value = "blog" >
< BlogSection / >
< BlogSection filters = { blogFilters } onFiltersChange = { setBlogFilters } / >
< / TabsContent >
< / Tabs >
< / div >