mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-09 16:53:33 +02:00
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>
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1739229597159
|
"lastUpdateCheck": 1741697790683
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,4 +1,10 @@
|
|||||||
PUBLIC_API_URL=https://api.roadmap.sh
|
PUBLIC_API_URL=https://api.roadmap.sh
|
||||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
|
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
|
@@ -53,6 +53,7 @@
|
|||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
|
"markdown-it-async": "^2.0.0",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nanostores": "^0.11.3",
|
"nanostores": "^0.11.3",
|
||||||
"node-html-parser": "^6.1.13",
|
"node-html-parser": "^6.1.13",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"react-calendar-heatmap": "^1.9.0",
|
"react-calendar-heatmap": "^1.9.0",
|
||||||
"react-confetti": "^6.1.0",
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-textarea-autosize": "^8.5.7",
|
||||||
"react-tooltip": "^5.28.0",
|
"react-tooltip": "^5.28.0",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"rehype-external-links": "^3.0.0",
|
"rehype-external-links": "^3.0.0",
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
"satori": "^0.11.2",
|
"satori": "^0.11.2",
|
||||||
"satori-html": "^0.3.2",
|
"satori-html": "^0.3.2",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
|
"shiki": "^3.1.0",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
204
pnpm-lock.yaml
generated
204
pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
|||||||
luxon:
|
luxon:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
markdown-it-async:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.0.7
|
specifier: ^5.0.7
|
||||||
version: 5.0.9
|
version: 5.0.9
|
||||||
@@ -110,6 +113,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@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:
|
react-tooltip:
|
||||||
specifier: ^5.28.0
|
specifier: ^5.28.0
|
||||||
version: 5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -137,6 +143,9 @@ importers:
|
|||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.33.5
|
specifier: ^0.33.5
|
||||||
version: 0.33.5
|
version: 0.33.5
|
||||||
|
shiki:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
slugify:
|
slugify:
|
||||||
specifier: ^1.6.6
|
specifier: ^1.6.6
|
||||||
version: 1.6.6
|
version: 1.6.6
|
||||||
@@ -396,6 +405,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@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':
|
'@babel/template@7.25.9':
|
||||||
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
|
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1179,24 +1192,45 @@ packages:
|
|||||||
'@shikijs/core@1.29.1':
|
'@shikijs/core@1.29.1':
|
||||||
resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==}
|
resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==}
|
||||||
|
|
||||||
|
'@shikijs/core@3.1.0':
|
||||||
|
resolution: {integrity: sha512-1ppAOyg3F18N8Ge9DmJjGqRVswihN33rOgPovR6gUHW17Hw1L4RlRhnmVQcsacSHh0A8IO1FIgNbtTxUFwodmg==}
|
||||||
|
|
||||||
'@shikijs/engine-javascript@1.29.1':
|
'@shikijs/engine-javascript@1.29.1':
|
||||||
resolution: {integrity: sha512-Hpi8k9x77rCQ7F/7zxIOUruNkNidMyBnP5qAGbLFqg4kRrg1HZhkB8btib5EXbQWTtLb5gBHOdBwshk20njD7Q==}
|
resolution: {integrity: sha512-Hpi8k9x77rCQ7F/7zxIOUruNkNidMyBnP5qAGbLFqg4kRrg1HZhkB8btib5EXbQWTtLb5gBHOdBwshk20njD7Q==}
|
||||||
|
|
||||||
|
'@shikijs/engine-javascript@3.1.0':
|
||||||
|
resolution: {integrity: sha512-/LwkhW17jYi7uPcdaaSQQDNW+xgrHXarkrxYPoC6WPzH2xW5mFMw12doHXJBqxmYvtcTbaatcv2MkH9+3PU1FA==}
|
||||||
|
|
||||||
'@shikijs/engine-oniguruma@1.29.1':
|
'@shikijs/engine-oniguruma@1.29.1':
|
||||||
resolution: {integrity: sha512-gSt2WhLNgEeLstcweQOSp+C+MhOpTsgdNXRqr3zP6M+BUBZ8Md9OU2BYwUYsALBxHza7hwaIWtFHjQ/aOOychw==}
|
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':
|
'@shikijs/langs@1.29.1':
|
||||||
resolution: {integrity: sha512-iERn4HlyuT044/FgrvLOaZgKVKf3PozjKjyV/RZ5GnlyYEAZFcgwHGkYboeBv2IybQG1KVS/e7VGgiAU4JY2Gw==}
|
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':
|
'@shikijs/themes@1.29.1':
|
||||||
resolution: {integrity: sha512-lb11zf72Vc9uxkl+aec2oW1HVTHJ2LtgZgumb4Rr6By3y/96VmlU44bkxEb8WBWH3RUtbqAJEN0jljD9cF7H7g==}
|
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':
|
'@shikijs/types@1.29.1':
|
||||||
resolution: {integrity: sha512-aBqAuhYRp5vSir3Pc9+QPu9WESBOjUo03ao0IHLC4TyTioSsp/SkbAZSrIH4ghYYC1T1KTEpRSBa83bas4RnPA==}
|
resolution: {integrity: sha512-aBqAuhYRp5vSir3Pc9+QPu9WESBOjUo03ao0IHLC4TyTioSsp/SkbAZSrIH4ghYYC1T1KTEpRSBa83bas4RnPA==}
|
||||||
|
|
||||||
|
'@shikijs/types@3.1.0':
|
||||||
|
resolution: {integrity: sha512-F8e7Fy4ihtcNpJG572BZZC1ErYrBrzJ5Cbc9Zi3REgWry43gIvjJ9lFAoUnuy7Bvy4IFz7grUSxL5edfrrjFEA==}
|
||||||
|
|
||||||
'@shikijs/vscode-textmate@10.0.1':
|
'@shikijs/vscode-textmate@10.0.1':
|
||||||
resolution: {integrity: sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==}
|
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':
|
'@shuding/opentype.js@1.4.0-beta.0':
|
||||||
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
@@ -2090,6 +2124,9 @@ packages:
|
|||||||
hast-util-to-html@9.0.4:
|
hast-util-to-html@9.0.4:
|
||||||
resolution: {integrity: sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==}
|
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:
|
hast-util-to-parse5@8.0.0:
|
||||||
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
|
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
|
||||||
|
|
||||||
@@ -2343,6 +2380,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
markdown-it-async@2.0.0:
|
||||||
|
resolution: {integrity: sha512-jBthmQR5MwXR9Y8Y0teRoZAenaKQMdjuTfpbNARqMBSRPvyzyXCVduHZHakyyhL3ugIacCobXJrO07t277sIjw==}
|
||||||
|
|
||||||
markdown-it-task-lists@2.1.1:
|
markdown-it-task-lists@2.1.1:
|
||||||
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||||
|
|
||||||
@@ -2603,6 +2643,9 @@ packages:
|
|||||||
oniguruma-to-es@2.3.0:
|
oniguruma-to-es@2.3.0:
|
||||||
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
||||||
|
|
||||||
|
oniguruma-to-es@3.1.1:
|
||||||
|
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
|
||||||
|
|
||||||
openai@4.80.1:
|
openai@4.80.1:
|
||||||
resolution: {integrity: sha512-+6+bbXFwbIE88foZsBEt36bPkgZPdyFN82clAXG61gnHb2gXdZApDyRrcAHqEtpYICywpqaNo57kOm9dtnb7Cw==}
|
resolution: {integrity: sha512-+6+bbXFwbIE88foZsBEt36bPkgZPdyFN82clAXG61gnHb2gXdZApDyRrcAHqEtpYICywpqaNo57kOm9dtnb7Cw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2847,6 +2890,9 @@ packages:
|
|||||||
property-information@6.5.0:
|
property-information@6.5.0:
|
||||||
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
|
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
|
||||||
|
|
||||||
|
property-information@7.0.0:
|
||||||
|
resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==}
|
||||||
|
|
||||||
prosemirror-changeset@2.2.1:
|
prosemirror-changeset@2.2.1:
|
||||||
resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==}
|
resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==}
|
||||||
|
|
||||||
@@ -2942,6 +2988,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
react-tooltip@5.28.0:
|
||||||
resolution: {integrity: sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==}
|
resolution: {integrity: sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2965,15 +3017,24 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
|
regenerator-runtime@0.14.1:
|
||||||
|
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
|
||||||
|
|
||||||
regex-recursion@5.1.1:
|
regex-recursion@5.1.1:
|
||||||
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
|
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
|
||||||
|
|
||||||
|
regex-recursion@6.0.2:
|
||||||
|
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||||
|
|
||||||
regex-utilities@2.3.0:
|
regex-utilities@2.3.0:
|
||||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||||
|
|
||||||
regex@5.1.1:
|
regex@5.1.1:
|
||||||
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
|
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
|
||||||
|
|
||||||
|
regex@6.0.1:
|
||||||
|
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
|
||||||
|
|
||||||
rehype-external-links@3.0.0:
|
rehype-external-links@3.0.0:
|
||||||
resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==}
|
resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==}
|
||||||
|
|
||||||
@@ -3110,6 +3171,9 @@ packages:
|
|||||||
shiki@1.29.1:
|
shiki@1.29.1:
|
||||||
resolution: {integrity: sha512-TghWKV9pJTd/N+IgAIVJtr0qZkB7FfFCUrrEJc0aRmZupo3D1OCVRknQWVRVA7AX/M0Ld7QfoAruPzr3CnUJuw==}
|
resolution: {integrity: sha512-TghWKV9pJTd/N+IgAIVJtr0qZkB7FfFCUrrEJc0aRmZupo3D1OCVRknQWVRVA7AX/M0Ld7QfoAruPzr3CnUJuw==}
|
||||||
|
|
||||||
|
shiki@3.1.0:
|
||||||
|
resolution: {integrity: sha512-LdTNyWQlC5zdCaHdcp1zPA1OVA2ivb+KjGOOnGcy02tGaF5ja+dGibWFH7Ar8YlngUgK/scDqworK18Ys9cbYA==}
|
||||||
|
|
||||||
signal-exit@4.1.0:
|
signal-exit@4.1.0:
|
||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -3348,6 +3412,33 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
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:
|
use-sync-external-store@1.4.0:
|
||||||
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
|
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3743,6 +3834,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/runtime@7.26.9':
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime: 0.14.1
|
||||||
|
|
||||||
'@babel/template@7.25.9':
|
'@babel/template@7.25.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.26.2
|
'@babel/code-frame': 7.26.2
|
||||||
@@ -4336,32 +4431,65 @@ snapshots:
|
|||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
hast-util-to-html: 9.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':
|
'@shikijs/engine-javascript@1.29.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.1
|
'@shikijs/types': 1.29.1
|
||||||
'@shikijs/vscode-textmate': 10.0.1
|
'@shikijs/vscode-textmate': 10.0.1
|
||||||
oniguruma-to-es: 2.3.0
|
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':
|
'@shikijs/engine-oniguruma@1.29.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.1
|
'@shikijs/types': 1.29.1
|
||||||
'@shikijs/vscode-textmate': 10.0.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':
|
'@shikijs/langs@1.29.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.1
|
'@shikijs/types': 1.29.1
|
||||||
|
|
||||||
|
'@shikijs/langs@3.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 3.1.0
|
||||||
|
|
||||||
'@shikijs/themes@1.29.1':
|
'@shikijs/themes@1.29.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 1.29.1
|
'@shikijs/types': 1.29.1
|
||||||
|
|
||||||
|
'@shikijs/themes@3.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@shikijs/types': 3.1.0
|
||||||
|
|
||||||
'@shikijs/types@1.29.1':
|
'@shikijs/types@1.29.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/vscode-textmate': 10.0.1
|
'@shikijs/vscode-textmate': 10.0.1
|
||||||
'@types/hast': 3.0.4
|
'@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.1': {}
|
||||||
|
|
||||||
|
'@shikijs/vscode-textmate@10.0.2': {}
|
||||||
|
|
||||||
'@shuding/opentype.js@1.4.0-beta.0':
|
'@shuding/opentype.js@1.4.0-beta.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fflate: 0.7.4
|
fflate: 0.7.4
|
||||||
@@ -5381,6 +5509,20 @@ snapshots:
|
|||||||
stringify-entities: 4.0.4
|
stringify-entities: 4.0.4
|
||||||
zwitch: 2.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:
|
hast-util-to-parse5@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -5611,6 +5753,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 6.3.1
|
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-task-lists@2.1.1: {}
|
||||||
|
|
||||||
markdown-it@14.1.0:
|
markdown-it@14.1.0:
|
||||||
@@ -6027,6 +6174,12 @@ snapshots:
|
|||||||
regex: 5.1.1
|
regex: 5.1.1
|
||||||
regex-recursion: 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):
|
openai@4.80.1(zod@3.24.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.19.74
|
'@types/node': 18.19.74
|
||||||
@@ -6214,6 +6367,8 @@ snapshots:
|
|||||||
|
|
||||||
property-information@6.5.0: {}
|
property-information@6.5.0: {}
|
||||||
|
|
||||||
|
property-information@7.0.0: {}
|
||||||
|
|
||||||
prosemirror-changeset@2.2.1:
|
prosemirror-changeset@2.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prosemirror-transform: 1.10.2
|
prosemirror-transform: 1.10.2
|
||||||
@@ -6348,6 +6503,15 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.14.2: {}
|
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):
|
react-tooltip@5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.6.13
|
'@floating-ui/dom': 1.6.13
|
||||||
@@ -6381,17 +6545,27 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
regenerator-runtime@0.14.1: {}
|
||||||
|
|
||||||
regex-recursion@5.1.1:
|
regex-recursion@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
regex: 5.1.1
|
regex: 5.1.1
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
|
regex-recursion@6.0.2:
|
||||||
|
dependencies:
|
||||||
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
regex-utilities@2.3.0: {}
|
regex-utilities@2.3.0: {}
|
||||||
|
|
||||||
regex@5.1.1:
|
regex@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
|
regex@6.0.1:
|
||||||
|
dependencies:
|
||||||
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
rehype-external-links@3.0.0:
|
rehype-external-links@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -6655,6 +6829,17 @@ snapshots:
|
|||||||
'@shikijs/vscode-textmate': 10.0.1
|
'@shikijs/vscode-textmate': 10.0.1
|
||||||
'@types/hast': 3.0.4
|
'@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: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
simple-swizzle@0.2.2:
|
simple-swizzle@0.2.2:
|
||||||
@@ -6912,6 +7097,25 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
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):
|
use-sync-external-store@1.4.0(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
@@ -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;
|
||||||
|
}
|
@@ -27,7 +27,7 @@ const sidebarLinks = [
|
|||||||
href: '/account/update-profile',
|
href: '/account/update-profile',
|
||||||
title: 'Profile',
|
title: 'Profile',
|
||||||
id: 'profile',
|
id: 'profile',
|
||||||
isNew: true,
|
isNew: false,
|
||||||
icon: {
|
icon: {
|
||||||
glyph: 'user',
|
glyph: 'user',
|
||||||
classes: 'h-4 w-4',
|
classes: 'h-4 w-4',
|
||||||
@@ -56,7 +56,7 @@ const sidebarLinks = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/account/road-card',
|
href: '/account/road-card',
|
||||||
title: 'Card',
|
title: 'Road Card',
|
||||||
id: 'road-card',
|
id: 'road-card',
|
||||||
isNew: false,
|
isNew: false,
|
||||||
icon: {
|
icon: {
|
||||||
@@ -64,6 +64,16 @@ const sidebarLinks = [
|
|||||||
classes: 'h-4 w-4',
|
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',
|
href: '/account/settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -97,7 +107,7 @@ const sidebarLinks = [
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||||
Teams
|
Teams
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{
|
{
|
||||||
|
219
src/components/Billing/BillingPage.tsx
Normal file
219
src/components/Billing/BillingPage.tsx
Normal file
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
22
src/components/Billing/CheckSubscriptionVerification.tsx
Normal file
22
src/components/Billing/CheckSubscriptionVerification.tsx
Normal file
@@ -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 />;
|
||||||
|
}
|
83
src/components/Billing/EmptyBillingScreen.tsx
Normal file
83
src/components/Billing/EmptyBillingScreen.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
96
src/components/Billing/UpdatePlanConfirmation.tsx
Normal file
96
src/components/Billing/UpdatePlanConfirmation.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
308
src/components/Billing/UpgradeAccountModal.tsx
Normal file
308
src/components/Billing/UpgradeAccountModal.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
76
src/components/Billing/VerifyUpgrade.tsx
Normal file
76
src/components/Billing/VerifyUpgrade.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
123
src/components/GenerateCourse/AICourse.tsx
Normal file
123
src/components/GenerateCourse/AICourse.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
73
src/components/GenerateCourse/AICourseCard.tsx
Normal file
73
src/components/GenerateCourse/AICourseCard.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
451
src/components/GenerateCourse/AICourseContent.tsx
Normal file
451
src/components/GenerateCourse/AICourseContent.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
131
src/components/GenerateCourse/AICourseFollowUp.css
Normal file
131
src/components/GenerateCourse/AICourseFollowUp.css
Normal file
@@ -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;
|
||||||
|
}
|
77
src/components/GenerateCourse/AICourseFollowUp.tsx
Normal file
77
src/components/GenerateCourse/AICourseFollowUp.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
382
src/components/GenerateCourse/AICourseFollowUpPopover.tsx
Normal file
382
src/components/GenerateCourse/AICourseFollowUpPopover.tsx
Normal file
@@ -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;
|
78
src/components/GenerateCourse/AICourseLimit.tsx
Normal file
78
src/components/GenerateCourse/AICourseLimit.tsx
Normal file
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
208
src/components/GenerateCourse/AICourseModuleList.tsx
Normal file
208
src/components/GenerateCourse/AICourseModuleList.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
344
src/components/GenerateCourse/AICourseModuleView.tsx
Normal file
344
src/components/GenerateCourse/AICourseModuleView.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
103
src/components/GenerateCourse/AILimitsPopup.tsx
Normal file
103
src/components/GenerateCourse/AILimitsPopup.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
57
src/components/GenerateCourse/CircularProgress.tsx
Normal file
57
src/components/GenerateCourse/CircularProgress.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
189
src/components/GenerateCourse/GenerateAICourse.tsx
Normal file
189
src/components/GenerateCourse/GenerateAICourse.tsx
Normal file
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
65
src/components/GenerateCourse/GetAICourse.tsx
Normal file
65
src/components/GenerateCourse/GetAICourse.tsx
Normal file
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
180
src/components/GenerateCourse/UserCoursesList.tsx
Normal file
180
src/components/GenerateCourse/UserCoursesList.tsx
Normal file
@@ -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,6 +1,6 @@
|
|||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
|
|
||||||
type RelatedGuidesProps = {
|
type RelatedGuidesProps = {
|
||||||
relatedTitle?: string;
|
relatedTitle?: string;
|
||||||
@@ -27,7 +27,7 @@ export function RelatedGuides(props: RelatedGuidesProps) {
|
|||||||
<div className={cn('relative min-w-[250px] pt-0 lg:px-5 lg:pt-10')}>
|
<div className={cn('relative min-w-[250px] pt-0 lg:px-5 lg:pt-10')}>
|
||||||
<h4 className="text-lg font-medium max-lg:hidden">{relatedTitle}</h4>
|
<h4 className="text-lg font-medium max-lg:hidden">{relatedTitle}</h4>
|
||||||
<button
|
<button
|
||||||
className="flex border-b w-full items-center justify-between gap-2 bg-gray-300 px-3 py-2 text-sm font-medium lg:hidden"
|
className="flex w-full items-center justify-between gap-2 border-b bg-gray-300 px-3 py-2 text-sm font-medium lg:hidden"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
{relatedTitle}
|
{relatedTitle}
|
||||||
|
@@ -10,11 +10,8 @@ export function CourseAnnouncement() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-[91]">
|
<div className="sticky top-0 z-[91]">
|
||||||
<a
|
<a href="/courses/sql" className="flex items-center bg-yellow-400 py-1.5">
|
||||||
href="/courses/sql"
|
<span className="container mx-auto flex items-center justify-start gap-2 text-center sm:justify-center sm:gap-4">
|
||||||
className="flex items-center bg-yellow-400 py-1.5"
|
|
||||||
>
|
|
||||||
<span className="container mx-auto flex items-center justify-start sm:justify-center gap-2 text-center sm:gap-4">
|
|
||||||
<span className="flex items-center gap-1.5 text-xs font-medium text-black md:text-base">
|
<span className="flex items-center gap-1.5 text-xs font-medium text-black md:text-base">
|
||||||
<Database className="hidden h-4 w-4 flex-shrink-0 text-black sm:block" />
|
<Database className="hidden h-4 w-4 flex-shrink-0 text-black sm:block" />
|
||||||
<span className="hidden sm:block">
|
<span className="hidden sm:block">
|
||||||
@@ -22,7 +19,7 @@ export function CourseAnnouncement() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="block sm:hidden">Announcing our SQL course</span>
|
<span className="block sm:hidden">Announcing our SQL course</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="items-center gap-1.5 rounded-full bg-black px-2 py-0.5 text-sm text-xs font-medium uppercase tracking-wide text-white hover:bg-zinc-800 sm:px-3 sm:py-1">
|
<span className="items-center gap-1.5 rounded-full bg-black px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-white hover:bg-zinc-800 sm:px-3 sm:py-1">
|
||||||
<span className="mr-1.5 hidden sm:inline">Start Learning</span>
|
<span className="mr-1.5 hidden sm:inline">Start Learning</span>
|
||||||
<span className="mr-1.5 inline sm:hidden">Visit</span>
|
<span className="mr-1.5 inline sm:hidden">Visit</span>
|
||||||
<span className="">→</span>
|
<span className="">→</span>
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
export function getPercentage(portion: number, total: number): string {
|
export function getPercentage(portion: number, total: number): number {
|
||||||
if (portion <= 0 || total <= 0) {
|
if (portion <= 0 || total <= 0) {
|
||||||
return '0.00';
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (portion >= total) {
|
if (portion >= total) {
|
||||||
return '100.00';
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const percentage = (portion / total) * 100;
|
const percentage = (portion / total) * 100;
|
||||||
return percentage.toFixed(2);
|
return Math.round(percentage);
|
||||||
}
|
}
|
||||||
|
@@ -71,3 +71,71 @@ export async function readAIRoadmapContentStream(
|
|||||||
onStreamEnd?.(result);
|
onStreamEnd?.(result);
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readAICourseStream(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
|
{
|
||||||
|
onStream,
|
||||||
|
onStreamEnd,
|
||||||
|
}: {
|
||||||
|
onStream?: (course: string) => void;
|
||||||
|
onStreamEnd?: (course: string) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the stream data as it comes in
|
||||||
|
if (value) {
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
if (value[i] === NEW_LINE) {
|
||||||
|
result += decoder.decode(value.slice(start, i + 1));
|
||||||
|
onStream?.(result);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < value.length) {
|
||||||
|
result += decoder.decode(value.slice(start));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStream?.(result);
|
||||||
|
onStreamEnd?.(result);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAICourseLessonStream(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
|
{
|
||||||
|
onStream,
|
||||||
|
onStreamEnd,
|
||||||
|
}: {
|
||||||
|
onStream?: (lesson: string) => void;
|
||||||
|
onStreamEnd?: (lesson: string) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += decoder.decode(value);
|
||||||
|
onStream?.(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStream?.(result);
|
||||||
|
onStreamEnd?.(result);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
1
src/icons/credit-card.svg
Normal file
1
src/icons/credit-card.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-credit-card"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import MarkdownItAsync from 'markdown-it-async';
|
||||||
|
|
||||||
// replaces @variableName@ with the value of the variable
|
// replaces @variableName@ with the value of the variable
|
||||||
export function replaceVariables(
|
export function replaceVariables(
|
||||||
@@ -16,13 +17,13 @@ export function replaceVariables(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
});
|
||||||
|
|
||||||
export function markdownToHtml(markdown: string, isInline = true): string {
|
export function markdownToHtml(markdown: string, isInline = true): string {
|
||||||
try {
|
try {
|
||||||
const md = new MarkdownIt({
|
|
||||||
html: true,
|
|
||||||
linkify: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Solution to open links in new tab in markdown
|
// Solution to open links in new tab in markdown
|
||||||
// otherwise default behaviour is to open in same tab
|
// otherwise default behaviour is to open in same tab
|
||||||
//
|
//
|
||||||
@@ -60,3 +61,52 @@ export function markdownToHtml(markdown: string, isInline = true): string {
|
|||||||
export function sanitizeMarkdown(markdown: string) {
|
export function sanitizeMarkdown(markdown: string) {
|
||||||
return markdown.replace(/\\\[([^\\]+)\\\]\(([^\\]+)\)/g, '[$1]($2)');
|
return markdown.replace(/\\\[([^\\]+)\\\]\(([^\\]+)\)/g, '[$1]($2)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownItAsync = MarkdownItAsync({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
|
||||||
|
async highlight(code, lang, attrs) {
|
||||||
|
const { codeToHtml } = await import('shiki');
|
||||||
|
|
||||||
|
const html = await codeToHtml(code, {
|
||||||
|
lang: lang?.toLowerCase(),
|
||||||
|
theme: 'dracula',
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function markdownToHtmlWithHighlighting(markdown: string) {
|
||||||
|
try {
|
||||||
|
// Solution to open links in new tab in markdown
|
||||||
|
// otherwise default behaviour is to open in same tab
|
||||||
|
//
|
||||||
|
// SOURCE: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
|
||||||
|
//
|
||||||
|
const defaultRender =
|
||||||
|
markdownItAsync.renderer.rules.link_open ||
|
||||||
|
function (tokens, idx, options, env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
markdownItAsync.renderer.rules.link_open = function (
|
||||||
|
tokens,
|
||||||
|
idx,
|
||||||
|
options,
|
||||||
|
env,
|
||||||
|
self,
|
||||||
|
) {
|
||||||
|
// Add a new `target` attribute, or replace the value of the existing one.
|
||||||
|
tokens[idx].attrSet('target', '_blank');
|
||||||
|
|
||||||
|
// Pass the token to the default renderer.
|
||||||
|
return defaultRender(tokens, idx, options, env, self);
|
||||||
|
};
|
||||||
|
|
||||||
|
return markdownItAsync.renderAsync(markdown);
|
||||||
|
} catch (e) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
16
src/pages/account/billing.astro
Normal file
16
src/pages/account/billing.astro
Normal file
@@ -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>
|
24
src/pages/ai-tutor/[courseSlug].astro
Normal file
24
src/pages/ai-tutor/[courseSlug].astro
Normal file
@@ -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>
|
10
src/pages/ai-tutor/index.astro
Normal file
10
src/pages/ai-tutor/index.astro
Normal file
@@ -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>
|
16
src/pages/ai-tutor/search.astro
Normal file
16
src/pages/ai-tutor/search.astro
Normal file
@@ -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>
|
92
src/queries/ai-course.ts
Normal file
92
src/queries/ai-course.ts
Normal file
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
@@ -1,5 +1,57 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import { httpGet } from '../lib/query-http';
|
import { httpGet } from '../lib/query-http';
|
||||||
|
import { isLoggedIn } from '../lib/jwt';
|
||||||
|
|
||||||
|
export const allowedSubscriptionStatus = [
|
||||||
|
'active',
|
||||||
|
'canceled',
|
||||||
|
'incomplete',
|
||||||
|
'incomplete_expired',
|
||||||
|
'past_due',
|
||||||
|
'paused',
|
||||||
|
'trialing',
|
||||||
|
'unpaid',
|
||||||
|
'none',
|
||||||
|
] as const;
|
||||||
|
export type AllowedSubscriptionStatus =
|
||||||
|
(typeof allowedSubscriptionStatus)[number];
|
||||||
|
|
||||||
|
export const USER_SUBSCRIPTION_PLAN_PRICES = [
|
||||||
|
{
|
||||||
|
name: 'Pay Monthly',
|
||||||
|
interval: 'month',
|
||||||
|
priceId: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID,
|
||||||
|
amount: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_AMOUNT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pay Yearly',
|
||||||
|
interval: 'year',
|
||||||
|
priceId: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID,
|
||||||
|
amount: import.meta.env.PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_AMOUNT,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AllowedSubscriptionInterval =
|
||||||
|
(typeof USER_SUBSCRIPTION_PLAN_PRICES)[number]['interval'];
|
||||||
|
|
||||||
|
type BillingDetailsResponse = {
|
||||||
|
status: AllowedSubscriptionStatus;
|
||||||
|
planId?: string;
|
||||||
|
priceId?: string;
|
||||||
|
interval?: string;
|
||||||
|
currentPeriodEnd?: Date;
|
||||||
|
cancelAtPeriodEnd?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function billingDetailsOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['billing-details'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return httpGet<BillingDetailsResponse>('/v1-billing-details');
|
||||||
|
},
|
||||||
|
enabled: !!isLoggedIn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type CoursePriceParams = {
|
type CoursePriceParams = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
@@ -22,4 +74,4 @@ export function coursePriceOptions(params: CoursePriceParams) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user