feat: implement discover custom roadmaps (#6162)

* feat: implement discover custom roadmaps

* feat: add error page

* wip: roadmap ratings

* wip

* feat: implement rating

* refactor: roadmap discover page

* Update UI

* fix: search

* fix: search query

* Update UI for the discover page

* Refactor rating logic

* Button changes on the custom roadmap page

* Refactor feedback modal

* Hide rating from custom roadmaps which are not discoverable

* feat: rating feedback pagination

* fix: remove per page

* Update ratings

* fix: button height

* Update UI for the discover page

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/6282/head
Arik Chakma 4 months ago committed by GitHub
parent 283a88e719
commit 9c3539eb3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 26
      package.json
  2. 1504
      pnpm-lock.yaml
  3. 34
      src/api/roadmap.ts
  4. 28
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  5. 3
      src/components/CustomRoadmap/CustomRoadmap.tsx
  6. 40
      src/components/CustomRoadmap/CustomRoadmapAlert.tsx
  7. 90
      src/components/CustomRoadmap/CustomRoadmapRatings.tsx
  8. 58
      src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
  9. 181
      src/components/CustomRoadmap/ListRoadmapRatings.tsx
  10. 273
      src/components/CustomRoadmap/RateRoadmapForm.tsx
  11. 20
      src/components/CustomRoadmap/RoadmapActionButton.tsx
  12. 73
      src/components/CustomRoadmap/RoadmapHeader.tsx
  13. 5
      src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
  14. 21
      src/components/DiscoverRoadmaps/DiscoverError.tsx
  15. 77
      src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx
  16. 271
      src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
  17. 53
      src/components/DiscoverRoadmaps/EmptyDiscoverRoadmaps.tsx
  18. 76
      src/components/DiscoverRoadmaps/SearchRoadmap.tsx
  19. 2
      src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx
  20. 12
      src/components/FeatureAnnouncement.tsx
  21. 4
      src/components/HeroSection/EmptyProgress.tsx
  22. 4
      src/components/HeroSection/HeroRoadmaps.tsx
  23. 4
      src/components/HeroSection/HeroSection.astro
  24. 34
      src/components/Navigation/Navigation.astro
  25. 8
      src/components/Navigation/NewIndicator.astro
  26. 121
      src/components/Rating/Rating.tsx
  27. 4
      src/components/ShareOptions/ShareFriendList.tsx
  28. 2
      src/components/ShareOptions/ShareOptionsModal.tsx
  29. 3
      src/components/TeamsList.tsx
  30. 6
      src/components/Tooltip.tsx
  31. 4
      src/lib/date.ts
  32. 8
      src/pages/community.astro

@ -32,13 +32,13 @@
"@astrojs/react": "^3.6.0", "@astrojs/react": "^3.6.0",
"@astrojs/sitemap": "^3.1.6", "@astrojs/sitemap": "^3.1.6",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@fingerprintjs/fingerprintjs": "^4.4.1", "@fingerprintjs/fingerprintjs": "^4.4.3",
"@nanostores/react": "^0.7.2", "@nanostores/react": "^0.7.2",
"@napi-rs/image": "^1.9.2", "@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"astro": "^4.11.3", "astro": "^4.11.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
@ -46,35 +46,35 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"htm": "^3.1.1", "htm": "^3.1.1",
"image-size": "^1.1.1", "image-size": "^1.1.1",
"jose": "^5.6.2", "jose": "^5.6.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.399.0", "lucide-react": "^0.399.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"node-html-parser": "^6.1.13", "node-html-parser": "^6.1.13",
"npm-check-updates": "^16.14.20", "npm-check-updates": "^16.14.20",
"playwright": "^1.45.0", "playwright": "^1.45.2",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-calendar-heatmap": "^1.9.0", "react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0", "react-confetti": "^6.1.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-tooltip": "^5.27.0", "react-tooltip": "^5.27.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"rehype-external-links": "^3.0.0", "rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6", "roadmap-renderer": "^1.0.6",
"satori": "^0.10.13", "satori": "^0.10.14",
"satori-html": "^0.3.2", "satori-html": "^0.3.2",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.6",
"unified": "^11.0.5", "unified": "^11.0.5",
"zustand": "^4.5.4" "zustand": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.45.0", "@playwright/test": "^1.45.2",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/dom-to-image": "^2.6.7", "@types/dom-to-image": "^2.6.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
@ -84,10 +84,10 @@
"gh-pages": "^6.1.1", "gh-pages": "^6.1.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"openai": "^4.52.2", "openai": "^4.52.7",
"prettier": "^3.3.2", "prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.0", "prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tsx": "^4.16.0" "tsx": "^4.16.2"
} }
} }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,34 @@
import { type APIContext } from 'astro';
import { api } from './api.ts';
import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
export type ListShowcaseRoadmapResponse = {
data: Pick<
RoadmapDocument,
| '_id'
| 'title'
| 'description'
| 'slug'
| 'creatorId'
| 'visibility'
| 'createdAt'
| 'topicCount'
| 'ratings'
>[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function roadmapApi(context: APIContext) {
return {
listShowcaseRoadmap: async function () {
const searchParams = new URLSearchParams(context.url.searchParams);
return api(context).get<ListShowcaseRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
searchParams,
);
},
};
}

@ -23,24 +23,44 @@ export const allowedCustomRoadmapType = ['role', 'skill'] as const;
export type AllowedCustomRoadmapType = export type AllowedCustomRoadmapType =
(typeof allowedCustomRoadmapType)[number]; (typeof allowedCustomRoadmapType)[number];
export const allowedShowcaseStatus = ['visible', 'hidden'] as const;
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
export interface RoadmapDocument { export interface RoadmapDocument {
_id?: string; _id?: string;
title: string; title: string;
description?: string; description?: string;
slug?: string; slug?: string;
creatorId: string; creatorId: string;
aiRoadmapId?: string;
teamId?: string; teamId?: string;
isDiscoverable: boolean; topicCount: number;
type: AllowedCustomRoadmapType;
visibility: AllowedRoadmapVisibility; visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[]; sharedFriendIds?: string[];
sharedTeamMemberIds?: string[]; sharedTeamMemberIds?: string[];
feedbacks?: {
userId: string;
email: string;
feedback: string;
}[];
metadata?: {
originalRoadmapId?: string;
defaultRoadmapId?: string;
};
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;
canManage: boolean;
isCustomResource: boolean;
} }
interface CreateRoadmapModalProps { interface CreateRoadmapModalProps {

@ -18,7 +18,7 @@ export const allowedLinkTypes = [
'roadmap.sh', 'roadmap.sh',
'official', 'official',
'roadmap', 'roadmap',
'feed' 'feed',
] as const; ] as const;
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number]; export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
@ -47,6 +47,7 @@ export type GetRoadmapResponse = RoadmapDocument & {
canManage: boolean; canManage: boolean;
creator?: CreatorType; creator?: CreatorType;
team?: CreatorType; team?: CreatorType;
unseenRatingCount: number;
}; };
export function hideRoadmapLoader() { export function hideRoadmapLoader() {

@ -1,4 +1,11 @@
import { BadgeCheck, MessageCircleHeart, PencilRuler } from 'lucide-react'; import {
BadgeCheck,
Heart,
HeartHandshake,
MessageCircleHeart,
PencilRuler,
Search,
} from 'lucide-react';
import { showLoginPopup } from '../../lib/popup.ts'; import { showLoginPopup } from '../../lib/popup.ts';
import { isLoggedIn } from '../../lib/jwt.ts'; import { isLoggedIn } from '../../lib/jwt.ts';
import { useState } from 'react'; import { useState } from 'react';
@ -17,14 +24,11 @@ export function CustomRoadmapAlert() {
/> />
)} )}
<div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5"> <div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5">
<h2 className="text-base font-semibold text-yellow-800 sm:text-lg"> <p className="mb-2.5 mt-2 text-sm text-yellow-800 sm:mb-1.5 sm:mt-1 sm:text-base">
Community Roadmap This is a custom roadmap made by a community member and is not
</h2> verified by <span className="font-semibold">roadmap.sh</span>
<p className="mt-2 mb-2.5 sm:mb-1.5 sm:mt-1 text-sm text-yellow-800 sm:text-base">
This is a custom roadmap made by a community member and is not verified by{' '}
<span className="font-semibold">roadmap.sh</span>
</p> </p>
<div className="flex items-start sm:items-center flex-col sm:flex-row gap-2"> <div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<a <a
href="/roadmaps" href="/roadmaps"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline" className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
@ -32,20 +36,16 @@ export function CustomRoadmapAlert() {
<BadgeCheck className="h-4 w-4 stroke-[2.5]" /> <BadgeCheck className="h-4 w-4 stroke-[2.5]" />
Visit Official Roadmaps Visit Official Roadmaps
</a> </a>
<span className="font-black text-yellow-700 hidden sm:block">&middot;</span> <span className="hidden font-black text-yellow-700 sm:block">
<button &middot;
</span>
<a
href="/discover"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline" className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
} else {
setIsCreatingRoadmap(true);
}
}}
> >
<PencilRuler className="h-4 w-4 stroke-[2.5]" /> <HeartHandshake className="h-4 w-4 stroke-[2.5]" />
Create Your Own Roadmap More Community Roadmaps
</button> </a>
</div> </div>
<MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" /> <MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" />

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Rating } from '../Rating/Rating';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal';
import { Star } from 'lucide-react';
type CustomRoadmapRatingsProps = {
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
canManage?: boolean;
unseenRatingCount: number;
};
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 (
<>
{isDetailsOpen && (
<CustomRoadmapRatingsModal
roadmapSlug={roadmapSlug}
onClose={() => {
setIsDetailsOpen(false);
}}
ratings={ratings}
canManage={canManage}
/>
)}
{average === 0 && (
<>
{!canManage && (
<button
className="flex h-[34px] 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>
)}
{canManage && (
<span className="flex h-[34px] cursor-default items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium opacity-50">
<Star className="size-4 fill-yellow-400 text-yellow-400" />
<span className="hidden md:block">No ratings yet</span>
<span className="block md:hidden">Rate</span>
</span>
)}
</>
)}
{average > 0 && (
<button
className="relative flex h-[34px] 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>
({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>
)}
</>
);
}

@ -0,0 +1,58 @@
import { useState } from 'react';
import { Modal } from '../Modal';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { RateRoadmapForm } from './RateRoadmapForm';
import { ListRoadmapRatings } from './ListRoadmapRatings';
type ActiveTab = 'ratings' | 'feedback';
type CustomRoadmapRatingsModalProps = {
onClose: () => void;
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
canManage?: boolean;
};
export function CustomRoadmapRatingsModal(
props: CustomRoadmapRatingsModalProps,
) {
const { onClose, ratings, roadmapSlug, canManage = false } = props;
const [activeTab, setActiveTab] = useState<ActiveTab>(
canManage ? 'feedback' : 'ratings',
);
const tabs: {
id: ActiveTab;
label: string;
}[] = [
{
id: 'ratings',
label: 'Ratings',
},
{
id: 'feedback',
label: 'Feedback',
},
];
return (
<Modal
onClose={onClose}
bodyClassName="bg-transparent shadow-none"
wrapperClassName="h-auto"
overlayClassName="items-start md:items-center"
>
{activeTab === 'ratings' && (
<RateRoadmapForm
ratings={ratings}
roadmapSlug={roadmapSlug}
canManage={canManage}
/>
)}
{activeTab === 'feedback' && (
<ListRoadmapRatings ratings={ratings} roadmapSlug={roadmapSlug} />
)}
</Modal>
);
}

@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { Loader2, MessageCircle, ServerCrash } from 'lucide-react';
import { Rating } from '../Rating/Rating';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { getRelativeTimeString } from '../../lib/date.ts';
import { cn } from '../../lib/classname.ts';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal.tsx';
import { Pagination } from '../Pagination/Pagination.tsx';
export interface RoadmapRatingDocument {
_id?: string;
roadmapId: string;
userId: string;
rating: number;
feedback?: string;
createdAt: Date;
updatedAt: Date;
}
type ListRoadmapRatingsResponse = {
data: (RoadmapRatingDocument & {
name: string;
avatar?: string;
})[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
type ListRoadmapRatingsProps = {
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
};
export function ListRoadmapRatings(props: ListRoadmapRatingsProps) {
const { roadmapSlug, ratings: ratingSummary } = props;
const totalWhoRated = Object.keys(ratingSummary.breakdown || {}).reduce(
(acc, key) => acc + ratingSummary.breakdown[key as any],
0,
);
const averageRating = ratingSummary.average;
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [ratingsResponse, setRatingsResponse] =
useState<ListRoadmapRatingsResponse | null>(null);
const listRoadmapRatings = async (currPage: number = 1) => {
setIsLoading(true);
const { response, error } = await httpGet<ListRoadmapRatingsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-roadmap-ratings/${roadmapSlug}`,
{
currPage,
},
);
if (!response || error) {
setError(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setRatingsResponse(response);
setError('');
setIsLoading(false);
};
useEffect(() => {
if (!isLoggedIn()) {
return;
}
listRoadmapRatings().then();
}, []);
if (error) {
return (
<div className="flex flex-col items-center justify-center bg-white py-10">
<ServerCrash className="size-12 text-red-500" />
<p className="mt-3 text-lg text-red-500">{error}</p>
</div>
);
}
const ratings = ratingsResponse?.data || [];
return (
<div className="relative min-h-[100px] overflow-auto rounded-lg bg-white p-2 md:max-h-[550px]">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner isDualRing={false} />
</div>
)}
{!isLoading && ratings.length > 0 && (
<div className="relative">
<div className="sticky top-1.5 mb-2 flex items-center justify-center gap-1 rounded-lg bg-yellow-50 px-2 py-1.5 text-sm text-yellow-900">
<span>
Rated{' '}
<span className="font-medium">{averageRating.toFixed(1)}</span>
</span>
<Rating starSize={15} rating={averageRating} readOnly />
by{' '}
<span className="font-medium">
{totalWhoRated} user{totalWhoRated > 1 && 's'}
</span>
</div>
<div className="mb-3 flex flex-col">
{ratings.map((rating) => {
const userAvatar = rating?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${rating.avatar}`
: '/images/default-avatar.png';
const isLastRating =
ratings[ratings.length - 1]._id === rating._id;
return (
<div
key={rating._id}
className={cn('px-2 py-2.5', {
'border-b': !isLastRating,
})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<img
src={userAvatar}
alt={rating.name}
className="h-4 w-4 rounded-full"
/>
<span className="text-sm font-medium">{rating.name}</span>
</div>
<span className="text-xs text-gray-400">
{getRelativeTimeString(rating.createdAt)}
</span>
</div>
<div className="mt-2.5">
<Rating rating={rating.rating} readOnly />
{rating.feedback && (
<p className="mt-2 text-sm text-gray-500">
{rating.feedback}
</p>
)}
</div>
</div>
);
})}
</div>
<Pagination
variant="minimal"
totalCount={ratingsResponse?.totalCount || 1}
currPage={ratingsResponse?.currPage || 1}
totalPages={ratingsResponse?.totalPages || 1}
perPage={ratingsResponse?.perPage || 1}
onPageChange={(page) => {
listRoadmapRatings(page).then();
}}
/>
</div>
)}
{!isLoading && ratings.length === 0 && (
<div className="flex flex-col items-center justify-center py-10">
<MessageCircle className="size-12 text-gray-200" />
<p className="mt-3 text-base text-gray-600">No Feedbacks</p>
</div>
)}
</div>
);
}

