admin/components/reviews/EventReviewsSection.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

338 lines
12 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import Button from '@/components/formElements/Button'
import ConsumerInput from '@/components/consumer/ConsumerInput'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import AddDocumentIcon from '@/components/icons/AddDocumentIcon'
import HeatIconFill from '@/components/icons/HeatIconFill'
import ReviewCard from '@/components/reviews/ReviewCard'
import StarRating from '@/components/reviews/StarRating'
import { coerceToString } from '@/helpers'
import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics'
import { isWithinHostReplyWindow, isWithinReviewWindow } from '@/lib/reviewWindow'
import { addToast } from '@/lib/toast'
import useAuth from '@/hooks/useAuth'
import { useEventReviewsInfiniteQuery } from '@/queries/consumer/useEventDetailQueries'
import { useEventViewerStateQuery } from '@/queries/consumer/useEventViewerStateQuery'
import { consumerKeys } from '@/queries/consumerKeys'
import { CREATE_REVIEW, HOST_REPLY_REVIEW, type EventReview } from '@/services/reviews'
import { texts, format } from '@/texts'
interface EventReviewsSectionProps {
eventId: string
eventStatus: string
/** Scheduled event end — review window is endsAt + 1 month; host reply + 2 months. */
endsAt: string
avgRating?: number | null
reviewsCount?: number
initialReviews?: EventReview[]
reviewsTotal?: number
/** Event organizer — used to enable host reply controls. */
organizerId?: string
}
const authorDisplayName = (review: EventReview) => {
const name = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ').trim()
return name || texts.events.guest
}
const EventReviewsSection = ({
eventId,
eventStatus,
endsAt,
avgRating: initialAvg,
reviewsCount: initialCount,
initialReviews,
reviewsTotal: initialTotal,
organizerId,
}: EventReviewsSectionProps) => {
const { user } = useAuth()
const queryClient = useQueryClient()
const viewerStateQuery = useEventViewerStateQuery(eventId)
const [draftBody, setDraftBody] = useState('')
const [draftRating, setDraftRating] = useState(0)
const [isSaving, setIsSaving] = useState(false)
const [replyTarget, setReplyTarget] = useState<EventReview | null>(null)
const [replyBody, setReplyBody] = useState('')
const [isSavingReply, setIsSavingReply] = useState(false)
const isHost = Boolean(organizerId && user?.userId && user.userId === organizerId)
const reviewsSeed =
initialReviews !== undefined
? { items: initialReviews, totalItemsCount: initialTotal ?? initialCount ?? initialReviews.length }
: undefined
const reviewsQuery = useEventReviewsInfiniteQuery(eventId, reviewsSeed)
const reviews = useMemo(() => {
const uniqueReviews = new Map<string, EventReview>()
for (const page of reviewsQuery.data?.pages ?? []) {
for (const review of page.items) uniqueReviews.set(review.id, review)
}
return [...uniqueReviews.values()]
}, [reviewsQuery.data?.pages])
const reviewsTotal = reviewsQuery.data?.pages.at(-1)?.totalItemsCount ?? initialTotal ?? initialCount ?? 0
const isLoading = reviewsQuery.isPending && reviewsQuery.fetchStatus === 'fetching'
const isLoadingMore = reviewsQuery.isFetchingNextPage
const bookingId =
eventStatus === 'completed' && viewerStateQuery.data?.activeBooking?.status === 'confirmed'
? viewerStateQuery.data.activeBooking.id
: null
const myReview = reviews.find((review) => review.userId === user?.userId) ?? null
const windowOpen = isWithinReviewWindow(endsAt)
const canReview = eventStatus === 'completed' && !!bookingId && !myReview && !!user?.userId && windowOpen
const showReviewWindowExpired = eventStatus === 'completed' && !!bookingId && !myReview && !!user?.userId && !windowOpen
const replyWindowOpen = isWithinHostReplyWindow(endsAt)
const canHostReply = isHost && replyWindowOpen
const avgRating =
initialAvg ?? (reviews.length > 0 ? Math.round((reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length) * 10) / 10 : null)
const reviewsCount = reviewsTotal
const reloadReviews = () => {
void queryClient.invalidateQueries({ queryKey: consumerKeys.eventReviewsRoot(eventId) })
void queryClient.invalidateQueries({ queryKey: consumerKeys.eventDetailRoot(eventId) })
}
const handleSubmitReview = async () => {
if (!bookingId) return
if (draftRating < 1 || draftRating > 5) {
addToast({ title: texts.reviews.selectRating, color: 'warning' })
return
}
setIsSaving(true)
const trimmed = draftBody.trim()
const result = await CREATE_REVIEW(eventId, {
bookingId,
rating: draftRating,
body: trimmed.length > 0 ? trimmed : null,
})
setIsSaving(false)
if (!result.ok) return
trackAnalyticsEventOnce(ANALYTICS_EVENTS.REVIEW_SUBMITTED, result.data.id, {
review_id: result.data.id,
event_id: eventId,
booking_id: bookingId,
rating: draftRating,
has_comment: trimmed.length > 0,
})
addToast({ title: texts.reviews.submitted, color: 'success' })
setDraftBody('')
setDraftRating(0)
reloadReviews()
}
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
}
setIsSavingReply(true)
const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: trimmed })
setIsSavingReply(false)
if (!result.ok) return
addToast({ title: texts.reviews.replySaved, color: 'success' })
setReplyTarget(null)
reloadReviews()
}
const clearReply = async () => {
if (!replyTarget) return
setIsSavingReply(true)
const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: null })
setIsSavingReply(false)
if (!result.ok) return
addToast({ title: texts.reviews.replyDeleted, color: 'success' })
setReplyTarget(null)
reloadReviews()
}
return (
<section className="rounded-[22px] bg-consumer-surface p-[10px] shadow-sm">
<div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-medium text-secondary-30">{texts.reviews.peopleSayTitle}</h3>
{reviewsCount > 0 && avgRating != null ? (
<div className="flex shrink-0 items-center gap-1 text-sm font-medium text-secondary-30">
<HeatIconFill className="size-3.5 text-primary" />
<span>
{format(texts.reviews.avgFromCount, {
rating: avgRating.toLocaleString('fa-IR', { maximumFractionDigits: 1 }),
count: reviewsCount.toLocaleString('fa-IR'),
})}
</span>
</div>
) : null}
</div>
{showReviewWindowExpired ? (
<p className="mt-2 text-xs font-medium text-fourth-700">{texts.errors.codes.REVIEW_WINDOW_EXPIRED}</p>
) : null}
{isLoading ? <p className="mt-4 text-sm font-medium text-secondary-30">{texts.reviews.loading}</p> : null}
{!isLoading && reviews.length === 0 ? (
<div className="mt-4 space-y-1">
<p className="text-sm font-medium text-secondary-30">{texts.reviews.eventEmptyTitle}</p>
<p className="text-sm font-medium text-secondary-30">{texts.reviews.eventEmptyHint}</p>
</div>
) : null}
{!isLoading && reviews.length > 0 ? (
<ul className="mt-4">
{reviews.map((review, index) => (
<ReviewCard
key={review.id}
authorAvatarUrl={review.user?.avatarUrl}
authorName={authorDisplayName(review)}
authorUserId={review.userId}
body={review.body}
className={index === 0 ? 'pb-4' : 'border-t border-secondary-45 py-4'}
createdAt={review.createdAt}
footer={
canHostReply ? (
<Button
className="mt-3 min-h-11"
size="sm"
variant="flat"
onClick={() => {
openReply(review)
}}
>
{review.hostReplyBody ? texts.reviews.editReply : texts.common.reply}
</Button>
) : null
}
hostReplyBody={review.hostReplyBody}
rating={review.rating}
tone="consumer"
/>
))}
</ul>
) : null}
{!isLoading && reviewsQuery.hasNextPage ? (
<Button
className="mt-3 w-full min-h-11"
isLoading={isLoadingMore}
variant="flat"
onClick={() => void reviewsQuery.fetchNextPage()}
>
{texts.reviews.loadMoreAlt}
</Button>
) : null}
{canReview ? (
<div className="mt-4 space-y-3">
<div className="rounded-[16px] bg-tertiary-900/5 p-3">
<p className="mb-2 text-sm font-medium text-tertiary-900">{texts.reviews.shareExperienceTitle}</p>
<ConsumerInput
generalType="textarea"
name="reviewDraftBody"
placeholder={texts.reviews.shareExperiencePlaceholder}
textAreaMinRows={3}
value={draftBody}
onValueChange={(next) => {
setDraftBody(coerceToString(next))
}}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Button
className="min-h-11 gap-2 bg-tertiary-900/10 px-4 text-sm font-medium text-tertiary-900 data-[hover=true]:bg-tertiary-900/15"
color="secondary-dark"
isLoading={isSaving}
variant="flat"
onClick={() => void handleSubmitReview()}
>
<AddDocumentIcon className="size-4 text-tertiary-900" />
{texts.reviews.submitReview}
</Button>
<StarRating
size="md"
value={draftRating}
onChange={setDraftRating}
/>
</div>
</div>
) : null}
{isHost && !replyWindowOpen && reviews.length > 0 ? (
<p className="mt-3 text-xs font-medium text-fourth-700">{texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED}</p>
) : null}
<ConsumerModal
acceptBtnDisabled={!replyBody.trim() || isSavingReply}
acceptBtnText={texts.reviews.saveReply}
footerChildren={
replyTarget?.hostReplyBody ? (
<Button
color="danger"
disabled={isSavingReply}
size="sm"
variant="flat"
onClick={() => void clearReply()}
>
{texts.reviews.deleteReply}
</Button>
) : null
}
isLoading={isSavingReply}
isOpen={!!replyTarget}
rejectBtnText={texts.common.close}
title={texts.reviews.replyModalTitle}
onAccept={() => void saveReply()}
onOpenChange={(open) => {
if (!open) setReplyTarget(null)
}}
onReject={() => {
setReplyTarget(null)
}}
>
<ConsumerInput
generalType="textarea"
label={texts.reviews.replyBodyLabel}
name="hostReplyBody"
textAreaMinRows={3}
value={replyBody}
onValueChange={(next) => {
setReplyBody(coerceToString(next))
}}
/>
</ConsumerModal>
</section>
)
}
export default EventReviewsSection