Files
web_sport/apps/admin/src/features/reviews/reviews-queue.tsx
T
2026-08-13 23:20:23 +07:00

228 lines
7.0 KiB
TypeScript

'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<AdminReview[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
// Pending by default: this screen exists to empty a queue, not to browse.
const [status, setStatus] = useState<StatusFilter>('PENDING');
const [busyId, setBusyId] = useState<string | null>(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 <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
<SegmentedControl
label={t('searchPlaceholder')}
value={status}
onChange={setStatus}
options={STATUS_FILTERS.map((value) => ({ value, label: t(`filter.${value}`) }))}
/>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!reviews ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
) : reviews.length === 0 ? (
<EmptyState title={status === 'PENDING' ? t('emptyQueue') : t('empty')} />
) : (
<ul className="space-y-3">
{reviews.map((review) => (
<ReviewCard
key={review.id}
review={review}
busy={busyId === review.id}
onDecide={decide}
t={t}
format={format}
/>
))}
</ul>
)}
</div>
);
}
function ReviewCard({
review,
busy,
onDecide,
t,
format,
}: {
review: AdminReview;
busy: boolean;
onDecide: (id: string, decision: 'APPROVED' | 'REJECTED', note?: string) => void;
t: ReturnType<typeof useTranslations<'reviews'>>;
format: ReturnType<typeof useFormatter>;
}) {
const [rejecting, setRejecting] = useState(false);
const [note, setNote] = useState('');
return (
<li className="border-ink-200 space-y-3 border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Rating value={review.rating} size="sm" />
<span className="text-sm font-semibold">{review.title || t('untitled')}</span>
</div>
<p className="text-ink-500 text-xs">
{review.productName} · {review.variantTitle} ·{' '}
<span className="font-mono">#{review.orderNumber}</span>
</p>
</div>
<Badge variant={STATUS_VARIANT[review.status]}>{t(`status.${review.status}`)}</Badge>
</div>
{review.body ? (
<p className="text-ink-700 whitespace-pre-line text-sm">{review.body}</p>
) : (
<p className="text-ink-500 text-sm italic">{t('noBody')}</p>
)}
<p className="text-ink-500 text-xs">
{t('by', { name: review.authorName })} · {formatDateTime(review.createdAt, format)}
{review.moderatedByName ? ` · ${t('moderatedBy', { name: review.moderatedByName })}` : null}
</p>
{review.moderationNote ? (
<p className="border-ink-200 text-ink-500 border-l-2 pl-3 text-xs">
{t('note')}: {review.moderationNote}
</p>
) : null}
{rejecting ? (
<div className="flex flex-wrap items-center gap-2">
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={t('notePlaceholder')}
className="max-w-sm"
aria-label={t('notePlaceholder')}
/>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => onDecide(review.id, 'REJECTED', note.trim() || undefined)}
>
{t('confirmReject')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRejecting(false)}>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex gap-2">
{/*
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.
*/}
<Button
size="sm"
disabled={busy || review.status === 'APPROVED'}
onClick={() => onDecide(review.id, 'APPROVED')}
>
{t('approve')}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || review.status === 'REJECTED'}
onClick={() => setRejecting(true)}
>
{t('reject')}
</Button>
</div>
)}
</li>
);
}