diff --git a/apps/app-portal/package.json b/apps/app-portal/package.json index 2e8c5684..62415b98 100644 --- a/apps/app-portal/package.json +++ b/apps/app-portal/package.json @@ -12,10 +12,12 @@ "dependencies": { "@repo/ui": "*", "@repo/util": "*", + "@hookform/resolvers": "^5.2.2", "next": "^14.2.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.75.0" + "react-hook-form": "^7.75.0", + "zod": "^3.25.67" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", diff --git a/apps/app-portal/src/app/(applicant)/dashboard/loading.tsx b/apps/app-portal/src/app/(applicant)/dashboard/loading.tsx new file mode 100644 index 00000000..a4fbd70c --- /dev/null +++ b/apps/app-portal/src/app/(applicant)/dashboard/loading.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +export default function Loading(): JSX.Element { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/app-portal/src/app/(applicant)/dashboard/page.tsx b/apps/app-portal/src/app/(applicant)/dashboard/page.tsx index b9a78d3e..885f76e6 100644 --- a/apps/app-portal/src/app/(applicant)/dashboard/page.tsx +++ b/apps/app-portal/src/app/(applicant)/dashboard/page.tsx @@ -1,7 +1,10 @@ import React from "react"; -import { returnDashboardBranch } from "../../../lib/status/machine"; -import { decisionDates } from "../../../lib/status/mock-singletons"; -import { getApplicantStatus } from "../../../lib/status/service"; +import { fetchPortalStatus } from "../../../lib/status/fetchPortalStatus"; +import type { + ApplicantStatus, + DashboardBranch, + SerializedDecisionDates, +} from "../../../lib/status/types"; import PreRegistrationView from "../../../components/dashboard/PreRegistrationView"; import InProgressView from "../../../components/dashboard/InProgressView"; import SubmittedView from "../../../components/dashboard/SubmittedView"; @@ -10,24 +13,56 @@ import WaitlistedView from "../../../components/dashboard/WaitlistedView"; import DeclinedView from "../../../components/dashboard/DeclinedView"; export default async function DashboardPage(): Promise { - const status = await getApplicantStatus("mock-user"); - const showDecision = new Date() >= decisionDates.showDecision; - const branch = returnDashboardBranch(status, decisionDates, showDecision); + let branch: DashboardBranch = "submitted"; + let status: ApplicantStatus | null = null; + let decisionDates: SerializedDecisionDates = { + registrationOpen: new Date().toISOString(), + showDecision: new Date().toISOString(), + confirmBy: new Date().toISOString(), + }; + + try { + const res = await fetchPortalStatus(); + branch = res.branch; + status = res.status; + decisionDates = res.decisionDates; + } catch (err) { + // If fetch fails, render a simple error view instead of crashing the page. + return ( +
+

Unable to load dashboard

+

{String(err)}

+
+ ); + } + // Ensure `status` is present before rendering views that require it. + if (!status) { + return ( +
+

Loading dashboard…

