wip: billing page

feat/course
Arik Chakma 2 weeks ago
parent 082ccbcda7
commit 29d9cd63d8
  1. 5
      .env.example
  2. 26
      src/api/user.ts
  3. 10
      src/components/AccountSidebar.astro
  4. 1
      src/components/Authenticator/authenticator.ts
  5. 180
      src/components/Billing/BillingPage.tsx
  6. 95
      src/components/Billing/UpdatePlanConfirmation.tsx
  7. 75
      src/components/Billing/VerifyUpgrade.tsx
  8. 228
      src/components/CourseLanding/CourseFloatingSidebar.tsx
  9. 95
      src/components/CourseLanding/UpgradeAndEnroll.tsx
  10. 290
      src/components/CourseLanding/UpgradePlanModal.tsx
  11. 4
      src/env.d.ts
  12. 16
      src/pages/account/billing.astro
  13. 104
      src/queries/billing.ts

@ -1,4 +1,7 @@
PUBLIC_API_URL=https://api.roadmap.sh
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
PUBLIC_COURSE_APP_URL=http://localhost:5173
PUBLIC_COURSE_APP_URL=http://localhost:5173
PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID=
PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID=

@ -22,6 +22,20 @@ export type AllowedProfileVisibility =
export const allowedOnboardingStatus = ['done', 'pending', 'ignored'] as const;
export type AllowedOnboardingStatus = (typeof allowedOnboardingStatus)[number];
export const allowedSubscriptionStatus = [
'active',
'canceled',
'incomplete',
'incomplete_expired',
'past_due',
'paused',
'trialing',
'unpaid',
'none',
] as const;
export type AllowedSubscriptionStatus =
(typeof allowedSubscriptionStatus)[number];
export interface UserDocument {
_id?: string;
name: string;
@ -73,6 +87,18 @@ export interface UserDocument {
inviteTeam: AllowedOnboardingStatus;
};
customerId: string;
subscription?: {
id: string;
planId: string;
priceId: string;
interval: string;
status: AllowedSubscriptionStatus;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
};
createdAt: string;
updatedAt: string;
}

