浏览代码

feat: implement evaluations page

Mohammad Mahdi Salimi 11 小时之前
父节点
当前提交
3e5e8da807
共有 29 个文件被更改,包括 1550 次插入120 次删除
  1. 6 1
      src/components/layout/data/sidebar-data.ts
  2. 173 0
      src/components/video-player.tsx
  3. 6 0
      src/constants/common-constansts.ts
  4. 5 0
      src/constants/evaluations/evaluations-constants.ts
  5. 0 7
      src/constants/users/users-constants.ts
  6. 139 0
      src/features/evaluations/components/data-table-bulk-actions.tsx
  7. 69 0
      src/features/evaluations/components/data-table-row-actions.tsx
  8. 114 0
      src/features/evaluations/components/evaluations-action-dialog.tsx
  9. 159 0
      src/features/evaluations/components/evaluations-columns.tsx
  10. 57 0
      src/features/evaluations/components/evaluations-delete-dialog.tsx
  11. 18 0
      src/features/evaluations/components/evaluations-dialogs.tsx
  12. 103 0
      src/features/evaluations/components/evaluations-multi-delete-dialog.tsx
  13. 49 0
      src/features/evaluations/components/evaluations-provider.tsx
  14. 62 0
      src/features/evaluations/components/evaluations-retry-dialog.tsx
  15. 200 0
      src/features/evaluations/components/evaluations-table.tsx
  16. 62 0
      src/features/evaluations/index.tsx
  17. 1 1
      src/features/posts/components/posts-action-dialog.tsx
  18. 0 110
      src/features/posts/components/video-player.tsx
  19. 1 1
      src/features/users/components/users-columns.tsx
  20. 12 0
      src/hooks/evaluations/useEvaluation.tsx
  21. 15 0
      src/hooks/evaluations/useEvaluations.tsx
  22. 17 0
      src/hooks/evaluations/useRetryEvaluations.tsx
  23. 28 0
      src/hooks/evaluations/useStoreEvaluation.tsx
  24. 10 0
      src/hooks/use-video-player.tsx
  25. 22 0
      src/routeTree.gen.ts
  26. 17 0
      src/routes/_authenticated/evaluations/index.tsx
  27. 53 0
      src/services/evaluations.service.ts
  28. 1 0
      src/types/common.types.ts
  29. 151 0
      src/types/evaluations.types.ts

+ 6 - 1
src/components/layout/data/sidebar-data.ts

@@ -1,4 +1,4 @@
-import { LayoutDashboard, Users, Play } from 'lucide-react'
+import { LayoutDashboard, Users, Play, Star } from 'lucide-react'
 import { type SidebarData } from '../types'
 import Logo from '@/assets/logo.svg'
 
@@ -29,6 +29,11 @@ export const sidebarData: SidebarData = {
           url: '/posts',
           icon: Play,
         },
+        {
+          title: 'ارزیابی',
+          url: '/evaluations',
+          icon: Star,
+        },
         // {
         //   title: 'کامنت ها',
         //   url: '/chats',

+ 173 - 0
src/components/video-player.tsx

