1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-08-30 20:49:49 +02:00

Refactor and add upgrade button on dashboard

This commit is contained in:
Kamran Ahmed
2025-07-14 13:58:57 +01:00
parent fe1a869a66
commit 911f34cba4
4 changed files with 73 additions and 149 deletions

View File

@@ -4,28 +4,53 @@ import { showLoginPopup } from '../../../lib/popup';
import { cn } from '../../../lib/classname'; import { cn } from '../../../lib/classname';
import { CreateRoadmapModal } from './CreateRoadmapModal'; import { CreateRoadmapModal } from './CreateRoadmapModal';
import { useState } from 'react'; import { useState } from 'react';
import { useIsPaidUser } from '../../../queries/billing';
import { UpgradeAccountModal } from '../../Billing/UpgradeAccountModal';
import { MAX_ROADMAP_LIMIT } from '../RoadmapListPage';
type CreateRoadmapButtonProps = { type CreateRoadmapButtonProps = {
className?: string; className?: string;
existingRoadmapCount?: number;
text?: string; text?: string;
teamId?: string; teamId?: string;
}; };
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) { export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
const { teamId, className, text = 'Create your own Roadmap' } = props; const {
teamId,
className,
text = 'Create your own Roadmap',
existingRoadmapCount = 0,
} = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false); const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
function toggleCreateRoadmapHandler() { function toggleCreateRoadmapHandler() {
if (!isLoggedIn()) { if (!isLoggedIn()) {
return showLoginPopup(); return showLoginPopup();
} }
const hasExceededLimit =
!isPaidUser &&
existingRoadmapCount > 0 &&
existingRoadmapCount >= MAX_ROADMAP_LIMIT;
if (hasExceededLimit) {
setShowUpgradeModal(true);
return;
}
setIsCreatingRoadmap(true); setIsCreatingRoadmap(true);
} }
return ( return (
<> <>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{isCreatingRoadmap && ( {isCreatingRoadmap && (
<CreateRoadmapModal <CreateRoadmapModal
teamId={teamId} teamId={teamId}

View File

@@ -39,7 +39,7 @@ const tabTypes: TabType[] = [
{ label: 'Shared by Friends', value: 'shared' }, { label: 'Shared by Friends', value: 'shared' },
]; ];
const MAX_ROADMAP_LIMIT = 3; export const MAX_ROADMAP_LIMIT = 3;
export function RoadmapListPage() { export function RoadmapListPage() {
const toast = useToast(); const toast = useToast();

View File

@@ -1,12 +1,4 @@
import { useStore } from '@nanostores/react'; import { CheckSquare, Gift, SquarePen } from 'lucide-react';
import {
ChartColumn,
CheckSquare,
FolderGit2,
SquarePen,
Zap,
type LucideIcon
} from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { AllowedProfileVisibility } from '../../api/user.ts'; import type { AllowedProfileVisibility } from '../../api/user.ts';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
@@ -16,7 +8,7 @@ import { httpGet } from '../../lib/http';
import type { QuestionGroupType } from '../../lib/question-group'; import type { QuestionGroupType } from '../../lib/question-group';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts'; import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
import type { VideoFileType } from '../../lib/video'; import type { VideoFileType } from '../../lib/video';
import { $accountStreak, type StreakResponse } from '../../stores/streak'; import { type StreakResponse } from '../../stores/streak';
import type { PageType } from '../CommandMenu/CommandMenu'; import type { PageType } from '../CommandMenu/CommandMenu';
import { FeaturedGuideList } from '../FeaturedGuides/FeaturedGuideList'; import { FeaturedGuideList } from '../FeaturedGuides/FeaturedGuideList';
import { FeaturedVideoList } from '../FeaturedVideos/FeaturedVideoList'; import { FeaturedVideoList } from '../FeaturedVideos/FeaturedVideoList';
@@ -27,8 +19,10 @@ import {
import { HeroRoadmap } from '../HeroSection/HeroRoadmap.tsx'; import { HeroRoadmap } from '../HeroSection/HeroRoadmap.tsx';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import { useIsPaidUser } from '../../queries/billing.ts';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx';
const projectGroups = [ const projectGroups = [
{ {
title: 'Frontend', title: 'Frontend',
id: 'frontend', id: 'frontend',
@@ -53,7 +47,6 @@ type UserDashboardResponse = {
progresses: UserProgress[]; progresses: UserProgress[];
projects: ProjectStatusDocument[]; projects: ProjectStatusDocument[];
aiRoadmaps: AIRoadmapType[]; aiRoadmaps: AIRoadmapType[];
topicDoneToday: number;
}; };
export type BuiltInRoadmap = { export type BuiltInRoadmap = {
@@ -78,34 +71,40 @@ type PersonalDashboardProps = {
}; };
type DashboardStatItemProps = { type DashboardStatItemProps = {
icon: LucideIcon;
iconClassName: string;
value: number;
label: string;
isLoading: boolean; isLoading: boolean;
}; };
function DashboardStatItem(props: DashboardStatItemProps) { function UpgradeAccountButton(props: DashboardStatItemProps) {
const { icon: Icon, iconClassName, value, label, isLoading } = props; const { isLoading } = props;
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
if (isPaidUser) {
return null;
}
return ( return (
<div <>
className={cn( {showUpgradeModal && (
'flex items-center gap-1.5 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3', <UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
{
'striped-loader-slate striped-loader-slate-fast text-transparent':
isLoading,
},
)} )}
>
<Icon <button
size={16} onClickCapture={() => setShowUpgradeModal(true)}
className={cn(iconClassName, { 'text-transparent': isLoading })} className={cn(
/> 'flex items-center gap-1.5 rounded-lg bg-white py-1.5 pr-3 pl-2.5 text-xs text-purple-700 duration-200 hover:bg-purple-600 hover:text-white',
<span> {
<span className="tabular-nums">{value}</span> {label} 'striped-loader-slate striped-loader-slate-fast border-transparent bg-slate-800/50 text-transparent hover:bg-transparent hover:text-transparent hover:shadow-none':
</span> isLoading || isPaidUserLoading,
</div> },
)}
onClick={() => {}}
>
<Gift size={16} className={cn({ 'text-transparent': isLoading })} />
<span>Upgrade Account</span>
</button>
</>
); );
} }
@@ -124,7 +123,7 @@ function PersonalProfileButton(props: ProfileButtonProps) {
<a <a
href="/account/update-profile" href="/account/update-profile"
className={cn( className={cn(
'flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 font-medium outline-slate-700 hover:bg-slate-800 hover:outline-slate-400', 'flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pr-3 pl-3 font-medium outline-slate-700 hover:bg-slate-800 hover:outline-slate-400',
{ {
'striped-loader-slate striped-loader-slate-fast text-transparent': 'striped-loader-slate striped-loader-slate-fast text-transparent':
isLoading, isLoading,
@@ -142,7 +141,7 @@ function PersonalProfileButton(props: ProfileButtonProps) {
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<a <a
href={`/u/${username}`} href={`/u/${username}`}
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-300 transition-colors hover:bg-slate-800/70" className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pr-3 pl-3 text-slate-300 transition-colors hover:bg-slate-800/70"
> >
<img <img
src={avatar} src={avatar}
@@ -153,7 +152,7 @@ function PersonalProfileButton(props: ProfileButtonProps) {
</a> </a>
<a <a
href="/account/update-profile" href="/account/update-profile"
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-400 transition-colors hover:bg-slate-800/70 hover:text-slate-300" className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pr-3 pl-3 text-slate-400 transition-colors hover:bg-slate-800/70 hover:text-slate-300"
title="Edit Profile" title="Edit Profile"
> >
<SquarePen className="h-4 w-4" /> <SquarePen className="h-4 w-4" />
@@ -165,22 +164,15 @@ function PersonalProfileButton(props: ProfileButtonProps) {
type DashboardStatsProps = { type DashboardStatsProps = {
profile: ProfileButtonProps; profile: ProfileButtonProps;
accountStreak?: StreakResponse; accountStreak?: StreakResponse;
topicsDoneToday?: number;
finishedProjectsCount?: number; finishedProjectsCount?: number;
isLoading: boolean; isLoading: boolean;
}; };
function DashboardStats(props: DashboardStatsProps) { function DashboardStats(props: DashboardStatsProps) {
const { const { isLoading, profile } = props;
accountStreak,
topicsDoneToday = 0,
finishedProjectsCount = 0,
isLoading,
profile,
} = props;
return ( return (
<div className="container mb-3 flex flex-col gap-4 pb-2 pt-6 text-sm text-slate-400 sm:flex-row sm:items-center sm:justify-between"> <div className="container mb-3 flex flex-col gap-4 pt-6 pb-2 text-sm text-slate-400 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<PersonalProfileButton <PersonalProfileButton
isLoading={isLoading} isLoading={isLoading}
@@ -189,27 +181,7 @@ function DashboardStats(props: DashboardStatsProps) {
avatar={profile.avatar} avatar={profile.avatar}
/> />
<div className="hidden flex-wrap items-center gap-2 md:flex"> <div className="hidden flex-wrap items-center gap-2 md:flex">
<DashboardStatItem <UpgradeAccountButton isLoading={isLoading} />
icon={Zap}
iconClassName="text-yellow-500"
value={accountStreak?.count || 0}
label="day streak"
isLoading={isLoading}
/>
<DashboardStatItem
icon={ChartColumn}
iconClassName="text-green-500"
value={topicsDoneToday}
label="learnt today"
isLoading={isLoading}
/>
<DashboardStatItem
icon={FolderGit2}
iconClassName="text-blue-500"
value={finishedProjectsCount}
label="projects finished"
isLoading={isLoading}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -232,25 +204,6 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
const [personalDashboardDetails, setPersonalDashboardDetails] = const [personalDashboardDetails, setPersonalDashboardDetails] =
useState<UserDashboardResponse>(); useState<UserDashboardResponse>();
const [projectDetails, setProjectDetails] = useState<PageType[]>([]); const [projectDetails, setProjectDetails] = useState<PageType[]>([]);
const accountStreak = useStore($accountStreak);
const loadAccountStreak = async () => {
if (accountStreak) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<StreakResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-streak`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to load account streak');
return;
}
$accountStreak.set(response);
};
async function loadProgress() { async function loadProgress() {
const { response: progressList, error } = const { response: progressList, error } =
@@ -293,11 +246,9 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
} }
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([loadProgress(), loadAllProjectDetails()]).finally(() =>
loadProgress(), setIsLoading(false),
loadAllProjectDetails(), );
loadAccountStreak(),
]).finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -324,8 +275,6 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
return updatedAtB.getTime() - updatedAtA.getTime(); return updatedAtB.getTime() - updatedAtA.getTime();
}); });
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || [];
const customRoadmaps: UserProgress[] = ( const customRoadmaps: UserProgress[] = (
personalDashboardDetails?.progresses || [] personalDashboardDetails?.progresses || []
) )
@@ -376,18 +325,11 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
isLoading, isLoading,
}} }}
isLoading={isLoading} isLoading={isLoading}
accountStreak={accountStreak}
topicsDoneToday={personalDashboardDetails?.topicDoneToday}
finishedProjectsCount={
enrichedProjects?.filter((p) => p.submittedAt && p.repositoryUrl)
.length
}
/> />
<FavoriteRoadmaps <FavoriteRoadmaps
progress={learningRoadmapsToShow} progress={learningRoadmapsToShow}
customRoadmaps={customRoadmaps} customRoadmaps={customRoadmaps}
aiRoadmaps={aiGeneratedRoadmaps}
projects={enrichedProjects || []} projects={enrichedProjects || []}
isLoading={isLoading} isLoading={isLoading}
/> />

View File

@@ -2,9 +2,9 @@ import {
FolderKanban, FolderKanban,
MapIcon, MapIcon,
Plus, Plus,
Sparkle,
Eye, Eye,
EyeOff, SquareCheckBig EyeOff,
SquareCheckBig,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx'; import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx';
@@ -28,12 +28,11 @@ type FavoriteRoadmapsProps = {
title: string; title: string;
})[]; })[];
customRoadmaps: UserProgress[]; customRoadmaps: UserProgress[];
aiRoadmaps: AIRoadmapType[];
isLoading: boolean; isLoading: boolean;
}; };
export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) { export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) {
const { progress, isLoading, customRoadmaps, aiRoadmaps, projects } = props; const { progress, isLoading, customRoadmaps, projects } = props;
const [showCompleted, setShowCompleted] = useState(false); const [showCompleted, setShowCompleted] = useState(false);
const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false); const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false);
@@ -131,49 +130,7 @@ export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) {
allowFavorite={false} allowFavorite={false}
/> />
))} ))}
<CreateRoadmapButton /> <CreateRoadmapButton existingRoadmapCount={customRoadmaps.length} />
</HeroItemsGroup>
<HeroItemsGroup
icon={<Sparkle className="mr-1.5 h-[14px] w-[14px]" />}
isLoading={isLoading}
title="Your AI roadmaps"
isEmpty={!isLoading && aiRoadmaps.length === 0}
emptyTitle={
<>
No AI roadmaps found
<a
href="/ai-roadmaps"
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
>
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
Generate AI roadmap
</a>
</>
}
>
{aiRoadmaps.map((aiRoadmap) => (
<HeroRoadmap
key={aiRoadmap.id}
resourceId={aiRoadmap.id}
resourceType={'roadmap'}
resourceTitle={aiRoadmap.title}
url={`/ai-roadmaps/${aiRoadmap.slug}`}
percentageDone={0}
allowFavorite={false}
isTrackable={false}
/>
))}
<a
href="/ai-roadmaps"
className={
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300'
}
>
<Plus size={16} />
Generate New
</a>
</HeroItemsGroup> </HeroItemsGroup>
<HeroItemsGroup <HeroItemsGroup