@ -64,6 +64,16 @@ const sidebarLinks = [
classes: 'h-4 w-4',
},
},
{
href: '/account/billing',
title: 'Billing',
id: 'billing',
isNew: true,
icon: {
glyph: 'badge',
classes: 'h-4 w-4',
},
},
{
href: '/account/settings',
title: 'Settings',

@ -32,6 +32,7 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
// Prepares the UI for the user who is logged in
function handleGuest() {
const authenticatedRoutes = [
'/account/billing',
'/account/update-profile',
'/account/notification',
'/account/update-password',

@ -0,0 +1,180 @@
import { useEffect, useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
billingDetailsOptions,
USER_SUBSCRIPTION_PLANS,
} from '../../queries/billing';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { UpgradePlanModal } from '../CourseLanding/UpgradePlanModal';
import { getUrlParams } from '../../lib/browser';
import { VerifyUpgrade } from './VerifyUpgrade';
export type CreateCustomerPortalBody = {};
export type CreateCustomerPortalResponse = {
url: string;
};
export function BillingPage() {
const toast = useToast();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showVerifyUpgradeModal, setShowVerifyUpgradeModal] = useState(false);
const { data: billingDetails, isPending } = useQuery(
billingDetailsOptions(),
queryClient,
);
const { mutate: createCustomerPortal, isPending: isCreatingCustomerPortal } =
useMutation(
{
mutationFn: (body: CreateCustomerPortalBody) => {
return httpPost<CreateCustomerPortalResponse>(
'/v1-create-customer-portal',
body,
);
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to Create Customer Portal');
},
},
queryClient,
);
useEffect(() => {
if (isPending) {
return;
}
pageProgressMessage.set('');
const shouldVerifyUpgrade = getUrlParams()?.s === '1';
if (shouldVerifyUpgrade) {
setShowVerifyUpgradeModal(true);
}
}, [isPending]);
if (isPending || !billingDetails) {
return null;
}
const selectedPlanDetails = USER_SUBSCRIPTION_PLANS.find(
(plan) => plan.planId === (billingDetails?.planId || 'free'),
);
const shouldHideDeleteButton =
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd;
const priceDetails =
(billingDetails?.interval || 'month') === 'month'
? selectedPlanDetails?.prices.month
: selectedPlanDetails?.prices.year;
const formattedNextBillDate = new Date(
billingDetails?.currentPeriodEnd || '',
).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<>
{showUpgradeModal && (
<UpgradePlanModal
onClose={() => {
setShowUpgradeModal(false);
}}
success="/account/billing?s=1"
cancel="/account/billing"
/>
)}
{showVerifyUpgradeModal && <VerifyUpgrade />}
{billingDetails?.status === 'none' && !isPending && (
<div className="flex h-full w-full flex-col">
<p className="text-gray-800">
You are using free plan,&nbsp;
<button
className="text-black underline underline-offset-2 hover:text-gray-800"
onClick={() => {
setShowUpgradeModal(true);
}}
>
upgrade account.
</button>
</p>
</div>
)}
{billingDetails?.status !== 'none' && !isPending && (
<>
{billingDetails?.status === 'past_due' && (
<div className="mb-4 rounded-md border border-red-300 bg-red-50 p-2 text-red-500">
Your subscription is past due. Please update your payment
information from the Stripe Portal.
</div>
)}
<div className="flex items-start gap-10">
<div className="flex flex-col">
<span className="text-gray-500">Plan</span>
<span className="mt-1 text-lg font-medium capitalize text-black">
{selectedPlanDetails?.name}
</span>
</div>
<div className="flex grow items-center justify-between gap-2">
<div className="flex flex-col">
<span className="text-gray-500">Payment</span>
<span className="mt-1 text-lg font-medium capitalize text-black">
${priceDetails!.amount / 100}
<span className="text-sm font-normal text-gray-500">
&nbsp;/ {priceDetails!.interval}
</span>
</span>
</div>
{!shouldHideDeleteButton && (
<button
className="inline-flex items-center gap-1 self-end text-xs underline underline-offset-1 hover:text-gray-600"
onClick={() => {
setShowUpgradeModal(true);
}}
>
Update Plan
</button>
)}
</div>
</div>
<div className="mt-4 flex justify-between gap-2">
<div className="flex flex-col">
<span className="text-gray-500">
{billingDetails?.cancelAtPeriodEnd ? 'Expires On' : 'Renews On'}
</span>
<span className="mt-1 text-lg font-medium capitalize text-black">
{formattedNextBillDate}
</span>
</div>
<button
className="inline-flex self-end text-xs underline underline-offset-1 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => {
createCustomerPortal({});
}}
disabled={isCreatingCustomerPortal}
>
Manage my Subscription
</button>
</div>
</>
)}
</>
);
}

