From cb64894e4951d02336e6b4bdd49eddc5d951a112 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Wed, 12 Mar 2025 13:17:38 +0000 Subject: [PATCH] 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 --- .astro/settings.json | 2 +- .env.example | 8 +- package.json | 3 + pnpm-lock.yaml | 204 ++++++++ src/api/ai-roadmap.ts | 13 + src/components/AccountSidebar.astro | 16 +- src/components/Billing/BillingPage.tsx | 219 +++++++++ .../Billing/CheckSubscriptionVerification.tsx | 22 + src/components/Billing/EmptyBillingScreen.tsx | 83 ++++ .../Billing/UpdatePlanConfirmation.tsx | 96 ++++ .../Billing/UpgradeAccountModal.tsx | 308 ++++++++++++ src/components/Billing/VerifyUpgrade.tsx | 76 +++ src/components/GenerateCourse/AICourse.tsx | 123 +++++ .../GenerateCourse/AICourseCard.tsx | 73 +++ .../GenerateCourse/AICourseContent.tsx | 451 ++++++++++++++++++ .../GenerateCourse/AICourseFollowUp.css | 131 +++++ .../GenerateCourse/AICourseFollowUp.tsx | 77 +++ .../AICourseFollowUpPopover.tsx | 382 +++++++++++++++ .../GenerateCourse/AICourseLimit.tsx | 78 +++ .../GenerateCourse/AICourseModuleList.tsx | 208 ++++++++ .../GenerateCourse/AICourseModuleView.tsx | 344 +++++++++++++ .../GenerateCourse/AILimitsPopup.tsx | 103 ++++ .../GenerateCourse/CircularProgress.tsx | 57 +++ .../GenerateCourse/GenerateAICourse.tsx | 189 ++++++++ src/components/GenerateCourse/GetAICourse.tsx | 65 +++ .../GenerateCourse/UserCoursesList.tsx | 180 +++++++ src/components/Guide/RelatedGuides.tsx | 4 +- .../SQLCourse/CourseAnnouncement.tsx | 9 +- src/helper/number.ts | 10 +- src/helper/read-stream.ts | 68 +++ src/icons/credit-card.svg | 1 + src/lib/ai.ts | 56 ++- src/lib/markdown.ts | 60 ++- src/pages/account/billing.astro | 16 + src/pages/ai-tutor/[courseSlug].astro | 24 + src/pages/ai-tutor/index.astro | 10 + src/pages/ai-tutor/search.astro | 16 + src/queries/ai-course.ts | 92 ++++ src/queries/billing.ts | 54 ++- 39 files changed, 3906 insertions(+), 25 deletions(-) create mode 100644 src/components/Billing/BillingPage.tsx create mode 100644 src/components/Billing/CheckSubscriptionVerification.tsx create mode 100644 src/components/Billing/EmptyBillingScreen.tsx create mode 100644 src/components/Billing/UpdatePlanConfirmation.tsx create mode 100644 src/components/Billing/UpgradeAccountModal.tsx create mode 100644 src/components/Billing/VerifyUpgrade.tsx create mode 100644 src/components/GenerateCourse/AICourse.tsx create mode 100644 src/components/GenerateCourse/AICourseCard.tsx create mode 100644 src/components/GenerateCourse/AICourseContent.tsx create mode 100644 src/components/GenerateCourse/AICourseFollowUp.css create mode 100644 src/components/GenerateCourse/AICourseFollowUp.tsx create mode 100644 src/components/GenerateCourse/AICourseFollowUpPopover.tsx create mode 100644 src/components/GenerateCourse/AICourseLimit.tsx create mode 100644 src/components/GenerateCourse/AICourseModuleList.tsx create mode 100644 src/components/GenerateCourse/AICourseModuleView.tsx create mode 100644 src/components/GenerateCourse/AILimitsPopup.tsx create mode 100644 src/components/GenerateCourse/CircularProgress.tsx create mode 100644 src/components/GenerateCourse/GenerateAICourse.tsx create mode 100644 src/components/GenerateCourse/GetAICourse.tsx create mode 100644 src/components/GenerateCourse/UserCoursesList.tsx create mode 100644 src/icons/credit-card.svg create mode 100644 src/pages/account/billing.astro create mode 100644 src/pages/ai-tutor/[courseSlug].astro create mode 100644 src/pages/ai-tutor/index.astro create mode 100644 src/pages/ai-tutor/search.astro create mode 100644 src/queries/ai-course.ts diff --git a/.astro/settings.json b/.astro/settings.json index 50ff25a6a..d6953d35d 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1739229597159 + "lastUpdateCheck": 1741697790683 } } \ No newline at end of file diff --git a/.env.example b/.env.example index 02a71f0a7..9bb481b2b 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/package.json b/package.json index 1ca7192c2..aeb3d2be3 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.452.0", "luxon": "^3.5.0", + "markdown-it-async": "^2.0.0", "nanoid": "^5.0.7", "nanostores": "^0.11.3", "node-html-parser": "^6.1.13", @@ -63,6 +64,7 @@ "react-calendar-heatmap": "^1.9.0", "react-confetti": "^6.1.0", "react-dom": "^18.3.1", + "react-textarea-autosize": "^8.5.7", "react-tooltip": "^5.28.0", "reactflow": "^11.11.4", "rehype-external-links": "^3.0.0", @@ -72,6 +74,7 @@ "satori": "^0.11.2", "satori-html": "^0.3.2", "sharp": "^0.33.5", + "shiki": "^3.1.0", "slugify": "^1.6.6", "tailwind-merge": "^2.5.3", "tailwindcss": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddbb8c9f1..2a8d60a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: luxon: specifier: ^3.5.0 version: 3.5.0 + markdown-it-async: + specifier: ^2.0.0 + version: 2.0.0 nanoid: specifier: ^5.0.7 version: 5.0.9 @@ -110,6 +113,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-textarea-autosize: + specifier: ^8.5.7 + version: 8.5.7(@types/react@18.3.18)(react@18.3.1) react-tooltip: specifier: ^5.28.0 version: 5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -137,6 +143,9 @@ importers: sharp: specifier: ^0.33.5 version: 0.33.5 + shiki: + specifier: ^3.1.0 + version: 3.1.0 slugify: specifier: ^1.6.6 version: 1.6.6 @@ -396,6 +405,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.26.9': + resolution: {integrity: sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -1179,24 +1192,45 @@ packages: '@shikijs/core@1.29.1': resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==} + '@shikijs/core@3.1.0': + resolution: {integrity: sha512-1ppAOyg3F18N8Ge9DmJjGqRVswihN33rOgPovR6gUHW17Hw1L4RlRhnmVQcsacSHh0A8IO1FIgNbtTxUFwodmg==} + '@shikijs/engine-javascript@1.29.1': resolution: {integrity: sha512-Hpi8k9x77rCQ7F/7zxIOUruNkNidMyBnP5qAGbLFqg4kRrg1HZhkB8btib5EXbQWTtLb5gBHOdBwshk20njD7Q==} + '@shikijs/engine-javascript@3.1.0': + resolution: {integrity: sha512-/LwkhW17jYi7uPcdaaSQQDNW+xgrHXarkrxYPoC6WPzH2xW5mFMw12doHXJBqxmYvtcTbaatcv2MkH9+3PU1FA==} + '@shikijs/engine-oniguruma@1.29.1': resolution: {integrity: sha512-gSt2WhLNgEeLstcweQOSp+C+MhOpTsgdNXRqr3zP6M+BUBZ8Md9OU2BYwUYsALBxHza7hwaIWtFHjQ/aOOychw==} + '@shikijs/engine-oniguruma@3.1.0': + resolution: {integrity: sha512-reRgy8VzDPdiDocuGDD60Rk/jLxgcgy+6H4n6jYLeN2Yw5ikasRjQQx8ERXtDM35yg2v/d6KolDBcK8hYYhcmw==} + '@shikijs/langs@1.29.1': resolution: {integrity: sha512-iERn4HlyuT044/FgrvLOaZgKVKf3PozjKjyV/RZ5GnlyYEAZFcgwHGkYboeBv2IybQG1KVS/e7VGgiAU4JY2Gw==} + '@shikijs/langs@3.1.0': + resolution: {integrity: sha512-hAM//sExPXAXG3ZDWjrmV6Vlw4zlWFOcT1ZXNhFRBwPP27scZu/ZIdZ+TdTgy06zSvyF4KIjnF8j6+ScKGu6ww==} + '@shikijs/themes@1.29.1': resolution: {integrity: sha512-lb11zf72Vc9uxkl+aec2oW1HVTHJ2LtgZgumb4Rr6By3y/96VmlU44bkxEb8WBWH3RUtbqAJEN0jljD9cF7H7g==} + '@shikijs/themes@3.1.0': + resolution: {integrity: sha512-A4MJmy9+ydLNbNCtkmdTp8a+ON+MMXoUe1KTkELkyu0+pHGOcbouhNuobhZoK59cL4cOST6CCz1x+kUdkp9UZA==} + '@shikijs/types@1.29.1': resolution: {integrity: sha512-aBqAuhYRp5vSir3Pc9+QPu9WESBOjUo03ao0IHLC4TyTioSsp/SkbAZSrIH4ghYYC1T1KTEpRSBa83bas4RnPA==} + '@shikijs/types@3.1.0': + resolution: {integrity: sha512-F8e7Fy4ihtcNpJG572BZZC1ErYrBrzJ5Cbc9Zi3REgWry43gIvjJ9lFAoUnuy7Bvy4IFz7grUSxL5edfrrjFEA==} + '@shikijs/vscode-textmate@10.0.1': resolution: {integrity: sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==} + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shuding/opentype.js@1.4.0-beta.0': resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} engines: {node: '>= 8.0.0'} @@ -2090,6 +2124,9 @@ packages: hast-util-to-html@9.0.4: resolution: {integrity: sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-parse5@8.0.0: resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} @@ -2343,6 +2380,9 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + markdown-it-async@2.0.0: + resolution: {integrity: sha512-jBthmQR5MwXR9Y8Y0teRoZAenaKQMdjuTfpbNARqMBSRPvyzyXCVduHZHakyyhL3ugIacCobXJrO07t277sIjw==} + markdown-it-task-lists@2.1.1: resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} @@ -2603,6 +2643,9 @@ packages: oniguruma-to-es@2.3.0: resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + openai@4.80.1: resolution: {integrity: sha512-+6+bbXFwbIE88foZsBEt36bPkgZPdyFN82clAXG61gnHb2gXdZApDyRrcAHqEtpYICywpqaNo57kOm9dtnb7Cw==} hasBin: true @@ -2847,6 +2890,9 @@ packages: property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + property-information@7.0.0: + resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + prosemirror-changeset@2.2.1: resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==} @@ -2942,6 +2988,12 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-textarea-autosize@8.5.7: + resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-tooltip@5.28.0: resolution: {integrity: sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==} peerDependencies: @@ -2965,15 +3017,24 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regex-recursion@5.1.1: resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + regex-utilities@2.3.0: resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} regex@5.1.1: resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==} + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} @@ -3110,6 +3171,9 @@ packages: shiki@1.29.1: resolution: {integrity: sha512-TghWKV9pJTd/N+IgAIVJtr0qZkB7FfFCUrrEJc0aRmZupo3D1OCVRknQWVRVA7AX/M0Ld7QfoAruPzr3CnUJuw==} + shiki@3.1.0: + resolution: {integrity: sha512-LdTNyWQlC5zdCaHdcp1zPA1OVA2ivb+KjGOOnGcy02tGaF5ja+dGibWFH7Ar8YlngUgK/scDqworK18Ys9cbYA==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3348,6 +3412,33 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-isomorphic-layout-effect@1.2.0: + resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.4.0: resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} peerDependencies: @@ -3743,6 +3834,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.26.9': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -4336,32 +4431,65 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.4 + '@shikijs/core@3.1.0': + dependencies: + '@shikijs/types': 3.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@1.29.1': dependencies: '@shikijs/types': 1.29.1 '@shikijs/vscode-textmate': 10.0.1 oniguruma-to-es: 2.3.0 + '@shikijs/engine-javascript@3.1.0': + dependencies: + '@shikijs/types': 3.1.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + '@shikijs/engine-oniguruma@1.29.1': dependencies: '@shikijs/types': 1.29.1 '@shikijs/vscode-textmate': 10.0.1 + '@shikijs/engine-oniguruma@3.1.0': + dependencies: + '@shikijs/types': 3.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@1.29.1': dependencies: '@shikijs/types': 1.29.1 + '@shikijs/langs@3.1.0': + dependencies: + '@shikijs/types': 3.1.0 + '@shikijs/themes@1.29.1': dependencies: '@shikijs/types': 1.29.1 + '@shikijs/themes@3.1.0': + dependencies: + '@shikijs/types': 3.1.0 + '@shikijs/types@1.29.1': dependencies: '@shikijs/vscode-textmate': 10.0.1 '@types/hast': 3.0.4 + '@shikijs/types@3.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.1': {} + '@shikijs/vscode-textmate@10.0.2': {} + '@shuding/opentype.js@1.4.0-beta.0': dependencies: fflate: 0.7.4 @@ -5381,6 +5509,20 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-parse5@8.0.0: dependencies: '@types/hast': 3.0.4 @@ -5611,6 +5753,11 @@ snapshots: dependencies: semver: 6.3.1 + markdown-it-async@2.0.0: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + markdown-it-task-lists@2.1.1: {} markdown-it@14.1.0: @@ -6027,6 +6174,12 @@ snapshots: regex: 5.1.1 regex-recursion: 5.1.1 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.0.1 + regex-recursion: 6.0.2 + openai@4.80.1(zod@3.24.1): dependencies: '@types/node': 18.19.74 @@ -6214,6 +6367,8 @@ snapshots: property-information@6.5.0: {} + property-information@7.0.0: {} + prosemirror-changeset@2.2.1: dependencies: prosemirror-transform: 1.10.2 @@ -6348,6 +6503,15 @@ snapshots: react-refresh@0.14.2: {} + react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.9 + react: 18.3.1 + use-composed-ref: 1.4.0(@types/react@18.3.18)(react@18.3.1) + use-latest: 1.3.0(@types/react@18.3.18)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + react-tooltip@5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@floating-ui/dom': 1.6.13 @@ -6381,17 +6545,27 @@ snapshots: dependencies: picomatch: 2.3.1 + regenerator-runtime@0.14.1: {} + regex-recursion@5.1.1: dependencies: regex: 5.1.1 regex-utilities: 2.3.0 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + regex-utilities@2.3.0: {} regex@5.1.1: dependencies: regex-utilities: 2.3.0 + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + rehype-external-links@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -6655,6 +6829,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.1 '@types/hast': 3.0.4 + shiki@3.1.0: + dependencies: + '@shikijs/core': 3.1.0 + '@shikijs/engine-javascript': 3.1.0 + '@shikijs/engine-oniguruma': 3.1.0 + '@shikijs/langs': 3.1.0 + '@shikijs/themes': 3.1.0 + '@shikijs/types': 3.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + signal-exit@4.1.0: {} simple-swizzle@0.2.2: @@ -6912,6 +7097,25 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-composed-ref@1.4.0(@types/react@18.3.18)(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + use-isomorphic-layout-effect@1.2.0(@types/react@18.3.18)(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + use-latest@1.3.0(@types/react@18.3.18)(react@18.3.1): + dependencies: + react: 18.3.1 + use-isomorphic-layout-effect: 1.2.0(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + use-sync-external-store@1.4.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/src/api/ai-roadmap.ts b/src/api/ai-roadmap.ts index 944b120ed..9a4227f71 100644 --- a/src/api/ai-roadmap.ts +++ b/src/api/ai-roadmap.ts @@ -18,3 +18,16 @@ export function aiRoadmapApi(context: APIContext) { }, }; } + +export interface AICourseDocument { + _id: string; + userId: string; + title: string; + slug?: string; + keyword: string; + difficulty: string; + data: string; + viewCount: number; + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/src/components/AccountSidebar.astro b/src/components/AccountSidebar.astro index 9b609304e..98f2c0fb7 100644 --- a/src/components/AccountSidebar.astro +++ b/src/components/AccountSidebar.astro @@ -27,7 +27,7 @@ const sidebarLinks = [ href: '/account/update-profile', title: 'Profile', id: 'profile', - isNew: true, + isNew: false, icon: { glyph: 'user', classes: 'h-4 w-4', @@ -56,7 +56,7 @@ const sidebarLinks = [ }, { href: '/account/road-card', - title: 'Card', + title: 'Road Card', id: 'road-card', isNew: false, icon: { @@ -64,6 +64,16 @@ const sidebarLinks = [ classes: 'h-4 w-4', }, }, + // { + // href: '/account/billing', + // title: 'Billing', + // id: 'billing', + // isNew: true, + // icon: { + // glyph: 'credit-card', + // classes: 'h-4 w-4', + // }, + // }, { href: '/account/settings', title: 'Settings', @@ -97,7 +107,7 @@ const sidebarLinks = [ }`} > - Teams + Teams { diff --git a/src/components/Billing/BillingPage.tsx b/src/components/Billing/BillingPage.tsx new file mode 100644 index 000000000..fdf8287b0 --- /dev/null +++ b/src/components/Billing/BillingPage.tsx @@ -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( + '/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 && ( + { + setShowUpgradeModal(false); + }} + success="/account/billing?s=1" + cancel="/account/billing" + /> + )} + + {showVerifyUpgradeModal && } + + {billingDetails?.status === 'none' && !isLoadingBillingDetails && ( + setShowUpgradeModal(true)} /> + )} + + {billingDetails?.status !== 'none' && + !isLoadingBillingDetails && + priceDetails && ( +
+ {billingDetails?.status === 'past_due' && ( +
+ + + We were not able to charge your card.{' '} + + +
+ )} + +