@@ -0,0 +1,173 @@
+'use client'
+
+import { useVideoPlayer } from '@/hooks/use-video-player'
+import { Play, RefreshCw, Volume2, VolumeX } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+
+type Props = {
+  src: string
+}
+
+// type VideoMode = "portrait" | "landscape" | "square";
+
+// function getVideoMode(ratio: number | null): VideoMode {
+//   if (ratio === null) return "portrait";
+//   if (ratio < 1) return "portrait";
+//   if (ratio > 1.2) return "landscape";
+//   return "square";
+// }
+//
+// const videoContainerClass: Record<VideoMode, string> = {
+//   portrait: "aspect-3/4",
+//   landscape: "aspect-video",
+//   square: "aspect-square",
+// };
+//
+// const videoObjectClass: Record<VideoMode, string> = {
+//   portrait: "object-cover",
+//   landscape: "object-contain",
+//   square: "object-cover",
+// };
+
+export default function VideoPlayer({ src }: Props) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const [isLoading, setIsLoading] = useState(true)
+
+  const {
+    videoRef,
+    isPlaying,
+    setIsPlaying,
+    isMuted,
+    progress,
+    isEnded,
+    aspectRatio,
+    togglePlay,
+    toggleMute,
+    seek,
+    handleLoadedMetadata,
+  } = useVideoPlayer()
+
+  // const mode = getVideoMode(aspectRatio);
+
+  useEffect(() => {
+    const container = containerRef.current
+    const video = videoRef.current
+    if (!container || !video) return
+
+    const observer = new IntersectionObserver(
+      ([entry]) => {
+        if (!entry?.isIntersecting) {
+          video.pause()
+          setIsPlaying(false)
+        }
+      },
+      { threshold: 0.6 }
+    )
+
+    observer.observe(container)
+    return () => observer.disconnect()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
+    const rect = e.currentTarget.getBoundingClientRect()
+    const percent = (e.clientX - rect.left) / rect.width
+    seek(percent)
+  }
+
+  return (
+    <div
+      className='relative mx-auto w-full overflow-hidden rounded-2xl bg-black'
+      ref={containerRef}
+    >
+      {/*{mode === "landscape" && (*/}
+      {/*  <video*/}
+      {/*    src={src}*/}
+      {/*    className="absolute inset-0 h-full w-full scale-110 object-cover opacity-40 blur-xl"*/}
+      {/*    muted*/}
+      {/*    playsInline*/}
+      {/*    aria-hidden*/}
+      {/*    tabIndex={-1}*/}
+      {/*    ref={(el) => {*/}
+      {/*      const main = videoRef.current;*/}
+      {/*      if (!el || !main) return;*/}
+      {/*      main.addEventListener("play", () => el.play());*/}
+      {/*      main.addEventListener("pause", () => el.pause());*/}
+      {/*      main.addEventListener("seeked", () => {*/}
+      {/*        el.currentTime = main.currentTime;*/}
+      {/*      });*/}
+      {/*    }}*/}
+      {/*  />*/}
+      {/*)}*/}
+
+      {/* Main video */}
+      <div
+        style={{ aspectRatio: aspectRatio ? aspectRatio : '9/16' }}
+        className={`relative w-full`}
+      >
+        <video
+          ref={videoRef}
+          src={src}
+          className={`h-full w-full`}
+          muted={isMuted}
+          playsInline
+          controls={false}
+          onClick={togglePlay}
+          onContextMenu={(e) => e.preventDefault()}
+          onCanPlay={() => setIsLoading(false)}
+          onLoadedMetadata={handleLoadedMetadata}
+        />
+
+        {/* Loading Overlay */}
+        {isLoading && (
+          <div className='absolute inset-0 overflow-hidden bg-neutral-900'>
+            <div className='animate-shimmer absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/5 to-transparent' />
+          </div>
+        )}
+
+        {/* Center Control */}
+        {!isLoading && (isEnded || !isPlaying) && (
+          <div
+            className='absolute inset-0 flex items-center justify-center bg-black/30 transition-all duration-300'
+            onClick={togglePlay}
+          >
+            {isEnded ? (
+              <button className='flex size-12 cursor-pointer items-center justify-center rounded-full bg-black/30 transition-transform duration-200 hover:scale-110'>
+                <RefreshCw size={24} className='text-white' />
+              </button>
+            ) : (
+              !isPlaying && (
+                <button className='flex size-12 cursor-pointer items-center justify-center rounded-full bg-black/30 transition-transform duration-200 hover:scale-110'>
+                  <Play fill='white' strokeWidth={0} size={24} />
+                </button>
+              )
+            )}
+          </div>
+        )}
+
+        {/* Bottom Controls */}
+        {!isLoading && (
+          <div className='absolute right-4 bottom-4 left-4 flex items-center justify-between'>
+            <button onClick={toggleMute} className='cursor-pointer text-white'>
+              {isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
+            </button>
+          </div>
+        )}
+
+        {/* Progress */}
+        {!isLoading && (
+          <div
+            dir='ltr'
+            className='absolute right-0 bottom-0 left-0 h-1.5 cursor-pointer bg-white/20'
+            onClick={handleSeek}
+          >
+            <div
+              className='h-full bg-white transition-all duration-150'
+              style={{ width: `${progress}%` }}
+            />
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 6 - 0
src/constants/common-constansts.ts

@@ -0,0 +1,6 @@
+export const planDurations: Record<number, string> = {
+  1: '1 ماهه',
+  3: '3 ماهه',
+  6: '6 ماهه',
+  12: '12 ماهه',
+}

+ 5 - 0
src/constants/evaluations/evaluations-constants.ts

@@ -0,0 +1,5 @@
+export const evaluationsStatus = {
+  approved: 'تکمیل شده',
+  pending: 'در حال بررسی',
+  reject: 'رد شده',
+}

+ 0 - 7
src/constants/users/users-constants.ts

@@ -34,10 +34,3 @@ export const activityHistoryOptions = [
   { label: 'دارم', value: true },
   { label: 'ندارم', value: false },
 ]
-
-export const planDurations: Record<number, string> = {
-  1: '1 ماهه',
-  3: '3 ماهه',
-  6: '6 ماهه',
-  12: '12 ماهه',
-}

+ 139 - 0
src/features/evaluations/components/data-table-bulk-actions.tsx

@@ -0,0 +1,139 @@
+import { useState } from 'react'
+import { type Table } from '@tanstack/react-table'
+import { Trash2, UserX, UserCheck, Mail } from 'lucide-react'
+import { toast } from 'sonner'
+import { sleep } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
+import { EvaluationsMultiDeleteDialog } from './evaluations-multi-delete-dialog'
+import type { Post } from '@/types/posts.types'
+
+type DataTableBulkActionsProps<TData> = {
+  table: Table<TData>
+}
+
+export function DataTableBulkActions<TData>({
+  table,
+}: DataTableBulkActionsProps<TData>) {
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+  const selectedRows = table.getFilteredSelectedRowModel().rows
+
+  const handleBulkStatusChange = (status: 'active' | 'inactive') => {
+    const selectedUsers = selectedRows.map((row) => row.original as Post)
+    toast.promise(sleep(2000), {
+      loading: `${status === 'active' ? 'Activating' : 'Deactivating'} users...`,
+      success: () => {
+        table.resetRowSelection()
+        return `${status === 'active' ? 'Activated' : 'Deactivated'} ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
+      },
+      error: `Error ${status === 'active' ? 'activating' : 'deactivating'} users`,
+    })
+    table.resetRowSelection()
+  }
+
+  const handleBulkInvite = () => {
+    const selectedUsers = selectedRows.map((row) => row.original as Post)
+    toast.promise(sleep(2000), {
+      loading: 'Inviting users...',
+      success: () => {
+        table.resetRowSelection()
+        return `Invited ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
+      },
+      error: 'Error inviting users',
+    })
+    table.resetRowSelection()
+  }
+
+  return (
+    <>
+      <BulkActionsToolbar table={table} entityName='user'>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={handleBulkInvite}
+              className='size-8'
+              aria-label='Invite selected users'
+              title='Invite selected users'
+            >
+              <Mail />
+              <span className='sr-only'>Invite selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Invite selected users</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={() => handleBulkStatusChange('active')}
+              className='size-8'
+              aria-label='Activate selected users'
+              title='Activate selected users'
+            >
+              <UserCheck />
+              <span className='sr-only'>Activate selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Activate selected users</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='outline'
+              size='icon'
+              onClick={() => handleBulkStatusChange('inactive')}
+              className='size-8'
+              aria-label='Deactivate selected users'
+              title='Deactivate selected users'
+            >
+              <UserX />
+              <span className='sr-only'>Deactivate selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Deactivate selected users</p>
+          </TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant='destructive'
+              size='icon'
+              onClick={() => setShowDeleteConfirm(true)}
+              className='size-8'
+              aria-label='Delete selected users'
+              title='Delete selected users'
+            >
+              <Trash2 />
+              <span className='sr-only'>Delete selected users</span>
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>Delete selected users</p>
+          </TooltipContent>
+        </Tooltip>
+      </BulkActionsToolbar>
+
+      <EvaluationsMultiDeleteDialog
+        table={table}
+        open={showDeleteConfirm}
+        onOpenChange={setShowDeleteConfirm}
+      />
+    </>
+  )
+}

+ 69 - 0
src/features/evaluations/components/data-table-row-actions.tsx

@@ -0,0 +1,69 @@
+import { DotsHorizontalIcon } from '@radix-ui/react-icons'
+import { type Row } from '@tanstack/react-table'
+import { Eye, RefreshCcw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuShortcut,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+import { type Evaluation } from '@/types/evaluations.types'
+import { useEvaluations } from './evaluations-provider'
+
+interface Props {
+  row: Row<Evaluation>
+}
+
+export function DataTableRowActions({ row }: Props) {
+  const { setOpen, setCurrentRow } = useEvaluations()
+
+  const evaluations = row.original
+
+  const handleShowConfirmRetry = () => {
+    setCurrentRow(evaluations)
+    setOpen('retry')
+  }
+
+  const handleView = () => {
+    setCurrentRow(evaluations)
+    setOpen('view')
+  }
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant='ghost' className='flex h-8 w-8 p-0'>
+          <DotsHorizontalIcon className='h-4 w-4' />
+        </Button>
+      </DropdownMenuTrigger>
+
+      <DropdownMenuContent align='end'>
+        <DropdownMenuItem onClick={handleView}>
+          مشاهده
+          <DropdownMenuShortcut>
+            <Eye size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        <DropdownMenuItem onClick={handleShowConfirmRetry}>
+          ارزیابی مجدد
+          <DropdownMenuShortcut>
+            <RefreshCcw size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        {/* <DropdownMenuSeparator /> */}
+
+        {/* <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
+          حذف
+          <DropdownMenuShortcut>
+            <Trash2 size={16} color='red' />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem> */}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 114 - 0
src/features/evaluations/components/evaluations-action-dialog.tsx

@@ -0,0 +1,114 @@
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+
+import { useEvaluations } from './evaluations-provider'
+
+import { useEvaluation } from '@/hooks/evaluations/useEvaluation'
+import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
+import { AlertTriangle } from 'lucide-react'
+import VideoPlayer from '@/components/video-player'
+
+export function EvaluationsActionDialog() {
+  const { open, setOpen, currentRow } = useEvaluations()
+
+  const opened = open === 'view'
+
+  const { data: evaluation, isLoading } = useEvaluation(
+    currentRow?.id as number
+  )
+  const mediaSrc = evaluation?.media?.[0]?.url
+
+  const handleClose = () => setOpen(null)
+
+  return (
+    <Dialog open={opened} modal onOpenChange={handleClose}>
+      <DialogContent className='max-h-[90vh] max-w-5xl overflow-y-auto'>
+        <DialogHeader>
+          <DialogTitle>مشاهده ارزیابی</DialogTitle>
+        </DialogHeader>
+
+        {isLoading || !evaluation ? (
+          <div className='py-10 text-center'>در حال دریافت اطلاعات...</div>
+        ) : (
+          <div className='flex flex-col gap-4'>
+            {mediaSrc && <VideoPlayer src={mediaSrc as string} />}
+
+            {evaluation.items.length === 0 ? (
+              <div className='flex items-center justify-center gap-3 rounded-xl border bg-muted px-2 py-4 text-center text-sm font-bold text-primary'>
+                <AlertTriangle />
+                <span>ارزیابی برای این کاربر یافت نشد.</span>
+              </div>
+            ) : (
+              <>
+                <h2>نتیجه ارزیابی</h2>
+                <div className='overflow-hidden rounded-2xl border border-slate-200'>
+                  <Table>
+                    <TableBody>
+                      {evaluation?.items.map((item) => (
+                        <TableRow
+                          key={item.id}
+                          className='border-b border-slate-100 last:border-0 hover:bg-transparent'
+                        >
+                          <TableCell className='p-3 text-right text-sm leading-5'>
+                            {item.item.name}
+                          </TableCell>
+
+                          <TableCell className='flex items-center justify-end gap-1 p-3 text-muted-foreground'>
+                            <span className='text-foreground'> 100 /</span>
+                            <span className='text-base text-foreground'>
+                              {item.score}
+                            </span>
+                          </TableCell>
+                        </TableRow>
+                      ))}
+                      <TableRow className='border-b border-slate-100 last:border-0 hover:bg-transparent'>
+                        <TableCell className='p-3 text-right text-sm leading-5 font-semibold'>
+                          مجموع عملکرد
+                        </TableCell>
+
+                        <TableCell className='flex items-center justify-end gap-1 p-3 text-base font-semibold'>
+                          <span> 100 /</span>
+                          <span>{evaluation.score}</span>
+                        </TableCell>
+                      </TableRow>
+                    </TableBody>
+                  </Table>
+                </div>
+
+                {evaluation?.pros_comment && (
+                  <div className='flex flex-col gap-4'>
+                    <span className='leading-6'>نقاط قوت:</span>
+                    <p className='leading-6 font-light text-muted-foreground'>
+                      {evaluation.pros_comment}
+                    </p>
+                  </div>
+                )}
+
+                {evaluation?.cons_comment && (
+                  <div className='flex flex-col gap-4'>
+                    <span className='leading-6'>نقاط ضعف:</span>
+                    <p className='leading-6 font-light text-muted-foreground'>
+                      {evaluation.cons_comment}
+                    </p>
+                  </div>
+                )}
+                {evaluation?.summary && (
+                  <div className='flex flex-col gap-4'>
+                    <span className='leading-6'>نتیجه:</span>
+                    <p className='leading-6 font-light text-muted-foreground'>
+                      {evaluation.summary}
+                    </p>
+                  </div>
+                )}
+              </>
+            )}
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 159 - 0
src/features/evaluations/components/evaluations-columns.tsx

@@ -0,0 +1,159 @@
+import { type ColumnDef } from '@tanstack/react-table'
+
+import { cn } from '@/lib/utils'
+
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+
+import { DataTableColumnHeader } from '@/components/data-table'
+import { LongText } from '@/components/long-text'
+
+import { DataTableRowActions } from './data-table-row-actions'
+import { type Evaluation } from '@/types/evaluations.types'
+import { planDurations } from '@/constants/common-constansts'
+import { evaluationsStatus } from '@/constants/evaluations/evaluations-constants'
+
+export const evaluationColumns: ColumnDef<Evaluation>[] = [
+  {
+    id: 'select',
+    header: ({ table }) => (
+      <Checkbox
+        checked={
+          table.getIsAllPageRowsSelected() ||
+          (table.getIsSomePageRowsSelected() && 'indeterminate')
+        }
+        onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+        aria-label='Select all'
+        className='translate-y-0.5'
+      />
+    ),
+    cell: ({ row }) => (
+      <Checkbox
+        checked={row.getIsSelected()}
+        onCheckedChange={(value) => row.toggleSelected(!!value)}
+        aria-label='Select row'
+        className='translate-y-0.5'
+      />
+    ),
+    enableSorting: false,
+    enableHiding: false,
+    meta: {
+      className: cn('inset-s-0 z-10 rounded-tl-[inherit] max-md:sticky'),
+    },
+  },
+
+  {
+    accessorKey: 'id',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='شناسه' />
+    ),
+    cell: ({ row }) => <div className='w-15'>#{row.original.id}</div>,
+    enableSorting: true,
+  },
+
+  {
+    accessorKey: 'username',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام کاربری' />
+    ),
+    cell: ({ row }) => (
+      <LongText className='max-w-36 ps-3'>
+        {row.original.user.username}
+      </LongText>
+    ),
+    enableHiding: false,
+    meta: {
+      className: cn(
+        'drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)]',
+        'dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)]',
+        'inset-s-6 ps-0.5 max-md:sticky',
+        '@4xl/content:table-cell',
+        '@4xl/content:drop-shadow-none'
+      ),
+    },
+  },
+
+  {
+    accessorKey: 'full_name',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام کامل' />
+    ),
+    cell: ({ row }) => (
+      <div className='max-w-36 ps-3'>{row.original.user.full_name}</div>
+    ),
+  },
+
+  {
+    accessorKey: 'subscription_type',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='اشتراک' />
+    ),
+    cell: ({ row }) => (
+      <div className='max-w-36 ps-3'>
+        {row.original?.user?.subscription?.plan
+          ? `${row.original?.user?.subscription?.plan?.type} (${planDurations[row.original.user?.subscription?.plan?.duration]})`
+          : 'رایگان'}
+      </div>
+    ),
+  },
+
+  {
+    accessorKey: 'score',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نمره ارزیابی' />
+    ),
+    cell: ({ row }) => (
+      <div className='max-w-36 ps-3'>{row.original.score ?? '-'}</div>
+    ),
+  },
+
+  {
+    accessorKey: 'status_type',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='وضعیت ارزیابی' />
+    ),
+    cell: ({ row }) => {
+      const status = row.original?.status
+      const variant =
+        status === 'reject'
+          ? 'destructive'
+          : status === 'pending'
+            ? 'outline'
+            : 'secondary'
+
+      return <Badge variant={variant}>{evaluationsStatus[status!]}</Badge>
+    },
+  },
+
+  {
+    accessorKey: 'created_at',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='تاریخ ارزیابی' />
+    ),
+    cell: ({ row }) => {
+      return (
+        <div className='text-sm text-muted-foreground'>
+          {new Date(row.original.created_at).toLocaleDateString('fa-IR')}
+        </div>
+      )
+    },
+  },
+
+  {
+    id: 'status',
+    accessorFn: (row) => row?.status,
+    header: () => null,
+    cell: () => null,
+    filterFn: (row, columnId, filterValue: string) => {
+      if (!filterValue) return true
+      return row.getValue(columnId) === filterValue
+    },
+  },
+
+  {
+    id: 'actions',
+    cell: DataTableRowActions,
+    enableSorting: false,
+    enableHiding: false,
+  },
+]

+ 57 - 0
src/features/evaluations/components/evaluations-delete-dialog.tsx

@@ -0,0 +1,57 @@
+import { AlertTriangle } from 'lucide-react'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+import { type Evaluation } from '@/types/evaluations.types'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: Evaluation | null
+}
+
+export function EvaluationsDeleteDialog({
+  open,
+  onOpenChange,
+  currentRow,
+}: Props) {
+  // const { mutate, isPending } = useDeleteUser()
+
+  if (!currentRow) return null
+
+  // const handleDelete = () => {
+  //   mutate(currentRow.id, {
+  //     onSuccess: () => {
+  //       onOpenChange(false)
+  //     },
+  //   })
+  // }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      form='evaluations-delete-form'
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='me-1 inline-block stroke-destructive'
+            size={18}
+          />{' '}
+          حذف پست
+        </span>
+      }
+      desc={
+        <Alert variant='destructive'>
+          <AlertTitle>هشدار!</AlertTitle>
+          <AlertDescription>
+            لطفا مراقب باشید، این عملیات قابل برگشت نیست.
+          </AlertDescription>
+        </Alert>
+      }
+      confirmText='حذف'
+      cancelBtnText='انصراف'
+      // isLoading={isPending}
+      destructive
+    />
+  )
+}

+ 18 - 0
src/features/evaluations/components/evaluations-dialogs.tsx

@@ -0,0 +1,18 @@
+import { EvaluationsActionDialog } from './evaluations-action-dialog'
+import { useEvaluations } from './evaluations-provider'
+import { EvaluationsRetryDialog } from './evaluations-retry-dialog'
+
+export function EvaluationsDialogs() {
+  const { open, setOpen, currentRow } = useEvaluations()
+  return (
+    <>
+      <EvaluationsActionDialog />
+
+      <EvaluationsRetryDialog
+        open={open === 'retry'}
+        onOpenChange={() => setOpen(null)}
+        currentRow={currentRow}
+      />
+    </>
+  )
+}

+ 103 - 0
src/features/evaluations/components/evaluations-multi-delete-dialog.tsx

@@ -0,0 +1,103 @@
+'use client'
+
+import { useState } from 'react'
+import { type Table } from '@tanstack/react-table'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+import { sleep } from '@/lib/utils'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+type EvaluationMultiDeleteDialogProps<TData> = {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  table: Table<TData>
+}
+
+const CONFIRM_WORD = 'DELETE'
+
+export function EvaluationsMultiDeleteDialog<TData>({
+  open,
+  onOpenChange,
+  table,
+}: EvaluationMultiDeleteDialogProps<TData>) {
+  const [value, setValue] = useState('')
+
+  const selectedRows = table.getFilteredSelectedRowModel().rows
+
+  const handleDelete = () => {
+    if (value.trim() !== CONFIRM_WORD) {
+      toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
+      return
+    }
+
+    onOpenChange(false)
+
+    toast.promise(sleep(2000), {
+      loading: 'Deleting evaluations...',
+      success: () => {
+        setValue('')
+        table.resetRowSelection()
+        return `Deleted ${selectedRows.length} ${
+          selectedRows.length > 1 ? 'evaluations' : 'evaluation'
+        }`
+      },
+      error: 'Error',
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      form='evaluations-multi-delete-form'
+      disabled={value.trim() !== CONFIRM_WORD}
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='me-1 inline-block stroke-destructive'
+            size={18}
+          />{' '}
+          Delete {selectedRows.length}{' '}
+          {selectedRows.length > 1 ? 'evaluations' : 'evaluation'}
+        </span>
+      }
+      desc={
+        <form
+          id='evaluations-multi-delete-form'
+          onSubmit={(e) => {
+            e.preventDefault()
+            handleDelete()
+          }}
+          className='space-y-4'
+        >
+          <p className='mb-2'>
+            Are you sure you want to delete the selected evaluations? <br />
+            This action cannot be undone.
+          </p>
+
+          <Label className='my-4 flex flex-col items-start gap-1.5'>
+            <span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
+            <Input
+              value={value}
+              onChange={(e) => setValue(e.target.value)}
+              placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
+              autoFocus
+            />
+          </Label>
+
+          <Alert variant='destructive'>
+            <AlertTitle>Warning!</AlertTitle>
+            <AlertDescription>
+              Please be careful, this operation can not be rolled back.
+            </AlertDescription>
+          </Alert>
+        </form>
+      }
+      confirmText='Delete'
+      destructive
+    />
+  )
+}

+ 49 - 0
src/features/evaluations/components/evaluations-provider.tsx

@@ -0,0 +1,49 @@
+import { type Evaluation } from '@/types/evaluations.types'
+import React, { createContext, useContext, useState } from 'react'
+
+type EvaluationsDialogType = 'retry' | 'view'
+
+interface EvaluationsContextType {
+  open: EvaluationsDialogType | null
+  setOpen: (str: EvaluationsDialogType | null) => void
+  currentRow: Evaluation | null
+  setCurrentRow: React.Dispatch<React.SetStateAction<Evaluation | null>>
+}
+
+const EvaluationsContext = createContext<EvaluationsContextType | undefined>(
+  undefined
+)
+
+interface Props {
+  children: React.ReactNode
+}
+
+export function EvaluationsProvider({ children }: Props) {
+  const [open, setOpen] = useState<EvaluationsDialogType | null>(null)
+
+  const [currentRow, setCurrentRow] = useState<Evaluation | null>(null)
+
+  return (
+    <EvaluationsContext.Provider
+      value={{
+        open,
+        setOpen,
+        currentRow,
+        setCurrentRow,
+      }}
+    >
+      {children}
+    </EvaluationsContext.Provider>
+  )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useEvaluations() {
+  const context = useContext(EvaluationsContext)
+
+  if (!context) {
+    throw new Error('useEvaluations must be used within EvaluationsProvider')
+  }
+
+  return context
+}

+ 62 - 0
src/features/evaluations/components/evaluations-retry-dialog.tsx

@@ -0,0 +1,62 @@
+import { ConfirmDialog } from '@/components/confirm-dialog'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { useRetryEvaluations } from '@/hooks/evaluations/useRetryEvaluations'
+import { type Evaluation } from '@/types/evaluations.types'
+import { RefreshCcw } from 'lucide-react'
+import { toast } from 'sonner'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: Evaluation | null
+}
+
+export function EvaluationsRetryDialog({
+  open,
+  onOpenChange,
+  currentRow,
+}: Props) {
+  const { mutate, isPending } = useRetryEvaluations(
+    currentRow?.id as string | number
+  )
+
+  if (!currentRow) return null
+
+  const handleRetry = () => {
+    mutate(undefined, {
+      onSuccess: () => {
+        toast.success('عملیات ارزیابی مجدد با موفقیت انجام شد.')
+        onOpenChange(false)
+      },
+
+      onError: () => {
+        toast.error('خطا در عملیات ارزیابی مجدد!')
+      },
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      title={
+        <span>
+          <RefreshCcw className='me-1 inline-block' size={18} /> ارزیابی مجدد
+        </span>
+      }
+      desc={
+        <Alert variant='default'>
+          <AlertTitle>توجه!</AlertTitle>
+          <AlertDescription>
+            ارزیابی مجدد توسط ادمین باعث ایجاد درخواست هزینه‌بر می‌شود. این
+            عملیات را با اطمینان انجام دهید.
+          </AlertDescription>
+        </Alert>
+      }
+      confirmText='ارزیابی مجدد'
+      cancelBtnText='انصراف'
+      handleConfirm={handleRetry}
+      isLoading={isPending}
+    />
+  )
+}

+ 200 - 0
src/features/evaluations/components/evaluations-table.tsx

@@ -0,0 +1,200 @@
+import { useEffect, useState } from 'react'
+import {
+  type SortingState,
+  type VisibilityState,
+  flexRender,
+  getCoreRowModel,
+  getFacetedRowModel,
+  getFacetedUniqueValues,
+  useReactTable,
+} from '@tanstack/react-table'
+import { cn } from '@/lib/utils'
+import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
+import { DataTableBulkActions } from './data-table-bulk-actions'
+import { evaluationColumns as columns } from './evaluations-columns'
+import type { Pagination } from '@/types/common.types'
+import type { Evaluation } from '@/types/evaluations.types'
+
+type DataTableProps = {
+  data: Evaluation[]
+  paginationData?: Pagination
+  search: Record<string, unknown>
+  navigate: NavigateFn
+}
+
+export function EvaluationsTable({
+  data,
+  paginationData,
+  search,
+  navigate,
+}: DataTableProps) {
+  // Local UI-only states
+  const [rowSelection, setRowSelection] = useState({})
+  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
+    status: false,
+  })
+  const [sorting, setSorting] = useState<SortingState>([])
+
+  // Local state management for table (uncomment to use local-only state, not synced with URL)
+  // const [columnFilters, onColumnFiltersChange] = useState<ColumnFiltersState>([])
+  // const [pagination, onPaginationChange] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
+
+  // Synced with URL states (keys/defaults mirror users route search schema)
+  const {
+    columnFilters,
+    onColumnFiltersChange,
+    pagination,
+    onPaginationChange,
+    ensurePageInRange,
+  } = useTableUrlState({
+    search,
+    navigate,
+    pagination: { defaultPage: 1, defaultPageSize: 10 },
+    globalFilter: { enabled: false },
+    columnFilters: [
+      // username per-column text filter
+      { columnId: 'username', searchKey: 'username', type: 'string' },
+      { columnId: 'status', searchKey: 'status', type: 'array' },
+      { columnId: 'role', searchKey: 'role', type: 'array' },
+      {
+        columnId: 'status',
+        searchKey: 'status',
+        type: 'string',
+      },
+    ],
+  })
+
+  // eslint-disable-next-line react-hooks/incompatible-library
+  const table = useReactTable({
+    data,
+    columns,
+    state: {
+      sorting,
+      pagination,
+      rowSelection,
+      columnFilters,
+      columnVisibility,
+    },
+    pageCount: paginationData?.last_page ?? -1,
+    manualPagination: true,
+    manualFiltering: true,
+    manualSorting: true,
+    enableRowSelection: true,
+    onPaginationChange,
+    onColumnFiltersChange,
+    onRowSelectionChange: setRowSelection,
+    onSortingChange: setSorting,
+    onColumnVisibilityChange: setColumnVisibility,
+    getCoreRowModel: getCoreRowModel(),
+    getFacetedRowModel: getFacetedRowModel(),
+    getFacetedUniqueValues: getFacetedUniqueValues(),
+  })
+
+  useEffect(() => {
+    ensurePageInRange(table.getPageCount())
+  }, [table, ensurePageInRange])
+
+  return (
+    <div
+      className={cn(
+        'max-sm:has-[div[role="toolbar"]]:mb-16', // Add margin bottom to the table on mobile when the toolbar is visible
+        'flex flex-1 flex-col gap-4'
+      )}
+    >
+      <DataTableToolbar
+        table={table}
+        searchPlaceholder='فیلتر کاربران...'
+        searchKey='username'
+        filters={[
+          {
+            columnId: 'status',
+            title: 'وضعیت ارزیابی',
+            multiple: false,
+            options: [
+              { label: 'تکمیل شده', value: 'approved' },
+              { label: 'در حال بررسی', value: 'pending' },
+              { label: 'رد شده', value: 'reject' },
+            ],
+          },
+        ]}
+      />
+      <div className='overflow-hidden rounded-md border'>
+        <Table>
+          <TableHeader>
+            {table.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id} className='group/row'>
+                {headerGroup.headers.map((header) => {
+                  return (
+                    <TableHead
+                      key={header.id}
+                      colSpan={header.colSpan}
+                      className={cn(
+                        'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
+                        header.column.columnDef.meta?.className,
+                        header.column.columnDef.meta?.thClassName
+                      )}
+                    >
+                      {header.isPlaceholder
+                        ? null
+                        : flexRender(
+                            header.column.columnDef.header,
+                            header.getContext()
+                          )}
+                    </TableHead>
+                  )
+                })}
+              </TableRow>
+            ))}
+          </TableHeader>
+          <TableBody>
+            {table.getRowModel().rows?.length ? (
+              table.getRowModel().rows.map((row) => (
+                <TableRow
+                  key={row.id}
+                  data-state={row.getIsSelected() && 'selected'}
+                  className='group/row'
+                >
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell
+                      key={cell.id}
+                      className={cn(
+                        'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
+                        cell.column.columnDef.meta?.className,
+                        cell.column.columnDef.meta?.tdClassName
+                      )}
+                    >
+                      {flexRender(
+                        cell.column.columnDef.cell,
+                        cell.getContext()
+                      )}
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))
+            ) : (
+              <TableRow>
+                <TableCell
+                  colSpan={columns?.length}
+                  className='h-24 text-center'
+                >
+                  نتیجه ‌ای یافت نشد.
+                </TableCell>
+              </TableRow>
+            )}
+          </TableBody>
+        </Table>
+      </div>
+      <DataTablePagination table={table} className='mt-auto' />
+      <DataTableBulkActions table={table} />
+    </div>
+  )
+}

