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