Refactor rating logic

feat/discover
Kamran Ahmed 5 months ago
parent 2c3e0bda07
commit 9a875f7381
  1. 42
      src/components/CustomRoadmap/CustomRoadmapRatings.tsx
  2. 4
      src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
  3. 236
      src/components/CustomRoadmap/RateRoadmapForm.tsx
  4. 7
      src/components/CustomRoadmap/RoadmapHeader.tsx
  5. 6
      src/components/Rating/Rating.tsx

@ -1,9 +1,8 @@
import { useState, type CSSProperties } from 'react'; import { useState } 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 { Star } from 'lucide-react';
import { cn } from '../../lib/classname';
type CustomRoadmapRatingsProps = { type CustomRoadmapRatingsProps = {
roadmapSlug: string; roadmapSlug: string;
@ -16,6 +15,11 @@ export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
const { ratings, roadmapSlug, canManage, unseenRatingCount } = props; const { ratings, roadmapSlug, canManage, unseenRatingCount } = props;
const average = ratings?.average || 0; const average = ratings?.average || 0;
const totalPeopleWhoRated = Object.keys(ratings?.breakdown || {}).reduce(
(acc, key) => acc + ratings?.breakdown[key as any],
0,
);
const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false);
return ( return (
@ -30,30 +34,46 @@ export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
canManage={canManage} canManage={canManage}
/> />
)} )}
<div className="flex items-center gap-2"> {average === 0 && (
<span className="hidden lg:block"> <button
<Rating rating={average} readOnly /> className="flex items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black"
</span> onClick={() => {
setIsDetailsOpen(true);
}}
>
<Star className="size-4 fill-yellow-400 text-yellow-400" />
<span className="hidden md:block">Rate this roadmap</span>
<span className="block md:hidden">Rate</span>
</button>
)}
{average > 0 && (
<button <button
className="relative flex items-center gap-2 text-sm font-medium underline" className="relative flex items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black"
onClick={() => { onClick={() => {
setIsDetailsOpen(true); setIsDetailsOpen(true);
}} }}
> >
{average.toFixed(1)}
<span className="hidden lg:block">
<Rating
starSize={16}
rating={average}
className={'pointer-events-none gap-px'}
readOnly
/>
</span>
<span className="lg:hidden"> <span className="lg:hidden">
<Star className="size-5 fill-yellow-400 text-yellow-400" /> <Star className="size-5 fill-yellow-400 text-yellow-400" />
</span> </span>
<span className="hidden lg:block">{average} out of 5</span> ({totalPeopleWhoRated})
<span className="lg:hidden">{average}/5</span>
{canManage && unseenRatingCount > 0 && ( {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"> <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} {unseenRatingCount}
</span> </span>
)} )}
</button> </button>
</div> )}
</> </>
); );
} }

