Stage M7
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { Star } from 'lucide-react';
|
||||
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, Input, Skeleton, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
type StatusFilter = ReviewStatus | 'ALL';
|
||||
|
||||
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')}
|
||||
/>
|
||||
|
||||
<div className="border-ink-200 rounded-card flex overflow-hidden border">
|
||||
{(['PENDING', 'APPROVED', 'REJECTED', 'ALL'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={status === value}
|
||||
onClick={() => setStatus(value)}
|
||||
className={cn(
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
status === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
|
||||
)}
|
||||
>
|
||||
{t(`filter.${value}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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 ? (
|
||||
<p className="text-ink-500 border-ink-200 border border-dashed p-8 text-center text-sm">
|
||||
{status === 'PENDING' ? t('emptyQueue') : t('empty')}
|
||||
</p>
|
||||
) : (
|
||||
<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">
|
||||
<Stars rating={review.rating} />
|
||||
<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-400 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>
|
||||
);
|
||||
}
|
||||
|
||||
function Stars({ rating }: { rating: number }) {
|
||||
return (
|
||||
<span className="flex items-center gap-0.5" aria-label={`${rating}/5`}>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
aria-hidden
|
||||
className={cn('size-3.5', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user