feat/discover
Arik Chakma 5 months ago
parent 987c7bc8ec
commit f801c5b608
  1. 12
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  2. 41
      src/components/CustomRoadmap/CustomRoadmapRatings.tsx
  3. 24
      src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
  4. 226
      src/components/CustomRoadmap/RateRoadmapForm.tsx
  5. 40
      src/components/CustomRoadmap/RoadmapHeader.tsx
  6. 19
      src/components/Rating/Rating.tsx

@ -38,8 +38,6 @@ export interface RoadmapDocument {
visibility: AllowedRoadmapVisibility; visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[]; sharedFriendIds?: string[];
sharedTeamMemberIds?: string[]; sharedTeamMemberIds?: string[];
isDiscoverable?: boolean;
showcaseStatus?: AllowedShowcaseStatus;
feedbacks?: { feedbacks?: {
userId: string; userId: string;
email: string; email: string;
@ -51,6 +49,16 @@ export interface RoadmapDocument {
}; };
nodes: any[]; nodes: any[];
edges: any[]; edges: any[];
isDiscoverable?: boolean;
showcaseStatus?: AllowedShowcaseStatus;
ratings: {
average: number;
breakdown: {
[key: number]: number;
};
};
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

@ -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>
</>
);
}

@ -9,10 +9,9 @@ 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, Shapes } from 'lucide-react'; import { Lock, Shapes } from 'lucide-react';
import { Modal } from '../Modal';
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
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';
type RoadmapHeaderProps = {}; type RoadmapHeaderProps = {};
@ -28,10 +27,10 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
creator, creator,
team, team,
visibility, visibility,
ratings,
} = useStore(currentRoadmap) || {}; } = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false); const [isSharing, setIsSharing] = useState(false);
const [isSharingWithOthers, setIsSharingWithOthers] = useState(false);
const toast = useToast(); const toast = useToast();
async function deleteResource() { async function deleteResource() {
@ -72,23 +71,6 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}` ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
: '/images/default-avatar.png'; : '/images/default-avatar.png';
const sharingWithOthersModal = isSharingWithOthers && (
<Modal
onClose={() => setIsSharingWithOthers(false)}
wrapperClassName="max-w-lg"
bodyClassName="p-4 flex flex-col"
>
<ShareSuccess
visibility="public"
roadmapSlug={roadmapSlug}
roadmapId={roadmapId!}
description={description}
onClose={() => setIsSharingWithOthers(false)}
isSharingWithOthers={true}
/>
</Modal>
);
return ( return (
<div className="border-b"> <div className="border-b">
<div className="container relative py-5 sm:py-12"> <div className="container relative py-5 sm:py-12">
@ -171,7 +153,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
import.meta.env.PUBLIC_EDITOR_APP_URL import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`} }/${$currentRoadmap?._id}`}
target="_blank" target="_blank"
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 sm:inline-block">Edit Roadmap</span>
@ -198,19 +180,11 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
deleteResource().finally(() => null); deleteResource().finally(() => null);
}} }}
/> />
</>
)}
{!$canManageCurrentRoadmap && visibility === 'public' && ( <CustomRoadmapRatings
<> roadmapSlug={roadmapSlug!}
{sharingWithOthersModal} ratings={ratings!}
<button />
onClick={() => setIsSharingWithOthers(true)}
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"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Share with Others
</button>
</> </>
)} )}
</div> </div>

@ -1,20 +1,29 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../lib/classname';
type RatingProps = { type RatingProps = {
ratings?: number; rating?: number;
onRatingChange?: (rating: number) => void;
starSize?: number; starSize?: number;
readOnly?: boolean; readOnly?: boolean;
className?: string;
}; };
export function Rating(props: RatingProps) { export function Rating(props: RatingProps) {
const { ratings = 0, starSize, readOnly = false } = props; const {
rating = 0,
starSize,
readOnly = false,
className,
onRatingChange,
} = props;
const [stars, setStars] = useState(Number(ratings.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);
return ( return (
<div className="mt-4 flex"> <div className={cn('flex', className)}>
{[1, 2, 3, 4, 5].map((counter) => { {[1, 2, 3, 4, 5].map((counter) => {
const isActive = counter <= starCount; const isActive = counter <= starCount;
const hasDecimal = starCount + 1 === counter; const hasDecimal = starCount + 1 === counter;
@ -28,6 +37,7 @@ export function Rating(props: RatingProps) {
} }
onClick={() => { onClick={() => {
setStars(counter); setStars(counter);
onRatingChange?.(counter);
}} }}
readOnly={readOnly} readOnly={readOnly}
/> />
@ -56,6 +66,7 @@ function RatingStar(props: RatingStarProps) {
height: `${starSize}px`, height: `${starSize}px`,
}} }}
disabled={readOnly} disabled={readOnly}
type="button"
> >
<span className="absolute inset-0"> <span className="absolute inset-0">
<svg <svg

Loading…
Cancel
Save