@ -0,0 +1,273 @@
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, Star } from 'lucide-react';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
import { Spinner } from '../ReactIcons/Spinner.tsx';
type GetMyRoadmapRatingResponse = {
id?: string;
rating: number;
feedback?: string;
};
type RateRoadmapFormProps = {
ratings: RoadmapDocument['ratings'];
roadmapSlug: string;
canManage?: boolean;
};
export function RateRoadmapForm(props: RateRoadmapFormProps) {
const { ratings, canManage = false, 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,
);
// 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 [isSubmitting, setIsSubmitting] = useState(false);
const [isRatingRoadmap, setIsRatingRoadmap] = useState(!showRatingsBreakdown);
const [userRatingId, setUserRatingId] = useState<string | undefined>();
const [userRating, setUserRating] = useState(0);
const [userFeedback, setUserFeedback] = useState('');
const loadMyRoadmapRating = async () => {
// user can't have the rating for their own roadmap
if (canManage) {
setIsLoading(false);
return;
}
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;
}
setIsSubmitting(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');
setIsSubmitting(false);
return;
}
window.location.reload();
};
useEffect(() => {
if (!isLoggedIn() || !roadmapSlug) {
setIsLoading(false);
return;
}
loadMyRoadmapRating().then();
}, [roadmapSlug]);
return (
<div className="flex flex-col gap-3">
{showRatingsBreakdown && !isRatingRoadmap && (
<>
<ul className="flex flex-col gap-1 rounded-lg bg-white p-5">
{ratingsKeys.map((rating) => {
const percentage =
totalRatings <= 0
? 0
: ((breakdown?.[rating] || 0) / totalRatings) * 100;
return (
<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="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-[35px] shrink-0 text-xs text-gray-500">
{parseInt(`${percentage}`, 10)}%
</span>
</li>
);
})}
</ul>
</>
)}
{!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>
<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 to Creator{' '}
<span className="font-normal text-gray-400">(Optional)</span>
</label>
<textarea
id="rating-feedback"
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);
}}
/>
</div>
<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={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={isSubmitting}
>
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : userRatingId ? (
'Update Rating'
) : (
'Submit Rating'
)}
</button>
</div>
</form>
</div>
)}
</div>
);
}

