parent
f50a2cba0c
commit
8c0fd5ac8e
7 changed files with 968 additions and 1 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_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<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 (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 && ( |
||||||
|
<UpgradePlanModal |
||||||
|
onClose={() => { |
||||||
|
setShowUpgradeModal(false); |
||||||
|
}} |
||||||
|
success="/account/billing?s=1" |
||||||
|
cancel="/account/billing" |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{showVerifyUpgradeModal && <VerifyUpgrade />} |
||||||
|
|
||||||
|
{billingDetails?.status === 'none' && !isLoadingBillingDetails && ( |
||||||
|
<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' && !isLoadingBillingDetails && ( |
||||||
|
<> |
||||||
|
{billingDetails?.status === 'past_due' && ( |
||||||
|
<div className="mb-4 rounded-md border border-red-300 bg-red-50 p-2 text-sm text-red-500"> |
||||||
|
We were not able to charge your card. Please update your payment |
||||||
|
information. |
||||||
|
</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_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<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,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<AllowedSubscriptionInterval>('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<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 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 ? ( |
||||||
|
<div className="absolute inset-0 flex h-[540px] w-full items-center justify-center"> |
||||||
|
<Loader2 className="h-6 w-6 animate-spin stroke-[3px] text-zinc-500" /> |
||||||
|
</div> |
||||||
|
) : null; |
||||||
|
|
||||||
|
const error = billingError; |
||||||
|
const errorContent = error ? ( |
||||||
|
<div className="flex h-full w-full flex-col"> |
||||||
|
<p className="text-center text-red-500"> |
||||||
|
{error?.message || |
||||||
|
'An error occurred while loading the billing details.'} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
) : null; |
||||||
|
|
||||||
|
const checkoutSuccessModal = isCheckoutSuccess |
||||||
|
? null |
||||||
|
: // <MyPlanUpdateSuccess />
|
||||||
|
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; |
||||||
|
// <UpdateMyPlanConfirmation
|
||||||
|
// planDetails={selectedPlanDetails}
|
||||||
|
// onClose={() => setIsUpdatingPlan(false)}
|
||||||
|
// onCancel={() => setIsUpdatingPlan(false)}
|
||||||
|
// />
|
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
onClose={() => {}} |
||||||
|
wrapperClassName="bg-zinc-900 rounded-xl p-6 max-w-3xl w-full min-h-[540px]" |
||||||
|
> |
||||||
|
<div onClick={(e) => e.stopPropagation()}> |
||||||
|
{errorContent} |
||||||
|
|
||||||
|
{loader} |
||||||
|
{!isLoading && !error && ( |
||||||
|
<div className="flex flex-col"> |
||||||
|
<div className="mb-8 text-left"> |
||||||
|
<h2 className="text-xl font-bold text-zinc-100"> |
||||||
|
Unlock premium features and by-pass the limits. |
||||||
|
</h2> |
||||||
|
</div> |
||||||
|
<div className="mb-8 grid grid-cols-2 gap-8"> |
||||||
|
{USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => { |
||||||
|
const isCurrentPlanSelected = |
||||||
|
currentPlan?.priceId === plan.priceId; |
||||||
|
const isYearly = plan.interval === 'year'; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={plan.interval} |
||||||
|
className={cn( |
||||||
|
'flex flex-col space-y-4 rounded-lg p-6', |
||||||
|
isYearly |
||||||
|
? 'border-2 border-yellow-400' |
||||||
|
: 'border border-zinc-700', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div className="flex items-start justify-between"> |
||||||
|
<div> |
||||||
|
<h4 className="font-semibold text-zinc-100"> |
||||||
|
{isYearly ? 'Yearly Payment' : 'Monthly Payment'} |
||||||
|
</h4> |
||||||
|
{isYearly && ( |
||||||
|
<span className="text-sm text-green-500"> |
||||||
|
(2 months free) |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{isYearly && ( |
||||||
|
<span className="rounded-full bg-yellow-400 px-2 py-1 text-xs font-semibold text-black"> |
||||||
|
Most Popular |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div className="flex items-baseline"> |
||||||
|
{isYearly && ( |
||||||
|
<p className="mr-2 text-sm text-zinc-400 line-through"> |
||||||
|
$ |
||||||
|
{calculateYearlyPrice( |
||||||
|
USER_SUBSCRIPTION_PLAN_PRICES[0].amount, |
||||||
|
)} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<p className="text-3xl font-bold text-yellow-400"> |
||||||
|
${plan.amount}{' '} |
||||||
|
<span className="text-sm font-normal text-zinc-500"> |
||||||
|
/ {isYearly ? 'year' : 'month'} |
||||||
|
</span> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<div className="flex-grow"></div> |
||||||
|
<div> |
||||||
|
<button |
||||||
|
className={cn( |
||||||
|
'w-full rounded-md py-2.5 font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 disabled:cursor-not-allowed disabled:opacity-60', |
||||||
|
'bg-yellow-400 text-black hover:bg-yellow-500', |
||||||
|
)} |
||||||
|
disabled={isCurrentPlanSelected} |
||||||
|
onClick={() => { |
||||||
|
setSelectedPlan(plan.interval); |
||||||
|
if (!currentPlanPriceId) { |
||||||
|
const currentUrlPath = window.location.pathname; |
||||||
|
createCheckoutSession({ |
||||||
|
priceId: plan.priceId, |
||||||
|
success: `${currentUrlPath}?s=1`, |
||||||
|
cancel: `${currentUrlPath}?s=0`, |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
setIsUpdatingPlan(true); |
||||||
|
}} |
||||||
|
data-1p-ignore="" |
||||||
|
data-form-type="other" |
||||||
|
data-lpignore="true" |
||||||
|
> |
||||||
|
{isCurrentPlanSelected ? 'Current Plan' : 'Select Plan'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<h4 className="mb-4 font-semibold text-zinc-100"> |
||||||
|
Features included in all paid plans: |
||||||
|
</h4> |
||||||
|
<ul className="grid grid-cols-2 gap-x-8 gap-y-2"> |
||||||
|
{features.map((feature, index) => ( |
||||||
|
<li key={index} className="flex items-center"> |
||||||
|
<Check className="mr-2 h-4 w-4 text-yellow-400" /> |
||||||
|
<span className="text-sm text-zinc-400"> |
||||||
|
{feature.paid} |
||||||
|
</span> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{checkoutSuccessModal} |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -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<IntervalType>('month'); |
||||||
|
const [priceId, setPriceId] = useState<string>(''); |
||||||
|
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'); |
||||||
|
}, [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 ( |
||||||
|
<UpdatePlanConfirmation |
||||||
|
planDetails={selectedPrice} |
||||||
|
interval={interval} |
||||||
|
onClose={() => { |
||||||
|
setIsUpdatingPlan(false); |
||||||
|
}} |
||||||
|
onCancel={() => { |
||||||
|
setIsUpdatingPlan(false); |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const showCancelSubscription = !!billingDetails?.priceId; |
||||||
|
|
||||||
|
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, unlimited limits, and |
||||||
|
more. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-1"> |
||||||
|
{USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => { |
||||||
|
const isselectedPrice = 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', |
||||||
|
isselectedPrice && 'border-purple-500', |
||||||
|
)} |
||||||
|
onClick={() => { |
||||||
|
setPlanId(plan.planId); |
||||||
|
}} |
||||||
|
> |
||||||
|
<div className="flex items-center gap-3"> |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'size-2 rounded-full bg-gray-300', |
||||||
|
isselectedPrice && '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 = selectedPrice?.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 ${selectedPrice?.name}`} |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isPending && |
||||||
|
!isCreatingCustomerPortal && |
||||||
|
showCancelSubscription && <>Cancel Subscription</>} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -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 ( |
||||||
|
<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,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> |
Loading…
Reference in new issue