+
+ ); + } + const resolvedDates = { + registrationOpen: new Date(decisionDates.registrationOpen), + showDecision: new Date(decisionDates.showDecision), + confirmBy: new Date(decisionDates.confirmBy), + }; switch (branch) { case "pre-registration": - return ; + return ; case "in-progress": - return ; + return ; case "submitted": - return ; + return ; case "admitted": - return ; + return ; case "waitlisted": - return ; + return ; case "declined": return ; default: - return ; + return ; } } diff --git a/apps/app-portal/src/app/(applicant)/layout.tsx b/apps/app-portal/src/app/(applicant)/layout.tsx new file mode 100644 index 00000000..0e59cbbc --- /dev/null +++ b/apps/app-portal/src/app/(applicant)/layout.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import Sidebar from "../../components/Sidebar"; + +export const metadata = { + title: "Applicant Portal", +}; + +export default function ApplicantLayout({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + return ( +
+
+ +
{children}
+
+
+ ); +} diff --git a/apps/app-portal/src/app/(applicant)/rsvp/loading.tsx b/apps/app-portal/src/app/(applicant)/rsvp/loading.tsx new file mode 100644 index 00000000..97a7acf3 --- /dev/null +++ b/apps/app-portal/src/app/(applicant)/rsvp/loading.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +export default function Loading(): JSX.Element { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/app-portal/src/app/(applicant)/rsvp/page.tsx b/apps/app-portal/src/app/(applicant)/rsvp/page.tsx index cdda45cd..d5ea5388 100644 --- a/apps/app-portal/src/app/(applicant)/rsvp/page.tsx +++ b/apps/app-portal/src/app/(applicant)/rsvp/page.tsx @@ -1,28 +1,21 @@ import React from "react"; -import { decisionDates } from "../../../lib/status/mock-singletons"; -import { getApplicantStatus } from "../../../lib/status/service"; -import ConfirmByCountdown from "../../../components/dashboard/ConfirmByCountdown"; -import RsvpForm from "../../../components/dashboard/RsvpForm"; +import { redirect } from "next/navigation"; +import { fetchPortalStatus } from "../../../lib/status/fetchPortalStatus"; +import RsvpExperience from "../../../components/dashboard/RsvpExperience"; export default async function RsvpPage(): Promise { - const status = await getApplicantStatus("mock-user"); - const isAdmitted = status.decisionStatus === "admitted"; - const isAfterConfirmBy = new Date() > decisionDates.confirmBy; - const canRsvp = isAdmitted && !isAfterConfirmBy; + const { branch, status, decisionDates } = await fetchPortalStatus(); + const confirmBy = new Date(decisionDates.confirmBy); + const isAfterConfirmBy = Date.now() > confirmBy.getTime(); - return ( -
-

Post-acceptance RSVP

-

confirm your attendance.

-
- -
+ if (branch !== "admitted" || isAfterConfirmBy) { + redirect("/dashboard"); + } - {canRsvp ? ( - - ) : ( -

no RSVP available

- )} -
+ return ( + ); } diff --git a/apps/app-portal/src/app/api/v1/post-acceptance/route.ts b/apps/app-portal/src/app/api/v1/post-acceptance/route.ts index 6cf6f31a..0cfc1db3 100644 --- a/apps/app-portal/src/app/api/v1/post-acceptance/route.ts +++ b/apps/app-portal/src/app/api/v1/post-acceptance/route.ts @@ -1,5 +1,27 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; +import { saveRsvp } from "../../../../lib/status/service"; -export async function POST() { - return NextResponse.json({ error: "error 501" }, { status: 501 }); +const rsvpSchema = z.object({ + attending: z.enum(["yes", "no"]), + dietaryRestrictions: z.string().max(240), + tshirtSize: z.enum(["xs", "s", "m", "l", "xl"]), + accessibilityNeeds: z.string().max(240), + additionalNotes: z.string().max(400), +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const parsed = rsvpSchema.parse(body); + + await saveRsvp("mock-user", parsed); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Unable to save RSVP right now" }, + { status: 400 }, + ); + } } diff --git a/apps/app-portal/src/app/api/v1/status/route.ts b/apps/app-portal/src/app/api/v1/status/route.ts index 1fef0acd..1021b11b 100644 --- a/apps/app-portal/src/app/api/v1/status/route.ts +++ b/apps/app-portal/src/app/api/v1/status/route.ts @@ -1,9 +1,22 @@ import { NextResponse } from "next/server"; +import { decisionDates } from "../../../../lib/status/mock-singletons"; +import { returnDashboardBranch } from "../../../../lib/status/machine"; import { getApplicantStatus } from "../../../../lib/status/service"; export async function GET() { const status = await getApplicantStatus("mock-user"); - return NextResponse.json(status); + const showDecision = new Date() >= decisionDates.showDecision; + const branch = returnDashboardBranch(status, decisionDates, showDecision); + + return NextResponse.json({ + branch, + status, + decisionDates: { + registrationOpen: decisionDates.registrationOpen.toISOString(), + showDecision: decisionDates.showDecision.toISOString(), + confirmBy: decisionDates.confirmBy.toISOString(), + }, + }); } export async function POST() { diff --git a/apps/app-portal/src/components/Sidebar.tsx b/apps/app-portal/src/components/Sidebar.tsx new file mode 100644 index 00000000..80612413 --- /dev/null +++ b/apps/app-portal/src/components/Sidebar.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function Sidebar(): JSX.Element { + const pathname = usePathname(); + + const items = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/rsvp", label: "RSVP" }, + ]; + + return ( + + ); +} diff --git a/apps/app-portal/src/components/dashboard/AdmittedView.tsx b/apps/app-portal/src/components/dashboard/AdmittedView.tsx index 9ccd0ea9..c749dc61 100644 --- a/apps/app-portal/src/components/dashboard/AdmittedView.tsx +++ b/apps/app-portal/src/components/dashboard/AdmittedView.tsx @@ -1,13 +1,68 @@ import React from "react"; import Link from "next/link"; +import PortalShell from "./PortalShell"; +//import { formatLongDate } from "../../lib/status/format"; +import { + //primaryActionClass, + secondaryActionClass, + statCardClass, +} from "./styles"; +import type { ApplicantStatus, DecisionDates } from "../../lib/status/types"; -export default function AdmittedView(): JSX.Element { +type AdmittedViewProps = { + status: ApplicantStatus; + decisionDates: DecisionDates; +}; + +export default function AdmittedView({ + status, + //decisionDates, +}: AdmittedViewProps): JSX.Element { return ( -
-

Acceptance message.

- - Link to RSVP - -
+ +

+ Confirm-by window +

+

RSVP before XXX to hold your spot.

+

Next steps

+ + } + description={ + <> + You're in. Go to RSVP so we can finalize your attendance details. + + } + eyebrow="Admission decision" + primaryAction={ + + RSVP now + + } + secondaryAction={ + + Refresh status + + } + title={<>You're in!} + > +
+

+ RSVP status +

+

+ {status.rsvpStatus === "submitted" ? "Confirmed" : "Needs RSVP"} +

+

+ {status.rsvpStatus === "submitted" + ? "Thanks for confirming your attendance." + : "Please complete the RSVP form before the deadline."} +

+
+
); } diff --git a/apps/app-portal/src/components/dashboard/ConfirmByCountdown.tsx b/apps/app-portal/src/components/dashboard/ConfirmByCountdown.tsx index 64e55e36..5a7974bc 100644 --- a/apps/app-portal/src/components/dashboard/ConfirmByCountdown.tsx +++ b/apps/app-portal/src/components/dashboard/ConfirmByCountdown.tsx @@ -1,23 +1,24 @@ import React from "react"; +import useConfirmByCountdown from "./useConfirmByCountdown"; type ConfirmByCountdownProps = { - confirmBy: Date; + confirmBy: string; }; export default function ConfirmByCountdown({ confirmBy, }: ConfirmByCountdownProps): JSX.Element { - const isClosed = new Date() > confirmBy; - let disabledLabel = "no"; - - if (isClosed) { - disabledLabel = "yes"; - } + const { expired, label } = useConfirmByCountdown(confirmBy); return ( -
-
RSVP deadline: {String(confirmBy)}
-
RSVP form disabled: {disabledLabel}
+
+
+ Confirm-by deadline +
+
{label}
+
+ {expired ? "RSVP is closed." : "The form updates every minute."} +
); } diff --git a/apps/app-portal/src/components/dashboard/DeclinedView.tsx b/apps/app-portal/src/components/dashboard/DeclinedView.tsx index 4fd2a8dc..96ac3c7f 100644 --- a/apps/app-portal/src/components/dashboard/DeclinedView.tsx +++ b/apps/app-portal/src/components/dashboard/DeclinedView.tsx @@ -1,9 +1,57 @@ import React from "react"; +import Link from "next/link"; +import PortalShell from "./PortalShell"; +import { + //primaryActionClass, + secondaryActionClass, + statCardClass, +} from "./styles"; export default function DeclinedView(): JSX.Element { return ( -
-

Declined message

-
+ +

+ Keep in touch +

+

+ The portal is still a good way to stay connected. We hope to see you + next year. +

+
+ } + description={ + <> + We appreciate your interest and hope you'll stay in the orbit for + future events. + + } + eyebrow="Decision update" + primaryAction={ + + Join the mailing list + + } + secondaryAction={ + + Refresh status + + } + title={<>We hope to see you next year} + > +
+

+ Next step +

+

+ Stay connected for the next cycle +

+

+ We'll keep sharing updates, and the mailing list is the easiest + place to hear about the next application window. +

+
+ ); } diff --git a/apps/app-portal/src/components/dashboard/InProgressView.tsx b/apps/app-portal/src/components/dashboard/InProgressView.tsx index 48292573..db178c2e 100644 --- a/apps/app-portal/src/components/dashboard/InProgressView.tsx +++ b/apps/app-portal/src/components/dashboard/InProgressView.tsx @@ -1,13 +1,64 @@ import React from "react"; import Link from "next/link"; +import PortalShell from "./PortalShell"; +import { formatPercentComplete } from "../../lib/status/format"; +import { + //primaryActionClass, + secondaryActionClass, + statCardClass, +} from "./styles"; +import type { ApplicantStatus } from "../../lib/status/types"; + +type InProgressViewProps = { + status: ApplicantStatus; +}; + +export default function InProgressView({ + status, +}: InProgressViewProps): JSX.Element { + const progressPercent = status.applicationStatus === "in-progress" ? 60 : 100; -export default function InProgressView(): JSX.Element { return ( -
-

Draft saved

- - Link to continue application - -
+ +

+ Status snapshot +

+
+

Current state

+

+ {formatPercentComplete(progressPercent)} +

+
+

+ + } + description={<>You've started your application.} + eyebrow="Application draft" + primaryAction={ + + Continue application + + } + secondaryAction={ + + Refresh status + + } + title={<>You've started your application} + > +
+

