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

111 lines
3.9 KiB
TypeScript

'use client'
import { useState } from 'react'
import Link from 'next/link'
import ConsumerState from '@/components/feedback/ConsumerState'
import ReviewCard from '@/components/reviews/ReviewCard'
import HeatIconFill from '@/components/icons/HeatIconFill'
import { SEO_ROUTES } from '@/constants/routes'
import Button from '@/components/formElements/Button'
import { LIST_ORGANIZER_REVIEWS_PAGE, ORGANIZER_REVIEWS_PAGE_SIZE, type OrganizerReview } from '@/services/reviews'
import { texts, format } from '@/texts'
interface OrganizerReviewsSectionProps {
organizerId: string
avgRating?: number | null
reviewsCount?: number
initialReviews?: OrganizerReview[]
}
const OrganizerReviewsSection = ({ organizerId, avgRating, reviewsCount = 0, initialReviews = [] }: OrganizerReviewsSectionProps) => {
const [reviews, setReviews] = useState<OrganizerReview[]>(initialReviews)
const [page, setPage] = useState(1)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const hasMore = reviews.length < reviewsCount
const loadMore = async () => {
if (!hasMore || isLoadingMore) return
setIsLoadingMore(true)
const nextPage = page + 1
const result = await LIST_ORGANIZER_REVIEWS_PAGE(organizerId, nextPage, ORGANIZER_REVIEWS_PAGE_SIZE, { 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)
}
return (
<section className="mt-10">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-xl font-extrabold">{texts.reviews.guestReviewsTitle}</h2>
{reviewsCount && reviewsCount > 0 && avgRating != null ? (
<div className="mt-1 flex items-center gap-2 text-sm font-medium text-secondary-30">
<HeatIconFill className="size-3.5 text-primary" />
<span>{avgRating.toLocaleString('fa-IR', { maximumFractionDigits: 1 })}</span>
<span className="text-xs text-tertiary-400">
{format(texts.events.reviewCountParen, { count: reviewsCount.toLocaleString('fa-IR') })}
</span>
</div>
) : (
<p className="mt-1 text-xs text-tertiary-400">{texts.reviews.organizerEmptyHint}</p>
)}
</div>
</div>
{reviews.length === 0 ? (
<ConsumerState
title={texts.reviews.organizerEmptyTitle}
variant="empty"
/>
) : (
<>
<ul className="space-y-3">
{reviews.map((review) => (
<ReviewCard
key={review.id}
authorAvatarUrl={review.user?.avatarUrl}
authorName={[review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ')}
authorUserId={review.userId}
body={review.body}
createdAt={review.createdAt}
headerExtra={
<Link
className="mt-2 inline-block text-xs font-bold text-primary hover:underline"
href={SEO_ROUTES.EVENT_LANDING(review.eventSlug)}
>
{review.eventTitle}
</Link>
}
hostReplyBody={review.hostReplyBody}
rating={review.rating}
tone="organizer"
/>
))}
</ul>
{hasMore ? (
<div className="mt-4 flex justify-center">
<Button
isLoading={isLoadingMore}
variant="flat"
onClick={() => void loadMore()}
>
{texts.reviews.loadMore}
</Button>
</div>
) : null}
</>
)}
</section>
)
}
export default OrganizerReviewsSection