diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100644 index 0000000..2049ba0 --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/app/(pages)/(dashboard)/layout.tsx b/apps/web/src/app/(pages)/(dashboard)/layout.tsx index eb52cd6..4a4dc3d 100644 --- a/apps/web/src/app/(pages)/(dashboard)/layout.tsx +++ b/apps/web/src/app/(pages)/(dashboard)/layout.tsx @@ -1,6 +1,7 @@ import { Toaster } from "@cooper/ui/toaster"; import HeaderLayout from "~/app/_components/header-layout"; +import OnboardingWrapper from "~/app/_components/onboarding/onboarding-wrapper"; export default function RootLayout({ children, @@ -9,8 +10,10 @@ export default function RootLayout({ }) { return ( - {children} - + + {children} + + ); } diff --git a/apps/web/src/app/_components/onboarding/constants/index.ts b/apps/web/src/app/_components/onboarding/constants/index.ts new file mode 100644 index 0000000..14c3b30 --- /dev/null +++ b/apps/web/src/app/_components/onboarding/constants/index.ts @@ -0,0 +1,21 @@ +export const monthOptions = [ + { value: "1", label: "January" }, + { value: "2", label: "February" }, + { value: "3", label: "March" }, + { value: "4", label: "April" }, + { value: "5", label: "May" }, + { value: "6", label: "June" }, + { value: "7", label: "July" }, + { value: "8", label: "August" }, + { value: "9", label: "September" }, + { value: "10", label: "October" }, + { value: "11", label: "November" }, + { value: "12", label: "December" }, +]; + +export const majors = [ + "Computer Science", + "Computer Science + Math", + "Computer Science + Business", + "Computer Science + Design", +]; diff --git a/apps/web/src/app/_components/onboarding/dialog.tsx b/apps/web/src/app/_components/onboarding/dialog.tsx new file mode 100644 index 0000000..8e98e64 --- /dev/null +++ b/apps/web/src/app/_components/onboarding/dialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; + +import type { Session } from "@cooper/auth"; +import { Dialog, DialogContent } from "@cooper/ui/dialog"; + +import { OnboardingForm } from "~/app/_components/onboarding/onboarding-form"; +import { api } from "~/trpc/react"; + +interface OnboardingDialogProps { + isOpen?: boolean; + session: Session | null; +} + +/** + * OnboardingDialog component that handles user onboarding. + * Implementation note: Use OnboardingWrapper to wrap the component and initiate the dialog. + * @param isOpen - Whether the dialog is open + * @param session - The current user session + * @returns The OnboardingDialog component or null + */ +export function OnboardingDialog({ + isOpen = true, + session, +}: OnboardingDialogProps) { + const [open, setOpen] = useState(isOpen); + + const profile = api.profile.getCurrentUser.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + + const shouldShowOnboarding = session && !profile.data; + + const closeDialog = () => { + setOpen(false); + }; + + if (profile.isPending || !shouldShowOnboarding) { + return null; + } + + return ( + + + + + + ); +} diff --git a/apps/web/src/app/_components/onboarding/onboarding-form.tsx b/apps/web/src/app/_components/onboarding/onboarding-form.tsx new file mode 100644 index 0000000..585af33 --- /dev/null +++ b/apps/web/src/app/_components/onboarding/onboarding-form.tsx @@ -0,0 +1,280 @@ +import { useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import type { Session } from "@cooper/auth"; +import { cn } from "@cooper/ui"; +import { Button } from "@cooper/ui/button"; +import { DialogTitle } from "@cooper/ui/dialog"; +import { Form, FormControl, FormField, FormItem } from "@cooper/ui/form"; + +import { + FormLabel, + FormMessage, +} from "~/app/_components/themed/onboarding/form"; +import { Input } from "~/app/_components/themed/onboarding/input"; +import { api } from "~/trpc/react"; +import { Select } from "../themed/onboarding/select"; +import { majors, monthOptions } from "./constants"; +import { BrowseAroundPrompt } from "./post-onboarding/browse-around-prompt"; +import { CoopPrompt } from "./post-onboarding/coop-prompt"; + +const currentYear = new Date().getFullYear(); + +const formSchema = z.object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + email: z + .string() + .min(1, { message: "Email is required" }) + .email("This is not a valid email"), + major: z.string().min(1, "Major is required"), + minor: z.string().optional(), + graduationYear: z.coerce + .number() + .min(2010, "Graduation year must be 2010 or later") + .max(currentYear + 6, "Graduation year must be within the next 5 years"), + graduationMonth: z.coerce + .number() + .min(1, "Graduation month is required") + .max(12, "Invalid month"), + cooped: z.boolean({ + required_error: "Please select whether you've completed a co-op before", + }), +}); + +export type OnboardingFormType = z.infer; + +interface OnboardingFormProps { + userId: string; + closeDialog: () => void; + session: Session; +} + +/** + * OnboardingForm component that handles user onboarding. + * @param userId - The user ID + * @param closeDialog - The function to close the dialog + * @param session - The current user session + * @returns The OnboardingForm component + */ +export function OnboardingForm({ + userId, + closeDialog, + session, +}: OnboardingFormProps) { + const [cooped, setCooped] = useState(undefined); + const profile = api.profile.create.useMutation(); + + const names = (session.user.name ?? " ").split(" "); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + firstName: names.length > 0 ? names[0] : "", + lastName: names.length > 1 ? names[1] : "", + email: session.user.email ?? "", + major: "", + minor: undefined, + graduationYear: undefined, + graduationMonth: 0, + cooped: undefined, + }, + }); + + const onSubmit = (data: OnboardingFormType) => { + profile.mutate({ userId, ...data }); + }; + + if (profile.isSuccess) { + const firstName = form.getValues("firstName"); + + return cooped ? ( + + ) : ( + + ); + } + + return ( + <> + + Create a Cooper Account + +
+

+ * Required +

+ +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Email + + + + + + )} + /> +
+ ( + + Major + + + + + + )} + /> +
+
+ ( + + Graduation Year + + + + + + )} + /> + ( + + Graduation Month + + + {placeholder && } + {options.map((option) => ( + + ))} + +
+ + + +
+
+ ); +}; diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index b94ab96..d7f8506 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -6,7 +6,7 @@ import { cva } from "class-variance-authority"; import { cn } from "@cooper/ui"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-full text-lg font-semibold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-lg font-semibold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { @@ -22,9 +22,9 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-10 rounded-full p-6", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", + default: "h-10 p-6", + sm: "h-9 px-3", + lg: "h-11 px-8", icon: "h-10 w-10", }, }, diff --git a/tooling/tailwind/web.ts b/tooling/tailwind/web.ts index 6602cfa..1329d98 100644 --- a/tooling/tailwind/web.ts +++ b/tooling/tailwind/web.ts @@ -22,6 +22,8 @@ export default { colors: { "cooper-gray-100": "#DDE8F0", "cooper-gray-200": "#64748B", + "cooper-gray-300": "#E6E6E6", + "cooper-gray-400": "#949494", "cooper-blue-800": "#5A9478", "cooper-blue-700": "#1D679C", "cooper-blue-600": "#436F8E",