Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions apps/web/src/app/dashboard/components/sidebar/nav-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,25 @@ export function NavItems({
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{items.map((item) => {
const isExternal = item.url.startsWith("http");
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a
href={item.url}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { redirect } from "next/navigation";

const HomePage = () => {
return;
// TODO: homepage is not ready yet, hide if from MVP for now
redirect("/dashboard/register");
};

export default HomePage;
169 changes: 133 additions & 36 deletions apps/web/src/app/onboarding/component/onboarding-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { api } from "@albert-plus/server/convex/_generated/api";
import type { Doc, Id } from "@albert-plus/server/convex/_generated/dataModel";
import { useClerk, useUser } from "@clerk/nextjs";
import { useForm } from "@tanstack/react-form";
import {
useConvexAuth,
Expand All @@ -10,6 +11,7 @@ import {
useQuery,
} from "convex/react";
import type { FunctionArgs } from "convex/server";
import { LogOutIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { Activity } from "react";
import { toast } from "sonner";
Expand All @@ -25,13 +27,16 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
Field as UIField,
} from "@/components/ui/field";
import { Label } from "@/components/ui/label";
import MultipleSelector from "@/components/ui/multiselect";
import {
Select,
Expand Down Expand Up @@ -72,6 +77,8 @@ const onboardingFormSchema = z
expectedGraduationDate: dateSchema,
// User courses
userCourses: z.array(z.object()).optional(),
// Final presentation invite
attendPresentation: z.boolean().optional(),
})
.refine(
(data) => {
Expand All @@ -94,13 +101,16 @@ const onboardingFormSchema = z

export function OnboardingForm() {
const router = useRouter();
const { user } = useUser();
const { signOut } = useClerk();
const { isAuthenticated } = useConvexAuth();
const [isFileLoaded, setIsFileLoaded] = React.useState(false);
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1);

// actions
const upsertStudent = useMutation(api.students.upsertCurrentStudent);
const importUserCourses = useMutation(api.userCourses.importUserCourses);
const createInvite = useMutation(api.studentInvites.createInvite);

// schools
const schools = useQuery(
Expand Down Expand Up @@ -183,6 +193,8 @@ export function OnboardingForm() {
userCourses: undefined as
| FunctionArgs<typeof api.userCourses.importUserCourses>["courses"]
| undefined,
// final presentation
attendPresentation: false,
},
validators: {
onSubmit: ({ value }) => {
Expand Down Expand Up @@ -215,6 +227,14 @@ export function OnboardingForm() {
if (value.userCourses) {
await importUserCourses({ courses: value.userCourses });
}

if (value.attendPresentation && user) {
await createInvite({
name: user.fullName || "Unknown",
email:
user.primaryEmailAddress?.emailAddress || "[email protected]",
});
}
} catch (error) {
console.error("Error completing onboarding:", error);
toast.error("Could not complete onboarding. Please try again.");
Expand Down Expand Up @@ -255,37 +275,51 @@ export function OnboardingForm() {
<Activity mode={currentStep === 1 ? "visible" : "hidden"}>
<Card>
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2">
Degree Progress Report
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="cursor-help size-5 rounded-full p-0 text-xs hover:bg-muted"
>
?
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
We do not store your degree progress report. Need help
finding it?{" "}
<a
href="https://www.nyu.edu/students/student-information-and-resources/registration-records-and-graduation/registration/tracking-degree-progress.html"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View NYU's guide
</a>
</p>
</TooltipContent>
</Tooltip>
</CardTitle>
<CardDescription>
Upload your degree progress report (PDF) so we can help you track
your academic progress and suggest courses.
</CardDescription>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-2xl flex items-center gap-2">
Degree Progress Report
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="cursor-help size-5 rounded-full p-0 text-xs hover:bg-muted"
>
?
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
We do not store your degree progress report. Need help
finding it?{" "}
<a
href="https://www.nyu.edu/students/student-information-and-resources/registration-records-and-graduation/registration/tracking-degree-progress.html"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
View NYU's guide
</a>
</p>
</TooltipContent>
</Tooltip>
</CardTitle>
<CardDescription>
Upload your degree progress report (PDF) so we can help you
track your academic progress and suggest courses.
</CardDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => signOut({ redirectUrl: "/" })}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<LogOutIcon className="size-4" />
Sign out
</Button>
</div>
</CardHeader>
<CardContent>
<DegreeProgreeUpload
Expand Down Expand Up @@ -337,11 +371,25 @@ export function OnboardingForm() {
<Activity mode={currentStep === 2 ? "visible" : "hidden"}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Academic Information</CardTitle>
<CardDescription>
Tell us about your academic background so we can personalize your
experience.
</CardDescription>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-2xl">Academic Information</CardTitle>
<CardDescription>
Tell us about your academic background so we can personalize
your experience.
</CardDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => signOut({ redirectUrl: "/" })}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<LogOutIcon className="size-4" />
Sign out
</Button>
</div>
</CardHeader>
<CardContent>
<FieldGroup>
Expand Down Expand Up @@ -545,6 +593,55 @@ export function OnboardingForm() {
{(field) => <FieldError errors={field.state.meta.errors} />}
</form.Field>
</FieldGroup>

<div className="rounded-lg border border-border/40 bg-muted/5 p-5">
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-base font-semibold">
Tech@NYU Final Presentation RSVP
</h3>
<p className="text-sm text-muted-foreground">
The Tech@NYU Dev Team will showcase the Albert Plus
project during our final presentation between December 7
and December 12, 2025. Let us know if you'd like to join
so we can send the exact date, time, and location details.
</p>
</div>
{/* Final presentation invite */}
<form.Field name="attendPresentation">
{(field) => (
<div className="space-y-2">
<UIField
orientation="horizontal"
className="items-start gap-3"
>
<Checkbox
id={field.name}
checked={Boolean(field.state.value)}
onCheckedChange={(checked) =>
field.handleChange(checked === true)
}
aria-describedby={`${field.name}-description`}
/>
<FieldContent>
<Label
htmlFor={field.name}
className="text-sm font-medium leading-snug"
>
I plan to attend the final presentation
</Label>
<FieldDescription id={`${field.name}-description`}>
We'll email you an RSVP as soon as the schedule is
finalized.
</FieldDescription>
</FieldContent>
</UIField>
<FieldError errors={field.state.meta.errors} />
</div>
)}
</form.Field>
</div>
</div>
</FieldGroup>
</CardContent>
<CardFooter>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ const config = {
],
navBottom: [
{ title: "Settings", url: "#settings", icon: Settings },
{ title: "Feedback", url: "/feedback", icon: Send },
{
title: "Feedback",
url: "https://techatnyu.featurebase.app/",
icon: Send,
},
],
},
};
Expand Down
4 changes: 4 additions & 0 deletions packages/server/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import type * as schemas_courseOfferings from "../schemas/courseOfferings.js";
import type * as schemas_courses from "../schemas/courses.js";
import type * as schemas_programs from "../schemas/programs.js";
import type * as schemas_schools from "../schemas/schools.js";
import type * as schemas_studentInvites from "../schemas/studentInvites.js";
import type * as schemas_students from "../schemas/students.js";
import type * as schools from "../schools.js";
import type * as scraper from "../scraper.js";
import type * as seed from "../seed.js";
import type * as studentInvites from "../studentInvites.js";
import type * as students from "../students.js";
import type * as userCourseOfferings from "../userCourseOfferings.js";
import type * as userCourses from "../userCourses.js";
Expand All @@ -49,10 +51,12 @@ declare const fullApi: ApiFromModules<{
"schemas/courses": typeof schemas_courses;
"schemas/programs": typeof schemas_programs;
"schemas/schools": typeof schemas_schools;
"schemas/studentInvites": typeof schemas_studentInvites;
"schemas/students": typeof schemas_students;
schools: typeof schools;
scraper: typeof scraper;
seed: typeof seed;
studentInvites: typeof studentInvites;
students: typeof students;
userCourseOfferings: typeof userCourseOfferings;
userCourses: typeof userCourses;
Expand Down
2 changes: 2 additions & 0 deletions packages/server/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { courses, prerequisites, userCourses } from "./schemas/courses";
import { programs, requirements } from "./schemas/programs";
import { schools } from "./schemas/schools";
import { studentInvites } from "./schemas/studentInvites";
import { students } from "./schemas/students";

export default defineSchema({
Expand Down Expand Up @@ -49,4 +50,5 @@ export default defineSchema({
]),
students: defineTable(students).index("by_user_id", ["userId"]),
schools: defineTable(schools).index("by_name_level", ["name", "level"]),
studentInvites: defineTable(studentInvites).index("by_user_id", ["userId"]),
});
9 changes: 9 additions & 0 deletions packages/server/convex/schemas/studentInvites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { v } from "convex/values";

const studentInvites = {
userId: v.string(),
name: v.string(),
email: v.string(),
};

export { studentInvites };
36 changes: 36 additions & 0 deletions packages/server/convex/studentInvites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ConvexError } from "convex/values";
import { omit } from "convex-helpers";
import { protectedMutation, protectedQuery } from "./helpers/auth";
import { studentInvites } from "./schemas/studentInvites";

export const getCurrentUserInvite = protectedQuery({
args: {},
handler: async (ctx) => {
const invite = await ctx.db
.query("studentInvites")
.withIndex("by_user_id", (q) => q.eq("userId", ctx.user.subject))
.unique();

return invite;
},
});

export const createInvite = protectedMutation({
args: omit(studentInvites, ["userId"]),
handler: async (ctx, args) => {
// Check if invite already exists
const existing = await ctx.db
.query("studentInvites")
.withIndex("by_user_id", (q) => q.eq("userId", ctx.user.subject))
.unique();

if (existing) {
throw new ConvexError("Invite already exists for this user");
}

return await ctx.db.insert("studentInvites", {
...args,
userId: ctx.user.subject,
});
},
});