+ Current Subscription +

+ +

+ Thank you for being a pro member. Your plan details are below. +

+ +
+
+
+ +
+
+ + Payment + +

+ ${priceDetails.amount} + + / {priceDetails.interval} + +

+
+
+
+ +
+
+
+ +
+
+ + {billingDetails?.cancelAtPeriodEnd + ? 'Expires On' + : 'Renews On'} + +

+ {formattedNextBillDate} +

+
+
+ +
+ {!shouldHideDeleteButton && ( + + )} + + +
+
+
+ )} + + ); +} diff --git a/src/components/Billing/CheckSubscriptionVerification.tsx b/src/components/Billing/CheckSubscriptionVerification.tsx new file mode 100644 index 000000000..b5266546e --- /dev/null +++ b/src/components/Billing/CheckSubscriptionVerification.tsx @@ -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 ; +} diff --git a/src/components/Billing/EmptyBillingScreen.tsx b/src/components/Billing/EmptyBillingScreen.tsx new file mode 100644 index 000000000..de6acf02a --- /dev/null +++ b/src/components/Billing/EmptyBillingScreen.tsx @@ -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 ( +
+

Subscription Details

+ +
+
+
+
+ +
+ +

+ No Active Subscription +

+ +

+ Unlock premium benefits by upgrading to a subscription +

+ +
+

Premium Benefits

+
+ {perks.map((perk) => ( +
+ + {perk.text} +
+ ))} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/Billing/UpdatePlanConfirmation.tsx b/src/components/Billing/UpdatePlanConfirmation.tsx new file mode 100644 index 000000000..78cc041de --- /dev/null +++ b/src/components/Billing/UpdatePlanConfirmation.tsx @@ -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( + '/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 ; + } + + return ( + {} : onClose} + bodyClassName="rounded-xl bg-white p-6" + > +

Subscription Update

+

+ Your plan will be updated to the{' '} + {planDetails.interval} plan, and will + be charged{' '} + + ${selectedPrice.amount}/{selectedPrice.interval} + + . +

+ +
+ + +
+
+ ); +} diff --git a/src/components/Billing/UpgradeAccountModal.tsx b/src/components/Billing/UpgradeAccountModal.tsx new file mode 100644 index 000000000..374a744e2 --- /dev/null +++ b/src/components/Billing/UpgradeAccountModal.tsx @@ -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('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( + '/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 ? ( +
+ +
+ ) : null; + + const error = billingError; + const errorContent = error ? ( +
+

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

+
+ ) : null; + + const calculateYearlyPrice = (monthlyPrice: number) => { + return (monthlyPrice * 12).toFixed(2); + }; + + if (isUpdatingPlan && selectedPlanDetails) { + return ( + setIsUpdatingPlan(false)} + onCancel={() => setIsUpdatingPlan(false)} + /> + ); + } + + return ( + +
e.stopPropagation()}> + {errorContent} + + {loader} + {!isLoading && !error && ( +
+
+

+ Unlock Premium Features +

+

+ Supercharge your learning experience with premium benefits +

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

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

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

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

+ )} +

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