@ -0,0 +1,95 @@
import { useMutation } from '@tanstack/react-query';
import type { USER_SUBSCRIPTION_PLANS } from '../../queries/billing';
import { Modal } from '../Modal';
import { queryClient } from '../../stores/query-client';
import { useToast } from '../../hooks/use-toast';
import { VerifyUpgrade } from './VerifyUpgrade';
import type { IntervalType } from '../CourseLanding/UpgradePlanModal';
import { Loader2Icon } from 'lucide-react';
import { httpPost } from '../../lib/query-http';
type UpdatePlanBody = {
priceId: string;
};
type UpdatePlanResponse = {
status: 'ok';
};
type UpdatePlanConfirmationProps = {
planDetails: (typeof USER_SUBSCRIPTION_PLANS)[number];
interval: IntervalType;
onClose: () => void;
onCancel: () => void;
};
export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) {
const { planDetails, onClose, onCancel, interval } = props;
const toast = useToast();
const {
mutate: updatePlan,
isPending,
status,
} = useMutation(
{
mutationFn: (body: UpdatePlanBody) => {
return httpPost<UpdatePlanResponse>('/v1-update-plan', body);
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to Create Customer Portal');
},
},
queryClient,
);
if (!planDetails) {
return null;
}
const selectedPrice = planDetails.prices[interval];
if (status === 'success') {
return <VerifyUpgrade newPriceId={selectedPrice.id} />;
}
return (
<Modal
onClose={isPending ? () => {} : onClose}
bodyClassName="rounded-xl bg-white p-4"
>
<h3 className="text-xl font-bold">Subscription Update</h3>
<p className="mt-2 text-balance text-gray-500">
Your plan will be updated to the{' '}
<b className="text-gray-600">{planDetails.name}</b> plan, and will be
charged{' '}
<b className="text-gray-600">
${selectedPrice.amount / 100} {selectedPrice.interval}
</b>
.
</p>
<div className="mt-6 grid grid-cols-2 gap-2">
<button
className="rounded-md border border-gray-300 py-2 text-sm font-semibold hover:opacity-80 disabled:opacity-50"
onClick={onCancel}
disabled={isPending}
>
Cancel
</button>
<button
className="flex items-center justify-center rounded-md border border-gray-800 bg-black py-2 text-sm font-semibold text-white hover:opacity-80 disabled:opacity-50"
disabled={isPending}
onClick={() => {
updatePlan({ priceId: selectedPrice.id });
}}
>
{isPending && (
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
)}
{!isPending && 'Confirm'}
</button>
</div>
</Modal>
);
}

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { billingDetailsOptions } from '../../queries/billing';
import { queryClient } from '../../stores/query-client';
import { Modal } from '../Modal';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { deleteUrlParam } from '../../lib/browser';
type VerifyUpgradeProps = {
newPriceId?: string;
};
export function VerifyUpgrade(props: VerifyUpgradeProps) {
const { newPriceId } = props;
const { data: userBillingDetails, isFetching } = useQuery(
{
...billingDetailsOptions(),
refetchInterval: 1000,
},
queryClient,
);
useEffect(() => {
if (!userBillingDetails) {
return;
}
if (
userBillingDetails.status === 'active' &&
(newPriceId ? userBillingDetails.priceId === newPriceId : true)
) {
deleteUrlParam('s');
window.location.reload();
}
}, [userBillingDetails]);
return (
<Modal
// it's an unique modal, so we don't need to close it
// user can close it by refreshing the page
onClose={() => {}}
bodyClassName="rounded-xl bg-white p-6"
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-xl font-bold">Subscription Activated</h3>
{isFetching && (
<div className="flex animate-pulse items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5px] text-gray-500" />
<span className="text-gray-500">Refreshing</span>
</div>
)}
</div>
<p className="mt-2 text-balance text-gray-500">
Your subscription has been activated successfully.
</p>
<p className="mt-4 text-balance text-gray-500">
It might take a few minutes for the changes to reflect. We will{' '}
<b className="text-gray-600">reload</b> the page for you.
</p>
<p className="mt-4 text-gray-500">
If it takes longer than expected, please{' '}
<a className="text-blue-500 underline underline-offset-2 hover:text-blue-300">
contact us
</a>
.
</p>
</Modal>
);
}

