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 onboarding flow with CV upload and AI parsing

+1293 -6
+1 -1
apps/client/src/components/VerifyEmailCard.tsx
··· 52 52 "Email verified", 53 53 "Your email has been successfully verified!", 54 54 ); 55 - navigate("/"); 55 + navigate("/onboarding"); 56 56 } catch (error) { 57 57 const errorCode = extractGraphQLErrorCode(error); 58 58 const errorMessage = extractGraphQLErrorMessage(error);
+3
apps/client/src/features/education/components/InstitutionSelect.tsx
··· 9 9 value: string; 10 10 onChange: (value: string) => void; 11 11 error?: string; 12 + defaultSearchValue?: string; 12 13 } 13 14 14 15 export const InstitutionSelect = ({ 15 16 value, 16 17 onChange, 17 18 error, 19 + defaultSearchValue, 18 20 }: InstitutionSelectProps) => { 19 21 const { data, isLoading } = useInstitutionsQuery(); 20 22 const { mutateAsync: createInstitution } = useCreateInstitutionMutation(); ··· 57 59 allowAddNew 58 60 onAddNew={handleAddNew} 59 61 addNewLabel="Add institution" 62 + defaultSearchValue={defaultSearchValue} 60 63 /> 61 64 ); 62 65 };
+3
apps/client/src/features/job-experience/components/CompanySelect.tsx
··· 9 9 value: string; 10 10 onChange: (value: string) => void; 11 11 error?: string; 12 + defaultSearchValue?: string; 12 13 } 13 14 14 15 export const CompanySelect = ({ 15 16 value, 16 17 onChange, 17 18 error, 19 + defaultSearchValue, 18 20 }: CompanySelectProps) => { 19 21 const { 20 22 data: companiesData, ··· 77 79 allowAddNew 78 80 onAddNew={handleAddNew} 79 81 addNewLabel="Add company" 82 + defaultSearchValue={defaultSearchValue} 80 83 /> 81 84 ); 82 85 };
+8 -1
apps/client/src/features/job-experience/components/LevelSelect.tsx
··· 9 9 value: string; 10 10 onChange: (value: string) => void; 11 11 error?: string; 12 + defaultSearchValue?: string; 12 13 } 13 14 14 - export const LevelSelect = ({ value, onChange, error }: LevelSelectProps) => { 15 + export const LevelSelect = ({ 16 + value, 17 + onChange, 18 + error, 19 + defaultSearchValue, 20 + }: LevelSelectProps) => { 15 21 const { 16 22 data: levelsData, 17 23 fetchNextPage: fetchNextLevels, ··· 73 79 allowAddNew 74 80 onAddNew={handleAddNew} 75 81 addNewLabel="Add level" 82 + defaultSearchValue={defaultSearchValue} 76 83 /> 77 84 ); 78 85 };
+8 -1
apps/client/src/features/job-experience/components/RoleSelect.tsx
··· 9 9 value: string; 10 10 onChange: (value: string) => void; 11 11 error?: string; 12 + defaultSearchValue?: string; 12 13 } 13 14 14 - export const RoleSelect = ({ value, onChange, error }: RoleSelectProps) => { 15 + export const RoleSelect = ({ 16 + value, 17 + onChange, 18 + error, 19 + defaultSearchValue, 20 + }: RoleSelectProps) => { 15 21 const { 16 22 data: rolesData, 17 23 fetchNextPage: fetchNextRoles, ··· 73 79 allowAddNew 74 80 onAddNew={handleAddNew} 75 81 addNewLabel="Add role" 82 + defaultSearchValue={defaultSearchValue} 76 83 /> 77 84 ); 78 85 };
+258
apps/client/src/features/onboarding/components/EducationReviewCard.tsx
··· 1 + import { Button, Calendar, Textarea, TextInput } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { InstitutionSelect } from "@/features/education/components/InstitutionSelect"; 4 + import { SelectedSkillsDisplay } from "@/features/job-experience/components/SelectedSkillsDisplay"; 5 + import { SkillsSelect } from "@/features/job-experience/components/SkillsSelect"; 6 + import { 7 + type DraftEducation, 8 + type DraftEntity, 9 + draftEducationSchema, 10 + } from "../schemas/draft.schema"; 11 + 12 + interface Props { 13 + data: DraftEducation; 14 + onChange: (data: DraftEducation) => void; 15 + } 16 + 17 + /** 18 + * Format date for display 19 + */ 20 + const formatDate = (date: Date | null): string => 21 + date 22 + ? date.toLocaleDateString("en-US", { year: "numeric", month: "short" }) 23 + : "Current"; 24 + 25 + /** 26 + * View mode - displays the education summary 27 + */ 28 + const EducationView = ({ 29 + data, 30 + onEdit, 31 + }: { 32 + data: DraftEducation; 33 + onEdit: () => void; 34 + }) => ( 35 + <div className="p-4 border border-ctp-overlay0 rounded-lg bg-ctp-surface0 hover:border-ctp-overlay1 transition-colors"> 36 + <div className="flex justify-between items-start mb-3"> 37 + <div> 38 + <h4 className="font-semibold text-ctp-text">{data.degree}</h4> 39 + <p className="text-sm text-ctp-subtext0">{data.institution.name}</p> 40 + {data.fieldOfStudy && ( 41 + <p className="text-xs text-ctp-subtext1 mt-1">{data.fieldOfStudy}</p> 42 + )} 43 + </div> 44 + <Button size="sm" variant="ghost" onClick={onEdit}> 45 + Edit 46 + </Button> 47 + </div> 48 + 49 + <p className="text-sm text-ctp-subtext1 mb-2"> 50 + {formatDate(data.startDate)} - {formatDate(data.endDate)} 51 + </p> 52 + 53 + {data.description && ( 54 + <p className="text-sm text-ctp-text mb-2">{data.description}</p> 55 + )} 56 + 57 + {data.skills.length > 0 && ( 58 + <div className="flex flex-wrap gap-1 mt-2"> 59 + {data.skills.map((skill) => ( 60 + <span 61 + key={skill.id ?? skill.name} 62 + className="text-xs px-2 py-1 bg-ctp-green/20 text-ctp-green rounded" 63 + > 64 + {skill.name} 65 + </span> 66 + ))} 67 + </div> 68 + )} 69 + </div> 70 + ); 71 + 72 + /** 73 + * Edit mode - form for editing the education 74 + */ 75 + const EducationEdit = ({ 76 + data, 77 + onSave, 78 + onCancel, 79 + }: { 80 + data: DraftEducation; 81 + onSave: (data: DraftEducation) => void; 82 + onCancel: () => void; 83 + }) => { 84 + const [form, setForm] = useState<DraftEducation>(data); 85 + const [errors, setErrors] = useState<Record<string, string>>({}); 86 + 87 + const updateField = <K extends keyof DraftEducation>( 88 + field: K, 89 + value: DraftEducation[K], 90 + ) => setForm((prev) => ({ ...prev, [field]: value })); 91 + 92 + const updateInstitution = (id: string) => 93 + setForm((prev) => ({ 94 + ...prev, 95 + institution: { ...prev.institution, id }, 96 + })); 97 + 98 + const addSkill = (id: string) => { 99 + if (!form.skills.some((s) => s.id === id)) { 100 + const newSkill: DraftEntity = { id, name: id }; 101 + updateField("skills", [...form.skills, newSkill]); 102 + } 103 + }; 104 + 105 + const removeSkill = (id: string) => 106 + updateField( 107 + "skills", 108 + form.skills.filter((s) => s.id !== id), 109 + ); 110 + 111 + const handleSubmit = (e: React.FormEvent) => { 112 + e.preventDefault(); 113 + const result = draftEducationSchema.safeParse(form); 114 + 115 + if (!result.success) { 116 + const fieldErrors: Record<string, string> = {}; 117 + result.error.errors.forEach((err) => { 118 + const path = err.path.join("."); 119 + fieldErrors[path] = err.message; 120 + }); 121 + setErrors(fieldErrors); 122 + return; 123 + } 124 + 125 + setErrors({}); 126 + onSave(form); 127 + }; 128 + 129 + return ( 130 + <form 131 + onSubmit={handleSubmit} 132 + className="p-4 border border-ctp-blue rounded-lg bg-ctp-surface0" 133 + > 134 + <div className="space-y-4"> 135 + <InstitutionSelect 136 + value={form.institution.id ?? ""} 137 + onChange={updateInstitution} 138 + defaultSearchValue={ 139 + form.institution.id ? undefined : form.institution.name 140 + } 141 + error={errors["institution.name"]} 142 + /> 143 + 144 + <TextInput 145 + label="Degree *" 146 + value={form.degree} 147 + onChange={(value) => updateField("degree", value)} 148 + placeholder="e.g., Bachelor of Science" 149 + error={errors["degree"]} 150 + /> 151 + 152 + <TextInput 153 + label="Field of Study" 154 + value={form.fieldOfStudy ?? ""} 155 + onChange={(value) => updateField("fieldOfStudy", value || null)} 156 + placeholder="e.g., Computer Science" 157 + /> 158 + 159 + <div className="grid grid-cols-2 gap-4"> 160 + <div className="space-y-2"> 161 + <label 162 + htmlFor="edu-start-date" 163 + className="block text-sm font-medium text-ctp-text" 164 + > 165 + Start Date * 166 + </label> 167 + <Calendar 168 + id="edu-start-date" 169 + value={form.startDate} 170 + onChange={(date) => date && updateField("startDate", date)} 171 + placeholder="Select start date" 172 + format="short" 173 + maxDate={new Date()} 174 + /> 175 + {errors["startDate"] && ( 176 + <p className="text-sm text-ctp-red">{errors["startDate"]}</p> 177 + )} 178 + </div> 179 + 180 + <div className="space-y-2"> 181 + <label 182 + htmlFor="edu-end-date" 183 + className="block text-sm font-medium text-ctp-text" 184 + > 185 + End Date 186 + </label> 187 + <Calendar 188 + id="edu-end-date" 189 + value={form.endDate} 190 + onChange={(date) => updateField("endDate", date)} 191 + placeholder="Current" 192 + format="short" 193 + maxDate={new Date()} 194 + /> 195 + {errors["endDate"] && ( 196 + <p className="text-sm text-ctp-red">{errors["endDate"]}</p> 197 + )} 198 + </div> 199 + </div> 200 + 201 + <Textarea 202 + label="Description" 203 + value={form.description ?? ""} 204 + onChange={(value) => updateField("description", value || null)} 205 + placeholder="Describe your studies, achievements, thesis..." 206 + rows={3} 207 + /> 208 + 209 + <div className="space-y-2"> 210 + <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 + /> 217 + </div> 218 + 219 + <div className="flex gap-2 pt-2"> 220 + <Button type="submit" size="sm" className="flex-1"> 221 + Save 222 + </Button> 223 + <Button 224 + type="button" 225 + size="sm" 226 + variant="ghost" 227 + onClick={onCancel} 228 + className="flex-1" 229 + > 230 + Cancel 231 + </Button> 232 + </div> 233 + </div> 234 + </form> 235 + ); 236 + }; 237 + 238 + /** 239 + * Education review card - toggles between view and edit modes 240 + */ 241 + export const EducationReviewCard = ({ data, onChange }: Props) => { 242 + const [isEditing, setIsEditing] = useState(false); 243 + 244 + const handleSave = (updated: DraftEducation) => { 245 + onChange(updated); 246 + setIsEditing(false); 247 + }; 248 + 249 + return isEditing ? ( 250 + <EducationEdit 251 + data={data} 252 + onSave={handleSave} 253 + onCancel={() => setIsEditing(false)} 254 + /> 255 + ) : ( 256 + <EducationView data={data} onEdit={() => setIsEditing(true)} /> 257 + ); 258 + };
+126
apps/client/src/features/onboarding/components/FileUploadStep.tsx
··· 1 + import { Button, useToast } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { useDropzone } from "react-dropzone"; 4 + import type { ParsedCVDataWithResolution } from "../schemas/draft.schema"; 5 + 6 + interface Props { 7 + onComplete: (data: ParsedCVDataWithResolution) => void; 8 + onBack: () => void; 9 + } 10 + 11 + export const FileUploadStep = ({ onComplete, onBack }: Props) => { 12 + const [uploading, setUploading] = useState(false); 13 + const [error, setError] = useState<string | null>(null); 14 + const { showSuccess, showError } = useToast(); 15 + 16 + const onDrop = async (acceptedFiles: File[]) => { 17 + const file = acceptedFiles[0]; 18 + if (!file) { 19 + setError("No valid files selected"); 20 + return; 21 + } 22 + 23 + setError(null); 24 + setUploading(true); 25 + 26 + try { 27 + const formData = new FormData(); 28 + formData.append("file", file); 29 + 30 + const response = await fetch("/api/cv-parser/upload", { 31 + method: "POST", 32 + body: formData, 33 + credentials: "include", 34 + }); 35 + 36 + if (!response.ok) { 37 + const errorData = await response.json().catch(() => ({})); 38 + throw new Error( 39 + errorData.message || `Upload failed (${response.status})`, 40 + ); 41 + } 42 + 43 + const result = await response.json(); 44 + 45 + if (!(result.success && result.data)) { 46 + throw new Error("Failed to parse file"); 47 + } 48 + 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 + } 58 + }; 59 + 60 + const { getRootProps, getInputProps, isDragActive } = useDropzone({ 61 + onDrop, 62 + accept: { 63 + "application/pdf": [".pdf"], 64 + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": 65 + [".docx"], 66 + "text/plain": [".txt"], 67 + "text/markdown": [".md"], 68 + }, 69 + maxSize: 10 * 1024 * 1024, // 10MB 70 + multiple: false, 71 + disabled: uploading, 72 + }); 73 + 74 + return ( 75 + <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> 95 + 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> 100 + </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 104 + </p> 105 + </div> 106 + )} 107 + 108 + {error && ( 109 + <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> 111 + </div> 112 + )} 113 + 114 + <div className="mt-8 flex gap-4"> 115 + <Button 116 + variant="outline" 117 + onClick={onBack} 118 + disabled={uploading} 119 + className="flex-1" 120 + > 121 + Back 122 + </Button> 123 + </div> 124 + </div> 125 + ); 126 + };
+257
apps/client/src/features/onboarding/components/JobExperienceReviewCard.tsx
··· 1 + import { Button, Calendar, Textarea } from "@cv/ui"; 2 + import { useState } from "react"; 3 + import { CompanySelect } from "@/features/job-experience/components/CompanySelect"; 4 + import { LevelSelect } from "@/features/job-experience/components/LevelSelect"; 5 + import { RoleSelect } from "@/features/job-experience/components/RoleSelect"; 6 + import { SelectedSkillsDisplay } from "@/features/job-experience/components/SelectedSkillsDisplay"; 7 + import { SkillsSelect } from "@/features/job-experience/components/SkillsSelect"; 8 + import { 9 + type DraftEntity, 10 + type DraftJobExperience, 11 + draftJobExperienceSchema, 12 + } from "../schemas/draft.schema"; 13 + 14 + interface Props { 15 + data: DraftJobExperience; 16 + onChange: (data: DraftJobExperience) => void; 17 + } 18 + 19 + /** 20 + * Format date for display 21 + */ 22 + const formatDate = (date: Date | null): string => 23 + date 24 + ? date.toLocaleDateString("en-US", { year: "numeric", month: "short" }) 25 + : "Present"; 26 + 27 + /** 28 + * View mode - displays the job experience summary 29 + */ 30 + const JobExperienceView = ({ 31 + data, 32 + onEdit, 33 + }: { 34 + data: DraftJobExperience; 35 + onEdit: () => void; 36 + }) => ( 37 + <div className="p-4 border border-ctp-overlay0 rounded-lg bg-ctp-surface0 hover:border-ctp-overlay1 transition-colors"> 38 + <div className="flex justify-between items-start mb-3"> 39 + <div> 40 + <h4 className="font-semibold text-ctp-text">{data.role.name}</h4> 41 + <p className="text-sm text-ctp-subtext0">{data.company.name}</p> 42 + {data.level.name && ( 43 + <p className="text-xs text-ctp-subtext1 mt-1">{data.level.name}</p> 44 + )} 45 + </div> 46 + <Button size="sm" variant="ghost" onClick={onEdit}> 47 + Edit 48 + </Button> 49 + </div> 50 + 51 + <p className="text-sm text-ctp-subtext1 mb-2"> 52 + {formatDate(data.startDate)} - {formatDate(data.endDate)} 53 + </p> 54 + 55 + {data.description && ( 56 + <p className="text-sm text-ctp-text mb-2">{data.description}</p> 57 + )} 58 + 59 + {data.skills.length > 0 && ( 60 + <div className="flex flex-wrap gap-1 mt-2"> 61 + {data.skills.map((skill) => ( 62 + <span 63 + key={skill.id ?? skill.name} 64 + className="text-xs px-2 py-1 bg-ctp-blue/20 text-ctp-blue rounded" 65 + > 66 + {skill.name} 67 + </span> 68 + ))} 69 + </div> 70 + )} 71 + </div> 72 + ); 73 + 74 + /** 75 + * Edit mode - form for editing the job experience 76 + */ 77 + const JobExperienceEdit = ({ 78 + data, 79 + onSave, 80 + onCancel, 81 + }: { 82 + data: DraftJobExperience; 83 + onSave: (data: DraftJobExperience) => void; 84 + onCancel: () => void; 85 + }) => { 86 + const [form, setForm] = useState<DraftJobExperience>(data); 87 + const [errors, setErrors] = useState<Record<string, string>>({}); 88 + 89 + const updateField = <K extends keyof DraftJobExperience>( 90 + field: K, 91 + value: DraftJobExperience[K], 92 + ) => setForm((prev) => ({ ...prev, [field]: value })); 93 + 94 + const updateEntity = (field: "company" | "role" | "level", id: string) => 95 + setForm((prev) => ({ 96 + ...prev, 97 + [field]: { ...prev[field], id }, 98 + })); 99 + 100 + const addSkill = (id: string) => { 101 + if (!form.skills.some((s) => s.id === id)) { 102 + const newSkill: DraftEntity = { id, name: id }; 103 + updateField("skills", [...form.skills, newSkill]); 104 + } 105 + }; 106 + 107 + const removeSkill = (id: string) => 108 + updateField( 109 + "skills", 110 + form.skills.filter((s) => s.id !== id), 111 + ); 112 + 113 + const handleSubmit = (e: React.FormEvent) => { 114 + e.preventDefault(); 115 + const result = draftJobExperienceSchema.safeParse(form); 116 + 117 + if (!result.success) { 118 + const fieldErrors: Record<string, string> = {}; 119 + result.error.errors.forEach((err) => { 120 + const path = err.path.join("."); 121 + fieldErrors[path] = err.message; 122 + }); 123 + setErrors(fieldErrors); 124 + return; 125 + } 126 + 127 + setErrors({}); 128 + onSave(form); 129 + }; 130 + 131 + return ( 132 + <form 133 + onSubmit={handleSubmit} 134 + className="p-4 border border-ctp-blue rounded-lg bg-ctp-surface0" 135 + > 136 + <div className="space-y-4"> 137 + <CompanySelect 138 + value={form.company.id ?? ""} 139 + onChange={(id) => updateEntity("company", id)} 140 + defaultSearchValue={form.company.id ? undefined : form.company.name} 141 + error={errors["company.name"]} 142 + /> 143 + 144 + <RoleSelect 145 + value={form.role.id ?? ""} 146 + onChange={(id) => updateEntity("role", id)} 147 + defaultSearchValue={form.role.id ? undefined : form.role.name} 148 + error={errors["role.name"]} 149 + /> 150 + 151 + <LevelSelect 152 + value={form.level.id ?? ""} 153 + onChange={(id) => updateEntity("level", id)} 154 + defaultSearchValue={form.level.id ? undefined : form.level.name} 155 + error={errors["level.name"]} 156 + /> 157 + 158 + <div className="grid grid-cols-2 gap-4"> 159 + <div className="space-y-2"> 160 + <label 161 + htmlFor="job-start-date" 162 + className="block text-sm font-medium text-ctp-text" 163 + > 164 + Start Date * 165 + </label> 166 + <Calendar 167 + id="job-start-date" 168 + value={form.startDate} 169 + onChange={(date) => date && updateField("startDate", date)} 170 + placeholder="Select start date" 171 + format="short" 172 + maxDate={new Date()} 173 + /> 174 + {errors["startDate"] && ( 175 + <p className="text-sm text-ctp-red">{errors["startDate"]}</p> 176 + )} 177 + </div> 178 + 179 + <div className="space-y-2"> 180 + <label 181 + htmlFor="job-end-date" 182 + className="block text-sm font-medium text-ctp-text" 183 + > 184 + End Date 185 + </label> 186 + <Calendar 187 + id="job-end-date" 188 + value={form.endDate} 189 + onChange={(date) => updateField("endDate", date)} 190 + placeholder="Present" 191 + format="short" 192 + maxDate={new Date()} 193 + /> 194 + {errors["endDate"] && ( 195 + <p className="text-sm text-ctp-red">{errors["endDate"]}</p> 196 + )} 197 + </div> 198 + </div> 199 + 200 + <Textarea 201 + label="Description" 202 + value={form.description ?? ""} 203 + onChange={(value) => updateField("description", value || null)} 204 + placeholder="Describe your role and achievements..." 205 + rows={3} 206 + /> 207 + 208 + <div className="space-y-2"> 209 + <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 + /> 216 + </div> 217 + 218 + <div className="flex gap-2 pt-2"> 219 + <Button type="submit" size="sm" className="flex-1"> 220 + Save 221 + </Button> 222 + <Button 223 + type="button" 224 + size="sm" 225 + variant="ghost" 226 + onClick={onCancel} 227 + className="flex-1" 228 + > 229 + Cancel 230 + </Button> 231 + </div> 232 + </div> 233 + </form> 234 + ); 235 + }; 236 + 237 + /** 238 + * Job experience review card - toggles between view and edit modes 239 + */ 240 + export const JobExperienceReviewCard = ({ data, onChange }: Props) => { 241 + const [isEditing, setIsEditing] = useState(false); 242 + 243 + const handleSave = (updated: DraftJobExperience) => { 244 + onChange(updated); 245 + setIsEditing(false); 246 + }; 247 + 248 + return isEditing ? ( 249 + <JobExperienceEdit 250 + data={data} 251 + onSave={handleSave} 252 + onCancel={() => setIsEditing(false)} 253 + /> 254 + ) : ( 255 + <JobExperienceView data={data} onEdit={() => setIsEditing(true)} /> 256 + ); 257 + };
+91
apps/client/src/features/onboarding/components/OnboardingMethodSelector.tsx
··· 1 + import { Button, Card } from "@cv/ui"; 2 + 3 + interface Props { 4 + onMethodSelect: (method: string) => void; 5 + } 6 + 7 + export const OnboardingMethodSelector = ({ onMethodSelect }: Props) => { 8 + const methods = [ 9 + { 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 + id: "file-upload", 19 + title: "Upload Existing CV", 20 + description: "PDF, DOCX, TXT, or Markdown", 21 + icon: "📄", 22 + disabled: false, 23 + }, 24 + { 25 + id: "story", 26 + title: "Tell Your Story", 27 + description: "Write about your career journey", 28 + icon: "✍️", 29 + disabled: true, 30 + comingSoon: true, 31 + }, 32 + { 33 + id: "manual", 34 + title: "Fill Forms Manually", 35 + description: "Enter details step by step", 36 + icon: "📝", 37 + disabled: false, 38 + }, 39 + ]; 40 + 41 + return ( 42 + <div className="mt-8"> 43 + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> 44 + {methods.map((method) => ( 45 + <Card 46 + key={method.id} 47 + className={`p-6 flex flex-col ${ 48 + method.disabled 49 + ? "opacity-60 cursor-not-allowed" 50 + : "cursor-pointer hover:shadow-lg transition-shadow" 51 + }`} 52 + onClick={() => !method.disabled && onMethodSelect(method.id)} 53 + > 54 + <div className="text-4xl mb-4">{method.icon}</div> 55 + <h3 className="text-xl font-semibold text-ctp-text mb-2"> 56 + {method.title} 57 + </h3> 58 + <p className="text-ctp-subtext0 mb-4 flex-grow"> 59 + {method.description} 60 + </p> 61 + {method.comingSoon && ( 62 + <div className="text-xs font-semibold text-ctp-yellow mb-3 uppercase"> 63 + Coming Soon 64 + </div> 65 + )} 66 + <Button 67 + disabled={method.disabled} 68 + className="w-full" 69 + onClick={(e) => { 70 + e.stopPropagation(); 71 + if (!method.disabled) { 72 + onMethodSelect(method.id); 73 + } 74 + }} 75 + > 76 + {method.disabled ? "Coming Soon" : "Get Started"} 77 + </Button> 78 + </Card> 79 + ))} 80 + </div> 81 + 82 + <div className="mt-8 p-4 bg-ctp-surface0 rounded-lg border border-ctp-overlay0"> 83 + <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. 87 + </p> 88 + </div> 89 + </div> 90 + ); 91 + };
+266
apps/client/src/features/onboarding/components/ReviewStep.tsx
··· 1 + import { Button, useToast } from "@cv/ui"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { useState } from "react"; 4 + import { z } from "zod"; 5 + import { 6 + useCreateEducationMutation, 7 + useCreateJobExperienceMutation, 8 + useMeEducationQuery, 9 + useMeJobExperienceQuery, 10 + } from "@/generated/graphql"; 11 + import { 12 + type DraftEducation, 13 + type DraftJobExperience, 14 + type ParsedCVDataWithResolution, 15 + type ValidatedEducation, 16 + type ValidatedJobExperience, 17 + validatedEducationSchema, 18 + validatedJobExperienceSchema, 19 + } from "../schemas/draft.schema"; 20 + import { EducationReviewCard } from "./EducationReviewCard"; 21 + import { JobExperienceReviewCard } from "./JobExperienceReviewCard"; 22 + 23 + /** 24 + * Type guard to check if a PromiseSettledResult is fulfilled 25 + */ 26 + const isFulfilled = <T,>( 27 + result: PromiseSettledResult<T>, 28 + ): result is PromiseFulfilledResult<T> => result.status === "fulfilled"; 29 + 30 + /** 31 + * Extract error message from a rejected promise result 32 + */ 33 + const formatError = (result: PromiseRejectedResult): string => 34 + result.reason instanceof Error ? result.reason.message : "Unknown error"; 35 + 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("; "); 41 + 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 + }); 54 + 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 + }); 67 + 68 + interface Props { 69 + parsedData: ParsedCVDataWithResolution; 70 + onComplete: () => void; 71 + onBack: () => void; 72 + onDataChange: (data: ParsedCVDataWithResolution) => void; 73 + } 74 + 75 + export const ReviewStep = ({ 76 + parsedData, 77 + onComplete, 78 + onBack, 79 + onDataChange, 80 + }: Props) => { 81 + const [saving, setSaving] = useState(false); 82 + const { showSuccess, showError } = useToast(); 83 + const queryClient = useQueryClient(); 84 + 85 + const { mutateAsync: createJobExperience } = useCreateJobExperienceMutation(); 86 + const { mutateAsync: createEducation } = useCreateEducationMutation(); 87 + 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); 96 + 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 + } 106 + 107 + setSaving(true); 108 + 109 + // Use validated data (IDs are guaranteed non-null by schema) 110 + const validatedJobs = jobsResult.data; 111 + const validatedEducation = eduResult.data; 112 + 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 + ]); 124 + 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 + ); 133 + 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 + ]); 143 + 144 + setSaving(false); 145 + 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 + 152 + showError( 153 + `Saved with errors (${succeeded.length}/${allResults.length})`, 154 + errorMessages + moreCount, 155 + ); 156 + return; 157 + } 158 + 159 + showSuccess("Saved successfully", `${succeeded.length} entries created`); 160 + onComplete(); 161 + }; 162 + 163 + const updateJobExperience = 164 + (index: number) => (updated: DraftJobExperience) => 165 + onDataChange({ 166 + ...parsedData, 167 + jobExperiences: parsedData.jobExperiences.map((job, i) => 168 + i === index ? updated : job, 169 + ), 170 + }); 171 + 172 + const updateEducation = (index: number) => (updated: DraftEducation) => 173 + onDataChange({ 174 + ...parsedData, 175 + education: parsedData.education.map((edu, i) => 176 + i === index ? updated : edu, 177 + ), 178 + }); 179 + 180 + const hasNoData = 181 + parsedData.jobExperiences.length === 0 && parsedData.education.length === 0; 182 + 183 + return ( 184 + <div className="mt-8 space-y-8"> 185 + <div> 186 + <h2 className="text-2xl font-bold text-ctp-text mb-2"> 187 + Review Your Information 188 + </h2> 189 + <p className="text-ctp-subtext0"> 190 + Please review the extracted information. You can edit any field before 191 + saving. 192 + </p> 193 + </div> 194 + 195 + {parsedData.jobExperiences.length > 0 && ( 196 + <section> 197 + <h3 className="text-xl font-semibold text-ctp-text mb-4"> 198 + Work Experience ({parsedData.jobExperiences.length}) 199 + </h3> 200 + <div className="space-y-4"> 201 + {parsedData.jobExperiences.map((job, index) => ( 202 + <JobExperienceReviewCard 203 + key={`${job.company.name}-${job.startDate.toISOString()}`} 204 + data={job} 205 + onChange={updateJobExperience(index)} 206 + /> 207 + ))} 208 + </div> 209 + </section> 210 + )} 211 + 212 + {parsedData.education.length > 0 && ( 213 + <section> 214 + <h3 className="text-xl font-semibold text-ctp-text mb-4"> 215 + Education ({parsedData.education.length}) 216 + </h3> 217 + <div className="space-y-4"> 218 + {parsedData.education.map((edu, index) => ( 219 + <EducationReviewCard 220 + key={`${edu.institution.name}-${edu.startDate.toISOString()}`} 221 + data={edu} 222 + onChange={updateEducation(index)} 223 + /> 224 + ))} 225 + </div> 226 + </section> 227 + )} 228 + 229 + {hasNoData && ( 230 + <div className="p-4 bg-ctp-surface0 rounded-lg border border-ctp-overlay0"> 231 + <p className="text-ctp-subtext0"> 232 + No job experiences or education entries were extracted. You can add 233 + them manually from the dashboard, or try uploading a different file 234 + format. 235 + </p> 236 + </div> 237 + )} 238 + 239 + <div className="flex gap-4 pt-6 border-t border-ctp-surface0"> 240 + <Button 241 + variant="outline" 242 + onClick={onBack} 243 + disabled={saving} 244 + className="flex-1" 245 + > 246 + Back 247 + </Button> 248 + <Button 249 + onClick={handleSaveAll} 250 + disabled={saving || hasNoData} 251 + className="flex-1" 252 + > 253 + {saving ? "Saving..." : "Save All"} 254 + </Button> 255 + <Button 256 + variant="ghost" 257 + onClick={onComplete} 258 + disabled={saving} 259 + className="flex-1" 260 + > 261 + Skip 262 + </Button> 263 + </div> 264 + </div> 265 + ); 266 + };
+104
apps/client/src/features/onboarding/schemas/draft.schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + /** 4 + * Draft entity - represents an entity that may or may not exist in the database 5 + * If id is null, the entity needs to be created on save 6 + */ 7 + export const draftEntitySchema = z.object({ 8 + id: z.string().nullable(), 9 + name: z.string().min(1, "Required"), 10 + }); 11 + 12 + export type DraftEntity = z.infer<typeof draftEntitySchema>; 13 + 14 + /** 15 + * Validated entity - has a confirmed ID (ready for persistence) 16 + */ 17 + export const validatedEntitySchema = z.object({ 18 + id: z.string().min(1, "Entity must be selected"), 19 + name: z.string().min(1, "Required"), 20 + }); 21 + 22 + export type ValidatedEntity = z.infer<typeof validatedEntitySchema>; 23 + 24 + /** 25 + * Draft job experience from parsed CV data 26 + */ 27 + export const draftJobExperienceSchema = z 28 + .object({ 29 + company: draftEntitySchema, 30 + role: draftEntitySchema, 31 + level: draftEntitySchema, 32 + skills: z.array(draftEntitySchema), 33 + startDate: z.coerce.date(), 34 + endDate: z.coerce.date().nullable(), 35 + description: z.string().nullable(), 36 + }) 37 + .refine((data) => !data.endDate || data.endDate >= data.startDate, { 38 + message: "End date must be after start date", 39 + path: ["endDate"], 40 + }); 41 + 42 + export type DraftJobExperience = z.infer<typeof draftJobExperienceSchema>; 43 + 44 + /** 45 + * Draft education from parsed CV data 46 + */ 47 + export const draftEducationSchema = z 48 + .object({ 49 + institution: draftEntitySchema, 50 + degree: z.string().min(1, "Degree required"), 51 + fieldOfStudy: z.string().nullable(), 52 + skills: z.array(draftEntitySchema), 53 + startDate: z.coerce.date(), 54 + endDate: z.coerce.date().nullable(), 55 + description: z.string().nullable(), 56 + }) 57 + .refine((data) => !data.endDate || data.endDate >= data.startDate, { 58 + message: "End date must be after start date", 59 + path: ["endDate"], 60 + }); 61 + 62 + export type DraftEducation = z.infer<typeof draftEducationSchema>; 63 + 64 + /** 65 + * Validated job experience - all entities have IDs (ready for persistence) 66 + */ 67 + export const validatedJobExperienceSchema = z.object({ 68 + company: validatedEntitySchema, 69 + role: validatedEntitySchema, 70 + level: validatedEntitySchema, 71 + skills: z.array(validatedEntitySchema), 72 + startDate: z.coerce.date(), 73 + endDate: z.coerce.date().nullable(), 74 + description: z.string().nullable(), 75 + }); 76 + 77 + export type ValidatedJobExperience = z.infer< 78 + typeof validatedJobExperienceSchema 79 + >; 80 + 81 + /** 82 + * Validated education - all entities have IDs (ready for persistence) 83 + */ 84 + export const validatedEducationSchema = z.object({ 85 + institution: validatedEntitySchema, 86 + degree: z.string().min(1, "Degree required"), 87 + fieldOfStudy: z.string().nullable(), 88 + skills: z.array(validatedEntitySchema), 89 + startDate: z.coerce.date(), 90 + endDate: z.coerce.date().nullable(), 91 + description: z.string().nullable(), 92 + }); 93 + 94 + export type ValidatedEducation = z.infer<typeof validatedEducationSchema>; 95 + 96 + /** 97 + * Parsed CV data with resolved entities 98 + */ 99 + export const parsedCVDataSchema = z.object({ 100 + jobExperiences: z.array(draftJobExperienceSchema), 101 + education: z.array(draftEducationSchema), 102 + }); 103 + 104 + export type ParsedCVDataWithResolution = z.infer<typeof parsedCVDataSchema>;
+44 -3
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 4 import { 4 5 useCvTemplatesQuery, 6 + useMeJobExperienceQuery, 5 7 useMeMinimalQuery, 6 8 useMyCVsQuery, 7 9 } from "@/generated/graphql"; 8 10 9 11 export default function DashboardPage() { 12 + const [showOnboarding, setShowOnboarding] = useState(false); 13 + 10 14 const { 11 15 data: userData, 12 16 isLoading: userLoading, ··· 15 19 const { data: cvsData, isLoading: cvsLoading } = useMyCVsQuery({ first: 5 }); 16 20 const { data: templatesData, isLoading: templatesLoading } = 17 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]); 18 34 19 35 if (userLoading || cvsLoading || templatesLoading) { 20 36 return ( ··· 48 64 description={`Welcome back, ${user?.name ?? "User"}! Here's your overview.`} 49 65 /> 50 66 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> 93 + )} 94 + 51 95 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> 52 - {/* Stats cards */} 53 96 <div className="rounded-lg bg-ctp-crust/40 p-6 shadow"> 54 97 <h3 className="text-lg font-medium text-ctp-text">CVs Created</h3> 55 98 <p className="mt-2 text-3xl font-bold text-ctp-blue">{totalCVs}</p> ··· 81 124 </div> 82 125 </div> 83 126 84 - {/* Quick actions */} 85 127 <div className="mt-8"> 86 128 <h2 className="mb-4 text-xl font-semibold text-ctp-text"> 87 129 Quick Actions ··· 102 144 </div> 103 145 </div> 104 146 105 - {/* Recent CVs */} 106 147 {cvs.length > 0 && ( 107 148 <div className="mt-8"> 108 149 <h2 className="mb-4 text-xl font-semibold text-ctp-text">
+97
apps/client/src/pages/OnboardingPage.tsx
··· 1 + import { PageHeader } from "@cv/ui"; 2 + import { useState } from "react"; 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"; 8 + 9 + export default function OnboardingPage() { 10 + const { step: stepParam } = useParams<{ step?: string }>(); 11 + const navigate = useNavigate(); 12 + const [parsedData, setParsedData] = 13 + useState<ParsedCVDataWithResolution | null>(null); 14 + 15 + const currentStep = stepParam || "select-method"; 16 + 17 + const handleSelectMethod = (method: string) => { 18 + // Reset parsed data when selecting a new method 19 + setParsedData(null); 20 + navigate(`/onboarding/${method}`); 21 + }; 22 + 23 + const handleFileParsed = (data: ParsedCVDataWithResolution) => { 24 + setParsedData(data); 25 + navigate("/onboarding/review"); 26 + }; 27 + 28 + const handleComplete = () => { 29 + navigate("/"); 30 + }; 31 + 32 + const renderStep = () => { 33 + switch (currentStep) { 34 + case "select-method": 35 + return <OnboardingMethodSelector onMethodSelect={handleSelectMethod} />; 36 + 37 + case "file-upload": 38 + return ( 39 + <FileUploadStep 40 + onComplete={handleFileParsed} 41 + onBack={() => navigate("/onboarding")} 42 + /> 43 + ); 44 + 45 + case "review": 46 + return parsedData ? ( 47 + <ReviewStep 48 + parsedData={parsedData} 49 + onComplete={handleComplete} 50 + onBack={() => navigate("/onboarding")} 51 + onDataChange={setParsedData} 52 + /> 53 + ) : ( 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> 72 + <button 73 + type="button" 74 + onClick={() => navigate("/onboarding")} 75 + className="mt-4 text-ctp-blue hover:underline" 76 + > 77 + Back to Start 78 + </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()} 95 + </div> 96 + ); 97 + }
+17
apps/client/src/router/AppRouter.tsx
··· 30 30 const DashboardPage = lazy(() => import("@/pages/DashboardPage")); 31 31 const EducationPage = lazy(() => import("@/pages/EducationPage")); 32 32 const JobExperiencePage = lazy(() => import("@/pages/JobExperiencePage")); 33 + const OnboardingPage = lazy(() => import("@/pages/OnboardingPage")); 33 34 const OrganizationsPage = lazy(() => import("@/pages/OrganizationsPage")); 34 35 const ProfilePage = lazy(() => import("@/pages/ProfilePage")); 35 36 const VacanciesPage = lazy(() => import("@/pages/VacanciesPage")); ··· 103 104 element={ 104 105 <Suspense fallback={<PageLoader />}> 105 106 <DashboardPage /> 107 + </Suspense> 108 + } 109 + /> 110 + <Route 111 + path="onboarding" 112 + element={ 113 + <Suspense fallback={<PageLoader />}> 114 + <OnboardingPage /> 115 + </Suspense> 116 + } 117 + /> 118 + <Route 119 + path="onboarding/:step" 120 + element={ 121 + <Suspense fallback={<PageLoader />}> 122 + <OnboardingPage /> 106 123 </Suspense> 107 124 } 108 125 />
+10
apps/client/vite.config.ts
··· 9 9 port: 5173, 10 10 host: true, 11 11 historyApiFallback: true, 12 + proxy: { 13 + "/api": { 14 + target: process.env.VITE_PROXY_TARGET || "http://localhost:3000", 15 + changeOrigin: true, 16 + }, 17 + "/graphql": { 18 + target: process.env.VITE_PROXY_TARGET || "http://localhost:3000", 19 + changeOrigin: true, 20 + }, 21 + }, 12 22 }, 13 23 preview: { 14 24 port: 5173,