+
+ +
+ +
+ +
+
+ ); + })} +
+ + {/* Benefits Section */} +
+
+ +
+

+ Unlimited AI Course Generations +

+

+ Generate as many custom courses as you need +

+
+
+
+ +
+

+ No Daily Limits on course features +

+

+ Use all features without restrictions +

+
+
+
+ +
+

+ Unlimited Course Follow-ups +

+

+ Ask as many questions as you need +

+
+
+
+ +
+

+ Early Access to Features +

+

+ Be the first to try new tools and features +

+
+
+
+ +
+

+ Support Development +

+

+ Help us continue building roadmap.sh +

+
+
+
+
+ )} +
+
+ ); +} diff --git a/src/components/Billing/VerifyUpgrade.tsx b/src/components/Billing/VerifyUpgrade.tsx new file mode 100644 index 000000000..f59105fd0 --- /dev/null +++ b/src/components/Billing/VerifyUpgrade.tsx @@ -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 ( + {}} + bodyClassName="rounded-xl bg-white p-6" + > +
+ +

Subscription Activated

+
+ +

+ Your subscription has been activated successfully. +

+ +

+ It might take a minute for the changes to reflect. We will{' '} + reload the page for you. +

+ +
+ + Please wait... +
+ +

+ If it takes longer than expected, please email us at{' '} + + info@roadmap.sh + + . +