@ -1,4 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQueries, useQuery } from '@tanstack/react-query';
import type { CourseDetailsResponse } from '../../api/course';
import { cn } from '../../lib/classname';
import { isLoggedIn } from '../../lib/jwt';
@ -9,6 +9,10 @@ import { useEffect, useState } from 'react';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { CheckCircle2Icon, Loader2Icon, LockIcon } from 'lucide-react';
import { getUrlParams } from '../../lib/browser';
import { billingDetailsOptions } from '../../queries/billing';
import { UpgradePlanModal } from './UpgradePlanModal';
import { UpgradeAndEnroll } from './UpgradeAndEnroll';
type CourseFloatingSidebarProps = {
isSticky: boolean;
@ -23,10 +27,33 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const { data: courseProgress, status } = useQuery(
const [showUpgradePlanModal, setShowUpgradePlanModal] = useState(false);
const [showUpgradeAndEnrollModal, setShowUpgradeAndEnrollModal] =
useState(false);
const {
courseProgress,
billingDetails,
pending: isPending,
} = useQueries(
{
...courseProgressOptions(slug),
enabled: !!isLoggedIn(),
queries: [
{
...courseProgressOptions(slug),
enabled: !!isLoggedIn(),
},
{
...billingDetailsOptions(),
enabled: !!isLoggedIn(),
},
],
combine(results) {
return {
courseProgress: results[0].data,
billingDetails: results[1].data,
pending: results.some((result) => result.isPending),
};
},
},
queryClient,
);
@ -47,18 +74,25 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
queryClient,
);
const hasEnrolled = courseProgress?.startedAt ? true : false;
const isPaidUser = billingDetails?.status === 'active';
useEffect(() => {
if (!isLoggedIn()) {
setIsLoading(false);
return;
}
if (status === 'pending') {
if (isPending) {
return;
}
setIsLoading(false);
}, [courseProgress, status]);
const shouldAutoEnroll = getUrlParams()?.e === '1';
if (!hasEnrolled && shouldAutoEnroll) {
setShowUpgradeAndEnrollModal(true);
}
}, [courseProgress, isPending]);
const whatYouGet = [
'Full access to all the courses',
@ -68,93 +102,111 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) {
'Challenges / Quizes',
];
const hasEnrolled = courseProgress?.startedAt ? true : false;
return (
<div
className={cn(
'sticky top-8 -translate-y-1/2 overflow-hidden rounded-lg border bg-white shadow-sm transition-transform',
isSticky && '-translate-y-0',
)}
>
<figure>
<img
src="https://images.unsplash.com/photo-1732200584655-3511db5c24e2?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw5fHx8ZW58MHx8fHx8"
alt="SQL 101"
className="aspect-video w-full object-cover"
<>
{showUpgradePlanModal && (
<UpgradePlanModal
onClose={() => setShowUpgradePlanModal(false)}
success={`/learn/${slug}?e=1`}
cancel={`/learn/${slug}`}
/>
</figure>
<div className="p-2">
<button
className={cn(
'relative flex w-full items-center justify-between gap-1 overflow-hidden rounded-lg bg-gradient-to-r from-purple-500 to-purple-700 p-2 px-3 text-slate-50 disabled:cursor-not-allowed disabled:opacity-50',
(hasEnrolled || isEnrolling) && 'justify-center',
)}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!hasEnrolled) {
enroll();
return;
}
window.location.href = courseUrl;
}}
disabled={isLoading || isEnrolling}
>
{!isEnrolling && (
<>
{hasEnrolled ? (
<>
<span>Resume Learning</span>
</>
) : (
<>
<span>Enroll now</span>
<span>5$ / month</span>
</>
)}
</>
)}
{isEnrolling && (
<>
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>Enrolling...</span>
</>
)}
{isLoading && (
<div className="striped-loader-darker absolute inset-0 z-10 h-full w-full bg-purple-500" />
)}
</button>
</div>
)}
<div className="border-b p-2 pb-4">
<Certificate
isLoading={isLoading}
hasEnrolled={hasEnrolled}
isCourseComplete={courseProgress?.completedAt ? true : false}
courseSlug={slug}
/>
</div>
{showUpgradeAndEnrollModal && <UpgradeAndEnroll courseSlug={slug} />}
<div className="p-2">
<h4 className="text-lg font-medium">What you get</h4>
<ul
role="list"
className="mt-2 flex list-disc flex-col gap-1 pl-4 text-sm text-gray-700 marker:text-gray-400"
>
{whatYouGet.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
<div
className={cn(
'sticky top-8 -translate-y-1/2 overflow-hidden rounded-lg border bg-white shadow-sm transition-transform',
isSticky && '-translate-y-0',
)}
>
<figure>
<img
src="https://images.unsplash.com/photo-1732200584655-3511db5c24e2?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw5fHx8ZW58MHx8fHx8"
alt="SQL 101"
className="aspect-video w-full object-cover"
/>
</figure>
<div className="p-2">
<button
className={cn(
'relative flex min-h-10 w-full items-center justify-between gap-1 overflow-hidden rounded-lg bg-gradient-to-r from-purple-500 to-purple-700 p-2 px-3 text-slate-50 disabled:cursor-not-allowed disabled:opacity-50',
(hasEnrolled || isEnrolling || isPaidUser) && 'justify-center',
)}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!slug) {
toast.error('Course slug not found');
return;
}
if (hasEnrolled && isPaidUser) {
window.location.href = courseUrl;
return;
}
if (isPaidUser) {
enroll();
return;
}
setShowUpgradePlanModal(true);
}}
disabled={isLoading || isEnrolling}
>
{!isEnrolling && (
<>
{hasEnrolled && isPaidUser && <span>Resume Learning</span>}
{!hasEnrolled && !isPaidUser && (
<>
<span>Enroll now</span>
<span>5$ / month</span>
</>
)}
{!hasEnrolled && isPaidUser && <span>Enroll now</span>}
</>
)}
{isEnrolling && (
<>
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
<span>Enrolling...</span>
</>
)}
{isLoading && (
<div className="striped-loader-darker absolute inset-0 z-10 h-full w-full bg-purple-500" />
)}
</button>
</div>
<div className="border-b p-2 pb-4">
<Certificate
isLoading={isLoading}
hasEnrolled={hasEnrolled}
isCourseComplete={courseProgress?.completedAt ? true : false}
courseSlug={slug}
/>
</div>
<div className="p-2">
<h4 className="text-lg font-medium">What you get</h4>
<ul
role="list"
className="mt-2 flex list-disc flex-col gap-1 pl-4 text-sm text-gray-700 marker:text-gray-400"
>
{whatYouGet.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
</div>
</div>
</>
);
}

