mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
Merge branch 'feat/raodmap-chat' of github.com:kamranahmedse/developer-roadmap into feat/raodmap-chat
This commit is contained in:
@@ -53,6 +53,10 @@ import {
|
||||
getTailwindScreenDimension,
|
||||
type TailwindScreenDimensions,
|
||||
} from '../../lib/is-mobile';
|
||||
import { UserPersonaForm } from '../UserPersona/UserPersonaForm';
|
||||
import { ChatPersona } from '../UserPersona/ChatPersona';
|
||||
import { userPersonaOptions } from '../../queries/user-persona';
|
||||
import { UpdatePersonaModal } from '../UserPersona/UpdatePersonaModal';
|
||||
|
||||
export type RoamdapAIChatHistoryType = {
|
||||
role: AllowedAIChatRole;
|
||||
@@ -103,6 +107,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] =
|
||||
useState<React.ReactNode | null>(null);
|
||||
const [showUpdatePersonaModal, setShowUpdatePersonaModal] = useState(false);
|
||||
|
||||
const { data: roadmapDetail, error: roadmapDetailError } = useQuery(
|
||||
roadmapJSONOptions(roadmapId),
|
||||
@@ -113,10 +118,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const {
|
||||
data: userResourceProgressData,
|
||||
isLoading: userResourceProgressLoading,
|
||||
} = useQuery(userResourceProgressOptions('roadmap', roadmapId), queryClient);
|
||||
const { isLoading: userResourceProgressLoading } = useQuery(
|
||||
userResourceProgressOptions('roadmap', roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { data: tokenUsage, isLoading: isTokenUsageLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
@@ -126,6 +131,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const { data: userPersona, isLoading: isUserPersonaLoading } = useQuery(
|
||||
userPersonaOptions(roadmapId),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
@@ -140,12 +150,12 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
}, [roadmapDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapTreeData || !roadmapDetail) {
|
||||
if (!roadmapTreeData || !roadmapDetail || isUserPersonaLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [roadmapTreeData, roadmapDetail]);
|
||||
}, [roadmapTreeData, roadmapDetail, isUserPersonaLoading]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const handleChatSubmit = (json: JSONContent) => {
|
||||
@@ -381,7 +391,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
roadmapTreeLoading ||
|
||||
userResourceProgressLoading ||
|
||||
isTokenUsageLoading ||
|
||||
isBillingDetailsLoading;
|
||||
isBillingDetailsLoading ||
|
||||
isUserPersonaLoading;
|
||||
|
||||
const shouldShowChatPersona =
|
||||
!isLoading && !isUserPersonaLoading && !userPersona && isLoggedIn();
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-row">
|
||||
@@ -390,6 +404,13 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
{showUpdatePersonaModal && (
|
||||
<UpdatePersonaModal
|
||||
roadmapId={roadmapId}
|
||||
onClose={() => setShowUpdatePersonaModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
||||
@@ -507,7 +528,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
{shouldShowChatPersona && !isLoading && (
|
||||
<ChatPersona roadmapId={roadmapId} />
|
||||
)}
|
||||
|
||||
{!isLoading && !shouldShowChatPersona && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
@@ -538,11 +563,13 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
{!isLoading && !shouldShowChatPersona && (
|
||||
<div className="flex flex-col border-t border-gray-200">
|
||||
{!isLimitExceeded && (
|
||||
<AIChatActionButtons
|
||||
onTellUsAboutYourSelf={() => {}}
|
||||
onTellUsAboutYourSelf={() => {
|
||||
setShowUpdatePersonaModal(true);
|
||||
}}
|
||||
messageCount={aiChatHistory.length}
|
||||
onClearChat={() => {
|
||||
setAiChatHistory([]);
|
||||
@@ -555,6 +582,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
editorRef={editorRef}
|
||||
roadmapId={roadmapId}
|
||||
onSubmit={(content) => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isStreamingMessage ||
|
||||
abortControllerRef.current ||
|
||||
@@ -592,29 +624,14 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoggedIn() && (
|
||||
<div className="absolute inset-0 z-10 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">
|
||||
Please login to continue
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
showLoginPopup();
|
||||
}}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Login / Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={(e) => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStreamingMessage || abortControllerRef.current) {
|
||||
handleAbort();
|
||||
return;
|
||||
|
@@ -144,7 +144,7 @@ export function RoadmapAIChatHeader(props: RoadmapAIChatHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isDataLoading && (
|
||||
{!isDataLoading && isLoggedIn() && (
|
||||
<div className="flex gap-1.5 pr-4">
|
||||
{!isPaidUser && (
|
||||
<>
|
||||
|
30
src/components/SelectNative.tsx
Normal file
30
src/components/SelectNative.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../lib/classname';
|
||||
|
||||
export function SelectNative(props: React.ComponentProps<'select'>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<div className="relative flex">
|
||||
<select
|
||||
data-slot="select-native"
|
||||
className={cn(
|
||||
'peer inline-flex w-full cursor-pointer appearance-none items-center rounded-lg border border-gray-200 text-sm text-black outline-none focus-visible:border-gray-500 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 has-[option[disabled]:checked]:text-gray-500 aria-invalid:border-red-500 aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40',
|
||||
props.multiple
|
||||
? '[&_option:checked]:bg-accent py-1 *:px-3 *:py-1'
|
||||
: 'h-9 ps-3 pe-8',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
{!props.multiple && (
|
||||
<span className="pointer-events-none absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center text-gray-500/80 peer-disabled:opacity-50 peer-aria-invalid:text-red-500/80">
|
||||
<ChevronDownIcon size={16} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
77
src/components/UserPersona/ChatPersona.tsx
Normal file
77
src/components/UserPersona/ChatPersona.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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 (
|
||||
<div className="relative mx-auto flex h-full max-w-[400px] grow flex-col justify-center p-4">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-lg font-semibold">Welcome to the AI Tutor</h2>
|
||||
<p className="mt-1 text-sm text-balance text-gray-500">
|
||||
Before we get started, tell me about your current experience with
|
||||
roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UserPersonaForm
|
||||
roadmapTitle={roadmapTitle}
|
||||
onSubmit={(data) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
90
src/components/UserPersona/UpdatePersonaModal.tsx
Normal file
90
src/components/UserPersona/UpdatePersonaModal.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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 (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
wrapperClassName="max-w-[450px]"
|
||||
bodyClassName="p-4"
|
||||
>
|
||||
<div className="mb-8 text-left">
|
||||
<h2 className="text-lg font-semibold">Update your persona</h2>
|
||||
<p className="mt-1 text-sm text-balance text-gray-500">
|
||||
Update your persona to get the best out of the AI Tutor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UserPersonaForm
|
||||
roadmapTitle={roadmapTitle}
|
||||
defaultValues={userPersona ?? undefined}
|
||||
onSubmit={(data) => {
|
||||
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}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
120
src/components/UserPersona/UserPersonaForm.tsx
Normal file
120
src/components/UserPersona/UserPersonaForm.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { SelectNative } from '../SelectNative';
|
||||
import AutogrowTextarea from 'react-textarea-autosize';
|
||||
import { Loader2Icon, PlayIcon } from 'lucide-react';
|
||||
|
||||
export type UserPersonaFormData = {
|
||||
expertise: string;
|
||||
goal: string;
|
||||
commit: string;
|
||||
};
|
||||
|
||||
type UserPersonaFormProps = {
|
||||
roadmapTitle: string;
|
||||
defaultValues?: UserPersonaFormData;
|
||||
onSubmit: (data: UserPersonaFormData) => void;
|
||||
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function UserPersonaForm(props: UserPersonaFormProps) {
|
||||
const { roadmapTitle, defaultValues, onSubmit, isLoading } = props;
|
||||
const [expertise, setExpertise] = useState(
|
||||
defaultValues?.expertise ?? 'no-experience',
|
||||
);
|
||||
const [goal, setGoal] = useState(defaultValues?.goal ?? '');
|
||||
const [commit, setCommit] = useState(defaultValues?.commit ?? '');
|
||||
|
||||
const expertiseFieldId = useId();
|
||||
const goalFieldId = useId();
|
||||
const commitFieldId = useId();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ expertise, goal, commit });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label
|
||||
className="text-sm leading-none text-gray-500"
|
||||
htmlFor={expertiseFieldId}
|
||||
>
|
||||
Rate your expertise in {roadmapTitle}:
|
||||
</label>
|
||||
<SelectNative
|
||||
id={expertiseFieldId}
|
||||
value={expertise}
|
||||
onChange={(e) => setExpertise(e.target.value)}
|
||||
>
|
||||
<option value="" selected hidden>
|
||||
Select your expertise
|
||||
</option>
|
||||
<option value="no-experience">No experience</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="expert">Expert</option>
|
||||
<option value="master">Master</option>
|
||||
</SelectNative>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2.5">
|
||||
<label
|
||||
className="text-sm leading-none text-gray-500"
|
||||
htmlFor={goalFieldId}
|
||||
>
|
||||
Define your goal:
|
||||
</label>
|
||||
|
||||
<AutogrowTextarea
|
||||
id={goalFieldId}
|
||||
className="block min-h-20 w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none placeholder:text-gray-500 focus:border-gray-500"
|
||||
placeholder={`e.g. I am a beginner in ${roadmapTitle} and need to find a job as soon as possible`}
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2.5">
|
||||
<label
|
||||
className="text-sm leading-none text-gray-500"
|
||||
htmlFor={commitFieldId}
|
||||
>
|
||||
How many hours per week can you commit to learning?
|
||||
</label>
|
||||
|
||||
<AutogrowTextarea
|
||||
id={commitFieldId}
|
||||
className="block min-h-20 w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm outline-none placeholder:text-gray-500 focus:border-gray-500"
|
||||
placeholder="e.g. 10 hours per week"
|
||||
value={commit}
|
||||
onChange={(e) => setCommit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className="mt-4 flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-black px-4 py-2 text-sm text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
) : (
|
||||
<>
|
||||
{defaultValues ? (
|
||||
<>
|
||||
<PlayIcon className="size-4" />
|
||||
Update Persona
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayIcon className="size-4" />
|
||||
Start Chatting
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
|
||||
export type GetUserResourceProgressResponse = {
|
||||
totalTopicCount: number;
|
||||
@@ -24,6 +25,7 @@ export function userResourceProgressOptions(
|
||||
},
|
||||
);
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
30
src/queries/user-persona.ts
Normal file
30
src/queries/user-persona.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
|
||||
export interface UserPersonaDocument {
|
||||
_id: string;
|
||||
userId: string;
|
||||
roadmaps: {
|
||||
roadmapId: string;
|
||||
expertise: string;
|
||||
goal: string;
|
||||
commit: string;
|
||||
}[];
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type UserPersonaResponse = UserPersonaDocument['roadmaps'][number] | null;
|
||||
|
||||
export function userPersonaOptions(roadmapId: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['user-persona', roadmapId],
|
||||
queryFn: async () => {
|
||||
return httpGet<UserPersonaResponse>(`/v1-user-persona/${roadmapId}`);
|
||||
},
|
||||
enabled: !!roadmapId && isLoggedIn(),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user