+ Completion +

+

+ {formatPercentComplete(progressPercent)} +

+

+ This is a temporary completion value +

+
+
); } diff --git a/apps/app-portal/src/components/dashboard/PortalShell.tsx b/apps/app-portal/src/components/dashboard/PortalShell.tsx new file mode 100644 index 00000000..94b57541 --- /dev/null +++ b/apps/app-portal/src/components/dashboard/PortalShell.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +type PortalShellProps = { + eyebrow: string; + title: ReactNode; + description: ReactNode; + primaryAction?: ReactNode; + secondaryAction?: ReactNode; + aside?: ReactNode; + children?: ReactNode; +}; + +export default function PortalShell({ + eyebrow, + title, + description, + primaryAction, + secondaryAction, + aside, + children, +}: PortalShellProps): JSX.Element { + return ( +
+
+
+
+ {eyebrow} +
+
+

+ {title} +

+

+ {description} +

+
+
+ +
+
+
{children}
+ {(primaryAction || secondaryAction) && ( +
+ {primaryAction} + {secondaryAction} +
+ )} +
+ + {aside ? ( + + ) : ( +
+

+ Review the status mock in the route handler to preview the other + branches. +

+ + Reload dashboard + +
+ )} +
+
+
+ ); +} diff --git a/apps/app-portal/src/components/dashboard/PreRegistrationView.tsx b/apps/app-portal/src/components/dashboard/PreRegistrationView.tsx index 1f19c422..aae9325a 100644 --- a/apps/app-portal/src/components/dashboard/PreRegistrationView.tsx +++ b/apps/app-portal/src/components/dashboard/PreRegistrationView.tsx @@ -1,9 +1,96 @@ import React from "react"; +import Link from "next/link"; +import PortalShell from "./PortalShell"; +import { formatCountdownLabel, formatLongDate } from "../../lib/status/format"; +import { + statCardClass, + //primaryActionClass, + secondaryActionClass, +} from "./styles"; +import type { DecisionDates } from "../../lib/status/types"; + +type PreRegistrationViewProps = { + decisionDates: DecisionDates; +}; + +export default function PreRegistrationView({ + decisionDates, +}: PreRegistrationViewProps): JSX.Element { + const registrationCountdown = formatCountdownLabel( + decisionDates.registrationOpen, + ); -export default function PreRegistrationView(): JSX.Element { return ( -
-

Applications open soon

-
+ +
+
+

+ Next step +

+

+ Applications are opening soon. +

+
+ +
+

+ We’ll open the application on{" "} + {formatLongDate(decisionDates.registrationOpen)}. +

+
+
+ + } + description={ + <> + The dashboard is waiting for registration to open. In the meantime, + we’re counting down the days and getting everything ready. + + } + eyebrow="Application portal" + primaryAction={ + + Learn more about HackBeanpot + + } + secondaryAction={ + + Refresh status + + } + title={<>Applications open in {registrationCountdown}} + > +
+
+

