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' | 'visibility'
| 'createdAt' | 'createdAt'
| 'topicCount' | 'topicCount'
| 'ratings'
>[]; >[];
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;

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

@ -1,15 +1,19 @@
import { useState } from 'react'; import { useState, type CSSProperties } from 'react';
import { Rating } from '../Rating/Rating'; import { Rating } from '../Rating/Rating';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal'; import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal';
import { Star } from 'lucide-react';
import { cn } from '../../lib/classname';
type CustomRoadmapRatingsProps = { type CustomRoadmapRatingsProps = {
roadmapSlug: string; roadmapSlug: string;
ratings: RoadmapDocument['ratings']; ratings: RoadmapDocument['ratings'];
canManage?: boolean;
unseenRatingCount: number;
}; };
export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) { export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
const { ratings, roadmapSlug } = props; const { ratings, roadmapSlug, canManage, unseenRatingCount } = props;
const average = ratings?.average || 0; const average = ratings?.average || 0;
const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false);
@ -18,22 +22,36 @@ export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
<> <>
{isDetailsOpen && ( {isDetailsOpen && (
<CustomRoadmapRatingsModal <CustomRoadmapRatingsModal
roadmapSlug={roadmapSlug} roadmapSlug={roadmapSlug}
onClose={() => { onClose={() => {
setIsDetailsOpen(false); setIsDetailsOpen(false);
}} }}
ratings={ratings} ratings={ratings}
canManage={canManage}
/> />
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Rating rating={average} readOnly /> <span className="hidden lg:block">
<Rating rating={average} readOnly />
</span>
<button <button
className="text-sm font-medium underline" className="relative flex items-center gap-2 text-sm font-medium underline"
onClick={() => { onClick={() => {
setIsDetailsOpen(true); 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> </button>
</div> </div>
</> </>

@ -4,21 +4,71 @@ import { Modal } from '../Modal';
import { Rating } from '../Rating/Rating'; import { Rating } from '../Rating/Rating';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { RateRoadmapForm } from './RateRoadmapForm'; import { RateRoadmapForm } from './RateRoadmapForm';
import { cn } from '../../lib/classname';
import { ListRoadmapRatings } from './ListRoadmapRatings';
type ActiveTab = 'ratings' | 'feedback';
type CustomRoadmapRatingsModalProps = { type CustomRoadmapRatingsModalProps = {
onClose: () => void; onClose: () => void;
roadmapSlug: string; roadmapSlug: string;
ratings: RoadmapDocument['ratings']; ratings: RoadmapDocument['ratings'];
canManage?: boolean;
}; };
export function CustomRoadmapRatingsModal( export function CustomRoadmapRatingsModal(
props: CustomRoadmapRatingsModalProps, 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 ( return (
<Modal onClose={onClose} bodyClassName="p-4"> <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> </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 { isLoggedIn } from '../../lib/jwt';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
type GetMyRoadmapRatingResponse = { type GetMyRoadmapRatingResponse = {
id?: string; id?: string;
@ -77,13 +78,12 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
return; return;
} }
toast.success('Rating successful'); window.location.reload();
setUserRatingId(response.id);
setIsLoading(false);
}; };
useEffect(() => { useEffect(() => {
if (!isLoggedIn() || !roadmapSlug) { if (!isLoggedIn() || !roadmapSlug) {
setIsLoading(false);
return; return;
} }
@ -209,6 +209,11 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
<button <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" 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={() => { onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsRatingRoadmap(true); setIsRatingRoadmap(true);
}} }}
disabled={isLoading} disabled={isLoading}

@ -28,6 +28,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
team, team,
visibility, visibility,
ratings, ratings,
unseenRatingCount,
} = useStore(currentRoadmap) || {}; } = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false); 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" 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]" /> <Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
<span className="hidden sm:inline-block">Edit Roadmap</span> <span className="hidden lg:inline-block">Edit Roadmap</span>
<span className="sm:hidden">Edit</span> <span className="lg:hidden">Edit</span>
</a> </a>
<button <button
onClick={() => setIsSharing(true)} onClick={() => setIsSharing(true)}
@ -180,13 +181,15 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
deleteResource().finally(() => null); deleteResource().finally(() => null);
}} }}
/> />
<CustomRoadmapRatings
roadmapSlug={roadmapSlug!}
ratings={ratings!}
/>
</> </>
)} )}
<CustomRoadmapRatings
roadmapSlug={roadmapSlug!}
ratings={ratings!}
canManage={$canManageCurrentRoadmap}
unseenRatingCount={unseenRatingCount || 0}
/>
</div> </div>
</div> </div>

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

Loading…
Cancel
Save