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. 164
      src/components/CustomRoadmap/RateRoadmapForm.tsx
  4. 5
      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 type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal';
import { Star } from 'lucide-react';
import { cn } from '../../lib/classname';
type CustomRoadmapRatingsProps = {
roadmapSlug: string;
@ -16,6 +15,11 @@ export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
const { ratings, roadmapSlug, canManage, unseenRatingCount } = props;
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);
return (
@ -30,30 +34,46 @@ export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
canManage={canManage}
/>
)}
<div className="flex items-center gap-2">
<span className="hidden lg:block">
<Rating rating={average} readOnly />
</span>
{average === 0 && (
<button
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"
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
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={() => {
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">
<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>
({totalPeopleWhoRated})
{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>
)}
</>
);
}

@ -37,7 +37,7 @@ export function CustomRoadmapRatingsModal(
];
return (
<Modal onClose={onClose} bodyClassName="p-4">
<Modal onClose={onClose} bodyClassName="bg-transparent shadow-none">
{canManage && (
<div className="-mx-4 mb-4 flex items-center gap-4 border-b px-4">
{tabs.map((tab) => {
@ -64,7 +64,7 @@ export function CustomRoadmapRatingsModal(
)}
{activeTab === 'ratings' && (
<RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} />
<RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} canManage={canManage} />
)}
{activeTab === 'feedback' && (
<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 { 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 { Loader2, Star } from 'lucide-react';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
import { Spinner } from '../ReactIcons/Spinner.tsx';
type GetMyRoadmapRatingResponse = {
id?: string;
@ -18,10 +19,11 @@ type GetMyRoadmapRatingResponse = {
type RateRoadmapFormProps = {
ratings: RoadmapDocument['ratings'];
roadmapSlug: string;
canManage?: boolean;
};
export function RateRoadmapForm(props: RateRoadmapFormProps) {
const { ratings, roadmapSlug } = props;
const { ratings, canManage = false, roadmapSlug } = props;
const { breakdown = {}, average: _average } = ratings || {};
const average = _average || 0;
@ -31,9 +33,14 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
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 [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 [userRating, setUserRating] = useState(0);
const [userFeedback, setUserFeedback] = useState('');
@ -61,7 +68,7 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
return;
}
setIsLoading(true);
setIsSubmitting(true);
const path = userRatingId
? 'v1-update-custom-roadmap-rating'
: 'v1-rate-custom-roadmap';
@ -74,7 +81,7 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
if (!response || error) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
setIsSubmitting(false);
return;
}
@ -91,17 +98,10 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
}, [roadmapSlug]);
return (
<div className="flex flex-col gap-3">
{showRatingsBreakdown && !isRatingRoadmap && (
<>
<div className="flex items-center gap-2">
<Rating rating={average} readOnly />
<span className="font-medium">{average} out of 5</span>
</div>
<span className="mt-2 inline-block text-gray-500">
{formatCommaNumber(totalRatings)} ratings
</span>
<ul className="mt-4 flex flex-col gap-2">
<ul className="mt-4 flex flex-col gap-1 rounded-lg bg-white p-5">
{ratingsKeys.map((rating) => {
const percentage =
totalRatings <= 0
@ -109,16 +109,24 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
: ((breakdown?.[rating] || 0) / totalRatings) * 100;
return (
<li key={`rating-${rating}`} className="flex items-center gap-2">
<li
key={`rating-${rating}`}
className="flex items-center gap-2 text-sm"
>
<span className="shrink-0">{rating} star</span>
<div className="relative h-8 w-full overflow-hidden rounded-md border">
<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={
{
'--rating-percentage': `${percentage}%`,
} as CSSProperties
}
/>
className="h-full bg-yellow-300"
style={{ width: `${percentage}%` }}
></div>
{percentage > 0 && (
<span className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-xs text-black">
{formatCommaNumber(breakdown?.[rating] || 0)}
</span>
)}
</div>
<span className="w-14 shrink-0 text-sm text-gray-500">
{percentage}%
</span>
@ -126,16 +134,73 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
);
})}
</ul>
</>
)}
<hr className="my-5 bg-gray-300" />
{!canManage && !isRatingRoadmap && (
<div className="relative min-h-[100px] rounded-lg bg-white p-4">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner isDualRing={false} className="h-5 w-5" />
</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>
{(isRatingRoadmap || userRatingId) && (
<form
className="mt-4"
onSubmit={(e) => {
@ -155,45 +220,37 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
htmlFor="rating-feedback"
className="block text-sm font-medium"
>
Feedback
Feedback to Creator{' '}
<span className="font-normal text-gray-400">(Optional)</span>
</label>
<textarea
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}
onChange={(e) => {
setUserFeedback(e.target.value);
}}
/>
<p className="text-right text-xs text-gray-700">
Feedback will be only visible to the creator.
</p>
</div>
<div
className={cn(
'mt-4 grid gap-1',
userRatingId ? 'grid-cols-1' : 'grid-cols-2',
)}
>
{!userRatingId && (
<div className={cn('mt-4 grid grid-cols-2 gap-1')}>
<button
className="h-10 w-full rounded-full border p-2.5 text-sm font-medium disabled:opacity-60"
onClick={() => {
setIsRatingRoadmap(false);
}}
type="button"
disabled={isLoading}
disabled={isSubmitting}
>
Cancel
</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"
type="submit"
disabled={isLoading}
disabled={isSubmitting}
>
{isLoading ? (
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : userRatingId ? (
'Update Rating'
@ -203,29 +260,8 @@ export function RateRoadmapForm(props: RateRoadmapFormProps) {
</button>
</div>
</form>
)}
{!isRatingRoadmap && !userRatingId && (
<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>
)}
</div>
</>
);
}

@ -110,11 +110,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<div className="flex justify-between gap-2 sm:gap-0">
<div className="flex justify-stretch gap-1 sm:gap-2">
<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"
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>
<ShareRoadmapButton

@ -14,19 +14,15 @@ export function Rating(props: RatingProps) {
const {
rating = 0,
starSize,
readOnly = false,
className,
onRatingChange,
readOnly = false,
} = props;
const [stars, setStars] = useState(Number(rating.toFixed(2)));
const starCount = Math.floor(stars);
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 (
<div className={cn('flex', className)}>
{[1, 2, 3, 4, 5].map((counter) => {

Loading…
Cancel
Save