@ -1,6 +1,6 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click'; import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react'; import { Lock, MoreVertical, PenSquare, Shapes, Trash2 } from 'lucide-react';
type RoadmapActionButtonProps = { type RoadmapActionButtonProps = {
onDelete?: () => void; onDelete?: () => void;
@ -32,34 +32,34 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
{isOpen && ( {isOpen && (
<div <div
ref={menuRef} ref={menuRef}
className="align-right absolute right-0 top-full mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md z-[9999]" className="align-right absolute right-0 top-full z-[9999] mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
> >
<ul> <ul>
{onUpdateSharing && ( {onCustomize && (
<li> <li>
<button <button
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
onUpdateSharing(); onCustomize();
}} }}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
> >
<Lock size={14} className="mr-2" /> <PenSquare size={14} className="mr-2" />
Sharing Edit
</button> </button>
</li> </li>
)} )}
{onCustomize && ( {onUpdateSharing && (
<li> <li>
<button <button
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
onCustomize(); onUpdateSharing();
}} }}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
> >
<Shapes size={14} className="mr-2" /> <Lock size={14} className="mr-2" />
Customize Sharing
</button> </button>
</li> </li>
)} )}

@ -8,11 +8,9 @@ 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, 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 +26,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
creator, creator,
team, team,
visibility, visibility,
ratings,
unseenRatingCount,
showcaseStatus,
} = 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 +72,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">
@ -127,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;Discover more</span>
</a> </a>
<ShareRoadmapButton <ShareRoadmapButton
@ -166,26 +150,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
/> />
)} )}
<a
href={`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`}
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"
>
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
<span className="hidden sm:inline-block">Edit Roadmap</span>
<span className="sm:hidden">Edit</span>
</a>
<button
onClick={() => setIsSharing(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]" />
Sharing
</button>
<RoadmapActionButton <RoadmapActionButton
onUpdateSharing={() => setIsSharing(true)}
onCustomize={() => {
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`;
}}
onDelete={() => { onDelete={() => {
const confirmation = window.confirm( const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?', 'Are you sure you want to delete this roadmap?',
@ -201,17 +172,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
</> </>
)} )}
{!$canManageCurrentRoadmap && visibility === 'public' && ( {((ratings?.average || 0) > 0 || showcaseStatus === 'visible') && (
<> <CustomRoadmapRatings
{sharingWithOthersModal} roadmapSlug={roadmapSlug!}
<button ratings={ratings!}
onClick={() => setIsSharingWithOthers(true)} canManage={$canManageCurrentRoadmap}
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" unseenRatingCount={unseenRatingCount || 0}
> />
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Share with Others
</button>
</>
)} )}
</div> </div>
</div> </div>

@ -17,9 +17,8 @@ export function SkeletonRoadmapHeader() {
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" /> <div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" /> <div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[92px]" />
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" /> <div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139px]" />
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
</div> </div>
</div> </div>

@ -0,0 +1,21 @@
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
type DiscoverErrorProps = {
message: string;
};
export function DiscoverError(props: DiscoverErrorProps) {
const { message } = props;
return (
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20">
<ErrorIcon additionalClasses="mb-4 h-8 w-8 sm:h-14 sm:w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
Oops! Something went wrong
</h2>
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
{message}
</p>
</div>
);
}

@ -0,0 +1,77 @@
import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import type { SortByValues } from './DiscoverRoadmaps';
const sortingLabels: { label: string; value: SortByValues }[] = [
{
label: 'Newest',
value: 'createdAt',
},
{
label: 'Oldest',
value: '-createdAt',
},
{
label: 'Highest Rated',
value: 'rating',
},
{
label: 'Lowest Rated',
value: '-rating',
},
];
type DiscoverRoadmapSortingProps = {
sortBy: SortByValues;
onSortChange: (sortBy: SortByValues) => void;
};
export function DiscoverRoadmapSorting(props: DiscoverRoadmapSortingProps) {
const { sortBy, onSortChange } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const selectedValue = sortingLabels.find((item) => item.value === sortBy);
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
return (
<div
className="min-auto relative flex flex-shrink-0 sm:min-w-[140px]"
ref={dropdownRef}
>
<button
className="py-15 flex w-full items-center justify-between gap-2 rounded-md border px-2 text-sm bg-white"
onClick={() => setIsOpen(!isOpen)}
>
<span>{selectedValue?.label}</span>
<span>
<ChevronDown className="ml-4 h-3.5 w-3.5" />
</span>
</button>
{isOpen && (
<div className="absolute right-0 top-10 z-10 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{sortingLabels.map((item) => (
<button
key={item.value}
className="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"
onClick={() => {
onSortChange(item.value);
setIsOpen(false);
}}
>
<span>{item.label}</span>
{item.value === sortBy && <Check className="ml-auto h-4 w-4" />}
</button>
))}
</div>
)}
</div>
);
}

@ -0,0 +1,271 @@
import { Shapes } from 'lucide-react';
import type { ListShowcaseRoadmapResponse } from '../../api/roadmap';
import { Pagination } from '../Pagination/Pagination';
import { SearchRoadmap } from './SearchRoadmap';
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps';
import { Rating } from '../Rating/Rating';
import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { LoadingRoadmaps } from '../ExploreAIRoadmap/LoadingRoadmaps';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { DiscoverRoadmapSorting } from './DiscoverRoadmapSorting';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { Tooltip } from '../Tooltip.tsx';
type DiscoverRoadmapsProps = {};
export type SortByValues = 'rating' | '-rating' | 'createdAt' | '-createdAt';
type QueryParams = {
q?: string;
s?: SortByValues;
p?: string;
};
type PageState = {
searchTerm: string;
sortBy: SortByValues;
currentPage: number;
};
export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
const toast = useToast();
const [pageState, setPageState] = useState<PageState>({
searchTerm: '',
sortBy: 'createdAt',
currentPage: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [roadmapsResponse, setRoadmapsResponse] =
useState<ListShowcaseRoadmapResponse | null>(null);
useEffect(() => {
const queryParams = getUrlParams() as QueryParams;
setPageState({
searchTerm: queryParams.q || '',
sortBy: queryParams.s || 'createdAt',
currentPage: +(queryParams.p || '1'),
});
}, []);
useEffect(() => {
setIsLoading(true);
if (!pageState.currentPage) {
return;
}
// only set the URL params if the user modified anything
if (
pageState.currentPage !== 1 ||
pageState.searchTerm !== '' ||
pageState.sortBy !== 'createdAt'
) {
setUrlParams({
q: pageState.searchTerm,
s: pageState.sortBy,
p: String(pageState.currentPage),
});
} else {
deleteUrlParam('q');
deleteUrlParam('s');
deleteUrlParam('p');
}
loadAIRoadmaps(
pageState.currentPage,
pageState.searchTerm,
pageState.sortBy,
).finally(() => {
setIsLoading(false);
});
}, [pageState]);
const loadAIRoadmaps = async (
currPage: number = 1,
searchTerm: string = '',
sortBy: SortByValues = 'createdAt',
) => {
const { response, error } = await httpGet<ListShowcaseRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
{
currPage,
...(searchTerm && { searchTerm }),
...(sortBy && { sortBy }),
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setRoadmapsResponse(response);
};
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const roadmaps = roadmapsResponse?.data || [];
const loadingIndicator = isLoading && <LoadingRoadmaps />;
return (
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<div className="border-b bg-white pt-10 pb-7">
<div className="container text-left">
<div className="flex flex-col items-start bg-white">
<h1 className="mb-1 text-2xl font-bold sm:text-4xl">
Community Roadmaps
</h1>
<p className="mb-3 text-base text-gray-500">
An unvetted, selected list of community-curated roadmaps
</p>
<div className="relative">
<div className="flex flex-col sm:flex-row items-center gap-1.5">
<span className="group relative normal-case">
<Tooltip
position={'bottom-left'}
additionalClass={
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
}
>
Ask us to feature it once you're done!
</Tooltip>
<button
className="rounded-md bg-black px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-black"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
Create your own roadmap
</button>
</span>
<span className="group relative normal-case">
<Tooltip
position={'bottom-left'}
additionalClass={
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
}
>
Up-to-date and maintained by the official team
</Tooltip>
<a
href="/roadmaps"
className="inline-block rounded-md bg-gray-300 px-3.5 py-2 text-sm text-black sm:py-1.5 sm:text-base"
>
Visit our official roadmaps
</a>
</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 py-3">
<section className="container mx-auto py-3">
<div className="mb-3.5 flex items-stretch justify-between gap-2.5">
<SearchRoadmap
total={roadmapsResponse?.totalCount || 0}
value={pageState.searchTerm}
isLoading={isLoading}
onValueChange={(value) => {
setPageState({
...pageState,
searchTerm: value,
currentPage: 1,
});
}}
/>
<DiscoverRoadmapSorting
sortBy={pageState.sortBy}
onSortChange={(sortBy) => {
setPageState({
...pageState,
sortBy,
});
}}
/>
</div>
{loadingIndicator}
{roadmaps.length === 0 && !isLoading && <EmptyDiscoverRoadmaps />}
{roadmaps.length > 0 && !isLoading && (
<>
<ul className="mb-4 grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roadmaps.map((roadmap) => {
const roadmapLink = `/r/${roadmap.slug}`;
const totalRatings = Object.keys(
roadmap.ratings?.breakdown || [],
).reduce(
(acc: number, key: string) =>
acc + roadmap.ratings.breakdown[key as any],
0,
);
return (
<li key={roadmap._id} className="h-full min-h-[175px]">
<a
key={roadmap._id}
href={roadmapLink}
className="flex h-full flex-col rounded-lg border bg-white p-3.5 transition-colors hover:border-gray-300 hover:bg-gray-50"
target={'_blank'}
>
<div className="grow">
<h2 className="text-balance text-base font-bold leading-tight">
{roadmap.title}
</h2>
<p className="mt-2 text-sm text-gray-500">
{roadmap.description}
</p>
</div>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-gray-400">
<Shapes size={15} className="inline-block" />
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(roadmap.topicCount)}{' '}
</span>
<Rating
rating={roadmap?.ratings?.average || 0}
readOnly={true}
starSize={16}
total={totalRatings}
/>
</div>
</a>
</li>
);
})}
</ul>
<Pagination
currPage={roadmapsResponse?.currPage || 1}
totalPages={roadmapsResponse?.totalPages || 1}
perPage={roadmapsResponse?.perPage || 0}
totalCount={roadmapsResponse?.totalCount || 0}
onPageChange={(page) => {
setPageState({
...pageState,
currentPage: page,
});
}}
/>
</>
)}
</section>
</div>
</>
);
}

@ -0,0 +1,53 @@
import { Map, Wand2 } from 'lucide-react';
import { useState } from 'react';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
export function EmptyDiscoverRoadmaps() {
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const creatingRoadmapModal = isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => setIsCreatingRoadmap(false)}
onCreated={(roadmap) => {
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${roadmap?._id}`;
}}
/>
);
return (
<>
{creatingRoadmapModal}
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20 bg-white">
<Map className="mb-4 h-8 w-8 opacity-10 sm:h-14 sm:w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
No Roadmaps Found
</h2>
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
Try searching for something else or create a new roadmap.
</p>
<div className="flex flex-col items-center gap-1 sm:flex-row sm:gap-1.5">
<button
className="flex w-full items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white sm:w-auto sm:text-sm"
type="button"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
<Wand2 className="h-4 w-4" />
Create your Roadmap
</button>
<a
href="/roadmaps"
className="flex w-full items-center gap-1.5 rounded-md bg-gray-300 px-3 py-1.5 text-xs text-black hover:bg-gray-400 sm:w-auto sm:text-sm"
>
<Map className="h-4 w-4" />
Visit Official Roadmaps
</a>
</div>
</div>
</>
);
}

