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:
@@ -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',
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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">
|
||||
|
@@ -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,
|
||||
),
|
||||
});
|
||||
}}
|
||||
|
@@ -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>
|
||||
|
@@ -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 && (
|
||||
|
Reference in New Issue
Block a user