+
+ ); +} diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx new file mode 100644 index 000000000..7d4ea4326 --- /dev/null +++ b/src/components/GenerateCourse/AICourse.tsx @@ -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('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 ( +
+
+

+ Learn anything with AI +

+

+ Enter a topic below to generate a personalized course for it +

+ +
+
{ + e.preventDefault(); + onSubmit(); + }} + > +
+ +
+
+ +
+ 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} + /> +
+
+ +
+ +
+ {difficultyLevels.map((level) => ( + + ))} +
+
+ + +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx new file mode 100644 index 000000000..3a4a6bd6c --- /dev/null +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -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 ( + +
+ + {course.difficulty} + +
+ +

+ {course.title} +

+ +
+
+ + {totalTopics} lessons +
+ + {totalTopics > 0 && ( +
+
+
+
+ + {progressPercentage}% + +
+ )} +
+
+ ); +} diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx new file mode 100644 index 000000000..ae841047c --- /dev/null +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -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 + >({}); + + const goToNextModule = () => { + if (activeModuleIndex < course.modules.length - 1) { + const nextModuleIndex = activeModuleIndex + 1; + setActiveModuleIndex(nextModuleIndex); + setActiveLessonIndex(0); + + setExpandedModules((prev) => { + const newState: Record = {}; + 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 = {}; + // 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 && ( + setShowUpgradeModal(false)} /> + )} + + {showAILimitsPopup && ( + setShowAILimitsPopup(false)} + onUpgrade={() => { + setShowAILimitsPopup(false); + setShowUpgradeModal(true); + }} + /> + )} + + ); + + if (error && !isLoading) { + const isLimitReached = error.includes('limit'); + + const icon = isLimitReached ? ( + + ) : ( + + ); + 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} +
+ {icon} +

{title}

+

{message}

+ + {isLimitReached && ( +
+ + +

+ + Back to AI Tutor + +

+
+ )} +
+ + ); + } + + return ( +
+ {modals} + +
+
+ + + Back to Generator + +
+
+ setShowUpgradeModal(true)} + onShowLimits={() => setShowAILimitsPopup(true)} + /> +
+ + +
+
+
+
+
+
+

+ {course.title || 'Loading Course...'} +

+
+ {totalModules} modules + + {totalCourseLessons} lessons + {viewMode === 'module' && ( + + + + + )} + {finishedPercentage > 0 && ( + <> + + + {finishedPercentage}% complete + + + )} +
+
+
+
+
+ setShowUpgradeModal(true)} + onShowLimits={() => setShowAILimitsPopup(true)} + /> +
+ + {viewMode === 'module' && ( + + )} +
+
+ +
+ + +
+ {viewMode === 'module' && ( + setShowUpgradeModal(true)} + /> + )} + + {viewMode === 'full' && ( +
+
+
+

+ {course.title || 'Loading course ..'} +

+

+ {course.title ? course.difficulty : 'Please wait ..'} +

+
+
+ {course.title ? ( +
+ {course.modules.map((courseModule, moduleIdx) => { + return ( +
+

+ {courseModule.title} +

+
+ {courseModule.lessons.map((lesson, lessonIdx) => { + const key = `${slugify(courseModule.title)}__${slugify(lesson)}`; + const isCompleted = + aiCourseProgress?.done.includes(key); + + return ( +
{ + setActiveModuleIndex(moduleIdx); + setActiveLessonIndex(lessonIdx); + setExpandedModules((prev) => { + const newState: Record = + {}; + course.modules.forEach((_, idx) => { + newState[idx] = false; + }); + newState[moduleIdx] = true; + return newState; + }); + + setSidebarOpen(false); + setViewMode('module'); + }} + > + {!isCompleted && ( + + {lessonIdx + 1} + + )} + + {isCompleted && ( + + )} + +

+ {lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} +

+ + {isCompleted ? 'View' : 'Start'} → + +
+ ); + })} +
+
+ ); + })} +
+ ) : ( +
+ +
+ )} +
+ )} +
+
+ + {sidebarOpen && ( +
setSidebarOpen(false)} + >
+ )} +
+ ); +} diff --git a/src/components/GenerateCourse/AICourseFollowUp.css b/src/components/GenerateCourse/AICourseFollowUp.css new file mode 100644 index 000000000..a6b8a9bac --- /dev/null +++ b/src/components/GenerateCourse/AICourseFollowUp.css @@ -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; +} diff --git a/src/components/GenerateCourse/AICourseFollowUp.tsx b/src/components/GenerateCourse/AICourseFollowUp.tsx new file mode 100644 index 000000000..9241ccdb2 --- /dev/null +++ b/src/components/GenerateCourse/AICourseFollowUp.tsx @@ -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 ( +
+ + + {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + + {isOpen && ( + { + setIsOpen(false); + setShowUpgradeModal(true); + }} + onOutsideClick={() => { + if (!isOpen) { + return; + } + + setIsOpen(false); + }} + /> + )} + + {isOpen && ( +
+ )} +
+ ); +} diff --git a/src/components/GenerateCourse/AICourseFollowUpPopover.tsx b/src/components/GenerateCourse/AICourseFollowUpPopover.tsx new file mode 100644 index 000000000..d1e84976f --- /dev/null +++ b/src/components/GenerateCourse/AICourseFollowUpPopover.tsx @@ -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(null); + const scrollareaRef = useRef(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) => { + 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 ( +
+
+

Course AI

+
+ +
+
+
+
+ {courseAIChatHistory.map((chat, index) => { + return ( + <> + + + {chat.isDefault && ( +
+
+ {capabilities.map((capability, index) => ( + + ))} +
+
+ )} + + ); + })} + + {isStreamingMessage && !streamedMessage && ( + + )} + + {streamedMessage && ( + + )} +
+
+
+
+ +
+ {isLimitExceeded && ( +
+ +

Limit reached for today

+ +
+ )} + setMessage(e.target.value)} + autoFocus={true} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + handleChatSubmit(e as unknown as FormEvent); + } + }} + /> + + +
+ ); +} + +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 ( +
+
+
+ +
+
+
+
+ ); +} + +type CapabilityCardProps = { + icon: React.ReactNode; + title: string; + description: string; + className?: string; +}; + +function CapabilityCard({ + icon, + title, + description, + className, +}: CapabilityCardProps) { + return ( +
+
+ {icon} + + {title} + +
+

{description}

+
+ ); +} + +const capabilities = [ + { + icon: ( + + ), + title: 'Clarify Concepts', + description: "If you don't understand a concept, ask me to clarify it", + }, + { + icon: ( + + ), + title: 'More Details', + description: 'Get deeper insights about topics covered in the lesson', + }, + { + icon: ( + + ), + title: 'Code Help', + description: 'Share your code and ask me to help you debug it', + }, + { + icon: , + title: 'Best Practices', + description: 'Share your code and ask me the best way to do something', + }, +] as const; diff --git a/src/components/GenerateCourse/AICourseLimit.tsx b/src/components/GenerateCourse/AICourseLimit.tsx new file mode 100644 index 000000000..db57beda3 --- /dev/null +++ b/src/components/GenerateCourse/AICourseLimit.tsx @@ -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 ( +
+ ); + } + + 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 ( + <> + + + {(!isPaidUser || isNearLimit) && ( + + )} + + {!isPaidUser && ( + + )} + + ); +} diff --git a/src/components/GenerateCourse/AICourseModuleList.tsx b/src/components/GenerateCourse/AICourseModuleList.tsx new file mode 100644 index 000000000..de7d71dac --- /dev/null +++ b/src/components/GenerateCourse/AICourseModuleList.tsx @@ -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; + setExpandedModules: Dispatch>>; + + 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 = {}; + // 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 ( + + ); +} diff --git a/src/components/GenerateCourse/AICourseModuleView.tsx b/src/components/GenerateCourse/AICourseModuleView.tsx new file mode 100644 index 000000000..34e07a91b --- /dev/null +++ b/src/components/GenerateCourse/AICourseModuleView.tsx @@ -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( + `/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 ( +
+
+ {(isGenerating || isLoading) && ( +
+ +
+ )} + +
+
+ Lesson {activeLessonIndex + 1} of {totalLessons} +
+ + {!isGenerating && !isLoading && ( + <> + + + )} +
+ +

