diff --git a/.env.example b/.env.example index 02a71f0a7..d6ab06b1d 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +PUBLIC_COURSE_APP_URL=http://localhost:5173 + +PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID= +PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID= \ No newline at end of file diff --git a/src/api/user.ts b/src/api/user.ts index 4e557e166..ccbf10702 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -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; } diff --git a/src/components/AccountSidebar.astro b/src/components/AccountSidebar.astro index 9b609304e..067e76106 100644 --- a/src/components/AccountSidebar.astro +++ b/src/components/AccountSidebar.astro @@ -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', diff --git a/src/components/Authenticator/authenticator.ts b/src/components/Authenticator/authenticator.ts index 1e9d57ac3..f4ff70752 100644 --- a/src/components/Authenticator/authenticator.ts +++ b/src/components/Authenticator/authenticator.ts @@ -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', diff --git a/src/components/Billing/BillingPage.tsx b/src/components/Billing/BillingPage.tsx new file mode 100644 index 000000000..fb02df7ca --- /dev/null +++ b/src/components/Billing/BillingPage.tsx @@ -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( + '/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 && ( + { + setShowUpgradeModal(false); + }} + success="/account/billing?s=1" + cancel="/account/billing" + /> + )} + + {showVerifyUpgradeModal && } + + {billingDetails?.status === 'none' && !isPending && ( +
+

+ You are using free plan,  + +

+
+ )} + + {billingDetails?.status !== 'none' && !isPending && ( + <> + {billingDetails?.status === 'past_due' && ( +
+ Your subscription is past due. Please update your payment + information from the Stripe Portal. +
+ )} + +
+
+ Plan + + {selectedPlanDetails?.name} + +
+
+
+ Payment + + ${priceDetails!.amount / 100} + +  / {priceDetails!.interval} + + +
+ + {!shouldHideDeleteButton && ( + + )} +
+
+ +
+
+ + {billingDetails?.cancelAtPeriodEnd ? 'Expires On' : 'Renews On'} + + + {formattedNextBillDate} + +
+ +
+ + )} + + ); +} diff --git a/src/components/Billing/UpdatePlanConfirmation.tsx b/src/components/Billing/UpdatePlanConfirmation.tsx new file mode 100644 index 000000000..b66abbad2 --- /dev/null +++ b/src/components/Billing/UpdatePlanConfirmation.tsx @@ -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('/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 ; + } + + return ( + {} : onClose} + bodyClassName="rounded-xl bg-white p-4" + > +

Subscription Update

+

+ Your plan will be updated to the{' '} + {planDetails.name} plan, and will be + charged{' '} + + ${selectedPrice.amount / 100} {selectedPrice.interval} + + . +

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

Subscription Activated

+ + {isFetching && ( +
+ + Refreshing +
+ )} +
+

+ Your subscription has been activated successfully. +

+ +

+ 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/components/CourseLanding/CourseFloatingSidebar.tsx b/src/components/CourseLanding/CourseFloatingSidebar.tsx index 82a05ce8c..b252b4173 100644 --- a/src/components/CourseLanding/CourseFloatingSidebar.tsx +++ b/src/components/CourseLanding/CourseFloatingSidebar.tsx @@ -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 ( -
-
- SQL 101 + {showUpgradePlanModal && ( + setShowUpgradePlanModal(false)} + success={`/learn/${slug}?e=1`} + cancel={`/learn/${slug}`} /> -
- -
- -
+ )} -
- -
+ {showUpgradeAndEnrollModal && } -
-

What you get

-
    - {whatYouGet.map((item, index) => ( -
  • {item}
  • - ))} -
+
+
+ SQL 101 +
+ +
+ +
+ +
+ +
+ +
+

What you get

+
    + {whatYouGet.map((item, index) => ( +
  • {item}
  • + ))} +
+
-
+ ); } diff --git a/src/components/CourseLanding/UpgradeAndEnroll.tsx b/src/components/CourseLanding/UpgradeAndEnroll.tsx new file mode 100644 index 000000000..65d4164a4 --- /dev/null +++ b/src/components/CourseLanding/UpgradeAndEnroll.tsx @@ -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 ( + {}} + bodyClassName="rounded-xl bg-white p-6" + > +
+

Activated & Enrolling

+ + {isFetching && ( +
+ + Refreshing +
+ )} +
+

+ Your subscription has been activated successfully, we are enrolling you + to the course. +

+ +

+ 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/components/CourseLanding/UpgradePlanModal.tsx b/src/components/CourseLanding/UpgradePlanModal.tsx new file mode 100644 index 000000000..5782e1764 --- /dev/null +++ b/src/components/CourseLanding/UpgradePlanModal.tsx @@ -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('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/env.d.ts b/src/env.d.ts index 5ce093285..d2784eab9 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -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 { diff --git a/src/pages/account/billing.astro b/src/pages/account/billing.astro new file mode 100644 index 000000000..616a494ed --- /dev/null +++ b/src/pages/account/billing.astro @@ -0,0 +1,16 @@ +--- +import AccountSidebar from '../../components/AccountSidebar.astro'; +import AccountLayout from '../../layouts/AccountLayout.astro'; +import { BillingPage } from '../../components/Billing/BillingPage'; +--- + + + + + + diff --git a/src/queries/billing.ts b/src/queries/billing.ts new file mode 100644 index 000000000..71ca78ef0 --- /dev/null +++ b/src/queries/billing.ts @@ -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('/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;