@ -37,7 +37,7 @@ export function CustomRoadmapRatingsModal(
]; ];
return ( return (
<Modal onClose={onClose} bodyClassName="p-4"> <Modal onClose={onClose} bodyClassName="bg-transparent shadow-none">
{canManage && ( {canManage && (
<div className="-mx-4 mb-4 flex items-center gap-4 border-b px-4"> <div className="-mx-4 mb-4 flex items-center gap-4 border-b px-4">
{tabs.map((tab) => { {tabs.map((tab) => {
@ -64,7 +64,7 @@ export function CustomRoadmapRatingsModal(
)} )}
{activeTab === 'ratings' && ( {activeTab === 'ratings' && (
<RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} /> <RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} canManage={canManage} />
)} )}
{activeTab === 'feedback' && ( {activeTab === 'feedback' && (
<ListRoadmapRatings roadmapSlug={roadmapSlug} /> <ListRoadmapRatings roadmapSlug={roadmapSlug} />

@ -1,13 +1,14 @@
import { useEffect, useState, type CSSProperties } from 'react'; import { useEffect, useState } from 'react';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { formatCommaNumber } from '../../lib/number'; import { formatCommaNumber } from '../../lib/number';
import { Rating } from '../Rating/Rating'; import { Rating } from '../Rating/Rating';
import { httpGet, httpPost } from '../../lib/http'; import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import { Loader2 } from 'lucide-react'; import { Loader2, Star } from 'lucide-react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup'; import { showLoginPopup } from '../../lib/popup';
import { Spinner } from '../ReactIcons/Spinner.tsx';
type GetMyRoadmapRatingResponse = { type GetMyRoadmapRatingResponse = {
id?: string; id?: string;
@ -18,10 +19,11 @@ type GetMyRoadmapRatingResponse = {
type RateRoadmapFormProps = { type RateRoadmapFormProps = {
ratings: RoadmapDocument['ratings']; ratings: RoadmapDocument['ratings'];
roadmapSlug: string; roadmapSlug: string;
canManage?: boolean;
}; };
export function RateRoadmapForm(props: RateRoadmapFormProps) { export function RateRoadmapForm(props: RateRoadmapFormProps) {
const { ratings, roadmapSlug } = props; const { ratings, canManage = false, roadmapSlug } = props;
const { breakdown = {}, average: _average } = ratings || {}; const { breakdown = {}, average: _average } = ratings || {};
const average = _average || 0; const average = _average || 0;
@ -31,9 +33,14 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
0, 0,
); );
// if no rating then only show the ratings breakdown if the user can manage the roadmap
const showRatingsBreakdown = average > 0 || canManage;
const toast = useToast(); const toast = useToast();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRatingRoadmap, setIsRatingRoadmap] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isRatingRoadmap, setIsRatingRoadmap] = useState(!showRatingsBreakdown);
const [userRatingId, setUserRatingId] = useState<string | undefined>(); const [userRatingId, setUserRatingId] = useState<string | undefined>();
const [userRating, setUserRating] = useState(0); const [userRating, setUserRating] = useState(0);
const [userFeedback, setUserFeedback] = useState(''); const [userFeedback, setUserFeedback] = useState('');
@ -61,7 +68,7 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
return; return;
} }
setIsLoading(true); setIsSubmitting(true);
const path = userRatingId const path = userRatingId
? 'v1-update-custom-roadmap-rating' ? 'v1-update-custom-roadmap-rating'
: 'v1-rate-custom-roadmap'; : 'v1-rate-custom-roadmap';
@ -74,7 +81,7 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
if (!response || error) { if (!response || error) {
toast.error(error?.message || 'Something went wrong'); toast.error(error?.message || 'Something went wrong');
setIsLoading(false); setIsSubmitting(false);
return; return;
} }
@ -91,51 +98,109 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
}, [roadmapSlug]); }, [roadmapSlug]);
return ( return (
<> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> {showRatingsBreakdown && !isRatingRoadmap && (
<Rating rating={average} readOnly /> <>
<span className="font-medium">{average} out of 5</span> <ul className="mt-4 flex flex-col gap-1 rounded-lg bg-white p-5">
</div> {ratingsKeys.map((rating) => {
const percentage =
<span className="mt-2 inline-block text-gray-500"> totalRatings <= 0
{formatCommaNumber(totalRatings)} ratings ? 0
</span> : ((breakdown?.[rating] || 0) / totalRatings) * 100;
<ul className="mt-4 flex flex-col gap-2"> return (
{ratingsKeys.map((rating) => { <li
const percentage = key={`rating-${rating}`}
totalRatings <= 0 className="flex items-center gap-2 text-sm"
? 0 >
: ((breakdown?.[rating] || 0) / totalRatings) * 100; <span className="shrink-0">{rating} star</span>
<div className="relative h-8 w-full overflow-hidden rounded-md border">
return ( <div
<li key={`rating-${rating}`} className="flex items-center gap-2"> className="h-full bg-yellow-300"
<span className="shrink-0">{rating} star</span> style={{ width: `${percentage}%` }}
<div ></div>
className="relative h-6 w-full overflow-hidden rounded-md border after:absolute after:inset-0 after:w-[var(--rating-percentage)] after:bg-yellow-400 after:content-['']"
style={ {percentage > 0 && (
{ <span className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-xs text-black">
'--rating-percentage': `${percentage}%`, {formatCommaNumber(breakdown?.[rating] || 0)}
} as CSSProperties </span>
} )}
/> </div>
<span className="w-14 shrink-0 text-sm text-gray-500">
{percentage}% <span className="w-14 shrink-0 text-sm text-gray-500">
</span> {percentage}%
</li> </span>
); </li>
})} );
</ul> })}
</ul>
<hr className="my-5 bg-gray-300" /> </>
)}
<div>
<h3 className="font-semibold">Rate this roadmap</h3> {!canManage && !isRatingRoadmap && (
<p className="mt-1 text-sm"> <div className="relative min-h-[100px] rounded-lg bg-white p-4">
Share your thoughts with the roadmap creator. {isLoading && (
</p> <div className="absolute inset-0 flex items-center justify-center">
<Spinner isDualRing={false} className="h-5 w-5" />
{(isRatingRoadmap || userRatingId) && ( </div>
)}
{!isLoading && !isRatingRoadmap && !userRatingId && (
<>
<p className="mb-2 text-center text-sm font-medium">
Rate and share your thoughts with the roadmap creator.
</p>
<button
className="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}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Rate Roadmap'
)}
</button>
</>
)}
{!isLoading && !isRatingRoadmap && userRatingId && (
<div>
<h3 className="mb-2.5 flex items-center justify-between text-base font-semibold">
Your Feedback
<button
className="ml-2 text-sm font-medium text-blue-500 underline underline-offset-2"
onClick={() => {
setIsRatingRoadmap(true);
}}
>
Edit Rating
</button>
</h3>
<div className="flex items-center gap-2">
<Rating rating={userRating} starSize={19} readOnly /> (
{userRating})
</div>
{userFeedback && <p className="mt-2 text-sm">{userFeedback}</p>}
</div>
)}
</div>
)}
{!canManage && isRatingRoadmap && (
<div className="rounded-lg bg-white p-5">
<h3 className="font-semibold">Rate this roadmap</h3>
<p className="mt-1 text-sm">
Share your thoughts with the roadmap creator.
</p>
<form <form
className="mt-4" className="mt-4"
onSubmit={(e) => { onSubmit={(e) => {
@ -155,45 +220,37 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
htmlFor="rating-feedback" htmlFor="rating-feedback"
className="block text-sm font-medium" className="block text-sm font-medium"
> >
Feedback Feedback to Creator{' '}
<span className="font-normal text-gray-400">(Optional)</span>
</label> </label>
<textarea <textarea
id="rating-feedback" id="rating-feedback"
className="min-h-24 rounded-md border p-1 outline-none focus:border-gray-500" className="min-h-24 rounded-md border p-2 text-sm outline-none focus:border-gray-500"
placeholder="Share your thoughts with the roadmap creator"
value={userFeedback} value={userFeedback}
onChange={(e) => { onChange={(e) => {
setUserFeedback(e.target.value); setUserFeedback(e.target.value);
}} }}
/> />
<p className="text-right text-xs text-gray-700">
Feedback will be only visible to the creator.
</p>
</div> </div>
<div <div className={cn('mt-4 grid grid-cols-2 gap-1')}>
className={cn( <button
'mt-4 grid gap-1', className="h-10 w-full rounded-full border p-2.5 text-sm font-medium disabled:opacity-60"
userRatingId ? 'grid-cols-1' : 'grid-cols-2', onClick={() => {
)} setIsRatingRoadmap(false);
> }}
{!userRatingId && ( type="button"
<button disabled={isSubmitting}
className="h-10 w-full rounded-full border p-2.5 text-sm font-medium disabled:opacity-60" >
onClick={() => { Cancel
setIsRatingRoadmap(false); </button>
}}
type="button"
disabled={isLoading}
>
Cancel
</button>
)}
<button <button
className="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="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"
type="submit" type="submit"
disabled={isLoading} disabled={isSubmitting}
> >
{isLoading ? ( {isSubmitting ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
) : userRatingId ? ( ) : userRatingId ? (
'Update Rating' 'Update Rating'
@ -203,29 +260,8 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
</button> </button>
</div> </div>
</form> </form>
)} </div>
)}
{!isRatingRoadmap && !userRatingId && ( </div>
<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}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Rate Roadmap'
)}
</button>
)}
</div>
</>
); );
} }

@ -8,7 +8,7 @@ import { httpDelete, httpPut } from '../../lib/http';
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector'; import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { RoadmapActionButton } from './RoadmapActionButton'; import { RoadmapActionButton } from './RoadmapActionButton';
import {Lock, Pencil, PenSquare, Shapes} from 'lucide-react'; import { Lock, Pencil, PenSquare, Shapes } from 'lucide-react';
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx'; import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx'; import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx'; import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
@ -110,11 +110,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<div className="flex justify-between gap-2 sm:gap-0"> <div className="flex justify-between gap-2 sm:gap-0">
<div className="flex justify-stretch gap-1 sm:gap-2"> <div className="flex justify-stretch gap-1 sm:gap-2">
<a <a
href="/roadmaps" href="/discover"
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm" className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
aria-label="Back to All Roadmaps" aria-label="Back to All Roadmaps"
> >
&larr;<span className="hidden sm:inline">&nbsp;All Roadmaps</span> &larr;
<span className="hidden sm:inline">&nbsp;Community Roadmaps</span>
</a> </a>
<ShareRoadmapButton <ShareRoadmapButton

@ -14,19 +14,15 @@ export function Rating(props: RatingProps) {
const { const {
rating = 0, rating = 0,
starSize, starSize,
readOnly = false,
className, className,
onRatingChange, onRatingChange,
readOnly = false,
} = props; } = props;
const [stars, setStars] = useState(Number(rating.toFixed(2))); const [stars, setStars] = useState(Number(rating.toFixed(2)));
const starCount = Math.floor(stars); const starCount = Math.floor(stars);
const decimalWidthPercentage = Math.min((stars - starCount) * 100, 100); const decimalWidthPercentage = Math.min((stars - starCount) * 100, 100);
if (readOnly && starCount === 0) {
return <span className="text-xs text-gray-400">No ratings yet</span>;
}
return ( return (
<div className={cn('flex', className)}> <div className={cn('flex', className)}>
{[1, 2, 3, 4, 5].map((counter) => { {[1, 2, 3, 4, 5].map((counter) => {

Loading…
Cancel
Save