+ {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} +

+ + {!error && isLoggedIn() && ( +
+ )} + + {error && isLoggedIn() && ( +
+ {error.includes('reached the limit') ? ( +
+

+ Limit reached +

+

+ You have reached the AI usage limit for today. Please upgrade + your account to continue. +

+ + +
+ ) : ( +

{error}

+ )} +
+ )} + + {!isLoggedIn() && ( +
+ +

+ Please login to generate course content +

+
+ )} + +
+ + + +
+
+ + {!isGenerating && !isLoading && ( + + )} +
+ ); +} diff --git a/src/components/GenerateCourse/AILimitsPopup.tsx b/src/components/GenerateCourse/AILimitsPopup.tsx new file mode 100644 index 000000000..7c87c6009 --- /dev/null +++ b/src/components/GenerateCourse/AILimitsPopup.tsx @@ -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 ( + +

+ Daily AI Limits +

+ + {/* Usage Progress Bar */} +
+
+ + Usage: {formatCommaNumber(used)} /  + {formatCommaNumber(limit)} tokens + + + {Math.round((used / limit) * 100)}% + +
+
+
+
+
+ + {/* Usage Stats */} +
+
+
+

Used Today

+

{formatCommaNumber(used)}

+
+
+

Daily Limit

+

{formatCommaNumber(limit)}

+
+
+
+ + {/* Explanation */} +
+
+

+ Limit resets every 24 hours.{' '} + {!isPaidUser && 'Consider upgrading for more tokens.'} +

+
+
+ + {/* Action Button */} +
+ {!isPaidUser && ( + + )} + +
+
+ ); +} diff --git a/src/components/GenerateCourse/CircularProgress.tsx b/src/components/GenerateCourse/CircularProgress.tsx new file mode 100644 index 000000000..67b6386e9 --- /dev/null +++ b/src/components/GenerateCourse/CircularProgress.tsx @@ -0,0 +1,57 @@ +import { cn } from '../../lib/classname'; + +export function ChapterNumberSkeleton() { + return ( +
+ ); +} + +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 ( +
+ {isVisible && !isLoading && ( + + + + )} + + {!isLoading && children} + {isLoading && } +
+ ); +} diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx new file mode 100644 index 000000000..5822a7855 --- /dev/null +++ b/src/components/GenerateCourse/GenerateAICourse.tsx @@ -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({ + 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 ( + + ); +} diff --git a/src/components/GenerateCourse/GetAICourse.tsx b/src/components/GenerateCourse/GetAICourse.tsx new file mode 100644 index 000000000..d26f613c8 --- /dev/null +++ b/src/components/GenerateCourse/GetAICourse.tsx @@ -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 ( + + ); +} diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx new file mode 100644 index 000000000..e6e10a50d --- /dev/null +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -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 && ( + setShowUpgradePopup(false)} /> + )} +
+
+

