|
|
|
@ -4,6 +4,10 @@ import { useToast } from '../../hooks/use-toast'; |
|
|
|
|
import { isLoggedIn } from '../../lib/jwt'; |
|
|
|
|
import { Loader2, MessageCircle, ServerCrash } from 'lucide-react'; |
|
|
|
|
import { Rating } from '../Rating/Rating'; |
|
|
|
|
import { Spinner } from '../ReactIcons/Spinner.tsx'; |
|
|
|
|
import { getRelativeTimeString } from '../../lib/date.ts'; |
|
|
|
|
import { cn } from '../../lib/classname.ts'; |
|
|
|
|
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal.tsx'; |
|
|
|
|
|
|
|
|
|
export interface RoadmapRatingDocument { |
|
|
|
|
_id?: string; |
|
|
|
@ -23,12 +27,18 @@ type ListRoadmapRatingsResponse = (RoadmapRatingDocument & { |
|
|
|
|
|
|
|
|
|
type ListRoadmapRatingsProps = { |
|
|
|
|
roadmapSlug: string; |
|
|
|
|
ratings: RoadmapDocument['ratings']; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export function ListRoadmapRatings(props: ListRoadmapRatingsProps) { |
|
|
|
|
const { roadmapSlug } = props; |
|
|
|
|
const { roadmapSlug, ratings: ratingSummary } = props; |
|
|
|
|
|
|
|
|
|
const totalWhoRated = Object.keys(ratingSummary.breakdown || {}).reduce( |
|
|
|
|
(acc, key) => acc + ratingSummary.breakdown[key as any], |
|
|
|
|
0, |
|
|
|
|
); |
|
|
|
|
const averageRating = ratingSummary.average; |
|
|
|
|
|
|
|
|
|
const toast = useToast(); |
|
|
|
|
const [isLoading, setIsLoading] = useState(true); |
|
|
|
|
const [error, setError] = useState(''); |
|
|
|
|
const [ratings, setRatings] = useState<ListRoadmapRatingsResponse>([]); |
|
|
|
@ -59,7 +69,7 @@ export function ListRoadmapRatings(props: ListRoadmapRatingsProps) { |
|
|
|
|
|
|
|
|
|
if (error) { |
|
|
|
|
return ( |
|
|
|
|
<div className="flex flex-col items-center justify-center py-10"> |
|
|
|
|
<div className="flex flex-col items-center justify-center bg-white py-10"> |
|
|
|
|
<ServerCrash className="size-12 text-red-500" /> |
|
|
|
|
<p className="mt-3 text-lg text-red-500">{error}</p> |
|
|
|
|
</div> |
|
|
|
@ -67,41 +77,64 @@ export function ListRoadmapRatings(props: ListRoadmapRatingsProps) { |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div> |
|
|
|
|
<div className="relative min-h-[100px] rounded-lg bg-white p-2 overflow-hidden"> |
|
|
|
|
{isLoading && ( |
|
|
|
|
<div className="flex items-center justify-center"> |
|
|
|
|
<Loader2 className="h-6 w-6 animate-spin stroke-[3px]" /> |
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center"> |
|
|
|
|
<Spinner isDualRing={false} /> |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
{!isLoading && ratings.length > 0 && ( |
|
|
|
|
<div className="flex flex-col gap-2"> |
|
|
|
|
{ratings.map((rating) => { |
|
|
|
|
const userAvatar = rating?.avatar |
|
|
|
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${rating.avatar}` |
|
|
|
|
: '/images/default-avatar.png'; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div key={rating._id} className="rounded-md border p-2"> |
|
|
|
|
<div className="flex items-center gap-2"> |
|
|
|
|
<img |
|
|
|
|
src={userAvatar} |
|
|
|
|
alt={rating.name} |
|
|
|
|
className="h-6 w-6 rounded-full" |
|
|
|
|
/> |
|
|
|
|
<span className="text-lg font-medium">{rating.name}</span> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div className="mt-2.5"> |
|
|
|
|
<Rating rating={rating.rating} readOnly /> |
|
|
|
|
|
|
|
|
|
{rating.feedback && ( |
|
|
|
|
<p className="mt-2 text-gray-500">{rating.feedback}</p> |
|
|
|
|
)} |
|
|
|
|
<div> |
|
|
|
|
<div className='text-sm px-2 py-1.5 text-yellow-900 mb-2 rounded-lg bg-yellow-50 flex items-center gap-1 justify-center'> |
|
|
|
|
<span>Rated <span className="font-medium">{averageRating.toFixed(1)}</span></span> |
|
|
|
|
<Rating starSize={15} rating={averageRating} readOnly /> |
|
|
|
|
by <span className="font-medium">{totalWhoRated} user{totalWhoRated > 1 && 's'}</span> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div className="flex flex-col"> |
|
|
|
|
{ratings.map((rating) => { |
|
|
|
|
const userAvatar = rating?.avatar |
|
|
|
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${rating.avatar}` |
|
|
|
|
: '/images/default-avatar.png'; |
|
|
|
|
|
|
|
|
|
const isLastRating = |
|
|
|
|
ratings[ratings.length - 1]._id === rating._id; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div |
|
|
|
|
key={rating._id} |
|
|
|
|
className={cn('px-2 py-2.5', { |
|
|
|
|
'border-b': !isLastRating, |
|
|
|
|
})} |
|
|
|
|
> |
|
|
|
|
<div className="flex items-center justify-between"> |
|
|
|
|
<div className="flex items-center gap-1"> |
|
|
|
|
<img |
|
|
|
|
src={userAvatar} |
|
|
|
|
alt={rating.name} |
|
|
|
|
className="h-4 w-4 rounded-full" |
|
|
|
|
/> |
|
|
|
|
<span className="text-sm font-medium">{rating.name}</span> |
|
|
|
|
</div> |
|
|
|
|
<span className="text-xs text-gray-400"> |
|
|
|
|
{getRelativeTimeString(rating.createdAt)} |
|
|
|
|
</span> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div className="mt-2.5"> |
|
|
|
|
<Rating rating={rating.rating} readOnly /> |
|
|
|
|
|
|
|
|
|
{rating.feedback && ( |
|
|
|
|
<p className="mt-2 text-sm text-gray-500"> |
|
|
|
|
{rating.feedback} |
|
|
|
|
</p> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
); |
|
|
|
|
})} |
|
|
|
|
); |
|
|
|
|
})} |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|