diff --git a/apps/web/package.json b/apps/web/package.json index b6704cac..a97a0203 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,8 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/web/src/app/dashboard/@header/account/page.tsx b/apps/web/src/app/dashboard/@header/account/page.tsx new file mode 100644 index 00000000..e3c55fa5 --- /dev/null +++ b/apps/web/src/app/dashboard/@header/account/page.tsx @@ -0,0 +1,5 @@ +import { AppHeader } from "@/app/dashboard/components/app-header"; + +export default function AdminHeader() { + return ; +} diff --git a/apps/web/src/app/dashboard/account/page.tsx b/apps/web/src/app/dashboard/account/page.tsx new file mode 100644 index 00000000..9bf385e7 --- /dev/null +++ b/apps/web/src/app/dashboard/account/page.tsx @@ -0,0 +1,797 @@ +"use client"; + +import { api } from "@albert-plus/server/convex/_generated/api"; +import type { Doc, Id } from "@albert-plus/server/convex/_generated/dataModel"; +import { useUser } from "@clerk/nextjs"; +import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; +import { useForm } from "@tanstack/react-form"; +import { + useConvexAuth, + useMutation, + usePaginatedQuery, + useQuery, +} from "convex/react"; +import type { FunctionArgs } from "convex/server"; +import { Mail, MapPin, Pencil } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import React from "react"; +import { toast } from "sonner"; +import z from "zod"; +import { SchoolCombobox } from "@/app/onboarding/component/school-combobox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + FieldContent, + FieldError, + Field as UIField, +} from "@/components/ui/field"; +import { Label } from "@/components/ui/label"; +import MultipleSelector, { type Option } from "@/components/ui/multiselect"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import DegreeProgreeUpload from "@/modules/report-parsing/components/degree-progress-upload"; +import type { UserCourse } from "@/modules/report-parsing/types"; +import type { StartingTerm } from "@/modules/report-parsing/utils/parse-starting-term"; +import { getTermAfterSemesters, type Term } from "@/utils/term"; + +const dateSchema = z.object({ + year: z.number().int().min(2000).max(2100), + term: z.union([ + z.literal("spring"), + z.literal("fall"), + z.literal("j-term"), + z.literal("summer"), + ]), +}); + +const onboardingFormSchema = z + .object({ + school: z.string({ + error: (issue) => + issue.input === undefined ? "Please select a school" : "Invalid input", + }), + programs: z.array(z.string()).min(1, "At least one program is required"), + startingDate: dateSchema, + expectedGraduationDate: dateSchema, + // User courses + userCourses: z.array(z.object()).optional(), + }) + .refine( + (data) => { + const startYear = data.startingDate.year; + const startTerm = data.startingDate.term; + const endYear = data.expectedGraduationDate.year; + const endTerm = data.expectedGraduationDate.term; + + // Convert to comparable numbers (spring=0, fall=1) + const startValue = startYear * 2 + (startTerm === "fall" ? 1 : 0); + const endValue = endYear * 2 + (endTerm === "fall" ? 1 : 0); + + return endValue > startValue; + }, + { + message: "Expected graduation date must be after starting date", + path: ["expectedGraduationDate"], + }, + ); + +function ProfileHeaderSkeleton() { + return ( + + +
+ +
+ +
+ +
+ + +
+
+
+
+
+
+ ); +} + +function AcademicInfoSkeleton() { + return ( + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} + +function DegreeProgressSkeleton() { + return ( + + + + + + + + + + ); +} + +export default function ProfilePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { isAuthenticated } = useConvexAuth(); + const [editingProfile, setEditingProfile] = React.useState(false); + const [isFileLoaded, setIsFileLoaded] = React.useState(false); + + const student = useQuery( + api.students.getCurrentStudent, + isAuthenticated ? {} : "skip", + ); + + const { user } = useUser(); + + // Get tab from query params, default to "personal" + const activeTab = searchParams.get("tab") || "personal"; + + // Function to update tab in URL + const setActiveTab = React.useCallback( + (tab: string) => { + const params = new URLSearchParams(searchParams); + params.set("tab", tab); + router.replace(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams], + ); + + // actions + const upsertStudent = useMutation(api.students.upsertCurrentStudent); + const updateStudent = useMutation(api.students.updateCurrentStudent); + const importUserCourses = useMutation(api.userCourses.importUserCourses); + + // schools + const schools = useQuery( + api.schools.getSchools, + isAuthenticated ? {} : ("skip" as const), + ); + + // Programs + const [programSearchInput, setProgramSearchInput] = + React.useState(""); + + const { results: programs } = usePaginatedQuery( + api.programs.getPrograms, + isAuthenticated + ? { query: programSearchInput.trim() || undefined } + : ("skip" as const), + { initialNumItems: 20 }, + ); + + const programOptions = React.useMemo( + () => + (programs ?? []).map((program) => ({ + value: program._id, + label: `${program.name} - ${program.school}`, + })), + [programs], + ); + + const programLabelCache = React.useRef, string>>( + new Map(), + ); + + React.useEffect(() => { + programOptions.forEach((option) => { + programLabelCache.current.set( + option.value as Id<"programs">, + option.label, + ); + }); + }, [programOptions]); + + const currentYear = React.useMemo(() => new Date().getFullYear(), []); + const defaultTerm = React.useMemo(() => { + const month = new Date().getMonth(); + return month >= 6 ? "fall" : "spring"; + }, []); + const defaultStartingDate = { + year: currentYear, + term: defaultTerm, + }; + const defaultExpectedGraduation = getTermAfterSemesters( + { + term: defaultTerm, + year: currentYear, + }, + 14, + ); + + // Generate year options: currentYear ± 4 years + const yearOptions = React.useMemo(() => { + const years: number[] = []; + for (let i = currentYear - 5; i <= currentYear + 5; i++) { + years.push(i); + } + return years; + }, [currentYear]); + + async function handleConfirmImport( + coursesToImport: UserCourse[], + startingTerm: StartingTerm | null, + ) { + if (coursesToImport.length === 0) { + return; + } + + // Update starting date if available + if (startingTerm) { + await updateStudent({ startingDate: startingTerm }); + } + + const result = await importUserCourses({ courses: coursesToImport }); + + const messages: string[] = []; + if (result) { + if (result.inserted > 0) { + messages.push( + `${result.inserted} new course${result.inserted !== 1 ? "s" : ""} imported`, + ); + } + if (result.updated > 0) { + messages.push( + `${result.updated} course${result.updated !== 1 ? "s" : ""} updated with grades`, + ); + } + if (result.duplicates > 0) { + messages.push( + `${result.duplicates} duplicate${result.duplicates !== 1 ? "s" : ""} skipped`, + ); + } + } + + const successMessage = + messages.length > 0 + ? `Import complete: ${messages.join(", ")}` + : "Import complete"; + + toast.success(successMessage); + // Don't show "PDF Selected" state - reset to allow another upload + setIsFileLoaded(false); + } + + const form = useForm({ + defaultValues: { + // student data + school: undefined as Id<"schools"> | undefined, + programs: [] as Id<"programs">[], + startingDate: defaultStartingDate as Doc<"students">["startingDate"], + expectedGraduationDate: + defaultExpectedGraduation as Doc<"students">["expectedGraduationDate"], + // user courses + userCourses: undefined as + | FunctionArgs["courses"] + | undefined, + }, + validators: { + onSubmit: ({ value }) => { + const result = onboardingFormSchema.safeParse(value); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { + const path = issue.path.join("."); + fieldErrors[path] = [{ message: issue.message }]; + } + return { + fields: fieldErrors, + }; + } + return undefined; + }, + }, + onSubmit: async ({ value }) => { + if (editingProfile) { + try { + toast.success("Successfully updated profile."); + // router.push("/dashboard"); + + await upsertStudent({ + school: value.school as Id<"schools">, + programs: value.programs, + startingDate: value.startingDate, + expectedGraduationDate: value.expectedGraduationDate, + }); + + setEditingProfile(false); + + if (value.userCourses) { + await importUserCourses({ courses: value.userCourses }); + } + } catch (error) { + console.error("Error completing onboarding:", error); + toast.error("Could not complete onboarding. Please try again."); + } + } + }, + }); + + React.useEffect(() => { + if (!student) return; + + // school: student.school can be an object or null + form.setFieldValue("school", student.school?._id ?? undefined); + + // programs: student.programs might be an array of objects or array of ids + const programIds: Id<"programs">[] = []; + (student.programs ?? []).forEach( + ( + p: + | Id<"programs"> + | { _id: Id<"programs">; name: string; school?: string }, + ) => { + const id = typeof p === "string" ? p : p._id; + programIds.push(id); + + // Populate cache with existing student programs + if (typeof p !== "string" && p.name) { + const label = p.school ? `${p.name} - ${p.school}` : p.name; + programLabelCache.current.set(id, label); + } + }, + ); + + form.setFieldValue("programs", programIds); + }, [student, form.setFieldValue]); + + // Loading state - student is undefined while loading + const isLoading = student === undefined; + + return ( +
+ + {isLoading ? ( + + ) : ( + + +
+
+ + {user?.imageUrl ? ( + + ) : ( + + {`${user?.firstName?.[0] || "U"}${user?.lastName?.[0] || "U"}`} + + )} + +
+
+
+

+ {user?.fullName || "Unknown User"} +

+
+ {student && ( +

+ {student.school?.level + ? student.school.level.charAt(0).toUpperCase() + + student.school.level.slice(1).toLowerCase() + + " Student" + : "Student"} +

+ )} +
+
+ + {user?.primaryEmailAddress?.emailAddress || ""} +
+ {student?.school?.name && ( +
+ + {student.school.name} +
+ )} +
+
+
+
+
+ )} + + Academic Profile + + Degree Progress Report + + + + {/* Academic Profile Tab */} + + {isLoading ? ( + + ) : student ? ( + + +
+ Academic Information + + View and update your academic information here. + +
+ {!editingProfile && ( + + )} +
+
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-6" + > + +
+
+ + {editingProfile && ( + + {(field) => { + return ( + + + + field.handleChange(value) + } + /> + + + + ); + }} + + )} + {!editingProfile && ( +

+ {student.school + ? `${student.school.name} (${ + student.school.level + ? student.school.level + .charAt(0) + .toUpperCase() + + student.school.level.slice(1).toLowerCase() + : "" + })` + : "N/A"} +

+ )} + {/* */} +
+
+ + {!editingProfile && ( +

+ {student.programs?.length > 0 + ? student.programs.map((p) => p.name).join(", ") + : "None"} +

+ )} + {editingProfile && ( + + {(field) => { + const selected = (field.state.value ?? []).map( + (p) => ({ + value: p, + label: + programOptions.find((val) => val.value === p) + ?.label || + programLabelCache.current.get(p) || + "", + }), + ); + return ( + + + + field.handleChange( + opts.map( + (o) => o.value as Id<"programs">, + ), + ) + } + defaultOptions={programOptions} + options={programOptions} + placeholder="Select your programs" + commandProps={{ + label: "Select programs", + shouldFilter: false, + }} + inputProps={{ + onValueChange: (value: string) => { + setProgramSearchInput(value); + }, + }} + emptyIndicator={ +

+ No programs found +

+ } + /> +
+ +
+ ); + }} +
+ )} + + {/* */} +
+
+ + {editingProfile && ( +
+ {/* startingDate.term */} + + {(field) => ( + + )} + + {/* startingDate.year */} + + {(field) => ( + + )} + +
+ )} + {!editingProfile && ( +

+ {student.startingDate + ? `${student.startingDate.term.charAt(0).toUpperCase()}${student.startingDate.term.slice(1)} ${student.startingDate.year}` + : "N/A"} +

+ )} +
+
+ + {editingProfile && ( +
+ {/* expectedGraduationDate.term */} + + {(field) => ( + + )} + + {/* expectedGraduationDate.year */} + + {(field) => ( + + )} + +
+ )} + {!editingProfile && ( +

+ {student.expectedGraduationDate + ? `${student.expectedGraduationDate.term.charAt(0).toUpperCase()}${student.expectedGraduationDate.term.slice(1)} ${student.expectedGraduationDate.year}` + : "N/A"} +

+ )} +
+
+
+ {editingProfile && ( + +
+ + +
+
+ )} +
+
+ ) : null} +
+ + {/* Degree Progress Report Tab */} + + {isLoading ? ( + + ) : ( + + + + Degree Progress Report + + + + ? + + + +

+ We do not store your degree progress report. Need help + finding it?{" "} + + View NYU's guide + +

+
+
+
+ + Upload a PDF of your degree progress report so we can help you + track your academic progress and suggest courses. + +
+ + setIsFileLoaded(false)} + /> + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/onboarding/component/onboarding-form.tsx b/apps/web/src/app/onboarding/component/onboarding-form.tsx index f881577f..3884d5b1 100644 --- a/apps/web/src/app/onboarding/component/onboarding-form.tsx +++ b/apps/web/src/app/onboarding/component/onboarding-form.tsx @@ -399,7 +399,7 @@ export function OnboardingForm() { return ( - What school or college of NYU do you go to? + What school or college of NYU do you attend? , - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Avatar.displayName = AvatarPrimitive.Root.displayName; +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarImage.displayName = AvatarPrimitive.Image.displayName; +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} -export { Avatar, AvatarImage, AvatarFallback }; +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx new file mode 100644 index 00000000..6a2b5241 --- /dev/null +++ b/apps/web/src/components/ui/switch.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index 34950573..497ba5ea 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Tabs as TabsPrimitive } from "radix-ui" +import * as TabsPrimitive from "@radix-ui/react-tabs" import { cn } from "@/lib/utils" @@ -26,7 +26,7 @@ function TabsList({ ) {