@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { billingDetailsOptions } from '../../queries/billing';
import { queryClient } from '../../stores/query-client';
import { Modal } from '../Modal';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
type UpgradeAndEnrollProps = {
courseSlug: string;
};
export function UpgradeAndEnroll(props: UpgradeAndEnrollProps) {
const { courseSlug } = props;
const { data: userBillingDetails, isFetching } = useQuery(
{
...billingDetailsOptions(),
refetchInterval: 1000,
},
queryClient,
);
const toast = useToast();
const [isEnrolled, setIsEnrolled] = useState(false);
const { mutate: enroll, isPending: isEnrolling } = useMutation(
{
mutationFn: () => {
return httpPost(`/v1-enroll-course/${courseSlug}`, {});
},
onSuccess: () => {
setIsEnrolled(true);
const courseUrl = `${import.meta.env.PUBLIC_COURSE_APP_URL}/${courseSlug}`;
window.location.href = courseUrl;
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to enroll');
},
onMutate: () => {
queryClient.cancelQueries(billingDetailsOptions());
},
},
queryClient,
);
useEffect(() => {
if (!userBillingDetails || isEnrolling) {
return;
}
if (userBillingDetails.status === 'active' && !isEnrolled) {
enroll();
}
}, [userBillingDetails, isEnrolling, isEnrolled]);
return (
<Modal
// it's an unique modal, so we don't need to close it
// user can close it by refreshing the page
onClose={() => {}}
bodyClassName="rounded-xl bg-white p-6"
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-xl font-bold">Activated & Enrolling</h3>
{isFetching && (
<div className="flex animate-pulse items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5px] text-gray-500" />
<span className="text-gray-500">Refreshing</span>
</div>
)}
</div>
<p className="mt-2 text-balance text-gray-500">
Your subscription has been activated successfully, we are enrolling you
to the course.
</p>
<p className="mt-4 text-balance text-gray-500">
It might take a few minutes for the changes to reflect. We will{' '}
<b className="text-gray-600">reload</b> the page for you.
</p>
<p className="mt-4 text-gray-500">
If it takes longer than expected, please{' '}
<a className="text-blue-500 underline underline-offset-2 hover:text-blue-300">
contact us
</a>
.
</p>
</Modal>
);
}