+ Opens +

+

+ {formatLongDate(decisionDates.registrationOpen)} +

+
+ {/*
+

+ Decision date +

+

+ {formatLongDate(decisionDates.showDecision)} +

+
+
+

+ Confirm by +

+

+ {formatLongDate(decisionDates.confirmBy)} +

+
*/} +
+
); } diff --git a/apps/app-portal/src/components/dashboard/RsvpExperience.tsx b/apps/app-portal/src/components/dashboard/RsvpExperience.tsx new file mode 100644 index 00000000..27258971 --- /dev/null +++ b/apps/app-portal/src/components/dashboard/RsvpExperience.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import ConfirmByCountdown from "./ConfirmByCountdown"; +import RsvpForm from "./RsvpForm"; + +type RsvpExperienceProps = { + confirmBy: string; + alreadySubmitted: boolean; +}; + +export default function RsvpExperience({ + confirmBy, + alreadySubmitted, +}: RsvpExperienceProps): JSX.Element { + return ( +
+ + {alreadySubmitted && ( +
+ RSVP received. You can still update your details before the deadline. +
+ )} + +
+ ); +} diff --git a/apps/app-portal/src/components/dashboard/RsvpForm.tsx b/apps/app-portal/src/components/dashboard/RsvpForm.tsx index 866cabc9..686f9078 100644 --- a/apps/app-portal/src/components/dashboard/RsvpForm.tsx +++ b/apps/app-portal/src/components/dashboard/RsvpForm.tsx @@ -2,32 +2,211 @@ import React from "react"; import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; -type RsvpFormValues = { - attending: "yes" | "no"; +const rsvpSchema = z.object({ + attending: z.enum(["yes", "no"], { + required_error: "Please choose whether you’re attending.", + }), + dietaryRestrictions: z.string().max(240).optional().or(z.literal("")), + tshirtSize: z.enum(["xs", "s", "m", "l", "xl"], { + required_error: "Please choose a t-shirt size.", + }), + accessibilityNeeds: z.string().max(240).optional().or(z.literal("")), + additionalNotes: z.string().max(400).optional().or(z.literal("")), +}); + +type RsvpFormValues = z.infer; + +type RsvpFormProps = { + confirmBy: string; + inverted?: boolean; }; -export default function RsvpForm(): JSX.Element { - const { register, handleSubmit } = useForm(); +const sizeOptions = [ + { value: "xs", label: "XS" }, + { value: "s", label: "S" }, + { value: "m", label: "M" }, + { value: "l", label: "L" }, + { value: "xl", label: "XL" }, +] as const; + +export default function RsvpForm({ + confirmBy, + inverted = false, +}: RsvpFormProps): JSX.Element { + const router = useRouter(); + const [toast, setToast] = React.useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(rsvpSchema), + defaultValues: { + attending: "yes", + dietaryRestrictions: "", + tshirtSize: "m", + accessibilityNeeds: "", + additionalNotes: "", + }, + }); + + React.useEffect(() => { + if (!toast) { + return; + } + + const timer = window.setTimeout(() => setToast(null), 4000); + return () => window.clearTimeout(timer); + }, [toast]); + + const isExpired = new Date() > new Date(confirmBy); + + const onSubmit = async (values: RsvpFormValues) => { + if (isExpired) { + setToast({ + type: "error", + message: "The confirm-by deadline has passed.", + }); + return; + } + + setIsSubmitting(true); - const onSubmit = (values: RsvpFormValues) => { - void values; + try { + const response = await fetch("/api/v1/post-acceptance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + if (!response.ok) { + throw new Error("Unable to submit RSVP"); + } + + setToast({ + type: "success", + message: "RSVP submitted successfully. Redirecting to your dashboard.", + }); + + window.setTimeout(() => { + router.push("/dashboard"); + router.refresh(); + }, 1000); + } catch { + setToast({ + type: "error", + message: "We couldn’t submit your RSVP. Please try again.", + }); + } finally { + setIsSubmitting(false); + } }; return ( -
- - - -
+
+ {toast && ( +
+ {toast.message} +
+ )} + +
+
+ + + +
+ + + +