Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/searchneu/app/catalog/[term]/[course]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const cachedCourse = unstable_cache(getCourse, ["banner.course"], {
tags: ["banner.course"],
});

async function getTrackedSections() {
export async function getTrackedSections() {
const session = await auth.api.getSession({
headers: await headers(),
});
Expand Down
10 changes: 10 additions & 0 deletions apps/searchneu/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,16 @@
url("/fonts/lato-thinitalic-webfont.woff") format("woff");
}

@layer utilities {
.shadow-neu-card {
box-shadow:
-1px 1px 3px 0px rgba(221, 221, 221, 0.1),
-3px 4px 5px 0px rgba(221, 221, 221, 0.09),
-7px 9px 7px 0px rgba(221, 221, 221, 0.05),
-12px 16px 8px 0px rgba(221, 221, 221, 0.01);
}
}

.sunset {
background: linear-gradient(
180deg,
Expand Down
27 changes: 27 additions & 0 deletions apps/searchneu/app/notifications/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Header } from "@/components/navigation/Header";
import { notificationsFlag } from "@/lib/flags";
import { notFound } from "next/navigation";
import { Suspense, type ReactNode } from "react";

export default async function Layout({
children,
}: LayoutProps<"/notifications">) {
return (
<div className="flex h-dvh flex-col pt-4">
<Header />
<Suspense>
<FlagCheck>{children}</FlagCheck>
</Suspense>
</div>
);
}

async function FlagCheck({ children }: { children: ReactNode }) {
const notificationsPage = await notificationsFlag();

if (!notificationsPage) {
notFound();
}

return children;
}
87 changes: 87 additions & 0 deletions apps/searchneu/app/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { SectionTableMeetingTime } from "@/components/catalog/SectionTable";
import { NotificationsWrapper } from "@/components/notifications/NotificationsWrapper";
import {
db,
trackersT,
coursesT,
sectionsT,
subjectsT,
termsT,
user,
} from "@/lib/db";
import { notificationsT } from "@sneu/db/schema";
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { getSectionInfo } from "@/lib/controllers/getTrackers";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export interface TrackerSection {
id: number;
crn: string;
faculty: string;
meetingTimes: SectionTableMeetingTime[];
campus: string;
seatRemaining: number;
seatCapacity: number;
waitlistCapacity: number;
waitlistRemaining: number;
}

export default async function Page() {
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
const currentUser = await db.query.user.findFirst({
where: eq(user.id, session.user.id),
});

const trackers = await db.query.trackersT.findMany({
where: and(
eq(trackersT.userId, session.user.id),
isNull(trackersT.deletedAt),
),
});

const trackerIds = trackers.map((t) => t.id);
const notifications =
trackerIds.length > 0
? await db
.select({
id: notificationsT.id,
crn: sectionsT.crn,
courseName: coursesT.name,
courseSubject: subjectsT.code,
courseNumber: coursesT.courseNumber,
sentAt: notificationsT.sentAt,
})
.from(notificationsT)
.innerJoin(trackersT, eq(notificationsT.trackerId, trackersT.id))
.innerJoin(sectionsT, eq(trackersT.sectionId, sectionsT.id))
.innerJoin(coursesT, eq(sectionsT.courseId, coursesT.id))
.innerJoin(subjectsT, eq(coursesT.subject, subjectsT.id))
.where(inArray(notificationsT.trackerId, trackerIds))
.orderBy(desc(notificationsT.sentAt))
: [];
const trackedSectionIds = trackers.map((t) => t.sectionId);
const sections =
trackedSectionIds.length > 0
? await getSectionInfo(trackedSectionIds)
: [];
const terms = await db
.selectDistinct({ name: termsT.name })
.from(sectionsT)
.innerJoin(termsT, eq(sectionsT.term, termsT.term))
.where(inArray(sectionsT.id, trackedSectionIds));

return (
<div className="bg-secondary h-full min-h-0 w-full overflow-hidden p-4 xl:px-6">
<NotificationsWrapper
subscribedCount={trackers.length}
totalLimit={currentUser?.trackingLimit ?? 12}
termNames={terms.map((term) => term.name)}
notifications={notifications}
sections={sections}
/>
</div>
);
}
}
2 changes: 1 addition & 1 deletion apps/searchneu/components/catalog/SectionTableBlocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,5 @@ function formatTimeRange(startTime: number, endTime: number) {
const formattedStart = `${start12Hour}:${startMinutes.toString().padStart(2, "0")}`;
const formattedEnd = `${end12Hour}:${endMinutes.toString().padStart(2, "0")}`;

return `${formattedStart}${startIsPM ? "pm" : "am"} — ${formattedEnd}${endIsPM ? "pm" : "am"}`;
return `${formattedStart}${startIsPM ? "PM" : "AM"} — ${formattedEnd}${endIsPM ? "PM" : "AM"}`;
}
21 changes: 21 additions & 0 deletions apps/searchneu/components/icons/BellIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Bell } from "lucide-react";

