diff --git a/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx b/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx index a7b36cda0..d77a7dbbb 100644 --- a/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx +++ b/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx @@ -38,8 +38,6 @@ export interface RoadmapDocument { visibility: AllowedRoadmapVisibility; sharedFriendIds?: string[]; sharedTeamMemberIds?: string[]; - isDiscoverable?: boolean; - showcaseStatus?: AllowedShowcaseStatus; feedbacks?: { userId: string; email: string; @@ -51,6 +49,16 @@ export interface RoadmapDocument { }; nodes: any[]; edges: any[]; + + isDiscoverable?: boolean; + showcaseStatus?: AllowedShowcaseStatus; + ratings: { + average: number; + breakdown: { + [key: number]: number; + }; + }; + createdAt: Date; updatedAt: Date; } diff --git a/src/components/CustomRoadmap/CustomRoadmapRatings.tsx b/src/components/CustomRoadmap/CustomRoadmapRatings.tsx new file mode 100644 index 000000000..bddc476b5 --- /dev/null +++ b/src/components/CustomRoadmap/CustomRoadmapRatings.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { Rating } from '../Rating/Rating'; +import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; +import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal'; + +type CustomRoadmapRatingsProps = { + roadmapSlug: string; + ratings: RoadmapDocument['ratings']; +}; + +export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) { + const { ratings, roadmapSlug } = props; + const average = ratings?.average || 0; + + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + + return ( + <> + {isDetailsOpen && ( + { + setIsDetailsOpen(false); + }} + ratings={ratings} + /> + )} +
+ + +
+ + ); +} diff --git a/src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx b/src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx new file mode 100644 index 000000000..65f626836 --- /dev/null +++ b/src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx @@ -0,0 +1,24 @@ +import { useState, type CSSProperties } from 'react'; +import { formatCommaNumber } from '../../lib/number'; +import { Modal } from '../Modal'; +import { Rating } from '../Rating/Rating'; +import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; +import { RateRoadmapForm } from './RateRoadmapForm'; + +type CustomRoadmapRatingsModalProps = { + onClose: () => void; + roadmapSlug: string; + ratings: RoadmapDocument['ratings']; +}; + +export function CustomRoadmapRatingsModal( + props: CustomRoadmapRatingsModalProps, +) { + const { onClose, ratings, roadmapSlug } = props; + + return ( + + + + ); +} diff --git a/src/components/CustomRoadmap/RateRoadmapForm.tsx b/src/components/CustomRoadmap/RateRoadmapForm.tsx new file mode 100644 index 000000000..3c5916358 --- /dev/null +++ b/src/components/CustomRoadmap/RateRoadmapForm.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState, type CSSProperties } from 'react'; +import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; +import { formatCommaNumber } from '../../lib/number'; +import { Rating } from '../Rating/Rating'; +import { httpGet, httpPost } from '../../lib/http'; +import { useToast } from '../../hooks/use-toast'; +import { isLoggedIn } from '../../lib/jwt'; +import { Loader2 } from 'lucide-react'; +import { cn } from '../../lib/classname'; + +type GetMyRoadmapRatingResponse = { + id?: string; + rating: number; + feedback?: string; +}; + +type RateRoadmapFormProps = { + ratings: RoadmapDocument['ratings']; + roadmapSlug: string; +}; + +export function RateRoadmapForm(props: RateRoadmapFormProps) { + const { ratings, roadmapSlug } = props; + const { breakdown = {}, average: _average } = ratings || {}; + const average = _average || 0; + + const ratingsKeys = [5, 4, 3, 2, 1]; + const totalRatings = ratingsKeys.reduce( + (total, rating) => total + breakdown?.[rating] || 0, + 0, + ); + + const toast = useToast(); + const [isLoading, setIsLoading] = useState(true); + const [isRatingRoadmap, setIsRatingRoadmap] = useState(false); + const [userRatingId, setUserRatingId] = useState(); + const [userRating, setUserRating] = useState(0); + const [userFeedback, setUserFeedback] = useState(''); + + const loadMyRoadmapRating = async () => { + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-get-my-roadmap-rating/${roadmapSlug}`, + ); + + if (!response || error) { + toast.error(error?.message || 'Something went wrong'); + setIsLoading(false); + return; + } + + setUserRatingId(response?.id); + setUserRating(response?.rating); + setUserFeedback(response?.feedback || ''); + setIsLoading(false); + }; + + const submitMyRoadmapRating = async () => { + if (userRating <= 0) { + toast.error('At least give it a star'); + return; + } + + setIsLoading(true); + const path = userRatingId + ? 'v1-update-custom-roadmap-rating' + : 'v1-rate-custom-roadmap'; + const { response, error } = await httpPost<{ + id: string; + }>(`${import.meta.env.PUBLIC_API_URL}/${path}/${roadmapSlug}`, { + rating: userRating, + feedback: userFeedback, + }); + + if (!response || error) { + toast.error(error?.message || 'Something went wrong'); + setIsLoading(false); + return; + } + + toast.success('Rating successful'); + setUserRatingId(response.id); + setIsLoading(false); + }; + + useEffect(() => { + if (!isLoggedIn() || !roadmapSlug) { + return; + } + + loadMyRoadmapRating().then(); + }, [roadmapSlug]); + + return ( + <> +
+ + {average} out of 5 +
+ + + {formatCommaNumber(totalRatings)} ratings + + +
    + {ratingsKeys.map((rating) => { + const percentage = + totalRatings <= 0 + ? 0 + : ((breakdown?.[rating] || 0) / totalRatings) * 100; + + return ( +
  • + {rating} star +
    + + {percentage}% + +
  • + ); + })} +
+ +
+ +
+

Rate this roadmap

+

+ Share your thoughts with the roadmap creator. +

+ + {(isRatingRoadmap || userRatingId) && ( +
{ + e.preventDefault(); + submitMyRoadmapRating().then(); + }} + > + { + setUserRating(rating); + }} + starSize={32} + /> +
+ +