parent
082ccbcda7
commit
29d9cd63d8
13 changed files with 1040 additions and 89 deletions
@ -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, |
||||
<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"> |
||||
/ {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> |
||||
); |
||||
} |
@ -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> |
||||
|
||||
<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> |
||||
</> |
||||
); |
||||
} |
@ -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…
Reference in new issue