@ -0,0 +1,290 @@
import { useEffect, useState } from 'react';
import {
billingDetailsOptions,
USER_SUBSCRIPTION_PLANS,
} from '../../queries/billing';
import { Modal } from '../Modal';
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { cn } from '../../lib/classname';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { Loader2Icon } from 'lucide-react';
import { UpdatePlanConfirmation } from '../Billing/UpdatePlanConfirmation';
import type {
CreateCustomerPortalBody,
CreateCustomerPortalResponse,
} from '../Billing/BillingPage';
type CreateCheckoutSessionBody = {
priceId: string;
success?: string;
cancel?: string;
};
type CreateCheckoutSessionResponse = {
checkoutUrl: string;
};
export type IntervalType = 'month' | 'year';
type UpgradePlanModalProps = {
onClose: () => void;
success?: string;
cancel?: string;
};
export function UpgradePlanModal(props: UpgradePlanModalProps) {
const { onClose, success, cancel } = props;
const { data: billingDetails } = useQuery(
billingDetailsOptions(),
queryClient,
);
const toast = useToast();
const [interval, setInterval] = useState<IntervalType>('month');
const [planId, setPlanId] = useState<string>('free');
const [isUpdatingPlan, setIsUpdatingPlan] = useState(false);
const { mutate: createCheckoutSession, isPending } = useMutation(
{
mutationFn: (body: CreateCheckoutSessionBody) => {
return httpPost<CreateCheckoutSessionResponse>(
'/v1-create-checkout-session',
body,
);
},
onSuccess: (data) => {
window.location.href = data.checkoutUrl;
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to create checkout session');
},
},
queryClient,
);
const { mutate: createCustomerPortal, isPending: isCreatingCustomerPortal } =
useMutation(
{
mutationFn: (body: CreateCustomerPortalBody) => {
return httpPost<CreateCustomerPortalResponse>(
'/v1-create-customer-portal',
body,
);
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to Create Customer Portal');
},
},
queryClient,
);
useEffect(() => {
if (!billingDetails) {
return;
}
setInterval((billingDetails.interval as IntervalType) || 'month');
setPlanId(billingDetails.planId || 'free');
}, [billingDetails]);
const isCurrentPlanSelected =
billingDetails?.planId === planId &&
(interval === billingDetails.interval || billingDetails?.planId === 'free');
const selectedPlan = USER_SUBSCRIPTION_PLANS.find(
(plan) => plan.planId === planId,
);
if (isUpdatingPlan && selectedPlan) {
return (
<UpdatePlanConfirmation
planDetails={selectedPlan}
interval={interval}
onClose={() => {
setIsUpdatingPlan(false);
}}
onCancel={() => {
setIsUpdatingPlan(false);
}}
/>
);
}
const showCancelSubscription =
billingDetails?.planId !== 'free' && planId === 'free';
return (
<>
<Modal
onClose={onClose}
wrapperClassName="max-w-2xl"
bodyClassName="overflow-hidden"
>
<div className="grid grid-cols-2">
<div className="p-4">
<h2 className="font-medium">Upgrade Plan</h2>
<p className="mt-1 text-balance text-sm text-gray-500">
Upgrade your plan to unlock more features, courses, and more.
</p>
<div className="mt-6 flex flex-col gap-1">
{USER_SUBSCRIPTION_PLANS.map((plan) => {
const isSelectedPlan = plan.planId === planId;
const price = plan.prices[interval];
const isCurrentPlan = billingDetails?.planId === plan.planId;
return (
<button
key={plan.planId}
className={cn(
'flex items-center justify-between gap-2 rounded-lg border p-2',
isSelectedPlan && 'border-purple-500',
)}
onClick={() => {
setPlanId(plan.planId);
}}
>
<div className="flex items-center gap-3">
<div
className={cn(
'size-2 rounded-full bg-gray-300',
isSelectedPlan && 'bg-purple-500',
)}
></div>
<h4>{plan.name}</h4>
{isCurrentPlan && (
<span className="rounded-full bg-purple-500 px-1.5 py-0.5 text-xs leading-none text-white">
Current
</span>
)}
</div>
<span className="text-sm">
<span className="font-medium">
${price?.amount / 100}
</span>
&nbsp;
<span className="text-gray-500">/ {interval}</span>
</span>
</button>
);
})}
</div>
<div className="mt-16">
{!showCancelSubscription && (
<button
className={cn(
'mb-2 rounded-lg border border-dashed p-2 text-left',
interval === 'year'
? 'border-purple-500 bg-purple-100/40'
: 'border-gray-300',
)}
onClick={() => {
setInterval(interval === 'month' ? 'year' : 'month');
}}
>
<h3 className="font-medium">Enjoy 20% Off</h3>
<p className="mt-1 text-balance text-sm text-gray-500">
Get 20% off when you upgrade to a yearly plan.
</p>
</button>
)}
{showCancelSubscription && (
<button
className="mb-2 rounded-lg border border-dashed p-2 text-left"
onClick={() => {
createCustomerPortal({});
}}
>
<h3 className="font-medium">Cancel Subscription</h3>
<p className="mt-1 text-balance text-sm text-gray-500">
To downgrade to the free plan, you need to cancel
your current subscription.
</p>
</button>
)}
<button
className="flex min-h-10 w-full items-center justify-center rounded-lg bg-purple-500 p-2 text-white disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:opacity-70"
disabled={isCurrentPlanSelected || isPending}
onClick={() => {
const priceId = selectedPlan?.prices[interval].id;
if (!priceId) {
toast.error('Price id is missing');
return;
}
// if downgrading from paid plan to free plan
// then redirect to customer portal to cancel the subscription
if (planId === 'free') {
createCustomerPortal({});
return;
}
// if user is already on a paid plan
// then show a confirmation modal to update the plan
// instead of creating a new checkout session
if (billingDetails?.planId !== 'free') {
setIsUpdatingPlan(true);
return;
}
createCheckoutSession({
priceId,
success,
cancel,
});
}}
>
{(isPending || isCreatingCustomerPortal) && (
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
)}
{!isPending &&
!isCreatingCustomerPortal &&
!showCancelSubscription && (
<>
{isCurrentPlanSelected
? 'Current Plan'
: `Select ${selectedPlan?.name}`}
</>
)}
{!isPending &&
!isCreatingCustomerPortal &&
showCancelSubscription && <>Cancel Subscription</>}
</button>
</div>
</div>
<div className="border-l bg-gray-100/40 p-4">
<h3 className="font-medium">{selectedPlan?.name} Features</h3>
<div className="mt-4 space-y-1">
{USER_SUBSCRIPTION_PLANS.find(
(plan) => plan.planId === planId,
)?.features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 text-gray-700"
>
<feature.icon className="size-4" />
<p>{feature.label}</p>
</div>
))}
</div>
</div>
</div>
</Modal>
</>
);
}

