import { useCallback, useMemo, useState } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { ButtonPrimary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { HorizontalEditorTabs } from '../components/shared/HorizontalEditorTabs'; import { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import { useAdminRouting } from '../lib/adminRouting'; import { formatPostDate } from '../lib/formatPostDate'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type ReviewRow = { id: number; user_id: number; course_id: number; rating: number; review_text: string; is_approved: boolean; author_name: string; author_email: string; course_title: string; created_at: string; created_at_label: string; edit_url: string; view_url: string; reported_count?: number; last_reported_at?: string; reply_text?: string; reply_user_id?: number; reply_created_at?: string; }; type ListResponse = { success: boolean; data: { items: ReviewRow[]; total: number; page: number; per_page: number; counts: { pending: number; approved: number; total: number }; }; }; type StatusFilter = 'pending' | 'approved' | 'all'; const STATUS_TABS = [ { id: 'pending', label: 'Pending' }, { id: 'approved', label: 'Approved' }, { id: 'all', label: 'All' }, ] as const; function StarDisplay({ value }: { value: number }) { const v = Math.max(0, Math.min(5, Math.round(value))); return ( {[1, 2, 3, 4, 5].map((n) => ( {n <= v ? '★' : '☆'} ))} ); } export function ReviewsPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const { confirm } = useSikshyaDialog(); const { navigateView } = useAdminRouting(); const featureOk = isFeatureEnabled(config, 'course_reviews'); const addon = useAddonEnabled('course_reviews'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const gateOpen = mode === 'full'; const [status, setStatus] = useState('pending'); const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); const [page, setPage] = useState(1); const [rowBusyId, setRowBusyId] = useState(null); const loader = useCallback(async () => { if (!gateOpen) { return { success: true, data: { items: [], total: 0, page: 1, per_page: 20, counts: { pending: 0, approved: 0, total: 0 } } } as ListResponse; } return getSikshyaApi().get( SIKSHYA_ENDPOINTS.admin.reviews({ status: status === 'all' ? undefined : status, search: search || undefined, page, per_page: 20, }) ); }, [gateOpen, status, search, page]); const { loading, data, error, refetch } = useAsyncData(loader, [gateOpen, status, search, page]); const rows = data?.data?.items ?? []; const counts = data?.data?.counts ?? { pending: 0, approved: 0, total: 0 }; const totalPages = useMemo(() => { if (!data?.data) return 1; return Math.max(1, Math.ceil(data.data.total / Math.max(1, data.data.per_page))); }, [data]); const tabsWithBadges: { id: string; label: string }[] = STATUS_TABS.map((t) => ({ id: t.id, label: t.id === 'pending' ? `Pending (${counts.pending})` : t.id === 'approved' ? `Approved (${counts.approved})` : `All (${counts.total})`, })); const approve = useCallback( async (id: number) => { setRowBusyId(id); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.reviewApprove(id), {}); refetch(); } finally { setRowBusyId(null); } }, [refetch] ); const reject = useCallback( async (id: number) => { setRowBusyId(id); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.reviewReject(id), {}); refetch(); } finally { setRowBusyId(null); } }, [refetch] ); const remove = useCallback( async (id: number) => { const ok = await confirm({ title: __('Delete review?', 'sikshya'), message: __('This review will be permanently removed and the course rating recalculated.', 'sikshya'), variant: 'danger', confirmLabel: __('Delete', 'sikshya'), }); if (!ok) return; setRowBusyId(id); try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.admin.reviewDelete(id)); refetch(); } finally { setRowBusyId(null); } }, [confirm, refetch] ); const onSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); setPage(1); setSearch(searchInput.trim()); }; const actionsForRow = useCallback( (r: ReviewRow): RowActionItem[] => { const busy = rowBusyId === r.id; const items: RowActionItem[] = [ { key: 'detail', label: 'View details', disabled: busy, onClick: () => navigateView('review', { id: String(r.id) }), }, ]; if (r.view_url) { items.push({ key: 'course', label: 'Open course page', href: r.view_url, external: true, }); } if (r.is_approved) { items.push({ key: 'unpublish', label: 'Unpublish', disabled: busy, onClick: () => void reject(r.id), }); } else { items.push({ key: 'approve', label: 'Approve', disabled: busy, onClick: () => void approve(r.id), }); } items.push({ key: 'delete', label: 'Delete', danger: true, disabled: busy, onClick: () => void remove(r.id), }); return items; }, [navigateView, approve, reject, remove, rowBusyId] ); return ( refetch()}> Refresh ) : null } > addon.enable()} addonError={addon.error} > {error ? (
refetch()} />
) : null}
{ setStatus(id as StatusFilter); setPage(1); }} ariaLabel={__('Review status', 'sikshya')} />
setSearchInput(e.target.value)} placeholder={__('Search text, student, course…', 'sikshya')} className="w-64 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-500" /> {__('Search', 'sikshya')} {search ? ( ) : null}
{loading ? (
{__('Loading…', 'sikshya')}
) : rows.length === 0 ? ( ) : (
{rows.map((r) => ( ))}
{__('Submitted', 'sikshya')} {__('Student', 'sikshya')} {__('Course', 'sikshya')} {__('Rating', 'sikshya')} {__('Review', 'sikshya')} {__('Status', 'sikshya')} {__('Actions', 'sikshya')}
{formatPostDate(r.created_at)}
{r.created_at_label}
{r.author_name || `User #${r.user_id}`}
{r.author_email ? (
{r.author_email}
) : null}
{r.view_url ? ( {r.course_title || `#${r.course_id}`} ) : ( {r.course_title || `#${r.course_id}`} )} {r.rating > 0 ? : } {r.review_text ? (

{r.review_text.length > 260 ? `${r.review_text.slice(0, 260)}…` : r.review_text}

) : ( (rating only) )}
{Number(r.reported_count ?? 0) > 0 ? ( Reports: {Number(r.reported_count).toLocaleString()} ) : null} {r.reply_text ? ( {__('Has official reply', 'sikshya')} ) : null}
)}
{totalPages > 1 ? (
Page {page} of {totalPages}
) : null}
); }