1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-02 22:02:39 +02:00

feat: limit roadmap creation (#8889)

* feat: limit roadmap creation

* wip

* Remove new from billing

* Add upgrade message on roadmap

* Update upgrade account modal UI

* Make profile noindex if no roadmaps

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
Arik Chakma
2025-07-11 21:10:55 +06:00
committed by GitHub
parent 9accbd7067
commit bc71395f64
6 changed files with 276 additions and 237 deletions

View File

@@ -69,7 +69,7 @@ const sidebarLinks = [
href: '/account/billing',
title: 'Billing',
id: 'billing',
isNew: true,
isNew: false,
icon: {
glyph: 'credit-card',
classes: 'h-4 w-4',

View File

@@ -1,66 +1,47 @@
import {
Loader2,
Zap,
Infinity,
MessageSquare,
Sparkles,
Heart,
MapIcon,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { getUser } from '../../lib/jwt';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Modal } from '../Modal';
import type { LucideIcon } from 'lucide-react';
import { Archive, Crown, Loader2, Map, MessageCircleIcon, X, Zap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { getUser } from '../../lib/jwt';
import { httpPost } from '../../lib/query-http';
import {
billingDetailsOptions,
USER_SUBSCRIPTION_PLAN_PRICES,
type AllowedSubscriptionInterval,
} from '../../queries/billing';
import { cn } from '../../lib/classname';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { Modal } from '../Modal';
import { UpdatePlanConfirmation } from './UpdatePlanConfirmation';
// Define the perk type
type Perk = {
icon: LucideIcon;
title: string;
description: string;
highlight?: boolean;
};
// Define the perks array
const PREMIUM_PERKS: Perk[] = [
{
icon: Zap,
title: 'AI Course Generations',
description: 'No limits on the number of AI courses',
title: 'Unlimited Courses and Guides',
description: 'No limits on number of courses, guides, and quizzes',
highlight: true,
},
{
icon: MapIcon,
title: 'AI Roadmaps',
description: 'No limits on the number of AI roadmaps',
icon: MessageCircleIcon,
title: 'Extended Chat Limits',
description: 'Chat with AI Tutor and Roadmaps without limits',
},
{
icon: Infinity,
title: 'Extended Daily Limits',
description: 'Generate more content in a day',
icon: Archive,
title: 'Chat History',
description: 'Access your AI Tutor and roadmap chats later',
},
{
icon: MessageSquare,
title: 'Course Follow-ups',
description: 'Ask as many questions as you need',
},
{
icon: Sparkles,
title: 'Early Access to Features',
description: 'Be the first to try new tools and features',
},
{
icon: Heart,
title: 'Support Development',
description: 'Help us continue building roadmap.sh',
icon: Map,
title: 'Custom Roadmaps',
description: 'Create upto 100 custom roadmaps',
},
];
@@ -76,7 +57,6 @@ type CreateSubscriptionCheckoutSessionResponse = {
type UpgradeAccountModalProps = {
onClose: () => void;
success?: string;
cancel?: string;
};
@@ -85,7 +65,7 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
const { onClose, success, cancel } = props;
const [selectedPlan, setSelectedPlan] =
useState<AllowedSubscriptionInterval>('month');
useState<AllowedSubscriptionInterval>('year');
const [isUpdatingPlan, setIsUpdatingPlan] = useState(false);
const user = getUser();
@@ -132,11 +112,17 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
(plan) => plan.priceId === currentPlanPriceId,
);
const monthlyPlan = USER_SUBSCRIPTION_PLAN_PRICES.find(
(p) => p.interval === 'month',
);
const yearlyPlan = USER_SUBSCRIPTION_PLAN_PRICES.find(
(p) => p.interval === 'year',
);
useEffect(() => {
if (!currentPlan) {
return;
}
setSelectedPlan(currentPlan.interval);
}, [currentPlan]);
@@ -152,25 +138,44 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
return null;
}
const loader = isLoading ? (
<div className="absolute inset-0 flex h-[540px] w-full items-center justify-center bg-white">
<Loader2 className="h-6 w-6 animate-spin stroke-[3px] text-green-600" />
</div>
) : null;
if (isLoading) {
return (
<Modal
onClose={onClose}
bodyClassName="p-0 bg-white"
wrapperClassName="h-auto rounded-lg max-w-md w-full mx-4"
overlayClassName="items-center"
hasCloseButton={false}
>
<div className="flex h-48 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-yellow-600" />
</div>
</Modal>
);
}
const error = billingError;
const errorContent = error ? (
<div className="flex h-full w-full flex-col">
<p className="text-center text-red-400">
{error?.message ||
'An error occurred while loading the billing details.'}
</p>
</div>
) : null;
const calculateYearlyPrice = (monthlyPrice: number) => {
return (monthlyPrice * 12).toFixed(2);
};
if (billingError) {
return (
<Modal
onClose={onClose}
bodyClassName="p-6 bg-white"
wrapperClassName="h-auto rounded-lg max-w-md w-full mx-4"
overlayClassName="items-center"
hasCloseButton={true}
>
<div className="text-center">
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-red-100">
<X className="h-5 w-5 text-red-600" />
</div>
<h3 className="mb-2 text-lg font-medium text-gray-900">Error</h3>
<p className="text-sm text-gray-600">
{billingError?.message ||
'An error occurred while loading billing details.'}
</p>
</div>
</Modal>
);
}
if (isUpdatingPlan && selectedPlanDetails) {
return (
@@ -182,175 +187,162 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
);
}
const handlePlanSelect = (plan: typeof selectedPlanDetails) => {
if (!plan) return;
setSelectedPlan(plan.interval);
if (!currentPlanPriceId) {
const currentUrlPath = window.location.pathname;
const encodedCurrentUrlPath = encodeURIComponent(currentUrlPath);
const successPage = `/thank-you?next=${encodedCurrentUrlPath}&s=1`;
window?.fireEvent({
action: 'tutor_checkout',
category: 'ai_tutor',
label: 'Checkout Started',
});
createCheckoutSession(
{
priceId: plan.priceId,
success: success || successPage,
cancel: cancel || `${currentUrlPath}?s=0`,
},
{
onSuccess: () => {
window?.fireEvent({
action: `tutor_checkout_${plan.interval === 'month' ? 'mo' : 'an'}`,
category: 'ai_tutor',
label: `${plan.interval} Plan Checkout Started`,
});
},
},
);
return;
}
setIsUpdatingPlan(true);
};
return (
<Modal
onClose={onClose}
bodyClassName="p-4 sm:p-6 bg-white"
wrapperClassName="h-auto rounded-xl max-w-3xl w-full min-h-[540px] mx-2 sm:mx-4"
overlayClassName="items-start md:items-center"
hasCloseButton={true}
bodyClassName="p-0 bg-white"
wrapperClassName="h-auto rounded-lg max-w-md w-full mx-4"
overlayClassName="items-center"
hasCloseButton={false}
>
<div onClick={(e) => e.stopPropagation()}>
{errorContent}
<div className="relative">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 flex h-6 w-6 items-center justify-center rounded-full text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
{loader}
{!isLoading && !error && (
<div className="flex flex-col">
<div className="mb-6 text-left sm:mb-8">
<h2 className="text-xl font-bold text-black sm:text-2xl">
Unlock Premium Features
</h2>
<p className="mt-1 text-sm text-gray-600 sm:mt-2 sm:text-base">
Supercharge your learning experience with premium benefits
</p>
</div>
<div className="mb-6 grid grid-cols-1 gap-4 sm:mb-8 sm:gap-6 md:grid-cols-2">
{USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => {
const isCurrentPlanSelected =
currentPlan?.priceId === plan.priceId;
const isYearly = plan.interval === 'year';
return (
<div
key={plan.interval}
className={cn(
'flex flex-col space-y-3 rounded-lg bg-white p-4 sm:space-y-4 sm:p-6',
isYearly
? 'border-2 border-yellow-400'
: 'border border-gray-200',
)}
>
<div className="flex items-start justify-between">
<div>
<h4 className="text-sm font-semibold text-black sm:text-base">
{isYearly ? 'Yearly Payment' : 'Monthly Payment'}
</h4>
{isYearly && (
<span className="text-xs font-medium text-green-500 sm:text-sm">
(2 months free)
</span>
)}
</div>
{isYearly && (
<span className="rounded-full bg-yellow-400 px-1.5 py-0.5 text-xs font-semibold text-black sm:px-2 sm:py-1">
Most Popular
</span>
)}
</div>
<div className="flex items-baseline">
{isYearly && (
<p className="mr-2 text-xs text-gray-400 line-through sm:text-sm">
$
{calculateYearlyPrice(
USER_SUBSCRIPTION_PLAN_PRICES[0].amount,
)}
</p>
)}
<p
className={cn(
'text-2xl font-bold text-black sm:text-3xl',
{
'mt-0 md:mt-6': !isYearly,
},
)}
>
${plan.amount}{' '}
<span className="text-xs font-normal text-gray-500 sm:text-sm">
/ {isYearly ? 'year' : 'month'}
</span>
</p>
</div>
<div className="grow"></div>
<div>
<button
className={cn(
'flex min-h-9 w-full items-center justify-center rounded-md py-2 text-sm font-medium transition-colors focus:outline-hidden focus-visible:ring-2 focus-visible:ring-yellow-400 disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-11 sm:py-2.5 sm:text-base',
'bg-yellow-400 text-black hover:bg-yellow-500',
)}
disabled={
isCurrentPlanSelected || isCreatingCheckoutSession
}
onClick={() => {
setSelectedPlan(plan.interval);
if (!currentPlanPriceId) {
const currentUrlPath = window.location.pathname;
const encodedCurrentUrlPath =
encodeURIComponent(currentUrlPath);
const successPage = `/thank-you?next=${encodedCurrentUrlPath}&s=1`;
window?.fireEvent({
action: 'tutor_checkout',
category: 'ai_tutor',
label: 'Checkout Started',
});
createCheckoutSession(
{
priceId: plan.priceId,
success: success || successPage,
cancel: cancel || `${currentUrlPath}?s=0`,
},
{
onSuccess: () => {
window?.fireEvent({
action: `tutor_checkout_${plan.interval === 'month' ? 'mo' : 'an'}`,
category: 'ai_tutor',
label: `${plan.interval} Plan Checkout Started`,
});
},
},
);
return;
}
setIsUpdatingPlan(true);
}}
data-1p-ignore=""
data-form-type="other"
data-lpignore="true"
>
{isCreatingCheckoutSession &&
selectedPlan === plan.interval ? (
<Loader2 className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4" />
) : isCurrentPlanSelected ? (
'Current Plan'
) : (
'Select Plan'
)}
</button>
</div>
</div>
);
})}
</div>
{/* Benefits Section */}
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2">
{PREMIUM_PERKS.map((perk, index) => {
const Icon = perk.icon;
return (
<div
key={index}
className="flex items-start space-x-2 sm:space-x-3"
>
<Icon className="mt-0.5 h-4 w-4 text-yellow-500 sm:h-5 sm:w-5" />
<div>
<h4 className="text-sm font-medium text-black sm:text-base">
{perk.title}
</h4>
<p className="text-xs text-gray-600 sm:text-sm">
{perk.description}
</p>
</div>
</div>
);
})}
</div>
{/* Header */}
<div className="border-b border-gray-100 px-6 py-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full border-2 border-yellow-200 bg-yellow-50">
<Crown className="h-6 w-6 text-yellow-600" />
</div>
)}
<h1 className="text-xl font-semibold text-gray-900">
Upgrade to Premium
</h1>
<p className="mt-1 text-sm text-gray-600">
Unlock all features and supercharge your learning
</p>
</div>
{/* Features List */}
<div className="px-6 py-4">
<div className="space-y-3">
{PREMIUM_PERKS.map((perk, index) => {
const Icon = perk.icon;
return (
<div key={index} className="flex items-start space-x-3">
<Icon className="size-4 mt-1 top-[0.5px] relative text-gray-600" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 mb-0.5">
{perk.title}
</p>
<p className="text-xs text-gray-600">{perk.description}</p>
</div>
</div>
);
})}
</div>
</div>
{/* Buttons */}
<div className="border-t border-gray-100 px-6 py-4">
<div className="space-y-2">
{/* Yearly Button */}
{yearlyPlan && (
<button
onClick={() => handlePlanSelect(yearlyPlan)}
disabled={
isCreatingCheckoutSession || currentPlan?.interval === 'year'
}
className={`w-full h-11 rounded-lg px-4 text-sm font-medium transition-colors flex items-center justify-center disabled:opacity-50 ${
currentPlan?.interval === 'year'
? 'bg-yellow-300 text-gray-700 cursor-not-allowed'
: 'bg-yellow-400 text-black hover:bg-yellow-500'
}`}
>
{isCreatingCheckoutSession && selectedPlan === 'year' ? (
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
) : (
<div className="flex items-center justify-between text-left w-full">
<span>Yearly Plan - ${yearlyPlan.amount}/year</span>
{currentPlan?.interval === 'year' ? (
<span className="rounded bg-green-600 px-2 py-1 text-xs text-white">Current Plan</span>
) : (
monthlyPlan && (
<span className="rounded bg-yellow-600 px-2 py-1 text-xs text-white">
{Math.round((monthlyPlan.amount * 12 - yearlyPlan.amount) / monthlyPlan.amount)} months free
</span>
)
)}
</div>
)}
</button>
)}
{/* Monthly Button */}
{monthlyPlan && (
<button
onClick={() => handlePlanSelect(monthlyPlan)}
disabled={
isCreatingCheckoutSession || currentPlan?.interval === 'month'
}
className={`w-full h-11 rounded-lg border px-4 text-sm font-medium transition-colors flex items-center justify-center disabled:opacity-50 ${
currentPlan?.interval === 'month'
? 'border-yellow-300 bg-yellow-50 text-gray-700 cursor-not-allowed'
: 'border-yellow-400 bg-yellow-50 text-black hover:bg-yellow-100'
}`}
>
{isCreatingCheckoutSession && selectedPlan === 'month' ? (
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
) : (
<div className="flex items-center justify-between text-left w-full">
<span>Monthly Plan - ${monthlyPlan.amount}/month</span>
{currentPlan?.interval === 'month' && (
<span className="rounded bg-black px-2 py-1 text-xs text-white">Current Plan</span>
)}
</div>
)}
</button>
)}
</div>
{/* Trust indicators */}
<div className="mt-4 text-center">
<p className="text-xs text-gray-500">
By upgrading you agree to our terms and conditions
</p>
</div>
</div>
</div>
</Modal>
);

View File

@@ -23,11 +23,19 @@ import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
type PersonalRoadmapListType = {
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
onDelete: (roadmapId: string) => void;
onUpgrade: () => void;
setAllRoadmaps: Dispatch<SetStateAction<GetRoadmapListResponse>>;
maxLimit?: number;
};
export function PersonalRoadmapList(props: PersonalRoadmapListType) {
const { roadmaps: roadmapList, onDelete, setAllRoadmaps } = props;
const {
roadmaps: roadmapList,
onDelete,
setAllRoadmaps,
onUpgrade,
maxLimit = -1,
} = props;
const toast = useToast();
@@ -105,10 +113,19 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
return (
<div>
{shareSettingsModal}
<div className="mb-3 flex items-center justify-between">
<span className={'text-sm text-gray-400'}>
{roadmapList.length} custom roadmap(s)
</span>
<div className="mb-3 flex items-center text-sm text-gray-400">
{maxLimit === -1 && <>{roadmapList.length} custom roadmap(s)</>}
{maxLimit !== -1 && (
<>
{roadmapList.length} of {maxLimit} roadmaps{' '}
<button
onClick={onUpgrade}
className="ml-2 text-blue-600 underline underline-offset-2 hover:text-blue-700"
>
Need more? Upgrade
</button>
</>
)}
</div>
<ul className="flex flex-col divide-y rounded-md border">
{roadmapList.map((roadmap) => {
@@ -145,7 +162,7 @@ function CustomRoadmapItem(props: CustomRoadmapItemProps) {
key={roadmap._id!}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
<p className="mb-1.5 truncate text-base leading-tight font-medium text-black">
{roadmap.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
@@ -235,7 +252,7 @@ function VisibilityBadge(props: VisibilityLabelProps) {
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
className={`inline-flex items-center gap-1.5 text-xs font-normal whitespace-nowrap`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">

View File

@@ -9,6 +9,8 @@ import { PersonalRoadmapList } from './PersonalRoadmapList';
import { useToast } from '../../hooks/use-toast';
import { SharedRoadmapList } from './SharedRoadmapList';
import type { FriendshipStatus } from '../Befriend';
import { useIsPaidUser } from '../../queries/billing';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
export type FriendUserType = {
id: string;
@@ -37,11 +39,14 @@ const tabTypes: TabType[] = [
{ label: 'Shared by Friends', value: 'shared' },
];
const MAX_ROADMAP_LIMIT = 3;
export function RoadmapListPage() {
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [activeTab, setActiveTab] = useState<TabType['value']>('personal');
const [allRoadmaps, setAllRoadmaps] = useState<GetRoadmapListResponse>({
@@ -49,10 +54,12 @@ export function RoadmapListPage() {
sharedRoadmaps: [],
});
const { isPaidUser, isLoading: isLoadingIsPaidUser } = useIsPaidUser();
async function loadRoadmapList() {
setIsLoading(true);
const { response, error } = await httpGet<GetRoadmapListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list`
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list`,
);
if (error || !response) {
@@ -65,7 +72,7 @@ export function RoadmapListPage() {
response! || {
personalRoadmaps: [],
sharedRoadmaps: [],
}
},
);
}
@@ -80,20 +87,27 @@ export function RoadmapListPage() {
return null;
}
const totalRoadmaps = allRoadmaps.personalRoadmaps.length;
const hasCrossedLimit = !isPaidUser && totalRoadmaps >= MAX_ROADMAP_LIMIT;
return (
<div>
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
<div className="mb-6 flex flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div className="flex grow items-center gap-2">
{tabTypes.map((tab) => {
return (
<button
key={tab.value}
className={`relative flex w-full items-center justify-center whitespace-nowrap rounded-md border p-1 px-3 text-sm sm:w-auto ${
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm whitespace-nowrap sm:w-auto ${
activeTab === tab.value ? 'border-gray-400 bg-gray-200' : ''
} w-full sm:w-auto`}
onClick={() => setActiveTab(tab.value)}
>
@@ -104,7 +118,15 @@ export function RoadmapListPage() {
</div>
<button
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`}
onClick={() => setIsCreatingRoadmap(true)}
onClick={() => {
if (hasCrossedLimit) {
setShowUpgradeModal(true);
return;
}
setIsCreatingRoadmap(true);
}}
disabled={isLoadingIsPaidUser}
>
+ Create Roadmap
</button>
@@ -113,13 +135,17 @@ export function RoadmapListPage() {
<div className="mt-4">
{activeTab === 'personal' && (
<PersonalRoadmapList
maxLimit={
isPaidUser ? -1 : Math.max(MAX_ROADMAP_LIMIT, totalRoadmaps)
}
roadmaps={allRoadmaps?.personalRoadmaps}
setAllRoadmaps={setAllRoadmaps}
onUpgrade={() => setShowUpgradeModal(true)}
onDelete={(roadmapId) => {
setAllRoadmaps({
...allRoadmaps,
personalRoadmaps: allRoadmaps.personalRoadmaps.filter(
(r) => r._id !== roadmapId
(r) => r._id !== roadmapId,
),
});
}}

View File

@@ -80,6 +80,7 @@ export function UserLink(props: UserLinkProps) {
target="_blank"
href={href}
className="flex h-6 w-6 items-center justify-center rounded-md border"
rel="nofollow noopener noreferrer"
>
<Icon className="h-3.5 w-3.5 shrink-0 stroke-2" />
</a>

View File

@@ -27,12 +27,15 @@ if (error || !userDetails) {
const projectDetails = await getProjectList();
const origin = Astro.url.origin;
const ogImage = `${origin}/og/user/${username}`;
const hasAnyRoadmaps = (userDetails?.roadmaps || []).length > 0;
---
<BaseLayout
title={`${userDetails?.name || 'Unknown'} - Skill Profile at roadmap.sh`}
description='Check out my skill profile at roadmap.sh'
ogImageUrl={ogImage}
noIndex={!hasAnyRoadmaps}
>
{
!errorMessage && (