@ -0,0 +1,76 @@
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useDebounceValue } from '../../hooks/use-debounce';
import { Spinner } from '../ReactIcons/Spinner';
type SearchRoadmapProps = {
value: string;
total: number;
isLoading: boolean;
onValueChange: (value: string) => void;
};
export function SearchRoadmap(props: SearchRoadmapProps) {
const { total, value: defaultValue, onValueChange, isLoading } = props;
const [term, setTerm] = useState(defaultValue);
const debouncedTerm = useDebounceValue(term, 500);
useEffect(() => {
setTerm(defaultValue);
}, [defaultValue]);
useEffect(() => {
if (debouncedTerm && debouncedTerm.length < 3) {
return;
}
if (debouncedTerm === defaultValue) {
return;
}
onValueChange(debouncedTerm);
}, [debouncedTerm]);
return (
<div className="relative flex w-full items-center gap-3">
<form
className="relative flex w-full max-w-[310px] items-center"
onSubmit={(e) => {
e.preventDefault();
onValueChange(term);
}}
>
<label
className="absolute left-3 flex h-full items-center text-gray-500"
htmlFor="search"
>
<Search className="h-4 w-4" />
</label>
<input
id="q"
name="q"
type="text"
minLength={3}
placeholder="Type 3 or more characters to search..."
className="w-full rounded-md border border-gray-200 px-3 py-2 pl-9 text-sm transition-colors focus:border-black focus:outline-none"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{isLoading && (
<span className="absolute right-3 top-0 flex h-full items-center text-gray-500">
<Spinner isDualRing={false} className={`h-3 w-3`} />
</span>
)}
</form>
{total > 0 && (
<p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block">
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(total)}{' '}
result{total > 1 ? 's' : ''} found
</p>
)}
</div>
);
}

