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
@ -1,4 +1,10 @@ |
||||
PUBLIC_API_URL=https://api.roadmap.sh |
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars |
||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh |
||||
PUBLIC_COURSE_APP_URL=http://localhost:5173 |
||||
PUBLIC_COURSE_APP_URL=http://localhost:5173 |
||||
|
||||
PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID= |
||||
PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID= |
||||
|
||||
PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_AMOUNT=10 |
||||
PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_AMOUNT=100 |
@ -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) { |
||||
return '0.00'; |
||||
return 0; |
||||
} |
||||
|
||||
|
||||
if (portion >= total) { |
||||
return '100.00'; |
||||
return 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