Skip to content
Open
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
4 changes: 3 additions & 1 deletion apps/app-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions apps/app-portal/src/app/(applicant)/dashboard/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from "react";

export default function Loading(): JSX.Element {
return (
<section className="min-h-screen px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div className="space-y-4">
<div className="h-6 w-36 rounded-full bg-slate-200/80" />
<div className="h-12 w-3/4 rounded-3xl bg-slate-200/80 sm:h-16" />
<div className="h-5 w-full max-w-2xl rounded-full bg-slate-200/70" />
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(280px,0.85fr)]">
<div className="space-y-4 rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div className="h-8 w-2/3 rounded-2xl bg-slate-100" />
<div className="h-5 w-full rounded-full bg-slate-100" />
<div className="h-5 w-5/6 rounded-full bg-slate-100" />
<div className="h-36 rounded-3xl bg-slate-100" />
</div>
<div className="rounded-[2rem] border border-dashed border-slate-200 bg-white/60 p-6 shadow-sm sm:p-8">
<div className="h-6 w-1/2 rounded-full bg-slate-200" />
<div className="mt-4 h-28 rounded-3xl bg-slate-100" />
</div>
</div>
</div>
</section>
);
}
59 changes: 47 additions & 12 deletions apps/app-portal/src/app/(applicant)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,24 +13,56 @@ import WaitlistedView from "../../../components/dashboard/WaitlistedView";
import DeclinedView from "../../../components/dashboard/DeclinedView";

export default async function DashboardPage(): Promise<JSX.Element> {
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 (
<div className="p-8">
<h2 className="text-xl font-semibold">Unable to load dashboard</h2>
<p className="mt-2 text-sm text-slate-600">{String(err)}</p>
</div>
);
}
// Ensure `status` is present before rendering views that require it.
if (!status) {
return (
<div className="p-8">
<h2 className="text-xl font-semibold">Loading dashboard…</h2>
</div>
);
}
const resolvedDates = {
registrationOpen: new Date(decisionDates.registrationOpen),
showDecision: new Date(decisionDates.showDecision),
confirmBy: new Date(decisionDates.confirmBy),
};

switch (branch) {
case "pre-registration":
return <PreRegistrationView />;
return <PreRegistrationView decisionDates={resolvedDates} />;
case "in-progress":
return <InProgressView />;
return <InProgressView status={status} />;
case "submitted":
return <SubmittedView />;
return <SubmittedView decisionDates={resolvedDates} status={status} />;
case "admitted":
return <AdmittedView />;
return <AdmittedView decisionDates={resolvedDates} status={status} />;
case "waitlisted":
return <WaitlistedView />;
return <WaitlistedView status={status} />;
case "declined":
return <DeclinedView />;
default:
return <SubmittedView />;
return <SubmittedView decisionDates={resolvedDates} status={status} />;
}
}
21 changes: 21 additions & 0 deletions apps/app-portal/src/app/(applicant)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen w-full bg-slate-50">
<div className="flex w-full items-stretch">
<Sidebar />
<main className="flex-1 px-6 py-8">{children}</main>
</div>
</div>
);
}
18 changes: 18 additions & 0 deletions apps/app-portal/src/app/(applicant)/rsvp/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from "react";

export default function Loading(): JSX.Element {
return (
<section className="min-h-screen px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div className="h-5 w-40 rounded-full bg-slate-200" />
<div className="h-11 w-3/4 rounded-3xl bg-slate-100" />
<div className="h-24 rounded-3xl bg-slate-100" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="h-16 rounded-3xl bg-slate-100" />
<div className="h-16 rounded-3xl bg-slate-100" />
</div>
<div className="h-44 rounded-3xl bg-slate-100" />
</div>
</section>
);
}
35 changes: 14 additions & 21 deletions apps/app-portal/src/app/(applicant)/rsvp/page.tsx
Original file line number Diff line number Diff line change
@@ -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<JSX.Element> {
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 (
<section className="p-8">
<h1 className="text-2xl font-semibold">Post-acceptance RSVP</h1>
<p className="mt-2">confirm your attendance.</p>
<div className="mt-4">
<ConfirmByCountdown confirmBy={decisionDates.confirmBy} />
</div>
if (branch !== "admitted" || isAfterConfirmBy) {
redirect("/dashboard");
}

{canRsvp ? (
<RsvpForm />
) : (
<p className="mt-6 text-gray-600">no RSVP available</p>
)}
</section>
return (
<RsvpExperience
alreadySubmitted={status.rsvpStatus === "submitted"}
confirmBy={confirmBy.toISOString()}
/>
);
}
26 changes: 24 additions & 2 deletions apps/app-portal/src/app/api/v1/post-acceptance/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
15 changes: 14 additions & 1 deletion apps/app-portal/src/app/api/v1/status/route.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
34 changes: 34 additions & 0 deletions apps/app-portal/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className="w-56 shrink-0 min-h-screen border-r border-slate-200 bg-white px-4 py-6">
<ul className="space-y-1">
{items.map((it) => {
const active = pathname === it.href;
return (
<li key={it.href}>
<Link
href={it.href}
className={`block rounded-xl px-3 py-2 text-sm font-medium transition ${active ? "bg-slate-100 text-slate-900" : "text-slate-600 hover:bg-slate-50"}`}
>
{it.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
69 changes: 62 additions & 7 deletions apps/app-portal/src/components/dashboard/AdmittedView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="p-8">
<h1 className="text-2xl font-semibold">Acceptance message.</h1>
<Link className="mt-4 inline-block text-blue-600" href="/rsvp">
Link to RSVP
</Link>
</section>
<PortalShell
aside={
<div className="space-y-4 text-sm leading-6 text-slate-300">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Confirm-by window
</p>
<p>RSVP before XXX to hold your spot.</p>
<p>Next steps</p>
</div>
}
description={
<>
You&apos;re in. Go to RSVP so we can finalize your attendance details.
</>
}
eyebrow="Admission decision"
primaryAction={
<Link
className="text-blue-600 font-semibold hover:underline"
href="/rsvp"
>
RSVP now
</Link>
}
secondaryAction={
<Link className={secondaryActionClass} href="/dashboard">
Refresh status
</Link>
}
title={<>You&apos;re in!</>}
>
<div className={statCardClass}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
RSVP status
</p>
<p className="mt-2 text-2xl font-semibold text-slate-950">
{status.rsvpStatus === "submitted" ? "Confirmed" : "Needs RSVP"}
</p>
<p className="mt-2 text-sm leading-6 text-slate-600">
{status.rsvpStatus === "submitted"
? "Thanks for confirming your attendance."
: "Please complete the RSVP form before the deadline."}
</p>
</div>
</PortalShell>
);
}
Loading