admin/features/events/management/EventReviewsPanel.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

273 lines
8.3 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { coerceToString } from '@/helpers'
import { addToast } from '@/lib/toast'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import AdminState from '@/components/feedback/AdminState'
import ConsumerState from '@/components/feedback/ConsumerState'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import ReviewCard from '@/components/reviews/ReviewCard'
import StatusChip from '@/components/ui/StatusChip'
import { getReviewStatus } from '@/constants/status'
import { isWithinHostReplyWindow } from '@/lib/reviewWindow'
import {
EVENT_REVIEWS_PAGE_SIZE,
HOST_REPLY_REVIEW,
LIST_ADMIN_EVENT_REVIEWS_PAGE,
LIST_EVENT_REVIEWS_PAGE,
type EventReview,
} from '@/services/reviews'
import { texts } from '@/texts'
interface EventReviewsPanelProps {
eventId: string
/**
* Scheduled event end — host reply window is endsAt + 2 months.
* Required for host mode; unused when mode is admin (read-only).
*/
endsAt?: string
/** Host can reply; admin only views reviews + host replies. */
mode?: 'host' | 'admin'
}
const authorDisplayName = (review: EventReview) => {
const name = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ').trim()
return name || texts.events.guest
}
const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanelProps) => {
const isAdmin = mode === 'admin'
const [reviews, setReviews] = useState<EventReview[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [page, setPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [replyTarget, setReplyTarget] = useState<EventReview | null>(null)
const [replyBody, setReplyBody] = useState('')
const [isSaving, setIsSaving] = useState(false)
const replyWindowOpen = !isAdmin && !!endsAt && isWithinHostReplyWindow(endsAt)
const fetchPage = useCallback(
async (nextPage: number, options?: { errorMode: 'silent' }) => {
if (isAdmin) {
return LIST_ADMIN_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options)
}
return LIST_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options)
},
[eventId, isAdmin]
)
const load = useCallback(async () => {
setIsLoading(true)
const result = await fetchPage(1)
if (result.ok) {
setReviews(result.data.items)
setTotalItems(result.data.totalItemsCount)
setPage(1)
}
setIsLoading(false)
}, [fetchPage])
const loadMore = async () => {
if (isLoadingMore || reviews.length >= totalItems) return
setIsLoadingMore(true)
const nextPage = page + 1
const result = await fetchPage(nextPage, { errorMode: 'silent' })
if (result.ok) {
setReviews((current) => {
const seen = new Set(current.map((review) => review.id))
return [...current, ...result.data.items.filter((review) => !seen.has(review.id))]
})
setPage(nextPage)
}
setIsLoadingMore(false)
}
useEffect(() => {
void load()
}, [load])
const openReply = (review: EventReview) => {
if (!replyWindowOpen) {
addToast({ title: texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED, color: 'warning' })
return
}
setReplyTarget(review)
setReplyBody(review.hostReplyBody ?? '')
}
const saveReply = async () => {
if (!replyTarget) return
const trimmed = replyBody.trim()
if (!trimmed) {
addToast({ title: texts.reviews.replyBodyRequired, color: 'warning' })
return
}
setIsSaving(true)
const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: trimmed })
setIsSaving(false)
if (!result.ok) return
addToast({ title: texts.reviews.replySaved, color: 'success' })
setReplyTarget(null)
setReviews((current) => current.map((review) => (review.id === result.data.id ? result.data : review)))
}
const clearReply = async () => {
if (!replyTarget) return
setIsSaving(true)
const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: null })
setIsSaving(false)
if (!result.ok) return
addToast({ title: texts.reviews.replyDeleted, color: 'success' })
setReplyTarget(null)
setReviews((current) => current.map((review) => (review.id === result.data.id ? result.data : review)))
}
if (isLoading) {
return <p className={`mt-4 text-sm ${isAdmin ? 'text-text-muted' : 'text-secondary-20'}`}>{texts.reviews.loading}</p>
}
if (reviews.length === 0) {
return (
<div className="mt-4">
{isAdmin ? (
<AdminState
description={texts.reviews.hostEmptyDescription}
title={texts.reviews.hostEmptyTitle}
variant="empty"
/>
) : (
<ConsumerState
description={texts.reviews.hostEmptyDescription}
title={texts.reviews.hostEmptyTitle}
variant="empty"
/>
)}
</div>
)
}
return (
<div className="mt-4 space-y-3">
{!isAdmin && !replyWindowOpen ? (
<p className="text-sm font-semibold text-fourth-700">{texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED}</p>
) : null}
<ul className="space-y-3">
{reviews.map((review) => {
const statusPresentation = isAdmin ? getReviewStatus(review.status) : null
return (
<ReviewCard
key={review.id}
authorAvatarUrl={review.user?.avatarUrl}
authorName={authorDisplayName(review)}
authorUserId={review.user?.id}
body={review.body ?? (isAdmin ? texts.reviews.ratingOnly : null)}
className={isAdmin ? 'admin-surface space-y-2 p-4' : 'consumer-surface space-y-2 p-4'}
createdAt={review.createdAt}
footer={
replyWindowOpen ? (
<Button
className="mt-3 min-h-11"
size="sm"
variant="flat"
onClick={() => {
openReply(review)
}}
>
{review.hostReplyBody ? texts.reviews.editReply : texts.common.reply}
</Button>
) : statusPresentation ? (
<div className="mt-3">
<StatusChip
chipColor={statusPresentation.chipColor}
label={statusPresentation.label}
/>
</div>
) : null
}
hostReplyBody={review.hostReplyBody}
rating={review.rating}
tone={isAdmin ? 'organizer' : 'consumer'}
/>
)
})}
</ul>
{reviews.length < totalItems ? (
<div className="flex justify-center">
<Button
isLoading={isLoadingMore}
variant="flat"
onClick={() => void loadMore()}
>
{texts.reviews.loadMore}
</Button>
</div>
) : null}
{!isAdmin ? (
<ConsumerModal
acceptBtnDisabled={!replyBody.trim() || isSaving}
acceptBtnText={texts.reviews.saveReply}
footerChildren={
replyTarget?.hostReplyBody ? (
<Button
color="danger"
disabled={isSaving}
size="sm"
variant="flat"
onClick={() => void clearReply()}
>
{texts.reviews.deleteReply}
</Button>
) : null
}
isLoading={isSaving}
isOpen={!!replyTarget}
rejectBtnText={texts.common.close}
title={texts.reviews.replyModalTitle}
onAccept={() => void saveReply()}
onOpenChange={(open) => {
if (!open) setReplyTarget(null)
}}
onReject={() => {
setReplyTarget(null)
}}
>
<Input
generalType="textarea"
label={texts.reviews.replyBodyLabel}
name="hostReplyBody"
textAreaMinRows={3}
value={replyBody}
onValueChange={(next) => {
setReplyBody(coerceToString(next))
}}
/>
</ConsumerModal>
) : null}
</div>
)
}
export default EventReviewsPanel