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