diff --git a/.astro/settings.json b/.astro/settings.json index 867818ff4..4d5033b8e 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1747060270496 + "lastUpdateCheck": 1748277554631 } } \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts index 03d7cc43f..f964fe0cf 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1,2 +1 @@ /// -/// \ No newline at end of file diff --git a/package.json b/package.json index 94b23d214..6840c25fd 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,14 @@ "@roadmapsh/editor": "workspace:*", "@tailwindcss/vite": "^4.1.7", "@tanstack/react-query": "^5.76.1", + "@tiptap/core": "^2.12.0", + "@tiptap/extension-document": "^2.12.0", + "@tiptap/extension-paragraph": "^2.12.0", + "@tiptap/extension-placeholder": "^2.12.0", + "@tiptap/extension-text": "^2.12.0", + "@tiptap/pm": "^2.12.0", + "@tiptap/react": "^2.12.0", + "@tiptap/suggestion": "^2.12.0", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "astro": "^5.7.13", @@ -80,6 +88,7 @@ "shiki": "^3.4.2", "slugify": "^1.6.6", "tailwind-merge": "^3.3.0", + "tippy.js": "^6.3.7", "tailwindcss": "^4.1.7", "tiptap-markdown": "^0.8.10", "turndown": "^7.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98dcdc64b..aa6421233 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,30 @@ importers: '@tanstack/react-query': specifier: ^5.76.1 version: 5.76.1(react@19.1.0) + '@tiptap/core': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/extension-document': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) + '@tiptap/extension-paragraph': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) + '@tiptap/extension-placeholder': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) + '@tiptap/extension-text': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) + '@tiptap/pm': + specifier: ^2.12.0 + version: 2.12.0 + '@tiptap/react': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tiptap/suggestion': + specifier: ^2.12.0 + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) '@types/react': specifier: ^19.1.4 version: 19.1.4 @@ -158,6 +182,9 @@ importers: tailwindcss: specifier: ^4.1.7 version: 4.1.7 + tippy.js: + specifier: ^6.3.7 + version: 6.3.7 tiptap-markdown: specifier: ^0.8.10 version: 0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)) @@ -983,6 +1010,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -1396,9 +1426,56 @@ packages: peerDependencies: '@tiptap/pm': ^2.7.0 + '@tiptap/extension-bubble-menu@2.12.0': + resolution: {integrity: sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-document@2.12.0': + resolution: {integrity: sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-floating-menu@2.12.0': + resolution: {integrity: sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-paragraph@2.12.0': + resolution: {integrity: sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-placeholder@2.12.0': + resolution: {integrity: sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-text@2.12.0': + resolution: {integrity: sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm@2.12.0': resolution: {integrity: sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==} + '@tiptap/react@2.12.0': + resolution: {integrity: sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/suggestion@2.12.0': + resolution: {integrity: sha512-bsXLoZbjUo1oOF1Z+XSfoGzbcnrTcYtJdfylM/FerMLU9T12dhsM/Ri2SKLX4IR5D0HJ07FcsEHCrGEy8Y5y0A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -1527,6 +1604,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -2859,6 +2939,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch-native@1.6.6: resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} @@ -3546,6 +3627,9 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tiptap-markdown@0.8.10: resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==} peerDependencies: @@ -4606,6 +4690,8 @@ snapshots: dependencies: playwright: 1.52.0 + '@popperjs/core@2.11.8': {} + '@remirror/core-constants@3.0.0': {} '@resvg/resvg-js-android-arm-eabi@2.6.2': @@ -4925,6 +5011,35 @@ snapshots: dependencies: '@tiptap/pm': 2.12.0 + '@tiptap/extension-bubble-menu@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/pm': 2.12.0 + tippy.js: 6.3.7 + + '@tiptap/extension-document@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + + '@tiptap/extension-floating-menu@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/pm': 2.12.0 + tippy.js: 6.3.7 + + '@tiptap/extension-paragraph@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + + '@tiptap/extension-placeholder@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/pm': 2.12.0 + + '@tiptap/extension-text@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/pm@2.12.0': dependencies: prosemirror-changeset: 2.3.0 @@ -4946,6 +5061,23 @@ snapshots: prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 + '@tiptap/react@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/extension-bubble-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) + '@tiptap/extension-floating-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) + '@tiptap/pm': 2.12.0 + '@types/use-sync-external-store': 0.0.6 + fast-deep-equal: 3.1.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.5.0(react@19.1.0) + + '@tiptap/suggestion@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)': + dependencies: + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) + '@tiptap/pm': 2.12.0 + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -5092,6 +5224,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4))': @@ -7409,6 +7543,10 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + tiptap-markdown@0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)): dependencies: '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) diff --git a/public/images/gifs/bot.gif b/public/images/gifs/bot.gif new file mode 100644 index 000000000..b2a7b834f Binary files /dev/null and b/public/images/gifs/bot.gif differ diff --git a/public/images/gifs/wave.gif b/public/images/gifs/wave.gif new file mode 100644 index 000000000..4066643d6 Binary files /dev/null and b/public/images/gifs/wave.gif differ diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx index 068956cfe..3c1e0a127 100644 --- a/src/components/AITutor/AITutorLayout.tsx +++ b/src/components/AITutor/AITutorLayout.tsx @@ -2,20 +2,22 @@ import { Menu } from 'lucide-react'; import { useState } from 'react'; import { AITutorSidebar, type AITutorTab } from './AITutorSidebar'; import { RoadmapLogoIcon } from '../ReactIcons/RoadmapLogo'; +import { cn } from '../../lib/classname'; type AITutorLayoutProps = { children: React.ReactNode; activeTab: AITutorTab; + wrapperClassName?: string; }; export function AITutorLayout(props: AITutorLayoutProps) { - const { children, activeTab } = props; + const { children, activeTab, wrapperClassName } = props; const [isSidebarFloating, setIsSidebarFloating] = useState(false); return ( <> -
+
@@ -27,13 +29,18 @@ export function AITutorLayout(props: AITutorLayoutProps) {
-
+
setIsSidebarFloating(false)} isFloating={isSidebarFloating} activeTab={activeTab} /> -
+
{children}
diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 471d75849..bd9bc41c2 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,9 +1,15 @@ +import { + BookOpen, Compass, + Plus, + Star, + X, + Zap +} from 'lucide-react'; import { useEffect, useState } from 'react'; -import { BookOpen, Compass, Plus, Star, X, Zap } from 'lucide-react'; -import { AITutorLogo } from '../ReactIcons/AITutorLogo'; -import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; -import { useIsPaidUser } from '../../queries/billing'; import { isLoggedIn } from '../../lib/jwt'; +import { useIsPaidUser } from '../../queries/billing'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { AITutorLogo } from '../ReactIcons/AITutorLogo'; type AITutorSidebarProps = { isFloating: boolean; @@ -24,6 +30,12 @@ const sidebarItems = [ href: '/ai/courses', icon: BookOpen, }, + // { + // key: 'chat', + // label: 'AI Chat', + // href: '/ai/chat', + // icon: Bot, + // }, { key: 'staff-picks', label: 'Staff Picks', diff --git a/src/components/AuthenticationFlow/EmailLoginForm.tsx b/src/components/AuthenticationFlow/EmailLoginForm.tsx index 8e7e2c0a3..75406960a 100644 --- a/src/components/AuthenticationFlow/EmailLoginForm.tsx +++ b/src/components/AuthenticationFlow/EmailLoginForm.tsx @@ -2,8 +2,9 @@ import type { FormEvent } from 'react'; import { useId, useState } from 'react'; import { httpPost } from '../../lib/http'; import { - COURSE_PURCHASE_PARAM, FIRST_LOGIN_PARAM, - setAuthToken + COURSE_PURCHASE_PARAM, + FIRST_LOGIN_PARAM, + setAuthToken, } from '../../lib/jwt'; type EmailLoginFormProps = { @@ -65,7 +66,11 @@ export function EmailLoginForm(props: EmailLoginFormProps) { const passwordFieldId = `form:${useId()}`; return ( -
+ diff --git a/src/components/AuthenticationFlow/LoginPopup.astro b/src/components/AuthenticationFlow/LoginPopup.astro index 04e19360e..25ef117b3 100644 --- a/src/components/AuthenticationFlow/LoginPopup.astro +++ b/src/components/AuthenticationFlow/LoginPopup.astro @@ -6,7 +6,7 @@ import { AuthenticationForm } from './AuthenticationForm';
-

+

Login or Signup

diff --git a/src/components/ChatEditor/ChatEditor.css b/src/components/ChatEditor/ChatEditor.css new file mode 100644 index 000000000..d5ff8dca4 --- /dev/null +++ b/src/components/ChatEditor/ChatEditor.css @@ -0,0 +1,25 @@ +.chat-editor .tiptap p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.chat-editor .tiptap p:first-child.is-empty::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.chat-editor .tiptap [data-type='variable'] { + font-size: 12px; + font-weight: 500; + line-height: 1.5; + padding: 2px 4px; + border-radius: 8px; + background-color: #f0f5ff; + color: #2c5df1; +} diff --git a/src/components/ChatEditor/ChatEditor.tsx b/src/components/ChatEditor/ChatEditor.tsx new file mode 100644 index 000000000..7361cb4c9 --- /dev/null +++ b/src/components/ChatEditor/ChatEditor.tsx @@ -0,0 +1,122 @@ +import './ChatEditor.css'; + +import { + Editor, + EditorContent, + useEditor, + type JSONContent, +} from '@tiptap/react'; +import DocumentExtension from '@tiptap/extension-document'; +import ParagraphExtension from '@tiptap/extension-paragraph'; +import TextExtension from '@tiptap/extension-text'; +import Placeholder from '@tiptap/extension-placeholder'; +import { VariableExtension } from './VariableExtension/VariableExtension'; +import { variableSuggestion } from './VariableExtension/VariableSuggestion'; +import { queryClient } from '../../stores/query-client'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, type RefObject } from 'react'; +import { roadmapDetailsOptions } from '../../queries/roadmap'; + +const extensions = [ + DocumentExtension, + ParagraphExtension, + TextExtension, + Placeholder.configure({ + placeholder: 'Ask AI anything about the roadmap...', + }), + VariableExtension.configure({ + suggestion: variableSuggestion(), + }), +]; + +const content = '

'; + +type ChatEditorProps = { + editorRef: RefObject; + roadmapId: string; + onSubmit: (content: JSONContent) => void; +}; + +export function ChatEditor(props: ChatEditorProps) { + const { roadmapId, onSubmit, editorRef } = props; + + const { data: roadmapTreeData } = useQuery( + roadmapTreeMappingOptions(roadmapId), + queryClient, + ); + + const { data: roadmapDetailsData } = useQuery( + roadmapDetailsOptions(roadmapId), + queryClient, + ); + + const editor = useEditor({ + extensions, + content, + editorProps: { + attributes: { + class: 'focus:outline-none w-full px-4 py-2 min-h-[40px]', + }, + handleKeyDown(_, event) { + if (!editor) { + return false; + } + + if (event.key === 'Enter' && !event.shiftKey) { + // check if the variable suggestion list is focused + // if it is, return false so the default behavior is not triggered + const variableSuggestionList = document.getElementById( + 'variable-suggestion-list', + ); + if (variableSuggestionList) { + return false; + } + + event.preventDefault(); + onSubmit(editor.getJSON()); + return true; + } + + if (event.key === 'Enter' && event.shiftKey) { + event.preventDefault(); + editor.commands.insertContent([ + { type: 'text', text: ' ' }, + { type: 'paragraph' }, + ]); + return true; + } + + return false; + }, + }, + onUpdate: ({ editor }) => { + editorRef.current = editor; + }, + onDestroy: () => { + editorRef.current = null; + }, + }); + + useEffect(() => { + if (!editor || !roadmapTreeData || !roadmapDetailsData) { + return; + } + + editor.storage.variable.variables = roadmapTreeData.map((mapping) => { + return { + id: mapping.nodeId, + // to remove the title of the roadmap + // and only keep the path + // e.g. "Roadmap > Topic > Subtopic" -> "Topic > Subtopic" + label: mapping.text.split(' > ').slice(1).join(' > '), + }; + }); + }, [editor, roadmapTreeData, roadmapDetailsData]); + + return ( +
+ +
+ ); +} diff --git a/src/components/ChatEditor/VariableExtension/VariableExtension.tsx b/src/components/ChatEditor/VariableExtension/VariableExtension.tsx new file mode 100644 index 000000000..840b34be6 --- /dev/null +++ b/src/components/ChatEditor/VariableExtension/VariableExtension.tsx @@ -0,0 +1,311 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import { type DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { PluginKey } from '@tiptap/pm/state'; +import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'; + +// See `addAttributes` below +export interface VariableNodeAttrs { + /** + * The identifier for the selected item that was mentioned, stored as a `data-id` + * attribute. + */ + id: string | null; + /** + * The label to be rendered by the editor as the displayed text for this mentioned + * item, if provided. Stored as a `data-label` attribute. See `renderLabel`. + */ + label?: string | null; +} + +export type VariableOptions< + SuggestionItem = any, + Attrs extends Record = VariableNodeAttrs, +> = { + /** + * The HTML attributes for a mention node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; + + /** + * A function to render the label of a mention. + * @deprecated use renderText and renderHTML instead + * @param props The render props + * @returns The label + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderLabel?: (props: { + options: VariableOptions; + node: ProseMirrorNode; + }) => string; + + /** + * A function to render the text of a mention. + * @param props The render props + * @returns The text + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderText: (props: { + options: VariableOptions; + node: ProseMirrorNode; + }) => string; + + /** + * A function to render the HTML of a mention. + * @param props The render props + * @returns The HTML as a ProseMirror DOM Output Spec + * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`] + */ + renderHTML: (props: { + options: VariableOptions; + node: ProseMirrorNode; + }) => DOMOutputSpec; + + /** + * Whether to delete the trigger character with backspace. + * @default false + */ + deleteTriggerWithBackspace: boolean; + + /** + * The suggestion options. + * @default {} + * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } } + */ + suggestion: Omit, 'editor'>; +}; + +export type VariableType = { + id: string; + label: string; +}; + +export type VariableStorage = { + variables: VariableType[]; +}; + +/** + * The plugin key for the variable plugin. + * @default 'variable' + */ +export const VariablePluginKey = new PluginKey('variable'); + +export const VariableExtension = Node.create({ + name: 'variable', + + priority: 101, + + addStorage() { + return { + variables: [], + }; + }, + + addOptions() { + return { + HTMLAttributes: {}, + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; + }, + deleteTriggerWithBackspace: false, + renderHTML({ options, node }) { + return [ + 'span', + mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, + ]; + }, + suggestion: { + char: '@', + pluginKey: VariablePluginKey, + command: ({ editor, range, props }) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter; + const overrideSpace = nodeAfter?.text?.startsWith(' '); + + if (overrideSpace) { + range.to += 1; + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: 'text', + text: ' ', + }, + ]) + .run(); + + // get reference to `window` object from editor element, to support cross-frame JS usage + editor.view.dom.ownerDocument.defaultView + ?.getSelection() + ?.collapseToEnd(); + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const type = state.schema.nodes[this.name]; + const allow = !!$from.parent.type.contentMatch.matchType(type); + + return allow; + }, + }, + }; + }, + + group: 'inline', + + inline: true, + + selectable: false, + + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute('data-id'), + renderHTML: (attributes) => { + if (!attributes.id) { + return {}; + } + + return { + 'data-id': attributes.id, + }; + }, + }, + + label: { + default: null, + parseHTML: (element) => element.getAttribute('data-label'), + renderHTML: (attributes) => { + if (!attributes.label) { + return {}; + } + + return { + 'data-label': attributes.label, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + if (this.options.renderLabel !== undefined) { + console.warn( + 'renderLabel is deprecated use renderText and renderHTML instead', + ); + return [ + 'span', + mergeAttributes( + { 'data-type': this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + this.options.renderLabel({ + options: this.options, + node, + }), + ]; + } + const mergedOptions = { ...this.options }; + + mergedOptions.HTMLAttributes = mergeAttributes( + { 'data-type': this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ); + const html = this.options.renderHTML({ + options: mergedOptions, + node, + }); + + if (typeof html === 'string') { + return [ + 'span', + mergeAttributes( + { 'data-type': this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + html, + ]; + } + return html; + }, + + renderText({ node }) { + if (this.options.renderLabel !== undefined) { + console.warn( + 'renderLabel is deprecated use renderText and renderHTML instead', + ); + return this.options.renderLabel({ + options: this.options, + node, + }); + } + return this.options.renderText({ + options: this.options, + node, + }); + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({ tr, state }) => { + let isVariable = false; + const { selection } = state; + const { empty, anchor } = selection; + + if (!empty) { + return false; + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isVariable = true; + tr.insertText( + this.options.deleteTriggerWithBackspace + ? '' + : this.options.suggestion.char || '', + pos, + pos + node.nodeSize, + ); + + return false; + } + }); + + return isVariable; + }), + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); diff --git a/src/components/ChatEditor/VariableExtension/VariableSuggestion.tsx b/src/components/ChatEditor/VariableExtension/VariableSuggestion.tsx new file mode 100644 index 000000000..ce3d1e432 --- /dev/null +++ b/src/components/ChatEditor/VariableExtension/VariableSuggestion.tsx @@ -0,0 +1,175 @@ +import { ReactRenderer } from '@tiptap/react'; +import type { SuggestionOptions } from '@tiptap/suggestion'; +import tippy, { type GetReferenceClientRect } from 'tippy.js'; + +import { + forwardRef, + Fragment, + useEffect, + useImperativeHandle, + useState, +} from 'react'; +import { cn } from '../../../lib/classname'; +import type { VariableStorage, VariableType } from './VariableExtension'; +import { ChevronRight } from 'lucide-react'; + +export type VariableListProps = { + command: (variable: VariableType) => void; + items: VariableType[]; +} & SuggestionOptions; + +export const VariableList = forwardRef((props: VariableListProps, ref) => { + const { items, command } = props; + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = (index: number) => { + const item = props.items[index]; + + if (!item) { + return; + } + + command(item); + }; + + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === 'ArrowUp') { + setSelectedIndex((selectedIndex + items.length - 1) % items.length); + return true; + } + + if (event.key === 'ArrowDown') { + setSelectedIndex((selectedIndex + 1) % items.length); + return true; + } + + if (event.key === 'Enter') { + selectItem(selectedIndex); + return true; + } + + return false; + }, + })); + + return ( +
+ {items.length ? ( + items.map((item, index) => { + const labelParts = item?.label.split('>'); + + return ( + + ); + }) + ) : ( +
No result
+ )} +
+ ); +}); + +VariableList.displayName = 'VariableList'; + +export function variableSuggestion(): Omit { + return { + items: ({ editor, query }) => { + const storage = editor.storage.variable as VariableStorage; + + return storage.variables + .filter((variable) => + variable?.label?.toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, 5); + }, + + render: () => { + let component: ReactRenderer; + let popup: InstanceType | null = null; + + return { + onStart: (props) => { + component = new ReactRenderer(VariableList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as GetReferenceClientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'top-start', + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }; +} diff --git a/src/components/GenerateCourse/FineTuneCourse.tsx b/src/components/GenerateCourse/FineTuneCourse.tsx index e727c8dfb..390fe9d07 100644 --- a/src/components/GenerateCourse/FineTuneCourse.tsx +++ b/src/components/GenerateCourse/FineTuneCourse.tsx @@ -56,7 +56,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) { return (
diff --git a/src/components/Premium/PremiumPage.tsx b/src/components/Premium/PremiumPage.tsx new file mode 100644 index 000000000..b89ad521d --- /dev/null +++ b/src/components/Premium/PremiumPage.tsx @@ -0,0 +1,420 @@ +import { + Brain, + Bot, + Book, + Star, + Rocket, + CheckCircle2, + Zap, + Clock, + Crown, + Users2, + Wand2, + Play, + GitPullRequest, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '../../lib/classname'; + +interface FeatureCardProps { + title: string; + description: string; + Icon: LucideIcon; + duration?: string; +} + +function FeatureCard({ + title, + description, + Icon, + duration = '2:30', +}: FeatureCardProps) { + return ( +
+
+
+
+ +
+
+
+ {duration} +
+
+

{title}

+

{description}

+
+ ); +} + +function StarRating() { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ); +} + +function Testimonial({ + name, + role, + content, +}: { + name: string; + role: string; + content: string; +}) { + return ( +
+ +

{content}

+
+
{name}
+
{role}
+
+
+ ); +} + +interface StatsItemProps { + icon: LucideIcon; + text: string; +} + +function StatsItem(props: StatsItemProps) { + const Icon = props.icon; + return ( +
+ + {props.text} +
+ ); +} + +interface CredibilityItemProps { + icon: LucideIcon; + iconClassName: string; + value: string; + label: string; + subLabel: string; +} + +function CredibilityItem(props: CredibilityItemProps) { + const Icon = props.icon; + return ( +
+
+ +
+
{props.value}
+
+ {props.label} +
+
{props.subLabel}
+
+ ); +} + +export function PremiumPage() { + const handleUpgrade = () => { + alert('Upgrade functionality coming soon!'); + }; + + return ( +
+
+ {/* Hero Section */} +
+
+
+ + + Unlock All Premium Features + +
+

+ Learn Faster with AI +

+

+ Generate unlimited courses about any topic, get career guidance + and instant answers from AI, test your skills and more +

+ +

+ + + 2 months free with yearly plan + +

+
+
+ + {/* Stats Section */} +
+ + + + +
+ + {/* Testimonials */} +
+

+ What others are saying +

+
+ + + + + + + + +
+
+ + {/* Features Grid */} +
+

+ Everything You Need to Succeed +

+
+ + + + + + +
+
+ + {/* Credibility Stats */} +
+
+
+

+ The Platform Developers Trust +

+

+ Join millions of developers in their learning journey +

+
+ +
+ + + + +
+
+
+ + {/* Pricing Section */} +
+

+ Choose Your Plan +

+
+
+

Monthly

+
+
+ $10 + /month +
+

+ Perfect for continuous learning +

+
+ +
    + {[ + 'AI Learning Assistant', + 'Personalized Learning Paths', + 'Interactive Exercises', + 'Premium Resources', + ].map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ +
+
+ + Most Popular + +
+

Yearly

+
+
+ $100 + /year +
+

+ Save $20 (2 months free) +

+
+ +
    + {[ + 'Everything in Monthly', + 'Priority Support', + 'Early Access Features', + 'Team Collaboration Tools', + 'Advanced Analytics', + ].map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+
+
+ + {/* Final CTA */} +
+

+ Not Ready to Commit Yet? +

+

+ Try our AI features for free and experience the power of AI-assisted + learning before upgrading. +

+ + + + Try AI Features for Free + + + → + + +
+
+
+ ); +} diff --git a/src/components/RoadmapAIChat/AIChatActionButtons.tsx b/src/components/RoadmapAIChat/AIChatActionButtons.tsx new file mode 100644 index 000000000..3f197d013 --- /dev/null +++ b/src/components/RoadmapAIChat/AIChatActionButtons.tsx @@ -0,0 +1,48 @@ +import { SettingsIcon, Trash2, type LucideIcon } from 'lucide-react'; + +type AIChatActionButtonProps = { + icon: LucideIcon; + label: string; + onClick: () => void; +}; + +function AIChatActionButton(props: AIChatActionButtonProps) { + const { icon: Icon, label, onClick } = props; + + return ( + + ); +} + +type AIChatActionButtonsProps = { + onTellUsAboutYourSelf: () => void; + onClearChat: () => void; + messageCount: number; +}; + +export function AIChatActionButtons(props: AIChatActionButtonsProps) { + const { onTellUsAboutYourSelf, onClearChat, messageCount } = props; + + return ( +
+ + {messageCount > 0 && ( + + )} +
+ ); +} diff --git a/src/components/RoadmapAIChat/ChatRoadmapRenderer.css b/src/components/RoadmapAIChat/ChatRoadmapRenderer.css new file mode 100644 index 000000000..88da24f40 --- /dev/null +++ b/src/components/RoadmapAIChat/ChatRoadmapRenderer.css @@ -0,0 +1,68 @@ +svg text tspan { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeSpeed; +} + +svg > g[data-type='topic'], +svg > g[data-type='subtopic'], +svg g[data-type='link-item'], +svg > g[data-type='button'], +svg > g[data-type='resourceButton'], +svg > g[data-type='todo-checkbox'], +svg > g[data-type='todo'], +svg > g[data-type='checklist'] > g[data-type='checklist-item'] > rect { + cursor: pointer; +} + +svg > g[data-type='topic']:hover > rect { + fill: var(--hover-color); +} + +svg > g[data-type='subtopic']:hover > rect { + fill: var(--hover-color); +} +svg g[data-type='button']:hover, +svg g[data-type='link-item']:hover, +svg g[data-type='resourceButton']:hover, +svg g[data-type='todo-checkbox']:hover { + opacity: 0.8; +} + +svg g[data-type='checklist'] > g[data-type='checklist-item'] > rect:hover { + fill: #cbcbcb !important; +} + +svg .done rect { + fill: #cbcbcb !important; +} + +svg .done text, +svg .skipped text { + text-decoration: line-through; +} + +svg > g[data-type='topic'].learning > rect + text, +svg > g[data-type='topic'].done > rect + text { + fill: black; +} + +svg .done text[fill='#ffffff'] { + fill: black; +} + +svg > g[data-type='subtipic'].done > rect + text, +svg > g[data-type='subtipic'].learning > rect + text { + fill: #cbcbcb; +} + +svg .learning rect { + fill: #dad1fd !important; +} +svg .learning text { + text-decoration: underline; +} + +svg .skipped rect { + fill: #496b69 !important; +} diff --git a/src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx b/src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx new file mode 100644 index 000000000..7b21ecc88 --- /dev/null +++ b/src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx @@ -0,0 +1,263 @@ +import './ChatRoadmapRenderer.css'; + +import { lazy, useCallback, useEffect, useRef, useState } from 'react'; +import { + renderResourceProgress, + updateResourceProgress, + type ResourceProgressType, + renderTopicProgress, + refreshProgressCounters, +} from '../../lib/resource-progress'; +import { pageProgressMessage } from '../../stores/page'; +import { useToast } from '../../hooks/use-toast'; +import type { Edge, Node } from '@roadmapsh/editor'; +import { slugify } from '../../lib/slugger'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { queryClient } from '../../stores/query-client'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { useQuery } from '@tanstack/react-query'; +import { TopicResourcesModal } from './TopicResourcesModal'; + +const Renderer = lazy(() => + import('@roadmapsh/editor').then((mod) => ({ + default: mod.Renderer, + })), +); + +type RoadmapNodeDetails = { + nodeId: string; + nodeType: string; + targetGroup: SVGElement; + title?: string; +}; + +function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null { + const targetGroup = (svgElement?.closest('g') as SVGElement) || {}; + + const nodeId = targetGroup?.dataset?.nodeId; + const nodeType = targetGroup?.dataset?.type; + const title = targetGroup?.dataset?.title; + + if (!nodeId || !nodeType) { + return null; + } + + return { nodeId, nodeType, targetGroup, title }; +} + +const allowedNodeTypes = [ + 'topic', + 'subtopic', + 'button', + 'link-item', + 'resourceButton', + 'todo', + 'todo-checkbox', + 'checklist-item', +]; + +export type ChatRoadmapRendererProps = { + roadmapId: string; + nodes: Node[]; + edges: Edge[]; + + onSelectTopic: (topicId: string, topicTitle: string) => void; +}; + +export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) { + const { roadmapId, nodes = [], edges = [], onSelectTopic } = props; + const roadmapRef = useRef(null); + + const toast = useToast(); + + const { data: userResourceProgressData } = useQuery( + userResourceProgressOptions('roadmap', roadmapId), + queryClient, + ); + + async function updateTopicStatus( + topicId: string, + newStatus: ResourceProgressType, + ) { + pageProgressMessage.set('Updating progress'); + updateResourceProgress( + { + resourceId: roadmapId, + resourceType: 'roadmap', + topicId, + }, + newStatus, + ) + .then(() => { + renderTopicProgress(topicId, newStatus); + queryClient.invalidateQueries( + userResourceProgressOptions('roadmap', roadmapId), + ); + }) + .catch((err) => { + toast.error('Something went wrong, please try again.'); + console.error(err); + }) + .finally(() => { + pageProgressMessage.set(''); + }); + + return; + } + + const handleSvgClick = useCallback((e: MouseEvent) => { + const target = e.target as SVGElement; + const { nodeId, nodeType, targetGroup, title } = + getNodeDetails(target) || {}; + + if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) { + return; + } + + if ( + nodeType === 'button' || + nodeType === 'link-item' || + nodeType === 'resourceButton' + ) { + const link = targetGroup?.dataset?.link || ''; + const isExternalLink = link.startsWith('http'); + if (isExternalLink) { + window.open(link, '_blank'); + } else { + window.location.href = link; + } + return; + } + + const isCurrentStatusLearning = targetGroup?.classList.contains('learning'); + const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped'); + + if (nodeType === 'todo-checkbox') { + e.preventDefault(); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + const newStatus = targetGroup?.classList.contains('done') + ? 'pending' + : 'done'; + updateTopicStatus(nodeId, newStatus); + return; + } + + if (e.shiftKey) { + e.preventDefault(); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + updateTopicStatus( + nodeId, + isCurrentStatusLearning ? 'pending' : 'learning', + ); + return; + } else if (e.altKey) { + e.preventDefault(); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped'); + return; + } + + // for the click on rect of checklist-item + if (nodeType === 'checklist-item' && target.tagName === 'rect') { + e.preventDefault(); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + const newStatus = targetGroup?.classList.contains('done') + ? 'pending' + : 'done'; + updateTopicStatus(nodeId, newStatus); + return; + } + + // we don't have the topic popup for checklist-item + if (nodeType === 'checklist-item') { + return; + } + + if (!title || !nodeId) { + return; + } + + onSelectTopic(nodeId, title); + }, []); + + const handleSvgRightClick = useCallback((e: MouseEvent) => { + e.preventDefault(); + + const target = e.target as SVGElement; + const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {}; + if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) { + return; + } + + if (nodeType === 'button') { + return; + } + + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + const isCurrentStatusDone = targetGroup?.classList.contains('done'); + updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done'); + }, []); + + useEffect(() => { + if (!roadmapRef?.current) { + return; + } + roadmapRef?.current?.addEventListener('click', handleSvgClick); + roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick); + + return () => { + roadmapRef?.current?.removeEventListener('click', handleSvgClick); + roadmapRef?.current?.removeEventListener( + 'contextmenu', + handleSvgRightClick, + ); + }; + }, []); + + return ( + { + roadmapRef.current?.setAttribute('data-renderer', 'editor'); + + if (!userResourceProgressData) { + return; + } + + const { done, learning, skipped } = userResourceProgressData; + done.forEach((topicId) => { + renderTopicProgress(topicId, 'done'); + }); + + learning.forEach((topicId) => { + renderTopicProgress(topicId, 'learning'); + }); + + skipped.forEach((topicId) => { + renderTopicProgress(topicId, 'skipped'); + }); + }} + /> + ); +} diff --git a/src/components/RoadmapAIChat/RoadmapAIChat.css b/src/components/RoadmapAIChat/RoadmapAIChat.css new file mode 100644 index 000000000..638795080 --- /dev/null +++ b/src/components/RoadmapAIChat/RoadmapAIChat.css @@ -0,0 +1,141 @@ +.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; +} + +.course-ai-content .chat-variable { + font-size: 12px; + font-weight: 500; + line-height: 1.5; + padding: 2px 4px; + border-radius: 8px; + background-color: #f0f5ff; + color: #2c5df1; +} diff --git a/src/components/RoadmapAIChat/RoadmapAIChat.tsx b/src/components/RoadmapAIChat/RoadmapAIChat.tsx new file mode 100644 index 000000000..7c6db13e0 --- /dev/null +++ b/src/components/RoadmapAIChat/RoadmapAIChat.tsx @@ -0,0 +1,717 @@ +import './RoadmapAIChat.css'; + +import { useQuery } from '@tanstack/react-query'; +import { roadmapJSONOptions } from '../../queries/roadmap'; +import { queryClient } from '../../stores/query-client'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Bot, + Frown, + Loader2Icon, + LockIcon, + PauseCircleIcon, + SendIcon, +} from 'lucide-react'; +import { ChatEditor } from '../ChatEditor/ChatEditor'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { type AllowedAIChatRole } from '../GenerateCourse/AICourseLessonChat'; +import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import type { JSONContent, Editor } from '@tiptap/core'; +import { flushSync } from 'react-dom'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { readStream } from '../../lib/ai'; +import { useToast } from '../../hooks/use-toast'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { ChatRoadmapRenderer } from './ChatRoadmapRenderer'; +import { + renderMessage, + type MessagePartRenderer, +} from '../../lib/render-chat-message'; +import { RoadmapAIChatCard } from './RoadmapAIChatCard'; +import { UserProgressList } from './UserProgressList'; +import { UserProgressActionList } from './UserProgressActionList'; +import { RoadmapTopicList } from './RoadmapTopicList'; +import { ShareResourceLink } from './ShareResourceLink'; +import { RoadmapRecommendations } from './RoadmapRecommendations'; +import { RoadmapAIChatHeader } from './RoadmapAIChatHeader'; +import { showLoginPopup } from '../../lib/popup'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { billingDetailsOptions } from '../../queries/billing'; +import { TopicDetail } from '../TopicDetail/TopicDetail'; +import { slugify } from '../../lib/slugger'; +import { AIChatActionButtons } from './AIChatActionButtons'; +import { cn } from '../../lib/classname'; +import { + getTailwindScreenDimension, + type TailwindScreenDimensions, +} from '../../lib/is-mobile'; +import { ChatPersona } from '../UserPersona/ChatPersona'; +import { userPersonaOptions } from '../../queries/user-persona'; +import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal'; +import { lockBodyScroll } from '../../lib/dom'; +import { TutorIntroMessage } from './TutorIntroMessage'; + +export type RoamdapAIChatHistoryType = { + role: AllowedAIChatRole; + isDefault?: boolean; + + // these two will be used only into the backend + // for transforming the raw message into the final message + content?: string; + json?: JSONContent; + + // these two will be used only into the frontend + // for rendering the message + html?: string; + jsx?: React.ReactNode; +}; + +export type RoadmapAIChatTab = 'chat' | 'topic'; + +type RoadmapAIChatProps = { + roadmapId: string; +}; + +export function RoadmapAIChat(props: RoadmapAIChatProps) { + const { roadmapId } = props; + + const toast = useToast(); + const editorRef = useRef(null); + const scrollareaRef = useRef(null); + + const [deviceType, setDeviceType] = useState(); + + useLayoutEffect(() => { + setDeviceType(getTailwindScreenDimension()); + }, []); + + const [isChatMobileVisible, setIsChatMobileVisible] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [selectedTopicId, setSelectedTopicId] = useState(null); + const [selectedTopicTitle, setSelectedTopicTitle] = useState( + null, + ); + const [activeTab, setActiveTab] = useState('chat'); + + const [aiChatHistory, setAiChatHistory] = useState< + RoamdapAIChatHistoryType[] + >([]); + const [isStreamingMessage, setIsStreamingMessage] = useState(false); + const [streamedMessage, setStreamedMessage] = + useState(null); + const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false); + + const { data: roadmapDetail, error: roadmapDetailError } = useQuery( + roadmapJSONOptions(roadmapId), + queryClient, + ); + const { data: roadmapTreeData, isLoading: roadmapTreeLoading } = useQuery( + roadmapTreeMappingOptions(roadmapId), + queryClient, + ); + + const { isLoading: userResourceProgressLoading } = useQuery( + userResourceProgressOptions('roadmap', roadmapId), + queryClient, + ); + + const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery( + getAiCourseLimitOptions(), + queryClient, + ); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + + const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery( + userPersonaOptions(roadmapId), + queryClient, + ); + + useEffect(() => { + lockBodyScroll(isChatMobileVisible); + }, [isChatMobileVisible]); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const roadmapContainerRef = useRef(null); + + useEffect(() => { + if (!roadmapDetail || !roadmapContainerRef.current) { + return; + } + + roadmapContainerRef.current.replaceChildren(roadmapDetail.svg); + }, [roadmapDetail]); + + useEffect(() => { + if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) { + return; + } + + setIsLoading(false); + }, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]); + + const abortControllerRef = useRef(null); + const handleChatSubmit = (json: JSONContent) => { + if ( + !json || + isStreamingMessage || + !isLoggedIn() || + isLoading || + abortControllerRef.current + ) { + return; + } + + abortControllerRef.current = new AbortController(); + + const html = htmlFromTiptapJSON(json); + const newMessages: RoamdapAIChatHistoryType[] = [ + ...aiChatHistory, + { + role: 'user', + json, + html, + }, + ]; + + flushSync(() => { + setAiChatHistory(newMessages); + editorRef.current?.commands.setContent('

'); + }); + + scrollToBottom(); + completeAITutorChat(newMessages, abortControllerRef.current); + }; + + const scrollToBottom = useCallback(() => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior: 'smooth', + }); + }, [scrollareaRef]); + + const handleSelectTopic = useCallback( + (topicId: string, topicTitle: string) => { + flushSync(() => { + setSelectedTopicId(topicId); + setSelectedTopicTitle(topicTitle); + setActiveTab('topic'); + + if (['sm', 'md', 'lg', 'xl'].includes(deviceType || 'xl')) { + setIsChatMobileVisible(true); + } + }); + + const topicWithSlug = slugify(topicTitle) + '@' + topicId; + window.dispatchEvent( + new CustomEvent('roadmap.node.click', { + detail: { + resourceType: 'roadmap', + resourceId: roadmapId, + topicId: topicWithSlug, + isCustomResource: false, + }, + }), + ); + }, + [roadmapId, deviceType], + ); + + const renderer: Record = useMemo(() => { + return { + 'user-progress': () => { + return ; + }, + 'update-progress': (options) => { + return ; + }, + 'roadmap-topics': (options) => { + return ( + { + const title = text.split(' > ').pop(); + if (!title) { + return; + } + + handleSelectTopic(topicId, title); + }} + {...options} + /> + ); + }, + 'resource-progress-link': () => { + return ; + }, + 'roadmap-recommendations': (options) => { + return ; + }, + }; + }, [roadmapId, handleSelectTopic]); + + const completeAITutorChat = async ( + messages: RoamdapAIChatHistoryType[], + abortController?: AbortController, + ) => { + try { + setIsStreamingMessage(true); + + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + signal: abortController?.signal, + body: JSON.stringify({ + roadmapId, + messages: messages.slice(-10), + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + + toast.error(data?.message || 'Something went wrong'); + setAiChatHistory([...messages].slice(0, messages.length - 1)); + setIsStreamingMessage(false); + + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + + queryClient.invalidateQueries(getAiCourseLimitOptions()); + return; + } + + const reader = response.body?.getReader(); + + if (!reader) { + setIsStreamingMessage(false); + toast.error('Something went wrong'); + return; + } + + await readStream(reader, { + onStream: async (content) => { + if (abortController?.signal.aborted) { + return; + } + + const jsx = await renderMessage(content, renderer, { + isLoading: true, + }); + + flushSync(() => { + setStreamedMessage(jsx); + }); + + scrollToBottom(); + }, + onStreamEnd: async (content) => { + if (abortController?.signal.aborted) { + return; + } + + const jsx = await renderMessage(content, renderer, { + isLoading: false, + }); + const newMessages: RoamdapAIChatHistoryType[] = [ + ...messages, + { + role: 'assistant', + content, + jsx, + }, + ]; + + flushSync(() => { + setStreamedMessage(null); + setIsStreamingMessage(false); + setAiChatHistory(newMessages); + }); + + queryClient.invalidateQueries(getAiCourseLimitOptions()); + scrollToBottom(); + }, + }); + + setIsStreamingMessage(false); + abortControllerRef.current = null; + } catch (error) { + setIsStreamingMessage(false); + setStreamedMessage(null); + abortControllerRef.current = null; + + if (abortController?.signal.aborted) { + return; + } + toast.error('Something went wrong'); + } + }; + + const handleAbort = () => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setIsStreamingMessage(false); + setStreamedMessage(null); + setAiChatHistory([...aiChatHistory].slice(0, aiChatHistory.length - 1)); + }; + + useEffect(() => { + scrollToBottom(); + }, []); + + if (roadmapDetailError) { + return ( +
+ +

There was an error

+

+ {roadmapDetailError.message} +

+
+ ); + } + + const isDataLoading = + isLoading || + roadmapTreeLoading || + userResourceProgressLoading || + isTokenUsageLoading || + isBillingDetailsLoading || + isUserPersonaLoading; + + const shouldShowChatPersona = + !isLoading && !isUserPersonaLoading && !userPersona && isLoggedIn(); + + return ( +
+
+ {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + + {showUpdatePersonaModal && ( + setShowUpdatePersonaModal(false)} + /> + )} + + {isLoading && ( +
+ +
+ )} + + {roadmapDetail?.json && !isLoading && ( +
+ + + {/* floating chat button */} + {!isChatMobileVisible && ( +
+ +
+ )} +
+ )} +
+ + {isChatMobileVisible && ( +
{ + setIsChatMobileVisible(false); + }} + className="fixed inset-0 z-50 bg-black/50" + /> + )} + + +
+ ); +} + +function isEmptyContent(content: JSONContent) { + if (!content) { + return true; + } + + // because they wrap the content in type doc + const firstContent = content.content?.[0]; + if (!firstContent) { + return true; + } + + return ( + firstContent.type === 'paragraph' && + (!firstContent?.content || firstContent?.content?.length === 0) + ); +} + +export function htmlFromTiptapJSON(json: JSONContent) { + const content = json.content; + + let text = ''; + for (const child of content || []) { + switch (child.type) { + case 'text': + text += child.text; + break; + case 'paragraph': + text += `

${htmlFromTiptapJSON(child)}

`; + break; + case 'variable': + const label = child?.attrs?.label || ''; + text += `${label}`; + break; + default: + break; + } + } + + return text; +} diff --git a/src/components/RoadmapAIChat/RoadmapAIChatCard.tsx b/src/components/RoadmapAIChat/RoadmapAIChatCard.tsx new file mode 100644 index 000000000..df1b2fee4 --- /dev/null +++ b/src/components/RoadmapAIChat/RoadmapAIChatCard.tsx @@ -0,0 +1,46 @@ +import type { RoamdapAIChatHistoryType } from './RoadmapAIChat'; +import { cn } from '../../lib/classname'; +import { BotIcon, User2Icon } from 'lucide-react'; + +type RoadmapAIChatCardProps = RoamdapAIChatHistoryType & { + isIntro?: boolean; +}; + +export function RoadmapAIChatCard(props: RoadmapAIChatCardProps) { + const { role, html, jsx, isIntro = false } = props; + + return ( +
+
+
+ {role === 'user' ? ( + + ) : ( + + )} +
+ + {!!jsx && jsx} + + {!!html && ( +
+ )} +
+
+ ); +} diff --git a/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx new file mode 100644 index 000000000..e9bcc65c3 --- /dev/null +++ b/src/components/RoadmapAIChat/RoadmapAIChatHeader.tsx @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; +import { billingDetailsOptions } from '../../queries/billing'; +import { isLoggedIn } from '../../lib/jwt'; +import { BookIcon, BotIcon, GiftIcon, XIcon } from 'lucide-react'; +import type { RoadmapAIChatTab } from './RoadmapAIChat'; +import { useState } from 'react'; +import { getPercentage } from '../../lib/number'; +import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup'; +import { cn } from '../../lib/classname'; +import { useKeydown } from '../../hooks/use-keydown'; + +type RoadmapAIChatHeaderProps = { + isLoading: boolean; + + onLogin: () => void; + onUpgrade: () => void; + + onCloseChat: () => void; + + activeTab: RoadmapAIChatTab; + onTabChange: (tab: RoadmapAIChatTab) => void; + onCloseTopic: () => void; + selectedTopicId: string | null; +}; + +type TabButtonProps = { + icon: React.ReactNode; + label: string; + isActive: boolean; + onClick: () => void; + showBorder?: boolean; + onClose?: () => void; +}; + +function TabButton(props: TabButtonProps) { + const { icon, label, isActive, onClick, onClose } = props; + + return ( + + ); +} + +export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) { + const { + onLogin, + onUpgrade, + isLoading: isDataLoading, + onCloseChat, + + activeTab, + onTabChange, + onCloseTopic, + selectedTopicId, + } = props; + + const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); + const { data: tokenUsage } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { data: userBillingDetails } = useQuery( + billingDetailsOptions(), + queryClient, + ); + + useKeydown('Escape', onCloseChat); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const usagePercentage = getPercentage( + tokenUsage?.used || 0, + tokenUsage?.limit || 0, + ); + + const handleCreditsClick = () => { + if (!isLoggedIn()) { + onLogin(); + return; + } + setShowAILimitsPopup(true); + }; + + const handleUpgradeClick = () => { + if (!isLoggedIn()) { + onLogin(); + return; + } + onUpgrade(); + }; + + return ( + <> + {showAILimitsPopup && ( + setShowAILimitsPopup(false)} + onUpgrade={() => { + setShowAILimitsPopup(false); + onUpgrade(); + }} + /> + )} + +
+
+ } + label="AI Chat" + isActive={activeTab === 'chat' && !!selectedTopicId} + onClick={() => onTabChange('chat')} + /> + + {(activeTab === 'topic' || selectedTopicId) && ( + } + label="Topic" + isActive={activeTab === 'topic' && !!selectedTopicId} + onClick={() => onTabChange('topic')} + onClose={onCloseTopic} + /> + )} +
+ + {!isDataLoading && isLoggedIn() && ( +
+ {!isPaidUser && ( + <> + + + + + )} +
+ )} +
+ + ); +} diff --git a/src/components/RoadmapAIChat/RoadmapRecommendations.tsx b/src/components/RoadmapAIChat/RoadmapRecommendations.tsx new file mode 100644 index 000000000..b8b962468 --- /dev/null +++ b/src/components/RoadmapAIChat/RoadmapRecommendations.tsx @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { useMemo } from 'react'; +import { listBuiltInRoadmaps } from '../../queries/roadmap'; +import { SquareArrowOutUpRightIcon } from 'lucide-react'; + +type RoadmapSlugListType = { + roadmapSlug: string; +}; + +function parseRoadmapSlugList(content: string): RoadmapSlugListType[] { + const items: RoadmapSlugListType[] = []; + + const roadmapSlugListRegex = /.*?<\/roadmap-slug>/gs; + const roadmapSlugListItems = content.match(roadmapSlugListRegex); + if (!roadmapSlugListItems) { + return items; + } + + for (const roadmapSlugListItem of roadmapSlugListItems) { + const roadmapSlugRegex = /(.*?)<\/roadmap-slug>/; + const roadmapSlug = roadmapSlugListItem + .match(roadmapSlugRegex)?.[1] + ?.trim(); + if (!roadmapSlug) { + continue; + } + + items.push({ + roadmapSlug, + }); + } + + return items; +} + +type RoadmapRecommendationsProps = { + roadmapId: string; + content: string; +}; + +export function RoadmapRecommendations(props: RoadmapRecommendationsProps) { + const { content } = props; + + const roadmapSlugListItems = parseRoadmapSlugList(content); + + const { data: roadmaps } = useQuery(listBuiltInRoadmaps(), queryClient); + + const progressItemWithText = useMemo(() => { + return roadmapSlugListItems.map((item) => { + const roadmap = roadmaps?.find( + (mapping) => mapping.id === item.roadmapSlug, + ); + + return { + ...item, + title: roadmap?.title, + }; + }); + }, [roadmapSlugListItems, roadmaps]); + + return ( + <> +
+ {progressItemWithText.map((item) => ( + + {item.title} + + + ))} +
+ + ); +} diff --git a/src/components/RoadmapAIChat/RoadmapTopicList.tsx b/src/components/RoadmapAIChat/RoadmapTopicList.tsx new file mode 100644 index 000000000..dd93851e8 --- /dev/null +++ b/src/components/RoadmapAIChat/RoadmapTopicList.tsx @@ -0,0 +1,99 @@ +import { useQuery } from '@tanstack/react-query'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { queryClient } from '../../stores/query-client'; +import { Fragment, useMemo } from 'react'; +import { ChevronRightIcon } from 'lucide-react'; + +type TopicListType = { + topicId: string; +}; + +function parseTopicList(content: string): TopicListType[] { + const items: TopicListType[] = []; + + const topicListRegex = /.*?<\/topic-id>/gs; + const topicListItems = content.match(topicListRegex); + if (!topicListItems) { + return items; + } + + for (const topicListItem of topicListItems) { + const topicIdRegex = /(.*?)<\/topic-id>/; + const topicId = topicListItem.match(topicIdRegex)?.[1]?.trim(); + if (!topicId) { + continue; + } + + items.push({ + topicId, + }); + } + + return items; +} + +type RoadmapTopicListProps = { + roadmapId: string; + content: string; + onTopicClick?: (topicId: string, topicTitle: string) => void; +}; + +export function RoadmapTopicList(props: RoadmapTopicListProps) { + const { roadmapId, content, onTopicClick } = props; + + const topicListItems = parseTopicList(content); + + const { data: roadmapTreeData } = useQuery( + roadmapTreeMappingOptions(roadmapId), + queryClient, + ); + + const progressItemWithText = useMemo(() => { + return topicListItems.map((item) => { + const roadmapTreeItem = roadmapTreeData?.find( + (mapping) => mapping.nodeId === item.topicId, + ); + + return { + ...item, + text: (roadmapTreeItem?.text || item.topicId) + ?.split(' > ') + .slice(1) + .join(' > '), + }; + }); + }, [topicListItems, roadmapTreeData]); + + return ( +
+ {progressItemWithText.map((item) => { + const labelParts = item.text.split(' > '); + const labelPartCount = labelParts.length; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/RoadmapAIChat/ShareResourceLink.tsx b/src/components/RoadmapAIChat/ShareResourceLink.tsx new file mode 100644 index 000000000..51c46e0c5 --- /dev/null +++ b/src/components/RoadmapAIChat/ShareResourceLink.tsx @@ -0,0 +1,46 @@ +import { ShareIcon } from 'lucide-react'; +import { useAuth } from '../../hooks/use-auth'; +import { useCopyText } from '../../hooks/use-copy-text'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { cn } from '../../lib/classname'; + +type ShareResourceLinkProps = { + roadmapId: string; +}; + +export function ShareResourceLink(props: ShareResourceLinkProps) { + const { roadmapId } = props; + const user = useAuth(); + const { copyText, isCopied } = useCopyText(); + + const handleShareResourceLink = () => { + const url = `${import.meta.env.PUBLIC_APP_URL}/${roadmapId}?s=${user?.id}`; + copyText(url); + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/RoadmapAIChat/TopicResourcesModal.tsx b/src/components/RoadmapAIChat/TopicResourcesModal.tsx new file mode 100644 index 000000000..8961dbc0b --- /dev/null +++ b/src/components/RoadmapAIChat/TopicResourcesModal.tsx @@ -0,0 +1,88 @@ +import { useQuery } from '@tanstack/react-query'; +import { Modal } from '../Modal'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { queryClient } from '../../stores/query-client'; +import { roadmapContentOptions } from '../../queries/roadmap'; +import { ModalLoader } from '../UserProgress/ModalLoader'; +import { TopicDetailLink } from '../TopicDetail/TopicDetailLink'; +import { Spinner } from '../ReactIcons/Spinner'; +import { ErrorIcon } from '../ReactIcons/ErrorIcon'; +import { markdownToHtml } from '../../lib/markdown'; + +type TopicResourcesModalProps = { + roadmapId: string; + topicId: string; + onClose: () => void; +}; + +export function TopicResourcesModal(props: TopicResourcesModalProps) { + const { roadmapId, topicId, onClose } = props; + + const { + data: roadmapContentData, + isLoading: isLoadingRoadmapContent, + error, + } = useQuery(roadmapContentOptions(roadmapId), queryClient); + + const topicContent = roadmapContentData?.[topicId]; + const links = topicContent?.links || []; + + return ( + + {!isLoadingRoadmapContent && !error && topicContent && ( +
+

{topicContent?.title}

+ +
+ + {links.length > 0 && ( +
    + {links.map((link, index) => { + return ( +
  • + +
  • + ); + })} +
+ )} +
+ )} + + {(isLoadingRoadmapContent || error || !topicContent) && ( +
+
+ {isLoadingRoadmapContent && ( + <> + + + Loading Topic Resources... + + + )} + + {(error || !topicContent) && !isLoadingRoadmapContent && ( + <> + + + {!topicContent + ? 'No resources found' + : (error?.message ?? 'Something went wrong')} + + + )} +
+
+ )} + + ); +} diff --git a/src/components/RoadmapAIChat/TutorIntroMessage.tsx b/src/components/RoadmapAIChat/TutorIntroMessage.tsx new file mode 100644 index 000000000..075129589 --- /dev/null +++ b/src/components/RoadmapAIChat/TutorIntroMessage.tsx @@ -0,0 +1,100 @@ +import type { RoadmapJSON } from '../../queries/roadmap'; + +type TutorIntroMessageProps = { + roadmap: RoadmapJSON; +}; + +export function TutorIntroMessage(props: TutorIntroMessageProps) { + const { roadmap } = props; + + const topicNodes = roadmap.nodes.filter((node) => node.type === 'topic'); + + const firstTopicNode = topicNodes[0]; + const firstTopicTitle = firstTopicNode?.data?.label || 'XYZ'; + + const secondTopicNode = topicNodes[1]; + const secondTopicTitle = secondTopicNode?.data?.label || 'XYZ'; + + const capabilities = [ + { + icon: '📚', + title: 'Learn concepts:', + description: 'Ask me about any topics on the roadmap', + examples: + '"Explain what React hooks are" or "How does async/await work?"', + }, + { + icon: '📊', + title: 'Track progress:', + description: 'Mark topics as done, learning, or skipped', + examples: `"Mark ${firstTopicTitle} as done" or "Show my overall progress"`, + }, + { + icon: '🎯', + title: 'Recommendations:', + description: 'Find what to learn next or explore other roadmaps', + examples: `"What should I learn next?" or "Recommend roadmaps for backend development"`, + }, + { + icon: '🔍', + title: 'Find resources:', + description: 'Get learning materials for specific topics', + examples: `"Show me resources for learning ${secondTopicTitle}"`, + }, + { + icon: '🔗', + title: 'Share progress:', + description: 'Get a link to share your learning progress', + examples: '"Give me my shareable progress link"', + }, + ]; + + return ( +
+
+
+

+ Hi! I'm your AI learning assistant 👋 +

+

+ I'm here to guide you through your learning journey on this roadmap. + I can help you understand concepts, track your progress, and provide + personalized learning advice. +

+
+
+ +
+

+ Here's what I can help you with: +

+ +
+ {capabilities.map((capability, index) => ( +
+ {capability.icon} +
+ + {capability.title} + {' '} + {capability.description} +
+ Try: {capability.examples} +
+
+
+ ))} +
+
+ +
+

+ Tip: I can see your current + progress on the roadmap, so my advice will be personalized to your + learning journey. Just ask me anything about the topics you see on the + roadmap! +

+
+
+ ); +} diff --git a/src/components/RoadmapAIChat/UserProgressActionList.tsx b/src/components/RoadmapAIChat/UserProgressActionList.tsx new file mode 100644 index 000000000..2ed8efd9e --- /dev/null +++ b/src/components/RoadmapAIChat/UserProgressActionList.tsx @@ -0,0 +1,332 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; +import { queryClient } from '../../stores/query-client'; +import { Fragment, useMemo, useState } from 'react'; +import { renderTopicProgress } from '../../lib/resource-progress'; +import { updateResourceProgress } from '../../lib/resource-progress'; +import { pageProgressMessage } from '../../stores/page'; +import type { ResourceProgressType } from '../../lib/resource-progress'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { useToast } from '../../hooks/use-toast'; +import { Check, ChevronRightIcon, Loader2Icon } from 'lucide-react'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { httpPost } from '../../lib/query-http'; +import { cn } from '../../lib/classname'; + +type UpdateUserProgress = { + id: string; + action: 'done' | 'learning' | 'skipped' | 'pending'; +}; + +function parseUserProgress(content: string): UpdateUserProgress[] { + const items: UpdateUserProgress[] = []; + + const progressRegex = /.*?<\/update-progress-item>/gs; + const progressItems = content.match(progressRegex); + if (!progressItems) { + return items; + } + + for (const progressItem of progressItems) { + const progressItemRegex = /(.*?)<\/topic-id>/; + const topicId = progressItem.match(progressItemRegex)?.[1]?.trim(); + const topicActionRegex = /(.*?)<\/topic-action>/; + const topicAction = progressItem + .match(topicActionRegex)?.[1] + .trim() + ?.toLowerCase(); + + if (!topicId || !topicAction) { + continue; + } + + items.push({ + id: topicId, + action: topicAction as UpdateUserProgress['action'], + }); + } + + return items; +} + +type BulkUpdateResourceProgressBody = { + done: string[]; + learning: string[]; + skipped: string[]; + pending: string[]; +}; + +type BulkUpdateResourceProgressResponse = { + done: string[]; + learning: string[]; + skipped: string[]; +}; + +type UserProgressActionListProps = { + roadmapId: string; + content: string; + isLoading?: boolean; +}; + +export function UserProgressActionList(props: UserProgressActionListProps) { + const { roadmapId, content, isLoading = false } = props; + + const toast = useToast(); + const updateUserProgress = parseUserProgress(content); + + const { data: roadmapTreeData } = useQuery( + roadmapTreeMappingOptions(roadmapId), + queryClient, + ); + + const { + mutate: bulkUpdateResourceProgress, + isPending: isBulkUpdating, + isSuccess: isBulkUpdateSuccess, + } = useMutation( + { + mutationFn: (body: BulkUpdateResourceProgressBody) => { + return httpPost( + `/v1-bulk-update-resource-progress/${roadmapId}`, + body, + ); + }, + onSuccess: () => { + return queryClient.invalidateQueries( + userResourceProgressOptions('roadmap', roadmapId), + ); + }, + onSettled: () => { + pageProgressMessage.set(''); + }, + onError: (error) => { + toast.error( + error?.message ?? 'Something went wrong, please try again.', + ); + }, + }, + queryClient, + ); + + const progressItemWithText = useMemo(() => { + return updateUserProgress.map((item) => { + const roadmapTreeItem = roadmapTreeData?.find( + (mapping) => mapping.nodeId === item.id, + ); + + return { + ...item, + text: (roadmapTreeItem?.text || item.id) + ?.split(' > ') + .slice(1) + .join(' > '), + }; + }); + }, [updateUserProgress, roadmapTreeData]); + + const [showAll, setShowAll] = useState(false); + const itemCountToShow = 4; + const itemsToShow = showAll + ? progressItemWithText + : progressItemWithText.slice(0, itemCountToShow); + + const hasMoreItemsToShow = progressItemWithText.length > itemCountToShow; + + return ( +
+
+ {itemsToShow.map((item) => ( + + ))} + + {hasMoreItemsToShow && ( +
+ + + +
+ )} +
+
+ ); +} + +type ProgressItemProps = { + roadmapId: string; + topicId: string; + text: string; + action: UpdateUserProgress['action']; + isStreaming: boolean; + isBulkUpdating: boolean; + isBulkUpdateSuccess: boolean; +}; + +function ProgressItem(props: ProgressItemProps) { + const { + roadmapId, + topicId, + text, + action, + isStreaming, + isBulkUpdating, + isBulkUpdateSuccess, + } = props; + + const toast = useToast(); + + const { + mutate: updateTopicStatus, + isSuccess, + isPending: isUpdating, + } = useMutation( + { + mutationFn: (action: ResourceProgressType) => { + return updateResourceProgress( + { + resourceId: roadmapId, + resourceType: 'roadmap', + topicId, + }, + action, + ); + }, + onMutate: () => {}, + onSuccess: () => { + renderTopicProgress(topicId, action); + }, + onError: () => { + toast.error('Something went wrong, please try again.'); + }, + onSettled: () => { + pageProgressMessage.set(''); + return queryClient.invalidateQueries( + userResourceProgressOptions('roadmap', roadmapId), + ); + }, + }, + queryClient, + ); + + const textParts = text.split(' > '); + const lastIndex = textParts.length - 1; + + return ( +
+ + {textParts.map((part, index) => { + return ( + + {part} + {index !== lastIndex && ( + + {' '} + + )} + + ); + })} + + {!isSuccess && !isBulkUpdateSuccess && ( + <> + {!isStreaming && ( + + )} + {isStreaming && ( + + + + )} + + )} + {(isSuccess || isBulkUpdateSuccess) && ( + + + + )} +
+ ); +} diff --git a/src/components/RoadmapAIChat/UserProgressList.tsx b/src/components/RoadmapAIChat/UserProgressList.tsx new file mode 100644 index 000000000..2b0b1c287 --- /dev/null +++ b/src/components/RoadmapAIChat/UserProgressList.tsx @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { userResourceProgressOptions } from '../../queries/resource-progress'; +import { getPercentage } from '../../lib/number'; + +type UserProgressListProps = { + roadmapId: string; +}; + +export function UserProgressList(props: UserProgressListProps) { + const { roadmapId } = props; + + const { data: userResourceProgressData } = useQuery( + userResourceProgressOptions('roadmap', roadmapId), + queryClient, + ); + + const totalTopicCount = userResourceProgressData?.totalTopicCount ?? 0; + const doneCount = userResourceProgressData?.done?.length ?? 0; + const skippedCount = userResourceProgressData?.skipped?.length ?? 0; + + const totalFinished = doneCount + skippedCount; + const progressPercentage = getPercentage(totalFinished, totalTopicCount); + + return ( +
+
+
+ Progress + + {progressPercentage}% + +
+ + {totalFinished} / {totalTopicCount} topics + +
+ +
+
+
+ +
+
+
+ Completed: {doneCount} +
+
+
+ Skipped: {skippedCount} +
+
+
+ ); +} diff --git a/src/components/RoadmapHeader.astro b/src/components/RoadmapHeader.astro index 86b71498e..a4266e2da 100644 --- a/src/components/RoadmapHeader.astro +++ b/src/components/RoadmapHeader.astro @@ -2,19 +2,20 @@ import { ArrowLeftIcon, BookOpenIcon, + Bot, FolderKanbanIcon, MapIcon, MessageCircle, } from 'lucide-react'; -import { TabLink } from './TabLink'; -import LoginPopup from './AuthenticationFlow/LoginPopup.astro'; -import { ScheduleButton } from './Schedule/ScheduleButton'; -import ProgressHelpPopup from './ProgressHelpPopup.astro'; -import { MarkFavorite } from './FeaturedItems/MarkFavorite'; import { type RoadmapFrontmatter } from '../lib/roadmap'; -import { ShareRoadmapButton } from './ShareRoadmapButton'; +import LoginPopup from './AuthenticationFlow/LoginPopup.astro'; import { DownloadRoadmapButton } from './DownloadRoadmapButton'; -import { CourseAnnouncement } from './SQLCourse/CourseAnnouncement'; +import { MarkFavorite } from './FeaturedItems/MarkFavorite'; +import ProgressHelpPopup from './ProgressHelpPopup.astro'; +import { ScheduleButton } from './Schedule/ScheduleButton'; +import { ShareRoadmapButton } from './ShareRoadmapButton'; +import { TabLink } from './TabLink'; + export interface Props { title: string; description: string; @@ -29,6 +30,7 @@ export interface Props { hasSearch?: boolean; projectCount?: number; coursesCount?: number; + hasAIChat?: boolean; question?: RoadmapFrontmatter['question']; hasTopics?: boolean; isForkable?: boolean; @@ -43,6 +45,7 @@ const { isUpcoming = false, note, hasTopics = false, + hasAIChat = false, projectCount = 0, question, activeTab = 'roadmap', @@ -150,6 +153,16 @@ const hasProjects = projectCount > 0; badgeText='New' /> )} + {hasAIChat && ( + + )}
) { + const { className, children, ...rest } = props; + return ( +
+ + {!props.multiple && ( + + + )} +
+ ); +} diff --git a/src/components/TabLink.tsx b/src/components/TabLink.tsx index 1129ac166..53304bb77 100644 --- a/src/components/TabLink.tsx +++ b/src/components/TabLink.tsx @@ -4,6 +4,7 @@ import { cn } from '../lib/classname.ts'; type TabLinkProps = { icon: LucideIcon; text: string; + mobileText?: string; isActive: boolean; isExternal?: boolean; badgeText?: string; @@ -19,6 +20,7 @@ export function TabLink(props: TabLinkProps) { isExternal = false, url, text, + mobileText, isActive, hideTextOnMobile = false, className: additionalClassName = '', @@ -75,7 +77,8 @@ export function TabLink(props: TabLinkProps) { className={className} > - {text} + {text} + {mobileText || text} {badgeNode} ); diff --git a/src/components/TopicDetail/TopicDetail.tsx b/src/components/TopicDetail/TopicDetail.tsx index b501eb8ae..22f467275 100644 --- a/src/components/TopicDetail/TopicDetail.tsx +++ b/src/components/TopicDetail/TopicDetail.tsx @@ -44,16 +44,6 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx'; import { TopicProgressButton } from './TopicProgressButton.tsx'; import { CreateCourseModal } from './CreateCourseModal.tsx'; -type TopicDetailProps = { - resourceId?: string; - resourceTitle?: string; - resourceType?: ResourceType; - renderer?: AllowedRoadmapRenderer; - - isEmbed?: boolean; - canSubmitContribution: boolean; -}; - type PaidResourceType = { _id?: string; title: string; @@ -93,13 +83,41 @@ async function fetchRoadmapPaidResources(roadmapId: string) { const PAID_RESOURCE_DISCLAIMER_HIDDEN = 'paid-resource-disclaimer-hidden'; +type TopicDetailProps = { + resourceId?: string; + resourceType?: ResourceType; + renderer?: AllowedRoadmapRenderer; + defaultActiveTab?: AllowedTopicDetailsTabs; + + hasUpgradeButtons?: boolean; + + isEmbed?: boolean; + canSubmitContribution: boolean; + + wrapperClassName?: string; + bodyClassName?: string; + overlayClassName?: string; + closeButtonClassName?: string; + onClose?: () => void; + shouldCloseOnBackdropClick?: boolean; + shouldCloseOnEscape?: boolean; +}; + export function TopicDetail(props: TopicDetailProps) { const { + hasUpgradeButtons = true, canSubmitContribution, resourceId: defaultResourceId, isEmbed = false, renderer = 'balsamiq', - resourceTitle, + wrapperClassName, + bodyClassName, + overlayClassName, + closeButtonClassName, + onClose, + shouldCloseOnBackdropClick = true, + shouldCloseOnEscape = true, + defaultActiveTab = 'content', } = props; const [hasEnoughLinks, setHasEnoughLinks] = useState(false); @@ -114,7 +132,7 @@ export function TopicDetail(props: TopicDetailProps) { const [topicHtmlTitle, setTopicHtmlTitle] = useState(''); const [links, setLinks] = useState([]); const [activeTab, setActiveTab] = - useState('content'); + useState(defaultActiveTab); const [aiChatHistory, setAiChatHistory] = useState(defaultChatHistory); const [showUpgradeModal, setShowUpgradeModal] = useState(false); @@ -138,6 +156,7 @@ export function TopicDetail(props: TopicDetailProps) { const [paidResources, setPaidResources] = useState([]); const handleClose = () => { + onClose?.(); setIsActive(false); setShowUpgradeModal(false); setAiChatHistory(defaultChatHistory); @@ -146,8 +165,11 @@ export function TopicDetail(props: TopicDetailProps) { }; // Close the topic detail when user clicks outside the topic detail - useOutsideClick(topicRef, handleClose); - useKeydown('Escape', handleClose); + useOutsideClick( + topicRef, + shouldCloseOnBackdropClick ? handleClose : undefined, + ); + useKeydown('Escape', shouldCloseOnEscape ? handleClose : undefined); useEffect(() => { if (resourceType !== 'roadmap' || !defaultResourceId) { @@ -349,7 +371,9 @@ export function TopicDetail(props: TopicDetailProps) { }); useEffect(() => { - if (isActive) topicRef?.current?.focus(); + if (isActive) { + topicRef?.current?.focus(); + } lockBodyScroll(isActive); }, [isActive]); @@ -370,11 +394,14 @@ export function TopicDetail(props: TopicDetailProps) { const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap'; return ( -
+
{showUpgradeModal && ( setShowUpgradeModal(false)} /> @@ -427,13 +454,16 @@ export function TopicDetail(props: TopicDetailProps) { } resourceId={resourceId} resourceType={resourceType} - onClose={handleClose} + onClose={() => null} /> )}
-
+
); } diff --git a/src/components/TopicDetail/TopicDetailAI.tsx b/src/components/TopicDetail/TopicDetailAI.tsx index 30f70e220..a62d90eb1 100644 --- a/src/components/TopicDetail/TopicDetailAI.tsx +++ b/src/components/TopicDetail/TopicDetailAI.tsx @@ -36,6 +36,8 @@ type TopicDetailAIProps = { resourceType: ResourceType; topicId: string; + hasUpgradeButtons?: boolean; + aiChatHistory: AIChatHistoryType[]; setAiChatHistory: (history: AIChatHistoryType[]) => void; @@ -52,6 +54,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { resourceId, resourceType, topicId, + hasUpgradeButtons = true, onUpgrade, onLogin, onShowSubjectSearchModal, @@ -303,7 +306,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) { )} - {!isPaidUser && ( + {!isPaidUser && hasUpgradeButtons && ( <> - ); - } - return (
{showChangeStatus && ( diff --git a/src/components/UserPersona/ChatPersona.tsx b/src/components/UserPersona/ChatPersona.tsx new file mode 100644 index 000000000..a03176de8 --- /dev/null +++ b/src/components/UserPersona/ChatPersona.tsx @@ -0,0 +1,81 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { UserPersonaForm, type UserPersonaFormData } from './UserPersonaForm'; +import { roadmapJSONOptions } from '../../queries/roadmap'; +import { queryClient } from '../../stores/query-client'; +import { httpPost } from '../../lib/query-http'; +import { useToast } from '../../hooks/use-toast'; +import { userPersonaOptions } from '../../queries/user-persona'; + +type ChatPersonaProps = { + roadmapId: string; +}; + +export function ChatPersona(props: ChatPersonaProps) { + const { roadmapId } = props; + + const toast = useToast(); + + const { data: roadmap } = useQuery( + roadmapJSONOptions(roadmapId), + queryClient, + ); + + const { mutate: createUserPersona, isPending: isCreatingUserPersona } = + useMutation( + { + mutationFn: async (data: UserPersonaFormData) => { + return httpPost('/v1-set-user-persona', { + ...data, + roadmapId, + }); + }, + onError: (error) => { + toast.error(error?.message || 'Something went wrong'); + }, + onSettled: () => { + return queryClient.invalidateQueries(userPersonaOptions(roadmapId)); + }, + }, + queryClient, + ); + + const roadmapTitle = roadmap?.json.title ?? ''; + + return ( +
+
+ Wave +

Welcome to the AI Tutor

+

+ Before we start, answer these questions so we can help you better. +

+
+ + { + const trimmedGoal = data?.goal?.trim(); + if (!trimmedGoal) { + toast.error('Please describe your goal'); + return; + } + + const trimmedCommit = data?.commit?.trim(); + if (!trimmedCommit) { + toast.error( + 'Please enter how many hours per week you can commit to learning', + ); + return; + } + + createUserPersona(data); + }} + isLoading={isCreatingUserPersona} + /> +
+ ); +} diff --git a/src/components/UserPersona/UpdatePersonaModal.tsx b/src/components/UserPersona/UpdatePersonaModal.tsx new file mode 100644 index 000000000..1beca9c9b --- /dev/null +++ b/src/components/UserPersona/UpdatePersonaModal.tsx @@ -0,0 +1,92 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { userPersonaOptions } from '../../queries/user-persona'; +import { queryClient } from '../../stores/query-client'; +import { roadmapJSONOptions } from '../../queries/roadmap'; +import { Modal } from '../Modal'; +import { UserPersonaForm, type UserPersonaFormData } from './UserPersonaForm'; +import { httpPost } from '../../lib/query-http'; +import { useToast } from '../../hooks/use-toast'; + +type UpdatePersonaModalProps = { + roadmapId: string; + onClose: () => void; +}; + +export function UpdatePersonaModal(props: UpdatePersonaModalProps) { + const { roadmapId, onClose } = props; + + const toast = useToast(); + const { data: roadmap } = useQuery( + roadmapJSONOptions(roadmapId), + queryClient, + ); + const { data: userPersona } = useQuery( + userPersonaOptions(roadmapId), + queryClient, + ); + + const { mutate: setUserPersona, isPending: isSettingUserPersona } = + useMutation( + { + mutationFn: async (data: UserPersonaFormData) => { + return httpPost('/v1-set-user-persona', { + ...data, + roadmapId, + }); + }, + onError: (error) => { + toast.error(error?.message || 'Something went wrong'); + }, + onSuccess: () => { + onClose(); + }, + onSettled: () => { + return queryClient.invalidateQueries(userPersonaOptions(roadmapId)); + }, + }, + queryClient, + ); + + const roadmapTitle = roadmap?.json.title ?? ''; + + return ( + +
+

Tell us more about yourself

+

+ We'll use this information to help you get the best out of the AI + Tutor. +

+
+ + { + const trimmedGoal = data?.goal?.trim(); + if (!trimmedGoal) { + toast.error('Please describe your goal'); + return; + } + + const trimmedCommit = data?.commit?.trim(); + if (!trimmedCommit) { + toast.error( + 'Please enter how many hours per week you can commit to learning', + ); + return; + } + + setUserPersona(data); + }} + isLoading={isSettingUserPersona} + /> +
+ ); +} diff --git a/src/components/UserPersona/UserPersonaForm.tsx b/src/components/UserPersona/UserPersonaForm.tsx new file mode 100644 index 000000000..3c2e09148 --- /dev/null +++ b/src/components/UserPersona/UserPersonaForm.tsx @@ -0,0 +1,180 @@ +import { useId, useRef, useState } from 'react'; +import { SelectNative } from '../SelectNative'; +import { Loader2Icon, MessageCircle } from 'lucide-react'; +import { cn } from '../../lib/classname'; + +export type UserPersonaFormData = { + expertise: string; + goal: string; + commit: string; +}; + +type UserPersonaFormProps = { + roadmapTitle: string; + defaultValues?: UserPersonaFormData; + onSubmit: (data: UserPersonaFormData) => void; + + className?: string; + + isLoading?: boolean; +}; + +export function UserPersonaForm(props: UserPersonaFormProps) { + const { + roadmapTitle, + defaultValues, + className = '', + onSubmit, + isLoading, + } = props; + const [expertise, setExpertise] = useState( + defaultValues?.expertise ?? 'no-experience', + ); + + const [hasInitialGoal, setHasInitialGoal] = useState(!!defaultValues?.goal); + const [goal, setGoal] = useState(defaultValues?.goal ?? ''); + const [commit, setCommit] = useState(defaultValues?.commit ?? ''); + + const expertiseFieldId = useId(); + const goalFieldId = useId(); + const commitFieldId = useId(); + + const goalRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ expertise, goal, commit }); + }; + + const hasFormCompleted = !!expertise && !!goal && !!commit; + + return ( + +
+ + setExpertise(e.target.value)} + className="h-[40px] border-gray-300 text-sm focus:border-gray-500 focus:ring-1 focus:ring-gray-500" + > + + {[ + 'No experience (just starting out)', + 'Beginner (less than 1 year of experience)', + 'Intermediate (1-3 years of experience)', + 'Expert (3-5 years of experience)', + 'Master (5+ years of experience)', + ].map((expertise) => ( + + ))} + +
+
+ + + {!hasInitialGoal && ( +
+ {[ + 'Finding a job', + 'Learning for fun', + 'Building a side project', + 'Switching careers', + 'Getting a promotion', + 'Filling knowledge gaps', + 'Other (tell us more)', + ].map((goalTemplate) => ( + + ))} +
+ )} + +