interface BellIconProps {
className?: string;
opacity?: number;
}

export function BellIcon({
className = "text-r4",
opacity = 1,
}: BellIconProps) {
return (
<Bell
size={12}
className={className}
fill="currentColor"
style={{ opacity }}
strokeWidth={0}
/>
);
}
10 changes: 9 additions & 1 deletion apps/searchneu/components/navigation/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import Link from "next/link";
import { Logo } from "../icons/logo";
import { UserIcon } from "./UserMenu";
import { faqFlag, graduateFlag, roomsFlag, schedulerFlag } from "@/lib/flags";
import {
faqFlag,
graduateFlag,
notificationsFlag,
roomsFlag,
schedulerFlag,
} from "@/lib/flags";
import { MenuIcon, XIcon } from "lucide-react";
import { Suspense } from "react";
import {
Expand All @@ -19,12 +25,14 @@ export function Header() {
const enableRoomsPage = roomsFlag();
const enableSchedulerPage = schedulerFlag();
const enableGraduatePage = graduateFlag();
const enableNotificationsPage = notificationsFlag();

const flags = {
rooms: enableRoomsPage,
faq: enableFaqPage,
scheduler: enableSchedulerPage,
graduate: enableGraduatePage,
notifications: enableNotificationsPage,
};

return (
Expand Down
16 changes: 15 additions & 1 deletion apps/searchneu/components/navigation/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
CircleQuestionMark,
DoorOpen,
GraduationCapIcon,
Bell,
} from "lucide-react";
import { type ReactNode, use } from "react";
import { ReactNode, use } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FlagValues } from "flags/react";
Expand All @@ -22,6 +23,7 @@ export function NavBar({
const faqFlag = use(flags["faq"]);
const schedulerFlag = use(flags["scheduler"]);
const graduateFlag = use(flags["graduate"]);
const notificationsFlag = use(flags["notifications"]);

const pathname = usePathname();

Expand Down Expand Up @@ -90,6 +92,18 @@ export function NavBar({
</Link>
</LinkWrapper>
)}
{notificationsFlag && (
<LinkWrapper mobileNav={closeable}>
<Link
href="/notifications"
data-active={pathname === "/notifications"}
className="bg-neu1 data-[active=true]:border-neu3 flex w-full items-center gap-2 rounded-full border-1 p-2 text-sm"
>
<Bell className="size-4" />
<span>Notifications</span>
</Link>
</LinkWrapper>
)}
</nav>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";
import NotificationsSectionCard from "./NotificationsSectionCard";
import { Trash2 } from "lucide-react";
import type { SectionTableMeetingTime } from "@/components/catalog/SectionTable";

interface Section {
crn: string;
messagesSent: number;
messageLimit: number;
isSubscribed: boolean;
meetingTimes: SectionTableMeetingTime[];
professor: string;
location: string;
campus: string;
enrollmentSeats: {
current: number;
total: number;
};
waitlistSeats: {
current: number;
total: number;
};
}

interface NotificationsCourseCardProps {
courseName: string;
courseTitle: string;
sections: Section[];
onToggleSubscription?: (crn: string) => void;
}

export default function NotificationsCourseCard({
courseName,
courseTitle,
sections,
onToggleSubscription,
}: NotificationsCourseCardProps) {
const unsubscribedCount = sections.filter((s) => !s.isSubscribed).length;
const totalSections = sections.length;

return (
<div className="border-neu2 flex flex-col gap-3 rounded-lg border bg-white p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-neu8 text-xl leading-[120%] font-bold">
{courseName}
</h3>
<p className="text-neu8 text-base font-normal">{courseTitle}</p>
</div>
<div className="flex items-center gap-2">
<button
className="border-neu2 bg-neu2 hover:bg-neu3 text-neu7 flex h-9 items-center gap-[10px] rounded-[24px] border px-4 py-2 text-[14px] leading-[16.8px] font-semibold transition"
onClick={() => console.log("View all sections")}
>
View all sections
</button>
<button
className="border-neu2 bg-neu2 hover:bg-neu3 flex h-9 w-9 items-center justify-center rounded-[24px] border transition"
onClick={() => console.log("Delete course")}
>
<Trash2 className="text-neu6 h-4 w-4 shrink-0" />
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sections.map((section, index) => (
<NotificationsSectionCard
key={index}
{...section}
onToggleSubscription={() => onToggleSubscription?.(section.crn)}
/>
))}
</div>

{unsubscribedCount > 0 && (
<p className="text-neu5 text-sm italic">
{unsubscribedCount}/{totalSections} unsubscribed sections available
</p>
)}
</div>
);
}
Loading
Loading