From 8c0fd5ac8ef0c2027609e44c5dff29d75df10d6e Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Fri, 7 Mar 2025 00:32:36 +0600 Subject: [PATCH] wip --- src/components/Billing/BillingPage.tsx | 180 ++++++++++++ .../Billing/UpdatePlanConfirmation.tsx | 95 ++++++ .../Billing/UpgradeAccountModal.tsx | 275 +++++++++++++++++ src/components/Billing/UpgradePlanModal.tsx | 276 ++++++++++++++++++ src/components/Billing/VerifyUpgrade.tsx | 73 +++++ src/pages/account/billing.astro | 16 + src/queries/billing.ts | 54 +++- 7 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 src/components/Billing/BillingPage.tsx create mode 100644 src/components/Billing/UpdatePlanConfirmation.tsx create mode 100644 src/components/Billing/UpgradeAccountModal.tsx create mode 100644 src/components/Billing/UpgradePlanModal.tsx create mode 100644 src/components/Billing/VerifyUpgrade.tsx create mode 100644 src/pages/account/billing.astro diff --git a/src/components/Billing/BillingPage.tsx b/src/components/Billing/BillingPage.tsx new file mode 100644 index 000000000..d2713ce59 --- /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_PLAN_PRICES, +} 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: isLoadingBillingDetails } = 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 (isLoadingBillingDetails) { + return; + } + + pageProgressMessage.set(''); + const shouldVerifyUpgrade = getUrlParams()?.s === '1'; + if (shouldVerifyUpgrade) { + setShowVerifyUpgradeModal(true); + } + }, [isLoadingBillingDetails]); + + if (isLoadingBillingDetails || !billingDetails) { + return null; + } + + const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.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' && !isLoadingBillingDetails && ( +
+

+ You are using free plan,  + +

