feat: add ai course generator (#8322 )
* Course landing page * Add ai course page * wip * wip * wip * wip * wip * wip * wip * wip: error handling * wip * wip * wip * wip: ai course progress * wip * wip * wip * feat: code highlighting * feat: usage limit * feat: follow up message * Update UI * wip * Add course content * wip: autogrow textarea & examples * Update types * Update * fix: add highlight to the AI chat * UI changes * Refactor * Update * Improve outline style * Improve spacing * Improve spacing * UI changes for sidebar * Update UI for sidebar * Improve course UI * Mark done, undone * Add toggle lesson done/undone * Update forward backward UI * wip * Minor ui change * Responsiveness of sidebar * wip * wip * wip: billing page * wip * Update UI * fix: hide upgrade if paid user * feat: token usage * feat: list ai courses * fix: limit for followup * Course content responsiveness * Make course content responsive * Responsiveness * Outline button * Responsiveness of course content * Responsiveness of course content * Add course upgrade button * Update design for upgrade * Improve logic for upgrade and limits button * Limits and errors * Add lesson count * Add course card * Improve UI for course generator * Update course functionality * Refactor AI course generation * Responsiveness of screen * Improve * Add responsiveness * Improve empty billing page design * Add empty billing screen * Update UI for billing page * Update UI for billing page * Update UI for billing page * Update billing page design * Update * Remove sidebar * Update --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>pull/8326/head
parent
faf43f7905
commit
cb64894e49
39 changed files with 3906 additions and 25 deletions
@ -0,0 +1,219 @@ |
|||||||
|
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 { UpgradeAccountModal } from './UpgradeAccountModal'; |
||||||
|
import { getUrlParams } from '../../lib/browser'; |
||||||
|
import { VerifyUpgrade } from './VerifyUpgrade'; |
||||||
|
import { EmptyBillingScreen } from './EmptyBillingScreen'; |
||||||
|
import { |
||||||
|
Calendar, |
||||||
|
RefreshCw, |
||||||
|
Loader2, |
||||||
|
AlertTriangle, |
||||||
|
CreditCard, |
||||||
|
ArrowRightLeft, |
||||||
|
} from 'lucide-react'; |
||||||
|
|
||||||
|
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, |
||||||
|
isSuccess: isCreatingCustomerPortalSuccess, |
||||||
|
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.priceId === billingDetails?.priceId, |
||||||
|
); |
||||||
|
|
||||||
|
const shouldHideDeleteButton = |
||||||
|
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd; |
||||||
|
const priceDetails = selectedPlanDetails; |
||||||
|
|
||||||
|
const formattedNextBillDate = new Date( |
||||||
|
billingDetails?.currentPeriodEnd || '', |
||||||
|
).toLocaleDateString('en-US', { |
||||||
|
year: 'numeric', |
||||||
|
month: 'long', |
||||||
|
day: 'numeric', |
||||||
|
}); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
{showUpgradeModal && ( |
||||||
|
<UpgradeAccountModal |
||||||
|
onClose={() => { |
||||||
|
setShowUpgradeModal(false); |
||||||
|
}} |
||||||
|
success="/account/billing?s=1" |
||||||
|
cancel="/account/billing" |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{showVerifyUpgradeModal && <VerifyUpgrade />} |
||||||
|
|
||||||
|
{billingDetails?.status === 'none' && !isLoadingBillingDetails && ( |
||||||
|
<EmptyBillingScreen onUpgrade={() => setShowUpgradeModal(true)} /> |
||||||
|
)} |
||||||
|
|
||||||
|
{billingDetails?.status !== 'none' && |
||||||
|
!isLoadingBillingDetails && |
||||||
|
priceDetails && ( |
||||||
|
<div className="mt-1"> |
||||||
|
{billingDetails?.status === 'past_due' && ( |
||||||
|
<div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600"> |
||||||
|
<AlertTriangle className="h-5 w-5" /> |
||||||
|
<span> |
||||||
|
We were not able to charge your card.{' '} |
||||||
|
<button |
||||||
|
disabled={ |
||||||
|
isCreatingCustomerPortal || |
||||||
|
isCreatingCustomerPortalSuccess |
||||||
|
} |
||||||
|
onClick={() => { |
||||||
|
createCustomerPortal({}); |
||||||
|
}} |
||||||
|
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50" |
||||||
|
> |
||||||
|
Update payment information. |
||||||
|
</button> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<h2 className="mb-2 text-xl font-semibold text-black"> |
||||||
|
Current Subscription |
||||||
|
</h2> |
||||||
|
|
||||||
|
<p className="text-sm text-gray-500"> |
||||||
|
Thank you for being a pro member. Your plan details are below. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between"> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100"> |
||||||
|
<RefreshCw className="size-5 text-gray-600" /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<span className="text-xs uppercase tracking-wider text-gray-400"> |
||||||
|
Payment |
||||||
|
</span> |
||||||
|
<h3 className="flex items-baseline text-lg font-semibold text-black"> |
||||||
|
${priceDetails.amount} |
||||||
|
<span className="ml-1 text-sm font-normal text-gray-500"> |
||||||
|
/ {priceDetails.interval} |
||||||
|
</span> |
||||||
|
</h3> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mt-6 border-t border-gray-100 pt-6"> |
||||||
|
<div className="flex items-start gap-4"> |
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100"> |
||||||
|
<Calendar className="size-5 text-gray-600" /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<span className="text-xs uppercase tracking-wider text-gray-400"> |
||||||
|
{billingDetails?.cancelAtPeriodEnd |
||||||
|
? 'Expires On' |
||||||
|
: 'Renews On'} |
||||||
|
</span> |
||||||
|
<h3 className="text-lg font-semibold text-black"> |
||||||
|
{formattedNextBillDate} |
||||||
|
</h3> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mt-8 flex gap-3 max-sm:flex-col"> |
||||||
|
{!shouldHideDeleteButton && ( |
||||||
|
<button |
||||||
|
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 max-sm:flex-grow" |
||||||
|
onClick={() => { |
||||||
|
setShowUpgradeModal(true); |
||||||
|
}} |
||||||
|
> |
||||||
|
<ArrowRightLeft className="mr-2 h-4 w-4" /> |
||||||
|
Switch Plan |
||||||
|
</button> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" |
||||||
|
onClick={() => { |
||||||
|
createCustomerPortal({}); |
||||||
|
}} |
||||||
|
disabled={ |
||||||
|
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess |
||||||
|
} |
||||||
|
> |
||||||
|
{isCreatingCustomerPortal || |
||||||
|
isCreatingCustomerPortalSuccess ? ( |
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
||||||
|
) : ( |
||||||
|
<CreditCard className="mr-2 h-4 w-4" /> |
||||||
|
)} |
||||||
|
Manage Subscription |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -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 />; |
||||||
|
} |
@ -0,0 +1,83 @@ |
|||||||
|
import { |
||||||
|
CreditCard, |
||||||
|
Ellipsis, |
||||||
|
HeartHandshake, |
||||||
|
MessageCircleIcon, |
||||||
|
SparklesIcon, |
||||||
|
Zap, |
||||||
|
CheckCircle, |
||||||
|
} from 'lucide-react'; |
||||||
|
|
||||||
|
type EmptyBillingScreenProps = { |
||||||
|
onUpgrade: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
const perks = [ |
||||||
|
{ |
||||||
|
icon: Zap, |
||||||
|
text: 'Unlimited AI course generations', |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: MessageCircleIcon, |
||||||
|
text: 'Unlimited AI Chat feature usage', |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: SparklesIcon, |
||||||
|
text: 'Early access to new features', |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: HeartHandshake, |
||||||
|
text: 'Support the development of platform', |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: Ellipsis, |
||||||
|
text: 'more perks coming soon!', |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export function EmptyBillingScreen(props: EmptyBillingScreenProps) { |
||||||
|
const { onUpgrade } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="mt-6 max-w-3xl"> |
||||||
|
<h2 className="mb-6 text-2xl font-bold text-black">Subscription Details</h2> |
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg bg-white shadow-sm"> |
||||||
|
<div className="p-6"> |
||||||
|
<div className="flex flex-col items-center text-center"> |
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100"> |
||||||
|
<CreditCard className="h-8 w-8 text-gray-500" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h3 className="mt-4 text-xl font-semibold text-black"> |
||||||
|
No Active Subscription |
||||||
|
</h3> |
||||||
|
|
||||||
|
<p className="mt-2 max-w-md text-balance text-gray-600"> |
||||||
|
Unlock premium benefits by upgrading to a subscription |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="mt-6 w-full max-w-md rounded-lg border border-gray-200 bg-gray-50 p-4"> |
||||||
|
<h4 className="mb-3 font-medium text-gray-800">Premium Benefits</h4> |
||||||
|
<div className="flex flex-col gap-3"> |
||||||
|
{perks.map((perk) => ( |
||||||
|
<div className="flex items-center gap-2 text-gray-700" key={perk.text}> |
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" /> |
||||||
|
<span>{perk.text}</span> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button |
||||||
|
onClick={onUpgrade} |
||||||
|
className="mt-6 inline-flex items-center justify-center rounded-md bg-black px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-black/80 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2" |
||||||
|
> |
||||||
|
Upgrade Account |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
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'; |
||||||
|
|
||||||
|
type UpdatePlanBody = { |
||||||
|
priceId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type UpdatePlanResponse = { |
||||||
|
status: 'ok'; |
||||||
|
}; |
||||||
|
|
||||||
|
type UpdatePlanConfirmationProps = { |
||||||
|
planDetails: (typeof USER_SUBSCRIPTION_PLAN_PRICES)[number]; |
||||||
|
onClose: () => void; |
||||||
|
onCancel: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) { |
||||||
|
const { planDetails, onClose, onCancel } = props; |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
const { |
||||||
|
mutate: updatePlan, |
||||||
|
isPending, |
||||||
|
status, |
||||||
|
} = useMutation( |
||||||
|
{ |
||||||
|
mutationFn: (body: UpdatePlanBody) => { |
||||||
|
return httpPost<UpdatePlanResponse>( |
||||||
|
'/v1-update-subscription-plan', |
||||||
|
body, |
||||||
|
); |
||||||
|
}, |
||||||
|
onError: (error) => { |
||||||
|
console.error(error); |
||||||
|
toast.error(error?.message || 'Failed to Create Customer Portal'); |
||||||
|
}, |
||||||
|
}, |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
if (!planDetails) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const selectedPrice = planDetails; |
||||||
|
if (status === 'success') { |
||||||
|
return <VerifyUpgrade newPriceId={selectedPrice.priceId} />; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
onClose={isPending ? () => {} : onClose} |
||||||
|
bodyClassName="rounded-xl bg-white p-6" |
||||||
|
> |
||||||
|
<h3 className="text-xl font-bold text-black">Subscription Update</h3> |
||||||
|
<p className="mt-2 text-balance text-gray-600"> |
||||||
|
Your plan will be updated to the{' '} |
||||||
|
<b className="text-black">{planDetails.interval}</b> plan, and will |
||||||
|
be charged{' '} |
||||||
|
<b className="text-black"> |
||||||
|
${selectedPrice.amount}/{selectedPrice.interval} |
||||||
|
</b> |
||||||
|
. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3"> |
||||||
|
<button |
||||||
|
className="rounded-md border border-gray-200 py-2 text-sm font-semibold hover:bg-gray-50 transition-colors disabled:opacity-50" |
||||||
|
onClick={onCancel} |
||||||
|
disabled={isPending} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
<button |
||||||
|
className="flex items-center justify-center rounded-md bg-purple-600 py-2 text-sm font-semibold text-white hover:bg-purple-500 transition-colors disabled:opacity-50" |
||||||
|
disabled={isPending} |
||||||
|
onClick={() => { |
||||||
|
updatePlan({ priceId: selectedPrice.priceId }); |
||||||
|
}} |
||||||
|
> |
||||||
|
{isPending && ( |
||||||
|
<Loader2Icon className="size-4 animate-spin stroke-[2.5] mr-2" /> |
||||||
|
)} |
||||||
|
{!isPending && 'Confirm'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,308 @@ |
|||||||
|
import { |
||||||
|
Loader2, |
||||||
|
Zap, |
||||||
|
Infinity, |
||||||
|
MessageSquare, |
||||||
|
Sparkles, |
||||||
|
Heart, |
||||||
|
} 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'; |
||||||
|
import { UpdatePlanConfirmation } from './UpdatePlanConfirmation'; |
||||||
|
|
||||||
|
type CreateSubscriptionCheckoutSessionBody = { |
||||||
|
priceId: string; |
||||||
|
success?: string; |
||||||
|
cancel?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type CreateSubscriptionCheckoutSessionResponse = { |
||||||
|
checkoutUrl: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type UpgradeAccountModalProps = { |
||||||
|
onClose: () => void; |
||||||
|
|
||||||
|
success?: string; |
||||||
|
cancel?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function UpgradeAccountModal(props: UpgradeAccountModalProps) { |
||||||
|
const { onClose, success, cancel } = props; |
||||||
|
|
||||||
|
const [selectedPlan, setSelectedPlan] = |
||||||
|
useState<AllowedSubscriptionInterval>('month'); |
||||||
|
const [isUpdatingPlan, setIsUpdatingPlan] = useState(false); |
||||||
|
|
||||||
|
const user = getUser(); |
||||||
|
|
||||||
|
const { |
||||||
|
data: userBillingDetails, |
||||||
|
isLoading, |
||||||
|
error: billingError, |
||||||
|
} = useQuery(billingDetailsOptions(), queryClient); |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
|
||||||
|
const { |
||||||
|
mutate: createCheckoutSession, |
||||||
|
isPending: isCreatingCheckoutSession, |
||||||
|
} = useMutation( |
||||||
|
{ |
||||||
|
mutationFn: (body: CreateSubscriptionCheckoutSessionBody) => { |
||||||
|
return httpPost<CreateSubscriptionCheckoutSessionResponse>( |
||||||
|
'/v1-create-subscription-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 bg-white"> |
||||||
|
<Loader2 className="h-6 w-6 animate-spin stroke-[3px] text-green-600" /> |
||||||
|
</div> |
||||||
|
) : null; |
||||||
|
|
||||||
|
const error = billingError; |
||||||
|
const errorContent = error ? ( |
||||||
|
<div className="flex h-full w-full flex-col"> |
||||||
|
<p className="text-center text-red-400"> |
||||||
|
{error?.message || |
||||||
|
'An error occurred while loading the billing details.'} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
) : null; |
||||||
|
|
||||||
|
const calculateYearlyPrice = (monthlyPrice: number) => { |
||||||
|
return (monthlyPrice * 12).toFixed(2); |
||||||
|
}; |
||||||
|
|
||||||
|
if (isUpdatingPlan && selectedPlanDetails) { |
||||||
|
return ( |
||||||
|
<UpdatePlanConfirmation |
||||||
|
planDetails={selectedPlanDetails} |
||||||
|
onClose={() => setIsUpdatingPlan(false)} |
||||||
|
onCancel={() => setIsUpdatingPlan(false)} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
onClose={onClose} |
||||||
|
bodyClassName="p-4 sm:p-6 bg-white" |
||||||
|
wrapperClassName="h-auto rounded-xl max-w-3xl w-full min-h-[540px] mx-2 sm:mx-4" |
||||||
|
overlayClassName="items-start md:items-center" |
||||||
|
> |
||||||
|
<div onClick={(e) => e.stopPropagation()}> |
||||||
|
{errorContent} |
||||||
|
|
||||||
|
{loader} |
||||||
|
{!isLoading && !error && ( |
||||||
|
<div className="flex flex-col"> |
||||||
|
<div className="mb-6 sm:mb-8 text-left"> |
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-black"> |
||||||
|
Unlock Premium Features |
||||||
|
</h2> |
||||||
|
<p className="mt-1 sm:mt-2 text-sm sm:text-base text-gray-600"> |
||||||
|
Supercharge your learning experience with premium benefits |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-6 sm:mb-8 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2"> |
||||||
|
{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-3 sm:space-y-4 rounded-lg bg-white p-4 sm:p-6', |
||||||
|
isYearly |
||||||
|
? 'border-2 border-purple-400' |
||||||
|
: 'border border-gray-200', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div className="flex items-start justify-between"> |
||||||
|
<div> |
||||||
|
<h4 className="text-sm sm:text-base font-semibold text-black"> |
||||||
|
{isYearly ? 'Yearly Payment' : 'Monthly Payment'} |
||||||
|
</h4> |
||||||
|
{isYearly && ( |
||||||
|
<span className="text-xs sm:text-sm font-medium text-blue-600"> |
||||||
|
(2 months free) |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{isYearly && ( |
||||||
|
<span className="rounded-full bg-purple-600 px-1.5 py-0.5 sm:px-2 sm:py-1 text-xs font-semibold text-white"> |
||||||
|
Most Popular |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div className="flex items-baseline"> |
||||||
|
{isYearly && ( |
||||||
|
<p className="mr-2 text-xs sm:text-sm text-gray-400 line-through"> |
||||||
|
$ |
||||||
|
{calculateYearlyPrice( |
||||||
|
USER_SUBSCRIPTION_PLAN_PRICES[0].amount, |
||||||
|
)} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-black"> |
||||||
|
${plan.amount}{' '} |
||||||
|
<span className="text-xs sm:text-sm font-normal text-gray-500"> |
||||||
|
/ {isYearly ? 'year' : 'month'} |
||||||
|
</span> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex-grow"></div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<button |
||||||
|
className={cn( |
||||||
|
'flex min-h-9 sm:min-h-11 w-full items-center justify-center rounded-md py-2 sm:py-2.5 text-sm sm:text-base font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-400 disabled:cursor-not-allowed disabled:opacity-60', |
||||||
|
'bg-purple-600 text-white hover:bg-purple-500', |
||||||
|
)} |
||||||
|
disabled={ |
||||||
|
isCurrentPlanSelected || isCreatingCheckoutSession |
||||||
|
} |
||||||
|
onClick={() => { |
||||||
|
setSelectedPlan(plan.interval); |
||||||
|
if (!currentPlanPriceId) { |
||||||
|
const currentUrlPath = window.location.pathname; |
||||||
|
createCheckoutSession({ |
||||||
|
priceId: plan.priceId, |
||||||
|
success: success || `${currentUrlPath}?s=1`, |
||||||
|
cancel: cancel || `${currentUrlPath}?s=0`, |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
setIsUpdatingPlan(true); |
||||||
|
}} |
||||||
|
data-1p-ignore="" |
||||||
|
data-form-type="other" |
||||||
|
data-lpignore="true" |
||||||
|
> |
||||||
|
{isCreatingCheckoutSession && |
||||||
|
selectedPlan === plan.interval ? ( |
||||||
|
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" /> |
||||||
|
) : isCurrentPlanSelected ? ( |
||||||
|
'Current Plan' |
||||||
|
) : ( |
||||||
|
'Select Plan' |
||||||
|
)} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Benefits Section */} |
||||||
|
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2"> |
||||||
|
<div className="flex items-start space-x-2 sm:space-x-3"> |
||||||
|
<Zap className="mt-0.5 h-4 w-4 sm:h-5 sm:w-5 text-purple-400" /> |
||||||
|
<div> |
||||||
|
<h4 className="text-sm sm:text-base font-medium text-black"> |
||||||
|
Unlimited AI Course Generations |
||||||
|
</h4> |
||||||
|
<p className="text-xs sm:text-sm text-gray-600"> |
||||||
|
Generate as many custom courses as you need |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex items-start space-x-2 sm:space-x-3"> |
||||||
|
<Infinity className="mt-0.5 h-4 w-4 sm:h-5 sm:w-5 text-purple-400" /> |
||||||
|
<div> |
||||||
|
<h4 className="text-sm sm:text-base font-medium text-black"> |
||||||
|
No Daily Limits on course features |
||||||
|
</h4> |
||||||
|
<p className="text-xs sm:text-sm text-gray-600"> |
||||||
|
Use all features without restrictions |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex items-start space-x-2 sm:space-x-3"> |
||||||
|
<MessageSquare className="mt-0.5 h-4 w-4 sm:h-5 sm:w-5 text-purple-400" /> |
||||||
|
<div> |
||||||
|
<h4 className="text-sm sm:text-base font-medium text-black"> |
||||||
|
Unlimited Course Follow-ups |
||||||
|
</h4> |
||||||
|
<p className="text-xs sm:text-sm text-gray-600"> |
||||||
|
Ask as many questions as you need |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex items-start space-x-2 sm:space-x-3"> |
||||||
|
<Sparkles className="mt-0.5 h-4 w-4 sm:h-5 sm:w-5 text-purple-400" /> |
||||||
|
<div> |
||||||
|
<h4 className="text-sm sm:text-base font-medium text-black"> |
||||||
|
Early Access to Features |
||||||
|
</h4> |
||||||
|
<p className="text-xs sm:text-sm text-gray-600"> |
||||||
|
Be the first to try new tools and features |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex items-start space-x-2 sm:space-x-3"> |
||||||
|
<Heart className="mt-0.5 h-4 w-4 sm:h-5 sm:w-5 text-purple-400" /> |
||||||
|
<div> |
||||||
|
<h4 className="text-sm sm:text-base font-medium text-black"> |
||||||
|
Support Development |
||||||
|
</h4> |
||||||
|
<p className="text-xs sm:text-sm text-gray-600"> |
||||||
|
Help us continue building roadmap.sh |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,76 @@ |
|||||||
|
import { useEffect } from 'react'; |
||||||
|
import { Loader2, CheckCircle } from 'lucide-react'; |
||||||
|
import { 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 } = 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="mb-4 flex flex-col items-center text-center"> |
||||||
|
<CheckCircle className="mb-3 h-12 w-12 text-green-600" /> |
||||||
|
<h3 className="text-xl font-bold text-black">Subscription Activated</h3> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p className="mt-2 text-balance text-center text-gray-600"> |
||||||
|
Your subscription has been activated successfully. |
||||||
|
</p> |
||||||
|
|
||||||
|
<p className="mt-4 text-balance text-center text-gray-600"> |
||||||
|
It might take a minute for the changes to reflect. We will{' '} |
||||||
|
<b className="text-black">reload</b> the page for you. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="my-6 flex animate-pulse items-center justify-center gap-2"> |
||||||
|
<Loader2 className="size-4 animate-spin stroke-[2.5px] text-green-600" /> |
||||||
|
<span className="text-gray-600">Please wait...</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500"> |
||||||
|
If it takes longer than expected, please email us at{' '} |
||||||
|
<a |
||||||
|
href="mailto:info@roadmap.sh" |
||||||
|
className="text-blue-600 underline underline-offset-2 hover:text-blue-700" |
||||||
|
> |
||||||
|
info@roadmap.sh |
||||||
|
</a> |
||||||
|
. |
||||||
|
</p> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,123 @@ |
|||||||
|
import { SearchIcon, WandIcon } from 'lucide-react'; |
||||||
|
import { useState } from 'react'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { showLoginPopup } from '../../lib/popup'; |
||||||
|
import { UserCoursesList } from './UserCoursesList'; |
||||||
|
|
||||||
|
export const difficultyLevels = [ |
||||||
|
'beginner', |
||||||
|
'intermediate', |
||||||
|
'advanced', |
||||||
|
] as const; |
||||||
|
export type DifficultyLevel = (typeof difficultyLevels)[number]; |
||||||
|
|
||||||
|
type AICourseProps = {}; |
||||||
|
|
||||||
|
export function AICourse(props: AICourseProps) { |
||||||
|
const [keyword, setKeyword] = useState(''); |
||||||
|
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner'); |
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => { |
||||||
|
if (e.key === 'Enter' && keyword.trim()) { |
||||||
|
onSubmit(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
function onSubmit() { |
||||||
|
if (!isLoggedIn()) { |
||||||
|
showLoginPopup(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}`; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="flex flex-grow flex-col bg-gray-100"> |
||||||
|
<div className="container mx-auto flex max-w-3xl flex-col py-24 max-sm:py-4"> |
||||||
|
<h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> |
||||||
|
Learn anything with AI |
||||||
|
</h1> |
||||||
|
<p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm"> |
||||||
|
Enter a topic below to generate a personalized course for it |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4"> |
||||||
|
<form |
||||||
|
className="flex flex-col gap-5" |
||||||
|
onSubmit={(e) => { |
||||||
|
e.preventDefault(); |
||||||
|
onSubmit(); |
||||||
|
}} |
||||||
|
> |
||||||
|
<div className="flex flex-col"> |
||||||
|
<label |
||||||
|
htmlFor="keyword" |
||||||
|
className="mb-2.5 text-sm font-medium text-gray-700" |
||||||
|
> |
||||||
|
Course Topic |
||||||
|
</label> |
||||||
|
<div className="relative"> |
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"> |
||||||
|
<SearchIcon size={18} /> |
||||||
|
</div> |
||||||
|
<input |
||||||
|
id="keyword" |
||||||
|
type="text" |
||||||
|
value={keyword} |
||||||
|
onChange={(e) => setKeyword(e.target.value)} |
||||||
|
onKeyDown={handleKeyDown} |
||||||
|
placeholder="e.g., Algebra, JavaScript, Photography" |
||||||
|
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-500 max-sm:placeholder:text-base" |
||||||
|
maxLength={50} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex flex-col"> |
||||||
|
<label className="mb-2.5 text-sm font-medium text-gray-700"> |
||||||
|
Difficulty Level |
||||||
|
</label> |
||||||
|
<div className="flex gap-2 max-sm:flex-col max-sm:gap-1"> |
||||||
|
{difficultyLevels.map((level) => ( |
||||||
|
<button |
||||||
|
key={level} |
||||||
|
type="button" |
||||||
|
onClick={() => setDifficulty(level)} |
||||||
|
className={cn( |
||||||
|
'rounded-md border px-4 py-2 capitalize max-sm:text-sm', |
||||||
|
difficulty === level |
||||||
|
? 'border-gray-800 bg-gray-800 text-white' |
||||||
|
: 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200', |
||||||
|
)} |
||||||
|
> |
||||||
|
{level} |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={!keyword.trim()} |
||||||
|
className={cn( |
||||||
|
'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm', |
||||||
|
!keyword.trim() |
||||||
|
? 'cursor-not-allowed bg-gray-400' |
||||||
|
: 'bg-black hover:bg-gray-800', |
||||||
|
)} |
||||||
|
> |
||||||
|
<WandIcon size={18} className="mr-2" /> |
||||||
|
Generate Course |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mt-8 min-h-[200px]"> |
||||||
|
<UserCoursesList /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
import type { AICourseListItem } from '../../queries/ai-course'; |
||||||
|
import type { DifficultyLevel } from './AICourse'; |
||||||
|
import { BookOpen } from 'lucide-react'; |
||||||
|
|
||||||
|
type AICourseCardProps = { |
||||||
|
course: AICourseListItem; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseCard(props: AICourseCardProps) { |
||||||
|
const { course } = props; |
||||||
|
|
||||||
|
// Format date if available
|
||||||
|
const formattedDate = course.createdAt |
||||||
|
? new Date(course.createdAt).toLocaleDateString('en-US', { |
||||||
|
month: 'short', |
||||||
|
day: 'numeric', |
||||||
|
}) |
||||||
|
: null; |
||||||
|
|
||||||
|
// Map difficulty to color
|
||||||
|
const difficultyColor = |
||||||
|
{ |
||||||
|
beginner: 'text-green-700', |
||||||
|
intermediate: 'text-blue-700', |
||||||
|
advanced: 'text-purple-700', |
||||||
|
}[course.difficulty as DifficultyLevel] || 'text-gray-700'; |
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const totalTopics = course.lessonCount || 0; |
||||||
|
const completedTopics = course.progress?.done?.length || 0; |
||||||
|
const progressPercentage = |
||||||
|
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0; |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={`/ai-tutor/${course.slug}`} |
||||||
|
className="hover:border-gray-3 00 group relative flex w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50" |
||||||
|
> |
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<span |
||||||
|
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`} |
||||||
|
> |
||||||
|
{course.difficulty} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h3 className="my-2 text-base font-semibold text-gray-900"> |
||||||
|
{course.title} |
||||||
|
</h3> |
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between pt-2"> |
||||||
|
<div className="flex items-center text-xs text-gray-600"> |
||||||
|
<BookOpen className="mr-1 h-3.5 w-3.5" /> |
||||||
|
<span>{totalTopics} lessons</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{totalTopics > 0 && ( |
||||||
|
<div className="flex items-center"> |
||||||
|
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200"> |
||||||
|
<div |
||||||
|
className="h-full rounded-full bg-blue-600" |
||||||
|
style={{ width: `${progressPercentage}%` }} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<span className="text-xs font-medium text-gray-700"> |
||||||
|
{progressPercentage}% |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,451 @@ |
|||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { |
||||||
|
BookOpenCheck, |
||||||
|
ChevronLeft, |
||||||
|
Loader2, |
||||||
|
Menu, |
||||||
|
X, |
||||||
|
CircleAlert, |
||||||
|
} from 'lucide-react'; |
||||||
|
import { useState } from 'react'; |
||||||
|
import { type AiCourse } from '../../lib/ai'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { slugify } from '../../lib/slugger'; |
||||||
|
import { getAiCourseProgressOptions } from '../../queries/ai-course'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { CheckIcon } from '../ReactIcons/CheckIcon'; |
||||||
|
import { ErrorIcon } from '../ReactIcons/ErrorIcon'; |
||||||
|
import { AICourseLimit } from './AICourseLimit'; |
||||||
|
import { AICourseModuleList } from './AICourseModuleList'; |
||||||
|
import { AICourseModuleView } from './AICourseModuleView'; |
||||||
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; |
||||||
|
import { AILimitsPopup } from './AILimitsPopup'; |
||||||
|
|
||||||
|
type AICourseContentProps = { |
||||||
|
courseSlug?: string; |
||||||
|
course: AiCourse; |
||||||
|
isLoading: boolean; |
||||||
|
error?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseContent(props: AICourseContentProps) { |
||||||
|
const { course, courseSlug, isLoading, error } = props; |
||||||
|
|
||||||
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false); |
||||||
|
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); |
||||||
|
|
||||||
|
const [activeModuleIndex, setActiveModuleIndex] = useState(0); |
||||||
|
const [activeLessonIndex, setActiveLessonIndex] = useState(0); |
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false); |
||||||
|
const [viewMode, setViewMode] = useState<'module' | 'full'>('full'); |
||||||
|
|
||||||
|
const { data: aiCourseProgress } = useQuery( |
||||||
|
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const [expandedModules, setExpandedModules] = useState< |
||||||
|
Record<number, boolean> |
||||||
|
>({}); |
||||||
|
|
||||||
|
const goToNextModule = () => { |
||||||
|
if (activeModuleIndex < course.modules.length - 1) { |
||||||
|
const nextModuleIndex = activeModuleIndex + 1; |
||||||
|
setActiveModuleIndex(nextModuleIndex); |
||||||
|
setActiveLessonIndex(0); |
||||||
|
|
||||||
|
setExpandedModules((prev) => { |
||||||
|
const newState: Record<number, boolean> = {}; |
||||||
|
course.modules.forEach((_, idx) => { |
||||||
|
newState[idx] = false; |
||||||
|
}); |
||||||
|
|
||||||
|
newState[nextModuleIndex] = true; |
||||||
|
return newState; |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const goToNextLesson = () => { |
||||||
|
const currentModule = course.modules[activeModuleIndex]; |
||||||
|
if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) { |
||||||
|
setActiveLessonIndex(activeLessonIndex + 1); |
||||||
|
} else { |
||||||
|
goToNextModule(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const goToPrevLesson = () => { |
||||||
|
if (activeLessonIndex > 0) { |
||||||
|
setActiveLessonIndex(activeLessonIndex - 1); |
||||||
|
} else { |
||||||
|
const prevModule = course.modules[activeModuleIndex - 1]; |
||||||
|
if (prevModule) { |
||||||
|
const prevModuleIndex = activeModuleIndex - 1; |
||||||
|
setActiveModuleIndex(prevModuleIndex); |
||||||
|
setActiveLessonIndex(prevModule.lessons.length - 1); |
||||||
|
|
||||||
|
// Expand the previous module in the sidebar
|
||||||
|
setExpandedModules((prev) => { |
||||||
|
const newState: Record<number, boolean> = {}; |
||||||
|
// Set all modules to collapsed
|
||||||
|
course.modules.forEach((_, idx) => { |
||||||
|
newState[idx] = false; |
||||||
|
}); |
||||||
|
// Expand only the previous module
|
||||||
|
newState[prevModuleIndex] = true; |
||||||
|
return newState; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const currentModule = course.modules[activeModuleIndex]; |
||||||
|
const currentLesson = currentModule?.lessons[activeLessonIndex]; |
||||||
|
const totalModules = course.modules.length; |
||||||
|
const totalLessons = currentModule?.lessons.length || 0; |
||||||
|
|
||||||
|
const totalCourseLessons = course.modules.reduce( |
||||||
|
(total, module) => total + module.lessons.length, |
||||||
|
0, |
||||||
|
); |
||||||
|
const totalDoneLessons = aiCourseProgress?.done?.length || 0; |
||||||
|
const finishedPercentage = Math.round( |
||||||
|
(totalDoneLessons / totalCourseLessons) * 100, |
||||||
|
); |
||||||
|
|
||||||
|
const modals = ( |
||||||
|
<> |
||||||
|
{showUpgradeModal && ( |
||||||
|
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} /> |
||||||
|
)} |
||||||
|
|
||||||
|
{showAILimitsPopup && ( |
||||||
|
<AILimitsPopup |
||||||
|
onClose={() => setShowAILimitsPopup(false)} |
||||||
|
onUpgrade={() => { |
||||||
|
setShowAILimitsPopup(false); |
||||||
|
setShowUpgradeModal(true); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
|
||||||
|
if (error && !isLoading) { |
||||||
|
const isLimitReached = error.includes('limit'); |
||||||
|
|
||||||
|
const icon = isLimitReached ? ( |
||||||
|
<CircleAlert className="mb-4 size-16 text-yellow-500" /> |
||||||
|
) : ( |
||||||
|
<ErrorIcon additionalClasses="mb-4 size-16" /> |
||||||
|
); |
||||||
|
const title = isLimitReached ? 'Limit Reached' : 'Error Generating Course'; |
||||||
|
const message = isLimitReached |
||||||
|
? 'You have reached the daily AI usage limit. Please upgrade your account to continue.' |
||||||
|
: error; |
||||||
|
return ( |
||||||
|
<> |
||||||
|
{modals} |
||||||
|
<div className="flex h-screen flex-col items-center justify-center px-4 text-center"> |
||||||
|
{icon} |
||||||
|
<h1 className="text-2xl font-bold">{title}</h1> |
||||||
|
<p className="my-3 max-w-sm text-balance text-gray-500">{message}</p> |
||||||
|
|
||||||
|
{isLimitReached && ( |
||||||
|
<div className="mt-4"> |
||||||
|
<button |
||||||
|
onClick={() => setShowUpgradeModal(true)} |
||||||
|
className="rounded-md bg-yellow-400 px-6 py-2 text-sm font-medium text-black hover:bg-yellow-500" |
||||||
|
> |
||||||
|
Upgrade to remove Limits |
||||||
|
</button> |
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-black"> |
||||||
|
<a href="/ai-tutor" className="underline underline-offset-2"> |
||||||
|
Back to AI Tutor |
||||||
|
</a> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50"> |
||||||
|
{modals} |
||||||
|
|
||||||
|
<div className="border-b border-gray-200 bg-gray-100"> |
||||||
|
<div className="flex items-center justify-between px-4 py-2"> |
||||||
|
<a |
||||||
|
href="/ai-tutor" |
||||||
|
className="flex flex-row items-center gap-1.5 text-sm font-medium text-gray-700 hover:text-gray-900" |
||||||
|
aria-label="Back to generator" |
||||||
|
> |
||||||
|
<ChevronLeft className="size-4" strokeWidth={2.5} /> |
||||||
|
Back<span className="hidden lg:inline"> to Generator</span> |
||||||
|
</a> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<div className="flex flex-row lg:hidden"> |
||||||
|
<AICourseLimit |
||||||
|
onUpgrade={() => setShowUpgradeModal(true)} |
||||||
|
onShowLimits={() => setShowAILimitsPopup(true)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button |
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)} |
||||||
|
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden" |
||||||
|
> |
||||||
|
{sidebarOpen ? ( |
||||||
|
<X size={17} strokeWidth={3} /> |
||||||
|
) : ( |
||||||
|
<Menu size={17} strokeWidth={3} /> |
||||||
|
)} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 max-lg:py-4 lg:h-[80px]"> |
||||||
|
<div className="flex items-center"> |
||||||
|
<div className="flex flex-col"> |
||||||
|
<h1 className="text-balance text-xl font-bold !leading-tight text-gray-900 max-lg:mb-0.5 max-lg:text-lg"> |
||||||
|
{course.title || 'Loading Course...'} |
||||||
|
</h1> |
||||||
|
<div className="mt-1 flex flex-row items-center gap-2 text-sm text-gray-600 max-lg:text-xs"> |
||||||
|
<span className="font-medium">{totalModules} modules</span> |
||||||
|
<span className="text-gray-400">•</span> |
||||||
|
<span className="font-medium">{totalCourseLessons} lessons</span> |
||||||
|
{viewMode === 'module' && ( |
||||||
|
<span className="flex flex-row items-center gap-1 lg:hidden"> |
||||||
|
<span className="text-gray-400">•</span> |
||||||
|
<button |
||||||
|
className="underline underline-offset-2" |
||||||
|
onClick={() => { |
||||||
|
setExpandedModules({}); |
||||||
|
setViewMode('full'); |
||||||
|
}} |
||||||
|
> |
||||||
|
View outline |
||||||
|
</button> |
||||||
|
</span> |
||||||
|
)} |
||||||
|
{finishedPercentage > 0 && ( |
||||||
|
<> |
||||||
|
<span className="text-gray-400">•</span> |
||||||
|
<span className="font-medium text-green-600"> |
||||||
|
{finishedPercentage}% complete |
||||||
|
</span> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex gap-2"> |
||||||
|
<div className="hidden gap-2 lg:flex"> |
||||||
|
<AICourseLimit |
||||||
|
onUpgrade={() => setShowUpgradeModal(true)} |
||||||
|
onShowLimits={() => setShowAILimitsPopup(true)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{viewMode === 'module' && ( |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
setExpandedModules({}); |
||||||
|
setViewMode('full'); |
||||||
|
}} |
||||||
|
className="flex flex-shrink-0 items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 max-lg:hidden" |
||||||
|
> |
||||||
|
<BookOpenCheck size={18} className="mr-2" /> |
||||||
|
View Course Outline |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden"> |
||||||
|
<aside |
||||||
|
className={cn( |
||||||
|
'fixed inset-y-0 left-0 z-20 w-80 transform overflow-y-auto border-r border-gray-200 bg-white transition-transform duration-200 ease-in-out lg:relative lg:mt-0 lg:translate-x-0', |
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'relative flex min-h-[40px] items-center justify-between border-b border-gray-200 px-3', |
||||||
|
isLoading && 'striped-loader bg-gray-50', |
||||||
|
)} |
||||||
|
> |
||||||
|
{!isLoading && ( |
||||||
|
<div className="text-xs text-black"> |
||||||
|
<span className="relative z-10 rounded-full bg-yellow-400 px-1.5 py-0.5"> |
||||||
|
{finishedPercentage}% |
||||||
|
</span>{' '} |
||||||
|
<span className="relative z-10">Completed</span> |
||||||
|
<span |
||||||
|
style={{ |
||||||
|
width: `${finishedPercentage}%`, |
||||||
|
}} |
||||||
|
className={cn( |
||||||
|
'absolute bottom-0 left-0 top-0', |
||||||
|
'bg-gray-200/50', |
||||||
|
)} |
||||||
|
></span> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
onClick={() => setSidebarOpen(false)} |
||||||
|
className="rounded-md p-1 hover:bg-gray-100 lg:hidden" |
||||||
|
> |
||||||
|
<X size={18} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<AICourseModuleList |
||||||
|
course={course} |
||||||
|
courseSlug={courseSlug} |
||||||
|
activeModuleIndex={ |
||||||
|
viewMode === 'module' ? activeModuleIndex : undefined |
||||||
|
} |
||||||
|
setActiveModuleIndex={setActiveModuleIndex} |
||||||
|
activeLessonIndex={ |
||||||
|
viewMode === 'module' ? activeLessonIndex : undefined |
||||||
|
} |
||||||
|
setActiveLessonIndex={setActiveLessonIndex} |
||||||
|
setSidebarOpen={setSidebarOpen} |
||||||
|
viewMode={viewMode} |
||||||
|
setViewMode={setViewMode} |
||||||
|
expandedModules={expandedModules} |
||||||
|
setExpandedModules={setExpandedModules} |
||||||
|
isLoading={isLoading} |
||||||
|
/> |
||||||
|
</aside> |
||||||
|
|
||||||
|
<main |
||||||
|
className={cn( |
||||||
|
'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out max-lg:p-3', |
||||||
|
sidebarOpen ? 'lg:ml-0' : '', |
||||||
|
)} |
||||||
|
> |
||||||
|
{viewMode === 'module' && ( |
||||||
|
<AICourseModuleView |
||||||
|
courseSlug={courseSlug!} |
||||||
|
activeModuleIndex={activeModuleIndex} |
||||||
|
totalModules={totalModules} |
||||||
|
currentModuleTitle={currentModule?.title || ''} |
||||||
|
activeLessonIndex={activeLessonIndex} |
||||||
|
totalLessons={totalLessons} |
||||||
|
currentLessonTitle={currentLesson || ''} |
||||||
|
onGoToPrevLesson={goToPrevLesson} |
||||||
|
onGoToNextLesson={goToNextLesson} |
||||||
|
key={`${courseSlug}-${activeModuleIndex}-${activeLessonIndex}`} |
||||||
|
onUpgrade={() => setShowUpgradeModal(true)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{viewMode === 'full' && ( |
||||||
|
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl"> |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden', |
||||||
|
isLoading && 'striped-loader', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div> |
||||||
|
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight"> |
||||||
|
{course.title || 'Loading course ..'} |
||||||
|
</h2> |
||||||
|
<p className="text-sm capitalize text-gray-500"> |
||||||
|
{course.title ? course.difficulty : 'Please wait ..'} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{course.title ? ( |
||||||
|
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4"> |
||||||
|
{course.modules.map((courseModule, moduleIdx) => { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={moduleIdx} |
||||||
|
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2" |
||||||
|
> |
||||||
|
<h2 className="mb-4 text-xl font-bold text-gray-800 max-lg:mb-2 max-lg:text-lg max-lg:leading-tight"> |
||||||
|
{courseModule.title} |
||||||
|
</h2> |
||||||
|
<div className="divide-y divide-gray-100"> |
||||||
|
{courseModule.lessons.map((lesson, lessonIdx) => { |
||||||
|
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`; |
||||||
|
const isCompleted = |
||||||
|
aiCourseProgress?.done.includes(key); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={key} |
||||||
|
className="flex cursor-pointer items-center gap-2 px-2 py-2.5 transition-colors hover:bg-gray-100 max-lg:px-0 max-lg:py-1.5" |
||||||
|
onClick={() => { |
||||||
|
setActiveModuleIndex(moduleIdx); |
||||||
|
setActiveLessonIndex(lessonIdx); |
||||||
|
setExpandedModules((prev) => { |
||||||
|
const newState: Record<number, boolean> = |
||||||
|
{}; |
||||||
|
course.modules.forEach((_, idx) => { |
||||||
|
newState[idx] = false; |
||||||
|
}); |
||||||
|
newState[moduleIdx] = true; |
||||||
|
return newState; |
||||||
|
}); |
||||||
|
|
||||||
|
setSidebarOpen(false); |
||||||
|
setViewMode('module'); |
||||||
|
}} |
||||||
|
> |
||||||
|
{!isCompleted && ( |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm font-medium text-gray-800 max-lg:size-5 max-lg:text-xs', |
||||||
|
)} |
||||||
|
> |
||||||
|
{lessonIdx + 1} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
|
||||||
|
{isCompleted && ( |
||||||
|
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" /> |
||||||
|
)} |
||||||
|
|
||||||
|
<p className="flex-1 truncate text-base text-gray-800 max-lg:text-sm"> |
||||||
|
{lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} |
||||||
|
</p> |
||||||
|
<span className="text-sm font-medium text-gray-700 max-lg:hidden"> |
||||||
|
{isCompleted ? 'View' : 'Start'} → |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<div className="flex h-64 items-center justify-center"> |
||||||
|
<Loader2 size={36} className="animate-spin text-gray-300" /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
{sidebarOpen && ( |
||||||
|
<div |
||||||
|
className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 lg:hidden" |
||||||
|
onClick={() => setSidebarOpen(false)} |
||||||
|
></div> |
||||||
|
)} |
||||||
|
</section> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,131 @@ |
|||||||
|
.prose ul li > code, |
||||||
|
.prose ol li > code, |
||||||
|
p code, |
||||||
|
a > code, |
||||||
|
strong > code, |
||||||
|
em > code, |
||||||
|
h1 > code, |
||||||
|
h2 > code, |
||||||
|
h3 > code { |
||||||
|
background: #ebebeb !important; |
||||||
|
color: currentColor !important; |
||||||
|
font-size: 14px; |
||||||
|
font-weight: normal !important; |
||||||
|
} |
||||||
|
|
||||||
|
.course-ai-content.course-content.prose ul li > code, |
||||||
|
.course-ai-content.course-content.prose ol li > code, |
||||||
|
.course-ai-content.course-content.prose p code, |
||||||
|
.course-ai-content.course-content.prose a > code, |
||||||
|
.course-ai-content.course-content.prose strong > code, |
||||||
|
.course-ai-content.course-content.prose em > code, |
||||||
|
.course-ai-content.course-content.prose h1 > code, |
||||||
|
.course-ai-content.course-content.prose h2 > code, |
||||||
|
.course-ai-content.course-content.prose h3 > code, |
||||||
|
.course-notes-content.prose ul li > code, |
||||||
|
.course-notes-content.prose ol li > code, |
||||||
|
.course-notes-content.prose p code, |
||||||
|
.course-notes-content.prose a > code, |
||||||
|
.course-notes-content.prose strong > code, |
||||||
|
.course-notes-content.prose em > code, |
||||||
|
.course-notes-content.prose h1 > code, |
||||||
|
.course-notes-content.prose h2 > code, |
||||||
|
.course-notes-content.prose h3 > code { |
||||||
|
font-size: 12px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.course-ai-content pre { |
||||||
|
-ms-overflow-style: none; |
||||||
|
scrollbar-width: none; |
||||||
|
} |
||||||
|
|
||||||
|
.course-ai-content pre::-webkit-scrollbar { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
.course-ai-content pre, |
||||||
|
.course-notes-content pre { |
||||||
|
overflow: scroll; |
||||||
|
font-size: 15px; |
||||||
|
margin: 10px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.prose ul li > code:before, |
||||||
|
p > code:before, |
||||||
|
.prose ul li > code:after, |
||||||
|
.prose ol li > code:before, |
||||||
|
p > code:before, |
||||||
|
.prose ol li > code:after, |
||||||
|
.course-content h1 > code:after, |
||||||
|
.course-content h1 > code:before, |
||||||
|
.course-content h2 > code:after, |
||||||
|
.course-content h2 > code:before, |
||||||
|
.course-content h3 > code:after, |
||||||
|
.course-content h3 > code:before, |
||||||
|
.course-content h4 > code:after, |
||||||
|
.course-content h4 > code:before, |
||||||
|
p > code:after, |
||||||
|
a > code:after, |
||||||
|
a > code:before { |
||||||
|
content: '' !important; |
||||||
|
} |
||||||
|
|
||||||
|
.course-content.prose ul li > code, |
||||||
|
.course-content.prose ol li > code, |
||||||
|
.course-content p code, |
||||||
|
.course-content a > code, |
||||||
|
.course-content strong > code, |
||||||
|
.course-content em > code, |
||||||
|
.course-content h1 > code, |
||||||
|
.course-content h2 > code, |
||||||
|
.course-content h3 > code, |
||||||
|
.course-content table code { |
||||||
|
background: #f4f4f5 !important; |
||||||
|
border: 1px solid #282a36 !important; |
||||||
|
color: #282a36 !important; |
||||||
|
padding: 2px 4px; |
||||||
|
border-radius: 5px; |
||||||
|
font-size: 16px !important; |
||||||
|
white-space: pre; |
||||||
|
font-weight: normal; |
||||||
|
} |
||||||
|
|
||||||
|
.course-content blockquote { |
||||||
|
font-style: normal; |
||||||
|
} |
||||||
|
|
||||||
|
.course-content.prose blockquote h1, |
||||||
|
.course-content.prose blockquote h2, |
||||||
|
.course-content.prose blockquote h3, |
||||||
|
.course-content.prose blockquote h4 { |
||||||
|
font-style: normal; |
||||||
|
margin-bottom: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.course-content.prose ul li > code:before, |
||||||
|
.course-content p > code:before, |
||||||
|
.course-content.prose ul li > code:after, |
||||||
|
.course-content p > code:after, |
||||||
|
.course-content h2 > code:after, |
||||||
|
.course-content h2 > code:before, |
||||||
|
.course-content table code:before, |
||||||
|
.course-content table code:after, |
||||||
|
.course-content a > code:after, |
||||||
|
.course-content a > code:before, |
||||||
|
.course-content h2 code:after, |
||||||
|
.course-content h2 code:before, |
||||||
|
.course-content h2 code:after, |
||||||
|
.course-content h2 code:before { |
||||||
|
content: '' !important; |
||||||
|
} |
||||||
|
|
||||||
|
.course-content table { |
||||||
|
border-collapse: collapse; |
||||||
|
border: 1px solid black; |
||||||
|
border-radius: 5px; |
||||||
|
} |
||||||
|
|
||||||
|
.course-content table td, |
||||||
|
.course-content table th { |
||||||
|
padding: 5px 10px; |
||||||
|
} |
@ -0,0 +1,77 @@ |
|||||||
|
import { ArrowRightIcon, BotIcon } from 'lucide-react'; |
||||||
|
import { useState } from 'react'; |
||||||
|
import { |
||||||
|
AICourseFollowUpPopover, |
||||||
|
type AIChatHistoryType, |
||||||
|
} from './AICourseFollowUpPopover'; |
||||||
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; |
||||||
|
|
||||||
|
type AICourseFollowUpProps = { |
||||||
|
courseSlug: string; |
||||||
|
moduleTitle: string; |
||||||
|
lessonTitle: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseFollowUp(props: AICourseFollowUpProps) { |
||||||
|
const { courseSlug, moduleTitle, lessonTitle } = props; |
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false); |
||||||
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false); |
||||||
|
|
||||||
|
const [courseAIChatHistory, setCourseAIChatHistory] = useState< |
||||||
|
AIChatHistoryType[] |
||||||
|
>([ |
||||||
|
{ |
||||||
|
role: 'assistant', |
||||||
|
content: |
||||||
|
'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖', |
||||||
|
isDefault: true, |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="relative"> |
||||||
|
<button |
||||||
|
className="mt-4 flex w-full items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-100 p-4 hover:bg-yellow-200 max-lg:mt-3 max-lg:text-sm" |
||||||
|
onClick={() => setIsOpen(true)} |
||||||
|
> |
||||||
|
<BotIcon className="h-4 w-4" /> |
||||||
|
<span> |
||||||
|
<span className="max-sm:hidden">Still confused? </span> |
||||||
|
Ask AI some follow up questions |
||||||
|
</span> |
||||||
|
|
||||||
|
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" /> |
||||||
|
</button> |
||||||
|
|
||||||
|
{showUpgradeModal && ( |
||||||
|
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} /> |
||||||
|
)} |
||||||
|
|
||||||
|
{isOpen && ( |
||||||
|
<AICourseFollowUpPopover |
||||||
|
courseSlug={courseSlug} |
||||||
|
moduleTitle={moduleTitle} |
||||||
|
lessonTitle={lessonTitle} |
||||||
|
courseAIChatHistory={courseAIChatHistory} |
||||||
|
setCourseAIChatHistory={setCourseAIChatHistory} |
||||||
|
onUpgradeClick={() => { |
||||||
|
setIsOpen(false); |
||||||
|
setShowUpgradeModal(true); |
||||||
|
}} |
||||||
|
onOutsideClick={() => { |
||||||
|
if (!isOpen) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsOpen(false); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{isOpen && ( |
||||||
|
<div className="pointer-events-none fixed inset-0 z-50 bg-black/50" /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,382 @@ |
|||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { BookOpen, Bot, Code, HelpCircle, LockIcon, Send } from 'lucide-react'; |
||||||
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; |
||||||
|
import { flushSync } from 'react-dom'; |
||||||
|
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||||
|
import { readAICourseLessonStream } from '../../helper/read-stream'; |
||||||
|
import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; |
||||||
|
import { useToast } from '../../hooks/use-toast'; |
||||||
|
import { |
||||||
|
markdownToHtml, |
||||||
|
markdownToHtmlWithHighlighting, |
||||||
|
} from '../../lib/markdown'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { getAiCourseLimitOptions } from '../../queries/ai-course'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import TextareaAutosize from 'react-textarea-autosize'; |
||||||
|
|
||||||
|
export type AllowedAIChatRole = 'user' | 'assistant'; |
||||||
|
export type AIChatHistoryType = { |
||||||
|
role: AllowedAIChatRole; |
||||||
|
content: string; |
||||||
|
isDefault?: boolean; |
||||||
|
html?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type AICourseFollowUpPopoverProps = { |
||||||
|
courseSlug: string; |
||||||
|
moduleTitle: string; |
||||||
|
lessonTitle: string; |
||||||
|
|
||||||
|
courseAIChatHistory: AIChatHistoryType[]; |
||||||
|
setCourseAIChatHistory: (value: AIChatHistoryType[]) => void; |
||||||
|
|
||||||
|
onOutsideClick?: () => void; |
||||||
|
onUpgradeClick: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) { |
||||||
|
const { |
||||||
|
courseSlug, |
||||||
|
moduleTitle, |
||||||
|
lessonTitle, |
||||||
|
onOutsideClick, |
||||||
|
onUpgradeClick, |
||||||
|
|
||||||
|
courseAIChatHistory, |
||||||
|
setCourseAIChatHistory, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null); |
||||||
|
const scrollareaRef = useRef<HTMLDivElement | null>(null); |
||||||
|
|
||||||
|
const [isStreamingMessage, setIsStreamingMessage] = useState(false); |
||||||
|
const [message, setMessage] = useState(''); |
||||||
|
const [streamedMessage, setStreamedMessage] = useState(''); |
||||||
|
|
||||||
|
useOutsideClick(containerRef, onOutsideClick); |
||||||
|
|
||||||
|
const { data: tokenUsage, isLoading } = useQuery( |
||||||
|
getAiCourseLimitOptions(), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); |
||||||
|
|
||||||
|
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => { |
||||||
|
e.preventDefault(); |
||||||
|
|
||||||
|
const trimmedMessage = message.trim(); |
||||||
|
if ( |
||||||
|
!trimmedMessage || |
||||||
|
isStreamingMessage || |
||||||
|
!isLoggedIn() || |
||||||
|
isLimitExceeded || |
||||||
|
isLoading |
||||||
|
) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const newMessages: AIChatHistoryType[] = [ |
||||||
|
...courseAIChatHistory, |
||||||
|
{ |
||||||
|
role: 'user', |
||||||
|
content: trimmedMessage, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
flushSync(() => { |
||||||
|
setCourseAIChatHistory(newMessages); |
||||||
|
setMessage(''); |
||||||
|
}); |
||||||
|
|
||||||
|
scrollToBottom(); |
||||||
|
completeCourseAIChat(newMessages); |
||||||
|
}; |
||||||
|
|
||||||
|
const scrollToBottom = () => { |
||||||
|
scrollareaRef.current?.scrollTo({ |
||||||
|
top: scrollareaRef.current.scrollHeight, |
||||||
|
behavior: 'smooth', |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => { |
||||||
|
setIsStreamingMessage(true); |
||||||
|
|
||||||
|
const response = await fetch( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`, |
||||||
|
{ |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
credentials: 'include', |
||||||
|
body: JSON.stringify({ |
||||||
|
moduleTitle, |
||||||
|
lessonTitle, |
||||||
|
messages: messages.slice(-10), |
||||||
|
}), |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
|
||||||
|
toast.error(data?.message || 'Something went wrong'); |
||||||
|
setCourseAIChatHistory([...messages].slice(0, messages.length - 1)); |
||||||
|
setIsStreamingMessage(false); |
||||||
|
|
||||||
|
if (data.status === 401) { |
||||||
|
removeAuthToken(); |
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const reader = response.body?.getReader(); |
||||||
|
|
||||||
|
if (!reader) { |
||||||
|
setIsStreamingMessage(false); |
||||||
|
toast.error('Something went wrong'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await readAICourseLessonStream(reader, { |
||||||
|
onStream: async (content) => { |
||||||
|
flushSync(() => { |
||||||
|
setStreamedMessage(content); |
||||||
|
}); |
||||||
|
|
||||||
|
scrollToBottom(); |
||||||
|
}, |
||||||
|
onStreamEnd: async (content) => { |
||||||
|
const newMessages: AIChatHistoryType[] = [ |
||||||
|
...messages, |
||||||
|
{ |
||||||
|
role: 'assistant', |
||||||
|
content, |
||||||
|
html: await markdownToHtmlWithHighlighting(content), |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
flushSync(() => { |
||||||
|
setStreamedMessage(''); |
||||||
|
setIsStreamingMessage(false); |
||||||
|
setCourseAIChatHistory(newMessages); |
||||||
|
}); |
||||||
|
|
||||||
|
queryClient.invalidateQueries(getAiCourseLimitOptions()); |
||||||
|
scrollToBottom(); |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
setIsStreamingMessage(false); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
scrollToBottom(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="absolute bottom-0 left-0 z-[99] flex h-[500px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow" |
||||||
|
ref={containerRef} |
||||||
|
> |
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm"> |
||||||
|
<h4 className="text-base font-medium">Course AI</h4> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div |
||||||
|
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto" |
||||||
|
ref={scrollareaRef} |
||||||
|
> |
||||||
|
<div className="absolute inset-0 flex flex-col"> |
||||||
|
<div className="flex grow flex-col justify-end"> |
||||||
|
<div className="flex flex-col justify-end gap-2 px-3 py-2"> |
||||||
|
{courseAIChatHistory.map((chat, index) => { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<AIChatCard |
||||||
|
key={`chat-${index}`} |
||||||
|
role={chat.role} |
||||||
|
content={chat.content} |
||||||
|
html={chat.html} |
||||||
|
/> |
||||||
|
|
||||||
|
{chat.isDefault && ( |
||||||
|
<div className="mb-1 mt-0.5"> |
||||||
|
<div className="grid grid-cols-2 gap-2"> |
||||||
|
{capabilities.map((capability, index) => ( |
||||||
|
<CapabilityCard |
||||||
|
key={`capability-${index}`} |
||||||
|
{...capability} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
})} |
||||||
|
|
||||||
|
{isStreamingMessage && !streamedMessage && ( |
||||||
|
<AIChatCard role="assistant" content="Thinking..." /> |
||||||
|
)} |
||||||
|
|
||||||
|
{streamedMessage && ( |
||||||
|
<AIChatCard role="assistant" content={streamedMessage} /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<form |
||||||
|
className="relative flex items-start border-t border-gray-200 text-sm" |
||||||
|
onSubmit={handleChatSubmit} |
||||||
|
> |
||||||
|
{isLimitExceeded && ( |
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white"> |
||||||
|
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} /> |
||||||
|
<p className="cursor-not-allowed">Limit reached for today</p> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
onUpgradeClick(); |
||||||
|
}} |
||||||
|
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300" |
||||||
|
> |
||||||
|
Upgrade for more |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<TextareaAutosize |
||||||
|
className="h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none" |
||||||
|
placeholder="Ask AI anything about the lesson..." |
||||||
|
value={message} |
||||||
|
onChange={(e) => setMessage(e.target.value)} |
||||||
|
autoFocus={true} |
||||||
|
onKeyDown={(e) => { |
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { |
||||||
|
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>); |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isStreamingMessage || isLimitExceeded} |
||||||
|
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black" |
||||||
|
> |
||||||
|
<Send className="size-4 stroke-[2.5]" /> |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
type AIChatCardProps = { |
||||||
|
role: AllowedAIChatRole; |
||||||
|
content: string; |
||||||
|
html?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
function AIChatCard(props: AIChatCardProps) { |
||||||
|
const { role, content, html: defaultHtml } = props; |
||||||
|
|
||||||
|
const html = useMemo(() => { |
||||||
|
if (defaultHtml) { |
||||||
|
return defaultHtml; |
||||||
|
} |
||||||
|
|
||||||
|
return markdownToHtml(content, false); |
||||||
|
}, [content, defaultHtml]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex flex-col rounded-lg', |
||||||
|
role === 'user' ? 'bg-gray-300/30' : 'bg-yellow-500/30', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div className="flex items-start gap-2.5 p-3"> |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex size-6 shrink-0 items-center justify-center rounded-full', |
||||||
|
role === 'user' |
||||||
|
? 'bg-gray-200 text-black' |
||||||
|
: 'bg-yellow-400 text-black', |
||||||
|
)} |
||||||
|
> |
||||||
|
<Bot className="size-4 stroke-[2.5]" /> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm" |
||||||
|
dangerouslySetInnerHTML={{ __html: html }} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
type CapabilityCardProps = { |
||||||
|
icon: React.ReactNode; |
||||||
|
title: string; |
||||||
|
description: string; |
||||||
|
className?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
function CapabilityCard({ |
||||||
|
icon, |
||||||
|
title, |
||||||
|
description, |
||||||
|
className, |
||||||
|
}: CapabilityCardProps) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex flex-col gap-2 rounded-lg bg-yellow-500/10 p-3', |
||||||
|
className, |
||||||
|
)} |
||||||
|
> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
{icon} |
||||||
|
<span className="text-[13px] font-medium leading-none text-black"> |
||||||
|
{title} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<p className="text-[12px] leading-normal text-gray-600">{description}</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const capabilities = [ |
||||||
|
{ |
||||||
|
icon: ( |
||||||
|
<HelpCircle |
||||||
|
className="size-4 shrink-0 text-yellow-600" |
||||||
|
strokeWidth={2.5} |
||||||
|
/> |
||||||
|
), |
||||||
|
title: 'Clarify Concepts', |
||||||
|
description: "If you don't understand a concept, ask me to clarify it", |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: ( |
||||||
|
<BookOpen className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} /> |
||||||
|
), |
||||||
|
title: 'More Details', |
||||||
|
description: 'Get deeper insights about topics covered in the lesson', |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: ( |
||||||
|
<Code className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} /> |
||||||
|
), |
||||||
|
title: 'Code Help', |
||||||
|
description: 'Share your code and ask me to help you debug it', |
||||||
|
}, |
||||||
|
{ |
||||||
|
icon: <Bot className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />, |
||||||
|
title: 'Best Practices', |
||||||
|
description: 'Share your code and ask me the best way to do something', |
||||||
|
}, |
||||||
|
] as const; |
@ -0,0 +1,78 @@ |
|||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { getAiCourseLimitOptions } from '../../queries/ai-course'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { billingDetailsOptions } from '../../queries/billing'; |
||||||
|
import { getPercentage } from '../../helper/number'; |
||||||
|
import { Gift, Info } from 'lucide-react'; |
||||||
|
|
||||||
|
type AICourseLimitProps = { |
||||||
|
onUpgrade: () => void; |
||||||
|
onShowLimits: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseLimit(props: AICourseLimitProps) { |
||||||
|
const { onUpgrade, onShowLimits } = props; |
||||||
|
|
||||||
|
const { data: limits, isLoading } = useQuery( |
||||||
|
getAiCourseLimitOptions(), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = |
||||||
|
useQuery(billingDetailsOptions(), queryClient); |
||||||
|
|
||||||
|
if (isLoading || !limits || isBillingDetailsLoading || !userBillingDetails) { |
||||||
|
return ( |
||||||
|
<div className="hidden h-[38px] w-[208.09px] animate-pulse rounded-lg border border-gray-200 bg-gray-200 lg:block"></div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const { used, limit } = limits; |
||||||
|
|
||||||
|
const totalPercentage = getPercentage(used, limit); |
||||||
|
|
||||||
|
// has consumed 80% of the limit
|
||||||
|
const isNearLimit = used >= limit * 0.8; |
||||||
|
const isPaidUser = userBillingDetails.status !== 'none'; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<button |
||||||
|
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden" |
||||||
|
onClick={() => onShowLimits()} |
||||||
|
> |
||||||
|
<Info className="size-4" /> |
||||||
|
{totalPercentage}% limit used |
||||||
|
</button> |
||||||
|
|
||||||
|
{(!isPaidUser || isNearLimit) && ( |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
onShowLimits(); |
||||||
|
}} |
||||||
|
className="relative hidden h-full min-h-[38px] cursor-pointer items-center overflow-hidden rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 lg:flex" |
||||||
|
> |
||||||
|
<span className="relative z-10"> |
||||||
|
{totalPercentage}% of the daily limit used |
||||||
|
</span> |
||||||
|
<div |
||||||
|
className="absolute inset-0 h-full bg-gray-200/80" |
||||||
|
style={{ |
||||||
|
width: `${totalPercentage}%`, |
||||||
|
}} |
||||||
|
></div> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isPaidUser && ( |
||||||
|
<button |
||||||
|
className="hidden items-center justify-center gap-1 rounded-md bg-yellow-400 px-4 py-1 text-sm font-medium underline-offset-2 hover:bg-yellow-500 lg:flex" |
||||||
|
onClick={() => onUpgrade()} |
||||||
|
> |
||||||
|
<Gift className="size-4" /> |
||||||
|
Upgrade |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,208 @@ |
|||||||
|
import { type Dispatch, type SetStateAction, useState } from 'react'; |
||||||
|
import type { AiCourse } from '../../lib/ai'; |
||||||
|
import { Check, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { getAiCourseProgressOptions } from '../../queries/ai-course'; |
||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { slugify } from '../../lib/slugger'; |
||||||
|
import { CheckIcon } from '../ReactIcons/CheckIcon'; |
||||||
|
import { CircularProgress } from './CircularProgress'; |
||||||
|
|
||||||
|
type AICourseModuleListProps = { |
||||||
|
course: AiCourse; |
||||||
|
courseSlug?: string; |
||||||
|
activeModuleIndex: number | undefined; |
||||||
|
setActiveModuleIndex: (index: number) => void; |
||||||
|
activeLessonIndex: number | undefined; |
||||||
|
setActiveLessonIndex: (index: number) => void; |
||||||
|
|
||||||
|
setSidebarOpen: (open: boolean) => void; |
||||||
|
|
||||||
|
viewMode: 'module' | 'full'; |
||||||
|
setViewMode: (mode: 'module' | 'full') => void; |
||||||
|
|
||||||
|
expandedModules: Record<number, boolean>; |
||||||
|
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; |
||||||
|
|
||||||
|
isLoading: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseModuleList(props: AICourseModuleListProps) { |
||||||
|
const { |
||||||
|
course, |
||||||
|
courseSlug, |
||||||
|
activeModuleIndex, |
||||||
|
setActiveModuleIndex, |
||||||
|
activeLessonIndex, |
||||||
|
setActiveLessonIndex, |
||||||
|
setSidebarOpen, |
||||||
|
setViewMode, |
||||||
|
expandedModules, |
||||||
|
setExpandedModules, |
||||||
|
|
||||||
|
isLoading, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const { data: aiCourseProgress } = useQuery( |
||||||
|
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const toggleModule = (index: number) => { |
||||||
|
setExpandedModules((prev) => { |
||||||
|
// If this module is already expanded, collapse it
|
||||||
|
if (prev[index]) { |
||||||
|
return { |
||||||
|
...prev, |
||||||
|
[index]: false, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, collapse all modules and expand only this one
|
||||||
|
const newState: Record<number, boolean> = {}; |
||||||
|
// Set all modules to collapsed
|
||||||
|
course.modules.forEach((_, idx) => { |
||||||
|
newState[idx] = false; |
||||||
|
}); |
||||||
|
// Expand only the clicked module
|
||||||
|
newState[index] = true; |
||||||
|
return newState; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const { done = [] } = aiCourseProgress || {}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<nav className="bg-gray-100"> |
||||||
|
{course.modules.map((courseModule, moduleIdx) => { |
||||||
|
const totalLessons = courseModule.lessons.length; |
||||||
|
const completedLessons = courseModule.lessons.filter((lesson) => { |
||||||
|
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`; |
||||||
|
return done.includes(key); |
||||||
|
}).length; |
||||||
|
|
||||||
|
const percentage = Math.round((completedLessons / totalLessons) * 100); |
||||||
|
const isActive = expandedModules[moduleIdx]; |
||||||
|
const isModuleCompleted = completedLessons === totalLessons; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div key={moduleIdx} className="rounded-md"> |
||||||
|
<button |
||||||
|
onClick={() => toggleModule(moduleIdx)} |
||||||
|
className={cn( |
||||||
|
'relative z-10 flex w-full cursor-pointer flex-row items-center gap-2 border-b border-b-gray-200 bg-white px-2 py-3 text-base text-gray-600 hover:bg-gray-100', |
||||||
|
activeModuleIndex === moduleIdx
|
||||||
|
? 'text-gray-900' |
||||||
|
: 'text-gray-700', |
||||||
|
moduleIdx === 0 && 'pt-4', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2"> |
||||||
|
<div className="flex-shrink-0"> |
||||||
|
<CircularProgress |
||||||
|
percentage={percentage} |
||||||
|
isVisible={!isModuleCompleted} |
||||||
|
isActive={isActive} |
||||||
|
isLoading={isLoading} |
||||||
|
> |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'flex size-[21px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white', |
||||||
|
{ |
||||||
|
'bg-black': isActive, |
||||||
|
'bg-green-600': isModuleCompleted, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
{!isModuleCompleted && moduleIdx + 1} |
||||||
|
{isModuleCompleted && ( |
||||||
|
<Check className="size-3 stroke-[3] text-white" /> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
</CircularProgress> |
||||||
|
</div> |
||||||
|
<span className="flex flex-1 items-center break-words text-left text-sm leading-relaxed"> |
||||||
|
{courseModule.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div className="ml-auto self-center"> |
||||||
|
{expandedModules[moduleIdx] ? ( |
||||||
|
<ChevronDownIcon size={16} className="flex-shrink-0" /> |
||||||
|
) : ( |
||||||
|
<ChevronRightIcon size={16} className="flex-shrink-0" /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
|
||||||
|
{/* Lessons */} |
||||||
|
{expandedModules[moduleIdx] && ( |
||||||
|
<div className="flex flex-col border-b border-b-gray-200 bg-gray-100"> |
||||||
|
{courseModule.lessons.map((lesson, lessonIdx) => { |
||||||
|
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`; |
||||||
|
const isCompleted = done.includes(key); |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
key={key} |
||||||
|
onClick={() => { |
||||||
|
setActiveModuleIndex(moduleIdx); |
||||||
|
setActiveLessonIndex(lessonIdx); |
||||||
|
setExpandedModules((prev) => { |
||||||
|
const newState: Record<number, boolean> = {}; |
||||||
|
course.modules.forEach((_, idx) => { |
||||||
|
newState[idx] = false; |
||||||
|
}); |
||||||
|
newState[moduleIdx] = true; |
||||||
|
return newState; |
||||||
|
}); |
||||||
|
setSidebarOpen(false); |
||||||
|
setViewMode('module'); |
||||||
|
}} |
||||||
|
className={cn( |
||||||
|
'flex gap-2.5 w-full cursor-pointer items-center py-3 pl-3.5 pr-2 text-left text-sm leading-normal', |
||||||
|
activeModuleIndex === moduleIdx && |
||||||
|
activeLessonIndex === lessonIdx |
||||||
|
? 'bg-gray-200 text-black' |
||||||
|
: 'text-gray-600 hover:bg-gray-200/70', |
||||||
|
)} |
||||||
|
> |
||||||
|
{isCompleted ? ( |
||||||
|
<CheckIcon |
||||||
|
additionalClasses={cn( |
||||||
|
'size-[18px] relative bg-white rounded-full top-[2px] flex-shrink-0 text-green-600', |
||||||
|
{ |
||||||
|
'text-black': |
||||||
|
activeModuleIndex === moduleIdx && |
||||||
|
activeLessonIndex === lessonIdx, |
||||||
|
}, |
||||||
|
)} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'flex size-[18px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white', |
||||||
|
{ |
||||||
|
'bg-black': |
||||||
|
activeModuleIndex === moduleIdx && |
||||||
|
activeLessonIndex === lessonIdx, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
{lessonIdx + 1} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
<span className="break-words"> |
||||||
|
{lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</nav> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,344 @@ |
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'; |
||||||
|
import { |
||||||
|
CheckIcon, |
||||||
|
ChevronLeft, |
||||||
|
ChevronRight, |
||||||
|
Loader2Icon, |
||||||
|
LockIcon, |
||||||
|
XIcon, |
||||||
|
} from 'lucide-react'; |
||||||
|
import { useEffect, useMemo, useState } from 'react'; |
||||||
|
import { readAICourseLessonStream } from '../../helper/read-stream'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; |
||||||
|
import { |
||||||
|
markdownToHtml, |
||||||
|
markdownToHtmlWithHighlighting, |
||||||
|
} from '../../lib/markdown'; |
||||||
|
import { httpPatch } from '../../lib/query-http'; |
||||||
|
import { slugify } from '../../lib/slugger'; |
||||||
|
import { |
||||||
|
getAiCourseLimitOptions, |
||||||
|
getAiCourseProgressOptions, |
||||||
|
type AICourseProgressDocument, |
||||||
|
} from '../../queries/ai-course'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { AICourseFollowUp } from './AICourseFollowUp'; |
||||||
|
import './AICourseFollowUp.css'; |
||||||
|
|
||||||
|
type AICourseModuleViewProps = { |
||||||
|
courseSlug: string; |
||||||
|
|
||||||
|
activeModuleIndex: number; |
||||||
|
totalModules: number; |
||||||
|
currentModuleTitle: string; |
||||||
|
activeLessonIndex: number; |
||||||
|
totalLessons: number; |
||||||
|
currentLessonTitle: string; |
||||||
|
|
||||||
|
onGoToPrevLesson: () => void; |
||||||
|
onGoToNextLesson: () => void; |
||||||
|
|
||||||
|
onUpgrade: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AICourseModuleView(props: AICourseModuleViewProps) { |
||||||
|
const { |
||||||
|
courseSlug, |
||||||
|
|
||||||
|
activeModuleIndex, |
||||||
|
totalModules, |
||||||
|
currentModuleTitle, |
||||||
|
activeLessonIndex, |
||||||
|
totalLessons, |
||||||
|
currentLessonTitle, |
||||||
|
|
||||||
|
onGoToPrevLesson, |
||||||
|
onGoToNextLesson, |
||||||
|
|
||||||
|
onUpgrade, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
const [isGenerating, setIsGenerating] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
|
||||||
|
const [lessonHtml, setLessonHtml] = useState(''); |
||||||
|
const { data: aiCourseProgress } = useQuery( |
||||||
|
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`; |
||||||
|
const isLessonDone = aiCourseProgress?.done.includes(lessonId); |
||||||
|
|
||||||
|
const abortController = useMemo( |
||||||
|
() => new AbortController(), |
||||||
|
[activeModuleIndex, activeLessonIndex], |
||||||
|
); |
||||||
|
|
||||||
|
const generateAiCourseContent = async () => { |
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
setLessonHtml(''); |
||||||
|
|
||||||
|
if (!isLoggedIn()) { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Please login to generate course content'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!currentModuleTitle || !currentLessonTitle) { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Invalid module title or lesson title'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const response = await fetch( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-lesson/${courseSlug}`, |
||||||
|
{ |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
signal: abortController.signal, |
||||||
|
credentials: 'include', |
||||||
|
body: JSON.stringify({ |
||||||
|
moduleTitle: currentModuleTitle, |
||||||
|
lessonTitle: currentLessonTitle, |
||||||
|
modulePosition: activeModuleIndex, |
||||||
|
lessonPosition: activeLessonIndex, |
||||||
|
totalLessonsInModule: totalLessons, |
||||||
|
}), |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
|
||||||
|
setError(data?.message || 'Something went wrong'); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
// Logout user if token is invalid
|
||||||
|
if (data.status === 401) { |
||||||
|
removeAuthToken(); |
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const reader = response.body?.getReader(); |
||||||
|
|
||||||
|
if (!reader) { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Something went wrong'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
setIsGenerating(true); |
||||||
|
await readAICourseLessonStream(reader, { |
||||||
|
onStream: async (result) => { |
||||||
|
if (abortController.signal.aborted) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setLessonHtml(markdownToHtml(result, false)); |
||||||
|
}, |
||||||
|
onStreamEnd: async (result) => { |
||||||
|
if (abortController.signal.aborted) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setLessonHtml(await markdownToHtmlWithHighlighting(result)); |
||||||
|
queryClient.invalidateQueries(getAiCourseLimitOptions()); |
||||||
|
setIsGenerating(false); |
||||||
|
}, |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const { mutate: toggleDone, isPending: isTogglingDone } = useMutation( |
||||||
|
{ |
||||||
|
mutationFn: () => { |
||||||
|
return httpPatch<AICourseProgressDocument>( |
||||||
|
`/v1-toggle-done-ai-lesson/${courseSlug}`, |
||||||
|
{ |
||||||
|
lessonId, |
||||||
|
}, |
||||||
|
); |
||||||
|
}, |
||||||
|
onSuccess: (data) => { |
||||||
|
queryClient.setQueryData( |
||||||
|
['ai-course-progress', { aiCourseSlug: courseSlug }], |
||||||
|
data, |
||||||
|
); |
||||||
|
}, |
||||||
|
}, |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
generateAiCourseContent(); |
||||||
|
}, [currentModuleTitle, currentLessonTitle]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
return () => { |
||||||
|
abortController.abort(); |
||||||
|
}; |
||||||
|
}, [abortController]); |
||||||
|
|
||||||
|
const cantGoForward = |
||||||
|
(activeModuleIndex === totalModules - 1 && |
||||||
|
activeLessonIndex === totalLessons - 1) || |
||||||
|
isGenerating || |
||||||
|
isLoading; |
||||||
|
|
||||||
|
const cantGoBack = |
||||||
|
(activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="mx-auto max-w-4xl"> |
||||||
|
<div className="relative rounded-lg border border-gray-200 bg-white p-6 shadow-sm max-lg:px-4 max-lg:pb-4 max-lg:pt-3"> |
||||||
|
{(isGenerating || isLoading) && ( |
||||||
|
<div className="absolute right-3 top-3 flex items-center justify-center"> |
||||||
|
<Loader2Icon |
||||||
|
size={18} |
||||||
|
strokeWidth={3} |
||||||
|
className="animate-spin text-gray-400/70" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="mb-4 flex items-center justify-between"> |
||||||
|
<div className="text-sm text-gray-500"> |
||||||
|
Lesson {activeLessonIndex + 1} of {totalLessons} |
||||||
|
</div> |
||||||
|
|
||||||
|
{!isGenerating && !isLoading && ( |
||||||
|
<> |
||||||
|
<button |
||||||
|
disabled={isLoading || isTogglingDone} |
||||||
|
className={cn( |
||||||
|
'absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs', |
||||||
|
isLessonDone |
||||||
|
? 'bg-red-500 hover:bg-red-600' |
||||||
|
: 'bg-green-500 hover:bg-green-600', |
||||||
|
)} |
||||||
|
onClick={() => toggleDone()} |
||||||
|
> |
||||||
|
{isTogglingDone ? ( |
||||||
|
<> |
||||||
|
<Loader2Icon |
||||||
|
size={16} |
||||||
|
strokeWidth={3} |
||||||
|
className="animate-spin text-white" |
||||||
|
/> |
||||||
|
Please wait ... |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
{isLessonDone ? ( |
||||||
|
<> |
||||||
|
<XIcon size={16} /> |
||||||
|
Mark as Undone |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<CheckIcon size={16} /> |
||||||
|
Mark as Done |
||||||
|
</> |
||||||
|
)} |
||||||
|
</> |
||||||
|
)} |
||||||
|
</button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl"> |
||||||
|
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} |
||||||
|
</h1> |
||||||
|
|
||||||
|
{!error && isLoggedIn() && ( |
||||||
|
<div |
||||||
|
className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm" |
||||||
|
dangerouslySetInnerHTML={{ __html: lessonHtml }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{error && isLoggedIn() && ( |
||||||
|
<div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80"> |
||||||
|
{error.includes('reached the limit') ? ( |
||||||
|
<div className="flex max-w-sm flex-col items-center text-center"> |
||||||
|
<h2 className="text-xl font-semibold text-red-600"> |
||||||
|
Limit reached |
||||||
|
</h2> |
||||||
|
<p className="my-3 text-red-600"> |
||||||
|
You have reached the AI usage limit for today. Please upgrade |
||||||
|
your account to continue. |
||||||
|
</p> |
||||||
|
|
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
onUpgrade(); |
||||||
|
}} |
||||||
|
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700" |
||||||
|
> |
||||||
|
Upgrade Account |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<p className="text-red-600">{error}</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isLoggedIn() && ( |
||||||
|
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8"> |
||||||
|
<LockIcon className="size-7 stroke-[2] text-gray-400/90" /> |
||||||
|
<p className="text-sm text-gray-500"> |
||||||
|
Please login to generate course content |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-between"> |
||||||
|
<button |
||||||
|
onClick={onGoToPrevLesson} |
||||||
|
disabled={cantGoBack} |
||||||
|
className={cn( |
||||||
|
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm', |
||||||
|
cantGoBack |
||||||
|
? 'cursor-not-allowed text-gray-400' |
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200', |
||||||
|
)} |
||||||
|
> |
||||||
|
<ChevronLeft size={16} className="mr-2" /> |
||||||
|
Previous <span className="hidden lg:inline"> Lesson</span> |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
onClick={onGoToNextLesson} |
||||||
|
disabled={cantGoForward} |
||||||
|
className={cn( |
||||||
|
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm', |
||||||
|
cantGoForward |
||||||
|
? 'cursor-not-allowed text-gray-400' |
||||||
|
: 'bg-gray-800 text-white hover:bg-gray-700', |
||||||
|
)} |
||||||
|
> |
||||||
|
Next <span className="hidden lg:inline"> Lesson</span> |
||||||
|
<ChevronRight size={16} className="ml-2" /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{!isGenerating && !isLoading && ( |
||||||
|
<AICourseFollowUp |
||||||
|
courseSlug={courseSlug} |
||||||
|
moduleTitle={currentModuleTitle} |
||||||
|
lessonTitle={currentLessonTitle} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,103 @@ |
|||||||
|
import { Gift } from 'lucide-react'; |
||||||
|
import { Modal } from '../Modal'; |
||||||
|
import { formatCommaNumber } from '../../lib/number'; |
||||||
|
import { billingDetailsOptions } from '../../queries/billing'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { getAiCourseLimitOptions } from '../../queries/ai-course'; |
||||||
|
|
||||||
|
type AILimitsPopupProps = { |
||||||
|
onClose: () => void; |
||||||
|
onUpgrade: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function AILimitsPopup(props: AILimitsPopupProps) { |
||||||
|
const { onClose, onUpgrade } = props; |
||||||
|
|
||||||
|
const { data: limits, isLoading } = useQuery( |
||||||
|
getAiCourseLimitOptions(), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const { used, limit } = limits ?? { used: 0, limit: 0 }; |
||||||
|
|
||||||
|
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = |
||||||
|
useQuery(billingDetailsOptions(), queryClient); |
||||||
|
|
||||||
|
const isPaidUser = userBillingDetails?.status !== 'none'; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
onClose={onClose} |
||||||
|
wrapperClassName="rounded-xl max-w-xl w-full h-auto" |
||||||
|
bodyClassName="p-6" |
||||||
|
overlayClassName="items-start md:items-center" |
||||||
|
> |
||||||
|
<h2 className="mb-8 text-center text-xl font-semibold"> |
||||||
|
Daily AI Limits |
||||||
|
</h2> |
||||||
|
|
||||||
|
{/* Usage Progress Bar */} |
||||||
|
<div className="mb-6"> |
||||||
|
<div className="mb-2 flex justify-between"> |
||||||
|
<span className="text-sm font-medium"> |
||||||
|
Usage: {formatCommaNumber(used)} / |
||||||
|
{formatCommaNumber(limit)} tokens |
||||||
|
</span> |
||||||
|
<span className="text-sm font-medium"> |
||||||
|
{Math.round((used / limit) * 100)}% |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div className="h-2.5 w-full rounded-full bg-gray-200"> |
||||||
|
<div |
||||||
|
className="h-2.5 rounded-full bg-yellow-500" |
||||||
|
style={{ width: `${Math.min(100, (used / limit) * 100)}%` }} |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Usage Stats */} |
||||||
|
<div className="rounded-lg bg-gray-50 p-4"> |
||||||
|
<div className="grid grid-cols-2 gap-4"> |
||||||
|
<div> |
||||||
|
<p className="text-sm text-gray-500">Used Today</p> |
||||||
|
<p className="text-2xl font-bold">{formatCommaNumber(used)}</p> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<p className="text-sm text-gray-500">Daily Limit</p> |
||||||
|
<p className="text-2xl font-bold">{formatCommaNumber(limit)}</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Explanation */} |
||||||
|
<div className="mt-2"> |
||||||
|
<div className="space-y-3 text-gray-600"> |
||||||
|
<p className="text-sm"> |
||||||
|
Limit resets every 24 hours.{' '} |
||||||
|
{!isPaidUser && 'Consider upgrading for more tokens.'} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Action Button */} |
||||||
|
<div className="mt-auto flex flex-col gap-2 pt-4"> |
||||||
|
{!isPaidUser && ( |
||||||
|
<button |
||||||
|
onClick={onUpgrade} |
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-yellow-400 px-4 py-2.5 text-sm font-medium text-black transition-colors hover:bg-yellow-500" |
||||||
|
> |
||||||
|
<Gift className="size-4" /> |
||||||
|
Upgrade to Unlimited |
||||||
|
</button> |
||||||
|
)} |
||||||
|
<button |
||||||
|
onClick={onClose} |
||||||
|
className="w-full rounded-lg bg-gray-200 px-4 py-2.5 text-sm text-gray-600 transition-colors hover:bg-gray-300" |
||||||
|
> |
||||||
|
Close |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,57 @@ |
|||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
|
||||||
|
export function ChapterNumberSkeleton() { |
||||||
|
return ( |
||||||
|
<div className="h-[28px] w-[28px] animate-pulse rounded-full bg-gray-200" /> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
type CircularProgressProps = { |
||||||
|
percentage: number; |
||||||
|
children: React.ReactNode; |
||||||
|
isVisible?: boolean; |
||||||
|
isActive?: boolean; |
||||||
|
isLoading?: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export function CircularProgress(props: CircularProgressProps) { |
||||||
|
const { |
||||||
|
percentage, |
||||||
|
children, |
||||||
|
isVisible = true, |
||||||
|
isActive = false, |
||||||
|
isLoading = false, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const circumference = 2 * Math.PI * 13; |
||||||
|
const strokeDasharray = `${circumference}`; |
||||||
|
const strokeDashoffset = circumference - (percentage / 100) * circumference; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="relative flex h-[28px] w-[28px] flex-shrink-0 items-center justify-center"> |
||||||
|
{isVisible && !isLoading && ( |
||||||
|
<svg className="absolute h-full w-full -rotate-90"> |
||||||
|
<circle |
||||||
|
cx="14" |
||||||
|
cy="14" |
||||||
|
r="13" |
||||||
|
stroke="currentColor" |
||||||
|
strokeWidth="1.75" |
||||||
|
fill="none" |
||||||
|
className={cn('text-gray-400/70', { |
||||||
|
'text-black': isActive, |
||||||
|
})} |
||||||
|
style={{ |
||||||
|
strokeDasharray, |
||||||
|
strokeDashoffset, |
||||||
|
transition: 'stroke-dashoffset 0.3s ease', |
||||||
|
}} |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isLoading && children} |
||||||
|
{isLoading && <ChapterNumberSkeleton />} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,189 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { getUrlParams } from '../../lib/browser'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { generateAiCourseStructure, type AiCourse } from '../../lib/ai'; |
||||||
|
import { readAICourseStream } from '../../helper/read-stream'; |
||||||
|
import { AICourseContent } from './AICourseContent'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { getAiCourseLimitOptions } from '../../queries/ai-course'; |
||||||
|
|
||||||
|
type GenerateAICourseProps = {}; |
||||||
|
|
||||||
|
export function GenerateAICourse(props: GenerateAICourseProps) { |
||||||
|
const [term, setTerm] = useState(''); |
||||||
|
const [difficulty, setDifficulty] = useState(''); |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
|
||||||
|
const [courseId, setCourseId] = useState(''); |
||||||
|
const [courseSlug, setCourseSlug] = useState(''); |
||||||
|
const [course, setCourse] = useState<AiCourse>({ |
||||||
|
title: '', |
||||||
|
modules: [], |
||||||
|
difficulty: '', |
||||||
|
}); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (term || difficulty) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const params = getUrlParams(); |
||||||
|
const paramsTerm = params?.term; |
||||||
|
const paramsDifficulty = params?.difficulty; |
||||||
|
if (!paramsTerm || !paramsDifficulty) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setTerm(paramsTerm); |
||||||
|
setDifficulty(paramsDifficulty); |
||||||
|
generateCourse({ term: paramsTerm, difficulty: paramsDifficulty }); |
||||||
|
}, [term, difficulty]); |
||||||
|
|
||||||
|
const generateCourse = async (options: { |
||||||
|
term: string; |
||||||
|
difficulty: string; |
||||||
|
}) => { |
||||||
|
const { term, difficulty } = options; |
||||||
|
|
||||||
|
if (!isLoggedIn()) { |
||||||
|
window.location.href = '/ai-tutor'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(true); |
||||||
|
setCourse({ |
||||||
|
title: '', |
||||||
|
modules: [], |
||||||
|
difficulty: '', |
||||||
|
}); |
||||||
|
setError(''); |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`, |
||||||
|
{ |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
keyword: term, |
||||||
|
difficulty, |
||||||
|
}), |
||||||
|
credentials: 'include', |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
console.error( |
||||||
|
'Error generating course:', |
||||||
|
data?.message || 'Something went wrong', |
||||||
|
); |
||||||
|
setIsLoading(false); |
||||||
|
setError(data?.message || 'Something went wrong'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const reader = response.body?.getReader(); |
||||||
|
|
||||||
|
if (!reader) { |
||||||
|
console.error('Failed to get reader from response'); |
||||||
|
setError('Something went wrong'); |
||||||
|
setIsLoading(false); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); |
||||||
|
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/); |
||||||
|
|
||||||
|
await readAICourseStream(reader, { |
||||||
|
onStream: (result) => { |
||||||
|
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) { |
||||||
|
const courseIdMatch = result.match(COURSE_ID_REGEX); |
||||||
|
const courseSlugMatch = result.match(COURSE_SLUG_REGEX); |
||||||
|
const extractedCourseId = courseIdMatch?.[1] || ''; |
||||||
|
const extractedCourseSlug = courseSlugMatch?.[1] || ''; |
||||||
|
|
||||||
|
if (extractedCourseSlug) { |
||||||
|
window.history.replaceState( |
||||||
|
{ |
||||||
|
courseId, |
||||||
|
courseSlug: extractedCourseSlug, |
||||||
|
term, |
||||||
|
difficulty, |
||||||
|
}, |
||||||
|
'', |
||||||
|
`${origin}/ai-tutor/${extractedCourseSlug}`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
result = result |
||||||
|
.replace(COURSE_ID_REGEX, '') |
||||||
|
.replace(COURSE_SLUG_REGEX, ''); |
||||||
|
|
||||||
|
setCourseId(extractedCourseId); |
||||||
|
setCourseSlug(extractedCourseSlug); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const aiCourse = generateAiCourseStructure(result); |
||||||
|
setCourse({ |
||||||
|
...aiCourse, |
||||||
|
difficulty: difficulty || '', |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
console.error('Error parsing streamed course content:', e); |
||||||
|
} |
||||||
|
}, |
||||||
|
onStreamEnd: (result) => { |
||||||
|
result = result |
||||||
|
.replace(COURSE_ID_REGEX, '') |
||||||
|
.replace(COURSE_SLUG_REGEX, ''); |
||||||
|
setIsLoading(false); |
||||||
|
queryClient.invalidateQueries(getAiCourseLimitOptions()); |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
setError(error?.message || 'Something went wrong'); |
||||||
|
console.error('Error in course generation:', error); |
||||||
|
setIsLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const handlePopState = (e: PopStateEvent) => { |
||||||
|
const { courseId, courseSlug, term, difficulty } = e.state || {}; |
||||||
|
if (!courseId || !courseSlug) { |
||||||
|
window.location.reload(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setCourseId(courseId); |
||||||
|
setCourseSlug(courseSlug); |
||||||
|
setTerm(term); |
||||||
|
setDifficulty(difficulty); |
||||||
|
|
||||||
|
setIsLoading(true); |
||||||
|
generateCourse({ term, difficulty }).finally(() => { |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState); |
||||||
|
return () => { |
||||||
|
window.removeEventListener('popstate', handlePopState); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<AICourseContent |
||||||
|
courseSlug={courseSlug} |
||||||
|
course={course} |
||||||
|
isLoading={isLoading} |
||||||
|
error={error} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,65 @@ |
|||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { getAiCourseOptions } from '../../queries/ai-course'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { AICourseContent } from './AICourseContent'; |
||||||
|
import { generateAiCourseStructure } from '../../lib/ai'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
|
||||||
|
type GetAICourseProps = { |
||||||
|
courseSlug: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function GetAICourse(props: GetAICourseProps) { |
||||||
|
const { courseSlug } = props; |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
const { data: aiCourse, error } = useQuery( |
||||||
|
{ |
||||||
|
...getAiCourseOptions({ aiCourseSlug: courseSlug }), |
||||||
|
select: (data) => { |
||||||
|
return { |
||||||
|
...data, |
||||||
|
course: generateAiCourseStructure(data.data), |
||||||
|
}; |
||||||
|
}, |
||||||
|
enabled: !!courseSlug && !!isLoggedIn(), |
||||||
|
}, |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!isLoggedIn()) { |
||||||
|
window.location.href = '/ai-tutor'; |
||||||
|
} |
||||||
|
}, [isLoggedIn]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!aiCourse) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
}, [aiCourse]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!error) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
}, [error]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<AICourseContent |
||||||
|
course={{ |
||||||
|
title: aiCourse?.title || '', |
||||||
|
modules: aiCourse?.course.modules || [], |
||||||
|
difficulty: aiCourse?.difficulty || 'Easy', |
||||||
|
}} |
||||||
|
isLoading={isLoading} |
||||||
|
courseSlug={courseSlug} |
||||||
|
error={error?.message} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,180 @@ |
|||||||
|
import { useQuery } from '@tanstack/react-query'; |
||||||
|
import { |
||||||
|
getAiCourseLimitOptions, |
||||||
|
listUserAiCoursesOptions, |
||||||
|
} from '../../queries/ai-course'; |
||||||
|
import { queryClient } from '../../stores/query-client'; |
||||||
|
import { AICourseCard } from './AICourseCard'; |
||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { Gift, Loader2, Search, User2 } from 'lucide-react'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { showLoginPopup } from '../../lib/popup'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { billingDetailsOptions } from '../../queries/billing'; |
||||||
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; |
||||||
|
|
||||||
|
type UserCoursesListProps = {}; |
||||||
|
|
||||||
|
export function UserCoursesList(props: UserCoursesListProps) { |
||||||
|
const [searchTerm, setSearchTerm] = useState(''); |
||||||
|
const [isInitialLoading, setIsInitialLoading] = useState(true); |
||||||
|
const [showUpgradePopup, setShowUpgradePopup] = useState(false); |
||||||
|
|
||||||
|
const { data: limits, isLoading } = useQuery( |
||||||
|
getAiCourseLimitOptions(), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
const { used, limit } = limits ?? { used: 0, limit: 0 }; |
||||||
|
|
||||||
|
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = |
||||||
|
useQuery(billingDetailsOptions(), queryClient); |
||||||
|
|
||||||
|
const isPaidUser = userBillingDetails?.status !== 'none'; |
||||||
|
|
||||||
|
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( |
||||||
|
listUserAiCoursesOptions(), |
||||||
|
queryClient, |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setIsInitialLoading(false); |
||||||
|
}, [userAiCourses]); |
||||||
|
|
||||||
|
const filteredCourses = userAiCourses?.filter((course) => { |
||||||
|
if (!searchTerm.trim()) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
const searchLower = searchTerm.toLowerCase(); |
||||||
|
|
||||||
|
return ( |
||||||
|
course.title.toLowerCase().includes(searchLower) || |
||||||
|
course.keyword.toLowerCase().includes(searchLower) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
const isAuthenticated = isLoggedIn(); |
||||||
|
|
||||||
|
const canSearch = |
||||||
|
!isInitialLoading && |
||||||
|
!isUserAiCoursesLoading && |
||||||
|
isAuthenticated && |
||||||
|
userAiCourses?.length !== 0; |
||||||
|
|
||||||
|
const limitUsedPercentage = Math.round((used / limit) * 100); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
{showUpgradePopup && ( |
||||||
|
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} /> |
||||||
|
)} |
||||||
|
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<h2 className="text-lg font-semibold"> |
||||||
|
<span className='max-md:hidden'>Your </span>Courses |
||||||
|
</h2> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex items-center gap-2 opacity-0 transition-opacity', |
||||||
|
{ |
||||||
|
'opacity-100': !isPaidUser, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
<p className="flex items-center text-sm text-yellow-600"> |
||||||
|
<span className="max-md:hidden"> |
||||||
|
{limitUsedPercentage}% of daily limit used{' '} |
||||||
|
</span> |
||||||
|
<span className="inline md:hidden"> |
||||||
|
{limitUsedPercentage}% used |
||||||
|
</span> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
setShowUpgradePopup(true); |
||||||
|
}} |
||||||
|
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white" |
||||||
|
> |
||||||
|
<Gift className="size-4" /> |
||||||
|
Upgrade |
||||||
|
</button> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className={cn('relative w-64 max-sm:hidden', {})}> |
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> |
||||||
|
<Search className="h-4 w-4 text-gray-400" /> |
||||||
|
</div> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 transition-all focus:border-gray-300 focus:outline-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm" |
||||||
|
placeholder="Search your courses..." |
||||||
|
value={searchTerm} |
||||||
|
onChange={(e) => setSearchTerm(e.target.value)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{!isInitialLoading && !isUserAiCoursesLoading && !isAuthenticated && ( |
||||||
|
<div className="flex min-h-[152px] flex-col items-center justify-center rounded-lg border border-gray-200 bg-white px-6 py-4"> |
||||||
|
<User2 className="mb-2 size-8 text-gray-300" /> |
||||||
|
<p className="max-w-sm text-balance text-center text-gray-500"> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
showLoginPopup(); |
||||||
|
}} |
||||||
|
className="font-medium text-black underline underline-offset-2 hover:opacity-80" |
||||||
|
> |
||||||
|
Sign up (free and takes 2s) or login |
||||||
|
</button>{' '} |
||||||
|
to generate and save courses. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isUserAiCoursesLoading && |
||||||
|
!isInitialLoading && |
||||||
|
userAiCourses?.length === 0 && ( |
||||||
|
<div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> |
||||||
|
<p className="text-sm text-gray-600"> |
||||||
|
You haven't generated any courses yet. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{(isUserAiCoursesLoading || isInitialLoading) && ( |
||||||
|
<div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> |
||||||
|
<Loader2 |
||||||
|
className="size-4 animate-spin text-gray-400" |
||||||
|
strokeWidth={2.5} |
||||||
|
/> |
||||||
|
<p className="text-sm font-medium text-gray-600">Loading...</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isUserAiCoursesLoading && |
||||||
|
filteredCourses && |
||||||
|
filteredCourses.length > 0 && ( |
||||||
|
<div className="flex flex-col gap-2"> |
||||||
|
{filteredCourses.map((course) => ( |
||||||
|
<AICourseCard key={course._id} course={course} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isUserAiCoursesLoading && |
||||||
|
(userAiCourses?.length || 0 > 0) && |
||||||
|
filteredCourses?.length === 0 && ( |
||||||
|
<div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> |
||||||
|
<p className="text-sm text-gray-600"> |
||||||
|
No courses match your search. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -1,12 +1,12 @@ |
|||||||
export function getPercentage(portion: number, total: number): string { |
export function getPercentage(portion: number, total: number): number { |
||||||
if (portion <= 0 || total <= 0) { |
if (portion <= 0 || total <= 0) { |
||||||
return '0.00'; |
return 0; |
||||||
} |
} |
||||||
|
|
||||||
if (portion >= total) { |
if (portion >= total) { |
||||||
return '100.00'; |
return 100; |
||||||
} |
} |
||||||
|
|
||||||
const percentage = (portion / total) * 100; |
const percentage = (portion / total) * 100; |
||||||
return percentage.toFixed(2); |
return Math.round(percentage); |
||||||
} |
} |
||||||
|
After Width: | Height: | Size: 308 B |
@ -1 +1,55 @@ |
|||||||
export const IS_KEY_ONLY_ROADMAP_GENERATION = false; |
export const IS_KEY_ONLY_ROADMAP_GENERATION = false; |
||||||
|
|
||||||
|
type Lesson = string; |
||||||
|
|
||||||
|
type Module = { |
||||||
|
title: string; |
||||||
|
lessons: Lesson[]; |
||||||
|
}; |
||||||
|
|
||||||
|
export type AiCourse = { |
||||||
|
title: string; |
||||||
|
modules: Module[]; |
||||||
|
difficulty: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function generateAiCourseStructure( |
||||||
|
data: string, |
||||||
|
): Omit<AiCourse, 'difficulty'> { |
||||||
|
const lines = data.split('\n'); |
||||||
|
let title = ''; |
||||||
|
const modules: Module[] = []; |
||||||
|
let currentModule: Module | null = null; |
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) { |
||||||
|
const line = lines[i].trim(); |
||||||
|
|
||||||
|
if (i === 0 && line.startsWith('#')) { |
||||||
|
// First line is the title
|
||||||
|
title = line.replace('#', '').trim(); |
||||||
|
} else if (line.startsWith('## ')) { |
||||||
|
// New module
|
||||||
|
if (currentModule) { |
||||||
|
modules.push(currentModule); |
||||||
|
} |
||||||
|
currentModule = { |
||||||
|
title: line.replace('## ', ''), |
||||||
|
lessons: [], |
||||||
|
}; |
||||||
|
// Removed auto-expand code to keep modules collapsed by default
|
||||||
|
} else if (line.startsWith('- ') && currentModule) { |
||||||
|
// Lesson within current module
|
||||||
|
currentModule.lessons.push(line.replace('- ', '')); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add the last module if it exists
|
||||||
|
if (currentModule) { |
||||||
|
modules.push(currentModule); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
title, |
||||||
|
modules, |
||||||
|
}; |
||||||
|
} |
||||||
|
@ -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 details'} |
||||||
|
> |
||||||
|
<AccountSidebar activePageId='billing' activePageTitle='Billing'> |
||||||
|
<BillingPage client:load /> |
||||||
|
</AccountSidebar> |
||||||
|
</AccountLayout> |
@ -0,0 +1,24 @@ |
|||||||
|
--- |
||||||
|
import { GetAICourse } from '../../components/GenerateCourse/GetAICourse'; |
||||||
|
import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; |
||||||
|
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification'; |
||||||
|
|
||||||
|
export const prerender = false; |
||||||
|
|
||||||
|
interface Params extends Record<string, string | undefined> { |
||||||
|
courseSlug: string; |
||||||
|
} |
||||||
|
|
||||||
|
const { courseSlug } = Astro.params as Params; |
||||||
|
--- |
||||||
|
|
||||||
|
<SkeletonLayout |
||||||
|
title='AI Tutor' |
||||||
|
briefTitle='AI Tutor' |
||||||
|
description='AI Tutor' |
||||||
|
keywords={['ai', 'tutor', 'education', 'learning']} |
||||||
|
canonicalUrl={`/ai-tutor/${courseSlug}`} |
||||||
|
> |
||||||
|
<GetAICourse client:load courseSlug={courseSlug} /> |
||||||
|
<CheckSubscriptionVerification client:load /> |
||||||
|
</SkeletonLayout> |
@ -0,0 +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> |
@ -0,0 +1,16 @@ |
|||||||
|
--- |
||||||
|
import { GenerateAICourse } from '../../components/GenerateCourse/GenerateAICourse'; |
||||||
|
import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; |
||||||
|
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SkeletonLayout |
||||||
|
title='AI Tutor' |
||||||
|
briefTitle='AI Tutor' |
||||||
|
description='AI Tutor' |
||||||
|
keywords={['ai', 'tutor', 'education', 'learning']} |
||||||
|
canonicalUrl='/ai-tutor/search' |
||||||
|
> |
||||||
|
<GenerateAICourse client:load /> |
||||||
|
<CheckSubscriptionVerification client:load /> |
||||||
|
</SkeletonLayout> |
@ -0,0 +1,92 @@ |
|||||||
|
import { httpGet } from '../lib/query-http'; |
||||||
|
import { isLoggedIn } from '../lib/jwt'; |
||||||
|
import { queryOptions } from '@tanstack/react-query'; |
||||||
|
|
||||||
|
export interface AICourseProgressDocument { |
||||||
|
_id: string; |
||||||
|
userId: string; |
||||||
|
courseId: string; |
||||||
|
done: string[]; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
type GetAICourseProgressParams = { |
||||||
|
aiCourseSlug: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type GetAICourseProgressResponse = AICourseProgressDocument; |
||||||
|
|
||||||
|
export function getAiCourseProgressOptions(params: GetAICourseProgressParams) { |
||||||
|
return { |
||||||
|
queryKey: ['ai-course-progress', params], |
||||||
|
queryFn: () => { |
||||||
|
return httpGet<GetAICourseProgressResponse>( |
||||||
|
`/v1-get-ai-course-progress/${params.aiCourseSlug}`, |
||||||
|
); |
||||||
|
}, |
||||||
|
enabled: !!params.aiCourseSlug && isLoggedIn(), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
type GetAICourseParams = { |
||||||
|
aiCourseSlug: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export interface AICourseDocument { |
||||||
|
_id: string; |
||||||
|
userId: string; |
||||||
|
title: string; |
||||||
|
slug?: string; |
||||||
|
keyword: string; |
||||||
|
difficulty: string; |
||||||
|
data: string; |
||||||
|
viewCount: number; |
||||||
|
createdAt: Date; |
||||||
|
updatedAt: Date; |
||||||
|
} |
||||||
|
|
||||||
|
type GetAICourseResponse = AICourseDocument; |
||||||
|
|
||||||
|
export function getAiCourseOptions(params: GetAICourseParams) { |
||||||
|
return { |
||||||
|
queryKey: ['ai-course', params], |
||||||
|
queryFn: () => { |
||||||
|
return httpGet<GetAICourseResponse>( |
||||||
|
`/v1-get-ai-course/${params.aiCourseSlug}`, |
||||||
|
); |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export type GetAICourseLimitResponse = { |
||||||
|
used: number; |
||||||
|
limit: number; |
||||||
|
}; |
||||||
|
|
||||||
|
export function getAiCourseLimitOptions() { |
||||||
|
return queryOptions({ |
||||||
|
queryKey: ['ai-course-limit'], |
||||||
|
queryFn: () => { |
||||||
|
return httpGet<GetAICourseLimitResponse>(`/v1-get-ai-course-limit`); |
||||||
|
}, |
||||||
|
enabled: !!isLoggedIn(), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export type AICourseListItem = AICourseDocument & { |
||||||
|
progress: AICourseProgressDocument; |
||||||
|
lessonCount: number; |
||||||
|
}; |
||||||
|
|
||||||
|
type ListUserAiCoursesResponse = AICourseListItem[]; |
||||||
|
|
||||||
|
export function listUserAiCoursesOptions() { |
||||||
|
return { |
||||||
|
queryKey: ['user-ai-courses'], |
||||||
|
queryFn: () => { |
||||||
|
return httpGet<ListUserAiCoursesResponse>(`/v1-list-user-ai-courses`); |
||||||
|
}, |
||||||
|
enabled: !!isLoggedIn(), |
||||||
|
}; |
||||||
|
} |
Loading…
Reference in new issue