+ 62 - 0
src/features/evaluations/index.tsx

@@ -0,0 +1,62 @@
+import { getRouteApi } from '@tanstack/react-router'
+import { ConfigDrawer } from '@/components/config-drawer'
+import { Header } from '@/components/layout/header'
+import { ProfileDropdown } from '@/components/profile-dropdown'
+import { Search } from '@/components/search'
+import { ThemeSwitch } from '@/components/theme-switch'
+import { Main } from '@/components/layout/main'
+import { useEvaluations } from '@/hooks/evaluations/useEvaluations'
+import { EvaluationsTable } from './components/evaluations-table'
+import { EvaluationsProvider } from './components/evaluations-provider'
+import { EvaluationsDialogs } from './components/evaluations-dialogs'
+
+const route = getRouteApi('/_authenticated/evaluations/')
+
+export function Evaluations() {
+  const search = route.useSearch()
+  const navigate = route.useNavigate()
+
+  const { data, isLoading } = useEvaluations({
+    page: search.page ?? 1,
+    per_page: search.pageSize ?? 10,
+    status: search.status,
+  })
+  const evaluations = data?.data?.evaluations
+  const paginationInfo = data?.data?.pagination
+
+  return (
+    <EvaluationsProvider>
+      <Header fixed>
+        <Search className='me-auto' />
+        <ThemeSwitch />
+        <ConfigDrawer />
+        <ProfileDropdown />
+      </Header>
+
+      <Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
+        <div className='flex flex-wrap items-end justify-between gap-2'>
+          <div>
+            <h2 className='text-2xl font-bold tracking-tight'>
+              لیست ارزیابی ها
+            </h2>
+            <p className='text-muted-foreground'>
+              در اینجا ارزیابی ها و وضعیت آنها را مدیریت کنید.
+            </p>
+          </div>
+        </div>
+        {isLoading ? (
+          <div>در حال دریافت...</div>
+        ) : (
+          <EvaluationsTable
+            data={evaluations ?? []}
+            paginationData={paginationInfo}
+            search={search}
+            navigate={navigate}
+          />
+        )}
+      </Main>
+
+      <EvaluationsDialogs />
+    </EvaluationsProvider>
+  )
+}

