feat/ai-courses
Arik Chakma 1 month ago
parent 3227d256cb
commit 9cec036273
  1. 22
      src/components/Billing/CheckSubscriptionVerification.tsx
  2. 21
      src/components/Billing/UpdatePlanConfirmation.tsx
  3. 117
      src/components/Billing/UpgradeAccountModal.tsx
  4. 276
      src/components/Billing/UpgradePlanModal.tsx
  5. 24
      src/components/GenerateCourse/GetAICourse.tsx
  6. 2
      src/pages/ai-tutor/[courseSlug].astro
  7. 2
      src/pages/ai-tutor/index.astro
  8. 2
      src/pages/ai-tutor/search.astro

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { VerifyUpgrade } from "./VerifyUpgrade";
export function CheckSubscriptionVerification() {
const [shouldVerifyUpgrade, setShouldVerifyUpgrade] = useState(false);
useEffect(() => {
const params = getUrlParams();
if (params.s !== '1') {
return;
}
setShouldVerifyUpgrade(true);
}, []);
if (!shouldVerifyUpgrade) {
return null;
}
return <VerifyUpgrade />;
}

@ -6,7 +6,6 @@ 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;
@ -18,13 +17,12 @@ type UpdatePlanResponse = {
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 { planDetails, onClose, onCancel } = props;
const toast = useToast();
const {
@ -34,7 +32,10 @@ export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) {
} = useMutation(
{
mutationFn: (body: UpdatePlanBody) => {
return httpPost<UpdatePlanResponse>('/v1-update-plan', body);
return httpPost<UpdatePlanResponse>(
'/v1-update-subscription-plan',
body,
);
},
onError: (error) => {
console.error(error);
@ -48,9 +49,9 @@ export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) {
return null;
}
const selectedPrice = planDetails.prices[interval];
const selectedPrice = planDetails;
if (status === 'success') {
return <VerifyUpgrade newPriceId={selectedPrice.id} />;
return <VerifyUpgrade newPriceId={selectedPrice.priceId} />;
}
return (
@ -61,10 +62,10 @@ export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) {
<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">{planDetails.interval}</b> plan, and will
be charged{' '}
<b className="text-gray-600">
${selectedPrice.amount / 100} {selectedPrice.interval}
${selectedPrice.amount} {selectedPrice.interval}
</b>
.
</p>
@ -81,7 +82,7 @@ export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) {
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 });
updatePlan({ priceId: selectedPrice.priceId });
}}
>
{isPending && (

@ -12,14 +12,15 @@ import { cn } from '../../lib/classname';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { UpdatePlanConfirmation } from './UpdatePlanConfirmation';
type CreateCheckoutSessionBody = {
type CreateSubscriptionCheckoutSessionBody = {
priceId: string;
success?: string;
cancel?: string;
};
type CreateCheckoutSessionResponse = {
type CreateSubscriptionCheckoutSessionResponse = {
checkoutUrl: string;
};
@ -29,7 +30,6 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
const [selectedPlan, setSelectedPlan] =
useState<AllowedSubscriptionInterval>('month');
const [isUpdatingPlan, setIsUpdatingPlan] = useState(false);
const [isCheckoutSuccess, setIsCheckoutSuccess] = useState(false);
const user = getUser();
@ -41,11 +41,14 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
const toast = useToast();
const { mutate: createCheckoutSession, isPending } = useMutation(
const {
mutate: createCheckoutSession,
isPending: isCreatingCheckoutSession,
} = useMutation(
{
mutationFn: (body: CreateCheckoutSessionBody) => {
return httpPost<CreateCheckoutSessionResponse>(
'/v1-create-checkout-session',
mutationFn: (body: CreateSubscriptionCheckoutSessionBody) => {
return httpPost<CreateSubscriptionCheckoutSessionResponse>(
'/v1-create-subscription-checkout-session',
body,
);
},
@ -96,67 +99,25 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
</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,
if (isUpdatingPlan && selectedPlanDetails) {
return (
<UpdatePlanConfirmation
planDetails={selectedPlanDetails}
onClose={() => setIsUpdatingPlan(false)}
onCancel={() => setIsUpdatingPlan(false)}
/>
);
};
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]"
wrapperClassName="rounded-xl max-w-3xl w-full min-h-[540px]"
bodyClassName="p-6"
>
<div onClick={(e) => e.stopPropagation()}>
{errorContent}
@ -165,11 +126,11 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
{!isLoading && !error && (
<div className="flex flex-col">
<div className="mb-8 text-left">
<h2 className="text-xl font-bold text-zinc-100">
<h2 className="text-xl font-bold">
Unlock premium features and by-pass the limits.
</h2>
</div>
<div className="mb-8 grid grid-cols-2 gap-8">
<div className="grid grid-cols-2 gap-8">
{USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => {
const isCurrentPlanSelected =
currentPlan?.priceId === plan.priceId;
@ -182,12 +143,12 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
'flex flex-col space-y-4 rounded-lg p-6',
isYearly
? 'border-2 border-yellow-400'
: 'border border-zinc-700',
: 'border border-gray-200',
)}
>
<div className="flex items-start justify-between">
<div>
<h4 className="font-semibold text-zinc-100">
<h4 className="font-semibold">
{isYearly ? 'Yearly Payment' : 'Monthly Payment'}
</h4>
{isYearly && (
@ -222,10 +183,12 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
<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',
'flex min-h-11 w-full items-center justify-center 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}
disabled={
isCurrentPlanSelected || isCreatingCheckoutSession
}
onClick={() => {
setSelectedPlan(plan.interval);
if (!currentPlanPriceId) {
@ -243,32 +206,22 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
data-form-type="other"
data-lpignore="true"
>
{isCurrentPlanSelected ? 'Current Plan' : 'Select Plan'}
{isCreatingCheckoutSession &&
selectedPlan === plan.interval ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : 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>
);

@ -1,276 +0,0 @@
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>
&nbsp;
<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>
</>
);
}

@ -4,6 +4,7 @@ import { queryClient } from '../../stores/query-client';
import { useEffect, useState } from 'react';
import { AICourseContent } from './AICourseContent';
import { generateAiCourseStructure } from '../../lib/ai';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
type GetAICourseProps = {
courseSlug: string;
@ -44,15 +45,18 @@ export function GetAICourse(props: GetAICourseProps) {
}, [error]);
return (
<AICourseContent
course={{
title: aiCourse?.title || '',
modules: aiCourse?.course.modules || [],
difficulty: aiCourse?.difficulty || 'Easy',
}}
isLoading={isLoading}
courseSlug={courseSlug}
error={error?.message}
/>
<>
<UpgradeAccountModal />
<AICourseContent
course={{
title: aiCourse?.title || '',
modules: aiCourse?.course.modules || [],
difficulty: aiCourse?.difficulty || 'Easy',
}}
isLoading={isLoading}
courseSlug={courseSlug}
error={error?.message}
/>
</>
);
}

@ -1,6 +1,7 @@
---
import { GetAICourse } from '../../components/GenerateCourse/GetAICourse';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
export const prerender = false;
@ -19,4 +20,5 @@ const { courseSlug } = Astro.params as Params;
canonicalUrl={`/ai-tutor/${courseSlug}`}
>
<GetAICourse client:load courseSlug={courseSlug} />
<CheckSubscriptionVerification client:load />
</SkeletonLayout>

@ -1,8 +1,10 @@
---
import { AICourse } from '../../components/GenerateCourse/AICourse';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
---
<BaseLayout title='AI Tutor' noIndex={true}>
<AICourse client:load />
<CheckSubscriptionVerification client:load />
</BaseLayout>

@ -1,6 +1,7 @@
---
import { GenerateAICourse } from '../../components/GenerateCourse/GenerateAICourse';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
---
<SkeletonLayout
@ -11,4 +12,5 @@ import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
canonicalUrl='/ai-tutor/search'
>
<GenerateAICourse client:load />
<CheckSubscriptionVerification client:load />
</SkeletonLayout>

Loading…
Cancel
Save