@ -4,7 +4,7 @@ export function LoadingRoadmaps() {
{new Array(21).fill(0).map((_, index) => ( {new Array(21).fill(0).map((_, index) => (
<li <li
key={index} key={index}
className="h-[95px] animate-pulse rounded-md border bg-gray-100" className="h-[175px] animate-pulse rounded-md border bg-gray-200"
/> />
))} ))}
</ul> </ul>

@ -1,16 +1,20 @@
type AIAnnouncementProps = {}; type AIAnnouncementProps = {};
export function AIAnnouncement(props: AIAnnouncementProps) { export function FeatureAnnouncement(props: AIAnnouncementProps) {
return ( return (
<a <a
className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200" className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200"
href="/ai" href="/community"
> >
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white"> <span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white">
New New
</span>{' '} </span>{' '}
<span className={'hidden sm:inline'}>Generate visual roadmaps with AI</span> <span className={'hidden sm:inline'}>
<span className={'inline text-sm sm:hidden'}>AI Roadmap Generator!</span> Explore community made roadmaps
</span>
<span className={'inline text-sm sm:hidden'}>
Community roadmaps explorer!
</span>
</a> </a>
); );
} }

@ -1,5 +1,5 @@
import { CheckIcon } from '../ReactIcons/CheckIcon'; import { CheckIcon } from '../ReactIcons/CheckIcon';
import { AIAnnouncement } from '../AIAnnouncement.tsx'; import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
type EmptyProgressProps = { type EmptyProgressProps = {
title?: string; title?: string;
@ -23,7 +23,7 @@ export function EmptyProgress(props: EmptyProgressProps) {
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p> <p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
<p className="mt-5"> <p className="mt-5">
<AIAnnouncement /> <FeatureAnnouncement />
</p> </p>
</div> </div>
); );

@ -7,7 +7,7 @@ import { MapIcon, Users2 } from 'lucide-react';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton'; import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { AIAnnouncement } from '../AIAnnouncement.tsx'; import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
type ProgressRoadmapProps = { type ProgressRoadmapProps = {
url: string; url: string;
@ -97,7 +97,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
return ( return (
<div className="relative pb-12 pt-4 sm:pt-7"> <div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-7 mt-2 text-sm"> <p className="mb-7 mt-2 text-sm">
<AIAnnouncement /> <FeatureAnnouncement />
</p> </p>
{isCreatingRoadmap && ( {isCreatingRoadmap && (
<CreateRoadmapModal <CreateRoadmapModal

@ -1,6 +1,6 @@
--- ---
import { FavoriteRoadmaps } from './FavoriteRoadmaps'; import { FavoriteRoadmaps } from './FavoriteRoadmaps';
import { AIAnnouncement } from "../AIAnnouncement"; import { FeatureAnnouncement } from "../FeatureAnnouncement";
--- ---
<div <div
@ -11,7 +11,7 @@ import { AIAnnouncement } from "../AIAnnouncement";
id='hero-text' id='hero-text'
> >
<p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'> <p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'>
<AIAnnouncement /> <FeatureAnnouncement />
</p> </p>
<h1 <h1

@ -3,6 +3,7 @@ import { Menu } from 'lucide-react';
import Icon from '../AstroIcon.astro'; import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown'; import { NavigationDropdown } from '../NavigationDropdown';
import { AccountDropdown } from './AccountDropdown'; import { AccountDropdown } from './AccountDropdown';
import NewIndicator from './NewIndicator.astro';
--- ---
<div class='bg-slate-900 py-5 text-white sm:py-8'> <div class='bg-slate-900 py-5 text-white sm:py-8'>
@ -18,7 +19,7 @@ import { AccountDropdown } from './AccountDropdown';
<a <a
href='/teams' href='/teams'
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white' class='group relative !mr-2 inline text-blue-300 hover:text-white sm:hidden'
> >
Teams Teams
@ -41,28 +42,25 @@ import { AccountDropdown } from './AccountDropdown';
</a> </a>
<a <a
href='/teams' href='/teams'
class='group relative !mr-2 text-blue-300 hover:text-white' class='group relative text-gray-400 hover:text-white'
> >
Teams Teams
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
></span>
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
></span>
</span>
</span>
</a> </a>
<a href='/ai' class='text-gray-400 hover:text-white'> AI</a>
<a <a
href='/ai' class='text-gray-400 hover:text-white'> AI Roadmaps</a> href='/community'
<button class='group relative !mr-2 text-blue-300 hover:text-white'
data-command-menu
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
> >
<Icon icon='search' class='h-3 w-3' /> Community
<span class='ml-2'>Search</span> <NewIndicator />
</button> </a>
<!--<button-->
<!-- data-command-menu-->
<!-- class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'-->
<!--&gt;-->
<!-- <Icon icon='search' class='h-3 w-3' />-->
<!-- <span class='ml-2'>Search</span>-->
<!--</button>-->
</div> </div>
</div> </div>

@ -0,0 +1,8 @@
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
></span>
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'></span>
</span>
</span>

@ -0,0 +1,121 @@
import { useState } from 'react';
import { cn } from '../../lib/classname';
type RatingProps = {
rating?: number;
onRatingChange?: (rating: number) => void;
starSize?: number;
readOnly?: boolean;
className?: string;
total?: number;
};
export function Rating(props: RatingProps) {
const {
rating = 0,
starSize,
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);
return (
<div className={cn('flex', className)}>
{[1, 2, 3, 4, 5].map((counter) => {
const isActive = counter <= starCount;
const hasDecimal = starCount + 1 === counter;
return (
<RatingStar
key={`start-${counter}`}
starSize={starSize}
widthPercentage={
isActive ? 100 : hasDecimal ? decimalWidthPercentage : 0
}
onClick={() => {
if (readOnly) {
return;
}
setStars(counter);
onRatingChange?.(counter);
}}
readOnly={readOnly}
/>
);
})}
{(props.total || 0) > 0 && (
<span className="ml-1.5 text-xs text-gray-400">
({Intl.NumberFormat('en-US').format(props.total!)})
</span>
)}
</div>
);
}
type RatingStarProps = {
starSize?: number;
onClick: () => void;
widthPercentage?: number;
readOnly: boolean;
};
function RatingStar(props: RatingStarProps) {
const { onClick, widthPercentage = 100, starSize = 20, readOnly } = props;
return (
<div
className="relative block cursor-pointer text-gray-300 disabled:cursor-default aria-disabled:cursor-default"
style={{
width: `${starSize}px`,
height: `${starSize}px`,
}}
onClick={onClick}
aria-disabled={readOnly}
role="button"
>
<span className="absolute inset-0">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="fill-none"
style={{
width: `${starSize}px`,
height: `${starSize}px`,
}}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
<span
className="absolute inset-0 overflow-hidden"
style={{
width: `${widthPercentage}%`,
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="fill-yellow-400 stroke-yellow-400"
style={{
width: `${starSize}px`,
height: `${starSize}px`,
}}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</span>
</span>
</div>
);
}

@ -206,11 +206,11 @@ export function ShareFriendList(props: ShareFriendListProps) {
{friends.length === 0 && !isLoading && ( {friends.length === 0 && !isLoading && (
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center"> <div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
<Users2 className="mb-3 h-10 w-10 text-gray-300" /> <Users2 className="mb-3 h-10 w-10 text-gray-300" />
<p className="font-semibold text-gray-500"> <p className="font-medium text-gray-500">
You do not have any friends yet. <br />{' '} You do not have any friends yet. <br />{' '}
<a <a
target="_blank" target="_blank"
className="underline underline-offset-2" className="underline underline-offset-2 text-sm"
href={`/account/friends`} href={`/account/friends`}
> >
Invite your friends to share roadmaps with. Invite your friends to share roadmaps with.

@ -403,7 +403,7 @@ function DiscoveryCheckbox(props: DiscoveryCheckboxProps) {
onChange={(e) => setIsDiscoverable(e.target.checked)} onChange={(e) => setIsDiscoverable(e.target.checked)}
/> />
<span className="text-sm text-gray-500 group-hover:text-gray-700"> <span className="text-sm text-gray-500 group-hover:text-gray-700">
Include on discovery page (when launched) Include on discovery page
</span> </span>
</label> </label>
); );

@ -11,7 +11,7 @@ export function TeamsList() {
const toast = useToast(); const toast = useToast();
async function getAllTeam() { async function getAllTeam() {
const { response, error } = await httpGet<UserTeamItem[]>( const { response, error } = await httpGet<UserTeamItem[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams` `${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`,
); );
if (error || !response) { if (error || !response) {
toast.error(error?.message || 'Something went wrong'); toast.error(error?.message || 'Something went wrong');
@ -36,6 +36,7 @@ export function TeamsList() {
Here are the teams you are part of Here are the teams you are part of
</p> </p>
</div> </div>
<ul className="mb-3 flex flex-col gap-1"> <ul className="mb-3 flex flex-col gap-1">
<li> <li>
<a <a

@ -3,6 +3,7 @@ import { clsx } from 'clsx';
type TooltipProps = { type TooltipProps = {
children: ReactNode; children: ReactNode;
additionalClass?: string;
position?: position?:
| 'right-center' | 'right-center'
| 'right-top' | 'right-top'
@ -19,7 +20,7 @@ type TooltipProps = {
}; };
export function Tooltip(props: TooltipProps) { export function Tooltip(props: TooltipProps) {
const { children, position = 'right-center' } = props; const { children, additionalClass = '', position = 'right-center' } = props;
let positionClass = ''; let positionClass = '';
if (position === 'right-center') { if (position === 'right-center') {
@ -52,7 +53,8 @@ export function Tooltip(props: TooltipProps) {
<span <span
className={clsx( className={clsx(
'pointer-events-none absolute z-10 block w-max transform rounded-md bg-gray-900 px-2 py-1 text-sm font-medium text-white opacity-0 shadow-sm duration-100 group-hover:opacity-100', 'pointer-events-none absolute z-10 block w-max transform rounded-md bg-gray-900 px-2 py-1 text-sm font-medium text-white opacity-0 shadow-sm duration-100 group-hover:opacity-100',
positionClass positionClass,
additionalClass,
)} )}
> >
{children} {children}

@ -1,11 +1,11 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
export function getRelativeTimeString( export function getRelativeTimeString(
date: string, date: string | Date,
isTimed: boolean = false, isTimed: boolean = false,
): string { ): string {
if (!Intl?.RelativeTimeFormat) { if (!Intl?.RelativeTimeFormat) {
return date; return date.toString();
} }
const rtf = new Intl.RelativeTimeFormat('en', { const rtf = new Intl.RelativeTimeFormat('en', {

@ -0,0 +1,8 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { DiscoverRoadmaps } from '../components/DiscoverRoadmaps/DiscoverRoadmaps';
---
<BaseLayout title='Discover Custom Roadmaps'>
<DiscoverRoadmaps client:load />
</BaseLayout>
Loading…
Cancel
Save