+
+ )} + + {billingDetails?.status !== 'none' && !isLoadingBillingDetails && ( + <> + {billingDetails?.status === 'past_due' && ( +
+ We were not able to charge your card. Please update your payment + information. +
+ )} + +
+
+ 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..f9606751f --- /dev/null +++ b/src/components/Billing/UpdatePlanConfirmation.tsx @@ -0,0 +1,95 @@ +import { useMutation } from '@tanstack/react-query'; +import type { USER_SUBSCRIPTION_PLAN_PRICES } from '../../queries/billing'; +import { Modal } from '../Modal'; +import { queryClient } from '../../stores/query-client'; +import { useToast } from '../../hooks/use-toast'; +import { VerifyUpgrade } from './VerifyUpgrade'; +import { Loader2Icon } from 'lucide-react'; +import { httpPost } from '../../lib/query-http'; +import type { IntervalType } from './UpgradePlanModal'; + +type UpdatePlanBody = { + priceId: string; +}; + +type UpdatePlanResponse = { + status: 'ok'; +}; + +type UpdatePlanConfirmationProps = { + planDetails: (typeof USER_SUBSCRIPTION_PLAN_PRICES)[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/UpgradeAccountModal.tsx b/src/components/Billing/UpgradeAccountModal.tsx new file mode 100644 index 000000000..d3cfce996 --- /dev/null +++ b/src/components/Billing/UpgradeAccountModal.tsx @@ -0,0 +1,275 @@ +import { Check, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { getUser } from '../../lib/jwt'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Modal } from '../Modal'; +import { + billingDetailsOptions, + USER_SUBSCRIPTION_PLAN_PRICES, + type AllowedSubscriptionInterval, +} from '../../queries/billing'; +import { cn } from '../../lib/classname'; +import { queryClient } from '../../stores/query-client'; +import { httpPost } from '../../lib/query-http'; +import { useToast } from '../../hooks/use-toast'; + +type CreateCheckoutSessionBody = { + priceId: string; + success?: string; + cancel?: string; +}; + +type CreateCheckoutSessionResponse = { + checkoutUrl: string; +}; + +type UpgradeAccountModalProps = {}; + +export function UpgradeAccountModal(props: UpgradeAccountModalProps) { + const [selectedPlan, setSelectedPlan] = + useState('month'); + const [isUpdatingPlan, setIsUpdatingPlan] = useState(false); + const [isCheckoutSuccess, setIsCheckoutSuccess] = useState(false); + + const user = getUser(); + + const { + data: userBillingDetails, + isLoading, + error: billingError, + } = useQuery(billingDetailsOptions(), queryClient); + + const toast = useToast(); + + 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 selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find( + (plan) => plan.interval === selectedPlan, + ); + const currentPlanPriceId = userBillingDetails?.priceId; + const currentPlan = USER_SUBSCRIPTION_PLAN_PRICES.find( + (plan) => plan.priceId === currentPlanPriceId, + ); + + useEffect(() => { + if (!currentPlan) { + return; + } + + setSelectedPlan(currentPlan.interval); + }, [currentPlan]); + + if (!user) { + return null; + } + + const loader = isLoading ? ( +
+ +
+ ) : null; + + const error = billingError; + const errorContent = error ? ( +
+

+ {error?.message || + 'An error occurred while loading the billing details.'} +

+
+ ) : null; + + const checkoutSuccessModal = isCheckoutSuccess + ? null + : // + null; + + const features = [ + { free: 'Unlimited timezones', paid: 'Unlimited Timezones' }, + { + free: 'Upto 3 Timezone Teams', + paid: 'Unlimited Timezone Teams', + }, + { + free: '1 Workspace and Project', + paid: 'Unlimited Workspaces and Projects', + }, + { free: '7 days Task History', paid: 'Unlimited Task History' }, + { + free: 'Daily Planner (7 tasks per day)', + paid: 'Daily Planner (Unlimited tasks)', + }, + { free: 'Pomodoro Timer', paid: 'Pomodoro Timer' }, + { free: 'Focus Sounds', paid: 'Focus sounds' }, + { + free: 'World Clock, Stop Watch, Timer', + paid: 'World Clock, Stop Watch, Timer', + }, + { free: '', paid: 'Help the development of the app' }, + { free: '', paid: '...and more features coming soon!' }, + ]; + + const calculateYearlyPrice = (monthlyPrice: number) => { + return (monthlyPrice * 12).toFixed(2); + }; + + const calculateDiscount = ( + originalPrice: number, + discountedPrice: number, + ) => { + return Math.round( + ((originalPrice - discountedPrice) / originalPrice) * 100, + ); + }; + + const yearlyDiscount = calculateDiscount( + parseFloat(calculateYearlyPrice(USER_SUBSCRIPTION_PLAN_PRICES[0].amount)), + USER_SUBSCRIPTION_PLAN_PRICES[1].amount, + ); + + if (isUpdatingPlan) { + return null; + // setIsUpdatingPlan(false)} + // onCancel={() => setIsUpdatingPlan(false)} + // /> + } + + return ( + {}} + wrapperClassName="bg-zinc-900 rounded-xl p-6 max-w-3xl w-full min-h-[540px]" + > +
e.stopPropagation()}> + {errorContent} + + {loader} + {!isLoading && !error && ( +
+
+

+ Unlock premium features and by-pass the limits. +

+
+
+ {USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => { + const isCurrentPlanSelected = + currentPlan?.priceId === plan.priceId; + const isYearly = plan.interval === 'year'; + + return ( +
+
+
+

+ {isYearly ? 'Yearly Payment' : 'Monthly Payment'} +

+ {isYearly && ( + + (2 months free) + + )} +
+ {isYearly && ( + + Most Popular + + )} +
+
+ {isYearly && ( +

+ $ + {calculateYearlyPrice( + USER_SUBSCRIPTION_PLAN_PRICES[0].amount, + )} +

+ )} +

+ ${plan.amount}{' '} + + / {isYearly ? 'year' : 'month'} + +

+
+
+
+ +
+
+ ); + })} +
+
+

+ Features included in all paid plans: +

+
    + {features.map((feature, index) => ( +
  • + + + {feature.paid} + +
  • + ))} +
+
+
+ )} + + {checkoutSuccessModal} +
+
+ ); +} diff --git a/src/components/Billing/UpgradePlanModal.tsx b/src/components/Billing/UpgradePlanModal.tsx new file mode 100644 index 000000000..1c86b24b3 --- /dev/null +++ b/src/components/Billing/UpgradePlanModal.tsx @@ -0,0 +1,276 @@ +import { useEffect, useState } from 'react'; +import { + billingDetailsOptions, + USER_SUBSCRIPTION_PLAN_PRICES, +} 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 [priceId, setPriceId] = useState(''); + 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'); + }, [billingDetails]); + + // const isCurrentPlanSelected = + // billingDetails?.planId === planId && + // (interval === billingDetails.interval || billingDetails?.planId === 'free'); + + // const selectedPrice = USER_SUBSCRIPTION_PLAN_PRICES.find( + // (plan) => plan.planId === planId, + // ); + + const selectedPrice = USER_SUBSCRIPTION_PLAN_PRICES.find( + (plan) => plan.priceId === priceId, + ); + + if (isUpdatingPlan && selectedPrice) { + return ( + { + setIsUpdatingPlan(false); + }} + onCancel={() => { + setIsUpdatingPlan(false); + }} + /> + ); + } + + const showCancelSubscription = !!billingDetails?.priceId; + + return ( + <> + +
+
+

Upgrade Plan

+

+ Upgrade your plan to unlock more features, unlimited limits, and + more. +

+ +
+ {USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => { + const isselectedPrice = plan.planId === planId; + const price = plan.prices[interval]; + const isCurrentPlan = billingDetails?.planId === plan.planId; + + return ( + + ); + })} +
+ +
+ {!showCancelSubscription && ( + + )} + + {showCancelSubscription && ( + + )} + + +
+
+
+
+ + ); +} diff --git a/src/components/Billing/VerifyUpgrade.tsx b/src/components/Billing/VerifyUpgrade.tsx new file mode 100644 index 000000000..efb913a30 --- /dev/null +++ b/src/components/Billing/VerifyUpgrade.tsx @@ -0,0 +1,73 @@ +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 { 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/pages/account/billing.astro b/src/pages/account/billing.astro new file mode 100644 index 000000000..b3350bb66 --- /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'; +--- + + + + + + \ No newline at end of file diff --git a/src/queries/billing.ts b/src/queries/billing.ts index ab42ef295..b0d978172 100644 --- a/src/queries/billing.ts +++ b/src/queries/billing.ts @@ -1,5 +1,57 @@ import { queryOptions } from '@tanstack/react-query'; import { httpGet } from '../lib/query-http'; +import { isLoggedIn } from '../lib/jwt'; + +export const allowedSubscriptionStatus = [ + 'active', + 'canceled', + 'incomplete', + 'incomplete_expired', + 'past_due', + 'paused', + 'trialing', + 'unpaid', + 'none', +] as const; +export type AllowedSubscriptionStatus = + (typeof allowedSubscriptionStatus)[number]; + +export const USER_SUBSCRIPTION_PLAN_PRICES = [ + { + name: 'Pay Monthly', + interval: 'month', + priceId: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID, + amount: 10, + }, + { + name: 'Pay Yearly', + interval: 'year', + priceId: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID, + amount: 100, + }, +] as const; + +export type AllowedSubscriptionInterval = + (typeof USER_SUBSCRIPTION_PLAN_PRICES)[number]['interval']; + +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(), + }); +} type CoursePriceParams = { courseSlug: string; @@ -22,4 +74,4 @@ export function coursePriceOptions(params: CoursePriceParams) { ); }, }); -} \ No newline at end of file +}