because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(client): add profile management, onboarding checklist, and settings pages

+3351 -590
+1 -1
apps/client/package.json
··· 34 34 "react-hook-form": "^7.70.0", 35 35 "react-router-dom": "^7.9.4", 36 36 "tailwind-merge": "^2.0.0", 37 - "zod": "^3.25.76" 37 + "zod": "^4.3.6" 38 38 }, 39 39 "devDependencies": { 40 40 "@biomejs/biome": "^2.2.6",
+3 -2
apps/client/src/components/Navbar.tsx
··· 47 47 </div> 48 48 </div> 49 49 50 - {/* User Profile Drawer */} 51 - <UserProfileDrawer user={user} onLogout={onLogout} /> 50 + <div className="flex items-center gap-2"> 51 + <UserProfileDrawer user={user} onLogout={onLogout} /> 52 + </div> 52 53 </div> 53 54 </div> 54 55 </nav>
+5 -1
apps/client/src/components/navLinks.ts
··· 1 1 export type NavLink = { 2 2 to: string; 3 3 label: string; 4 + adminOnly?: boolean; 4 5 }; 5 6 6 7 export const defaultNavLinks: NavLink[] = [ ··· 9 10 { to: "/education", label: "Education" }, 10 11 { to: "/cvs", label: "My CVs" }, 11 12 { to: "/vacancies", label: "Vacancies" }, 12 - { to: "/profile", label: "Profile" }, 13 + { to: "/profiles", label: "Profiles" }, 14 + { to: "/settings", label: "Settings" }, 15 + { to: "/admin", label: "Admin", adminOnly: true }, 16 + { to: "/system", label: "System", adminOnly: true }, 13 17 ];
+118
apps/client/src/contexts/ProfileProvider.tsx
··· 1 + import { raise } from "@cv/utils"; 2 + import { 3 + createContext, 4 + type ReactNode, 5 + useCallback, 6 + useContext, 7 + useMemo, 8 + useState, 9 + } from "react"; 10 + import { useMyProfilesQuery } from "@/generated/graphql"; 11 + 12 + interface Profile { 13 + id: string; 14 + name: string; 15 + fullName: string | null; 16 + headline: string | null; 17 + phone: string | null; 18 + address: string | null; 19 + postalCode: string | null; 20 + city: string | null; 21 + country: string | null; 22 + website: string | null; 23 + linkedInUrl: string | null; 24 + summary: string | null; 25 + createdAt: string; 26 + updatedAt: string; 27 + } 28 + 29 + interface ProfileContextType { 30 + profiles: Profile[]; 31 + activeProfile: Profile | null; 32 + activeProfileId: string | null; 33 + setActiveProfileId: (id: string) => void; 34 + isLoading: boolean; 35 + hasProfiles: boolean; 36 + refetch: () => void; 37 + } 38 + 39 + const STORAGE_KEY = "cv-generator:active-profile-id"; 40 + 41 + const ProfileContext = createContext<ProfileContextType | null>(null); 42 + 43 + export function ProfileProvider({ children }: { children: ReactNode }) { 44 + const [selectedProfileId, setSelectedProfileId] = useState<string | null>( 45 + () => localStorage.getItem(STORAGE_KEY), 46 + ); 47 + 48 + const { data, isPending, refetch } = useMyProfilesQuery( 49 + {}, 50 + { 51 + retry: false, 52 + refetchOnWindowFocus: false, 53 + }, 54 + ); 55 + 56 + const profiles = useMemo( 57 + () => (data?.myProfiles as Profile[] | undefined) ?? [], 58 + [data], 59 + ); 60 + 61 + const activeProfile = useMemo(() => { 62 + if (profiles.length === 0) return null; 63 + if (selectedProfileId) { 64 + const found = profiles.find((p) => p.id === selectedProfileId); 65 + if (found) return found; 66 + } 67 + return profiles[0] ?? null; 68 + }, [profiles, selectedProfileId]); 69 + 70 + const activeProfileId = activeProfile?.id ?? null; 71 + 72 + const setActiveProfileId = useCallback((id: string) => { 73 + setSelectedProfileId(id); 74 + localStorage.setItem(STORAGE_KEY, id); 75 + }, []); 76 + 77 + const handleRefetch = useCallback(() => { 78 + refetch(); 79 + }, [refetch]); 80 + 81 + const value = useMemo( 82 + () => ({ 83 + profiles, 84 + activeProfile, 85 + activeProfileId, 86 + setActiveProfileId, 87 + isLoading: isPending, 88 + hasProfiles: profiles.length > 0, 89 + refetch: handleRefetch, 90 + }), 91 + [ 92 + profiles, 93 + activeProfile, 94 + activeProfileId, 95 + setActiveProfileId, 96 + isPending, 97 + handleRefetch, 98 + ], 99 + ); 100 + 101 + return ( 102 + <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider> 103 + ); 104 + } 105 + 106 + export function useProfile() { 107 + return ( 108 + useContext(ProfileContext) ?? 109 + raise("useProfile must be used within a ProfileProvider") 110 + ); 111 + } 112 + 113 + export function useActiveProfileId() { 114 + const { activeProfileId } = useProfile(); 115 + return ( 116 + activeProfileId ?? raise("No active profile — user needs to create one") 117 + ); 118 + }
+7 -1
apps/client/src/contexts/TokenProvider.tsx
··· 14 14 logout: () => Promise<void>; 15 15 isAuthenticated: boolean; 16 16 isLoading: boolean; 17 + isAdmin: boolean; 18 + role: string | null; 17 19 } 18 20 19 21 const AuthContext = createContext<AuthContextType | null>(null); ··· 62 64 const isAuthenticated = Boolean(meData); 63 65 const queryFinished = Boolean(meData || meIsError); 64 66 const isLoading = meIsPending && !queryFinished; 67 + const role = meData?.me?.role ?? null; 68 + const isAdmin = role === "ADMIN"; 65 69 66 70 return ( 67 - <AuthContext.Provider value={{ logout, isAuthenticated, isLoading }}> 71 + <AuthContext.Provider 72 + value={{ logout, isAuthenticated, isLoading, isAdmin, role }} 73 + > 68 74 {children} 69 75 </AuthContext.Provider> 70 76 );
+34
apps/client/src/features/onboarding/components/AiPreferenceOnboardingStep.tsx
··· 1 + import { AiPreferenceStep } from "@/features/user-settings/components/AiPreferenceStep"; 2 + 3 + interface Props { 4 + onComplete: () => void; 5 + onSkip: () => void; 6 + } 7 + 8 + export const AiPreferenceOnboardingStep = ({ onComplete, onSkip }: Props) => ( 9 + <div className="space-y-6"> 10 + <div className="flex justify-between items-start"> 11 + <div> 12 + <h2 className="text-2xl font-bold text-ctp-text">AI Preferences</h2> 13 + <p className="text-ctp-subtext0 mt-1"> 14 + Choose how you'd like to use AI in your CV building process. 15 + </p> 16 + </div> 17 + <button 18 + type="button" 19 + onClick={onSkip} 20 + className="text-sm text-ctp-subtext0 hover:text-ctp-text underline shrink-0" 21 + > 22 + Skip for now 23 + </button> 24 + </div> 25 + <AiPreferenceStep onComplete={() => onComplete()} /> 26 + <button 27 + type="button" 28 + onClick={onSkip} 29 + className="text-sm text-ctp-subtext0 hover:text-ctp-text underline" 30 + > 31 + Skip for now 32 + </button> 33 + </div> 34 + );
+103
apps/client/src/features/onboarding/components/CareerHistoryStep.tsx
··· 1 + import { Button, Placeholder } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 4 + import { JobExperienceForm } from "@/features/job-experience/components/JobExperienceForm"; 5 + import { 6 + type MeJobExperienceQuery, 7 + useMeJobExperienceQuery, 8 + } from "@/generated/graphql"; 9 + 10 + type JobExperienceNode = NonNullable< 11 + NonNullable<MeJobExperienceQuery["profile"]>["experience"] 12 + >["edges"][number]["node"]; 13 + 14 + interface Props { 15 + onComplete: () => void; 16 + onSkip: () => void; 17 + } 18 + 19 + export const CareerHistoryStep = ({ onComplete, onSkip }: Props) => { 20 + const profileId = useActiveProfileId(); 21 + const { data, isLoading } = useMeJobExperienceQuery({ profileId }); 22 + const [showForm, setShowForm] = useState(false); 23 + 24 + if (isLoading) { 25 + return <Placeholder variant="loading" message="Loading experience..." />; 26 + } 27 + 28 + const experiences = 29 + data?.profile?.experience?.edges?.map((e) => e.node) ?? []; 30 + const hasExperiences = experiences.length > 0; 31 + 32 + return ( 33 + <div className="space-y-6"> 34 + <div className="flex justify-between items-start"> 35 + <div> 36 + <h2 className="text-2xl font-bold text-ctp-text">Career History</h2> 37 + <p className="text-ctp-subtext0 mt-1"> 38 + {hasExperiences 39 + ? "Review your work experience. You can add more entries." 40 + : "Add your work experience to build your CV."} 41 + </p> 42 + </div> 43 + <button 44 + type="button" 45 + onClick={onSkip} 46 + className="text-sm text-ctp-subtext0 hover:text-ctp-text underline shrink-0" 47 + > 48 + Skip for now 49 + </button> 50 + </div> 51 + 52 + {hasExperiences && ( 53 + <div className="space-y-3"> 54 + {experiences.map((exp: JobExperienceNode) => ( 55 + <div 56 + key={exp.id} 57 + className="p-4 rounded-lg border border-ctp-surface1 bg-ctp-crust/40" 58 + > 59 + <div className="flex justify-between items-start"> 60 + <div> 61 + <h3 className="font-medium text-ctp-text"> 62 + {exp.role.name} at {exp.company.name} 63 + </h3> 64 + <p className="text-sm text-ctp-subtext0">{exp.level.name}</p> 65 + </div> 66 + </div> 67 + </div> 68 + ))} 69 + </div> 70 + )} 71 + 72 + {(showForm || !hasExperiences) && ( 73 + <div className="rounded-lg border border-ctp-surface1 p-4"> 74 + <h3 className="text-sm font-medium text-ctp-text mb-3"> 75 + {hasExperiences 76 + ? "Add another position" 77 + : "Add your first position"} 78 + </h3> 79 + <JobExperienceForm 80 + onSuccess={() => { 81 + setShowForm(false); 82 + onComplete(); 83 + }} 84 + onCancel={hasExperiences ? () => setShowForm(false) : undefined} 85 + /> 86 + </div> 87 + )} 88 + 89 + {hasExperiences && !showForm && ( 90 + <div className="flex gap-4"> 91 + <Button variant="outline" onClick={() => setShowForm(true)}> 92 + Add More 93 + </Button> 94 + <Button onClick={onComplete}>Continue</Button> 95 + </div> 96 + )} 97 + 98 + <Button variant="ghost" onClick={onSkip}> 99 + Skip for now 100 + </Button> 101 + </div> 102 + ); 103 + };
+57 -21
apps/client/src/features/onboarding/components/EducationReviewCard.tsx
··· 1 1 import { Button, Calendar, Textarea, TextInput } from "@cv/ui"; 2 2 import { useState } from "react"; 3 3 import { InstitutionSelect } from "@/features/education/components/InstitutionSelect"; 4 - import { SelectedSkillsDisplay } from "@/features/job-experience/components/SelectedSkillsDisplay"; 5 4 import { SkillsSelect } from "@/features/job-experience/components/SkillsSelect"; 6 5 import { 7 6 type DraftEducation, ··· 12 11 interface Props { 13 12 data: DraftEducation; 14 13 onChange: (data: DraftEducation) => void; 14 + onConvert?: () => void; 15 15 } 16 16 17 17 /** 18 18 * Format date for display 19 19 */ 20 - const formatDate = (date: Date | null): string => 20 + const formatDate = (date: Date | string | null): string => 21 21 date 22 - ? date.toLocaleDateString("en-US", { year: "numeric", month: "short" }) 22 + ? new Date(date).toLocaleDateString("en-US", { 23 + year: "numeric", 24 + month: "short", 25 + }) 23 26 : "Current"; 24 27 25 28 /** ··· 28 31 const EducationView = ({ 29 32 data, 30 33 onEdit, 34 + onConvert, 31 35 }: { 32 36 data: DraftEducation; 33 37 onEdit: () => void; 38 + onConvert?: () => void; 34 39 }) => ( 35 40 <div className="p-4 border border-ctp-overlay0 rounded-lg bg-ctp-surface0 hover:border-ctp-overlay1 transition-colors"> 36 41 <div className="flex justify-between items-start mb-3"> ··· 41 46 <p className="text-xs text-ctp-subtext1 mt-1">{data.fieldOfStudy}</p> 42 47 )} 43 48 </div> 44 - <Button size="sm" variant="ghost" onClick={onEdit}> 45 - Edit 46 - </Button> 49 + <div className="flex gap-1"> 50 + {onConvert && ( 51 + <Button size="sm" variant="ghost" onClick={onConvert}> 52 + Convert to Work History 53 + </Button> 54 + )} 55 + <Button size="sm" variant="ghost" onClick={onEdit}> 56 + Edit 57 + </Button> 58 + </div> 47 59 </div> 48 60 49 61 <p className="text-sm text-ctp-subtext1 mb-2"> ··· 81 93 onSave: (data: DraftEducation) => void; 82 94 onCancel: () => void; 83 95 }) => { 84 - const [form, setForm] = useState<DraftEducation>(data); 96 + const [form, setForm] = useState<DraftEducation>({ 97 + ...data, 98 + startDate: new Date(data.startDate), 99 + endDate: data.endDate ? new Date(data.endDate) : null, 100 + }); 85 101 const [errors, setErrors] = useState<Record<string, string>>({}); 86 102 87 103 const updateField = <K extends keyof DraftEducation>( ··· 102 118 } 103 119 }; 104 120 105 - const removeSkill = (id: string) => 121 + const removeSkill = (key: string) => 106 122 updateField( 107 123 "skills", 108 - form.skills.filter((s) => s.id !== id), 124 + form.skills.filter((s) => (s.id ?? s.name) !== key), 109 125 ); 110 126 111 127 const handleSubmit = (e: React.FormEvent) => { ··· 114 130 115 131 if (!result.success) { 116 132 const fieldErrors: Record<string, string> = {}; 117 - result.error.errors.forEach((err) => { 133 + result.error.issues.forEach((err) => { 118 134 const path = err.path.join("."); 119 135 fieldErrors[path] = err.message; 120 136 }); ··· 135 151 <InstitutionSelect 136 152 value={form.institution.id ?? ""} 137 153 onChange={updateInstitution} 138 - defaultSearchValue={ 139 - form.institution.id ? undefined : form.institution.name 140 - } 154 + defaultSearchValue={form.institution.name} 141 155 error={errors["institution.name"]} 142 156 /> 143 157 ··· 208 222 209 223 <div className="space-y-2"> 210 224 <SkillsSelect value="" onChange={addSkill} /> 211 - <SelectedSkillsDisplay 212 - skillIds={form.skills 213 - .map((s) => s.id) 214 - .filter((id): id is string => id !== null)} 215 - onRemoveSkill={removeSkill} 216 - /> 225 + {form.skills.length > 0 && ( 226 + <div className="space-y-3"> 227 + <p className="text-sm font-medium text-ctp-text"> 228 + Selected Skills ({form.skills.length}) 229 + </p> 230 + <div className="flex flex-wrap gap-2"> 231 + {form.skills.map((skill) => ( 232 + <div 233 + key={skill.id ?? skill.name} 234 + className="flex items-center gap-2 bg-ctp-surface0 px-3 py-1 rounded-full text-sm" 235 + > 236 + <span>{skill.name}</span> 237 + <button 238 + type="button" 239 + onClick={() => removeSkill(skill.id ?? skill.name)} 240 + className="text-ctp-red hover:text-ctp-red/80" 241 + > 242 + x 243 + </button> 244 + </div> 245 + ))} 246 + </div> 247 + </div> 248 + )} 217 249 </div> 218 250 219 251 <div className="flex gap-2 pt-2"> ··· 238 270 /** 239 271 * Education review card - toggles between view and edit modes 240 272 */ 241 - export const EducationReviewCard = ({ data, onChange }: Props) => { 273 + export const EducationReviewCard = ({ data, onChange, onConvert }: Props) => { 242 274 const [isEditing, setIsEditing] = useState(false); 243 275 244 276 const handleSave = (updated: DraftEducation) => { ··· 253 285 onCancel={() => setIsEditing(false)} 254 286 /> 255 287 ) : ( 256 - <EducationView data={data} onEdit={() => setIsEditing(true)} /> 288 + <EducationView 289 + data={data} 290 + onEdit={() => setIsEditing(true)} 291 + onConvert={onConvert} 292 + /> 257 293 ); 258 294 };
+103
apps/client/src/features/onboarding/components/EducationStep.tsx
··· 1 + import { Button, Placeholder } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 4 + import { EducationForm } from "@/features/education/components/EducationForm"; 5 + import { 6 + type MeEducationQuery, 7 + useMeEducationQuery, 8 + } from "@/generated/graphql"; 9 + 10 + type EducationNode = NonNullable< 11 + NonNullable<MeEducationQuery["profile"]>["educationHistory"] 12 + >["edges"][number]["node"]; 13 + 14 + interface Props { 15 + onComplete: () => void; 16 + onSkip: () => void; 17 + } 18 + 19 + export const EducationStep = ({ onComplete, onSkip }: Props) => { 20 + const profileId = useActiveProfileId(); 21 + const { data, isLoading } = useMeEducationQuery({ profileId }); 22 + const [showForm, setShowForm] = useState(false); 23 + 24 + if (isLoading) { 25 + return <Placeholder variant="loading" message="Loading education..." />; 26 + } 27 + 28 + const entries = 29 + data?.profile?.educationHistory?.edges?.map((e) => e.node) ?? []; 30 + const hasEntries = entries.length > 0; 31 + 32 + return ( 33 + <div className="space-y-6"> 34 + <div className="flex justify-between items-start"> 35 + <div> 36 + <h2 className="text-2xl font-bold text-ctp-text">Education</h2> 37 + <p className="text-ctp-subtext0 mt-1"> 38 + {hasEntries 39 + ? "Review your education history. You can add more entries." 40 + : "Add your education background."} 41 + </p> 42 + </div> 43 + <button 44 + type="button" 45 + onClick={onSkip} 46 + className="text-sm text-ctp-subtext0 hover:text-ctp-text underline shrink-0" 47 + > 48 + Skip for now 49 + </button> 50 + </div> 51 + 52 + {hasEntries && ( 53 + <div className="space-y-3"> 54 + {entries.map((edu: EducationNode) => ( 55 + <div 56 + key={edu.id} 57 + className="p-4 rounded-lg border border-ctp-surface1 bg-ctp-crust/40" 58 + > 59 + <div> 60 + <h3 className="font-medium text-ctp-text"> 61 + {edu.degree} — {edu.institution.name} 62 + </h3> 63 + {edu.fieldOfStudy && ( 64 + <p className="text-sm text-ctp-subtext0"> 65 + {edu.fieldOfStudy} 66 + </p> 67 + )} 68 + </div> 69 + </div> 70 + ))} 71 + </div> 72 + )} 73 + 74 + {(showForm || !hasEntries) && ( 75 + <div className="rounded-lg border border-ctp-surface1 p-4"> 76 + <h3 className="text-sm font-medium text-ctp-text mb-3"> 77 + {hasEntries ? "Add another entry" : "Add your first entry"} 78 + </h3> 79 + <EducationForm 80 + onSuccess={() => { 81 + setShowForm(false); 82 + onComplete(); 83 + }} 84 + onCancel={hasEntries ? () => setShowForm(false) : undefined} 85 + /> 86 + </div> 87 + )} 88 + 89 + {hasEntries && !showForm && ( 90 + <div className="flex gap-4"> 91 + <Button variant="outline" onClick={() => setShowForm(true)}> 92 + Add More 93 + </Button> 94 + <Button onClick={onComplete}>Continue</Button> 95 + </div> 96 + )} 97 + 98 + <Button variant="ghost" onClick={onSkip}> 99 + Skip for now 100 + </Button> 101 + </div> 102 + ); 103 + };
+144 -67
apps/client/src/features/onboarding/components/FileUploadStep.tsx
··· 1 1 import { Button, useToast } from "@cv/ui"; 2 - import { useState } from "react"; 2 + import { useCallback, useEffect, useState } from "react"; 3 3 import { useDropzone } from "react-dropzone"; 4 + import { useProfile } from "@/contexts/ProfileProvider"; 5 + import { useUploadFileMutation } from "../mutations/useUploadFileMutation"; 6 + import { useUserFileQuery } from "../queries/useUserFileQuery"; 4 7 import type { ParsedCVDataWithResolution } from "../schemas/draft.schema"; 8 + 9 + type UploadState = "idle" | "uploading" | "polling" | "completed" | "failed"; 5 10 6 11 interface Props { 12 + profileId?: string; 7 13 onComplete: (data: ParsedCVDataWithResolution) => void; 8 14 onBack: () => void; 9 15 } 10 16 11 - export const FileUploadStep = ({ onComplete, onBack }: Props) => { 12 - const [uploading, setUploading] = useState(false); 17 + const POLL_INTERVAL_MS = 2000; 18 + 19 + const StatusIcon = ({ active }: { active: boolean }) => 20 + active ? ( 21 + <div className="w-6 h-6 rounded-full border-2 border-ctp-blue flex items-center justify-center"> 22 + <div className="w-3 h-3 rounded-full border-2 border-ctp-blue border-t-transparent animate-spin" /> 23 + </div> 24 + ) : ( 25 + <div className="w-6 h-6 rounded-full bg-ctp-green flex items-center justify-center"> 26 + <svg 27 + className="w-4 h-4 text-ctp-base" 28 + fill="none" 29 + viewBox="0 0 24 24" 30 + stroke="currentColor" 31 + strokeWidth={3} 32 + role="img" 33 + aria-label="Complete" 34 + > 35 + <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> 36 + </svg> 37 + </div> 38 + ); 39 + 40 + export const FileUploadStep = ({ profileId, onComplete, onBack }: Props) => { 41 + const [uploadState, setUploadState] = useState<UploadState>("idle"); 42 + const [userFileId, setUserFileId] = useState<string | null>(null); 13 43 const [error, setError] = useState<string | null>(null); 14 44 const { showSuccess, showError } = useToast(); 45 + const { setActiveProfileId, refetch } = useProfile(); 46 + const uploadMutation = useUploadFileMutation(profileId); 15 47 16 - const onDrop = async (acceptedFiles: File[]) => { 17 - const file = acceptedFiles[0]; 18 - if (!file) { 19 - setError("No valid files selected"); 20 - return; 21 - } 48 + const shouldPoll = uploadState === "polling"; 22 49 23 - setError(null); 24 - setUploading(true); 50 + const { data } = useUserFileQuery(userFileId, { 51 + enabled: shouldPoll, 52 + refetchInterval: shouldPoll ? POLL_INTERVAL_MS : false, 53 + }); 25 54 26 - try { 27 - const formData = new FormData(); 28 - formData.append("file", file); 55 + const userFile = data?.userFile; 56 + 57 + useEffect(() => { 58 + if (!userFile || uploadState !== "polling") return; 29 59 30 - const response = await fetch("/api/cv-parser/upload", { 31 - method: "POST", 32 - body: formData, 33 - credentials: "include", 34 - }); 60 + if (userFile.status === "completed" && userFile.result) { 61 + setUploadState("completed"); 62 + showSuccess("File processed", "Your CV has been analyzed successfully"); 63 + onComplete(userFile.result as ParsedCVDataWithResolution); 64 + } 65 + 66 + if (userFile.status === "failed") { 67 + setUploadState("failed"); 68 + const msg = userFile.error ?? "Processing failed"; 69 + setError(msg); 70 + showError("Processing failed", msg); 71 + } 72 + }, [userFile, uploadState, onComplete, showSuccess, showError]); 35 73 36 - if (!response.ok) { 37 - const errorData = await response.json().catch(() => ({})); 38 - throw new Error( 39 - errorData.message || `Upload failed (${response.status})`, 40 - ); 74 + const onDrop = useCallback( 75 + (acceptedFiles: File[]) => { 76 + const file = acceptedFiles[0]; 77 + if (!file) { 78 + setError("No valid files selected"); 79 + return; 41 80 } 42 81 43 - const result = await response.json(); 82 + setError(null); 83 + setUploadState("uploading"); 44 84 45 - if (!(result.success && result.data)) { 46 - throw new Error("Failed to parse file"); 47 - } 85 + uploadMutation.mutate(file, { 86 + onSuccess: (result) => { 87 + const { id, profileId: newProfileId, isDuplicate } = result.uploadFile; 88 + 89 + if (newProfileId) { 90 + setActiveProfileId(newProfileId); 91 + refetch(); 92 + } 93 + 94 + setUserFileId(id); 95 + setUploadState("polling"); 96 + 97 + if (isDuplicate) { 98 + showSuccess( 99 + "File recognized", 100 + "Using cached results from previous import", 101 + ); 102 + } 103 + }, 104 + onError: (err) => { 105 + setError(err.message); 106 + setUploadState("failed"); 107 + showError("Upload failed", err.message); 108 + }, 109 + }); 110 + }, 111 + [uploadMutation, showError, setActiveProfileId, refetch, showSuccess], 112 + ); 48 113 49 - showSuccess("File processed", "Your CV has been analyzed successfully"); 50 - onComplete(result.data); 51 - } catch (err) { 52 - const message = err instanceof Error ? err.message : "Unknown error"; 53 - setError(message); 54 - showError("Upload failed", message); 55 - } finally { 56 - setUploading(false); 57 - } 114 + const handleRetry = () => { 115 + setUploadState("idle"); 116 + setUserFileId(null); 117 + setError(null); 58 118 }; 119 + 120 + const isProcessing = uploadState === "uploading" || uploadState === "polling"; 121 + const statusMessage = userFile?.statusMessage ?? "Uploading file..."; 59 122 60 123 const { getRootProps, getInputProps, isDragActive } = useDropzone({ 61 124 onDrop, ··· 66 129 "text/plain": [".txt"], 67 130 "text/markdown": [".md"], 68 131 }, 69 - maxSize: 10 * 1024 * 1024, // 10MB 132 + maxSize: 10 * 1024 * 1024, 70 133 multiple: false, 71 - disabled: uploading, 134 + disabled: isProcessing, 72 135 }); 73 136 74 137 return ( 75 138 <div className="mt-8 max-w-2xl mx-auto"> 76 - <div 77 - {...getRootProps()} 78 - className={` 79 - border-2 border-dashed rounded-lg p-12 text-center cursor-pointer 80 - transition-colors 81 - ${isDragActive ? "border-ctp-blue bg-ctp-blue/10" : "border-ctp-overlay0"} 82 - ${uploading ? "opacity-50 cursor-not-allowed" : "hover:border-ctp-blue"} 83 - `} 84 - > 85 - <input {...getInputProps()} disabled={uploading} /> 86 - <div className="text-6xl mb-4">📄</div> 87 - <h3 className="text-xl font-semibold text-ctp-text mb-2"> 88 - {isDragActive ? "Drop your file here" : "Drag & drop your CV"} 89 - </h3> 90 - <p className="text-ctp-subtext0 mb-4">or click to browse</p> 91 - <p className="text-sm text-ctp-subtext1"> 92 - Supported formats: PDF, DOCX, TXT, MD (max 10MB) 93 - </p> 94 - </div> 139 + {uploadState === "idle" && ( 140 + <div 141 + {...getRootProps()} 142 + className={` 143 + border-2 border-dashed rounded-lg p-12 text-center cursor-pointer 144 + transition-colors 145 + ${isDragActive ? "border-ctp-blue bg-ctp-blue/10" : "border-ctp-overlay0"} 146 + hover:border-ctp-blue 147 + `} 148 + > 149 + <input {...getInputProps()} /> 150 + <div className="text-6xl mb-4">📄</div> 151 + <h3 className="text-xl font-semibold text-ctp-text mb-2"> 152 + {isDragActive ? "Drop your file here" : "Drag & drop your CV"} 153 + </h3> 154 + <p className="text-ctp-subtext0 mb-4">or click to browse</p> 155 + <p className="text-sm text-ctp-subtext1"> 156 + Supported formats: PDF, DOCX, TXT, MD (max 10MB) 157 + </p> 158 + </div> 159 + )} 95 160 96 - {uploading && ( 97 - <div className="mt-6 text-center"> 98 - <div className="inline-block"> 99 - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-ctp-blue"></div> 161 + {isProcessing && ( 162 + <div className="mt-2 space-y-4"> 163 + <div className="p-6 bg-ctp-surface0 rounded-lg border border-ctp-overlay0"> 164 + <h3 className="text-lg font-semibold text-ctp-text mb-4"> 165 + Processing your CV 166 + </h3> 167 + <div className="flex items-center gap-3"> 168 + <StatusIcon active /> 169 + <span className="text-ctp-blue font-medium"> 170 + {statusMessage}... 171 + </span> 172 + </div> 100 173 </div> 101 - <p className="mt-3 text-ctp-text font-medium">Analyzing your CV...</p> 102 - <p className="text-sm text-ctp-subtext0"> 103 - This may take a few moments 174 + <p className="text-sm text-ctp-subtext0 text-center"> 175 + This may take up to a minute depending on your AI provider 104 176 </p> 105 177 </div> 106 178 )} 107 179 108 180 {error && ( 109 181 <div className="mt-6 p-4 bg-ctp-red/20 border border-ctp-red rounded-lg"> 110 - <p className="text-sm text-ctp-red font-medium">Error: {error}</p> 182 + <p className="text-sm text-ctp-red font-medium mb-3"> 183 + Error: {error} 184 + </p> 185 + <Button variant="outline" size="sm" onClick={handleRetry}> 186 + Try again 187 + </Button> 111 188 </div> 112 189 )} 113 190 ··· 115 192 <Button 116 193 variant="outline" 117 194 onClick={onBack} 118 - disabled={uploading} 195 + disabled={isProcessing} 119 196 className="flex-1" 120 197 > 121 198 Back
+100
apps/client/src/features/onboarding/components/ImportStep.tsx
··· 1 + import { Button } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { usePlatformCapabilitiesQuery } from "@/generated/graphql"; 4 + import type { ParsedCVDataWithResolution } from "../schemas/draft.schema"; 5 + import { FileUploadStep } from "./FileUploadStep"; 6 + import { OnboardingMethodSelector } from "./OnboardingMethodSelector"; 7 + import { ReviewStep } from "./ReviewStep"; 8 + 9 + type SubStep = "select" | "file-upload" | "review"; 10 + 11 + interface Props { 12 + onComplete: () => void; 13 + onSkip: () => void; 14 + } 15 + 16 + export const ImportStep = ({ onComplete, onSkip }: Props) => { 17 + const [subStep, setSubStep] = useState<SubStep>("select"); 18 + const [parsedData, setParsedData] = 19 + useState<ParsedCVDataWithResolution | null>(null); 20 + const { data: capabilities } = usePlatformCapabilitiesQuery(); 21 + 22 + const aiParsing = capabilities?.platformCapabilities?.aiParsing ?? false; 23 + 24 + const handleMethodSelect = (method: string) => { 25 + if (method === "manual") { 26 + onSkip(); 27 + return; 28 + } 29 + if (method === "file-upload") { 30 + setSubStep("file-upload"); 31 + } 32 + }; 33 + 34 + const handleFileParsed = (data: ParsedCVDataWithResolution) => { 35 + setParsedData(data); 36 + setSubStep("review"); 37 + }; 38 + 39 + if (subStep === "file-upload") { 40 + return ( 41 + <div className="space-y-6"> 42 + <div> 43 + <h2 className="text-2xl font-bold text-ctp-text">Upload Your CV</h2> 44 + <p className="text-ctp-subtext0 mt-1"> 45 + We'll extract your work history and education automatically. 46 + </p> 47 + </div> 48 + <FileUploadStep 49 + onComplete={handleFileParsed} 50 + onBack={() => setSubStep("select")} 51 + /> 52 + </div> 53 + ); 54 + } 55 + 56 + if (subStep === "review" && parsedData) { 57 + return ( 58 + <div className="space-y-6"> 59 + <ReviewStep 60 + parsedData={parsedData} 61 + onComplete={onComplete} 62 + onBack={() => setSubStep("file-upload")} 63 + onDataChange={setParsedData} 64 + /> 65 + </div> 66 + ); 67 + } 68 + 69 + return ( 70 + <div className="space-y-6"> 71 + <div className="flex justify-between items-start"> 72 + <div> 73 + <h2 className="text-2xl font-bold text-ctp-text">Import Your Data</h2> 74 + <p className="text-ctp-subtext0 mt-1"> 75 + Jump-start your CV by importing existing data, or add everything 76 + manually in the next steps. 77 + </p> 78 + </div> 79 + <button 80 + type="button" 81 + onClick={onSkip} 82 + className="text-sm text-ctp-subtext0 hover:text-ctp-text underline shrink-0" 83 + > 84 + Skip for now 85 + </button> 86 + </div> 87 + 88 + <OnboardingMethodSelector 89 + onMethodSelect={handleMethodSelect} 90 + aiPreference={aiParsing ? ("PLATFORM" as never) : ("NO_AI" as never)} 91 + /> 92 + 93 + <div className="flex justify-center pt-4"> 94 + <Button variant="ghost" onClick={onSkip}> 95 + Skip — I'll add my data manually 96 + </Button> 97 + </div> 98 + </div> 99 + ); 100 + };
+63 -21
apps/client/src/features/onboarding/components/JobExperienceReviewCard.tsx
··· 3 3 import { CompanySelect } from "@/features/job-experience/components/CompanySelect"; 4 4 import { LevelSelect } from "@/features/job-experience/components/LevelSelect"; 5 5 import { RoleSelect } from "@/features/job-experience/components/RoleSelect"; 6 - import { SelectedSkillsDisplay } from "@/features/job-experience/components/SelectedSkillsDisplay"; 7 6 import { SkillsSelect } from "@/features/job-experience/components/SkillsSelect"; 8 7 import { 9 8 type DraftEntity, ··· 14 13 interface Props { 15 14 data: DraftJobExperience; 16 15 onChange: (data: DraftJobExperience) => void; 16 + onConvert?: () => void; 17 17 } 18 18 19 19 /** 20 20 * Format date for display 21 21 */ 22 - const formatDate = (date: Date | null): string => 22 + const formatDate = (date: Date | string | null): string => 23 23 date 24 - ? date.toLocaleDateString("en-US", { year: "numeric", month: "short" }) 24 + ? new Date(date).toLocaleDateString("en-US", { 25 + year: "numeric", 26 + month: "short", 27 + }) 25 28 : "Present"; 26 29 27 30 /** ··· 30 33 const JobExperienceView = ({ 31 34 data, 32 35 onEdit, 36 + onConvert, 33 37 }: { 34 38 data: DraftJobExperience; 35 39 onEdit: () => void; 40 + onConvert?: () => void; 36 41 }) => ( 37 42 <div className="p-4 border border-ctp-overlay0 rounded-lg bg-ctp-surface0 hover:border-ctp-overlay1 transition-colors"> 38 43 <div className="flex justify-between items-start mb-3"> ··· 43 48 <p className="text-xs text-ctp-subtext1 mt-1">{data.level.name}</p> 44 49 )} 45 50 </div> 46 - <Button size="sm" variant="ghost" onClick={onEdit}> 47 - Edit 48 - </Button> 51 + <div className="flex gap-1"> 52 + {onConvert && ( 53 + <Button size="sm" variant="ghost" onClick={onConvert}> 54 + Convert to Education 55 + </Button> 56 + )} 57 + <Button size="sm" variant="ghost" onClick={onEdit}> 58 + Edit 59 + </Button> 60 + </div> 49 61 </div> 50 62 51 63 <p className="text-sm text-ctp-subtext1 mb-2"> ··· 83 95 onSave: (data: DraftJobExperience) => void; 84 96 onCancel: () => void; 85 97 }) => { 86 - const [form, setForm] = useState<DraftJobExperience>(data); 98 + const [form, setForm] = useState<DraftJobExperience>({ 99 + ...data, 100 + startDate: new Date(data.startDate), 101 + endDate: data.endDate ? new Date(data.endDate) : null, 102 + }); 87 103 const [errors, setErrors] = useState<Record<string, string>>({}); 88 104 89 105 const updateField = <K extends keyof DraftJobExperience>( ··· 104 120 } 105 121 }; 106 122 107 - const removeSkill = (id: string) => 123 + const removeSkill = (key: string) => 108 124 updateField( 109 125 "skills", 110 - form.skills.filter((s) => s.id !== id), 126 + form.skills.filter((s) => (s.id ?? s.name) !== key), 111 127 ); 112 128 113 129 const handleSubmit = (e: React.FormEvent) => { ··· 116 132 117 133 if (!result.success) { 118 134 const fieldErrors: Record<string, string> = {}; 119 - result.error.errors.forEach((err) => { 135 + result.error.issues.forEach((err) => { 120 136 const path = err.path.join("."); 121 137 fieldErrors[path] = err.message; 122 138 }); ··· 137 153 <CompanySelect 138 154 value={form.company.id ?? ""} 139 155 onChange={(id) => updateEntity("company", id)} 140 - defaultSearchValue={form.company.id ? undefined : form.company.name} 156 + defaultSearchValue={form.company.name} 141 157 error={errors["company.name"]} 142 158 /> 143 159 144 160 <RoleSelect 145 161 value={form.role.id ?? ""} 146 162 onChange={(id) => updateEntity("role", id)} 147 - defaultSearchValue={form.role.id ? undefined : form.role.name} 163 + defaultSearchValue={form.role.name} 148 164 error={errors["role.name"]} 149 165 /> 150 166 151 167 <LevelSelect 152 168 value={form.level.id ?? ""} 153 169 onChange={(id) => updateEntity("level", id)} 154 - defaultSearchValue={form.level.id ? undefined : form.level.name} 170 + defaultSearchValue={form.level.name} 155 171 error={errors["level.name"]} 156 172 /> 157 173 ··· 207 223 208 224 <div className="space-y-2"> 209 225 <SkillsSelect value="" onChange={addSkill} /> 210 - <SelectedSkillsDisplay 211 - skillIds={form.skills 212 - .map((s) => s.id) 213 - .filter((id): id is string => id !== null)} 214 - onRemoveSkill={removeSkill} 215 - /> 226 + {form.skills.length > 0 && ( 227 + <div className="space-y-3"> 228 + <p className="text-sm font-medium text-ctp-text"> 229 + Selected Skills ({form.skills.length}) 230 + </p> 231 + <div className="flex flex-wrap gap-2"> 232 + {form.skills.map((skill) => ( 233 + <div 234 + key={skill.id ?? skill.name} 235 + className="flex items-center gap-2 bg-ctp-surface0 px-3 py-1 rounded-full text-sm" 236 + > 237 + <span>{skill.name}</span> 238 + <button 239 + type="button" 240 + onClick={() => removeSkill(skill.id ?? skill.name)} 241 + className="text-ctp-red hover:text-ctp-red/80" 242 + > 243 + x 244 + </button> 245 + </div> 246 + ))} 247 + </div> 248 + </div> 249 + )} 216 250 </div> 217 251 218 252 <div className="flex gap-2 pt-2"> ··· 237 271 /** 238 272 * Job experience review card - toggles between view and edit modes 239 273 */ 240 - export const JobExperienceReviewCard = ({ data, onChange }: Props) => { 274 + export const JobExperienceReviewCard = ({ 275 + data, 276 + onChange, 277 + onConvert, 278 + }: Props) => { 241 279 const [isEditing, setIsEditing] = useState(false); 242 280 243 281 const handleSave = (updated: DraftJobExperience) => { ··· 252 290 onCancel={() => setIsEditing(false)} 253 291 /> 254 292 ) : ( 255 - <JobExperienceView data={data} onEdit={() => setIsEditing(true)} /> 293 + <JobExperienceView 294 + data={data} 295 + onEdit={() => setIsEditing(true)} 296 + onConvert={onConvert} 297 + /> 256 298 ); 257 299 };
+80
apps/client/src/features/onboarding/components/OnboardingChecklist.tsx
··· 1 + import { ViewTransitionLink } from "@cv/routing"; 2 + import type { OnboardingStepStatus } from "@/generated/graphql"; 3 + 4 + interface Step { 5 + name: string; 6 + status: OnboardingStepStatus; 7 + blockedBy: string[]; 8 + } 9 + 10 + interface Props { 11 + steps: Step[]; 12 + } 13 + 14 + const stepLabels: Record<string, string> = { 15 + "ai-preference": "AI Preferences", 16 + import: "Import Data", 17 + "personal-profile": "Personal Profile", 18 + "career-history": "Career History", 19 + education: "Education", 20 + }; 21 + 22 + const statusIcon: Record<OnboardingStepStatus, string> = { 23 + COMPLETE: "✓", 24 + IN_PROGRESS: "~", 25 + NOT_STARTED: " ", 26 + }; 27 + 28 + const statusColor: Record<OnboardingStepStatus, string> = { 29 + COMPLETE: "text-ctp-green border-ctp-green", 30 + IN_PROGRESS: "text-ctp-yellow border-ctp-yellow", 31 + NOT_STARTED: "text-ctp-overlay0 border-ctp-overlay0", 32 + }; 33 + 34 + export const OnboardingChecklist = ({ steps }: Props) => { 35 + const completed = steps.filter((s) => s.status === "COMPLETE").length; 36 + const total = steps.length; 37 + 38 + return ( 39 + <div className="p-4 rounded-lg border border-ctp-surface1 bg-ctp-crust/40"> 40 + <div className="flex justify-between items-center mb-3"> 41 + <h3 className="font-semibold text-ctp-text">Setup Guide</h3> 42 + <span className="text-sm text-ctp-subtext0"> 43 + {completed}/{total} complete 44 + </span> 45 + </div> 46 + 47 + <div className="space-y-2"> 48 + {steps.map((step) => { 49 + const isBlocked = step.blockedBy.length > 0; 50 + const label = stepLabels[step.name] ?? step.name; 51 + 52 + return ( 53 + <ViewTransitionLink 54 + key={step.name} 55 + to={`/onboarding/${step.name}`} 56 + className={`flex items-center gap-3 p-2 rounded hover:bg-ctp-surface0 transition-colors ${ 57 + isBlocked ? "opacity-50 pointer-events-none" : "" 58 + }`} 59 + > 60 + <span 61 + className={`inline-flex items-center justify-center h-5 w-5 rounded-full border text-xs font-bold ${statusColor[step.status]}`} 62 + > 63 + {statusIcon[step.status]} 64 + </span> 65 + <span 66 + className={`text-sm ${ 67 + step.status === "COMPLETE" 68 + ? "text-ctp-subtext0 line-through" 69 + : "text-ctp-text" 70 + }`} 71 + > 72 + {label} 73 + </span> 74 + </ViewTransitionLink> 75 + ); 76 + })} 77 + </div> 78 + </div> 79 + ); 80 + };
+27 -22
apps/client/src/features/onboarding/components/OnboardingMethodSelector.tsx
··· 1 1 import { Button, Card } from "@cv/ui"; 2 + import type { AiPreference } from "@/generated/graphql"; 2 3 3 4 interface Props { 4 5 onMethodSelect: (method: string) => void; 6 + aiPreference?: AiPreference | null; 5 7 } 6 8 7 - export const OnboardingMethodSelector = ({ onMethodSelect }: Props) => { 9 + export const OnboardingMethodSelector = ({ 10 + onMethodSelect, 11 + aiPreference, 12 + }: Props) => { 13 + const isNoAi = aiPreference === ("NO_AI" as AiPreference); 14 + 8 15 const methods = [ 9 16 { 10 - id: "linkedin", 11 - title: "Connect with LinkedIn", 12 - description: "Import your profile automatically", 13 - icon: "🔗", 14 - disabled: true, 15 - comingSoon: true, 16 - }, 17 - { 18 17 id: "file-upload", 19 18 title: "Upload Existing CV", 20 19 description: "PDF, DOCX, TXT, or Markdown", 21 20 icon: "📄", 22 - disabled: false, 21 + disabled: isNoAi, 22 + comingSoon: false, 23 + requiresAi: true, 23 24 }, 24 25 { 25 26 id: "story", ··· 28 29 icon: "✍️", 29 30 disabled: true, 30 31 comingSoon: true, 32 + requiresAi: true, 31 33 }, 32 34 { 33 35 id: "manual", ··· 35 37 description: "Enter details step by step", 36 38 icon: "📝", 37 39 disabled: false, 40 + comingSoon: false, 41 + requiresAi: false, 38 42 }, 39 43 ]; 40 44 45 + const tipText = isNoAi 46 + ? "You chose to skip AI. Only manual entry is available. You can change this in your profile settings." 47 + : 'Choose "Upload Existing CV" or "Tell Your Story" to let our AI extract your data automatically. You can review and edit everything before saving.'; 48 + 41 49 return ( 42 50 <div className="mt-8"> 43 - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> 51 + <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> 44 52 {methods.map((method) => ( 45 53 <Card 46 54 key={method.id} 47 55 className={`p-6 flex flex-col ${ 48 56 method.disabled 49 57 ? "opacity-60 cursor-not-allowed" 50 - : "cursor-pointer hover:shadow-lg transition-shadow" 58 + : "hover:shadow-lg transition-shadow" 51 59 }`} 52 - onClick={() => !method.disabled && onMethodSelect(method.id)} 53 60 > 54 61 <div className="text-4xl mb-4">{method.icon}</div> 55 62 <h3 className="text-xl font-semibold text-ctp-text mb-2"> ··· 63 70 Coming Soon 64 71 </div> 65 72 )} 73 + {isNoAi && method.requiresAi && !method.comingSoon && ( 74 + <div className="text-xs font-semibold text-ctp-subtext0 mb-3"> 75 + Requires AI 76 + </div> 77 + )} 66 78 <Button 67 79 disabled={method.disabled} 68 80 className="w-full" 69 - onClick={(e) => { 70 - e.stopPropagation(); 71 - if (!method.disabled) { 72 - onMethodSelect(method.id); 73 - } 74 - }} 81 + onClick={() => onMethodSelect(method.id)} 75 82 > 76 83 {method.disabled ? "Coming Soon" : "Get Started"} 77 84 </Button> ··· 81 88 82 89 <div className="mt-8 p-4 bg-ctp-surface0 rounded-lg border border-ctp-overlay0"> 83 90 <p className="text-sm text-ctp-subtext1"> 84 - 💡 <strong>Tip:</strong> Choose "Upload Existing CV" or "Tell Your 85 - Story" to let our AI extract your data automatically. You can review 86 - and edit everything before saving. 91 + <strong>Tip:</strong> {tipText} 87 92 </p> 88 93 </div> 89 94 </div>
+62
apps/client/src/features/onboarding/components/PersonalProfileStep.tsx
··· 1 + import { Placeholder } from "@cv/ui"; 2 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 3 + import { PersonalProfileForm } from "@/features/profile/components/PersonalProfileForm"; 4 + import { useProfileQuery } from "@/generated/graphql"; 5 + 6 + interface Props { 7 + onComplete: () => void; 8 + onSkip: () => void; 9 + } 10 + 11 + export const PersonalProfileStep = ({ onComplete, onSkip }: Props) => { 12 + const profileId = useActiveProfileId(); 13 + const { data, isLoading } = useProfileQuery({ id: profileId }); 14 + 15 + if (isLoading) { 16 + return <Placeholder variant="loading" message="Loading profile..." />; 17 + } 18 + 19 + const profile = data?.profile; 20 + const initialData = profile 21 + ? { 22 + fullName: profile.fullName ?? "", 23 + headline: profile.headline ?? "", 24 + phone: profile.phone ?? "", 25 + city: profile.city ?? "", 26 + country: profile.country ?? "", 27 + website: profile.website ?? "", 28 + linkedInUrl: profile.linkedInUrl ?? "", 29 + summary: profile.summary ?? "", 30 + } 31 + : undefined; 32 + 33 + return ( 34 + <div className="space-y-6"> 35 + <div className="flex justify-between items-start"> 36 + <div> 37 + <h2 className="text-2xl font-bold text-ctp-text">Personal Profile</h2> 38 + <p className="text-ctp-subtext0 mt-1"> 39 + Add your contact details and professional summary. Everything is 40 + optional and only appears on your CV when you choose to include it. 41 + </p> 42 + </div> 43 + <button 44 + type="button" 45 + onClick={onSkip} 46 + className="text-sm text-ctp-subtext0 hover:text-ctp-text underline shrink-0" 47 + > 48 + Skip for now 49 + </button> 50 + </div> 51 + 52 + <div className="rounded-lg border border-ctp-surface1 p-4"> 53 + <PersonalProfileForm 54 + profileId={profileId} 55 + initialData={initialData} 56 + onSuccess={onComplete} 57 + onSkip={onSkip} 58 + /> 59 + </div> 60 + </div> 61 + ); 62 + };
+316 -112
apps/client/src/features/onboarding/components/ReviewStep.tsx
··· 1 1 import { Button, useToast } from "@cv/ui"; 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 - import { useState } from "react"; 4 - import { z } from "zod"; 3 + import { useCallback, useState } from "react"; 4 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 5 5 import { 6 + useCreateCompanyMutation, 6 7 useCreateEducationMutation, 8 + useCreateInstitutionMutation, 7 9 useCreateJobExperienceMutation, 8 - useMeEducationQuery, 9 - useMeJobExperienceQuery, 10 + useCreateLevelMutation, 11 + useCreateRoleMutation, 12 + useCreateSkillMutation, 10 13 } from "@/generated/graphql"; 11 - import { 12 - type DraftEducation, 13 - type DraftJobExperience, 14 - type ParsedCVDataWithResolution, 15 - type ValidatedEducation, 16 - type ValidatedJobExperience, 17 - validatedEducationSchema, 18 - validatedJobExperienceSchema, 14 + import type { 15 + DraftEducation, 16 + DraftEntity, 17 + DraftJobExperience, 18 + ParsedCVDataWithResolution, 19 19 } from "../schemas/draft.schema"; 20 20 import { EducationReviewCard } from "./EducationReviewCard"; 21 21 import { JobExperienceReviewCard } from "./JobExperienceReviewCard"; 22 22 23 + const BATCH_SIZE = 5; 24 + 25 + interface SaveProgress { 26 + phase: string; 27 + current: number; 28 + total: number; 29 + } 30 + 23 31 /** 24 - * Type guard to check if a PromiseSettledResult is fulfilled 32 + * Collect unique entities with null IDs, keyed by lowercase name 25 33 */ 26 - const isFulfilled = <T,>( 27 - result: PromiseSettledResult<T>, 28 - ): result is PromiseFulfilledResult<T> => result.status === "fulfilled"; 34 + const collectUnresolved = ( 35 + entities: DraftEntity[], 36 + ): Map<string, DraftEntity> => { 37 + const map = new Map<string, DraftEntity>(); 38 + entities 39 + .filter((e) => !e.id && e.name) 40 + .forEach((e) => map.set(e.name.toLowerCase(), e)); 41 + return map; 42 + }; 29 43 30 44 /** 31 - * Extract error message from a rejected promise result 45 + * Run async tasks in batches of `size`, calling `onProgress` after each 32 46 */ 33 - const formatError = (result: PromiseRejectedResult): string => 34 - result.reason instanceof Error ? result.reason.message : "Unknown error"; 47 + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); 35 48 36 - /** 37 - * Format Zod error for display 38 - */ 39 - const formatZodError = (error: z.ZodError): string => 40 - error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; "); 49 + const BATCH_DELAY_MS = 1000; 50 + 51 + const batchExecute = async <T,>( 52 + tasks: (() => Promise<T>)[], 53 + size: number, 54 + onProgress: () => void, 55 + ): Promise<PromiseSettledResult<T>[]> => { 56 + const results: PromiseSettledResult<T>[] = []; 41 57 42 - /** 43 - * Map validated job experience to mutation input (IDs guaranteed by schema) 44 - */ 45 - const mapJobToInput = (job: ValidatedJobExperience) => ({ 46 - companyId: job.company.id, 47 - roleId: job.role.id, 48 - levelId: job.level.id, 49 - startDate: job.startDate, 50 - endDate: job.endDate ?? undefined, 51 - description: job.description ?? undefined, 52 - skillIds: job.skills.map((s) => s.id), 53 - }); 58 + for (let i = 0; i < tasks.length; i += size) { 59 + const batch = tasks.slice(i, i + size); 60 + const [batchResults] = await Promise.all([ 61 + Promise.allSettled(batch.map((fn) => fn())), 62 + delay(BATCH_DELAY_MS), 63 + ]); 64 + results.push(...batchResults); 65 + batchResults.forEach(onProgress); 66 + } 54 67 55 - /** 56 - * Map validated education to mutation input (IDs guaranteed by schema) 57 - */ 58 - const mapEducationToInput = (edu: ValidatedEducation) => ({ 59 - institutionId: edu.institution.id, 60 - degree: edu.degree, 61 - fieldOfStudy: edu.fieldOfStudy ?? undefined, 62 - startDate: edu.startDate, 63 - endDate: edu.endDate ?? undefined, 64 - description: edu.description ?? undefined, 65 - skillIds: edu.skills.map((s) => s.id), 66 - }); 68 + return results; 69 + }; 67 70 68 71 interface Props { 69 72 parsedData: ParsedCVDataWithResolution; ··· 78 81 onBack, 79 82 onDataChange, 80 83 }: Props) => { 84 + const profileId = useActiveProfileId(); 81 85 const [saving, setSaving] = useState(false); 86 + const [progress, setProgress] = useState<SaveProgress | null>(null); 82 87 const { showSuccess, showError } = useToast(); 83 88 const queryClient = useQueryClient(); 84 89 90 + const { mutateAsync: createCompany } = useCreateCompanyMutation(); 91 + const { mutateAsync: createRole } = useCreateRoleMutation(); 92 + const { mutateAsync: createLevel } = useCreateLevelMutation(); 93 + const { mutateAsync: createSkill } = useCreateSkillMutation(); 94 + const { mutateAsync: createInstitution } = useCreateInstitutionMutation(); 85 95 const { mutateAsync: createJobExperience } = useCreateJobExperienceMutation(); 86 96 const { mutateAsync: createEducation } = useCreateEducationMutation(); 87 97 88 - const handleSaveAll = async () => { 89 - // Validate all entries with Zod - this gives us type-safe validated data 90 - const jobsResult = z 91 - .array(validatedJobExperienceSchema) 92 - .safeParse(parsedData.jobExperiences); 93 - const eduResult = z 94 - .array(validatedEducationSchema) 95 - .safeParse(parsedData.education); 98 + const handleSaveAll = useCallback(async () => { 99 + setSaving(true); 100 + const errors: string[] = []; 96 101 97 - // Guard: show validation errors if any entries are incomplete 98 - if (!(jobsResult.success && eduResult.success)) { 99 - const errors = [ 100 - ...(!jobsResult.success ? [formatZodError(jobsResult.error)] : []), 101 - ...(!eduResult.success ? [formatZodError(eduResult.error)] : []), 102 - ]; 103 - showError("Please complete all selections", errors.join("; ")); 104 - return; 105 - } 102 + try { 103 + // Phase 1: Collect all unresolved entities, deduplicated by name 104 + const allJobs = parsedData.jobExperiences; 105 + const allEdu = parsedData.education; 106 106 107 - setSaving(true); 107 + const unresolvedCompanies = collectUnresolved(allJobs.map((j) => j.company)); 108 + const unresolvedRoles = collectUnresolved(allJobs.map((j) => j.role)); 109 + const unresolvedLevels = collectUnresolved(allJobs.map((j) => j.level)); 110 + const unresolvedInstitutions = collectUnresolved(allEdu.map((e) => e.institution)); 111 + const unresolvedSkills = collectUnresolved([ 112 + ...allJobs.flatMap((j) => j.skills), 113 + ...allEdu.flatMap((e) => e.skills), 114 + ]); 108 115 109 - // Use validated data (IDs are guaranteed non-null by schema) 110 - const validatedJobs = jobsResult.data; 111 - const validatedEducation = eduResult.data; 116 + const totalEntities = 117 + unresolvedCompanies.size + 118 + unresolvedRoles.size + 119 + unresolvedLevels.size + 120 + unresolvedInstitutions.size + 121 + unresolvedSkills.size; 122 + 123 + // Phase 2: Create missing entities in batches 124 + // Maps lowercase name → created ID 125 + const idMap = new Map<string, Map<string, string>>(); 126 + idMap.set("company", new Map()); 127 + idMap.set("role", new Map()); 128 + idMap.set("level", new Map()); 129 + idMap.set("institution", new Map()); 130 + idMap.set("skill", new Map()); 131 + 132 + let created = 0; 112 133 113 - // Parallel save - all jobs and education at once 114 - const [jobResults, eduResults] = await Promise.all([ 115 - Promise.allSettled( 116 - validatedJobs.map((job) => createJobExperience(mapJobToInput(job))), 117 - ), 118 - Promise.allSettled( 119 - validatedEducation.map((edu) => 120 - createEducation(mapEducationToInput(edu)), 121 - ), 122 - ), 123 - ]); 134 + if (totalEntities > 0) { 135 + setProgress({ phase: "Creating entities", current: 0, total: totalEntities }); 124 136 125 - const allResults = [ 126 - ...jobResults, 127 - ...eduResults, 128 - ] as PromiseSettledResult<unknown>[]; 129 - const succeeded = allResults.filter(isFulfilled); 130 - const failed = allResults.filter( 131 - (r): r is PromiseRejectedResult => r.status === "rejected", 132 - ); 137 + const bump = () => { 138 + created++; 139 + setProgress({ phase: "Creating entities", current: created, total: totalEntities }); 140 + }; 133 141 134 - // Invalidate queries to refresh data 135 - await Promise.all([ 136 - queryClient.invalidateQueries({ 137 - queryKey: [useMeJobExperienceQuery.getKey()], 138 - }), 139 - queryClient.invalidateQueries({ 140 - queryKey: [useMeEducationQuery.getKey()], 141 - }), 142 - ]); 142 + const entityTasks: (() => Promise<void>)[] = []; 143 143 144 - setSaving(false); 144 + unresolvedCompanies.forEach((entity) => { 145 + entityTasks.push(async () => { 146 + const result = await createCompany({ name: entity.name }); 147 + idMap.get("company")!.set(entity.name.toLowerCase(), result.createCompany.id); 148 + }); 149 + }); 145 150 146 - // Guard clause for error handling 147 - if (failed.length > 0) { 148 - const errorMessages = failed.slice(0, 3).map(formatError).join("; "); 149 - const moreCount = 150 - failed.length > 3 ? `... and ${failed.length - 3} more` : ""; 151 + unresolvedRoles.forEach((entity) => { 152 + entityTasks.push(async () => { 153 + const result = await createRole({ name: entity.name }); 154 + idMap.get("role")!.set(entity.name.toLowerCase(), result.createRole.id); 155 + }); 156 + }); 151 157 158 + unresolvedLevels.forEach((entity) => { 159 + entityTasks.push(async () => { 160 + const result = await createLevel({ name: entity.name }); 161 + idMap.get("level")!.set(entity.name.toLowerCase(), result.createLevel.id); 162 + }); 163 + }); 164 + 165 + unresolvedInstitutions.forEach((entity) => { 166 + entityTasks.push(async () => { 167 + const result = await createInstitution({ name: entity.name }); 168 + idMap.get("institution")!.set(entity.name.toLowerCase(), result.createInstitution.id); 169 + }); 170 + }); 171 + 172 + unresolvedSkills.forEach((entity) => { 173 + entityTasks.push(async () => { 174 + const result = await createSkill({ name: entity.name }); 175 + idMap.get("skill")!.set(entity.name.toLowerCase(), result.createSkill.id); 176 + }); 177 + }); 178 + 179 + const entityResults = await batchExecute(entityTasks, BATCH_SIZE, bump); 180 + const entityErrors = entityResults.filter( 181 + (r): r is PromiseRejectedResult => r.status === "rejected", 182 + ); 183 + 184 + if (entityErrors.length > 0) { 185 + errors.push( 186 + ...entityErrors.slice(0, 3).map((r) => 187 + r.reason instanceof Error ? r.reason.message : "Entity creation failed", 188 + ), 189 + ); 190 + } 191 + } 192 + 193 + // Phase 3: Resolve all IDs (existing + newly created) 194 + const resolveId = (type: string, entity: DraftEntity): string | null => 195 + entity.id ?? idMap.get(type)?.get(entity.name.toLowerCase()) ?? null; 196 + 197 + // Phase 4: Create job experiences and education 198 + const totalRecords = allJobs.length + allEdu.length; 199 + let savedRecords = 0; 200 + setProgress({ phase: "Saving records", current: 0, total: totalRecords }); 201 + 202 + const recordBump = () => { 203 + savedRecords++; 204 + setProgress({ phase: "Saving records", current: savedRecords, total: totalRecords }); 205 + }; 206 + 207 + const jobTasks = allJobs.map((job) => async () => { 208 + const companyId = resolveId("company", job.company); 209 + const roleId = resolveId("role", job.role); 210 + const levelId = resolveId("level", job.level); 211 + const skillIds = job.skills 212 + .map((s) => resolveId("skill", s)) 213 + .filter((id): id is string => id !== null); 214 + 215 + if (!companyId || !roleId || !levelId) { 216 + throw new Error(`Missing entity IDs for job at ${job.company.name}`); 217 + } 218 + 219 + await createJobExperience({ 220 + profileId, 221 + companyId, 222 + roleId, 223 + levelId, 224 + startDate: new Date(job.startDate), 225 + endDate: job.endDate ? new Date(job.endDate) : undefined, 226 + description: job.description ?? undefined, 227 + skillIds, 228 + }); 229 + }); 230 + 231 + const eduTasks = allEdu.map((edu) => async () => { 232 + const institutionId = resolveId("institution", edu.institution); 233 + const skillIds = edu.skills 234 + .map((s) => resolveId("skill", s)) 235 + .filter((id): id is string => id !== null); 236 + 237 + if (!institutionId) { 238 + throw new Error(`Missing institution ID for ${edu.institution.name}`); 239 + } 240 + 241 + await createEducation({ 242 + profileId, 243 + institutionId, 244 + degree: edu.degree, 245 + fieldOfStudy: edu.fieldOfStudy ?? undefined, 246 + startDate: new Date(edu.startDate), 247 + endDate: edu.endDate ? new Date(edu.endDate) : undefined, 248 + description: edu.description ?? undefined, 249 + skillIds, 250 + }); 251 + }); 252 + 253 + const recordResults = await batchExecute( 254 + [...jobTasks, ...eduTasks], 255 + BATCH_SIZE, 256 + recordBump, 257 + ); 258 + 259 + const recordErrors = recordResults.filter( 260 + (r): r is PromiseRejectedResult => r.status === "rejected", 261 + ); 262 + 263 + if (recordErrors.length > 0) { 264 + errors.push( 265 + ...recordErrors.slice(0, 3).map((r) => 266 + r.reason instanceof Error ? r.reason.message : "Record creation failed", 267 + ), 268 + ); 269 + } 270 + 271 + // Phase 5: Invalidate and report 272 + await Promise.all([ 273 + queryClient.invalidateQueries({ queryKey: ["MeJobExperience"] }), 274 + queryClient.invalidateQueries({ queryKey: ["MeEducation"] }), 275 + ]); 276 + 277 + const succeeded = recordResults.filter((r) => r.status === "fulfilled").length; 278 + 279 + if (errors.length > 0) { 280 + showError( 281 + `Saved with errors (${succeeded}/${totalRecords})`, 282 + errors.join("; "), 283 + ); 284 + } else { 285 + showSuccess("Saved successfully", `${succeeded} entries created`); 286 + onComplete(); 287 + } 288 + } catch (err) { 152 289 showError( 153 - `Saved with errors (${succeeded.length}/${allResults.length})`, 154 - errorMessages + moreCount, 290 + "Save failed", 291 + err instanceof Error ? err.message : "Unknown error", 155 292 ); 156 - return; 293 + } finally { 294 + setSaving(false); 295 + setProgress(null); 157 296 } 158 - 159 - showSuccess("Saved successfully", `${succeeded.length} entries created`); 160 - onComplete(); 161 - }; 297 + }, [ 298 + profileId, 299 + parsedData, 300 + createCompany, 301 + createRole, 302 + createLevel, 303 + createSkill, 304 + createInstitution, 305 + createJobExperience, 306 + createEducation, 307 + queryClient, 308 + showSuccess, 309 + showError, 310 + onComplete, 311 + ]); 162 312 163 313 const updateJobExperience = 164 314 (index: number) => (updated: DraftJobExperience) => ··· 177 327 ), 178 328 }); 179 329 330 + const convertJobToEducation = (index: number) => () => { 331 + const job = parsedData.jobExperiences[index]; 332 + if (!job) return; 333 + const newEducation: DraftEducation = { 334 + institution: { id: null, name: job.company.name }, 335 + degree: job.role.name, 336 + fieldOfStudy: null, 337 + skills: job.skills, 338 + startDate: job.startDate, 339 + endDate: job.endDate, 340 + description: job.description, 341 + }; 342 + onDataChange({ 343 + ...parsedData, 344 + jobExperiences: parsedData.jobExperiences.filter((_, i) => i !== index), 345 + education: [...parsedData.education, newEducation], 346 + }); 347 + }; 348 + 349 + const convertEducationToJob = (index: number) => () => { 350 + const edu = parsedData.education[index]; 351 + if (!edu) return; 352 + const newJob: DraftJobExperience = { 353 + company: { id: null, name: edu.institution.name }, 354 + role: { id: null, name: edu.degree }, 355 + level: { id: null, name: "" }, 356 + skills: edu.skills, 357 + startDate: edu.startDate, 358 + endDate: edu.endDate, 359 + description: edu.description, 360 + }; 361 + onDataChange({ 362 + ...parsedData, 363 + education: parsedData.education.filter((_, i) => i !== index), 364 + jobExperiences: [...parsedData.jobExperiences, newJob], 365 + }); 366 + }; 367 + 180 368 const hasNoData = 181 369 parsedData.jobExperiences.length === 0 && parsedData.education.length === 0; 182 370 ··· 200 388 <div className="space-y-4"> 201 389 {parsedData.jobExperiences.map((job, index) => ( 202 390 <JobExperienceReviewCard 203 - key={`${job.company.name}-${job.startDate.toISOString()}`} 391 + key={`${job.company.name}-${String(job.startDate)}`} 204 392 data={job} 205 393 onChange={updateJobExperience(index)} 394 + onConvert={convertJobToEducation(index)} 206 395 /> 207 396 ))} 208 397 </div> ··· 217 406 <div className="space-y-4"> 218 407 {parsedData.education.map((edu, index) => ( 219 408 <EducationReviewCard 220 - key={`${edu.institution.name}-${edu.startDate.toISOString()}`} 409 + key={`${edu.institution.name}-${String(edu.startDate)}`} 221 410 data={edu} 222 411 onChange={updateEducation(index)} 412 + onConvert={convertEducationToJob(index)} 223 413 /> 224 414 ))} 225 415 </div> ··· 233 423 them manually from the dashboard, or try uploading a different file 234 424 format. 235 425 </p> 426 + </div> 427 + )} 428 + 429 + {progress && ( 430 + <div className="p-4 bg-ctp-surface0 rounded-lg border border-ctp-overlay0 space-y-2"> 431 + <p className="text-sm font-medium text-ctp-text"> 432 + {progress.phase} ({progress.current}/{progress.total}) 433 + </p> 434 + <div className="w-full h-2 bg-ctp-surface1 rounded-full overflow-hidden"> 435 + <div 436 + className="h-full bg-ctp-blue rounded-full transition-all duration-300" 437 + style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }} 438 + /> 439 + </div> 236 440 </div> 237 441 )} 238 442
+18
apps/client/src/features/onboarding/mutations/create-profile.graphql
··· 1 + mutation CreateProfile($input: CreateProfileInput!) { 2 + createProfile(input: $input) { 3 + id 4 + name 5 + fullName 6 + headline 7 + phone 8 + address 9 + postalCode 10 + city 11 + country 12 + website 13 + linkedInUrl 14 + summary 15 + createdAt 16 + updatedAt 17 + } 18 + }
+3
apps/client/src/features/onboarding/mutations/delete-profile.graphql
··· 1 + mutation DeleteProfile($id: ID!) { 2 + deleteProfile(id: $id) 3 + }
+3
apps/client/src/features/onboarding/mutations/reset-onboarding.graphql
··· 1 + mutation ResetOnboarding { 2 + resetOnboarding 3 + }
+18
apps/client/src/features/onboarding/mutations/update-profile.graphql
··· 1 + mutation UpdateProfile($id: ID!, $input: UpdateProfileInput!) { 2 + updateProfile(id: $id, input: $input) { 3 + id 4 + name 5 + fullName 6 + headline 7 + phone 8 + address 9 + postalCode 10 + city 11 + country 12 + website 13 + linkedInUrl 14 + summary 15 + createdAt 16 + updatedAt 17 + } 18 + }
+12
apps/client/src/features/onboarding/mutations/upload-file.graphql
··· 1 + mutation UploadFile($input: UploadFileInput!) { 2 + uploadFile(input: $input) { 3 + id 4 + profileId 5 + fileName 6 + mimeType 7 + sizeBytes 8 + status 9 + statusMessage 10 + isDuplicate 11 + } 12 + }
+36
apps/client/src/features/onboarding/mutations/useUploadFileMutation.ts
··· 1 + import { useMutation } from "@tanstack/react-query"; 2 + import { 3 + type UploadFileMutation, 4 + useUploadFileMutation as useCodegenUploadFile, 5 + } from "@/generated/graphql"; 6 + 7 + const readFileAsBase64 = (file: File): Promise<string> => 8 + new Promise((resolve, reject) => { 9 + const reader = new FileReader(); 10 + reader.onload = () => { 11 + const result = reader.result as string; 12 + resolve(result.split(",")[1] ?? ""); 13 + }; 14 + reader.onerror = () => reject(new Error("Failed to read file")); 15 + reader.readAsDataURL(file); 16 + }); 17 + 18 + /** 19 + * Wraps the codegen'd UploadFile mutation with File -> base64 conversion. 20 + * When profileId is omitted, the server auto-creates a "Default" profile. 21 + */ 22 + export const useUploadFileMutation = (profileId?: string) => 23 + useMutation<UploadFileMutation, Error, File>({ 24 + mutationKey: useCodegenUploadFile.getKey(), 25 + mutationFn: async (file: File) => { 26 + const content = await readFileAsBase64(file); 27 + return useCodegenUploadFile.fetcher({ 28 + input: { 29 + profileId, 30 + fileName: file.name, 31 + mimeType: file.type, 32 + content, 33 + }, 34 + })(); 35 + }, 36 + });
+18
apps/client/src/features/onboarding/queries/my-profiles.graphql
··· 1 + query MyProfiles { 2 + myProfiles { 3 + id 4 + name 5 + fullName 6 + headline 7 + phone 8 + address 9 + postalCode 10 + city 11 + country 12 + website 13 + linkedInUrl 14 + summary 15 + createdAt 16 + updatedAt 17 + } 18 + }
+8
apps/client/src/features/onboarding/queries/onboarding-status.graphql
··· 1 + query OnboardingStatus { 2 + onboardingStatus { 3 + name 4 + status 5 + dependsOn 6 + blockedBy 7 + } 8 + }
+7
apps/client/src/features/onboarding/queries/platform-capabilities.graphql
··· 1 + query PlatformCapabilities { 2 + platformCapabilities { 3 + availableProviderTypes 4 + aiParsing 5 + storyMode 6 + } 7 + }
+18
apps/client/src/features/onboarding/queries/profile.graphql
··· 1 + query Profile($id: ID!) { 2 + profile(id: $id) { 3 + id 4 + name 5 + fullName 6 + headline 7 + phone 8 + address 9 + postalCode 10 + city 11 + country 12 + website 13 + linkedInUrl 14 + summary 15 + createdAt 16 + updatedAt 17 + } 18 + }
+21
apps/client/src/features/onboarding/queries/useUserFileQuery.ts
··· 1 + import { 2 + type UserFileQuery, 3 + useUserFileQuery as useCodegenUserFile, 4 + } from "@/generated/graphql"; 5 + 6 + export type { UserFileQuery }; 7 + 8 + /** 9 + * Wraps the codegen'd UserFile query with nullable ID + polling convenience. 10 + */ 11 + export const useUserFileQuery = ( 12 + id: string | null, 13 + options?: { enabled?: boolean; refetchInterval?: number | false }, 14 + ) => 15 + useCodegenUserFile( 16 + { id: id ?? "" }, 17 + { 18 + enabled: options?.enabled ?? Boolean(id), 19 + refetchInterval: options?.refetchInterval, 20 + }, 21 + );
+15
apps/client/src/features/onboarding/queries/user-file.graphql
··· 1 + query UserFile($id: String!) { 2 + userFile(id: $id) { 3 + id 4 + profileId 5 + fileName 6 + source 7 + status 8 + statusMessage 9 + result 10 + error 11 + isDuplicate 12 + createdAt 13 + updatedAt 14 + } 15 + }
+1 -1
apps/client/src/features/onboarding/schemas/draft.schema.ts
··· 1 - import { z } from "zod"; 1 + import { z } from "zod/v4"; 2 2 3 3 /** 4 4 * Draft entity - represents an entity that may or may not exist in the database
+210
apps/client/src/features/profile/components/PersonalProfileForm.tsx
··· 1 + import { Button, Textarea, TextInput, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { useUpdateProfileMutation } from "@/generated/graphql"; 5 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 6 + import { 7 + type ProfileFormData, 8 + profileFormSchema, 9 + } from "../schemas/profile.schema"; 10 + 11 + interface Props { 12 + profileId: string; 13 + initialData?: Partial<ProfileFormData>; 14 + onSuccess?: () => void; 15 + onCancel?: () => void; 16 + onSkip?: () => void; 17 + } 18 + 19 + export const PersonalProfileForm = ({ 20 + profileId, 21 + initialData, 22 + onSuccess, 23 + onCancel, 24 + onSkip, 25 + }: Props) => { 26 + const { showSuccess, showError } = useToast(); 27 + const queryClient = useQueryClient(); 28 + const { mutateAsync, isPending } = useUpdateProfileMutation(); 29 + 30 + const [form, setForm] = useState<ProfileFormData>({ 31 + fullName: initialData?.fullName ?? "", 32 + headline: initialData?.headline ?? "", 33 + phone: initialData?.phone ?? "", 34 + address: initialData?.address ?? "", 35 + postalCode: initialData?.postalCode ?? "", 36 + city: initialData?.city ?? "", 37 + country: initialData?.country ?? "", 38 + website: initialData?.website ?? "", 39 + linkedInUrl: initialData?.linkedInUrl ?? "", 40 + summary: initialData?.summary ?? "", 41 + }); 42 + 43 + const [errors, setErrors] = useState< 44 + Partial<Record<keyof ProfileFormData, string>> 45 + >({}); 46 + 47 + const updateField = (field: keyof ProfileFormData, value: string) => { 48 + setForm((prev) => ({ ...prev, [field]: value })); 49 + setErrors((prev) => ({ ...prev, [field]: undefined })); 50 + }; 51 + 52 + const handleSubmit = async () => { 53 + const result = profileFormSchema.safeParse(form); 54 + 55 + if (!result.success) { 56 + const fieldErrors: Partial<Record<keyof ProfileFormData, string>> = {}; 57 + result.error.issues.forEach((err) => { 58 + const field = err.path[0] as keyof ProfileFormData; 59 + fieldErrors[field] = err.message; 60 + }); 61 + setErrors(fieldErrors); 62 + return; 63 + } 64 + 65 + try { 66 + await mutateAsync({ 67 + id: profileId, 68 + input: { 69 + fullName: form.fullName || undefined, 70 + headline: form.headline || undefined, 71 + phone: form.phone || undefined, 72 + address: form.address || undefined, 73 + postalCode: form.postalCode || undefined, 74 + city: form.city || undefined, 75 + country: form.country || undefined, 76 + website: form.website || undefined, 77 + linkedInUrl: form.linkedInUrl || undefined, 78 + summary: form.summary || undefined, 79 + }, 80 + }); 81 + await queryClient.invalidateQueries({ queryKey: ["MyProfiles"] }); 82 + await queryClient.invalidateQueries({ queryKey: ["Profile"] }); 83 + await queryClient.invalidateQueries({ queryKey: ["OnboardingStatus"] }); 84 + showSuccess("Profile updated"); 85 + onSuccess?.(); 86 + } catch (err) { 87 + showError(extractGraphQLErrorMessage(err) || "Failed to update profile"); 88 + } 89 + }; 90 + 91 + return ( 92 + <div className="space-y-4"> 93 + <p className="text-sm text-ctp-subtext0 border border-ctp-surface1 rounded-lg p-3 bg-ctp-crust/40"> 94 + All fields are optional. This information is stored in your profile but 95 + will only appear on a CV if you choose to include it. Sensitive fields 96 + (phone, address, postal code) are encrypted at rest. 97 + </p> 98 + 99 + <TextInput 100 + label="Full Name" 101 + placeholder="e.g. Jane Doe" 102 + value={form.fullName} 103 + onChange={(value) => updateField("fullName", value)} 104 + error={errors.fullName} 105 + autoComplete="name" 106 + /> 107 + 108 + <TextInput 109 + label="Headline" 110 + placeholder="e.g. Senior Frontend Engineer" 111 + value={form.headline} 112 + onChange={(value) => updateField("headline", value)} 113 + error={errors.headline} 114 + autoComplete="organization-title" 115 + /> 116 + 117 + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 118 + <TextInput 119 + label="City" 120 + placeholder="e.g. Amsterdam" 121 + value={form.city} 122 + onChange={(value) => updateField("city", value)} 123 + error={errors.city} 124 + autoComplete="address-level2" 125 + /> 126 + <TextInput 127 + label="Country" 128 + placeholder="e.g. Netherlands" 129 + value={form.country} 130 + onChange={(value) => updateField("country", value)} 131 + error={errors.country} 132 + autoComplete="country-name" 133 + /> 134 + </div> 135 + 136 + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 137 + <TextInput 138 + label="Phone" 139 + placeholder="+31 6 1234 5678" 140 + value={form.phone} 141 + onChange={(value) => updateField("phone", value)} 142 + error={errors.phone} 143 + type="tel" 144 + autoComplete="tel" 145 + /> 146 + <TextInput 147 + label="Postal Code" 148 + placeholder="e.g. 1012 AB" 149 + value={form.postalCode} 150 + onChange={(value) => updateField("postalCode", value)} 151 + error={errors.postalCode} 152 + autoComplete="postal-code" 153 + /> 154 + </div> 155 + 156 + <TextInput 157 + label="Address" 158 + placeholder="Street and house number" 159 + value={form.address} 160 + onChange={(value) => updateField("address", value)} 161 + error={errors.address} 162 + autoComplete="street-address" 163 + /> 164 + 165 + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 166 + <TextInput 167 + label="Website" 168 + placeholder="https://yoursite.com" 169 + value={form.website} 170 + onChange={(value) => updateField("website", value)} 171 + error={errors.website} 172 + type="url" 173 + autoComplete="url" 174 + /> 175 + <TextInput 176 + label="LinkedIn URL" 177 + placeholder="https://linkedin.com/in/you" 178 + value={form.linkedInUrl} 179 + onChange={(value) => updateField("linkedInUrl", value)} 180 + error={errors.linkedInUrl} 181 + type="url" 182 + /> 183 + </div> 184 + 185 + <Textarea 186 + label="Summary" 187 + placeholder="A brief overview of your professional background..." 188 + value={form.summary} 189 + onChange={(value) => updateField("summary", value)} 190 + rows={4} 191 + /> 192 + 193 + <div className="flex gap-4 pt-4"> 194 + {onCancel && ( 195 + <Button variant="outline" onClick={onCancel} disabled={isPending}> 196 + Cancel 197 + </Button> 198 + )} 199 + <Button onClick={handleSubmit} disabled={isPending}> 200 + {isPending ? "Saving..." : "Save Profile"} 201 + </Button> 202 + {onSkip && ( 203 + <Button variant="ghost" onClick={onSkip} disabled={isPending}> 204 + Skip for now 205 + </Button> 206 + )} 207 + </div> 208 + </div> 209 + ); 210 + };
+89
apps/client/src/features/profile/components/ProfileSwitcher.tsx
··· 1 + import { useRef, useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { useProfile } from "@/contexts/ProfileProvider"; 4 + 5 + export const ProfileSwitcher = () => { 6 + const { profiles, activeProfile, setActiveProfileId } = useProfile(); 7 + const [isOpen, setIsOpen] = useState(false); 8 + const containerRef = useRef<HTMLDivElement>(null); 9 + const navigate = useNavigate(); 10 + 11 + if (profiles.length <= 1) return null; 12 + 13 + const handleSelect = (id: string) => { 14 + setActiveProfileId(id); 15 + setIsOpen(false); 16 + }; 17 + 18 + const handleBlur = (e: React.FocusEvent) => { 19 + if (!containerRef.current?.contains(e.relatedTarget as Node)) { 20 + setIsOpen(false); 21 + } 22 + }; 23 + 24 + return ( 25 + <div ref={containerRef} className="relative" onBlur={handleBlur}> 26 + <button 27 + type="button" 28 + onClick={() => setIsOpen((prev) => !prev)} 29 + className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-ctp-subtext0 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text" 30 + > 31 + <span className="max-w-[120px] truncate"> 32 + {activeProfile?.name ?? "Profile"} 33 + </span> 34 + <svg 35 + className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-180" : ""}`} 36 + fill="none" 37 + viewBox="0 0 24 24" 38 + stroke="currentColor" 39 + strokeWidth={2} 40 + > 41 + <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> 42 + </svg> 43 + </button> 44 + 45 + {isOpen && ( 46 + <div className="absolute right-0 z-50 mt-1 min-w-[200px] rounded-lg border border-ctp-surface0 bg-ctp-crust shadow-lg"> 47 + <div className="p-1"> 48 + {profiles.map((profile) => ( 49 + <button 50 + key={profile.id} 51 + type="button" 52 + onClick={() => handleSelect(profile.id)} 53 + className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${ 54 + profile.id === activeProfile?.id 55 + ? "bg-ctp-surface0 text-ctp-blue" 56 + : "text-ctp-text hover:bg-ctp-surface0" 57 + }`} 58 + > 59 + <div className="min-w-0 flex-1"> 60 + <p className="truncate font-medium">{profile.name}</p> 61 + {profile.headline && ( 62 + <p className="truncate text-xs text-ctp-subtext0"> 63 + {profile.headline} 64 + </p> 65 + )} 66 + </div> 67 + {profile.id === activeProfile?.id && ( 68 + <div className="h-1.5 w-1.5 shrink-0 rounded-full bg-ctp-blue" /> 69 + )} 70 + </button> 71 + ))} 72 + </div> 73 + <div className="border-t border-ctp-surface0 p-1"> 74 + <button 75 + type="button" 76 + onClick={() => { 77 + setIsOpen(false); 78 + navigate("/profiles"); 79 + }} 80 + className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-ctp-subtext0 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text" 81 + > 82 + Manage Profiles 83 + </button> 84 + </div> 85 + </div> 86 + )} 87 + </div> 88 + ); 89 + };
+38
apps/client/src/features/profile/schemas/profile.schema.ts
··· 1 + import { z } from "zod/v4"; 2 + 3 + export const profileFormSchema = z.object({ 4 + fullName: z 5 + .string() 6 + .max(120, "Max 120 characters") 7 + .optional() 8 + .or(z.literal("")), 9 + headline: z 10 + .string() 11 + .max(120, "Max 120 characters") 12 + .optional() 13 + .or(z.literal("")), 14 + phone: z.string().max(30).optional().or(z.literal("")), 15 + address: z.string().max(200).optional().or(z.literal("")), 16 + postalCode: z.string().max(20).optional().or(z.literal("")), 17 + city: z.string().max(100).optional().or(z.literal("")), 18 + country: z.string().max(100).optional().or(z.literal("")), 19 + website: z 20 + .string() 21 + .url("Must be a valid URL") 22 + .max(200) 23 + .optional() 24 + .or(z.literal("")), 25 + linkedInUrl: z 26 + .string() 27 + .url("Must be a valid URL") 28 + .max(200) 29 + .optional() 30 + .or(z.literal("")), 31 + summary: z 32 + .string() 33 + .max(2000, "Max 2000 characters") 34 + .optional() 35 + .or(z.literal("")), 36 + }); 37 + 38 + export type ProfileFormData = z.infer<typeof profileFormSchema>;
+145
apps/client/src/features/user-settings/components/AddAiProviderForm.tsx
··· 1 + import { Button, Checkbox, Select, TextInput, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { useAddAiProviderMutation } from "@/generated/graphql"; 5 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 6 + 7 + interface Props { 8 + onSuccess?: () => void; 9 + onCancel?: () => void; 10 + providerCount: number; 11 + } 12 + 13 + const PROVIDER_OPTIONS = [ 14 + { value: "anthropic", label: "Anthropic" }, 15 + { value: "openai", label: "OpenAI" }, 16 + ]; 17 + 18 + export const AddAiProviderForm = ({ 19 + onSuccess, 20 + onCancel, 21 + providerCount, 22 + }: Props) => { 23 + const { showSuccess, showError } = useToast(); 24 + const queryClient = useQueryClient(); 25 + const addMutation = useAddAiProviderMutation(); 26 + 27 + const isFirstProvider = providerCount === 0; 28 + 29 + const [form, setForm] = useState({ 30 + label: "", 31 + providerType: "anthropic", 32 + apiKey: "", 33 + model: "", 34 + baseUrl: "", 35 + setActive: true, 36 + }); 37 + 38 + const handleSubmit = async (e: React.FormEvent) => { 39 + e.preventDefault(); 40 + 41 + if (!(form.label.trim() && form.apiKey.trim())) return; 42 + 43 + try { 44 + await addMutation.mutateAsync({ 45 + label: form.label.trim(), 46 + providerType: form.providerType, 47 + apiKey: form.apiKey, 48 + setActive: isFirstProvider || form.setActive, 49 + ...(form.model.trim() && { model: form.model.trim() }), 50 + ...(form.baseUrl.trim() && { baseUrl: form.baseUrl.trim() }), 51 + }); 52 + await Promise.all([ 53 + queryClient.invalidateQueries({ queryKey: ["MyAiProviders"] }), 54 + queryClient.invalidateQueries({ queryKey: ["MyAiSettings"] }), 55 + ]); 56 + showSuccess("AI provider added"); 57 + setForm({ 58 + label: "", 59 + providerType: "anthropic", 60 + apiKey: "", 61 + model: "", 62 + baseUrl: "", 63 + setActive: true, 64 + }); 65 + onSuccess?.(); 66 + } catch (err) { 67 + showError(extractGraphQLErrorMessage(err) || "Failed to add AI provider"); 68 + } 69 + }; 70 + 71 + return ( 72 + <form onSubmit={handleSubmit} className="space-y-4"> 73 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 74 + <TextInput 75 + label="Label" 76 + value={form.label} 77 + onChange={(value: string) => setForm((f) => ({ ...f, label: value }))} 78 + placeholder="e.g., Work OpenAI" 79 + required 80 + /> 81 + 82 + <Select 83 + label="Provider" 84 + value={form.providerType} 85 + onChange={(value: string) => 86 + setForm((f) => ({ ...f, providerType: value })) 87 + } 88 + options={PROVIDER_OPTIONS} 89 + /> 90 + </div> 91 + 92 + <TextInput 93 + label="API Key" 94 + value={form.apiKey} 95 + onChange={(value: string) => setForm((f) => ({ ...f, apiKey: value }))} 96 + placeholder="sk-..." 97 + required 98 + /> 99 + 100 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 101 + <TextInput 102 + label="Model (optional)" 103 + value={form.model} 104 + onChange={(value: string) => setForm((f) => ({ ...f, model: value }))} 105 + placeholder={ 106 + form.providerType === "anthropic" 107 + ? "claude-sonnet-4-5-20250929" 108 + : "gpt-4o-mini" 109 + } 110 + /> 111 + 112 + <TextInput 113 + label="Base URL (optional)" 114 + value={form.baseUrl} 115 + onChange={(value: string) => 116 + setForm((f) => ({ ...f, baseUrl: value })) 117 + } 118 + placeholder={ 119 + form.providerType === "anthropic" 120 + ? "https://api.anthropic.com" 121 + : "https://api.openai.com" 122 + } 123 + /> 124 + </div> 125 + 126 + <Checkbox 127 + label="Set as active provider" 128 + checked={isFirstProvider || form.setActive} 129 + onChange={(checked) => setForm((f) => ({ ...f, setActive: checked }))} 130 + disabled={isFirstProvider} 131 + /> 132 + 133 + <div className="flex gap-3"> 134 + <Button type="submit" disabled={addMutation.isPending}> 135 + {addMutation.isPending ? "Validating..." : "Validate & Save"} 136 + </Button> 137 + {onCancel && ( 138 + <Button type="button" variant="ghost" onClick={onCancel}> 139 + Cancel 140 + </Button> 141 + )} 142 + </div> 143 + </form> 144 + ); 145 + };
+90
apps/client/src/features/user-settings/components/AiPreferenceSelector.tsx
··· 1 + import { Card } from "@cv/ui"; 2 + import type { AiPreference } from "@/generated/graphql"; 3 + 4 + interface Props { 5 + value: AiPreference; 6 + onChange: (preference: AiPreference) => void; 7 + platformAvailable: boolean; 8 + } 9 + 10 + const options: { 11 + id: AiPreference; 12 + title: string; 13 + description: string; 14 + disabledReason?: string; 15 + }[] = [ 16 + { 17 + id: "NO_AI" as AiPreference, 18 + title: "I don't want to use AI", 19 + description: "Manual-only workflows. You can fill in your CV data by hand.", 20 + }, 21 + { 22 + id: "PLATFORM" as AiPreference, 23 + title: "I'm fine with AI", 24 + description: 25 + "Use the platform's built-in AI to parse your CV and extract data automatically.", 26 + }, 27 + { 28 + id: "BYOK" as AiPreference, 29 + title: "I'll bring my own AI key", 30 + description: 31 + "Use your own API key from OpenAI or Anthropic. Full control over your provider.", 32 + }, 33 + ]; 34 + 35 + export const AiPreferenceSelector = ({ 36 + value, 37 + onChange, 38 + platformAvailable, 39 + }: Props) => ( 40 + <div className="space-y-3"> 41 + {options.map((option) => { 42 + const isDisabled = 43 + option.id === ("PLATFORM" as AiPreference) && !platformAvailable; 44 + const isSelected = value === option.id; 45 + 46 + return ( 47 + <Card 48 + key={option.id} 49 + className={`p-4 cursor-pointer transition-all ${ 50 + isDisabled 51 + ? "opacity-50 cursor-not-allowed" 52 + : isSelected 53 + ? "ring-2 ring-ctp-blue border-ctp-blue" 54 + : "hover:border-ctp-overlay1" 55 + }`} 56 + onClick={() => !isDisabled && onChange(option.id)} 57 + > 58 + <div className="flex items-start gap-3"> 59 + <div 60 + className={`mt-0.5 h-4 w-4 rounded-full border-2 flex-shrink-0 ${ 61 + isSelected 62 + ? "border-ctp-blue bg-ctp-blue" 63 + : "border-ctp-overlay1" 64 + }`} 65 + > 66 + {isSelected && ( 67 + <div className="h-full w-full flex items-center justify-center"> 68 + <div className="h-1.5 w-1.5 rounded-full bg-ctp-base" /> 69 + </div> 70 + )} 71 + </div> 72 + <div> 73 + <h3 className="text-sm font-medium text-ctp-text"> 74 + {option.title} 75 + </h3> 76 + <p className="text-sm text-ctp-subtext0 mt-0.5"> 77 + {option.description} 78 + </p> 79 + {isDisabled && ( 80 + <p className="text-xs text-ctp-yellow mt-1"> 81 + Platform AI is currently unavailable 82 + </p> 83 + )} 84 + </div> 85 + </div> 86 + </Card> 87 + ); 88 + })} 89 + </div> 90 + );
+82
apps/client/src/features/user-settings/components/AiPreferenceStep.tsx
··· 1 + import { Button, Placeholder, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { 5 + type AiPreference, 6 + usePlatformAiAvailableQuery, 7 + useUpdateAiPreferenceMutation, 8 + } from "@/generated/graphql"; 9 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 10 + import { AddAiProviderForm } from "./AddAiProviderForm"; 11 + import { AiPreferenceSelector } from "./AiPreferenceSelector"; 12 + 13 + interface Props { 14 + onComplete: (preference: AiPreference) => void; 15 + } 16 + 17 + export const AiPreferenceStep = ({ onComplete }: Props) => { 18 + const { showError } = useToast(); 19 + const queryClient = useQueryClient(); 20 + const { data: platformData, isLoading } = usePlatformAiAvailableQuery(); 21 + const updatePreferenceMutation = useUpdateAiPreferenceMutation(); 22 + 23 + const [selected, setSelected] = useState<AiPreference>( 24 + "NO_AI" as AiPreference, 25 + ); 26 + const [byokProviderAdded, setByokProviderAdded] = useState(false); 27 + 28 + const platformAvailable = 29 + platformData?.platformAiAvailable?.available ?? false; 30 + 31 + const isByok = selected === ("BYOK" as AiPreference); 32 + const canContinue = !isByok || byokProviderAdded; 33 + 34 + const handleContinue = async () => { 35 + try { 36 + await updatePreferenceMutation.mutateAsync({ preference: selected }); 37 + await queryClient.invalidateQueries({ queryKey: ["MyAiSettings"] }); 38 + onComplete(selected); 39 + } catch (err) { 40 + showError(extractGraphQLErrorMessage(err) || "Failed to save preference"); 41 + } 42 + }; 43 + 44 + if (isLoading) { 45 + return <Placeholder variant="loading" message="Loading..." />; 46 + } 47 + 48 + return ( 49 + <div className="mt-8 space-y-6"> 50 + <AiPreferenceSelector 51 + value={selected} 52 + onChange={(pref) => { 53 + setSelected(pref); 54 + setByokProviderAdded(false); 55 + }} 56 + platformAvailable={platformAvailable} 57 + /> 58 + 59 + {isByok && !byokProviderAdded && ( 60 + <div className="rounded-lg border border-ctp-surface1 p-4"> 61 + <h3 className="text-sm font-medium text-ctp-text mb-3"> 62 + Add your first AI provider 63 + </h3> 64 + <AddAiProviderForm providerCount={0} onSuccess={() => setByokProviderAdded(true)} /> 65 + </div> 66 + )} 67 + 68 + {isByok && byokProviderAdded && ( 69 + <p className="text-sm text-ctp-green"> 70 + Provider added. You can manage your providers in profile settings. 71 + </p> 72 + )} 73 + 74 + <Button 75 + onClick={handleContinue} 76 + disabled={!canContinue || updatePreferenceMutation.isPending} 77 + > 78 + {updatePreferenceMutation.isPending ? "Saving..." : "Continue"} 79 + </Button> 80 + </div> 81 + ); 82 + };
+164
apps/client/src/features/user-settings/components/AiProviderList.tsx
··· 1 + import { Button, useConfirmationModal, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { 4 + useRemoveAiProviderMutation, 5 + useSetActiveAiProviderMutation, 6 + } from "@/generated/graphql"; 7 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 8 + 9 + interface Provider { 10 + id: string; 11 + label: string; 12 + providerType: string; 13 + model?: string | null | undefined; 14 + baseUrl?: string | null | undefined; 15 + maskedApiKey: string; 16 + createdAt: string; 17 + } 18 + 19 + interface Props { 20 + providers: Provider[]; 21 + activeProviderId: string | null; 22 + onReplaceKey: (providerId: string) => void; 23 + onEdit: (providerId: string) => void; 24 + } 25 + 26 + export const AiProviderList = ({ 27 + providers, 28 + activeProviderId, 29 + onReplaceKey, 30 + onEdit, 31 + }: Props) => { 32 + const { showSuccess, showError } = useToast(); 33 + const { showConfirmation } = useConfirmationModal(); 34 + const queryClient = useQueryClient(); 35 + const setActiveMutation = useSetActiveAiProviderMutation(); 36 + const removeMutation = useRemoveAiProviderMutation(); 37 + 38 + const handleSetActive = async (providerId: string) => { 39 + try { 40 + await setActiveMutation.mutateAsync({ providerId }); 41 + await queryClient.invalidateQueries({ queryKey: ["MyAiSettings"] }); 42 + showSuccess("Active provider updated"); 43 + } catch (err) { 44 + showError( 45 + extractGraphQLErrorMessage(err) || "Failed to set active provider", 46 + ); 47 + } 48 + }; 49 + 50 + const handleRemove = async (provider: Provider) => { 51 + const confirmed = await showConfirmation({ 52 + title: "Remove AI Provider", 53 + message: `Remove "${provider.label}" (${provider.maskedApiKey})? This cannot be undone.`, 54 + confirmText: "Remove", 55 + cancelText: "Cancel", 56 + variant: "danger", 57 + onConfirm: () => {}, 58 + }); 59 + 60 + if (!confirmed) return; 61 + 62 + try { 63 + const result = await removeMutation.mutateAsync({ 64 + providerId: provider.id, 65 + }); 66 + await Promise.all([ 67 + queryClient.invalidateQueries({ queryKey: ["MyAiProviders"] }), 68 + queryClient.invalidateQueries({ queryKey: ["MyAiSettings"] }), 69 + ]); 70 + 71 + const removal = result.removeAiProvider; 72 + if (removal?.wasActive && removal.newPreference === "PLATFORM") { 73 + showSuccess( 74 + "Provider removed. Switched back to platform AI since no BYOK keys remain.", 75 + ); 76 + } else if (removal?.wasActive) { 77 + showSuccess( 78 + "Provider removed. Another key has been set as active.", 79 + ); 80 + } else { 81 + showSuccess("Provider removed"); 82 + } 83 + } catch (err) { 84 + showError(extractGraphQLErrorMessage(err) || "Failed to remove provider"); 85 + } 86 + }; 87 + 88 + if (providers.length === 0) { 89 + return ( 90 + <p className="text-sm text-ctp-subtext0 py-4"> 91 + No AI providers configured. Add one below. 92 + </p> 93 + ); 94 + } 95 + 96 + return ( 97 + <div className="space-y-3"> 98 + {providers.map((provider) => { 99 + const isActive = provider.id === activeProviderId; 100 + return ( 101 + <div 102 + key={provider.id} 103 + className={`flex items-center justify-between rounded-lg border p-3 ${ 104 + isActive ? "border-ctp-blue bg-ctp-blue/5" : "border-ctp-surface1" 105 + }`} 106 + > 107 + <div className="min-w-0 flex-1"> 108 + <div className="flex items-center gap-2"> 109 + <span className="text-sm font-medium text-ctp-text truncate"> 110 + {provider.label} 111 + </span> 112 + <span className="text-xs rounded bg-ctp-surface1 px-1.5 py-0.5 text-ctp-subtext0"> 113 + {provider.providerType} 114 + </span> 115 + {isActive && ( 116 + <span className="text-xs rounded bg-ctp-blue/20 px-1.5 py-0.5 text-ctp-blue font-medium"> 117 + Active 118 + </span> 119 + )} 120 + </div> 121 + <div className="flex items-center gap-3 mt-1 text-xs text-ctp-subtext0"> 122 + <span>Key: {provider.maskedApiKey}</span> 123 + {provider.model && <span>Model: {provider.model}</span>} 124 + </div> 125 + </div> 126 + 127 + <div className="flex items-center gap-1 ml-3 flex-shrink-0"> 128 + {!isActive && ( 129 + <Button 130 + variant="ghost" 131 + onClick={() => handleSetActive(provider.id)} 132 + className="text-xs px-2 py-1 h-auto" 133 + > 134 + Set Active 135 + </Button> 136 + )} 137 + <Button 138 + variant="ghost" 139 + onClick={() => onEdit(provider.id)} 140 + className="text-xs px-2 py-1 h-auto" 141 + > 142 + Edit 143 + </Button> 144 + <Button 145 + variant="ghost" 146 + onClick={() => onReplaceKey(provider.id)} 147 + className="text-xs px-2 py-1 h-auto" 148 + > 149 + Replace Key 150 + </Button> 151 + <Button 152 + variant="ghost" 153 + onClick={() => handleRemove(provider)} 154 + className="text-xs px-2 py-1 h-auto text-ctp-red" 155 + > 156 + Remove 157 + </Button> 158 + </div> 159 + </div> 160 + ); 161 + })} 162 + </div> 163 + ); 164 + };
+118
apps/client/src/features/user-settings/components/AiSettingsSection.tsx
··· 1 + import { Button, Placeholder, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { 5 + type AiPreference, 6 + useMyAiProvidersQuery, 7 + useMyAiSettingsQuery, 8 + usePlatformAiAvailableQuery, 9 + useUpdateAiPreferenceMutation, 10 + } from "@/generated/graphql"; 11 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 12 + import { AddAiProviderForm } from "./AddAiProviderForm"; 13 + import { AiPreferenceSelector } from "./AiPreferenceSelector"; 14 + import { AiProviderList } from "./AiProviderList"; 15 + import { EditProviderDialog } from "./EditProviderDialog"; 16 + import { ReplaceKeyDialog } from "./ReplaceKeyDialog"; 17 + 18 + export const AiSettingsSection = () => { 19 + const { showSuccess, showError } = useToast(); 20 + const queryClient = useQueryClient(); 21 + 22 + const { data: settingsData, isLoading: settingsLoading } = 23 + useMyAiSettingsQuery(); 24 + const { data: providersData, isLoading: providersLoading } = 25 + useMyAiProvidersQuery(); 26 + const { data: platformData } = usePlatformAiAvailableQuery(); 27 + 28 + const updatePreferenceMutation = useUpdateAiPreferenceMutation(); 29 + 30 + const [showAddForm, setShowAddForm] = useState(false); 31 + const [replaceKeyProviderId, setReplaceKeyProviderId] = useState< 32 + string | null 33 + >(null); 34 + const [editProviderId, setEditProviderId] = useState<string | null>(null); 35 + 36 + const handlePreferenceChange = async (preference: AiPreference) => { 37 + try { 38 + await updatePreferenceMutation.mutateAsync({ preference }); 39 + await queryClient.invalidateQueries({ queryKey: ["MyAiSettings"] }); 40 + showSuccess("AI preference updated"); 41 + } catch (err) { 42 + showError( 43 + extractGraphQLErrorMessage(err) || "Failed to update preference", 44 + ); 45 + } 46 + }; 47 + 48 + if (settingsLoading || providersLoading) { 49 + return <Placeholder variant="loading" message="Loading AI settings..." />; 50 + } 51 + 52 + const settings = settingsData?.myAiSettings; 53 + const providers = providersData?.myAiProviders ?? []; 54 + const platformAvailable = 55 + platformData?.platformAiAvailable?.available ?? false; 56 + 57 + if (!settings) return null; 58 + 59 + const isByok = settings.aiPreference === ("BYOK" as AiPreference); 60 + 61 + return ( 62 + <div className="space-y-6"> 63 + <div> 64 + <h3 className="text-lg font-medium text-ctp-text mb-3"> 65 + AI Preference 66 + </h3> 67 + <AiPreferenceSelector 68 + value={settings.aiPreference} 69 + onChange={handlePreferenceChange} 70 + platformAvailable={platformAvailable} 71 + /> 72 + </div> 73 + 74 + {isByok && ( 75 + <div> 76 + <h3 className="text-lg font-medium text-ctp-text mb-3"> 77 + Your AI Providers 78 + </h3> 79 + <AiProviderList 80 + providers={providers} 81 + activeProviderId={settings.activeProviderId ?? null} 82 + onReplaceKey={setReplaceKeyProviderId} 83 + onEdit={setEditProviderId} 84 + /> 85 + 86 + <div className="mt-4"> 87 + {showAddForm ? ( 88 + <AddAiProviderForm 89 + onSuccess={() => setShowAddForm(false)} 90 + onCancel={() => setShowAddForm(false)} 91 + providerCount={providers.length} 92 + /> 93 + ) : ( 94 + <Button variant="outline" onClick={() => setShowAddForm(true)}> 95 + Add Provider 96 + </Button> 97 + )} 98 + </div> 99 + </div> 100 + )} 101 + 102 + {replaceKeyProviderId && ( 103 + <ReplaceKeyDialog 104 + providerId={replaceKeyProviderId} 105 + onClose={() => setReplaceKeyProviderId(null)} 106 + /> 107 + )} 108 + 109 + {editProviderId && ( 110 + <EditProviderDialog 111 + providerId={editProviderId} 112 + providers={providers} 113 + onClose={() => setEditProviderId(null)} 114 + /> 115 + )} 116 + </div> 117 + ); 118 + };
+116
apps/client/src/features/user-settings/components/EditProviderDialog.tsx
··· 1 + import { Button, TextInput, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { useUpdateAiProviderMutation } from "@/generated/graphql"; 5 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 6 + 7 + interface Provider { 8 + id: string; 9 + label: string; 10 + providerType: string; 11 + model?: string | null | undefined; 12 + baseUrl?: string | null | undefined; 13 + } 14 + 15 + interface Props { 16 + providerId: string; 17 + providers: Provider[]; 18 + onClose: () => void; 19 + } 20 + 21 + export const EditProviderDialog = ({ 22 + providerId, 23 + providers, 24 + onClose, 25 + }: Props) => { 26 + const provider = providers.find((p) => p.id === providerId); 27 + const { showSuccess, showError } = useToast(); 28 + const queryClient = useQueryClient(); 29 + const mutation = useUpdateAiProviderMutation(); 30 + 31 + const [form, setForm] = useState({ 32 + label: provider?.label ?? "", 33 + model: provider?.model ?? "", 34 + baseUrl: provider?.baseUrl ?? "", 35 + }); 36 + 37 + if (!provider) return null; 38 + 39 + const handleSubmit = async (e: React.FormEvent) => { 40 + e.preventDefault(); 41 + 42 + try { 43 + await mutation.mutateAsync({ 44 + providerId, 45 + ...(form.label.trim() !== provider.label && { 46 + label: form.label.trim(), 47 + }), 48 + ...(form.model.trim() !== (provider.model ?? "") && { 49 + model: form.model.trim() || null, 50 + }), 51 + ...(form.baseUrl.trim() !== (provider.baseUrl ?? "") && { 52 + baseUrl: form.baseUrl.trim() || null, 53 + }), 54 + }); 55 + await queryClient.invalidateQueries({ queryKey: ["MyAiProviders"] }); 56 + showSuccess("Provider updated"); 57 + onClose(); 58 + } catch (err) { 59 + showError(extractGraphQLErrorMessage(err) || "Failed to update provider"); 60 + } 61 + }; 62 + 63 + return ( 64 + <div className="fixed inset-0 z-50 flex items-center justify-center bg-ctp-crust/70"> 65 + <div className="w-full max-w-md rounded-lg bg-ctp-base p-6 shadow-lg border border-ctp-surface1"> 66 + <h3 className="text-lg font-medium text-ctp-text mb-4"> 67 + Edit Provider 68 + </h3> 69 + <form onSubmit={handleSubmit} className="space-y-4"> 70 + <TextInput 71 + label="Label" 72 + value={form.label} 73 + onChange={(value: string) => 74 + setForm((f) => ({ ...f, label: value })) 75 + } 76 + /> 77 + 78 + <TextInput 79 + label="Model (optional)" 80 + value={form.model} 81 + onChange={(value: string) => 82 + setForm((f) => ({ ...f, model: value })) 83 + } 84 + placeholder={ 85 + provider.providerType === "anthropic" 86 + ? "claude-sonnet-4-5-20250929" 87 + : "gpt-4o-mini" 88 + } 89 + /> 90 + 91 + <TextInput 92 + label="Base URL (optional)" 93 + value={form.baseUrl} 94 + onChange={(value: string) => 95 + setForm((f) => ({ ...f, baseUrl: value })) 96 + } 97 + placeholder={ 98 + provider.providerType === "anthropic" 99 + ? "https://api.anthropic.com" 100 + : "https://api.openai.com" 101 + } 102 + /> 103 + 104 + <div className="flex gap-3"> 105 + <Button type="submit" disabled={mutation.isPending}> 106 + {mutation.isPending ? "Saving..." : "Save Changes"} 107 + </Button> 108 + <Button type="button" variant="ghost" onClick={onClose}> 109 + Cancel 110 + </Button> 111 + </div> 112 + </form> 113 + </div> 114 + </div> 115 + ); 116 + };
+114
apps/client/src/features/user-settings/components/ImportedFilesSection.tsx
··· 1 + import { Button, useConfirmationModal, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { 4 + useDeleteUserFileMutation, 5 + useMyUserFilesQuery, 6 + } from "@/generated/graphql"; 7 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 8 + 9 + const SOURCE_LABELS: Record<string, string> = { 10 + "file-upload": "File Upload", 11 + }; 12 + 13 + const STATUS_STYLES: Record<string, { dot: string; text: string }> = { 14 + completed: { dot: "bg-ctp-green", text: "Completed" }, 15 + failed: { dot: "bg-ctp-red", text: "Failed" }, 16 + processing: { dot: "bg-ctp-yellow animate-pulse", text: "Processing" }, 17 + pending: { dot: "bg-ctp-overlay1", text: "Pending" }, 18 + }; 19 + 20 + const formatFileSize = (bytes: number): string => { 21 + if (bytes < 1024) return `${bytes} B`; 22 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 23 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 24 + }; 25 + 26 + const formatDate = (dateStr: string): string => 27 + new Date(dateStr).toLocaleDateString(undefined, { 28 + year: "numeric", 29 + month: "short", 30 + day: "numeric", 31 + hour: "2-digit", 32 + minute: "2-digit", 33 + }); 34 + 35 + export const ImportedFilesSection = () => { 36 + const { showSuccess, showError } = useToast(); 37 + const { showConfirmation } = useConfirmationModal(); 38 + const queryClient = useQueryClient(); 39 + const { data, isLoading } = useMyUserFilesQuery(); 40 + const deleteMutation = useDeleteUserFileMutation(); 41 + 42 + const files = data?.myUserFiles ?? []; 43 + 44 + const handleDelete = async (fileId: string, fileName: string) => { 45 + const confirmed = await showConfirmation({ 46 + title: "Delete Imported File", 47 + message: `Are you sure you want to delete "${fileName}"? This will remove the import record but won't affect data already saved to your profile.`, 48 + confirmText: "Delete", 49 + cancelText: "Cancel", 50 + variant: "danger", 51 + onConfirm: () => {}, 52 + }); 53 + 54 + if (!confirmed) return; 55 + 56 + try { 57 + await deleteMutation.mutateAsync({ id: fileId }); 58 + await queryClient.invalidateQueries({ queryKey: ["MyUserFiles"] }); 59 + showSuccess("File deleted"); 60 + } catch (err) { 61 + showError(extractGraphQLErrorMessage(err) || "Failed to delete file"); 62 + } 63 + }; 64 + 65 + if (isLoading) { 66 + return <p className="text-sm text-ctp-subtext0">Loading files...</p>; 67 + } 68 + 69 + if (files.length === 0) { 70 + return ( 71 + <p className="text-sm text-ctp-subtext0">No imported files yet.</p> 72 + ); 73 + } 74 + 75 + return ( 76 + <div className="space-y-3"> 77 + {files.map((file) => { 78 + const status = STATUS_STYLES[file.status] ?? { dot: "bg-ctp-overlay1", text: "Unknown" }; 79 + const sourceLabel = SOURCE_LABELS[file.source] ?? file.source; 80 + 81 + return ( 82 + <div 83 + key={file.id} 84 + className="flex items-center justify-between rounded-lg border border-ctp-surface0 bg-ctp-surface0/30 px-4 py-3" 85 + > 86 + <div className="flex items-center gap-3 min-w-0"> 87 + <div className={`h-2.5 w-2.5 shrink-0 rounded-full ${status.dot}`} /> 88 + <div className="min-w-0"> 89 + <p className="truncate text-sm font-medium text-ctp-text"> 90 + {file.fileName} 91 + </p> 92 + <p className="text-xs text-ctp-subtext0"> 93 + {sourceLabel} &middot; {formatFileSize(file.sizeBytes)} &middot;{" "} 94 + {formatDate(file.createdAt)} &middot;{" "} 95 + <span className={file.status === "failed" ? "text-ctp-red" : ""}> 96 + {status.text} 97 + </span> 98 + </p> 99 + </div> 100 + </div> 101 + <Button 102 + variant="ghost" 103 + size="sm" 104 + onClick={() => handleDelete(file.id, file.fileName)} 105 + disabled={deleteMutation.isPending} 106 + > 107 + Delete 108 + </Button> 109 + </div> 110 + ); 111 + })} 112 + </div> 113 + ); 114 + };
+58
apps/client/src/features/user-settings/components/ReplaceKeyDialog.tsx
··· 1 + import { Button, TextInput, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { useReplaceAiProviderKeyMutation } from "@/generated/graphql"; 5 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 6 + 7 + interface Props { 8 + providerId: string; 9 + onClose: () => void; 10 + } 11 + 12 + export const ReplaceKeyDialog = ({ providerId, onClose }: Props) => { 13 + const { showSuccess, showError } = useToast(); 14 + const queryClient = useQueryClient(); 15 + const mutation = useReplaceAiProviderKeyMutation(); 16 + const [newApiKey, setNewApiKey] = useState(""); 17 + 18 + const handleSubmit = async (e: React.FormEvent) => { 19 + e.preventDefault(); 20 + if (!newApiKey.trim()) return; 21 + 22 + try { 23 + await mutation.mutateAsync({ providerId, newApiKey }); 24 + await queryClient.invalidateQueries({ queryKey: ["MyAiProviders"] }); 25 + showSuccess("API key replaced"); 26 + onClose(); 27 + } catch (err) { 28 + showError(extractGraphQLErrorMessage(err) || "Failed to replace key"); 29 + } 30 + }; 31 + 32 + return ( 33 + <div className="fixed inset-0 z-50 flex items-center justify-center bg-ctp-crust/70"> 34 + <div className="w-full max-w-md rounded-lg bg-ctp-base p-6 shadow-lg border border-ctp-surface1"> 35 + <h3 className="text-lg font-medium text-ctp-text mb-4"> 36 + Replace API Key 37 + </h3> 38 + <form onSubmit={handleSubmit} className="space-y-4"> 39 + <TextInput 40 + label="New API Key" 41 + value={newApiKey} 42 + onChange={setNewApiKey} 43 + placeholder="sk-..." 44 + required 45 + /> 46 + <div className="flex gap-3"> 47 + <Button type="submit" disabled={mutation.isPending}> 48 + {mutation.isPending ? "Replacing..." : "Replace Key"} 49 + </Button> 50 + <Button type="button" variant="ghost" onClick={onClose}> 51 + Cancel 52 + </Button> 53 + </div> 54 + </form> 55 + </div> 56 + </div> 57 + ); 58 + };
+25
apps/client/src/features/user-settings/mutations/add-ai-provider.graphql
··· 1 + mutation AddAiProvider( 2 + $label: String! 3 + $providerType: String! 4 + $apiKey: String! 5 + $model: String 6 + $baseUrl: String 7 + $setActive: Boolean 8 + ) { 9 + addAiProvider( 10 + label: $label 11 + providerType: $providerType 12 + apiKey: $apiKey 13 + model: $model 14 + baseUrl: $baseUrl 15 + setActive: $setActive 16 + ) { 17 + id 18 + label 19 + providerType 20 + model 21 + baseUrl 22 + maskedApiKey 23 + createdAt 24 + } 25 + }
+3
apps/client/src/features/user-settings/mutations/delete-user-file.graphql
··· 1 + mutation DeleteUserFile($id: String!) { 2 + deleteUserFile(id: $id) 3 + }
+7
apps/client/src/features/user-settings/mutations/remove-ai-provider.graphql
··· 1 + mutation RemoveAiProvider($providerId: String!) { 2 + removeAiProvider(providerId: $providerId) { 3 + success 4 + wasActive 5 + newPreference 6 + } 7 + }
+11
apps/client/src/features/user-settings/mutations/replace-ai-provider-key.graphql
··· 1 + mutation ReplaceAiProviderKey($providerId: String!, $newApiKey: String!) { 2 + replaceAiProviderKey(providerId: $providerId, newApiKey: $newApiKey) { 3 + id 4 + label 5 + providerType 6 + model 7 + baseUrl 8 + maskedApiKey 9 + createdAt 10 + } 11 + }
+7
apps/client/src/features/user-settings/mutations/set-active-ai-provider.graphql
··· 1 + mutation SetActiveAiProvider($providerId: String!) { 2 + setActiveAiProvider(providerId: $providerId) { 3 + id 4 + aiPreference 5 + activeProviderId 6 + } 7 + }
+7
apps/client/src/features/user-settings/mutations/update-ai-preference.graphql
··· 1 + mutation UpdateAiPreference($preference: AiPreference!) { 2 + updateAiPreference(preference: $preference) { 3 + id 4 + aiPreference 5 + activeProviderId 6 + } 7 + }
+21
apps/client/src/features/user-settings/mutations/update-ai-provider.graphql
··· 1 + mutation UpdateAiProvider( 2 + $providerId: String! 3 + $label: String 4 + $model: String 5 + $baseUrl: String 6 + ) { 7 + updateAiProvider( 8 + providerId: $providerId 9 + label: $label 10 + model: $model 11 + baseUrl: $baseUrl 12 + ) { 13 + id 14 + label 15 + providerType 16 + model 17 + baseUrl 18 + maskedApiKey 19 + createdAt 20 + } 21 + }
+11
apps/client/src/features/user-settings/queries/my-ai-providers.graphql
··· 1 + query MyAiProviders { 2 + myAiProviders { 3 + id 4 + label 5 + providerType 6 + model 7 + baseUrl 8 + maskedApiKey 9 + createdAt 10 + } 11 + }
+7
apps/client/src/features/user-settings/queries/my-ai-settings.graphql
··· 1 + query MyAiSettings { 2 + myAiSettings { 3 + id 4 + aiPreference 5 + activeProviderId 6 + } 7 + }
+14
apps/client/src/features/user-settings/queries/my-user-files.graphql
··· 1 + query MyUserFiles { 2 + myUserFiles { 3 + id 4 + profileId 5 + fileName 6 + source 7 + status 8 + statusMessage 9 + sizeBytes 10 + isDuplicate 11 + createdAt 12 + updatedAt 13 + } 14 + }
+5
apps/client/src/features/user-settings/queries/platform-ai-available.graphql
··· 1 + query PlatformAiAvailable { 2 + platformAiAvailable { 3 + available 4 + } 5 + }
+14 -11
apps/client/src/layouts/AuthenticatedLayout.tsx
··· 2 2 import { Navigate, Outlet } from "react-router-dom"; 3 3 import { Navbar } from "@/components/Navbar"; 4 4 import { defaultNavLinks } from "@/components/navLinks"; 5 + import { ProfileProvider } from "@/contexts/ProfileProvider"; 5 6 import { useToken } from "@/contexts/TokenProvider"; 6 7 import { useMeMinimalQuery } from "@/generated/graphql"; 7 8 import { extractGraphQLErrorCode } from "@/utils/graphql-error"; ··· 15 16 refetchOnWindowFocus: false, 16 17 }, 17 18 ); 18 - const { logout, isAuthenticated } = useToken(); 19 + const { logout, isAuthenticated, isAdmin } = useToken(); 19 20 20 21 const isEmailVerified = data?.me?.emailVerifiedAt !== null; 21 22 const hasUserData = isAuthenticated && Boolean(data?.me); ··· 78 79 } 79 80 80 81 return ( 81 - <div className="min-h-screen bg-ctp-base"> 82 - <Navbar 83 - user={{ name: data?.me?.name ?? "" }} 84 - onLogout={handleLogout} 85 - links={defaultNavLinks} 86 - /> 87 - <main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> 88 - <Outlet /> 89 - </main> 90 - </div> 82 + <ProfileProvider> 83 + <div className="min-h-screen bg-ctp-base"> 84 + <Navbar 85 + user={{ name: data?.me?.name ?? "" }} 86 + onLogout={handleLogout} 87 + links={defaultNavLinks.filter((l) => !l.adminOnly || isAdmin)} 88 + /> 89 + <main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> 90 + <Outlet /> 91 + </main> 92 + </div> 93 + </ProfileProvider> 91 94 ); 92 95 };
+18 -49
apps/client/src/pages/DashboardPage.tsx
··· 1 1 import { ViewTransitionLink } from "@cv/routing"; 2 2 import { Button, FormattedDate, PageHeader, Placeholder } from "@cv/ui"; 3 - import { useEffect, useState } from "react"; 3 + import { useActiveProfileId } from "@/contexts/ProfileProvider"; 4 + import { OnboardingChecklist } from "@/features/onboarding/components/OnboardingChecklist"; 4 5 import { 5 6 useCvTemplatesQuery, 6 - useMeJobExperienceQuery, 7 7 useMeMinimalQuery, 8 8 useMyCVsQuery, 9 + useOnboardingStatusQuery, 9 10 } from "@/generated/graphql"; 10 11 11 12 export default function DashboardPage() { 12 - const [showOnboarding, setShowOnboarding] = useState(false); 13 - 13 + const profileId = useActiveProfileId(); 14 14 const { 15 15 data: userData, 16 16 isLoading: userLoading, 17 17 error: userError, 18 18 } = useMeMinimalQuery(); 19 - const { data: cvsData, isLoading: cvsLoading } = useMyCVsQuery({ first: 5 }); 19 + const { data: cvsData, isLoading: cvsLoading } = useMyCVsQuery({ profileId }); 20 20 const { data: templatesData, isLoading: templatesLoading } = 21 21 useCvTemplatesQuery({ first: 10 }); 22 - const { data: jobExperienceData } = useMeJobExperienceQuery(); 23 - 24 - useEffect(() => { 25 - const hasJobExperience = 26 - jobExperienceData?.me?.experience?.totalCount && 27 - jobExperienceData.me.experience.totalCount > 0; 28 - const isDismissed = localStorage.getItem("onboarding_dismissed") === "true"; 29 - 30 - if (!(hasJobExperience || isDismissed)) { 31 - setShowOnboarding(true); 32 - } 33 - }, [jobExperienceData]); 22 + const { data: onboardingData } = useOnboardingStatusQuery(); 34 23 35 24 if (userLoading || cvsLoading || templatesLoading) { 36 25 return ( ··· 51 40 } 52 41 53 42 const user = userData?.me; 54 - const cvs = cvsData?.me?.cvs?.edges || []; 55 - const totalCVs = cvsData?.me?.cvs?.totalCount || 0; 43 + const allCvs = 44 + cvsData?.profile?.cvs?.edges?.map((e) => e.node) ?? []; 45 + const totalCVs = cvsData?.profile?.cvs?.totalCount ?? 0; 56 46 const totalTemplates = templatesData?.cvTemplates?.totalCount || 0; 57 47 58 - const latestCV = cvs[0]?.node; 48 + const latestCV = allCvs[0]; 49 + 50 + const onboardingSteps = onboardingData?.onboardingStatus ?? []; 51 + const allComplete = onboardingSteps.every((s) => s.status === "COMPLETE"); 59 52 60 53 return ( 61 54 <div className="space-y-6"> ··· 64 57 description={`Welcome back, ${user?.name ?? "User"}! Here's your overview.`} 65 58 /> 66 59 67 - {showOnboarding && ( 68 - <div className="p-4 bg-ctp-blue/10 border border-ctp-blue rounded-lg"> 69 - <div className="flex justify-between items-start"> 70 - <div> 71 - <h3 className="font-semibold text-ctp-text mb-1"> 72 - 🚀 Get Started Faster 73 - </h3> 74 - <p className="text-sm text-ctp-subtext0 mb-3"> 75 - Use our AI-powered onboarding to quickly import your CV data. 76 - </p> 77 - <ViewTransitionLink to="/onboarding"> 78 - <Button size="sm">Start Onboarding</Button> 79 - </ViewTransitionLink> 80 - </div> 81 - <button 82 - type="button" 83 - onClick={() => { 84 - setShowOnboarding(false); 85 - localStorage.setItem("onboarding_dismissed", "true"); 86 - }} 87 - className="text-ctp-subtext0 hover:text-ctp-text text-xl leading-none" 88 - > 89 - 90 - </button> 91 - </div> 92 - </div> 60 + {!allComplete && onboardingSteps.length > 0 && ( 61 + <OnboardingChecklist steps={onboardingSteps} /> 93 62 )} 94 63 95 64 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> ··· 144 113 </div> 145 114 </div> 146 115 147 - {cvs.length > 0 && ( 116 + {allCvs.length > 0 && ( 148 117 <div className="mt-8"> 149 118 <h2 className="mb-4 text-xl font-semibold text-ctp-text"> 150 119 Recent CVs 151 120 </h2> 152 121 <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 153 - {cvs.slice(0, 6).map(({ node: cv }) => ( 122 + {allCvs.slice(0, 6).map((cv) => ( 154 123 <ViewTransitionLink 155 124 key={cv.id} 156 125 to={`/cvs/${cv.id}`} ··· 168 137 </ViewTransitionLink> 169 138 ))} 170 139 </div> 171 - {cvs.length >= 5 && ( 140 + {totalCVs >= 5 && ( 172 141 <div className="mt-4"> 173 142 <ViewTransitionLink to="/cvs"> 174 143 <Button variant="outline">View All CVs</Button>
+112 -71
apps/client/src/pages/OnboardingPage.tsx
··· 1 - import { PageHeader } from "@cv/ui"; 2 - import { useState } from "react"; 1 + import { PageHeader, Placeholder, Progress } from "@cv/ui"; 2 + import type { ComponentType } from "react"; 3 3 import { useNavigate, useParams } from "react-router-dom"; 4 - import { FileUploadStep } from "@/features/onboarding/components/FileUploadStep"; 5 - import { OnboardingMethodSelector } from "@/features/onboarding/components/OnboardingMethodSelector"; 6 - import { ReviewStep } from "@/features/onboarding/components/ReviewStep"; 7 - import type { ParsedCVDataWithResolution } from "@/features/onboarding/schemas/draft.schema"; 4 + import { AiPreferenceOnboardingStep } from "@/features/onboarding/components/AiPreferenceOnboardingStep"; 5 + import { CareerHistoryStep } from "@/features/onboarding/components/CareerHistoryStep"; 6 + import { EducationStep } from "@/features/onboarding/components/EducationStep"; 7 + import { ImportStep } from "@/features/onboarding/components/ImportStep"; 8 + import { PersonalProfileStep } from "@/features/onboarding/components/PersonalProfileStep"; 9 + 10 + import { 11 + type OnboardingStepStatus, 12 + useOnboardingStatusQuery, 13 + } from "@/generated/graphql"; 14 + 15 + interface StepComponentProps { 16 + onComplete: () => void; 17 + onSkip: () => void; 18 + } 19 + 20 + const stepComponents: Record<string, ComponentType<StepComponentProps>> = { 21 + "ai-preference": AiPreferenceOnboardingStep, 22 + import: ImportStep, 23 + "personal-profile": PersonalProfileStep, 24 + "career-history": CareerHistoryStep, 25 + education: EducationStep, 26 + }; 27 + 28 + const stepLabels: Record<string, string> = { 29 + "ai-preference": "AI Setup", 30 + import: "Import", 31 + "personal-profile": "Profile", 32 + "career-history": "Experience", 33 + education: "Education", 34 + }; 35 + 36 + const statusToState = ( 37 + status: OnboardingStepStatus, 38 + isActive: boolean, 39 + isBlocked: boolean, 40 + ) => { 41 + if (isBlocked) return "disabled" as const; 42 + if (isActive) return "selected" as const; 43 + if (status === "COMPLETE") return "completed" as const; 44 + return "pending" as const; 45 + }; 8 46 9 47 export default function OnboardingPage() { 10 48 const { step: stepParam } = useParams<{ step?: string }>(); 11 49 const navigate = useNavigate(); 12 - const [parsedData, setParsedData] = 13 - useState<ParsedCVDataWithResolution | null>(null); 50 + const { data, isLoading, refetch } = useOnboardingStatusQuery(); 51 + 52 + if (isLoading) { 53 + return ( 54 + <div className="max-w-4xl mx-auto px-4 py-8"> 55 + <PageHeader title="Build Your CV" /> 56 + <Placeholder variant="loading" message="Loading..." /> 57 + </div> 58 + ); 59 + } 14 60 15 - const currentStep = stepParam || "select-method"; 61 + const steps = data?.onboardingStatus ?? []; 62 + const stepNames = steps.map((s) => s.name); 16 63 17 - const handleSelectMethod = (method: string) => { 18 - // Reset parsed data when selecting a new method 19 - setParsedData(null); 20 - navigate(`/onboarding/${method}`); 64 + const activeStepName = 65 + stepParam && stepNames.includes(stepParam) 66 + ? stepParam 67 + : (steps.find((s) => s.status !== "COMPLETE")?.name ?? stepNames[0]); 68 + 69 + const activeIndex = stepNames.indexOf(activeStepName ?? ""); 70 + 71 + const navigateToStep = (name: string) => { 72 + navigate(`/onboarding/${name}`); 21 73 }; 22 74 23 - const handleFileParsed = (data: ParsedCVDataWithResolution) => { 24 - setParsedData(data); 25 - navigate("/onboarding/review"); 75 + const handleStepChange = (index: number) => { 76 + const targetStep = steps[index]; 77 + if (!targetStep || targetStep.blockedBy.length > 0) return; 78 + navigateToStep(targetStep.name); 26 79 }; 27 80 28 - const handleComplete = () => { 29 - navigate("/"); 81 + const goToNext = () => { 82 + refetch(); 83 + const nextIncomplete = steps.find( 84 + (s, i) => i > activeIndex && s.status !== "COMPLETE", 85 + ); 86 + if (nextIncomplete) { 87 + navigateToStep(nextIncomplete.name); 88 + } else { 89 + navigate("/"); 90 + } 30 91 }; 31 92 32 - const renderStep = () => { 33 - switch (currentStep) { 34 - case "select-method": 35 - return <OnboardingMethodSelector onMethodSelect={handleSelectMethod} />; 93 + const StepComponent = activeStepName 94 + ? stepComponents[activeStepName] 95 + : undefined; 36 96 37 - case "file-upload": 38 - return ( 39 - <FileUploadStep 40 - onComplete={handleFileParsed} 41 - onBack={() => navigate("/onboarding")} 42 - /> 43 - ); 97 + return ( 98 + <div className="max-w-4xl mx-auto px-4 py-8"> 99 + <PageHeader 100 + title="Build Your CV" 101 + description="Complete these steps to set up your profile. You can always come back later." 102 + /> 44 103 45 - case "review": 46 - return parsedData ? ( 47 - <ReviewStep 48 - parsedData={parsedData} 49 - onComplete={handleComplete} 50 - onBack={() => navigate("/onboarding")} 51 - onDataChange={setParsedData} 52 - /> 104 + <Progress currentStep={activeIndex} onStepChange={handleStepChange}> 105 + {steps.map((step) => { 106 + const isActive = step.name === activeStepName; 107 + const isBlocked = step.blockedBy.length > 0; 108 + 109 + return ( 110 + <Progress.Step 111 + key={step.name} 112 + name={stepLabels[step.name] ?? step.name} 113 + state={statusToState(step.status, isActive, isBlocked)} 114 + disabled={isBlocked} 115 + /> 116 + ); 117 + })} 118 + </Progress> 119 + 120 + <div className="mt-8"> 121 + {StepComponent ? ( 122 + <StepComponent onComplete={goToNext} onSkip={goToNext} /> 53 123 ) : ( 54 - <> 55 - <p className="text-ctp-text"> 56 - No data to review. Please start over. 57 - </p> 58 - <button 59 - type="button" 60 - onClick={() => navigate("/onboarding")} 61 - className="mt-4 text-ctp-blue hover:underline" 62 - > 63 - Back to Methods 64 - </button> 65 - </> 66 - ); 67 - 68 - default: 69 - return ( 70 - <> 71 - <p className="text-ctp-text">Unknown step: {currentStep}</p> 124 + <div className="text-center text-ctp-subtext0"> 125 + <p>Unknown step: {activeStepName}</p> 72 126 <button 73 127 type="button" 74 128 onClick={() => navigate("/onboarding")} ··· 76 130 > 77 131 Back to Start 78 132 </button> 79 - </> 80 - ); 81 - } 82 - }; 83 - 84 - return ( 85 - <div className="max-w-4xl mx-auto px-4 py-8"> 86 - <PageHeader 87 - title="Build Your CV" 88 - description={ 89 - currentStep === "select-method" 90 - ? "Choose how you'd like to get started" 91 - : `Step: ${currentStep}` 92 - } 93 - /> 94 - {renderStep()} 133 + </div> 134 + )} 135 + </div> 95 136 </div> 96 137 ); 97 138 }
+29 -203
apps/client/src/pages/ProfilePage.tsx
··· 1 - import { Button, PageHeader, Placeholder, TextInput, useToast } from "@cv/ui"; 2 - import { useState } from "react"; 3 - import { useNavigate } from "react-router-dom"; 4 - import { ActiveSessions } from "@/components/ActiveSessions"; 5 - import { ChangePasswordModal } from "@/components/ChangePasswordModal"; 6 - import { DeleteAccountModal } from "@/components/DeleteAccountModal"; 7 - import { useToken } from "@/contexts/TokenProvider"; 8 - import { 9 - useChangePasswordMutation, 10 - useDeleteAccountMutation, 11 - useMeMinimalQuery, 12 - } from "@/generated/graphql"; 13 - import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 1 + import { PageHeader, Placeholder } from "@cv/ui"; 2 + import { useActiveProfileId, useProfile } from "@/contexts/ProfileProvider"; 3 + import { PersonalProfileForm } from "@/features/profile/components/PersonalProfileForm"; 4 + import { ImportedFilesSection } from "@/features/user-settings/components/ImportedFilesSection"; 14 5 15 6 export default function ProfilePage() { 16 - const { data, isLoading, error } = useMeMinimalQuery(); 17 - const { logout } = useToken(); 18 - const navigate = useNavigate(); 19 - const { showSuccess, showError } = useToast(); 20 - const changePasswordMutation = useChangePasswordMutation(); 21 - const deleteAccountMutation = useDeleteAccountMutation(); 22 - 23 - const [isEditing, setIsEditing] = useState(false); 24 - const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); 25 - const [isDeleteAccountOpen, setIsDeleteAccountOpen] = useState(false); 26 - const [formData, setFormData] = useState({ 27 - name: "", 28 - }); 29 - 30 - const handleEdit = () => { 31 - if (data?.me) { 32 - setFormData({ 33 - name: data.me.name, 34 - }); 35 - } 36 - setIsEditing(true); 37 - }; 38 - 39 - const handleSave = () => { 40 - // TODO: Implement profile update 41 - setIsEditing(false); 42 - }; 43 - 44 - const handleCancel = () => { 45 - setIsEditing(false); 46 - }; 47 - 48 - const handleChangePassword = async ( 49 - currentPassword: string, 50 - newPassword: string, 51 - ) => { 52 - try { 53 - await changePasswordMutation.mutateAsync({ 54 - currentPassword, 55 - newPassword, 56 - }); 57 - showSuccess("Password changed successfully"); 58 - } catch (err: unknown) { 59 - const errorMessage = 60 - extractGraphQLErrorMessage(err) || 61 - "Failed to change password. Please try again."; 62 - showError(errorMessage); 63 - throw new Error(errorMessage); 64 - } 65 - }; 66 - 67 - const handleDeleteAccount = async (password: string) => { 68 - try { 69 - await deleteAccountMutation.mutateAsync({ password }); 70 - await logout(); 71 - navigate("/auth/login"); 72 - } catch (err: unknown) { 73 - const errorMessage = 74 - extractGraphQLErrorMessage(err) || 75 - "Failed to delete account. Please try again."; 76 - showError(errorMessage); 77 - throw new Error(errorMessage); 78 - } 79 - }; 7 + const { activeProfile, isLoading } = useProfile(); 8 + const profileId = useActiveProfileId(); 80 9 81 10 if (isLoading) { 82 11 return ( 83 12 <div className="space-y-6"> 84 13 <PageHeader 85 14 title="Profile" 86 - description="Manage your account information and preferences" 15 + description="Edit your active professional profile" 87 16 /> 88 17 <Placeholder variant="loading" message="Loading..." /> 89 18 </div> 90 19 ); 91 20 } 92 21 93 - if (error) { 94 - return ( 95 - <div className="space-y-6"> 96 - <PageHeader 97 - title="Profile" 98 - description="Manage your account information and preferences" 99 - /> 100 - <Placeholder variant="error" message="Error loading user data" /> 101 - </div> 102 - ); 103 - } 104 - 105 - const user = data?.me; 22 + const initialData = activeProfile 23 + ? { 24 + fullName: activeProfile.fullName ?? "", 25 + headline: activeProfile.headline ?? "", 26 + phone: activeProfile.phone ?? "", 27 + address: activeProfile.address ?? "", 28 + postalCode: activeProfile.postalCode ?? "", 29 + city: activeProfile.city ?? "", 30 + country: activeProfile.country ?? "", 31 + website: activeProfile.website ?? "", 32 + linkedInUrl: activeProfile.linkedInUrl ?? "", 33 + summary: activeProfile.summary ?? "", 34 + } 35 + : undefined; 106 36 107 37 return ( 108 38 <div className="space-y-6"> 109 39 <PageHeader 110 40 title="Profile" 111 - description="Manage your account information and preferences" 41 + description={`Editing: ${activeProfile?.name ?? "Default"}`} 112 42 /> 113 43 114 44 <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 115 - <div className="mb-6 flex items-center justify-between"> 116 - <h2 className="text-xl font-semibold text-ctp-text"> 117 - Personal Information 118 - </h2> 119 - {!isEditing && ( 120 - <Button variant="outline" onClick={handleEdit}> 121 - Edit Profile 122 - </Button> 123 - )} 124 - </div> 125 - 126 - {isEditing ? ( 127 - <div className="space-y-4"> 128 - <TextInput 129 - label="Name" 130 - value={formData.name} 131 - onChange={(value: string) => 132 - setFormData({ ...formData, name: value }) 133 - } 134 - /> 135 - <div> 136 - <div className="block text-sm font-medium text-ctp-subtext0"> 137 - Email 138 - </div> 139 - <p className="mt-1 text-ctp-text text-sm text-ctp-subtext0"> 140 - {user?.email} (cannot be changed) 141 - </p> 142 - </div> 143 - <div className="flex space-x-4"> 144 - <Button onClick={handleSave}>Save Changes</Button> 145 - <Button variant="outline" onClick={handleCancel}> 146 - Cancel 147 - </Button> 148 - </div> 149 - </div> 150 - ) : ( 151 - <div className="space-y-4"> 152 - <div> 153 - <div className="block text-sm font-medium text-ctp-subtext0"> 154 - Name 155 - </div> 156 - <p className="mt-1 text-ctp-text">{user?.name}</p> 157 - </div> 158 - <div> 159 - <div className="block text-sm font-medium text-ctp-subtext0"> 160 - Email 161 - </div> 162 - <p className="mt-1 text-ctp-text">{user?.email}</p> 163 - </div> 164 - <div> 165 - <div className="block text-sm font-medium text-ctp-subtext0"> 166 - User ID 167 - </div> 168 - <p className="mt-1 font-mono text-sm text-ctp-text">{user?.id}</p> 169 - </div> 170 - </div> 171 - )} 172 - </div> 173 - 174 - {/* Active Sessions */} 175 - <div className="mt-8 rounded-lg bg-ctp-crust/40 p-6 shadow"> 176 - <ActiveSessions /> 45 + <h2 className="mb-4 text-xl font-semibold text-ctp-text"> 46 + Personal Profile 47 + </h2> 48 + <PersonalProfileForm profileId={profileId} initialData={initialData} /> 177 49 </div> 178 50 179 - {/* Account settings */} 180 - <div className="mt-8 rounded-lg bg-ctp-crust/40 p-6 shadow"> 51 + <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 181 52 <h2 className="mb-4 text-xl font-semibold text-ctp-text"> 182 - Account Settings 53 + Imported Files 183 54 </h2> 184 - <div className="space-y-4"> 185 - <div className="flex items-center justify-between"> 186 - <div> 187 - <h3 className="text-sm font-medium text-ctp-text"> 188 - Change Password 189 - </h3> 190 - <p className="text-sm text-ctp-subtext0"> 191 - Update your account password 192 - </p> 193 - </div> 194 - <Button 195 - variant="outline" 196 - onClick={() => setIsChangePasswordOpen(true)} 197 - > 198 - Change 199 - </Button> 200 - </div> 201 - <div className="flex items-center justify-between"> 202 - <div> 203 - <h3 className="text-sm font-medium text-ctp-text"> 204 - Delete Account 205 - </h3> 206 - <p className="text-sm text-ctp-subtext0"> 207 - Permanently delete your account 208 - </p> 209 - </div> 210 - <Button 211 - variant="destructive" 212 - onClick={() => setIsDeleteAccountOpen(true)} 213 - > 214 - Delete 215 - </Button> 216 - </div> 217 - </div> 55 + <ImportedFilesSection /> 218 56 </div> 219 - 220 - <ChangePasswordModal 221 - isOpen={isChangePasswordOpen} 222 - onClose={() => setIsChangePasswordOpen(false)} 223 - onSubmit={handleChangePassword} 224 - /> 225 - 226 - <DeleteAccountModal 227 - isOpen={isDeleteAccountOpen} 228 - onClose={() => setIsDeleteAccountOpen(false)} 229 - onSubmit={handleDeleteAccount} 230 - /> 231 57 </div> 232 58 ); 233 59 }
+164
apps/client/src/pages/ProfilesPage.tsx
··· 1 + import { Button, PageHeader, useConfirmationModal, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useNavigate } from "react-router-dom"; 4 + import { useProfile } from "@/contexts/ProfileProvider"; 5 + import { 6 + useCreateProfileMutation, 7 + useDeleteProfileMutation, 8 + } from "@/generated/graphql"; 9 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 10 + 11 + const formatDate = (dateStr: string): string => 12 + new Date(dateStr).toLocaleDateString(undefined, { 13 + year: "numeric", 14 + month: "short", 15 + day: "numeric", 16 + }); 17 + 18 + export default function ProfilesPage() { 19 + const { profiles, activeProfile, setActiveProfileId, refetch } = 20 + useProfile(); 21 + const { showSuccess, showError } = useToast(); 22 + const { showConfirmation } = useConfirmationModal(); 23 + const queryClient = useQueryClient(); 24 + const navigate = useNavigate(); 25 + const createProfileMutation = useCreateProfileMutation(); 26 + const deleteProfileMutation = useDeleteProfileMutation(); 27 + 28 + const handleCreateManual = async () => { 29 + try { 30 + const name = `Profile ${profiles.length + 1}`; 31 + const result = await createProfileMutation.mutateAsync({ 32 + input: { name }, 33 + }); 34 + const newId = result.createProfile.id; 35 + setActiveProfileId(newId); 36 + await refetch(); 37 + await queryClient.invalidateQueries(); 38 + showSuccess("Profile created"); 39 + navigate("/profile"); 40 + } catch (err) { 41 + showError( 42 + extractGraphQLErrorMessage(err) || "Failed to create profile", 43 + ); 44 + } 45 + }; 46 + 47 + const handleImportCV = () => { 48 + navigate("/onboarding"); 49 + }; 50 + 51 + const handleDelete = async (profileId: string, profileName: string) => { 52 + const confirmed = await showConfirmation({ 53 + title: "Delete Profile", 54 + message: `Are you sure you want to delete "${profileName}"? All associated data (job experience, education, CVs) will be permanently removed.`, 55 + confirmText: "Delete", 56 + cancelText: "Cancel", 57 + variant: "danger", 58 + onConfirm: () => {}, 59 + }); 60 + 61 + if (!confirmed) return; 62 + 63 + try { 64 + await deleteProfileMutation.mutateAsync({ id: profileId }); 65 + await refetch(); 66 + await queryClient.invalidateQueries(); 67 + showSuccess("Profile deleted"); 68 + } catch (err) { 69 + showError( 70 + extractGraphQLErrorMessage(err) || "Failed to delete profile", 71 + ); 72 + } 73 + }; 74 + 75 + return ( 76 + <div className="space-y-6"> 77 + <PageHeader 78 + title="Profiles" 79 + description="Manage your professional profiles. Each profile has its own job experience, education, and CVs." 80 + /> 81 + 82 + <div className="flex gap-3"> 83 + <Button onClick={handleCreateManual}>Create Profile</Button> 84 + <Button variant="outline" onClick={handleImportCV}> 85 + Import from CV 86 + </Button> 87 + </div> 88 + 89 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 90 + {profiles.map((profile) => { 91 + const isActive = profile.id === activeProfile?.id; 92 + return ( 93 + <div 94 + key={profile.id} 95 + className={`rounded-lg border p-5 transition-colors ${ 96 + isActive 97 + ? "border-ctp-blue bg-ctp-blue/5" 98 + : "border-ctp-surface0 bg-ctp-crust/40" 99 + }`} 100 + > 101 + <div className="mb-3 flex items-start justify-between"> 102 + <div className="min-w-0 flex-1"> 103 + <h3 className="truncate text-lg font-semibold text-ctp-text"> 104 + {profile.name} 105 + </h3> 106 + {profile.headline && ( 107 + <p className="mt-0.5 truncate text-sm text-ctp-subtext0"> 108 + {profile.headline} 109 + </p> 110 + )} 111 + </div> 112 + {isActive && ( 113 + <span className="ml-2 shrink-0 rounded-full bg-ctp-blue/20 px-2 py-0.5 text-xs font-medium text-ctp-blue"> 114 + Active 115 + </span> 116 + )} 117 + </div> 118 + 119 + {(profile.city || profile.country) && ( 120 + <p className="mb-2 text-sm text-ctp-subtext0"> 121 + {[profile.city, profile.country].filter(Boolean).join(", ")} 122 + </p> 123 + )} 124 + 125 + <p className="mb-4 text-xs text-ctp-subtext1"> 126 + Created {formatDate(profile.createdAt)} 127 + </p> 128 + 129 + <div className="flex gap-2"> 130 + {!isActive && ( 131 + <Button 132 + size="sm" 133 + variant="outline" 134 + onClick={() => setActiveProfileId(profile.id)} 135 + > 136 + Set Active 137 + </Button> 138 + )} 139 + <Button 140 + size="sm" 141 + variant="outline" 142 + onClick={() => { 143 + setActiveProfileId(profile.id); 144 + navigate("/profile"); 145 + }} 146 + > 147 + Edit 148 + </Button> 149 + <Button 150 + size="sm" 151 + variant="ghost" 152 + onClick={() => handleDelete(profile.id, profile.name)} 153 + disabled={profiles.length <= 1 || deleteProfileMutation.isPending} 154 + > 155 + Delete 156 + </Button> 157 + </div> 158 + </div> 159 + ); 160 + })} 161 + </div> 162 + </div> 163 + ); 164 + }
+226
apps/client/src/pages/SettingsPage.tsx
··· 1 + import { 2 + Button, 3 + PageHeader, 4 + Placeholder, 5 + useConfirmationModal, 6 + useToast, 7 + } from "@cv/ui"; 8 + import { useQueryClient } from "@tanstack/react-query"; 9 + import { useState } from "react"; 10 + import { useNavigate } from "react-router-dom"; 11 + import { ActiveSessions } from "@/components/ActiveSessions"; 12 + import { ChangePasswordModal } from "@/components/ChangePasswordModal"; 13 + import { DeleteAccountModal } from "@/components/DeleteAccountModal"; 14 + import { useToken } from "@/contexts/TokenProvider"; 15 + import { AiSettingsSection } from "@/features/user-settings/components/AiSettingsSection"; 16 + import { 17 + useChangePasswordMutation, 18 + useDeleteAccountMutation, 19 + useMeMinimalQuery, 20 + useResetOnboardingMutation, 21 + } from "@/generated/graphql"; 22 + import { extractGraphQLErrorMessage } from "@/utils/graphql-error"; 23 + 24 + export default function SettingsPage() { 25 + const { data, isLoading, error } = useMeMinimalQuery(); 26 + const { logout } = useToken(); 27 + const navigate = useNavigate(); 28 + const queryClient = useQueryClient(); 29 + const { showSuccess, showError } = useToast(); 30 + const { showConfirmation } = useConfirmationModal(); 31 + const changePasswordMutation = useChangePasswordMutation(); 32 + const deleteAccountMutation = useDeleteAccountMutation(); 33 + const resetOnboardingMutation = useResetOnboardingMutation(); 34 + 35 + const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); 36 + const [isDeleteAccountOpen, setIsDeleteAccountOpen] = useState(false); 37 + 38 + const handleChangePassword = async ( 39 + currentPassword: string, 40 + newPassword: string, 41 + ) => { 42 + try { 43 + await changePasswordMutation.mutateAsync({ 44 + currentPassword, 45 + newPassword, 46 + }); 47 + showSuccess("Password changed successfully"); 48 + } catch (err: unknown) { 49 + const errorMessage = 50 + extractGraphQLErrorMessage(err) || 51 + "Failed to change password. Please try again."; 52 + showError(errorMessage); 53 + throw new Error(errorMessage); 54 + } 55 + }; 56 + 57 + const handleDeleteAccount = async (password: string) => { 58 + try { 59 + await deleteAccountMutation.mutateAsync({ password }); 60 + await logout(); 61 + navigate("/auth/login"); 62 + } catch (err: unknown) { 63 + const errorMessage = 64 + extractGraphQLErrorMessage(err) || 65 + "Failed to delete account. Please try again."; 66 + showError(errorMessage); 67 + throw new Error(errorMessage); 68 + } 69 + }; 70 + 71 + const handleResetOnboarding = async () => { 72 + const confirmed = await showConfirmation({ 73 + title: "Restart Onboarding", 74 + message: 75 + "This will delete your profile, job experiences, education, AI settings, and uploaded files. This cannot be undone.", 76 + confirmText: "Reset & Restart", 77 + cancelText: "Cancel", 78 + variant: "danger", 79 + onConfirm: () => {}, 80 + }); 81 + 82 + if (!confirmed) return; 83 + 84 + try { 85 + await resetOnboardingMutation.mutateAsync({}); 86 + await queryClient.invalidateQueries(); 87 + showSuccess("Onboarding reset. Redirecting..."); 88 + navigate("/onboarding"); 89 + } catch (err) { 90 + showError( 91 + extractGraphQLErrorMessage(err) || "Failed to reset onboarding", 92 + ); 93 + } 94 + }; 95 + 96 + if (isLoading) { 97 + return ( 98 + <div className="space-y-6"> 99 + <PageHeader 100 + title="Settings" 101 + description="Manage your account and preferences" 102 + /> 103 + <Placeholder variant="loading" message="Loading..." /> 104 + </div> 105 + ); 106 + } 107 + 108 + if (error) { 109 + return ( 110 + <div className="space-y-6"> 111 + <PageHeader 112 + title="Settings" 113 + description="Manage your account and preferences" 114 + /> 115 + <Placeholder variant="error" message="Error loading user data" /> 116 + </div> 117 + ); 118 + } 119 + 120 + const user = data?.me; 121 + 122 + return ( 123 + <div className="space-y-6"> 124 + <PageHeader 125 + title="Settings" 126 + description="Manage your account and preferences" 127 + /> 128 + 129 + <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 130 + <h2 className="mb-4 text-xl font-semibold text-ctp-text">Account</h2> 131 + <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> 132 + <div> 133 + <div className="text-sm font-medium text-ctp-subtext0">Name</div> 134 + <p className="mt-1 text-ctp-text">{user?.name}</p> 135 + </div> 136 + <div> 137 + <div className="text-sm font-medium text-ctp-subtext0">Email</div> 138 + <p className="mt-1 text-ctp-text">{user?.email}</p> 139 + </div> 140 + <div> 141 + <div className="text-sm font-medium text-ctp-subtext0"> 142 + User ID 143 + </div> 144 + <p className="mt-1 font-mono text-sm text-ctp-text">{user?.id}</p> 145 + </div> 146 + </div> 147 + </div> 148 + 149 + <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 150 + <h2 className="mb-4 text-xl font-semibold text-ctp-text"> 151 + AI Settings 152 + </h2> 153 + <AiSettingsSection /> 154 + </div> 155 + 156 + <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 157 + <ActiveSessions /> 158 + </div> 159 + 160 + <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 161 + <h2 className="mb-4 text-xl font-semibold text-ctp-text"> 162 + Account Settings 163 + </h2> 164 + <div className="space-y-4"> 165 + <div className="flex items-center justify-between"> 166 + <div> 167 + <h3 className="text-sm font-medium text-ctp-text"> 168 + Change Password 169 + </h3> 170 + <p className="text-sm text-ctp-subtext0"> 171 + Update your account password 172 + </p> 173 + </div> 174 + <Button 175 + variant="outline" 176 + onClick={() => setIsChangePasswordOpen(true)} 177 + > 178 + Change 179 + </Button> 180 + </div> 181 + <div className="flex items-center justify-between"> 182 + <div> 183 + <h3 className="text-sm font-medium text-ctp-text"> 184 + Restart Onboarding 185 + </h3> 186 + <p className="text-sm text-ctp-subtext0"> 187 + Clear your profile data and start the setup wizard again 188 + </p> 189 + </div> 190 + <Button variant="outline" onClick={handleResetOnboarding}> 191 + Restart 192 + </Button> 193 + </div> 194 + <div className="flex items-center justify-between"> 195 + <div> 196 + <h3 className="text-sm font-medium text-ctp-text"> 197 + Delete Account 198 + </h3> 199 + <p className="text-sm text-ctp-subtext0"> 200 + Permanently delete your account 201 + </p> 202 + </div> 203 + <Button 204 + variant="destructive" 205 + onClick={() => setIsDeleteAccountOpen(true)} 206 + > 207 + Delete 208 + </Button> 209 + </div> 210 + </div> 211 + </div> 212 + 213 + <ChangePasswordModal 214 + isOpen={isChangePasswordOpen} 215 + onClose={() => setIsChangePasswordOpen(false)} 216 + onSubmit={handleChangePassword} 217 + /> 218 + 219 + <DeleteAccountModal 220 + isOpen={isDeleteAccountOpen} 221 + onClose={() => setIsDeleteAccountOpen(false)} 222 + onSubmit={handleDeleteAccount} 223 + /> 224 + </div> 225 + ); 226 + }
+39
apps/client/src/router/AppRouter.tsx
··· 1 1 import { lazy, Suspense } from "react"; 2 2 import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; 3 + import { AdminRoute } from "@/components/AdminRoute"; 3 4 import { PageLoader } from "@/components/PageLoader"; 4 5 import { useToken } from "@/contexts/TokenProvider"; 5 6 import { LoginForm, RegisterForm } from "@/features/auth/components"; ··· 37 38 const OnboardingPage = lazy(() => import("@/pages/OnboardingPage")); 38 39 const OrganizationsPage = lazy(() => import("@/pages/OrganizationsPage")); 39 40 const ProfilePage = lazy(() => import("@/pages/ProfilePage")); 41 + const ProfilesPage = lazy(() => import("@/pages/ProfilesPage")); 42 + const SettingsPage = lazy(() => import("@/pages/SettingsPage")); 43 + const AdminPage = lazy(() => import("@/pages/AdminPage")); 44 + const SystemStatusPage = lazy(() => import("@/pages/SystemStatusPage")); 40 45 const VacanciesPage = lazy(() => import("@/pages/VacanciesPage")); 41 46 42 47 export default function AppRouter() { ··· 136 141 } 137 142 /> 138 143 <Route 144 + path="profiles" 145 + element={ 146 + <Suspense fallback={<PageLoader />}> 147 + <ProfilesPage /> 148 + </Suspense> 149 + } 150 + /> 151 + <Route 139 152 path="job-experience" 140 153 element={ 141 154 <Suspense fallback={<PageLoader />}> ··· 255 268 </Suspense> 256 269 } 257 270 /> 271 + <Route 272 + path="settings" 273 + element={ 274 + <Suspense fallback={<PageLoader />}> 275 + <SettingsPage /> 276 + </Suspense> 277 + } 278 + /> 279 + <Route element={<AdminRoute />}> 280 + <Route 281 + path="admin" 282 + element={ 283 + <Suspense fallback={<PageLoader />}> 284 + <AdminPage /> 285 + </Suspense> 286 + } 287 + /> 288 + <Route 289 + path="system" 290 + element={ 291 + <Suspense fallback={<PageLoader />}> 292 + <SystemStatusPage /> 293 + </Suspense> 294 + } 295 + /> 296 + </Route> 258 297 </Route> 259 298 260 299 {/* Catch all */}
+6 -7
apps/client/src/utils/graphql-error.schema.ts
··· 1 - import { z } from "zod"; 1 + import { z } from "zod/v4"; 2 2 3 3 export const GraphQLErrorExtensionSchema = z.object({ 4 4 code: z.string().optional(), 5 5 variables: z 6 6 .record( 7 + z.string(), 7 8 z.union([ 8 9 z.string(), 9 10 z.number(), ··· 24 25 errors: z.array(GraphQLErrorSchema).optional(), 25 26 }); 26 27 27 - export const GraphQLErrorWrapperSchema = z 28 - .object({ 29 - response: GraphQLResponseSchema.optional(), 30 - message: z.string().optional(), 31 - }) 32 - .passthrough(); 28 + export const GraphQLErrorWrapperSchema = z.looseObject({ 29 + response: GraphQLResponseSchema.optional(), 30 + message: z.string().optional(), 31 + });