'use client'; import { useFormatter, useTranslations } from 'next-intl'; import { useCallback, useEffect, useState } from 'react'; import { isApiClientError } from '@sport/api-client'; import { PERMISSIONS, type AdminReview, type ReviewStatus } from '@sport/types'; import { Badge, Button, EmptyState, Input, Rating, SegmentedControl, Skeleton } from '@sport/ui'; import { useSession } from '@/features/auth/session-provider'; import { browserApi } from '@/lib/api'; import { formatDateTime } from '@/lib/format'; const STATUS_FILTERS = ['PENDING', 'APPROVED', 'REJECTED', 'ALL'] as const satisfies readonly ( ReviewStatus | 'ALL' )[]; type StatusFilter = (typeof STATUS_FILTERS)[number]; const STATUS_VARIANT = { PENDING: 'warning', APPROVED: 'success', REJECTED: 'neutral', } as const; export function ReviewsQueue() { const t = useTranslations('reviews'); const format = useFormatter(); const { can } = useSession(); const [reviews, setReviews] = useState(null); const [error, setError] = useState(null); const [query, setQuery] = useState(''); // Pending by default: this screen exists to empty a queue, not to browse. const [status, setStatus] = useState('PENDING'); const [busyId, setBusyId] = useState(null); const load = useCallback(async () => { try { const result = await browserApi.reviewsAdmin.list({ perPage: 50, q: query.trim() || undefined, status: status === 'ALL' ? undefined : status, }); setReviews([...result.items]); setError(null); } catch (caught) { setError(isApiClientError(caught) ? caught.message : t('loadFailed')); } }, [query, status, t]); useEffect(() => { let cancelled = false; const timer = setTimeout(() => { if (!cancelled) void load(); }, 250); return () => { cancelled = true; clearTimeout(timer); }; }, [load]); async function decide(id: string, decision: 'APPROVED' | 'REJECTED', note?: string) { setBusyId(id); setError(null); try { await browserApi.reviewsAdmin.moderate(id, { status: decision, note: note ?? null }); await load(); } catch (caught) { setError(isApiClientError(caught) ? caught.message : t('moderateFailed')); } finally { setBusyId(null); } } if (!can(PERMISSIONS.REVIEW_MODERATE)) { return

{t('noPermission')}

; } return (
setQuery(event.target.value)} placeholder={t('searchPlaceholder')} className="max-w-xs" aria-label={t('searchPlaceholder')} /> ({ value, label: t(`filter.${value}`) }))} />
{error ? (

{error}

) : null} {!reviews ? (
) : reviews.length === 0 ? ( ) : (
    {reviews.map((review) => ( ))}
)}
); } function ReviewCard({ review, busy, onDecide, t, format, }: { review: AdminReview; busy: boolean; onDecide: (id: string, decision: 'APPROVED' | 'REJECTED', note?: string) => void; t: ReturnType>; format: ReturnType; }) { const [rejecting, setRejecting] = useState(false); const [note, setNote] = useState(''); return (
  • {review.title || t('untitled')}

    {review.productName} · {review.variantTitle} ·{' '} #{review.orderNumber}

    {t(`status.${review.status}`)}
    {review.body ? (

    {review.body}

    ) : (

    {t('noBody')}

    )}

    {t('by', { name: review.authorName })} · {formatDateTime(review.createdAt, format)} {review.moderatedByName ? ` · ${t('moderatedBy', { name: review.moderatedByName })}` : null}

    {review.moderationNote ? (

    {t('note')}: {review.moderationNote}

    ) : null} {rejecting ? (
    setNote(event.target.value)} placeholder={t('notePlaceholder')} className="max-w-sm" aria-label={t('notePlaceholder')} />
    ) : (
    {/* Both actions stay available on an already-moderated review: a decision made in haste has to be reversible, and the aggregate is recomputed from scratch on every decision so reversing one is safe. */}
    )}
  • ); }