+ Your Courses +

+
+ +
+
+

+ + {limitUsedPercentage}% of daily limit used{' '} + + + {limitUsedPercentage}% used + + +

+
+ +
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+
+
+ + {!isInitialLoading && !isUserAiCoursesLoading && !isAuthenticated && ( +
+ +

+ {' '} + to generate and save courses. +

+
+ )} + + {!isUserAiCoursesLoading && + !isInitialLoading && + userAiCourses?.length === 0 && ( +
+

+ You haven't generated any courses yet. +

+
+ )} + + {(isUserAiCoursesLoading || isInitialLoading) && ( +
+ +

Loading...

+
+ )} + + {!isUserAiCoursesLoading && + filteredCourses && + filteredCourses.length > 0 && ( +
+ {filteredCourses.map((course) => ( + + ))} +
+ )} + + {!isUserAiCoursesLoading && + (userAiCourses?.length || 0 > 0) && + filteredCourses?.length === 0 && ( +
+

+ No courses match your search. +

+
+ )} + + ); +} diff --git a/src/components/Guide/RelatedGuides.tsx b/src/components/Guide/RelatedGuides.tsx index fe565dfcc..08f313e8f 100644 --- a/src/components/Guide/RelatedGuides.tsx +++ b/src/components/Guide/RelatedGuides.tsx @@ -1,6 +1,6 @@ +import { ChevronDown } from 'lucide-react'; import { useState } from 'react'; import { cn } from '../../lib/classname'; -import { ChevronDown } from 'lucide-react'; type RelatedGuidesProps = { relatedTitle?: string; @@ -27,7 +27,7 @@ export function RelatedGuides(props: RelatedGuidesProps) {

{relatedTitle}