+ 1 - 1
src/features/posts/components/posts-action-dialog.tsx

@@ -13,7 +13,7 @@ import { usePosts } from './posts-provider'
 import { usePost } from '@/hooks/posts/usePost'
 import { useChangeStatePost } from '@/hooks/posts/useChangeStatePost'
 
-import VideoPlayer from './video-player'
+import VideoPlayer from '@/components/video-player'
 import { useState } from 'react'
 import { type Post } from '@/types/posts.types'
 

+ 0 - 110
src/features/posts/components/video-player.tsx

@@ -1,110 +0,0 @@
-'use client'
-
-import { useVideoPlayer } from '@/hooks/posts/useVideoPlayer'
-import { Play, RefreshCw, Volume2, VolumeX } from 'lucide-react'
-import { useEffect, useRef } from 'react'
-
-type Props = {
-  src: string
-}
-
-export default function VideoPlayer({ src }: Props) {
-  const containerRef = useRef<HTMLDivElement>(null)
-
-  const {
-    videoRef,
-    isPlaying,
-    setIsPlaying,
-    isMuted,
-    progress,
-    isEnded,
-    togglePlay,
-    toggleMute,
-    seek,
-  } = useVideoPlayer()
-
-  useEffect(() => {
-    const container = containerRef.current
-    const video = videoRef.current
-
-    if (!container || !video) return
-
-    const observer = new IntersectionObserver(
-      ([entry]) => {
-        if (!entry?.isIntersecting) {
-          video.pause()
-          setIsPlaying(false)
-        }
-      },
-      { threshold: 0.6 }
-    )
-
-    observer.observe(container)
-
-    return () => observer.disconnect()
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [])
-
-  const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
-    const rect = e.currentTarget.getBoundingClientRect()
-    const percent = (e.clientX - rect.left) / rect.width
-    seek(percent)
-  }
-
-  return (
-    <div
-      className='relative mx-auto w-full overflow-hidden rounded-2xl bg-black'
-      ref={containerRef}
-    >
-      <video
-        ref={videoRef}
-        src={src}
-        className='aspect-3/4 w-full object-cover'
-        muted={isMuted}
-        playsInline
-        controls={false}
-        onClick={togglePlay}
-        onContextMenu={(e) => e.preventDefault()}
-      />
-
-      {/* Center Control */}
-      {(isEnded || !isPlaying) && (
-        <div
-          className={`absolute inset-0 flex items-center justify-center bg-black/30 transition-all duration-300`}
-          onClick={togglePlay}
-        >
-          {isEnded ? (
-            <button className='flex size-12 cursor-pointer items-center justify-center rounded-full bg-black/30 transition-transform duration-200 hover:scale-110'>
-              <RefreshCw size={24} className='text-white' />
-            </button>
-          ) : (
-            !isPlaying && (
-              <button className='flex size-12 cursor-pointer items-center justify-center rounded-full bg-black/30 transition-transform duration-200 hover:scale-110'>
-                <Play fill='white' strokeWidth={0} size={24} />
-              </button>
-            )
-          )}
-        </div>
-      )}
-
-      {/* Bottom Controls */}
-      <div className='absolute right-4 bottom-4 left-4 flex items-center justify-between'>
-        <button onClick={toggleMute} className='cursor-pointer text-white'>
-          {isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
-        </button>
-      </div>
-
-      {/* Progress */}
-      <div
-        dir='ltr'
-        className='absolute right-0 bottom-0 left-0 h-1.5 cursor-pointer bg-white/20'
-        onClick={handleSeek}
-      >
-        <div
-          className='h-full bg-white transition-all duration-150'
-          style={{ width: `${progress}%` }}
-        />
-      </div>
-    </div>
-  )
-}

+ 1 - 1
src/features/users/components/users-columns.tsx

@@ -11,7 +11,7 @@ import { LongText } from '@/components/long-text'
 import { type User } from '@/types/users.types'
 
 import { DataTableRowActions } from './data-table-row-actions'
-import { planDurations } from '@/constants/users/users-constants'
+import { planDurations } from '@/constants/common-constansts'
 
 export const usersColumns: ColumnDef<User>[] = [
   {

+ 12 - 0
src/hooks/evaluations/useEvaluation.tsx

@@ -0,0 +1,12 @@
+import { useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { type Evaluation } from '@/types/evaluations.types'
+import { evaluationsService } from '@/services/evaluations.service'
+
+export function useEvaluation(evaluationId: number | string) {
+  return useQuery<Evaluation>({
+    queryKey: queryKeys.evaluations.detail(evaluationId),
+    queryFn: () => evaluationsService.detail(evaluationId),
+    enabled: !!evaluationId,
+  })
+}

+ 15 - 0
src/hooks/evaluations/useEvaluations.tsx

@@ -0,0 +1,15 @@
+import { queryKeys } from '@/core/react-query/keys'
+import { evaluationsService } from '@/services/evaluations.service'
+import { type PaginationParams } from '@/types/common.types'
+import { type GetEvaluationsResponse } from '@/types/evaluations.types'
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+
+export function useEvaluations(
+  params: PaginationParams = { page: 1, per_page: 10 }
+) {
+  return useQuery<GetEvaluationsResponse>({
+    queryKey: queryKeys.evaluations.list(params),
+    queryFn: () => evaluationsService.list(params),
+    placeholderData: keepPreviousData,
+  })
+}

+ 17 - 0
src/hooks/evaluations/useRetryEvaluations.tsx

@@ -0,0 +1,17 @@
+import { queryKeys } from '@/core/react-query/keys'
+import { evaluationsService } from '@/services/evaluations.service'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+export function useRetryEvaluations(evaluationId: number | string) {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: () => evaluationsService.retry(evaluationId),
+
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.all,
+      })
+    },
+  })
+}

