feat: implement rating

feat/discover
Arik Chakma 5 months ago
parent f801c5b608
commit 6c5ff30ddb
  1. 1
      src/api/roadmap.ts
  2. 3
      src/components/CustomRoadmap/CustomRoadmap.tsx
  3. 30
      src/components/CustomRoadmap/CustomRoadmapRatings.tsx
  4. 54
      src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
  5. 116
      src/components/CustomRoadmap/ListRoadmapRatings.tsx
  6. 11
      src/components/CustomRoadmap/RateRoadmapForm.tsx
  7. 17
      src/components/CustomRoadmap/RoadmapHeader.tsx
  8. 7
      src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx

@ -13,6 +13,7 @@ export type ListShowcaseRoadmapResponse = {
| 'visibility'
| 'createdAt'
| 'topicCount'
| 'ratings'
>[];
totalCount: number;
totalPages: number;

@ -18,7 +18,7 @@ export const allowedLinkTypes = [
'roadmap.sh',
'official',
'roadmap',
'feed'
'feed',
] as const;
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
@ -47,6 +47,7 @@ export type GetRoadmapResponse = RoadmapDocument & {
canManage: boolean;
creator?: CreatorType;
team?: CreatorType;
unseenRatingCount: number;
};
export function hideRoadmapLoader() {

@ -1,15 +1,19 @@
import { useState } from 'react';
import { useState, type CSSProperties } from 'react';
import { Rating } from '../Rating/Rating';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal';
import { Star } from 'lucide-react';
import { cn } from '../../lib/classname';
type CustomRoadmapRatingsProps = {
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
canManage?: boolean;
unseenRatingCount: number;
};
export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
const { ratings, roadmapSlug } = props;
const { ratings, roadmapSlug, canManage, unseenRatingCount } = props;
const average = ratings?.average || 0;
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
@ -18,22 +22,36 @@ export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
<>
{isDetailsOpen && (
<CustomRoadmapRatingsModal
roadmapSlug={roadmapSlug}
roadmapSlug={roadmapSlug}
onClose={() => {
setIsDetailsOpen(false);
}}
ratings={ratings}
canManage={canManage}
/>
)}
<div className="flex items-center gap-2">
<Rating rating={average} readOnly />
<span className="hidden lg:block">
<Rating rating={average} readOnly />
</span>
<button
className="text-sm font-medium underline"
className="relative flex items-center gap-2 text-sm font-medium underline"
onClick={() => {
setIsDetailsOpen(true);
}}
>
{average} out of 5
<span className="lg:hidden">
<Star className="size-5 fill-yellow-400 text-yellow-400" />
</span>
<span className="hidden lg:block">{average} out of 5</span>
<span className="lg:hidden">{average}/5</span>
{canManage && unseenRatingCount > 0 && (
<span className="absolute right-0 top-0 flex size-4 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium leading-none text-white">
{unseenRatingCount}
</span>
)}
</button>
</div>
</>

@ -4,21 +4,71 @@ import { Modal } from '../Modal';
import { Rating } from '../Rating/Rating';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { RateRoadmapForm } from './RateRoadmapForm';
import { cn } from '../../lib/classname';
import { ListRoadmapRatings } from './ListRoadmapRatings';
type ActiveTab = 'ratings' | 'feedback';
type CustomRoadmapRatingsModalProps = {
onClose: () => void;
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
canManage?: boolean;
};
export function CustomRoadmapRatingsModal(
props: CustomRoadmapRatingsModalProps,
) {
const { onClose, ratings, roadmapSlug } = props;
const { onClose, ratings, roadmapSlug, canManage = false } = props;
const [activeTab, setActiveTab] = useState<ActiveTab>('ratings');
const tabs: {
id: ActiveTab;
label: string;
}[] = [
{
id: 'ratings',
label: 'Ratings',
},
{
id: 'feedback',
label: 'Feedback',
},
];
return (
<Modal onClose={onClose} bodyClassName="p-4">
<RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} />
{canManage && (
<div className="-mx-4 mb-4 flex items-center gap-4 border-b px-4">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
}}
className={cn(
'py-2 text-sm',
isActive
? 'border-b-2 border-black font-medium'
: 'text-gray-500 hover:text-gray-700',
)}
>
{tab.label}
</button>
);
})}
</div>
)}
{activeTab === 'ratings' && (
<RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} />
)}
{activeTab === 'feedback' && (
<ListRoadmapRatings roadmapSlug={roadmapSlug} />
)}
</Modal>
);
}

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { Loader2, MessageCircle, ServerCrash } from 'lucide-react';
import { Rating } from '../Rating/Rating';
export interface RoadmapRatingDocument {
_id?: string;
roadmapId: string;
userId: string;
rating: number;
feedback?: string;
createdAt: Date;
updatedAt: Date;
}
type ListRoadmapRatingsResponse = (RoadmapRatingDocument & {
name: string;
avatar: string;
})[];
type ListRoadmapRatingsProps = {
roadmapSlug: string;
};
export function ListRoadmapRatings(props: ListRoadmapRatingsProps) {
const { roadmapSlug } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [ratings, setRatings] = useState<ListRoadmapRatingsResponse>([]);
const listRoadmapRatings = async () => {
const { response, error } = await httpGet<ListRoadmapRatingsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-roadmap-ratings/${roadmapSlug}`,
);
if (!response || error) {
setError(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setRatings(response);
setError('');
setIsLoading(false);
};
useEffect(() => {
if (!isLoggedIn()) {
return;
}
listRoadmapRatings().then();
}, []);
if (error) {
return (
<div className="flex flex-col items-center justify-center py-10">
<ServerCrash className="size-12 text-red-500" />
<p className="mt-3 text-lg text-red-500">{error}</p>
</div>
);
}
return (
<div>
{isLoading && (
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin stroke-[3px]" />
</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>
);
})}
</div>
)}
{!isLoading && ratings.length === 0 && (
<div className="flex flex-col items-center justify-center py-10">
<MessageCircle className="size-12 text-gray-600" />
<p className="mt-3 text-lg text-gray-600">No Feedbacks</p>
</div>
)}
</div>
);
}

@ -7,6 +7,7 @@ import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { Loader2 } from 'lucide-react';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
type GetMyRoadmapRatingResponse = {
id?: string;
@ -77,13 +78,12 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
return;
}
toast.success('Rating successful');
setUserRatingId(response.id);
setIsLoading(false);
window.location.reload();
};
useEffect(() => {
if (!isLoggedIn() || !roadmapSlug) {
setIsLoading(false);
return;
}
@ -209,6 +209,11 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
<button
className="mt-4 flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsRatingRoadmap(true);
}}
disabled={isLoading}

@ -28,6 +28,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
team,
visibility,
ratings,
unseenRatingCount,
} = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false);
@ -156,8 +157,8 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
<span className="hidden sm:inline-block">Edit Roadmap</span>
<span className="sm:hidden">Edit</span>
<span className="hidden lg:inline-block">Edit Roadmap</span>
<span className="lg:hidden">Edit</span>
</a>
<button
onClick={() => setIsSharing(true)}
@ -180,13 +181,15 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
deleteResource().finally(() => null);
}}
/>
<CustomRoadmapRatings
roadmapSlug={roadmapSlug!}
ratings={ratings!}
/>
</>
)}
<CustomRoadmapRatings
roadmapSlug={roadmapSlug!}
ratings={ratings!}
canManage={$canManageCurrentRoadmap}
unseenRatingCount={unseenRatingCount || 0}
/>
</div>
</div>

@ -3,6 +3,7 @@ import type { ListShowcaseRoadmapResponse } from '../../api/roadmap';
import { Pagination } from '../Pagination/Pagination';
import { SearchRoadmap } from './SearchRoadmap';
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps';
import { Rating } from '../Rating/Rating';
type DiscoverRoadmapsProps = {
searchParams: string;
@ -55,6 +56,12 @@ export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
}).format(roadmap.topicCount)}{' '}
topics
</span>
<Rating
rating={roadmap?.ratings?.average || 0}
readOnly={true}
starSize={16}
/>
</div>
</a>
</li>

Loading…
Cancel
Save