From a749f36df14faf41f73d16e80635cb950b8d6351 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Fri, 10 Jan 2025 02:25:26 +0600 Subject: [PATCH] wip: course enroll --- .../CourseLanding/CourseFloatingSidebar.tsx | 154 ++-------- src/components/CourseLanding/EnrollButton.tsx | 130 ++++++++ .../CourseLanding/UpgradePlanModal.tsx | 290 ------------------ .../CourseLanding/VerifyEnrollment.tsx | 75 +++++ src/queries/billing.ts | 99 ++---- 5 files changed, 258 insertions(+), 490 deletions(-) create mode 100644 src/components/CourseLanding/EnrollButton.tsx delete mode 100644 src/components/CourseLanding/UpgradePlanModal.tsx create mode 100644 src/components/CourseLanding/VerifyEnrollment.tsx diff --git a/src/components/CourseLanding/CourseFloatingSidebar.tsx b/src/components/CourseLanding/CourseFloatingSidebar.tsx index b252b4173..49dd29c50 100644 --- a/src/components/CourseLanding/CourseFloatingSidebar.tsx +++ b/src/components/CourseLanding/CourseFloatingSidebar.tsx @@ -9,10 +9,26 @@ 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 { deleteUrlParam, getUrlParams } from '../../lib/browser'; +import { + billingDetailsOptions, + coursePriceOptions, +} from '../../queries/billing'; import { UpgradeAndEnroll } from './UpgradeAndEnroll'; +import { VerifyEnrollment } from './VerifyEnrollment'; +import { EnrollButton } from './EnrollButton'; + +type CreateCheckoutSessionBody = { + courseId: string; + success?: string; + cancel?: string; +}; + +type CreateCheckoutSessionParams = {}; + +type CreateCheckoutSessionResponse = { + checkoutUrl: string; +}; type CourseFloatingSidebarProps = { isSticky: boolean; @@ -22,77 +38,32 @@ type CourseFloatingSidebarProps = { export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) { const { isSticky, course } = props; - const { slug } = course; - const courseUrl = `${import.meta.env.PUBLIC_COURSE_APP_URL}/${slug}`; + const { slug, _id: courseId } = course; - const toast = useToast(); const [isLoading, setIsLoading] = useState(true); - const [showUpgradePlanModal, setShowUpgradePlanModal] = useState(false); - const [showUpgradeAndEnrollModal, setShowUpgradeAndEnrollModal] = - useState(false); - - const { - courseProgress, - billingDetails, - pending: isPending, - } = useQueries( + const { data: courseProgress, isLoading: isCourseProgressLoading } = useQuery( { - 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), - }; - }, + ...courseProgressOptions(slug), + enabled: !!isLoggedIn(), }, queryClient, ); - const { mutate: enroll, isPending: isEnrolling } = useMutation( - { - mutationFn: () => { - return httpPost(`/v1-enroll-course/${slug}`, {}); - }, - onSuccess: () => { - window.location.href = courseUrl; - }, - onError: (error) => { - console.error(error); - toast.error(error?.message || 'Failed to enroll'); - }, - }, + const { isLoading: isCoursePricingLoading } = useQuery( + coursePriceOptions({ courseSlug: slug }), queryClient, ); const hasEnrolled = courseProgress?.startedAt ? true : false; - const isPaidUser = billingDetails?.status === 'active'; + const isQueryLoading = isCourseProgressLoading || isCoursePricingLoading; useEffect(() => { - if (!isLoggedIn()) { - setIsLoading(false); - return; - } - - if (isPending) { + if (isQueryLoading) { return; } setIsLoading(false); - const shouldAutoEnroll = getUrlParams()?.e === '1'; - if (!hasEnrolled && shouldAutoEnroll) { - setShowUpgradeAndEnrollModal(true); - } - }, [courseProgress, isPending]); + }, [courseProgress, isQueryLoading]); const whatYouGet = [ 'Full access to all the courses', @@ -104,16 +75,6 @@ export function CourseFloatingSidebar(props: CourseFloatingSidebarProps) { return ( <> - {showUpgradePlanModal && ( - setShowUpgradePlanModal(false)} - success={`/learn/${slug}?e=1`} - cancel={`/learn/${slug}`} - /> - )} - - {showUpgradeAndEnrollModal && } -
- +
diff --git a/src/components/CourseLanding/EnrollButton.tsx b/src/components/CourseLanding/EnrollButton.tsx new file mode 100644 index 000000000..5b11db148 --- /dev/null +++ b/src/components/CourseLanding/EnrollButton.tsx @@ -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( + '/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 ( + + ); +} diff --git a/src/components/CourseLanding/UpgradePlanModal.tsx b/src/components/CourseLanding/UpgradePlanModal.tsx deleted file mode 100644 index 5782e1764..000000000 --- a/src/components/CourseLanding/UpgradePlanModal.tsx +++ /dev/null @@ -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('month'); - const [planId, setPlanId] = useState('free'); - const [isUpdatingPlan, setIsUpdatingPlan] = useState(false); - - const { mutate: createCheckoutSession, isPending } = useMutation( - { - mutationFn: (body: CreateCheckoutSessionBody) => { - return httpPost( - '/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( - '/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 ( - { - setIsUpdatingPlan(false); - }} - onCancel={() => { - setIsUpdatingPlan(false); - }} - /> - ); - } - - const showCancelSubscription = - billingDetails?.planId !== 'free' && planId === 'free'; - - return ( - <> - -
-
-

Upgrade Plan

-

- Upgrade your plan to unlock more features, courses, and more. -

- -
- {USER_SUBSCRIPTION_PLANS.map((plan) => { - const isSelectedPlan = plan.planId === planId; - const price = plan.prices[interval]; - const isCurrentPlan = billingDetails?.planId === plan.planId; - - return ( - - ); - })} -
- -
- {!showCancelSubscription && ( - - )} - - {showCancelSubscription && ( - - )} - - -
-
- -
-

{selectedPlan?.name} Features

-
- {USER_SUBSCRIPTION_PLANS.find( - (plan) => plan.planId === planId, - )?.features.map((feature, index) => ( -
- -

{feature.label}

-
- ))} -
-
-
-
- - ); -} diff --git a/src/components/CourseLanding/VerifyEnrollment.tsx b/src/components/CourseLanding/VerifyEnrollment.tsx new file mode 100644 index 000000000..807fbe90c --- /dev/null +++ b/src/components/CourseLanding/VerifyEnrollment.tsx @@ -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 ( + {}} + bodyClassName="rounded-xl bg-white p-6" + > +
+

Enrolling

+ +
+ + Refreshing +
+
+

+ We are enrolling you to the course. Please wait. +

+ +

+ It might take a few minutes for the changes to reflect. We will{' '} + reload the page for you. +

+ +

+ If it takes longer than expected, please{' '} + + contact us + + . +

+
+ ); +} diff --git a/src/queries/billing.ts b/src/queries/billing.ts index 71ca78ef0..7c0876aa5 100644 --- a/src/queries/billing.ts +++ b/src/queries/billing.ts @@ -1,16 +1,6 @@ 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 = { @@ -32,73 +22,24 @@ export function billingDetailsOptions() { }); } -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', - }, +type CoursePriceParams = { + courseSlug: string; +}; + +type CoursePriceResponse = { + fullPrice: number; + regionalPrice: number; + regionalDiscountPercentage: number; + isEligibleForDiscount: boolean; +}; + +export function coursePriceOptions(params: CoursePriceParams) { + return queryOptions({ + queryKey: ['course-price', params], + queryFn: async () => { + return httpGet( + `/v1-course-price/${params.courseSlug}`, + ); }, - 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; + }); +}