+ 28 - 0
src/hooks/evaluations/useStoreEvaluation.tsx

@@ -0,0 +1,28 @@
+import { queryKeys } from '@/core/react-query/keys'
+import { evaluationsService } from '@/services/evaluations.service'
+import { type CreateEvaluation } from '@/types/evaluations.types'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+type CreateEvaluationVariables = {
+  id: number
+  payload: CreateEvaluation
+}
+
+export function useStoreEvaluation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: CreateEvaluationVariables) =>
+      evaluationsService.store(id, payload),
+
+    onSuccess: (_, variables) => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.detail(variables.id),
+      })
+
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.lists(),
+      })
+    },
+  })
+}

+ 10 - 0
src/hooks/posts/useVideoPlayer.tsx → src/hooks/use-video-player.tsx

@@ -7,6 +7,7 @@ export function useVideoPlayer() {
   const [isMuted, setIsMuted] = useState(true)
   const [progress, setProgress] = useState(0)
   const [isEnded, setIsEnded] = useState(false)
+  const [aspectRatio, setAspectRatio] = useState<number | null>(null)
 
   const togglePlay = () => {
     const video = videoRef.current
@@ -41,6 +42,13 @@ export function useVideoPlayer() {
     video.currentTime = percent * video.duration
   }
 
+  const handleLoadedMetadata = () => {
+    const video = videoRef.current
+    if (!video) return
+
+    setAspectRatio(video.videoWidth / video.videoHeight)
+  }
+
   useEffect(() => {
     const video = videoRef.current
     if (!video) return
@@ -71,8 +79,10 @@ export function useVideoPlayer() {
     isMuted,
     progress,
     isEnded,
+    aspectRatio,
     togglePlay,
     toggleMute,
     seek,
+    handleLoadedMetadata,
   }
 }

+ 22 - 0
src/routeTree.gen.ts

@@ -23,6 +23,7 @@ import { Route as AuthenticatedTasksIndexRouteImport } from './routes/_authentic
 import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
 import { Route as AuthenticatedPostsIndexRouteImport } from './routes/_authenticated/posts/index'
 import { Route as AuthenticatedHelpCenterIndexRouteImport } from './routes/_authenticated/help-center/index'
+import { Route as AuthenticatedEvaluationsIndexRouteImport } from './routes/_authenticated/evaluations/index'
 import { Route as AuthenticatedChatsIndexRouteImport } from './routes/_authenticated/chats/index'
 import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications'
 import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display'
@@ -102,6 +103,12 @@ const AuthenticatedHelpCenterIndexRoute =
     path: '/help-center/',
     getParentRoute: () => AuthenticatedRouteRoute,
   } as any)
+const AuthenticatedEvaluationsIndexRoute =
+  AuthenticatedEvaluationsIndexRouteImport.update({
+    id: '/evaluations/',
+    path: '/evaluations/',
+    getParentRoute: () => AuthenticatedRouteRoute,
+  } as any)
 const AuthenticatedChatsIndexRoute = AuthenticatedChatsIndexRouteImport.update({
   id: '/chats/',
   path: '/chats/',
@@ -153,6 +160,7 @@ export interface FileRoutesByFullPath {
   '/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
   '/chats/': typeof AuthenticatedChatsIndexRoute
+  '/evaluations/': typeof AuthenticatedEvaluationsIndexRoute
   '/help-center/': typeof AuthenticatedHelpCenterIndexRoute
   '/posts/': typeof AuthenticatedPostsIndexRoute
   '/settings/': typeof AuthenticatedSettingsIndexRoute
@@ -173,6 +181,7 @@ export interface FileRoutesByTo {
   '/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
   '/chats': typeof AuthenticatedChatsIndexRoute
+  '/evaluations': typeof AuthenticatedEvaluationsIndexRoute
   '/help-center': typeof AuthenticatedHelpCenterIndexRoute
   '/posts': typeof AuthenticatedPostsIndexRoute
   '/settings': typeof AuthenticatedSettingsIndexRoute
@@ -196,6 +205,7 @@ export interface FileRoutesById {
   '/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
   '/_authenticated/chats/': typeof AuthenticatedChatsIndexRoute
+  '/_authenticated/evaluations/': typeof AuthenticatedEvaluationsIndexRoute
   '/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexRoute
   '/_authenticated/posts/': typeof AuthenticatedPostsIndexRoute
   '/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
@@ -219,6 +229,7 @@ export interface FileRouteTypes {
     | '/settings/display'
     | '/settings/notifications'
     | '/chats/'
+    | '/evaluations/'
     | '/help-center/'
     | '/posts/'
     | '/settings/'
@@ -239,6 +250,7 @@ export interface FileRouteTypes {
     | '/settings/display'
     | '/settings/notifications'
     | '/chats'
+    | '/evaluations'
     | '/help-center'
     | '/posts'
     | '/settings'
@@ -261,6 +273,7 @@ export interface FileRouteTypes {
     | '/_authenticated/settings/display'
     | '/_authenticated/settings/notifications'
     | '/_authenticated/chats/'
+    | '/_authenticated/evaluations/'
     | '/_authenticated/help-center/'
     | '/_authenticated/posts/'
     | '/_authenticated/settings/'
@@ -378,6 +391,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedHelpCenterIndexRouteImport
       parentRoute: typeof AuthenticatedRouteRoute
     }
+    '/_authenticated/evaluations/': {
+      id: '/_authenticated/evaluations/'
+      path: '/evaluations'
+      fullPath: '/evaluations/'
+      preLoaderRoute: typeof AuthenticatedEvaluationsIndexRouteImport
+      parentRoute: typeof AuthenticatedRouteRoute
+    }
     '/_authenticated/chats/': {
       id: '/_authenticated/chats/'
       path: '/chats'
@@ -451,6 +471,7 @@ interface AuthenticatedRouteRouteChildren {
   AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
   AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
   AuthenticatedChatsIndexRoute: typeof AuthenticatedChatsIndexRoute
+  AuthenticatedEvaluationsIndexRoute: typeof AuthenticatedEvaluationsIndexRoute
   AuthenticatedHelpCenterIndexRoute: typeof AuthenticatedHelpCenterIndexRoute
   AuthenticatedPostsIndexRoute: typeof AuthenticatedPostsIndexRoute
   AuthenticatedTasksIndexRoute: typeof AuthenticatedTasksIndexRoute
@@ -462,6 +483,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
   AuthenticatedIndexRoute: AuthenticatedIndexRoute,
   AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
   AuthenticatedChatsIndexRoute: AuthenticatedChatsIndexRoute,
+  AuthenticatedEvaluationsIndexRoute: AuthenticatedEvaluationsIndexRoute,
   AuthenticatedHelpCenterIndexRoute: AuthenticatedHelpCenterIndexRoute,
   AuthenticatedPostsIndexRoute: AuthenticatedPostsIndexRoute,
   AuthenticatedTasksIndexRoute: AuthenticatedTasksIndexRoute,

+ 17 - 0
src/routes/_authenticated/evaluations/index.tsx

@@ -0,0 +1,17 @@
+import z from 'zod'
+import { createFileRoute } from '@tanstack/react-router'
+import { Evaluations } from '@/features/evaluations'
+
+const evaluationsSearchSchema = z.object({
+  page: z.coerce.number().optional().catch(1),
+  pageSize: z.coerce.number().optional().catch(10),
+  status: z
+    .union([z.literal('approved'), z.literal('pending'), z.literal('reject')])
+    .optional()
+    .catch(undefined),
+})
+
+export const Route = createFileRoute('/_authenticated/evaluations/')({
+  validateSearch: evaluationsSearchSchema,
+  component: Evaluations,
+})

+ 53 - 0
src/services/evaluations.service.ts

@@ -0,0 +1,53 @@
+import http from '@/core/api/http'
+import type { ApiResponse, PaginationParams } from '@/types/common.types'
+import type {
+  CreateEvaluation,
+  GetEvaluationDetailResponse,
+  GetEvaluationItems,
+  GetEvaluationsResponse,
+} from '@/types/evaluations.types'
+
+const BASE_URL = '/admin/evaluations'
+const BASE_ITEMS__URL = '/admin/items'
+
+export const evaluationsService = {
+  list: async (params?: PaginationParams): Promise<GetEvaluationsResponse> => {
+    const searchParams = new URLSearchParams()
+
+    if (params?.page) searchParams.set('page', String(params.page))
+    if (params?.per_page) searchParams.set('per_page', String(params.per_page))
+    if (params?.status) searchParams.set('status', params.status)
+
+    const query = searchParams.toString()
+    const url = query ? `${BASE_URL}?${query}` : BASE_URL
+
+    return http.get<GetEvaluationsResponse>(url)
+  },
+
+  detail: async (evaluationId: number | string) => {
+    const res = await http.get<GetEvaluationDetailResponse>(
+      `${BASE_URL}/${evaluationId}`
+    )
+    return res?.data?.evaluation
+  },
+
+  store: async (evaluationId: number | string, payload: CreateEvaluation) => {
+    const res = await http.post<ApiResponse<void>>(
+      `${BASE_URL}/${evaluationId}/store`,
+      payload
+    )
+    return res?.data
+  },
+
+  items: async () => {
+    const res = await http.get<GetEvaluationItems>(BASE_ITEMS__URL)
+    return res?.items
+  },
+
+  retry: async (evaluationId: number | string) => {
+    const res = await http.post<ApiResponse<void>>(
+      `${BASE_URL}/${evaluationId}/retry`
+    )
+    return res?.data
+  },
+}

+ 1 - 0
src/types/common.types.ts

@@ -2,6 +2,7 @@ export interface PaginationParams {
   page?: number
   per_page?: number
   subscription_type?: string
+  status?: string
 }
 
 export interface Links {

+ 151 - 0
src/types/evaluations.types.ts

@@ -0,0 +1,151 @@
+import { type Pagination } from './common.types'
+
+export interface Evaluation {
+  id: number
+  score: null | string
+  pros_comment: null | string
+  cons_comment: null | string
+  suggestion: null | string
+  summary: null | string
+  status: 'approved' | 'pending' | 'reject' | null
+  media: Media[]
+  jalali_date: string
+  items: Item[]
+  created_at: string
+  updated_at: string
+  user: User
+}
+
+interface User {
+  id: number
+  is_me: boolean
+  first_name: string
+  last_name: string
+  full_name: string
+  phone: string
+  email: null
+  referral_code: string
+  referrals_count: number
+  referred_by_user_id: null
+  username: string
+  province_id: number
+  city_id: number
+  gender: number
+  birth_date: string
+  weight: number
+  height: number
+  foot_specialization: null
+  post_skill: string
+  skill_level: null
+  activity_history: null
+  team_name: null
+  favorite_iranian_team: null
+  favorite_foreign_team: null
+  shirt_number: null
+  bio: null
+  followers_count: number
+  following_count: number
+  post_count: number
+  subscription: Subscription
+  created_at: string
+  updated_at: string
+}
+
+interface Subscription {
+  id: number
+  plan: Plan
+  status: string
+  price_paid: string
+  discount_price: string
+  started_at: string
+  expires_at: string
+  cancelled_at: null
+  payment_reference: null
+}
+
+interface Plan {
+  id: number
+  name: string
+  price: number
+  discount_price: number
+  duration: number
+  type: string
+  type_label: string
+  status: string
+  description: string
+  features: Feature[]
+  created_at: string
+  updated_at: string
+}
+
+interface Feature {
+  title: string
+  type: string
+  is_special: boolean
+}
+
+interface Item {
+  id: number
+  score: number
+  item: Item2
+}
+
+interface Item2 {
+  id: number
+  name: string
+  type: string
+}
+
+interface Media {
+  id: number
+  name: string
+  path: string
+  url: string
+  hash: string
+  extension: string
+  mime_type: string
+  size: string
+  size_formatted: string
+  alt: null
+  type: string
+  collection: string
+  disk: string
+  meta_data: null
+  entity_slug: string
+  created_at: string
+  updated_at: string
+}
+
+export interface GetEvaluationsResponse {
+  success: boolean
+  message: string
+  data: {
+    evaluations: Evaluation[]
+    pagination: Pagination
+  }
+}
+
+export interface GetEvaluationDetailResponse {
+  success: boolean
+  message: string
+  data: {
+    evaluation: Evaluation
+  }
+}
+
+export interface GetEvaluationItems {
+  success: boolean
+  message: string
+  items: Item2[]
+}
+
+export interface CreateEvaluation {
+  pros_comment: string
+  cons_comment: string
+  suggestion: string
+  summary: string
+  items: {
+    item_id: number
+    score: number
+  }
+}