Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
273 lines
8.3 KiB
TypeScript
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
|