1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00

wip: user persona

This commit is contained in:
Arik Chakma
2025-05-27 08:02:34 +06:00
parent 6af820bb71
commit d075d60a8d
6 changed files with 273 additions and 9 deletions

View File

@@ -53,6 +53,9 @@ import {
getTailwindScreenDimension,
type TailwindScreenDimensions,
} from '../../lib/is-mobile';
import { UserPersonaForm } from '../UserPersona/UserPersonaForm';
import { ChatPersona } from '../UserPersona/ChatPersona';
import { userPersonaOptions } from '../../queries/user-persona';
export type RoamdapAIChatHistoryType = {
role: AllowedAIChatRole;
@@ -113,10 +116,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 +129,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 +148,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 +389,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">
@@ -507,7 +519,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,7 +554,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
)}
</div>
{!isLoading && (
{!isLoading && !shouldShowChatPersona && (
<div className="flex flex-col border-t border-gray-200">
{!isLimitExceeded && (
<AIChatActionButtons

View 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>
);
}

View 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';
export 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>
);
}

View File

@@ -0,0 +1,111 @@
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 frontend 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]" />
) : (
<>
<PlayIcon className="size-4" />
Start Chatting
</>
)}
</button>
</form>
);
}

View File

@@ -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,
});
}

View File

@@ -0,0 +1,28 @@
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;
}[];
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(),
});
}