4
src/env.d.ts vendored

@ -8,6 +8,10 @@ interface ImportMetaEnv {
PUBLIC_AVATAR_BASE_URL: string;
PUBLIC_EDITOR_APP_URL: string;
PUBLIC_COURSE_APP_URL: string;
PUBLIC_STRIPE_INDIVIDUAL_PLAN_ID: string;
PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID: string;
PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID: string;
}
interface ImportMeta {

@ -0,0 +1,16 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { BillingPage } from '../../components/Billing/BillingPage';
---
<AccountLayout
title='Billing'
description=''
noIndex={true}
initialLoadingMessage={'Loading billing information'}
>
<AccountSidebar activePageId='billing' activePageTitle='Billing'>
<BillingPage client:load />
</AccountSidebar>
</AccountLayout>

@ -0,0 +1,104 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import {
BookCopyIcon,
BotIcon,
CodeIcon,
FenceIcon,
ShieldCheckIcon,
SparkleIcon,
SparklesIcon,
SwordsIcon,
} from 'lucide-react';
import type { AllowedSubscriptionStatus } from '../api/user';
type BillingDetailsResponse = {
status: AllowedSubscriptionStatus;
planId?: string;
priceId?: string;
interval?: string;
currentPeriodEnd?: Date;
cancelAtPeriodEnd?: boolean;
};
export function billingDetailsOptions() {
return queryOptions({
queryKey: ['billing-details'],
queryFn: async () => {
return httpGet<BillingDetailsResponse>('/v1-billing-details');
},
enabled: !!isLoggedIn(),
});
}
export const USER_SUBSCRIPTION_PLANS = [
{
name: 'Free',
planId: 'free',
prices: {
month: {
id: 'free',
amount: 0,
interval: 'month',
},
year: {
id: 'free',
amount: 0,
interval: 'year',
},
},
features: [
{
label: 'Access to all free courses',
icon: SparkleIcon,
},
{
label: 'Access to free course materials',
icon: FenceIcon,
},
],
},
{
name: 'Pro',
planId: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_PLAN_ID,
prices: {
month: {
id: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID,
amount: 599,
interval: 'month',
},
year: {
id: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID,
amount: 5999,
interval: 'year',
},
},
features: [
{
label: 'All Free features',
icon: SparklesIcon,
},
{
label: 'Full access to all the courses',
icon: BookCopyIcon,
},
{
label: 'Personalized access using AI',
icon: BotIcon,
},
{
label: 'Certificate of Completion',
icon: ShieldCheckIcon,
},
{
label: 'Playground for live-coding',
icon: CodeIcon,
},
{
label: 'Challenges / Quizes',
icon: SwordsIcon,
},
],
},
] as const;
Loading…
Cancel
Save