parent
987c7bc8ec
commit
f801c5b608
6 changed files with 323 additions and 39 deletions
@ -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 && ( |
||||
<CustomRoadmapRatingsModal |
||||
roadmapSlug={roadmapSlug} |
||||
onClose={() => { |
||||
setIsDetailsOpen(false); |
||||
}} |
||||
ratings={ratings} |
||||
/> |
||||
)} |
||||
<div className="flex items-center gap-2"> |
||||
<Rating rating={average} readOnly /> |
||||
<button |
||||
className="text-sm font-medium underline" |
||||
onClick={() => { |
||||
setIsDetailsOpen(true); |
||||
}} |
||||
> |
||||
{average} out of 5 |
||||
</button> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
@ -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 ( |
||||
<Modal onClose={onClose} bodyClassName="p-4"> |
||||
<RateRoadmapForm ratings={ratings} roadmapSlug={roadmapSlug} /> |
||||
</Modal> |
||||
); |
||||
} |
@ -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<string | undefined>(); |
||||
const [userRating, setUserRating] = useState(0); |
||||
const [userFeedback, setUserFeedback] = useState(''); |
||||
|
||||
const loadMyRoadmapRating = async () => { |
||||
const { response, error } = await httpGet<GetMyRoadmapRatingResponse>( |
||||
`${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 ( |
||||
<> |
||||
<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"> |
||||
{ratingsKeys.map((rating) => { |
||||
const percentage = |
||||
totalRatings <= 0 |
||||
? 0 |
||||
: ((breakdown?.[rating] || 0) / totalRatings) * 100; |
||||
|
||||
return ( |
||||
<li key={`rating-${rating}`} className="flex items-center gap-2"> |
||||
<span className="shrink-0">{rating} star</span> |
||||
<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 |
||||
} |
||||
/> |
||||
<span className="w-14 shrink-0 text-sm text-gray-500"> |
||||
{percentage}% |
||||
</span> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
|
||||
<hr className="my-5 bg-gray-300" /> |
||||
|
||||
<div> |
||||
<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) => { |
||||
e.preventDefault(); |
||||
submitMyRoadmapRating().then(); |
||||
}} |
||||
> |
||||
<Rating |
||||
rating={userRating} |
||||
onRatingChange={(rating) => { |
||||
setUserRating(rating); |
||||
}} |
||||
starSize={32} |
||||
/> |
||||
<div className="mt-3 flex flex-col gap-1"> |
||||
<label |
||||
htmlFor="rating-feedback" |
||||
className="block text-sm font-medium" |
||||
> |
||||
Feedback |
||||
</label> |
||||
<textarea |
||||
id="rating-feedback" |
||||
className="min-h-24 rounded-md border p-1 outline-none focus:border-gray-500" |
||||
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 && ( |
||||
<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} |
||||
> |
||||
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} |
||||
> |
||||
{isLoading ? ( |
||||
<Loader2 className="size-4 animate-spin" /> |
||||
) : userRatingId ? ( |
||||
'Update Rating' |
||||
) : ( |
||||
'Submit Rating' |
||||
)} |
||||
</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={() => { |
||||
setIsRatingRoadmap(true); |
||||
}} |
||||
disabled={isLoading} |
||||
> |
||||
{isLoading ? ( |
||||
<Loader2 className="size-4 animate-spin" /> |
||||
) : ( |
||||
'Rate Roadmap' |
||||
)} |
||||
</button> |
||||
)} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
Loading…
Reference in new issue