parent
758642764c
commit
a749f36df1
5 changed files with 258 additions and 490 deletions
@ -0,0 +1,130 @@ |
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { useToast } from '../../hooks/use-toast'; |
||||||
|
import { httpPost } from '../../lib/query-http'; |
||||||
|
import { courseProgressOptions } from '../../queries/course-progress'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { showLoginPopup } from '../../lib/popup'; |
||||||
|
import { Loader2Icon } from 'lucide-react'; |
||||||
|
import { coursePriceOptions } from '../../queries/billing'; |
||||||
|
|
||||||
|
type CreateCheckoutSessionBody = { |
||||||
|
courseId: string; |
||||||
|
success?: string; |
||||||
|
cancel?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type CreateCheckoutSessionResponse = { |
||||||
|
checkoutUrl: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type EnrollButtonProps = { |
||||||
|
courseId: string; |
||||||
|
courseSlug: string; |
||||||
|
isLoading: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export function EnrollButton(props: EnrollButtonProps) { |
||||||
|
const { courseId, courseSlug, isLoading } = props; |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
|
||||||
|
const { data: courseProgress, isLoading: isCourseProgressLoading } = useQuery( |
||||||
|
{ |
||||||
|
...courseProgressOptions(courseSlug), |
||||||
|
enabled: !!isLoggedIn(), |
||||||
|
}, |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const { data: coursePricing } = useQuery( |
||||||
|
coursePriceOptions({ courseSlug }), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const { |
||||||
|
mutate: createCheckoutSession, |
||||||
|
isPending: isCreatingCheckoutSession, |
||||||
|
} = 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 hasEnrolled = !!courseProgress?.startedAt; |
||||||
|
|
||||||
|
return ( |
||||||
|
<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 || isCreatingCheckoutSession) && 'justify-center', |
||||||
|
)} |
||||||
|
onClick={() => { |
||||||
|
if (isCourseProgressLoading || isCreatingCheckoutSession) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!isLoggedIn()) { |
||||||
|
showLoginPopup(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!courseSlug) { |
||||||
|
toast.error('Course slug not found'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (hasEnrolled) { |
||||||
|
const courseUrl = `${import.meta.env.PUBLIC_COURSE_APP_URL}/${courseSlug}`; |
||||||
|
window.location.href = courseUrl; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
createCheckoutSession({ |
||||||
|
courseId, |
||||||
|
success: `/learn/${courseSlug}?e=1`, |
||||||
|
cancel: `/learn/${courseSlug}`, |
||||||
|
}); |
||||||
|
}} |
||||||
|
disabled={isCreatingCheckoutSession || isLoading} |
||||||
|
> |
||||||
|
{hasEnrolled && !isCreatingCheckoutSession && ( |
||||||
|
<span>Resume Learning</span> |
||||||
|
)} |
||||||
|
|
||||||
|
{!hasEnrolled && !isCreatingCheckoutSession && ( |
||||||
|
<> |
||||||
|
<span>Enroll now</span> |
||||||
|
<span> |
||||||
|
$ |
||||||
|
{coursePricing?.isEligibleForDiscount |
||||||
|
? coursePricing?.regionalPrice |
||||||
|
: coursePricing?.fullPrice} |
||||||
|
</span> |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
{isCreatingCheckoutSession && ( |
||||||
|
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" /> |
||||||
|
)} |
||||||
|
|
||||||
|
{isLoading && ( |
||||||
|
<div className="striped-loader-darker absolute inset-0 z-10 h-full w-full bg-purple-500" /> |
||||||
|
)} |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
@ -1,290 +0,0 @@ |
|||||||
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,75 @@ |
|||||||
|
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'; |
||||||
|
import { courseProgressOptions } from '../../queries/course-progress'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { deleteUrlParam } from '../../lib/browser'; |
||||||
|
|
||||||
|
type VerifyEnrollmentProps = { |
||||||
|
courseSlug: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function VerifyEnrollment(props: VerifyEnrollmentProps) { |
||||||
|
const { courseSlug } = props; |
||||||
|
|
||||||
|
const { data: courseProgress, isFetching } = useQuery( |
||||||
|
{ |
||||||
|
...courseProgressOptions(courseSlug), |
||||||
|
enabled: !!isLoggedIn(), |
||||||
|
refetchInterval: 1000, |
||||||
|
}, |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!courseProgress) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (courseProgress.startedAt) { |
||||||
|
deleteUrlParam('e'); |
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
}, [courseProgress]); |
||||||
|
|
||||||
|
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">Enrolling</h3> |
||||||
|
|
||||||
|
<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"> |
||||||
|
We are enrolling you to the course. Please wait. |
||||||
|
</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> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue