From bba51efd3b4fe8ba906f1ec12a5dbe81948c52a7 Mon Sep 17 00:00:00 2001 From: maxmwang Date: Fri, 31 Oct 2025 02:25:48 -0700 Subject: [PATCH 01/14] rebase onto gql --- .gitignore | 7 +- apps/backend/package.json | 8 +- apps/backend/src/modules/user/controller.ts | 28 + apps/backend/src/modules/user/formatter.ts | 3 + apps/backend/src/modules/user/resolver.ts | 14 + .../backend/src/modules/user/typedefs/user.ts | 23 + apps/backend/src/services/email.ts | 3 + apps/datapuller/package.json | 1 + apps/datapuller/src/main.ts | 2 + .../src/pullers/enrollment-checks.ts | 375 +++++ apps/frontend/src/App.tsx | 9 + .../ConfirmationPopups.module.scss | 28 + .../ConfirmationPopups/index.tsx | 66 + .../NotificationClassCard.module.scss | 108 ++ .../NotificationClassCard/index.tsx | 264 ++++ .../Notifications/Notifications.module.scss | 327 +++++ .../src/app/Profile/Notifications/index.tsx | 275 ++++ apps/frontend/src/app/Profile/index.tsx | 14 +- apps/frontend/src/lib/api/users.ts | 45 + infra/app/values.yaml | 3 + package-lock.json | 1269 ++++++++++++++--- packages/common/src/models/user.ts | 45 + 22 files changed, 2701 insertions(+), 216 deletions(-) create mode 100644 apps/backend/src/services/email.ts create mode 100644 apps/datapuller/src/pullers/enrollment-checks.ts create mode 100644 apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss create mode 100644 apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx create mode 100644 apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss create mode 100644 apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx create mode 100644 apps/frontend/src/app/Profile/Notifications/Notifications.module.scss create mode 100644 apps/frontend/src/app/Profile/Notifications/index.tsx diff --git a/.gitignore b/.gitignore index 3bddd1d2d..8003ba187 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ bt-app-*.tgz generated-types/ stage_backup.gz *storybook.log -vitest.config.js \ No newline at end of file +vitest.config.js +apps/frontend/tsconfig.node.tsbuildinfo +apps/frontend/tsconfig.tsbuildinfo +apps/frontend/vite.config.d.ts +apps/frontend/vite.config.js +prod-backup.gz diff --git a/apps/backend/package.json b/apps/backend/package.json index c3c6f47bc..174897be5 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -41,6 +41,8 @@ "@repo/common": "*", "@repo/shared": "*", "@repo/sis-api": "*", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^7.0.2", "compression": "^1.8.1", "connect-redis": "^9.0.0", "cors": "^2.8.5", @@ -54,8 +56,10 @@ "helmet": "^8.1.0", "keyv": "^5.5.3", "lodash": "^4.17.21", - "mongodb": "^6.20.0", - "mongoose": "^8.19.1", + "mongodb": "^6.18.0", + "mongoose": "^8.17.0", + "node-cron": "^4.2.1", + "nodemailer": "^7.0.7", "papaparse": "^5.5.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", diff --git a/apps/backend/src/modules/user/controller.ts b/apps/backend/src/modules/user/controller.ts index 6475bb56b..6a7e9c6a3 100644 --- a/apps/backend/src/modules/user/controller.ts +++ b/apps/backend/src/modules/user/controller.ts @@ -79,3 +79,31 @@ export const getBookmarkedClasses = async ( return classes.map(formatClass); }; + +export const getMonitoredClasses = async ( + monitoredClasses: UserModule.MonitoredClassInput[] | UserModule.MonitoredClass[] +) => { + const classes = []; + + for (const monitoredClass of monitoredClasses) { + const classData = monitoredClass.class; + + const _class = await ClassModel.findOne({ + year: classData.year, + semester: classData.semester, + sessionId: classData.sessionId ? classData.sessionId : "1", + subject: classData.subject, + courseNumber: classData.courseNumber, + number: classData.number, + }).lean(); + + if (!_class) continue; + + classes.push({ + class: formatClass(_class), + thresholds: monitoredClass.thresholds, + }); + } + + return classes; +} \ No newline at end of file diff --git a/apps/backend/src/modules/user/formatter.ts b/apps/backend/src/modules/user/formatter.ts index 97c01cef0..11fdbaf96 100644 --- a/apps/backend/src/modules/user/formatter.ts +++ b/apps/backend/src/modules/user/formatter.ts @@ -5,6 +5,7 @@ import { UserModule } from "./generated-types/module-types"; interface UserRelationships { bookmarkedCourses: UserModule.BookmarkedCourseInput[]; bookmarkedClasses: UserModule.BookmarkedClassInput[]; + monitoredClasses: UserModule.MonitoredClassInput[]; } export type IntermediateUser = Omit & @@ -21,5 +22,7 @@ export const formatUser = (user: UserType) => { bookmarkedClasses: user.bookmarkedClasses, majors: user.majors ? user.majors : [], minors: user.minors ? user.minors : [], + monitoredClasses: user.monitoredClasses, + notificationsOn: user.notificationsOn, } as IntermediateUser; }; diff --git a/apps/backend/src/modules/user/resolver.ts b/apps/backend/src/modules/user/resolver.ts index f19b82208..c0f6ff9fd 100644 --- a/apps/backend/src/modules/user/resolver.ts +++ b/apps/backend/src/modules/user/resolver.ts @@ -1,6 +1,7 @@ import { getBookmarkedClasses, getBookmarkedCourses, + getMonitoredClasses, getUser, updateUser, } from "./controller"; @@ -40,6 +41,19 @@ const resolvers: UserModule.Resolvers = { return courses as unknown as UserModule.Course[]; }, + + monitoredClasses: async (parent: UserModule.User | IntermediateUser) => { + if ( + parent.monitoredClasses[0] && + (parent.monitoredClasses[0] as UserModule.MonitoredClass).class + ) { + return parent.monitoredClasses as UserModule.MonitoredClass[]; + } + + const monitoredClasses = await getMonitoredClasses(parent.monitoredClasses); + + return monitoredClasses as unknown as UserModule.MonitoredClass[]; + }, }, Mutation: { diff --git a/apps/backend/src/modules/user/typedefs/user.ts b/apps/backend/src/modules/user/typedefs/user.ts index ef579a2a3..0738076cd 100644 --- a/apps/backend/src/modules/user/typedefs/user.ts +++ b/apps/backend/src/modules/user/typedefs/user.ts @@ -1,6 +1,11 @@ import { gql } from "graphql-tag"; const typedef = gql` + type MonitoredClass { + class: Class! + thresholds: [Float]! + } + type User @cacheControl(scope: PRIVATE) { _id: ID! email: String! @@ -11,6 +16,8 @@ const typedef = gql` bookmarkedClasses: [Class!]! majors: [String!]! minors: [String!]! + monitoredClasses: [MonitoredClass!]! + notificationsOn: Boolean! } type Query { @@ -31,11 +38,27 @@ const typedef = gql` number: ClassNumber! } + input MonitoredClassRefInput { + year: Int! + semester: Semester! + sessionId: SessionIdentifier + subject: String! + courseNumber: CourseNumber! + number: ClassNumber! + } + + input MonitoredClassInput { + class: MonitoredClassRefInput! + thresholds: [Float!]! + } + input UpdateUserInput { bookmarkedClasses: [BookmarkedClassInput!] bookmarkedCourses: [BookmarkedCourseInput!] majors: [String!] minors: [String!] + monitoredClasses: [MonitoredClassInput!] + notificationsOn: Boolean! } type Mutation { diff --git a/apps/backend/src/services/email.ts b/apps/backend/src/services/email.ts new file mode 100644 index 000000000..1116d1c81 --- /dev/null +++ b/apps/backend/src/services/email.ts @@ -0,0 +1,3 @@ +// Connects to an SMTP server or email provider. +// Formats and sends the email to a user. +// Handles retries or errors if sending fails. \ No newline at end of file diff --git a/apps/datapuller/package.json b/apps/datapuller/package.json index 8d1e30f20..732a3690f 100644 --- a/apps/datapuller/package.json +++ b/apps/datapuller/package.json @@ -17,6 +17,7 @@ "@repo/common": "*", "@repo/sis-api": "*", "dotenv": "^17.2.3", + "@sendgrid/mail": "^8.1.4", "papaparse": "^5.5.3", "tslog": "^4.10.2" } diff --git a/apps/datapuller/src/main.ts b/apps/datapuller/src/main.ts index 8a0cb0ad4..ff7452788 100644 --- a/apps/datapuller/src/main.ts +++ b/apps/datapuller/src/main.ts @@ -3,6 +3,7 @@ import { parseArgs } from "node:util"; import classesPuller from "./pullers/classes"; import coursesPuller from "./pullers/courses"; import enrollmentHistoriesPuller from "./pullers/enrollment"; +import enrollmentChecksPuller from "./pullers/enrollment-checks"; import gradeDistributionsPuller from "./pullers/grade-distributions"; import sectionsPuller from "./pullers/sections"; import termsPuller from "./pullers/terms"; @@ -26,6 +27,7 @@ const pullerMap: { "grades-recent": gradeDistributionsPuller.recentPastTerms, "grades-last-five-years": gradeDistributionsPuller.lastFiveYearsTerms, enrollments: enrollmentHistoriesPuller.updateEnrollmentHistories, + "enrollment-checks": enrollmentChecksPuller.checkEnrollmentThresholds, "terms-all": termsPuller.allTerms, "terms-nearby": termsPuller.nearbyTerms, } as const; diff --git a/apps/datapuller/src/pullers/enrollment-checks.ts b/apps/datapuller/src/pullers/enrollment-checks.ts new file mode 100644 index 000000000..05389424d --- /dev/null +++ b/apps/datapuller/src/pullers/enrollment-checks.ts @@ -0,0 +1,375 @@ +import sgMail from "@sendgrid/mail"; +import { Logger } from "tslog"; + +import { NewEnrollmentHistoryModel, UserModel } from "@repo/common"; + +import { Config } from "../shared/config"; +import { getActiveTerms } from "../shared/term-selectors"; + +interface EnrollmentThreshold { + percentage: number; + name: string; +} + +const ENROLLMENT_THRESHOLDS: EnrollmentThreshold[] = [ + { percentage: 50, name: "half_full" }, + { percentage: 75, name: "three_quarters_full" }, + { percentage: 90, name: "nearly_full" }, + { percentage: 100, name: "completely_full" }, +]; + +interface EnrollmentAlert { + termId: string; + sessionId: string; + sectionId: string; + subject: string; + courseNumber: string; + sectionNumber: string; + year: number; + semester: string; + threshold: string; + percentage: number; + enrolledCount: number; + maxEnroll: number; + waitlistedCount: number; + maxWaitlist: number; + timestamp: string; + previousPercentage?: number; +} + +const generateEnrollmentAlertEmail = ( + userEmail: string, + userName: string, + alerts: EnrollmentAlert[] +): { to: string; from: string; subject: string; html: string } => { + // TODO: Replace with actual email template/design + const alertsList = alerts + .map( + (alert) => ` +
+

+ ${alert.subject} ${alert.courseNumber} - Section ${alert.sectionNumber} +

+

+ Enrollment Status: ${alert.threshold.replace(/_/g, " ").toUpperCase()} +

+

+ Current Enrollment: ${alert.enrolledCount}/${alert.maxEnroll} (${alert.percentage}%) +

+ ${ + alert.waitlistedCount > 0 + ? `

+ Waitlist: ${alert.waitlistedCount}/${alert.maxWaitlist} +

` + : "" + } +

+ Term: ${alert.semester} ${alert.year} +

+
+ ` + ) + .join(""); + + const html = ` + + + + + + + +
+

BerkeleTime Enrollment Alert

+

Your monitored classes have reached enrollment thresholds

+
+ +
+

Hello ${userName},

+

The following classes you're monitoring have crossed their enrollment thresholds:

+
+ + ${alertsList} + +
+

You're receiving this email because you've set up enrollment notifications on BerkeleTime.

+

To manage your notification settings, please visit your profile page.

+
+ + + `; + + return { + to: userEmail, + from: process.env.SENDGRID_FROM_EMAIL || "octo.berkeleytime@asuc.org", + subject: `BerkeleTime Alert: ${alerts.length} class${alerts.length > 1 ? "es" : ""} reached enrollment threshold`, + html, + }; +}; + +const checkEnrollmentThresholds = async (config: Config) => { + const log = + config.log || + new Logger({ + type: "pretty", + prettyLogTimeZone: "local", + }); + log.trace(`Starting enrollment threshold checks...`); + + const allTerms = await getActiveTerms(); + const terms = allTerms.filter((term) => { + if (term.academicCareerCode !== "UGRD") { + return false; + } + + if (!term.sessions) return true; + return term.sessions.some((session) => { + if (!session.enrollBeginDate || !session.enrollEndDate) return false; + + const now = Date.now(); + const enrollBeginDate = new Date(session.enrollBeginDate).getTime(); + const enrollEndDate = new Date(session.enrollEndDate).getTime(); + + return now >= enrollBeginDate && now <= enrollEndDate; + }); + }); + + if (terms.length === 0) { + log.warn("No active terms found for enrollment checking"); + return; + } + + const termIds = terms.map((term) => term.id); + log.info( + `Checking enrollment thresholds for ${terms.length} active terms: ${terms.map((term) => term.name).join(", ")}` + ); + + const enrollmentHistories = await NewEnrollmentHistoryModel.find({ + termId: { $in: termIds }, + }).lean(); + + log.info(`Found ${enrollmentHistories.length} enrollment histories to check`); + + const alerts: EnrollmentAlert[] = []; + let totalChecks = 0; + + for (const history of enrollmentHistories) { + if (!history.history || history.history.length < 2) { + continue; + } + + const latestData = history.history[history.history.length - 1]; + const previousData = history.history[history.history.length - 2]; + + if (!latestData.enrolledCount || !latestData.maxEnroll) { + continue; + } + + totalChecks++; + + const currentPercentage = Math.round( + (latestData.enrolledCount / latestData.maxEnroll) * 100 + ); + + const previousPercentage = + previousData.enrolledCount && previousData.maxEnroll + ? Math.round( + (previousData.enrolledCount / previousData.maxEnroll) * 100 + ) + : undefined; + + // Check each threshold + for (const threshold of ENROLLMENT_THRESHOLDS) { + const crossedThreshold = + currentPercentage >= threshold.percentage && + (!previousPercentage || previousPercentage < threshold.percentage); + + if (crossedThreshold) { + alerts.push({ + termId: history.termId, + sessionId: history.sessionId, + sectionId: history.sectionId, + subject: history.subject, + courseNumber: history.courseNumber, + sectionNumber: history.sectionNumber, + year: history.year, + semester: history.semester, + threshold: threshold.name, + percentage: currentPercentage, + enrolledCount: latestData.enrolledCount, + maxEnroll: latestData.maxEnroll, + waitlistedCount: latestData.waitlistedCount || 0, + maxWaitlist: latestData.maxWaitlist || 0, + timestamp: latestData.time, + previousPercentage, + }); + } + } + } + + log.info(`Completed ${totalChecks} enrollment checks`); + + if (alerts.length > 0) { + log.info(`Found ${alerts.length} enrollment threshold alerts:`); + + const alertsByThreshold = alerts.reduce( + (acc, alert) => { + if (!acc[alert.threshold]) { + acc[alert.threshold] = []; + } + acc[alert.threshold].push(alert); + return acc; + }, + {} as Record + ); + + for (const [threshold, thresholdAlerts] of Object.entries( + alertsByThreshold + )) { + log.info(` ${threshold}: ${thresholdAlerts.length} sections`); + + thresholdAlerts.slice(0, 3).forEach((alert) => { + log.info( + ` ${alert.subject} ${alert.courseNumber} ${alert.sectionNumber} - ${alert.percentage}% (${alert.enrolledCount}/${alert.maxEnroll})` + ); + }); + + if (thresholdAlerts.length > 3) { + log.info(` ... and ${thresholdAlerts.length - 3} more`); + } + } + + const usersToNotify = new Map>(); + + for (const alert of alerts) { + const thresholdPercentage = ENROLLMENT_THRESHOLDS.find( + (t) => t.name === alert.threshold + )?.percentage; + + if (!thresholdPercentage) { + continue; + } + + const matchingUsers = await UserModel.find({ + notificationsOn: true, + monitoredClasses: { + $elemMatch: { + "class.year": alert.year, + "class.semester": alert.semester, + "class.subject": alert.subject, + "class.courseNumber": alert.courseNumber, + "class.number": alert.sectionNumber, + }, + }, + }).lean(); + + for (const user of matchingUsers) { + const monitoredClass = user.monitoredClasses?.find( + (mc) => + mc.class && + mc.class.year === alert.year && + mc.class.semester === alert.semester && + mc.class.subject === alert.subject && + mc.class.courseNumber === alert.courseNumber && + mc.class.number === alert.sectionNumber + ); + + if ( + monitoredClass && + monitoredClass.thresholds.includes(thresholdPercentage) + ) { + const userId = user._id.toString(); + if (!usersToNotify.has(userId)) { + usersToNotify.set(userId, new Set()); + } + usersToNotify.get(userId)!.add(alert); + } + } + } + + log.info(`Found ${usersToNotify.size} users to notify`); + + // Initialize SendGrid + const sendgridApiKey = process.env.SENDGRID_API_KEY; + if (!sendgridApiKey) { + log.warn("SENDGRID_API_KEY not set - skipping email notifications"); + } else { + sgMail.setApiKey(sendgridApiKey); + } + + let emailsSent = 0; + let emailsFailed = 0; + let emailsThrottled = 0; + + for (const [userId, userAlerts] of usersToNotify.entries()) { + log.info(` User ${userId}: ${userAlerts.size} alert(s)`); + + // Get user email for notifications + const user = await UserModel.findById(userId).lean(); + if (!user) { + log.warn(` User ${userId} not found, skipping`); + continue; + } + + // Only send email notifications + if (!user.notificationsOn) { + log.info(`User has notifications off, skipping email`); + continue; + } + + // Check if user was notified less than 2 hours ago + if (user.lastNotified) { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + if (new Date(user.lastNotified) > twoHoursAgo) { + emailsThrottled++; + log.info( + ` User was notified less than 2 hours ago, skipping email` + ); + continue; + } + } + + for (const alert of userAlerts) { + log.info( + ` - ${alert.subject} ${alert.courseNumber} ${alert.sectionNumber}: ${alert.threshold} (${alert.percentage}%)` + ); + } + + // Send email if SendGrid is configured + if (sendgridApiKey && user.email) { + try { + const emailData = generateEnrollmentAlertEmail( + user.email, + user.name, + Array.from(userAlerts) + ); + + await sgMail.send(emailData); + + // Update lastNotified after successfully sending email + await UserModel.findByIdAndUpdate(userId, { + lastNotified: new Date(), + }); + + emailsSent++; + log.info(` ✓ Email sent to ${user.email}`); + } catch (error) { + emailsFailed++; + log.error(` ✗ Failed to send email to ${user.email}:`, error); + } + } + } + + if (sendgridApiKey) { + log.info( + `Email notifications: ${emailsSent} sent, ${emailsFailed} failed, ${emailsThrottled} throttled` + ); + } else { + log.info("Email sending skipped - SENDGRID_API_KEY not configured"); + } + } else { + log.info("No enrollment threshold alerts detected"); + } +}; + +export default { checkEnrollmentThresholds }; diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index b16e38aef..62a673656 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -20,6 +20,7 @@ const Landing = lazy(() => import("@/app/Landing")); const Profile = { Root: lazy(() => import("@/app/Profile")), Account: lazy(() => import("@/app/Profile/Account")), + Notifications: lazy(() => import("@/app/Profile/Notifications")), Support: lazy(() => import("@/app/Profile/Support")), Ratings: lazy(() => import("@/app/Profile/Ratings")), }; @@ -186,6 +187,14 @@ const router = createBrowserRouter([ ), index: true, }, + { + element: ( + + + + ), + path: "notifications", + }, { element: ( diff --git a/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss new file mode 100644 index 000000000..8d7b88e87 --- /dev/null +++ b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss @@ -0,0 +1,28 @@ +.body { + align-items: center; + color: var(--heading-color); +} +.icon { + margin-top: 40px; + width: 70px; + height: 70px; + color: var(--blue-500); +} +.red { + color: var(--red-500); +} + +.title { + margin-top: 28px; + margin-bottom: 14px; + font-size: 28px; + font-weight: 600; +} + +.subtitle { + margin: 0px 20px; + color: var(--paragraph-color); + font-size: 15px; + line-height: 1.5; + margin-bottom: 28px; +} diff --git a/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx new file mode 100644 index 000000000..38561b414 --- /dev/null +++ b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; + +import classNames from "classnames"; +import { + ArrowRight, + CheckCircleSolid, + WarningTriangleSolid, +} from "iconoir-react"; + +import { Button, Dialog } from "@repo/theme"; + +import styles from "./ConfirmationPopups.module.scss"; + +interface RemoveClassPopupProps { + isOpen: boolean; + onClose: () => void; + onConfirmRemove: () => Promise; +} + +export function RemoveClassPopup({ + isOpen, + onClose, + onConfirmRemove, +}: RemoveClassPopupProps) { + const [isRemoving, setIsRemoving] = useState(false); + + const handleRemove = async () => { + setIsRemoving(true); + try { + await onConfirmRemove(); + onClose(); + } finally { + setIsRemoving(false); + } + }; + + return ( + + + + + + +
Stop Tracking Class
+
+ You will no longer receive notifications about
+ enrollment changes for this class. +
+
+ + {!isRemoving && ( + + )} + + +
+
+
+ ); +} diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss new file mode 100644 index 000000000..4418944e7 --- /dev/null +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss @@ -0,0 +1,108 @@ +.cardWrapper { + position: relative; + overflow: visible; + height: 128px; // Fixed height to match carousel cards + + &.popupOpen { + z-index: 100; + } + + // Override Card's default overflow: hidden but keep height + > * { + height: 100% !important; + overflow: visible !important; + } +} + +.cardHeading { + font-size: 16px !important; + font-weight: 600 !important; + margin-bottom: 8px !important; /* <--- ADD THIS LINE */ +} + +.bellWrapper { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 24px; + height: 24px; + padding: 0 4px; + margin-top: -3.5px; + border: 1px solid var(--border-color); + border-radius: 4px; + transition: background-color 150ms ease-in-out; + cursor: pointer; + + &:hover { + background-color: var(--background-color); + } + + &.active { + color: var(--blue-500); + + &:hover { + background-color: var(--background-color); + color: var(--blue-600); + } + } +} + +// fix this up for the popup to be sticky +.popup { + background-color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 10000; + width: 280px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.gradeWrapper { + /* The > * selector targets the component + inside our wrapper and forces these styles onto it. + */ + > * { + /* 1. Remove the default pill styles */ + background: none !important; + padding: 0 !important; + border: none !important; + + /* 2. Add the new target styles */ + font-size: 14px !important; + color: #F59E0B !important; + font-weight: 600 !important; + + /* Optional: a small tweak to align it */ + margin-top: 1px; + } +} + +.checkboxOption { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--paragraph-color); + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + flex-shrink: 0; + } + + span { + line-height: 1.3; + } + + &:hover { + color: var(--blue-500); + } +} diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx new file mode 100644 index 000000000..0f5fd3f9e --- /dev/null +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx @@ -0,0 +1,264 @@ +import { ComponentPropsWithRef, useState, useRef, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; + +import { Bookmark, BookmarkSolid, Bell, BellNotification, NavArrowDown } from "iconoir-react"; + +import { Card } from "@repo/theme"; + +import { AverageGrade } from "@/components/AverageGrade"; +import Capacity from "@/components/Capacity"; +import Units from "@/components/Units"; +import { IClass } from "@/lib/api"; + +import { RemoveClassPopup } from "../ConfirmationPopups"; +import styles from "./NotificationClassCard.module.scss"; + +interface NotificationClassCardProps { + class: IClass; + thresholds: number[]; + onThresholdChange: (threshold: number, checked: boolean) => void; + onRemoveClass: () => Promise; + bookmarked?: boolean; + bookmarkToggle?: () => void; +} + +export default function NotificationClassCard({ + class: { + course: { + title: courseTitle, + subject: courseSubject, + number: courseNumber2, + gradeDistribution, + }, + title, + subject, + courseNumber, + number, + primarySection: { enrollment }, + unitsMax, + unitsMin, + }, + thresholds, + onThresholdChange, + onRemoveClass, + bookmarked = false, + bookmarkToggle, + ...props +}: NotificationClassCardProps & Omit, keyof NotificationClassCardProps>) { + const [showPopup, setShowPopup] = useState(false); + const [showRemoveConfirmation, setShowRemoveConfirmation] = useState(false); + const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + const [tempThresholds, setTempThresholds] = useState(thresholds); + const popupRef = useRef(null); + const bellIconRef = useRef(null); + const isActive = thresholds.length > 0; + + // Sync temp thresholds with prop changes + useEffect(() => { + setTempThresholds(thresholds); + }, [thresholds]); + + // Update popup position when it opens + useEffect(() => { + if (showPopup && bellIconRef.current) { + const rect = bellIconRef.current.getBoundingClientRect(); + const popupWidth = 280; // min-width from CSS + const viewportWidth = window.innerWidth; + const spaceOnRight = viewportWidth - rect.left; + + // If popup would overflow the right edge, align it to the right edge + const shouldAlignRight = spaceOnRight < popupWidth; + + setPopupPosition({ + top: rect.top + 20, // 20px below the icon + left: shouldAlignRight ? viewportWidth - popupWidth - 16 : rect.left, // 16px padding from edge + }); + } + }, [showPopup]); + + // Handle threshold change with temp state + const handleTempThresholdChange = (threshold: number, checked: boolean) => { + setTempThresholds((prev) => { + if (checked) { + return [...prev, threshold].sort((a, b) => a - b); + } else { + return prev.filter((t) => t !== threshold); + } + }); + }; + + // Apply threshold changes + const applyThresholdChanges = () => { + tempThresholds.forEach((threshold) => { + if (!thresholds.includes(threshold)) { + onThresholdChange(threshold, true); + } + }); + thresholds.forEach((threshold) => { + if (!tempThresholds.includes(threshold)) { + onThresholdChange(threshold, false); + } + }); + }; + + // Close popup when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + popupRef.current && + bellIconRef.current && + !popupRef.current.contains(event.target as Node) && + !bellIconRef.current.contains(event.target as Node) + ) { + // Check if user unchecked all options + if (tempThresholds.length === 0 && thresholds.length > 0) { + setShowRemoveConfirmation(true); + setShowPopup(false); + } else { + // Apply changes + applyThresholdChanges(); + setShowPopup(false); + } + } + }; + + if (showPopup) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showPopup, tempThresholds, thresholds, onThresholdChange]); + + return ( +
+ + + +
+ + {subject ?? courseSubject} {courseNumber ?? courseNumber2} #{number} + + {title ?? courseTitle} +
+ {/* --- CHANGE 1: marginTop changed to '12px' --- + */} + + + + +
+ + {/* --- CHANGE 2: Wrapped AverageGrade in a div to fix styling --- + */} + {gradeDistribution && ( +
+ +
+ )} + {bookmarkToggle && ( + + {bookmarked ? ( + + ) : ( + + )} + + )} +
setShowPopup(!showPopup)} + > + {isActive ? ( + <> + + + + ) : ( + + )} +
+ {showPopup && createPortal( +
+ + + + + + +
, + document.body + )} +
+
+
+ { + // User changed their mind, restore previous thresholds + setTempThresholds(thresholds); + setShowRemoveConfirmation(false); + }} + onConfirmRemove={async () => { + await onRemoveClass(); + setShowRemoveConfirmation(false); + }} + /> +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss b/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss new file mode 100644 index 000000000..557cbf130 --- /dev/null +++ b/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss @@ -0,0 +1,327 @@ +.container { + max-width: 100%; +} + +.header { + margin-bottom: 32px; + + h1 { + /* --- APPLY THESE CHANGES --- */ + font-size: 32px; /* Changed from 28px to 32px */ + font-weight: 700; /* Increased to 700 (bold) for better consistency, or keep at 600 */ + margin: 0 0 24px 0; /* Changed from 12px to 40px margin at the bottom for spacing */ + /* --------------------------- */ + color: var(--heading-color); + } +} + +.subtitle { + /* Update font-size and color */ + font-size: 16px; + color: #64748B; + /* Remaining styles */ + line-height: 1.6; + margin: 0 0 40px 0; + max-width: 80%; +} + +.section { + margin-bottom: 48px; + + h2 { + font-size: 24px; + font-weight: 600; + margin: 0 0 24px 0; + color: var(--heading-color); + max-width: 80%; + } +} + +.sectionDescription { + /* Update font-size and color */ + font-size: 16px; + color: #64748B; + /* Remaining styles */ + line-height: 1.6; + margin: 0 0 20px 0; +} + +.emptyState { + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--foreground-color); + height: 200px; + color: var(--label-color); +} + +.classGrid { + display: grid; + grid-template-columns: repeat(auto-fill, 326px); /* <--- CHANGE THIS */ + gap: 20px; + /* ... */ +} + +.classCard { + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--foreground-color); + overflow: hidden; + transition: box-shadow 150ms ease-in-out; + padding: 16px; /* <--- ADD THIS LINE */ + /* ... */ +} + +.cardHeader { +} + +.classTitle { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--paragraph-color); + } +} + +.classActions { + display: flex; + align-items: center; + gap: 8px; +} + +.grade { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 24px; + padding: 0 8px; + background-color: var(--green-100); + color: var(--green-700); + border-radius: 4px; + font-size: 13px; + font-weight: 600; +} + +.bookmarkIcon { + width: 20px; + height: 20px; + color: var(--paragraph-color); + cursor: pointer; + transition: color 100ms ease-in-out; + + &:hover { + color: var(--blue-500); + } +} + +.dropdown { + position: relative; +} + +.bellIcon { + width: 20px; + height: 20px; + color: var(--blue-500); + cursor: pointer; + transition: color 100ms ease-in-out; + + &:hover { + color: var(--blue-600); + } +} + +.courseName { + margin: 0 0 8px 0; + font-size: 14px; + color: #64748B !important; /* <--- ADD !important */ + line-height: 1.4; + white-space: nowrap !important; /* <--- ADD !important */ + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.enrollment { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--paragraph-color); +} + +.enrollmentIcon { + font-size: 14px; +} + +.units { + margin-left: auto; + color: var(--paragraph-color); +} + +.notificationOptions { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.checkboxOption { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--paragraph-color); + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; + } + + span { + line-height: 1.4; + } + + &:hover { + color: var(--blue-500); + } +} + +.stopTrackingContainer { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.stopTrackingButton { + padding: 10px 20px; + background-color: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--red-500); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 100ms ease-in-out; + + &:hover { + background-color: var(--red-50); + border-color: var(--red-500); + } +} + +.toggleOptions { + display: flex; + flex-direction: column; + gap: 16px; +} + +.toggleOption { + display: flex; + align-items: center; + gap: 12px; + font-size: 16px; + color: #64748B; + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + width: 44px; + height: 24px; + cursor: pointer; + appearance: none; + background-color: var(--border-color); + border-radius: 12px; + position: relative; + transition: background-color 150ms ease-in-out; + flex-shrink: 0; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: white; + top: 2px; + left: 2px; + transition: transform 150ms ease-in-out; + } + + &:checked { + background-color: var(--blue-500); + + &::before { + transform: translateX(20px); + } + } + } + + span { + line-height: 1.4; + } +} + +.toggleHeader { + display: flex; + align-items: center; + justify-content: space-between; + + h2 { + margin: 0; + } +} + +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + + input[type="checkbox"] { + opacity: 0; + width: 0; + height: 0; + + &:checked + .slider { + background-color: var(--blue-500); + + &::before { + transform: translateX(20px); + } + } + } +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + border-radius: 12px; + transition: background-color 150ms ease-in-out; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: white; + top: 2px; + left: 2px; + transition: transform 150ms ease-in-out; + } +} diff --git a/apps/frontend/src/app/Profile/Notifications/index.tsx b/apps/frontend/src/app/Profile/Notifications/index.tsx new file mode 100644 index 000000000..d5921f12e --- /dev/null +++ b/apps/frontend/src/app/Profile/Notifications/index.tsx @@ -0,0 +1,275 @@ +import { useState, useCallback } from "react"; + +import { useReadUser, useUpdateUser } from "@/hooks/api"; +import { IMonitoredClass } from "@/lib/api/users"; + +import NotificationClassCard from "./NotificationClassCard"; +import styles from "./Notifications.module.scss"; + +// Test data for development +const TEST_DATA: IMonitoredClass[] = [ + { + class: { + title: "Foundations of the U.S Air Force", + subject: "AEROSPC", + number: "1A", + courseNumber: "1A", + year: 2024, + semester: "Spring", + sessionId: null, + unitsMin: 1, + unitsMax: 1, + course: { + title: "Foundations of U.S Air Force", + subject: "AEROSPC", + number: "1A", + gradeDistribution: { + average: "A+" + } + }, + primarySection: { + enrollment: { + latest: { + enrolledCount: 0, + maxEnroll: 60, + waitlistedCount: 0, + maxWaitlist: 0 + } + } + }, + gradeDistribution: { + average: "A+" + } + } as any, + thresholds: [100] + }, + { + class: { + title: "Intro to Computer Science", + subject: "COMPSCI", + number: "61A", + year: 2024, + semester: "Fall", + sessionId: null, + unitsMin: 4, + unitsMax: 4, + course: { + title: "Intro to Computer Science", + subject: "COMPSCI", + number: "61A", + gradeDistribution: { + average: "B+" + } + }, + primarySection: { + enrollment: { + latest: { + enrolledCount: 450, + maxEnroll: 500, + waitlistedCount: 25, + maxWaitlist: 50 + } + } + }, + gradeDistribution: { + average: "B+" + } + } as any, + thresholds: [50, 75, 90] + }, + { + class: { + title: "Calculus", + subject: "MATH", + number: "1A", + year: 2024, + semester: "Spring", + sessionId: null, + unitsMin: 4, + unitsMax: 4, + course: { + title: "Calculus", + subject: "MATH", + gradeDistribution: { + average: "B" + } + }, + primarySection: { + enrollment: { + latest: { + enrolledCount: 200, + maxEnroll: 250, + waitlistedCount: 10, + maxWaitlist: 30 + } + } + }, + gradeDistribution: { + average: "B" + } + } as any, + thresholds: [75, 100] + } +]; + +export default function Notifications() { + const { data: user } = useReadUser(); + const [updateUser] = useUpdateUser(); + + // Local state for managing notification preferences + // Use test data if no monitored classes exist + const [monitoredClasses, setMonitoredClasses] = useState( + user?.monitoredClasses && user.monitoredClasses.length > 0 + ? user.monitoredClasses + : TEST_DATA + ); + + const [addDropDeadline, setAddDropDeadline] = useState(false); + const [lateChangeSchedule, setLateChangeSchedule] = useState(false); + const [receiveEmails, setReceiveEmails] = useState(true); + + const handleThresholdChange = (classIndex: number, threshold: number, checked: boolean) => { + const updated = [...monitoredClasses]; + const currentThresholds = updated[classIndex].thresholds; + + if (checked) { + updated[classIndex].thresholds = [...currentThresholds, threshold].sort((a, b) => a - b); + } else { + updated[classIndex].thresholds = currentThresholds.filter((t) => t !== threshold); + } + + setMonitoredClasses(updated); + // TODO: Call mutation to update user preferences + console.log("Update thresholds for class:", classIndex, updated[classIndex].thresholds); + }; + + const handleRemoveClass = async (classIndex: number) => { + const updated = monitoredClasses.filter((_, index) => index !== classIndex); + setMonitoredClasses(updated); + // TODO: Call mutation to update user monitoredClasses + console.log("Removed class at index:", classIndex); + }; + + const bookmark = useCallback(async (classToBookmark: any) => { + if (!user || !classToBookmark) return; + + const bookmarked = user.bookmarkedClasses.some( + (bookmarkedClass) => + bookmarkedClass.subject === classToBookmark.subject && + bookmarkedClass.courseNumber === classToBookmark.courseNumber && + bookmarkedClass.number === classToBookmark.number && + bookmarkedClass.year === classToBookmark.year && + bookmarkedClass.semester === classToBookmark.semester + ); + + const bookmarkedClasses = bookmarked + ? user.bookmarkedClasses.filter( + (bookmarkedClass) => + !( + bookmarkedClass.subject === classToBookmark.subject && + bookmarkedClass.courseNumber === classToBookmark.courseNumber && + bookmarkedClass.number === classToBookmark.number && + bookmarkedClass.year === classToBookmark.year && + bookmarkedClass.semester === classToBookmark.semester + ) + ) + : [...user.bookmarkedClasses, classToBookmark]; + + const payload = { + bookmarkedClasses: bookmarkedClasses.map((bookmarkedClass) => ({ + subject: bookmarkedClass.subject, + number: bookmarkedClass.number, + courseNumber: bookmarkedClass.courseNumber, + year: bookmarkedClass.year, + semester: bookmarkedClass.semester, + sessionId: bookmarkedClass.sessionId || "1", // Default to "1" if null + })), + }; + + await updateUser(payload); + }, [user, updateUser]); + + return ( +
+
+

Course Enrollment Notifications

+

+ Manage the classes you are tracking by setting specific alerts for enrollment thresholds. Notifications will be delivered to your registered @berkeley.edu email address. +

+
+ +
+
+ +
+
+ +
+

Classes You're Tracking

+ + {monitoredClasses.length === 0 ? null : ( +
+ {monitoredClasses.map((monitoredClass, index) => { + const isBookmarked = user?.bookmarkedClasses.some( + (bookmarkedClass) => + bookmarkedClass.subject === monitoredClass.class.subject && + bookmarkedClass.courseNumber === monitoredClass.class.courseNumber && + bookmarkedClass.number === monitoredClass.class.number && + bookmarkedClass.year === monitoredClass.class.year && + bookmarkedClass.semester === monitoredClass.class.semester + ); + + return ( + handleThresholdChange(index, threshold, checked)} + onRemoveClass={async () => await handleRemoveClass(index)} + bookmarked={isBookmarked} + bookmarkToggle={() => bookmark(monitoredClass.class)} + /> + ); + })} +
+ )} +
+ +
+

Add/Drop Deadline Notifications

+

+ Get notified about key academic deadlines, including add/drop and late change of class schedule for the semester. +

+ +
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/app/Profile/index.tsx b/apps/frontend/src/app/Profile/index.tsx index 9e7b2bc8a..2ec8699c4 100644 --- a/apps/frontend/src/app/Profile/index.tsx +++ b/apps/frontend/src/app/Profile/index.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { ChatBubbleQuestion, LogOut, ProfileCircle, Star } from "iconoir-react"; +import { Bell, ChatBubbleQuestion, LogOut, ProfileCircle, Star } from "iconoir-react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import UserContext from "@/contexts/UserContext"; @@ -35,6 +35,18 @@ export default function Root() { )} + + {({ isActive }) => ( +
+ + Notifications +
+ )} +
{({ isActive }) => (
=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.913.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.913.0.tgz", + "integrity": "sha512-xurdihi/HH3em6XgvI+AlolCjqu74CxyOAEEWAJBk1Xp+yqNUaYR5d1m26iI7UuTy8gAZ+48UNvQBVhUXe5ZwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.911.0", + "@aws-sdk/credential-provider-node": "3.913.0", + "@aws-sdk/middleware-host-header": "3.910.0", + "@aws-sdk/middleware-logger": "3.910.0", + "@aws-sdk/middleware-recursion-detection": "3.910.0", + "@aws-sdk/middleware-user-agent": "3.911.0", + "@aws-sdk/region-config-resolver": "3.910.0", + "@aws-sdk/signature-v4-multi-region": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@aws-sdk/util-endpoints": "3.910.0", + "@aws-sdk/util-user-agent-browser": "3.910.0", + "@aws-sdk/util-user-agent-node": "3.911.0", + "@smithy/config-resolver": "^4.3.2", + "@smithy/core": "^3.16.1", + "@smithy/fetch-http-handler": "^5.3.3", + "@smithy/hash-node": "^4.2.2", + "@smithy/invalid-dependency": "^4.2.2", + "@smithy/middleware-content-length": "^4.2.2", + "@smithy/middleware-endpoint": "^4.3.3", + "@smithy/middleware-retry": "^4.4.3", + "@smithy/middleware-serde": "^4.2.2", + "@smithy/middleware-stack": "^4.2.2", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/node-http-handler": "^4.4.1", + "@smithy/protocol-http": "^5.3.2", + "@smithy/smithy-client": "^4.8.1", + "@smithy/types": "^4.7.1", + "@smithy/url-parser": "^4.2.2", + "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.0", - "@smithy/util-defaults-mode-browser": "^4.2.0", - "@smithy/util-defaults-mode-node": "^4.2.0", - "@smithy/util-endpoints": "^3.2.0", - "@smithy/util-middleware": "^4.2.0", - "@smithy/util-retry": "^4.2.0", - "@smithy/util-stream": "^4.4.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.2", + "@smithy/util-defaults-mode-node": "^4.2.3", + "@smithy/util-endpoints": "^3.2.2", + "@smithy/util-middleware": "^4.2.2", + "@smithy/util-retry": "^4.2.2", "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/client-sso": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.911.0.tgz", + "integrity": "sha512-N9QAeMvN3D1ZyKXkQp4aUgC4wUMuA5E1HuVCkajc0bq1pnH4PIke36YlrDGGREqPlyLFrXCkws2gbL5p23vtlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.911.0", + "@aws-sdk/middleware-host-header": "3.910.0", + "@aws-sdk/middleware-logger": "3.910.0", + "@aws-sdk/middleware-recursion-detection": "3.910.0", + "@aws-sdk/middleware-user-agent": "3.911.0", + "@aws-sdk/region-config-resolver": "3.910.0", + "@aws-sdk/types": "3.910.0", + "@aws-sdk/util-endpoints": "3.910.0", + "@aws-sdk/util-user-agent-browser": "3.910.0", + "@aws-sdk/util-user-agent-node": "3.911.0", + "@smithy/config-resolver": "^4.3.2", + "@smithy/core": "^3.16.1", + "@smithy/fetch-http-handler": "^5.3.3", + "@smithy/hash-node": "^4.2.2", + "@smithy/invalid-dependency": "^4.2.2", + "@smithy/middleware-content-length": "^4.2.2", + "@smithy/middleware-endpoint": "^4.3.3", + "@smithy/middleware-retry": "^4.4.3", + "@smithy/middleware-serde": "^4.2.2", + "@smithy/middleware-stack": "^4.2.2", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/node-http-handler": "^4.4.1", + "@smithy/protocol-http": "^5.3.2", + "@smithy/smithy-client": "^4.8.1", + "@smithy/types": "^4.7.1", + "@smithy/url-parser": "^4.2.2", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.2", + "@smithy/util-defaults-mode-node": "^4.2.3", + "@smithy/util-endpoints": "^3.2.2", + "@smithy/util-middleware": "^4.2.2", + "@smithy/util-retry": "^4.2.2", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/core": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.911.0.tgz", + "integrity": "sha512-k4QG9A+UCq/qlDJFmjozo6R0eXXfe++/KnCDMmajehIE9kh+b/5DqlGvAmbl9w4e92LOtrY6/DN3mIX1xs4sXw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@aws-sdk/xml-builder": "3.911.0", + "@smithy/core": "^3.16.1", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/property-provider": "^4.2.2", + "@smithy/protocol-http": "^5.3.2", + "@smithy/signature-v4": "^5.3.2", + "@smithy/smithy-client": "^4.8.1", + "@smithy/types": "^4.7.1", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.2", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.911.0.tgz", + "integrity": "sha512-6FWRwWn3LUZzLhqBXB+TPMW2ijCWUqGICSw8bVakEdODrvbiv1RT/MVUayzFwz/ek6e6NKZn6DbSWzx07N9Hjw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/property-provider": "^4.2.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.911.0.tgz", + "integrity": "sha512-xUlwKmIUW2fWP/eM3nF5u4CyLtOtyohlhGJ5jdsJokr3MrQ7w0tDITO43C9IhCn+28D5UbaiWnKw5ntkw7aVfA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/fetch-http-handler": "^5.3.3", + "@smithy/node-http-handler": "^4.4.1", + "@smithy/property-provider": "^4.2.2", + "@smithy/protocol-http": "^5.3.2", + "@smithy/smithy-client": "^4.8.1", + "@smithy/types": "^4.7.1", + "@smithy/util-stream": "^4.5.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.913.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.913.0.tgz", + "integrity": "sha512-iR4c4NQ1OSRKQi0SxzpwD+wP1fCy+QNKtEyCajuVlD0pvmoIHdrm5THK9e+2/7/SsQDRhOXHJfLGxHapD74WJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/credential-provider-env": "3.911.0", + "@aws-sdk/credential-provider-http": "3.911.0", + "@aws-sdk/credential-provider-process": "3.911.0", + "@aws-sdk/credential-provider-sso": "3.911.0", + "@aws-sdk/credential-provider-web-identity": "3.911.0", + "@aws-sdk/nested-clients": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/credential-provider-imds": "^4.2.2", + "@smithy/property-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.913.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.913.0.tgz", + "integrity": "sha512-HQPLkKDxS83Q/nZKqg9bq4igWzYQeOMqhpx5LYs4u1GwsKeCsYrrfz12Iu4IHNWPp9EnGLcmdfbfYuqZGrsaSQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.911.0", + "@aws-sdk/credential-provider-http": "3.911.0", + "@aws-sdk/credential-provider-ini": "3.913.0", + "@aws-sdk/credential-provider-process": "3.911.0", + "@aws-sdk/credential-provider-sso": "3.911.0", + "@aws-sdk/credential-provider-web-identity": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/credential-provider-imds": "^4.2.2", + "@smithy/property-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.911.0.tgz", + "integrity": "sha512-mKshhV5jRQffZjbK9x7bs+uC2IsYKfpzYaBamFsEov3xtARCpOiKaIlM8gYKFEbHT2M+1R3rYYlhhl9ndVWS2g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/property-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.911.0.tgz", + "integrity": "sha512-JAxd4uWe0Zc9tk6+N0cVxe9XtJVcOx6Ms0k933ZU9QbuRMH6xti/wnZxp/IvGIWIDzf5fhqiGyw5MSyDeI5b1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.911.0", + "@aws-sdk/core": "3.911.0", + "@aws-sdk/token-providers": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/property-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.911.0.tgz", + "integrity": "sha512-urIbXWWG+cm54RwwTFQuRwPH0WPsMFSDF2/H9qO2J2fKoHRURuyblFCyYG3aVKZGvFBhOizJYexf5+5w3CJKBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/nested-clients": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/property-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.910.0.tgz", + "integrity": "sha512-F9Lqeu80/aTM6S/izZ8RtwSmjfhWjIuxX61LX+/9mxJyEkgaECRxv0chsLQsLHJumkGnXRy/eIyMLBhcTPF5vg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@smithy/protocol-http": "^5.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-logger": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.910.0.tgz", + "integrity": "sha512-3LJyyfs1USvRuRDla1pGlzGRtXJBXD1zC9F+eE9Iz/V5nkmhyv52A017CvKWmYoR0DM9dzjLyPOI0BSSppEaTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.910.0.tgz", + "integrity": "sha512-m/oLz0EoCy+WoIVBnXRXJ4AtGpdl0kPE7U+VH9TsuUzHgxY1Re/176Q1HWLBRVlz4gr++lNsgsMWEC+VnAwMpw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@aws/lambda-invoke-store": "^0.0.1", + "@smithy/protocol-http": "^5.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.911.0.tgz", + "integrity": "sha512-P0mIIW/QkAGNvFu15Jqa5NSmHeQvZkkQY8nbQpCT3tGObZe4wRsq5u1mOS+CJp4DIBbRZuHeX7ohbX5kPMi4dg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@aws-sdk/util-arn-parser": "3.893.0", + "@smithy/core": "^3.16.1", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/protocol-http": "^5.3.2", + "@smithy/signature-v4": "^5.3.2", + "@smithy/smithy-client": "^4.8.1", + "@smithy/types": "^4.7.1", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.2", + "@smithy/util-stream": "^4.5.2", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.911.0.tgz", + "integrity": "sha512-rY3LvGvgY/UI0nmt5f4DRzjEh8135A2TeHcva1bgOmVfOI4vkkGfA20sNRqerOkSO6hPbkxJapO50UJHFzmmyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@aws-sdk/util-endpoints": "3.910.0", + "@smithy/core": "^3.16.1", + "@smithy/protocol-http": "^5.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/nested-clients": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.911.0.tgz", + "integrity": "sha512-lp/sXbdX/S0EYaMYPVKga0omjIUbNNdFi9IJITgKZkLC6CzspihIoHd5GIdl4esMJevtTQQfkVncXTFkf/a4YA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.911.0", + "@aws-sdk/middleware-host-header": "3.910.0", + "@aws-sdk/middleware-logger": "3.910.0", + "@aws-sdk/middleware-recursion-detection": "3.910.0", + "@aws-sdk/middleware-user-agent": "3.911.0", + "@aws-sdk/region-config-resolver": "3.910.0", + "@aws-sdk/types": "3.910.0", + "@aws-sdk/util-endpoints": "3.910.0", + "@aws-sdk/util-user-agent-browser": "3.910.0", + "@aws-sdk/util-user-agent-node": "3.911.0", + "@smithy/config-resolver": "^4.3.2", + "@smithy/core": "^3.16.1", + "@smithy/fetch-http-handler": "^5.3.3", + "@smithy/hash-node": "^4.2.2", + "@smithy/invalid-dependency": "^4.2.2", + "@smithy/middleware-content-length": "^4.2.2", + "@smithy/middleware-endpoint": "^4.3.3", + "@smithy/middleware-retry": "^4.4.3", + "@smithy/middleware-serde": "^4.2.2", + "@smithy/middleware-stack": "^4.2.2", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/node-http-handler": "^4.4.1", + "@smithy/protocol-http": "^5.3.2", + "@smithy/smithy-client": "^4.8.1", + "@smithy/types": "^4.7.1", + "@smithy/url-parser": "^4.2.2", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.2", + "@smithy/util-defaults-mode-node": "^4.2.3", + "@smithy/util-endpoints": "^3.2.2", + "@smithy/util-middleware": "^4.2.2", + "@smithy/util-retry": "^4.2.2", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.910.0.tgz", + "integrity": "sha512-gzQAkuHI3xyG6toYnH/pju+kc190XmvnB7X84vtN57GjgdQJICt9So/BD0U6h+eSfk9VBnafkVrAzBzWMEFZVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/types": "^4.7.1", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.911.0.tgz", + "integrity": "sha512-SJ4dUcY9+HPDIMCHiskT8F7JrRVZF2Y1NUN0Yiy6VUHSULgq2MDlIzSQpNICnmXhk1F1E1B2jJG9XtPYrvtqUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/protocol-http": "^5.3.2", + "@smithy/signature-v4": "^5.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/token-providers": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.911.0.tgz", + "integrity": "sha512-O1c5F1pbEImgEe3Vr8j1gpWu69UXWj3nN3vvLGh77hcrG5dZ8I27tSP5RN4Labm8Dnji/6ia+vqSYpN8w6KN5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.911.0", + "@aws-sdk/nested-clients": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/property-provider": "^4.2.2", + "@smithy/shared-ini-file-loader": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/types": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.910.0.tgz", + "integrity": "sha512-o67gL3vjf4nhfmuSUNNkit0d62QJEwwHLxucwVJkR/rw9mfUtAWsgBs8Tp16cdUbMgsyQtCQilL8RAJDoGtadQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-arn-parser": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz", + "integrity": "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-endpoints": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.910.0.tgz", + "integrity": "sha512-6XgdNe42ibP8zCQgNGDWoOF53RfEKzpU/S7Z29FTTJ7hcZv0SytC0ZNQQZSx4rfBl036YWYwJRoJMlT4AA7q9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@smithy/types": "^4.7.1", + "@smithy/url-parser": "^4.2.2", + "@smithy/util-endpoints": "^3.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.910.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.910.0.tgz", + "integrity": "sha512-iOdrRdLZHrlINk9pezNZ82P/VxO/UmtmpaOAObUN+xplCUJu31WNM2EE/HccC8PQw6XlAudpdA6HDTGiW6yVGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.910.0", + "@smithy/types": "^4.7.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.911.0.tgz", + "integrity": "sha512-3l+f6ooLF6Z6Lz0zGi7vSKSUYn/EePPizv88eZQpEAFunBHv+CSVNPtxhxHfkm7X9tTsV4QGZRIqo3taMLolmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.911.0", + "@aws-sdk/types": "3.910.0", + "@smithy/node-config-provider": "^4.3.2", + "@smithy/types": "^4.7.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/xml-builder": { + "version": "3.911.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.911.0.tgz", + "integrity": "sha512-/yh3oe26bZfCVGrIMRM9Z4hvvGJD+qx5tOLlydOkuBkm72aXON7D9+MucjJXTAcI8tF2Yq+JHa0478eHQOhnLg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.7.1", + "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { @@ -1315,6 +1863,8 @@ }, "node_modules/@aws/lambda-invoke-store": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz", + "integrity": "sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -6527,28 +7077,110 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", - "cpu": [ - "x64" - ], + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", + "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", + "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", + "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sendgrid/client": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", + "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", + "license": "MIT", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.12.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", + "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@sendgrid/client": "^8.1.5", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } }, "node_modules/@shopify/draggable": { "version": "1.1.4", "license": "MIT" }, "node_modules/@smithy/abort-controller": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.3.tgz", + "integrity": "sha512-xWL9Mf8b7tIFuAlpjKtRPnHrR8XVrwTj5NPYO/QwZPtc0SDLsPxb56V5tzi5yspSMytISHybifez+4jlrx0vkQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6577,13 +7209,15 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.3.0", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.3.3.tgz", + "integrity": "sha512-xSql8A1Bl41O9JvGU/CtgiLBlwkvpHTSKRlvz9zOBvBCPjXghZ6ZkcVzmV2f7FLAA+80+aqKmIOmy8pEDrtCaw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/node-config-provider": "^4.3.3", + "@smithy/types": "^4.8.0", "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", + "@smithy/util-middleware": "^4.2.3", "tslib": "^2.6.2" }, "engines": { @@ -6591,16 +7225,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.14.0", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.17.0.tgz", + "integrity": "sha512-Tir3DbfoTO97fEGUZjzGeoXgcQAUBRDTmuH9A8lxuP8ATrgezrAJ6cLuRvwdKN4ZbYNlHgKlBX69Hyu3THYhtg==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", - "@smithy/util-base64": "^4.2.0", + "@smithy/middleware-serde": "^4.2.3", + "@smithy/protocol-http": "^5.3.3", + "@smithy/types": "^4.8.0", + "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", - "@smithy/util-stream": "^4.4.0", + "@smithy/util-middleware": "^4.2.3", + "@smithy/util-stream": "^4.5.3", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" @@ -6610,13 +7246,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.3.tgz", + "integrity": "sha512-hA1MQ/WAHly4SYltJKitEsIDVsNmXcQfYBRv2e+q04fnqtAX5qXaybxy/fhUeAMCnQIdAjaGDb04fMHQefWRhw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/property-provider": "^4.2.0", - "@smithy/types": "^4.6.0", - "@smithy/url-parser": "^4.2.0", + "@smithy/node-config-provider": "^4.3.3", + "@smithy/property-provider": "^4.2.3", + "@smithy/types": "^4.8.0", + "@smithy/url-parser": "^4.2.3", "tslib": "^2.6.2" }, "engines": { @@ -6684,13 +7322,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.0", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.4.tgz", + "integrity": "sha512-bwigPylvivpRLCm+YK9I5wRIYjFESSVwl8JQ1vVx/XhCw0PtCi558NwTnT2DaVCl5pYlImGuQTSwMsZ+pIavRw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.0", - "@smithy/querystring-builder": "^4.2.0", - "@smithy/types": "^4.6.0", - "@smithy/util-base64": "^4.2.0", + "@smithy/protocol-http": "^5.3.3", + "@smithy/querystring-builder": "^4.2.3", + "@smithy/types": "^4.8.0", + "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -6711,10 +7351,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.3.tgz", + "integrity": "sha512-6+NOdZDbfuU6s1ISp3UOk5Rg953RJ2aBLNLLBEcamLjHAg1Po9Ha7QIB5ZWhdRUVuOUrT8BVFR+O2KIPmw027g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -6736,10 +7378,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.3.tgz", + "integrity": "sha512-Cc9W5DwDuebXEDMpOpl4iERo8I0KFjTnomK2RMdhhR87GwrSmUmwMxS4P5JdRf+LsjOdIqumcerwRgYMr/tZ9Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6748,6 +7392,8 @@ }, "node_modules/@smithy/is-array-buffer": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6769,11 +7415,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.3.tgz", + "integrity": "sha512-/atXLsT88GwKtfp5Jr0Ks1CSa4+lB+IgRnkNrrYP0h1wL4swHNb0YONEvTceNKNdZGJsye+W2HH8W7olbcPUeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6781,16 +7429,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.0", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.4.tgz", + "integrity": "sha512-/RJhpYkMOaUZoJEkddamGPPIYeKICKXOu/ojhn85dKDM0n5iDIhjvYAQLP3K5FPhgB203O3GpWzoK2OehEoIUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.14.0", - "@smithy/middleware-serde": "^4.2.0", - "@smithy/node-config-provider": "^4.3.0", - "@smithy/shared-ini-file-loader": "^4.3.0", - "@smithy/types": "^4.6.0", - "@smithy/url-parser": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", + "@smithy/core": "^3.17.0", + "@smithy/middleware-serde": "^4.2.3", + "@smithy/node-config-provider": "^4.3.3", + "@smithy/shared-ini-file-loader": "^4.3.3", + "@smithy/types": "^4.8.0", + "@smithy/url-parser": "^4.2.3", + "@smithy/util-middleware": "^4.2.3", "tslib": "^2.6.2" }, "engines": { @@ -6798,16 +7448,18 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.0", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.4.tgz", + "integrity": "sha512-vSgABQAkuUHRO03AhR2rWxVQ1un284lkBn+NFawzdahmzksAoOeVMnXXsuPViL4GlhRHXqFaMlc8Mj04OfQk1w==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/service-error-classification": "^4.2.0", - "@smithy/smithy-client": "^4.7.0", - "@smithy/types": "^4.6.0", - "@smithy/util-middleware": "^4.2.0", - "@smithy/util-retry": "^4.2.0", + "@smithy/node-config-provider": "^4.3.3", + "@smithy/protocol-http": "^5.3.3", + "@smithy/service-error-classification": "^4.2.3", + "@smithy/smithy-client": "^4.9.0", + "@smithy/types": "^4.8.0", + "@smithy/util-middleware": "^4.2.3", + "@smithy/util-retry": "^4.2.3", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, @@ -6816,11 +7468,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.3.tgz", + "integrity": "sha512-8g4NuUINpYccxiCXM5s1/V+uLtts8NcX4+sPEbvYQDZk4XoJfDpq5y2FQxfmUL89syoldpzNzA0R9nhzdtdKnQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6828,10 +7482,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.3.tgz", + "integrity": "sha512-iGuOJkH71faPNgOj/gWuEGS6xvQashpLwWB1HjHq1lNNiVfbiJLpZVbhddPuDbx9l4Cgl0vPLq5ltRfSaHfspA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6839,12 +7495,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.0", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.3.tgz", + "integrity": "sha512-NzI1eBpBSViOav8NVy1fqOlSfkLgkUjUTlohUSgAEhHaFWA3XJiLditvavIP7OpvTjDp5u2LhtlBhkBlEisMwA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.0", - "@smithy/shared-ini-file-loader": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/property-provider": "^4.2.3", + "@smithy/shared-ini-file-loader": "^4.3.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6852,13 +7510,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.3.0", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.2.tgz", + "integrity": "sha512-MHFvTjts24cjGo1byXqhXrbqm7uznFD/ESFx8npHMWTFQVdBZjrT1hKottmp69LBTRm/JQzP/sn1vPt0/r6AYQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/querystring-builder": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/abort-controller": "^4.2.3", + "@smithy/protocol-http": "^5.3.3", + "@smithy/querystring-builder": "^4.2.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6866,10 +7526,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.3.tgz", + "integrity": "sha512-+1EZ+Y+njiefCohjlhyOcy1UNYjT+1PwGFHCxA/gYctjg3DQWAU19WigOXAco/Ql8hZokNehpzLd0/+3uCreqQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6877,10 +7539,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.0", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.3.tgz", + "integrity": "sha512-Mn7f/1aN2/jecywDcRDvWWWJF4uwg/A0XjFMJtj72DsgHTByfjRltSqcT9NyE9RTdBSN6X1RSXrhn/YWQl8xlw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6888,10 +7552,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.3.tgz", + "integrity": "sha512-LOVCGCmwMahYUM/P0YnU/AlDQFjcu+gWbFJooC417QRB/lDJlWSn8qmPSDp+s4YVAHOgtgbNG4sR+SxF/VOcJQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, @@ -6900,10 +7566,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.3.tgz", + "integrity": "sha512-cYlSNHcTAX/wc1rpblli3aUlLMGgKZ/Oqn8hhjFASXMCXjIqeuQBei0cnq2JR8t4RtU9FpG6uyl6PxyArTiwKA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6911,20 +7579,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.3.tgz", + "integrity": "sha512-NkxsAxFWwsPsQiwFG2MzJ/T7uIR6AQNh1SzcxSUnmmIqIQMlLRQDKhc17M7IYjiuBXhrQRjQTo3CxX+DobS93g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0" + "@smithy/types": "^4.8.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.3.0", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.3.tgz", + "integrity": "sha512-9f9Ixej0hFhroOK2TxZfUUDR13WVa8tQzhSzPDgXe5jGL3KmaM9s8XN7RQwqtEypI82q9KHnKS71CJ+q/1xLtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6932,14 +7604,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.0", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.3.tgz", + "integrity": "sha512-CmSlUy+eEYbIEYN5N3vvQTRfqt0lJlQkaQUIf+oizu7BbDut0pozfDjBGecfcfWf7c62Yis4JIEgqQ/TCfodaA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", + "@smithy/protocol-http": "^5.3.3", + "@smithy/types": "^4.8.0", "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.0", + "@smithy/util-middleware": "^4.2.3", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -6949,15 +7623,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.7.0", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.0.tgz", + "integrity": "sha512-qz7RTd15GGdwJ3ZCeBKLDQuUQ88m+skh2hJwcpPm1VqLeKzgZvXf6SrNbxvx7uOqvvkjCMXqx3YB5PDJyk00ww==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.14.0", - "@smithy/middleware-endpoint": "^4.3.0", - "@smithy/middleware-stack": "^4.2.0", - "@smithy/protocol-http": "^5.3.0", - "@smithy/types": "^4.6.0", - "@smithy/util-stream": "^4.4.0", + "@smithy/core": "^3.17.0", + "@smithy/middleware-endpoint": "^4.3.4", + "@smithy/middleware-stack": "^4.2.3", + "@smithy/protocol-http": "^5.3.3", + "@smithy/types": "^4.8.0", + "@smithy/util-stream": "^4.5.3", "tslib": "^2.6.2" }, "engines": { @@ -6965,7 +7641,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.6.0", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.8.0.tgz", + "integrity": "sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6975,11 +7653,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.3.tgz", + "integrity": "sha512-I066AigYvY3d9VlU3zG9XzZg1yT10aNqvCaBTw9EPgu5GrsEl1aUkcMvhkIXascYH1A8W0LQo3B1Kr1cJNcQEw==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/querystring-parser": "^4.2.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -6987,7 +7667,9 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.2.0", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7000,6 +7682,8 @@ }, "node_modules/@smithy/util-body-length-browser": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7009,7 +7693,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.0", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7020,6 +7706,8 @@ }, "node_modules/@smithy/util-buffer-from": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", @@ -7031,6 +7719,8 @@ }, "node_modules/@smithy/util-config-provider": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7040,13 +7730,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.2.0", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.3.tgz", + "integrity": "sha512-vqHoybAuZXbFXZqgzquiUXtdY+UT/aU33sxa4GBPkiYklmR20LlCn+d3Wc3yA5ZM13gQ92SZe/D8xh6hkjx+IQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.0", - "@smithy/smithy-client": "^4.7.0", - "@smithy/types": "^4.6.0", - "bowser": "^2.11.0", + "@smithy/property-provider": "^4.2.3", + "@smithy/smithy-client": "^4.9.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -7054,15 +7745,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.0", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.4.tgz", + "integrity": "sha512-X5/xrPHedifo7hJUUWKlpxVb2oDOiqPUXlvsZv1EZSjILoutLiJyWva3coBpn00e/gPSpH8Rn2eIbgdwHQdW7Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.3.0", - "@smithy/credential-provider-imds": "^4.2.0", - "@smithy/node-config-provider": "^4.3.0", - "@smithy/property-provider": "^4.2.0", - "@smithy/smithy-client": "^4.7.0", - "@smithy/types": "^4.6.0", + "@smithy/config-resolver": "^4.3.3", + "@smithy/credential-provider-imds": "^4.2.3", + "@smithy/node-config-provider": "^4.3.3", + "@smithy/property-provider": "^4.2.3", + "@smithy/smithy-client": "^4.9.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -7070,11 +7763,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.0", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.3.tgz", + "integrity": "sha512-aCfxUOVv0CzBIkU10TubdgKSx5uRvzH064kaiPEWfNIvKOtNpu642P4FP1hgOFkjQIkDObrfIDnKMKkeyrejvQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.0", - "@smithy/types": "^4.6.0", + "@smithy/node-config-provider": "^4.3.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -7083,6 +7778,8 @@ }, "node_modules/@smithy/util-hex-encoding": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7092,10 +7789,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.3.tgz", + "integrity": "sha512-v5ObKlSe8PWUHCqEiX2fy1gNv6goiw6E5I/PN2aXg3Fb/hse0xeaAnSpXDiWl7x6LamVKq7senB+m5LOYHUAHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.6.0", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -7103,11 +7802,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.0", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.3.tgz", + "integrity": "sha512-lLPWnakjC0q9z+OtiXk+9RPQiYPNAovt2IXD3CP4LkOnd9NpUsxOjMx1SnoUVB7Orb7fZp67cQMtTBKMFDvOGg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/service-error-classification": "^4.2.3", + "@smithy/types": "^4.8.0", "tslib": "^2.6.2" }, "engines": { @@ -7115,13 +7816,15 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.4.0", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.3.tgz", + "integrity": "sha512-oZvn8a5bwwQBNYHT2eNo0EU8Kkby3jeIg1P2Lu9EQtqDxki1LIjGRJM6dJ5CZUig8QmLxWxqOKWvg3mVoOBs5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.0", - "@smithy/node-http-handler": "^4.3.0", - "@smithy/types": "^4.6.0", - "@smithy/util-base64": "^4.2.0", + "@smithy/fetch-http-handler": "^5.3.4", + "@smithy/node-http-handler": "^4.4.2", + "@smithy/types": "^4.8.0", + "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", @@ -7133,6 +7836,8 @@ }, "node_modules/@smithy/util-uri-escape": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7143,6 +7848,8 @@ }, "node_modules/@smithy/util-utf8": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7156,8 +7863,8 @@ "version": "4.2.0", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.0", - "@smithy/types": "^4.6.0", + "@smithy/abort-controller": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -7166,6 +7873,8 @@ }, "node_modules/@smithy/uuid": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7718,10 +8427,28 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.7.0", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "license": "MIT" + }, + "node_modules/@types/nodemailer": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.2.tgz", + "integrity": "sha512-Zo6uOA9157WRgBk/ZhMpTQ/iCWLMk7OIs/Q9jvHarMvrzUUP/MDdPHL2U1zpf57HrrWGv4nYQn5uIxna0xY3xw==", "license": "MIT", "dependencies": { - "undici-types": "~7.14.0" + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" } }, "node_modules/@types/normalize-package-data": { @@ -8607,6 +9334,21 @@ "retry": "0.13.1" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/auto-bind": { "version": "4.0.0", "dev": true, @@ -8631,11 +9373,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axe-core": { - "version": "4.10.3", - "license": "MPL-2.0", - "engines": { - "node": ">=4" + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" } }, "node_modules/babel-plugin-macros": { @@ -9424,6 +10170,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/common-tags": { "version": "1.8.2", "dev": true, @@ -9855,6 +10613,28 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "license": "MIT", @@ -9882,6 +10662,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -10097,6 +10886,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.39.10", "license": "MIT", @@ -10796,6 +11600,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "license": "MIT", @@ -10809,28 +11633,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "license": "ISC", + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "license": "ISC", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, "node_modules/formdata-polyfill": { @@ -12560,7 +13376,9 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -12569,9 +13387,10 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "dev": true, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">=6" @@ -12818,6 +13637,15 @@ "license": "MIT", "optional": true }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "dev": true, @@ -12907,6 +13735,15 @@ "version": "2.0.23", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "license": "BSD-2-Clause", @@ -13807,6 +14644,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/prr": { "version": "1.0.1", "dev": true, @@ -16180,7 +17023,9 @@ } }, "node_modules/undici-types": { - "version": "7.14.0", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/packages/common/src/models/user.ts b/packages/common/src/models/user.ts index 2d8d178a1..77dd16c91 100644 --- a/packages/common/src/models/user.ts +++ b/packages/common/src/models/user.ts @@ -75,6 +75,51 @@ export const userSchema = new Schema( }, ], }, + monitoredClasses: { + required: false, + default: [], + type: [ + { + class: { + year: { + type: Number, + required: true, + }, + semester: { + type: String, + enum: ["Spring", "Summer", "Fall", "Winter"], + required: true, + }, + subject: { + type: String, + required: true, + }, + courseNumber: { + type: String, + required: true, + }, + number: { + type: String, + required: true, + }, + }, + thresholds: [Number], + }, + ], + }, + notificationsOn: { + type: Boolean, + required: true, + default: false, + }, + lastNotified: { + type: Date, + required: false, + }, + refresh_token: { + type: String, + required: false, + }, minors: { type: [String], trim: true, From 4ca21766c92c776b39e2ce09179664495146c865 Mon Sep 17 00:00:00 2001 From: maxmwang Date: Fri, 31 Oct 2025 02:26:51 -0700 Subject: [PATCH 02/14] finalize rebase onto gql --- apps/backend/src/modules/user/controller.ts | 6 +- apps/backend/src/modules/user/resolver.ts | 4 +- .../backend/src/modules/user/typedefs/user.ts | 14 ++ apps/backend/src/services/email.ts | 6 +- .../NotificationClassCard/index.tsx | 219 +++++++++++------- .../src/app/Profile/Notifications/index.tsx | 157 +++++++------ apps/frontend/src/app/Profile/index.tsx | 8 +- package-lock.json | 117 +++++++--- 8 files changed, 337 insertions(+), 194 deletions(-) diff --git a/apps/backend/src/modules/user/controller.ts b/apps/backend/src/modules/user/controller.ts index 6a7e9c6a3..527212ccc 100644 --- a/apps/backend/src/modules/user/controller.ts +++ b/apps/backend/src/modules/user/controller.ts @@ -81,7 +81,9 @@ export const getBookmarkedClasses = async ( }; export const getMonitoredClasses = async ( - monitoredClasses: UserModule.MonitoredClassInput[] | UserModule.MonitoredClass[] + monitoredClasses: + | UserModule.MonitoredClassInput[] + | UserModule.MonitoredClass[] ) => { const classes = []; @@ -106,4 +108,4 @@ export const getMonitoredClasses = async ( } return classes; -} \ No newline at end of file +}; diff --git a/apps/backend/src/modules/user/resolver.ts b/apps/backend/src/modules/user/resolver.ts index c0f6ff9fd..f27714c81 100644 --- a/apps/backend/src/modules/user/resolver.ts +++ b/apps/backend/src/modules/user/resolver.ts @@ -50,7 +50,9 @@ const resolvers: UserModule.Resolvers = { return parent.monitoredClasses as UserModule.MonitoredClass[]; } - const monitoredClasses = await getMonitoredClasses(parent.monitoredClasses); + const monitoredClasses = await getMonitoredClasses( + parent.monitoredClasses + ); return monitoredClasses as unknown as UserModule.MonitoredClass[]; }, diff --git a/apps/backend/src/modules/user/typedefs/user.ts b/apps/backend/src/modules/user/typedefs/user.ts index 0738076cd..b9f6f956e 100644 --- a/apps/backend/src/modules/user/typedefs/user.ts +++ b/apps/backend/src/modules/user/typedefs/user.ts @@ -52,6 +52,20 @@ const typedef = gql` thresholds: [Float!]! } + input MonitoredClassRefInput { + year: Int! + semester: Semester! + sessionId: SessionIdentifier + subject: String! + courseNumber: CourseNumber! + number: ClassNumber! + } + + input MonitoredClassInput { + class: MonitoredClassRefInput! + thresholds: [Float!]! + } + input UpdateUserInput { bookmarkedClasses: [BookmarkedClassInput!] bookmarkedCourses: [BookmarkedCourseInput!] diff --git a/apps/backend/src/services/email.ts b/apps/backend/src/services/email.ts index 1116d1c81..5d6376891 100644 --- a/apps/backend/src/services/email.ts +++ b/apps/backend/src/services/email.ts @@ -1,3 +1,3 @@ -// Connects to an SMTP server or email provider. -// Formats and sends the email to a user. -// Handles retries or errors if sending fails. \ No newline at end of file +// Connects to an SMTP server or email provider. +// Formats and sends the email to a user. +// Handles retries or errors if sending fails. diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx index 0f5fd3f9e..1f611b12d 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx @@ -1,7 +1,19 @@ -import { ComponentPropsWithRef, useState, useRef, useEffect, useCallback } from "react"; -import { createPortal } from "react-dom"; +import { + ComponentPropsWithRef, + useCallback, + useEffect, + useRef, + useState, +} from "react"; -import { Bookmark, BookmarkSolid, Bell, BellNotification, NavArrowDown } from "iconoir-react"; +import { + Bell, + BellNotification, + Bookmark, + BookmarkSolid, + NavArrowDown, +} from "iconoir-react"; +import { createPortal } from "react-dom"; import { Card } from "@repo/theme"; @@ -44,7 +56,8 @@ export default function NotificationClassCard({ bookmarked = false, bookmarkToggle, ...props -}: NotificationClassCardProps & Omit, keyof NotificationClassCardProps>) { +}: NotificationClassCardProps & + Omit, keyof NotificationClassCardProps>) { const [showPopup, setShowPopup] = useState(false); const [showRemoveConfirmation, setShowRemoveConfirmation] = useState(false); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); @@ -132,19 +145,37 @@ export default function NotificationClassCard({ }, [showPopup, tempThresholds, thresholds, onThresholdChange]); return ( -
+
- - + +
- {subject ?? courseSubject} {courseNumber ?? courseNumber2} #{number} + {subject ?? courseSubject} {courseNumber ?? courseNumber2} # + {number} - {title ?? courseTitle} + {title ?? courseTitle}
{/* --- CHANGE 1: marginTop changed to '12px' --- - */} - + */} +
- + {/* --- CHANGE 2: Wrapped AverageGrade in a div to fix styling --- - */} + */} {gradeDistribution && ( -
- -
+
+ +
)} {bookmarkToggle && ( - - {bookmarked ? ( - - ) : ( - - )} - + + {bookmarked ? ( + + ) : ( + + )} + )}
setShowPopup(!showPopup)} > {isActive ? ( <> - - + + ) : ( )}
- {showPopup && createPortal( -
- - - - - - -
, - document.body - )} + {showPopup && + createPortal( +
+ + + + + + +
, + document.body + )}
@@ -261,4 +308,4 @@ export default function NotificationClassCard({ />
); -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/Profile/Notifications/index.tsx b/apps/frontend/src/app/Profile/Notifications/index.tsx index d5921f12e..17788dd06 100644 --- a/apps/frontend/src/app/Profile/Notifications/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useCallback, useState } from "react"; import { useReadUser, useUpdateUser } from "@/hooks/api"; import { IMonitoredClass } from "@/lib/api/users"; @@ -24,8 +24,8 @@ const TEST_DATA: IMonitoredClass[] = [ subject: "AEROSPC", number: "1A", gradeDistribution: { - average: "A+" - } + average: "A+", + }, }, primarySection: { enrollment: { @@ -33,15 +33,15 @@ const TEST_DATA: IMonitoredClass[] = [ enrolledCount: 0, maxEnroll: 60, waitlistedCount: 0, - maxWaitlist: 0 - } - } + maxWaitlist: 0, + }, + }, }, gradeDistribution: { - average: "A+" - } + average: "A+", + }, } as any, - thresholds: [100] + thresholds: [100], }, { class: { @@ -58,8 +58,8 @@ const TEST_DATA: IMonitoredClass[] = [ subject: "COMPSCI", number: "61A", gradeDistribution: { - average: "B+" - } + average: "B+", + }, }, primarySection: { enrollment: { @@ -67,15 +67,15 @@ const TEST_DATA: IMonitoredClass[] = [ enrolledCount: 450, maxEnroll: 500, waitlistedCount: 25, - maxWaitlist: 50 - } - } + maxWaitlist: 50, + }, + }, }, gradeDistribution: { - average: "B+" - } + average: "B+", + }, } as any, - thresholds: [50, 75, 90] + thresholds: [50, 75, 90], }, { class: { @@ -91,8 +91,8 @@ const TEST_DATA: IMonitoredClass[] = [ title: "Calculus", subject: "MATH", gradeDistribution: { - average: "B" - } + average: "B", + }, }, primarySection: { enrollment: { @@ -100,16 +100,16 @@ const TEST_DATA: IMonitoredClass[] = [ enrolledCount: 200, maxEnroll: 250, waitlistedCount: 10, - maxWaitlist: 30 - } - } + maxWaitlist: 30, + }, + }, }, gradeDistribution: { - average: "B" - } + average: "B", + }, } as any, - thresholds: [75, 100] - } + thresholds: [75, 100], + }, ]; export default function Notifications() { @@ -128,19 +128,31 @@ export default function Notifications() { const [lateChangeSchedule, setLateChangeSchedule] = useState(false); const [receiveEmails, setReceiveEmails] = useState(true); - const handleThresholdChange = (classIndex: number, threshold: number, checked: boolean) => { + const handleThresholdChange = ( + classIndex: number, + threshold: number, + checked: boolean + ) => { const updated = [...monitoredClasses]; const currentThresholds = updated[classIndex].thresholds; if (checked) { - updated[classIndex].thresholds = [...currentThresholds, threshold].sort((a, b) => a - b); + updated[classIndex].thresholds = [...currentThresholds, threshold].sort( + (a, b) => a - b + ); } else { - updated[classIndex].thresholds = currentThresholds.filter((t) => t !== threshold); + updated[classIndex].thresholds = currentThresholds.filter( + (t) => t !== threshold + ); } setMonitoredClasses(updated); // TODO: Call mutation to update user preferences - console.log("Update thresholds for class:", classIndex, updated[classIndex].thresholds); + console.log( + "Update thresholds for class:", + classIndex, + updated[classIndex].thresholds + ); }; const handleRemoveClass = async (classIndex: number) => { @@ -150,51 +162,56 @@ export default function Notifications() { console.log("Removed class at index:", classIndex); }; - const bookmark = useCallback(async (classToBookmark: any) => { - if (!user || !classToBookmark) return; + const bookmark = useCallback( + async (classToBookmark: any) => { + if (!user || !classToBookmark) return; - const bookmarked = user.bookmarkedClasses.some( - (bookmarkedClass) => - bookmarkedClass.subject === classToBookmark.subject && - bookmarkedClass.courseNumber === classToBookmark.courseNumber && - bookmarkedClass.number === classToBookmark.number && - bookmarkedClass.year === classToBookmark.year && - bookmarkedClass.semester === classToBookmark.semester - ); + const bookmarked = user.bookmarkedClasses.some( + (bookmarkedClass) => + bookmarkedClass.subject === classToBookmark.subject && + bookmarkedClass.courseNumber === classToBookmark.courseNumber && + bookmarkedClass.number === classToBookmark.number && + bookmarkedClass.year === classToBookmark.year && + bookmarkedClass.semester === classToBookmark.semester + ); - const bookmarkedClasses = bookmarked - ? user.bookmarkedClasses.filter( - (bookmarkedClass) => - !( - bookmarkedClass.subject === classToBookmark.subject && - bookmarkedClass.courseNumber === classToBookmark.courseNumber && - bookmarkedClass.number === classToBookmark.number && - bookmarkedClass.year === classToBookmark.year && - bookmarkedClass.semester === classToBookmark.semester - ) - ) - : [...user.bookmarkedClasses, classToBookmark]; + const bookmarkedClasses = bookmarked + ? user.bookmarkedClasses.filter( + (bookmarkedClass) => + !( + bookmarkedClass.subject === classToBookmark.subject && + bookmarkedClass.courseNumber === classToBookmark.courseNumber && + bookmarkedClass.number === classToBookmark.number && + bookmarkedClass.year === classToBookmark.year && + bookmarkedClass.semester === classToBookmark.semester + ) + ) + : [...user.bookmarkedClasses, classToBookmark]; - const payload = { - bookmarkedClasses: bookmarkedClasses.map((bookmarkedClass) => ({ - subject: bookmarkedClass.subject, - number: bookmarkedClass.number, - courseNumber: bookmarkedClass.courseNumber, - year: bookmarkedClass.year, - semester: bookmarkedClass.semester, - sessionId: bookmarkedClass.sessionId || "1", // Default to "1" if null - })), - }; + const payload = { + bookmarkedClasses: bookmarkedClasses.map((bookmarkedClass) => ({ + subject: bookmarkedClass.subject, + number: bookmarkedClass.number, + courseNumber: bookmarkedClass.courseNumber, + year: bookmarkedClass.year, + semester: bookmarkedClass.semester, + sessionId: bookmarkedClass.sessionId || "1", // Default to "1" if null + })), + }; - await updateUser(payload); - }, [user, updateUser]); + await updateUser(payload); + }, + [user, updateUser] + ); return (

Course Enrollment Notifications

- Manage the classes you are tracking by setting specific alerts for enrollment thresholds. Notifications will be delivered to your registered @berkeley.edu email address. + Manage the classes you are tracking by setting specific alerts for + enrollment thresholds. Notifications will be delivered to your + registered @berkeley.edu email address.

@@ -221,7 +238,8 @@ export default function Notifications() { const isBookmarked = user?.bookmarkedClasses.some( (bookmarkedClass) => bookmarkedClass.subject === monitoredClass.class.subject && - bookmarkedClass.courseNumber === monitoredClass.class.courseNumber && + bookmarkedClass.courseNumber === + monitoredClass.class.courseNumber && bookmarkedClass.number === monitoredClass.class.number && bookmarkedClass.year === monitoredClass.class.year && bookmarkedClass.semester === monitoredClass.class.semester @@ -232,7 +250,9 @@ export default function Notifications() { key={index} class={monitoredClass.class} thresholds={monitoredClass.thresholds} - onThresholdChange={(threshold, checked) => handleThresholdChange(index, threshold, checked)} + onThresholdChange={(threshold, checked) => + handleThresholdChange(index, threshold, checked) + } onRemoveClass={async () => await handleRemoveClass(index)} bookmarked={isBookmarked} bookmarkToggle={() => bookmark(monitoredClass.class)} @@ -246,7 +266,8 @@ export default function Notifications() {

Add/Drop Deadline Notifications

- Get notified about key academic deadlines, including add/drop and late change of class schedule for the semester. + Get notified about key academic deadlines, including add/drop and late + change of class schedule for the semester.

diff --git a/apps/frontend/src/app/Profile/index.tsx b/apps/frontend/src/app/Profile/index.tsx index 2ec8699c4..1d056c941 100644 --- a/apps/frontend/src/app/Profile/index.tsx +++ b/apps/frontend/src/app/Profile/index.tsx @@ -1,5 +1,11 @@ import classNames from "classnames"; -import { Bell, ChatBubbleQuestion, LogOut, ProfileCircle, Star } from "iconoir-react"; +import { + Bell, + ChatBubbleQuestion, + LogOut, + ProfileCircle, + Star, +} from "iconoir-react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import UserContext from "@/contexts/UserContext"; diff --git a/package-lock.json b/package-lock.json index 45d1c6ae2..b51f4e79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,7 +119,7 @@ "@repo/common": "*", "@repo/sis-api": "*", "@sendgrid/mail": "^8.1.4", - "dotenv": "^17.2.1", + "dotenv": "^17.2.3", "papaparse": "^5.5.3", "tslog": "^4.10.2" }, @@ -7097,7 +7097,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7111,7 +7110,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7125,7 +7123,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8427,12 +8424,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/node-cron": { @@ -9340,15 +9337,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/auto-bind": { "version": "4.0.0", "dev": true, @@ -10387,6 +10375,7 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -10622,19 +10611,6 @@ "node": ">=0.10.0" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "license": "MIT", @@ -12580,6 +12556,7 @@ }, "node_modules/isexe": { "version": "2.0.0", + "dev": true, "license": "ISC" }, "node_modules/isomorphic-ws": { @@ -14256,6 +14233,7 @@ }, "node_modules/path-key": { "version": "3.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15893,6 +15871,7 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15903,6 +15882,7 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16844,6 +16824,20 @@ "turbo-windows-arm64": "2.5.8" } }, + "node_modules/turbo-darwin-64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.5.8.tgz", + "integrity": "sha512-Dh5bCACiHO8rUXZLpKw+m3FiHtAp2CkanSyJre+SInEvEr5kIxjGvCK/8MFX8SFRjQuhjtvpIvYYZJB4AGCxNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/turbo-darwin-arm64": { "version": "2.5.8", "cpu": [ @@ -16856,6 +16850,62 @@ "darwin" ] }, + "node_modules/turbo-linux-64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.5.8.tgz", + "integrity": "sha512-hMyvc7w7yadBlZBGl/bnR6O+dJTx3XkTeyTTH4zEjERO6ChEs0SrN8jTFj1lueNXKIHh1SnALmy6VctKMGnWfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/turbo-linux-arm64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.5.8.tgz", + "integrity": "sha512-LQELGa7bAqV2f+3rTMRPnj5G/OHAe2U+0N9BwsZvfMvHSUbsQ3bBMWdSQaYNicok7wOZcHjz2TkESn1hYK6xIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/turbo-windows-64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.5.8.tgz", + "integrity": "sha512-3YdcaW34TrN1AWwqgYL9gUqmZsMT4T7g8Y5Azz+uwwEJW+4sgcJkIi9pYFyU4ZBSjBvkfuPZkGgfStir5BBDJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/turbo-windows-arm64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.5.8.tgz", + "integrity": "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/turf-extent": { "version": "1.0.4", "license": "MIT", @@ -17023,9 +17073,9 @@ } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -17528,6 +17578,7 @@ }, "node_modules/which": { "version": "2.0.2", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" From 607ae234780f422055e3007219135783076f4aeb Mon Sep 17 00:00:00 2001 From: Priya Venkatesan Date: Fri, 31 Oct 2025 04:19:03 -0700 Subject: [PATCH 03/14] notfis react component fixed, popup correct dropdowns, updated styling --- .../NotificationClassCard.module.scss | 78 +++--- .../NotificationClassCard/index.tsx | 226 ++++++------------ 2 files changed, 121 insertions(+), 183 deletions(-) diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss index 4418944e7..a557a0bdf 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss @@ -1,7 +1,14 @@ +/* Final NotificationClassCard.module.scss */ + +// This section controls the overall card box and allows the popup to show over everything .cardWrapper { position: relative; overflow: visible; - height: 128px; // Fixed height to match carousel cards + /* The original height was fixed (e.g., 128px), which made the card short. + We are removing the fixed height to let the content inside dictate the size, + which should match the Catalog card's natural height. + */ + height: auto; // Change from fixed height to auto for flexibility &.popupOpen { z-index: 100; @@ -14,20 +21,54 @@ } } +// Card Header Styles .cardHeading { font-size: 16px !important; font-weight: 600 !important; - margin-bottom: 8px !important; /* <--- ADD THIS LINE */ + margin-bottom: 8px !important; + color: var(--heading-color); +} + +.sectionNumber { + color: var(--paragraph-color) !important; /* MUST be the light grey color */ + font-weight: 600 !important; /* Ensure it's not bold */ +} + +// Class Description Styles (for ellipsis and grey color) +.description { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + color: var(--paragraph-color); +} + +// Enrollment Percentage Text Styles (unbolded, color is set by inline style in TSX) +.enrollmentText { + font-size: 14px; + font-weight: 400; + line-height: 1; +} + +// Grade Wrapper (Restored to work with AverageGrade component) +.gradeWrapper { + /* This block is usually needed to correctly position the AverageGrade component */ + padding-right: 4px; /* small adjustment */ } + +/* --- Bell Icon and Popup Styles (Necessary for Functionality) --- */ + .bellWrapper { display: inline-flex; align-items: center; justify-content: center; + position: relative; gap: 2px; - min-width: 24px; - height: 24px; - padding: 0 4px; + min-width: 28px; + height: 28px; + z-index: 100; + padding: 0 6px; margin-top: -3.5px; border: 1px solid var(--border-color); border-radius: 4px; @@ -42,13 +83,11 @@ color: var(--blue-500); &:hover { - background-color: var(--background-color); color: var(--blue-600); } } } -// fix this up for the popup to be sticky .popup { background-color: var(--foreground-color); border: 1px solid var(--border-color); @@ -60,26 +99,9 @@ display: flex; flex-direction: column; gap: 8px; -} - -.gradeWrapper { - /* The > * selector targets the component - inside our wrapper and forces these styles onto it. - */ - > * { - /* 1. Remove the default pill styles */ - background: none !important; - padding: 0 !important; - border: none !important; - - /* 2. Add the new target styles */ - font-size: 14px !important; - color: #F59E0B !important; - font-weight: 600 !important; - - /* Optional: a small tweak to align it */ - margin-top: 1px; - } + position: absolute; /* Anchors it to the bell wrapper */ + top: 40px; /* Positions it ~10px below the bell icon (30px bell height + 10px gap) */ + right: 0; } .checkboxOption { @@ -105,4 +127,4 @@ &:hover { color: var(--blue-500); } -} +} \ No newline at end of file diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx index 1f611b12d..599c3113d 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx @@ -5,20 +5,15 @@ import { useRef, useState, } from "react"; - -import { - Bell, - BellNotification, - Bookmark, - BookmarkSolid, - NavArrowDown, -} from "iconoir-react"; import { createPortal } from "react-dom"; +import { Bookmark, BookmarkSolid, Bell, BellNotification, NavArrowDown } from "iconoir-react"; + import { Card } from "@repo/theme"; import { AverageGrade } from "@/components/AverageGrade"; import Capacity from "@/components/Capacity"; +import { getEnrollmentColor } from "@/components/Capacity"; import Units from "@/components/Units"; import { IClass } from "@/lib/api"; @@ -56,11 +51,10 @@ export default function NotificationClassCard({ bookmarked = false, bookmarkToggle, ...props -}: NotificationClassCardProps & - Omit, keyof NotificationClassCardProps>) { +}: NotificationClassCardProps & Omit, keyof NotificationClassCardProps>) { const [showPopup, setShowPopup] = useState(false); const [showRemoveConfirmation, setShowRemoveConfirmation] = useState(false); - const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + // DELETED: const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); const [tempThresholds, setTempThresholds] = useState(thresholds); const popupRef = useRef(null); const bellIconRef = useRef(null); @@ -71,23 +65,7 @@ export default function NotificationClassCard({ setTempThresholds(thresholds); }, [thresholds]); - // Update popup position when it opens - useEffect(() => { - if (showPopup && bellIconRef.current) { - const rect = bellIconRef.current.getBoundingClientRect(); - const popupWidth = 280; // min-width from CSS - const viewportWidth = window.innerWidth; - const spaceOnRight = viewportWidth - rect.left; - - // If popup would overflow the right edge, align it to the right edge - const shouldAlignRight = spaceOnRight < popupWidth; - - setPopupPosition({ - top: rect.top + 20, // 20px below the icon - left: shouldAlignRight ? viewportWidth - popupWidth - 16 : rect.left, // 16px padding from edge - }); - } - }, [showPopup]); + // DELETED: useEffect hook for position calculation removed // Handle threshold change with temp state const handleTempThresholdChange = (threshold: number, checked: boolean) => { @@ -143,154 +121,92 @@ export default function NotificationClassCard({ document.removeEventListener("mousedown", handleClickOutside); }; }, [showPopup, tempThresholds, thresholds, onThresholdChange]); + const enrolled = enrollment?.latest.enrolledCount ?? 0; + const max = enrollment?.latest.maxEnroll ?? 0; + const enrollmentPercentage = max > 0 ? Math.round((enrolled / max) * 100) : 0; + const enrollmentColor = getEnrollmentColor(enrolled, max); return ( -
+
- - + +
- {subject ?? courseSubject} {courseNumber ?? courseNumber2} # - {number} - - {title ?? courseTitle} + {subject ?? courseSubject} {courseNumber ?? courseNumber2} + #{number} + + {title ?? courseTitle}
- {/* --- CHANGE 1: marginTop changed to '12px' --- - */} - - + {/* --- Footer with Enrollment and Units --- */} + + + {enrollmentPercentage}% enrolled +
- - {/* --- CHANGE 2: Wrapped AverageGrade in a div to fix styling --- - */} + + {/* --- AverageGrade Pill --- */} {gradeDistribution && ( -
- -
- )} - {bookmarkToggle && ( - - {bookmarked ? ( - - ) : ( - - )} - +
+ +
)} + {/* Bell Icon and Dropdown Arrow */}
setShowPopup(!showPopup)} > {isActive ? ( <> - - + + ) : ( )}
- {showPopup && - createPortal( -
- - - - - - -
, - document.body - )} + {/* Popup Menu - Cleaned up to use pure CSS positioning */} + {showPopup && createPortal( +
+ + + + {/* DELETED: When an unreserved seat opens */} + {/* DELETED: When my waitlist position improves */} + {/* DELETED: When there is space to join the waitlist */} +
, + document.body + )}
@@ -308,4 +224,4 @@ export default function NotificationClassCard({ />
); -} +} \ No newline at end of file From aadf6c15bccbcd8036d7168cef4cda1686c839c7 Mon Sep 17 00:00:00 2001 From: Priya Venkatesan Date: Fri, 31 Oct 2025 10:32:12 -0700 Subject: [PATCH 04/14] fixed relative popover, right-edge anchor --- .../NotificationClassCard.module.scss | 6 +++-- .../NotificationClassCard/index.tsx | 27 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss index a557a0bdf..805c012dd 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss @@ -100,8 +100,8 @@ flex-direction: column; gap: 8px; position: absolute; /* Anchors it to the bell wrapper */ - top: 40px; /* Positions it ~10px below the bell icon (30px bell height + 10px gap) */ - right: 0; + top: 48px; /* Positions it ~10px below the bell icon (30px bell height + 10px gap) */ + right: 13px; } .checkboxOption { @@ -118,6 +118,8 @@ height: 16px; cursor: pointer; flex-shrink: 0; + background-color: var(--foreground-color); + border: 3px solid var(--border-color); } span { diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx index 599c3113d..371df5bbb 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx @@ -1,18 +1,16 @@ import { ComponentPropsWithRef, - useCallback, useEffect, useRef, useState, } from "react"; -import { createPortal } from "react-dom"; +// Removed: import { createPortal } from "react-dom"; -import { Bookmark, BookmarkSolid, Bell, BellNotification, NavArrowDown } from "iconoir-react"; +import { Bell, BellNotification, NavArrowDown } from "iconoir-react"; import { Card } from "@repo/theme"; import { AverageGrade } from "@/components/AverageGrade"; -import Capacity from "@/components/Capacity"; import { getEnrollmentColor } from "@/components/Capacity"; import Units from "@/components/Units"; import { IClass } from "@/lib/api"; @@ -54,7 +52,7 @@ export default function NotificationClassCard({ }: NotificationClassCardProps & Omit, keyof NotificationClassCardProps>) { const [showPopup, setShowPopup] = useState(false); const [showRemoveConfirmation, setShowRemoveConfirmation] = useState(false); - // DELETED: const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + // DELETED: popupPosition state variable const [tempThresholds, setTempThresholds] = useState(thresholds); const popupRef = useRef(null); const bellIconRef = useRef(null); @@ -65,7 +63,7 @@ export default function NotificationClassCard({ setTempThresholds(thresholds); }, [thresholds]); - // DELETED: useEffect hook for position calculation removed + // DELETED: Obsolete position calculation useEffect hook // Handle threshold change with temp state const handleTempThresholdChange = (threshold: number, checked: boolean) => { @@ -121,6 +119,7 @@ export default function NotificationClassCard({ document.removeEventListener("mousedown", handleClickOutside); }; }, [showPopup, tempThresholds, thresholds, onThresholdChange]); + const enrolled = enrollment?.latest.enrolledCount ?? 0; const max = enrollment?.latest.maxEnroll ?? 0; const enrollmentPercentage = max > 0 ? Math.round((enrolled / max) * 100) : 0; @@ -135,7 +134,7 @@ export default function NotificationClassCard({ {subject ?? courseSubject} {courseNumber ?? courseNumber2} #{number} - + {title ?? courseTitle}
{/* --- Footer with Enrollment and Units --- */} @@ -170,12 +169,13 @@ export default function NotificationClassCard({ )}
- {/* Popup Menu - Cleaned up to use pure CSS positioning */} - {showPopup && createPortal( + + {/* 🌟 FINAL FIX: Popup Menu rendered directly inside Card.Actions (NO PORTAL) 🌟 */} + {showPopup && (
- {/* DELETED: When an unreserved seat opens */} - {/* DELETED: When my waitlist position improves */} - {/* DELETED: When there is space to join the waitlist */} -
, - document.body +
)} + From 00118285515957f4ee40507a21aada03ee3af2f7 Mon Sep 17 00:00:00 2001 From: Priya Venkatesan Date: Fri, 31 Oct 2025 11:30:20 -0700 Subject: [PATCH 05/14] dynamic arrow, confirmation model, final feature cleanup --- .../ConfirmationPopups.module.scss | 1 + .../ConfirmationPopups/index.tsx | 2 +- .../NotificationClassCard.module.scss | 36 +++++++------------ .../NotificationClassCard/index.tsx | 17 ++------- .../Notifications/Notifications.module.scss | 21 +++++------ .../src/app/Profile/Notifications/index.tsx | 4 +-- 6 files changed, 27 insertions(+), 54 deletions(-) diff --git a/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss index 8d7b88e87..5c3a5fa0a 100644 --- a/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss +++ b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/ConfirmationPopups.module.scss @@ -22,6 +22,7 @@ .subtitle { margin: 0px 20px; color: var(--paragraph-color); + text-align: center; font-size: 15px; line-height: 1.5; margin-bottom: 28px; diff --git a/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx index 38561b414..2c561039a 100644 --- a/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/ConfirmationPopups/index.tsx @@ -52,7 +52,7 @@ export function RemoveClassPopup({ {!isRemoving && ( )}
)} - {/* Bell Icon and Dropdown Arrow */}
- + ) : ( )}
- {/* 🌟 FINAL FIX: Popup Menu rendered directly inside Card.Actions (NO PORTAL) 🌟 */} {showPopup && (
+ +
+ handleTempThresholdChange(75, e.target.checked)} - /> - When 75% full - - + +
+ +
+ handleTempThresholdChange(90, e.target.checked)} - /> - When 90% full - + onCheckedChange={(checked) => handleTempThresholdChange(90, checked as boolean)} + > + + + + + +
)} @@ -210,4 +235,62 @@ export default function NotificationClassCard({ />
); -} \ No newline at end of file +} + +const CustomBellNotificationIcon = (props: React.SVGProps) => ( + + + + + +); + +const CustomBellIcon = (props: React.SVGProps) => ( + + + + +); \ No newline at end of file From b002a3d0192b27e473a9d4095d9176cec231bc65 Mon Sep 17 00:00:00 2001 From: Priya Venkatesan Date: Sun, 2 Nov 2025 17:28:43 -0800 Subject: [PATCH 07/14] resolved all frontend related pr comments --- .../NotificationClassCard.module.scss | 11 ++++ .../NotificationClassCard/index.tsx | 55 +++++++++++-------- .../src/app/Profile/Notifications/index.tsx | 9 +-- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss index 8d9e1b712..82bf14efd 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/NotificationClassCard.module.scss @@ -13,6 +13,17 @@ } } +.cardColumnHeader { + height: 100%; +} + +.cardBodyWrapper { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + .cardHeading { font-size: 16px !important; font-weight: 600 !important; diff --git a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx index 8912401b7..4c3b518be 100644 --- a/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/NotificationClassCard/index.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { ComponentPropsWithRef, useEffect, @@ -120,23 +121,30 @@ export default function NotificationClassCard({ const uniqueId = `${subject}-${number}`; return ( -
+
- - -
- - {subject ?? courseSubject} {courseNumber ?? courseNumber2} - #{number} - - {title ?? courseTitle} + + + + +
+
+ + {subject ?? courseSubject} {courseNumber ?? courseNumber2} + #{number} + + {title ?? courseTitle} +
+ + + {enrollmentPercentage}% enrolled + + +
- - - {enrollmentPercentage}% enrolled - - - +
{gradeDistribution && ( @@ -237,6 +245,7 @@ export default function NotificationClassCard({ ); } + const CustomBellNotificationIcon = (props: React.SVGProps) => ( ) => ( strokeWidth="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" - color="currentColor" // Use CSS color + color="currentColor" {...props} > ); @@ -277,20 +286,20 @@ const CustomBellIcon = (props: React.SVGProps) => ( strokeWidth="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" - color="currentColor" // Use CSS color + color="currentColor" {...props} > -); \ No newline at end of file +); diff --git a/apps/frontend/src/app/Profile/Notifications/index.tsx b/apps/frontend/src/app/Profile/Notifications/index.tsx index d3c62bc41..4ca18902c 100644 --- a/apps/frontend/src/app/Profile/Notifications/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/index.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { Text } from "@repo/theme"; import { useReadUser, useUpdateUser } from "@/hooks/api"; import { IMonitoredClass } from "@/lib/api/users"; @@ -263,10 +264,10 @@ export default function Notifications() {

Add/Drop Deadline Notifications

-

- Get notified about key academic deadlines, including add/drop and late - change of class schedule for the semester. -

+ + Get notified about key academic deadlines, including add/drop and late + change of class schedule for the semester. +

Add/Drop Deadline Notifications

- + Get notified about key academic deadlines, including add/drop and late change of class schedule for the semester. @@ -275,7 +223,7 @@ export default function Notifications() { type="checkbox" checked={addDropDeadline} onChange={(e) => setAddDropDeadline(e.target.checked)} - className={styles.toggle} + className={styles.toggleOption} /> Add/drop deadline @@ -284,7 +232,7 @@ export default function Notifications() { type="checkbox" checked={lateChangeSchedule} onChange={(e) => setLateChangeSchedule(e.target.checked)} - className={styles.toggle} + className={styles.toggleOption} /> Late change of class schedule diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index 6ae4bf0a6..23c9d4b23 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode, lazy, useCallback, useEffect, useMemo } from "react"; +import { ReactNode, lazy, useCallback, useEffect, useMemo, useState } from "react"; import classNames from "classnames"; import { @@ -30,6 +30,7 @@ import { AverageGrade } from "@/components/AverageGrade"; import CCN from "@/components/CCN"; import EnrollmentDisplay from "@/components/EnrollmentDisplay"; import Units from "@/components/Units"; +import NotificationButton from "@/components/NotificationButton"; import ClassContext from "@/contexts/ClassContext"; import { ClassPin } from "@/contexts/PinsContext"; import { useReadCourseForClass, useUpdateUser } from "@/hooks/api"; @@ -224,6 +225,95 @@ export default function Class({ ); }, [_class, bookmarked, updateUser, user]); + // Notification management + const [notificationThresholds, setNotificationThresholds] = useState([]); + + // Sync notification thresholds from user.monitoredClasses + useEffect(() => { + if (!user?.monitoredClasses || !_class) return; + + const monitoredClass = user.monitoredClasses.find( + (mc) => + mc.class.subject === _class.subject && + mc.class.courseNumber === _class.courseNumber && + mc.class.number === _class.number && + mc.class.year === _class.year && + mc.class.semester === _class.semester + ); + + setNotificationThresholds(monitoredClass?.thresholds || []); + }, [user, _class]); + + const handleNotificationChange = useCallback( + async (threshold: number, checked: boolean) => { + if (!user || !_class) return; + + const monitoredClasses = user.monitoredClasses || []; + const existingIndex = monitoredClasses.findIndex( + (mc) => + mc.class.subject === _class.subject && + mc.class.courseNumber === _class.courseNumber && + mc.class.number === _class.number && + mc.class.year === _class.year && + mc.class.semester === _class.semester + ); + + let updatedMonitoredClasses; + + if (existingIndex >= 0) { + // Class already monitored, update thresholds + const existingClass = monitoredClasses[existingIndex]; + const updatedThresholds = checked + ? [...existingClass.thresholds, threshold].sort((a, b) => a - b) + : existingClass.thresholds.filter((t) => t !== threshold); + + updatedMonitoredClasses = [...monitoredClasses]; + updatedMonitoredClasses[existingIndex] = { + ...existingClass, + thresholds: updatedThresholds, + }; + + // Update local state + setNotificationThresholds(updatedThresholds); + } else { + // New monitored class + updatedMonitoredClasses = [ + ...monitoredClasses, + { + class: _class, + thresholds: [threshold], + }, + ]; + + setNotificationThresholds([threshold]); + } + + // TODO: Call backend mutation to update user.monitoredClasses when backend is ready + // await updateUser({ monitoredClasses: updatedMonitoredClasses }); + }, + [user, _class] + ); + + const handleRemoveNotification = useCallback(async () => { + if (!user || !_class) return; + + const updatedMonitoredClasses = (user.monitoredClasses || []).filter( + (mc) => + !( + mc.class.subject === _class.subject && + mc.class.courseNumber === _class.courseNumber && + mc.class.number === _class.number && + mc.class.year === _class.year && + mc.class.semester === _class.semester + ) + ); + + setNotificationThresholds([]); + + // TODO: Call backend mutation to update user.monitoredClasses when backend is ready + // await updateUser({ monitoredClasses: updatedMonitoredClasses }); + }, [user, _class]); + useEffect(() => { if (!_class) return; @@ -347,6 +437,15 @@ export default function Class({ + {_class && ( + + )} diff --git a/apps/frontend/src/components/ClassCard/index.tsx b/apps/frontend/src/components/ClassCard/index.tsx index bbc36734b..a07336819 100644 --- a/apps/frontend/src/components/ClassCard/index.tsx +++ b/apps/frontend/src/components/ClassCard/index.tsx @@ -30,6 +30,7 @@ interface ClassProps { bookmarkToggle?: () => void; active?: boolean; wrapDescription?: boolean; + showGrades?: boolean; } export default function ClassCard({ @@ -45,10 +46,12 @@ export default function ClassCard({ bookmarkToggle, active = false, wrapDescription = false, + showGrades = true, ...props }: ClassProps & Omit, keyof ClassProps>) { - const gradeDistribution = - _class?.course?.gradeDistribution ?? _class?.gradeDistribution; + const gradeDistribution = showGrades + ? (_class?.course?.gradeDistribution ?? _class?.gradeDistribution) + : undefined; return ( (thresholds); + + const isActive = thresholds.length > 0; + + // Sync temp thresholds with prop changes + useEffect(() => { + setTempThresholds(thresholds); + }, [thresholds]); + + const handleTempThresholdChange = useCallback((threshold: number, checked: boolean) => { + setTempThresholds((prev) => { + if (checked) { + return [...prev, threshold].sort((a, b) => a - b); + } else { + return prev.filter((t) => t !== threshold); + } + }); + }, []); + + const applyThresholdChanges = useCallback(() => { + // Add new thresholds + tempThresholds.forEach((threshold) => { + if (!thresholds.includes(threshold)) { + onThresholdsChange(threshold, true); + } + }); + + // Remove old thresholds + thresholds.forEach((threshold) => { + if (!tempThresholds.includes(threshold)) { + onThresholdsChange(threshold, false); + } + }); + }, [tempThresholds, thresholds, onThresholdsChange]); + + const handleOpenChange = useCallback( + (open: boolean) => { + setShowPopup(open); + + // When closing the popover, handle threshold changes + if (!open) { + // If user removed all thresholds and there were some before, show confirmation + if (tempThresholds.length === 0 && thresholds.length > 0 && onRemove) { + setShowRemoveConfirmation(true); + } else { + applyThresholdChanges(); + } + } + }, + [tempThresholds, thresholds, onRemove, applyThresholdChanges] + ); + + const tooltipContent = useMemo(() => { + if (disabled) return "Notification disabled"; + return isActive ? "Manage notifications" : "Notify me"; + }, [disabled, isActive]); + + const buttonClassName = classNames( + styles.button, + { + [styles.active]: isActive, + [styles.iconButton]: variant === "iconButton", + [styles.card]: variant === "card", + }, + className + ); + + return ( + <> + + + + {variant === "iconButton" ? ( + + + {isActive ? ( + + + ) : ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setShowPopup(!showPopup); + } + }} + > + {isActive ? ( +
+ )} +
+
+ + + { + setTempThresholds(thresholds); // Reset on escape + setShowPopup(false); + }} + > + + {NOTIFICATION_THRESHOLDS.map((threshold) => ( + + ))} + + + + +
+ + {onRemove && showRemoveConfirmation && ( +
+
+ + Stop tracking this class? + + + This will remove all notification thresholds for this class. + + + + + +
+
+ )} + + ); +} diff --git a/apps/frontend/src/types/notifications.ts b/apps/frontend/src/types/notifications.ts new file mode 100644 index 000000000..a05c2e655 --- /dev/null +++ b/apps/frontend/src/types/notifications.ts @@ -0,0 +1,50 @@ +/** + * Notification-related types for course enrollment tracking + */ + +import { IClass } from "@/lib/api/classes"; + +/** + * Supported enrollment threshold percentages + */ +export type NotificationThreshold = 50 | 75 | 90; + +/** + * All possible threshold values as a const array for validation + */ +export const NOTIFICATION_THRESHOLDS: readonly NotificationThreshold[] = [50, 75, 90] as const; + +/** + * Monitored class with notification thresholds + * Re-exported for consistency with backend types + */ +export interface IMonitoredClass { + class: IClass; + thresholds: number[]; +} + +/** + * Class identifier for notification operations + */ +export interface ClassIdentifier { + subject: string; + courseNumber: string; + number: string; + year: number; + semester: string; +} + +/** + * Props for notification button variants + */ +export type NotificationButtonVariant = "iconButton" | "card"; + +/** + * Threshold change callback type + */ +export type OnThresholdChange = (threshold: number, checked: boolean) => void | Promise; + +/** + * Remove class callback type + */ +export type OnRemoveClass = () => void | Promise; From 35f88b1145c3c3e79b2554582f694529259ba3f6 Mon Sep 17 00:00:00 2001 From: PineND Date: Thu, 13 Nov 2025 00:10:29 -0800 Subject: [PATCH 12/14] warning and confirmation --- .../Notifications/Notifications.module.scss | 26 +++ .../src/app/Profile/Notifications/index.tsx | 148 +++++++++++++++++- .../src/components/Class/Class.module.scss | 27 ++++ apps/frontend/src/components/Class/index.tsx | 138 +++++++++++----- .../NotificationButton.module.scss | 34 ++-- .../components/NotificationButton/index.tsx | 88 +++++++---- 6 files changed, 365 insertions(+), 96 deletions(-) diff --git a/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss b/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss index d31a466ec..71ed1971d 100644 --- a/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss +++ b/apps/frontend/src/app/Profile/Notifications/Notifications.module.scss @@ -121,3 +121,29 @@ .noMarginHeading { margin: 0; } + +.toast { + position: fixed; + left: 50%; + bottom: -120px; + transform: translateX(-50%); + width: min(720px, calc(100% - 32px)); + background-color: var(--blue-500); + color: white; + padding: 16px 24px; + border-radius: 12px; + font-size: 15px; + line-height: 1.5; + opacity: 0; + pointer-events: none; + transition: + bottom 250ms ease, + opacity 200ms ease; + z-index: 11000; +} + +.toastVisible { + bottom: 32px; + opacity: 1; + pointer-events: auto; +} diff --git a/apps/frontend/src/app/Profile/Notifications/index.tsx b/apps/frontend/src/app/Profile/Notifications/index.tsx index 780ff7587..820643bf6 100644 --- a/apps/frontend/src/app/Profile/Notifications/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/index.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import classNames from "classnames"; import { Text } from "@repo/theme"; import { useReadUser } from "@/hooks/api"; @@ -9,6 +10,14 @@ import NotificationButton from "@/components/NotificationButton"; import styles from "./Notifications.module.scss"; +const TOAST_DURATION_MS = 3000; +const TOAST_TRANSITION_MS = 250; + +const getClassKey = (monitoredClass: IMonitoredClass) => { + const cls = monitoredClass.class; + return `${cls.subject}-${cls.courseNumber}-${cls.number}-${cls.year}-${cls.semester}`; +}; + // Test data for development const TEST_DATA: IMonitoredClass[] = [ { @@ -129,6 +138,70 @@ export default function Notifications() { const [addDropDeadline, setAddDropDeadline] = useState(false); const [lateChangeSchedule, setLateChangeSchedule] = useState(false); const [receiveEmails, setReceiveEmails] = useState(true); + const [toastMessage, setToastMessage] = useState(""); + const [isToastVisible, setToastVisible] = useState(false); + const toastTimeoutRef = useRef | null>(null); + const toastHideTimeoutRef = useRef | null>(null); + const prevThresholdMapRef = useRef>(new Map()); + const currentToastKeyRef = useRef(null); + + const clearToastTimeout = useCallback(() => { + if (toastTimeoutRef.current) { + clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + }, []); + + const clearToastHideTimeout = useCallback(() => { + if (toastHideTimeoutRef.current) { + clearTimeout(toastHideTimeoutRef.current); + toastHideTimeoutRef.current = null; + } + }, []); + + const hideToast = useCallback( + (onHidden?: () => void) => { + clearToastTimeout(); + if (!isToastVisible) { + onHidden?.(); + return; + } + + setToastVisible(false); + clearToastHideTimeout(); + toastHideTimeoutRef.current = setTimeout(() => { + toastHideTimeoutRef.current = null; + onHidden?.(); + }, TOAST_TRANSITION_MS); + }, + [clearToastTimeout, clearToastHideTimeout, isToastVisible] + ); + + const showToast = useCallback( + (toastKey: string, message: string) => { + const display = () => { + currentToastKeyRef.current = toastKey; + setToastMessage(message); + setToastVisible(true); + clearToastTimeout(); + toastTimeoutRef.current = setTimeout(() => { + hideToast(() => { + if (currentToastKeyRef.current === toastKey) { + currentToastKeyRef.current = null; + setToastMessage(""); + } + }); + }, TOAST_DURATION_MS); + }; + + if (isToastVisible) { + hideToast(display); + } else { + display(); + } + }, + [clearToastTimeout, hideToast, isToastVisible] + ); const handleThresholdChange = ( classIndex: number, @@ -160,6 +233,69 @@ export default function Notifications() { // await updateUser({ monitoredClasses: updated }); }; + useEffect(() => { + if (monitoredClasses.length === 0) { + prevThresholdMapRef.current.clear(); + currentToastKeyRef.current = null; + hideToast(() => setToastMessage("")); + return; + } + + const seenKeys = new Set(); + + for (let index = 0; index < monitoredClasses.length; index += 1) { + const monitoredClass = monitoredClasses[index]; + const key = getClassKey(monitoredClass); + seenKeys.add(key); + + const sortedThresholds = [...monitoredClass.thresholds].sort((a, b) => a - b); + const prevThresholds = prevThresholdMapRef.current.get(key); + + const hasChanged = + !prevThresholds || + sortedThresholds.length !== prevThresholds.length || + sortedThresholds.some((value, thresholdIndex) => value !== prevThresholds[thresholdIndex]); + + if (hasChanged) { + prevThresholdMapRef.current.set(key, sortedThresholds); + + if (sortedThresholds.length > 0) { + const thresholdsText = sortedThresholds.map((threshold) => `${threshold}%`).join(", "); + const courseLabel = `${monitoredClass.class.subject} ${monitoredClass.class.courseNumber}`; + showToast(key, `You'll be notified about ${courseLabel} enrollment milestones (${thresholdsText}).`); + } else if (currentToastKeyRef.current === key) { + hideToast(() => { + currentToastKeyRef.current = null; + setToastMessage(""); + }); + } + + return; + } + + prevThresholdMapRef.current.set(key, sortedThresholds); + } + + prevThresholdMapRef.current.forEach((_value, key) => { + if (!seenKeys.has(key)) { + prevThresholdMapRef.current.delete(key); + if (currentToastKeyRef.current === key) { + hideToast(() => { + currentToastKeyRef.current = null; + setToastMessage(""); + }); + } + } + }); + }, [monitoredClasses, showToast, hideToast]); + + useEffect(() => { + return () => { + clearToastTimeout(); + clearToastHideTimeout(); + }; + }, [clearToastTimeout, clearToastHideTimeout]); + return (
@@ -238,6 +374,16 @@ export default function Notifications() {
+
+ {toastMessage} +
); } diff --git a/apps/frontend/src/components/Class/Class.module.scss b/apps/frontend/src/components/Class/Class.module.scss index 076029883..822606a16 100644 --- a/apps/frontend/src/components/Class/Class.module.scss +++ b/apps/frontend/src/components/Class/Class.module.scss @@ -1,5 +1,6 @@ .root { background-color: var(--background-color); + position: relative; } .header { @@ -57,3 +58,29 @@ background-color: var(--blue-500); } } + +.notificationToast { + position: absolute; + left: 50%; + bottom: -120px; + transform: translateX(-50%); + width: min(720px, calc(100% - 32px)); + background-color: var(--blue-500); + color: white; + padding: 16px 24px; + border-radius: 12px; + font-size: 15px; + line-height: 1.5; + opacity: 0; + pointer-events: none; + transition: + bottom 250ms ease, + opacity 200ms ease; + z-index: 11000; +} + +.notificationToastVisible { + bottom: 32px; + opacity: 1; + pointer-events: auto; +} diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index 23c9d4b23..fcc962c7d 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode, lazy, useCallback, useEffect, useMemo, useState } from "react"; +import { ReactNode, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import classNames from "classnames"; import { @@ -227,6 +227,42 @@ export default function Class({ // Notification management const [notificationThresholds, setNotificationThresholds] = useState([]); + const [isNotificationToastVisible, setNotificationToastVisible] = useState(false); + const [notificationToastMessage, setNotificationToastMessage] = useState(""); + const toastTimeoutRef = useRef | null>(null); + const prevThresholdsRef = useRef([]); + const hasInitializedToastRef = useRef(false); + + const closeNotificationToast = useCallback(() => { + if (toastTimeoutRef.current) { + clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + setNotificationToastVisible(false); + }, []); + + const triggerNotificationToast = useCallback( + (message: string) => { + closeNotificationToast(); + setNotificationToastMessage(message); + + const showToast = () => { + setNotificationToastVisible(true); + }; + + if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(showToast); + } else { + showToast(); + } + + toastTimeoutRef.current = setTimeout(() => { + setNotificationToastVisible(false); + toastTimeoutRef.current = null; + }, 3000); + }, + [closeNotificationToast] + ); // Sync notification thresholds from user.monitoredClasses useEffect(() => { @@ -241,52 +277,31 @@ export default function Class({ mc.class.semester === _class.semester ); - setNotificationThresholds(monitoredClass?.thresholds || []); - }, [user, _class]); + const thresholds = [...(monitoredClass?.thresholds || [])].sort((a, b) => a - b); + setNotificationThresholds(thresholds); + prevThresholdsRef.current = thresholds; + hasInitializedToastRef.current = true; + closeNotificationToast(); + setNotificationToastMessage(""); + }, [user, _class, closeNotificationToast]); + + useEffect(() => { + if (_class && !hasInitializedToastRef.current) { + hasInitializedToastRef.current = true; + prevThresholdsRef.current = notificationThresholds; + } + }, [_class, notificationThresholds]); const handleNotificationChange = useCallback( async (threshold: number, checked: boolean) => { if (!user || !_class) return; - const monitoredClasses = user.monitoredClasses || []; - const existingIndex = monitoredClasses.findIndex( - (mc) => - mc.class.subject === _class.subject && - mc.class.courseNumber === _class.courseNumber && - mc.class.number === _class.number && - mc.class.year === _class.year && - mc.class.semester === _class.semester - ); - - let updatedMonitoredClasses; - - if (existingIndex >= 0) { - // Class already monitored, update thresholds - const existingClass = monitoredClasses[existingIndex]; - const updatedThresholds = checked - ? [...existingClass.thresholds, threshold].sort((a, b) => a - b) - : existingClass.thresholds.filter((t) => t !== threshold); - - updatedMonitoredClasses = [...monitoredClasses]; - updatedMonitoredClasses[existingIndex] = { - ...existingClass, - thresholds: updatedThresholds, - }; - - // Update local state - setNotificationThresholds(updatedThresholds); - } else { - // New monitored class - updatedMonitoredClasses = [ - ...monitoredClasses, - { - class: _class, - thresholds: [threshold], - }, - ]; - - setNotificationThresholds([threshold]); - } + setNotificationThresholds((prev) => { + if (checked) { + return Array.from(new Set([...prev, threshold])).sort((a, b) => a - b); + } + return prev.filter((t) => t !== threshold).sort((a, b) => a - b); + }); // TODO: Call backend mutation to update user.monitoredClasses when backend is ready // await updateUser({ monitoredClasses: updatedMonitoredClasses }); @@ -314,6 +329,35 @@ export default function Class({ // await updateUser({ monitoredClasses: updatedMonitoredClasses }); }, [user, _class]); + useEffect(() => { + if (!hasInitializedToastRef.current || !_class) return; + + const sortedThresholds = [...notificationThresholds].sort((a, b) => a - b); + const prevSorted = prevThresholdsRef.current; + + const hasChanged = + sortedThresholds.length !== prevSorted.length || + sortedThresholds.some((value, index) => value !== prevSorted[index]); + + if (hasChanged && sortedThresholds.length > 0) { + const thresholdsText = sortedThresholds.map((threshold) => `${threshold}%`).join(", "); + triggerNotificationToast( + `You'll be notified about ${_class.subject} ${_class.courseNumber} enrollment milestones (${thresholdsText}).` + ); + } else if (sortedThresholds.length === 0) { + closeNotificationToast(); + setNotificationToastMessage(""); + } + + prevThresholdsRef.current = sortedThresholds; + }, [notificationThresholds, _class, triggerNotificationToast, closeNotificationToast]); + + useEffect(() => { + return () => { + closeNotificationToast(); + }; + }, [closeNotificationToast]); + useEffect(() => { if (!_class) return; @@ -641,6 +685,16 @@ export default function Class({ )} +
+ {notificationToastMessage} +
); diff --git a/apps/frontend/src/components/NotificationButton/NotificationButton.module.scss b/apps/frontend/src/components/NotificationButton/NotificationButton.module.scss index 993f98d3d..8508f89b8 100644 --- a/apps/frontend/src/components/NotificationButton/NotificationButton.module.scss +++ b/apps/frontend/src/components/NotificationButton/NotificationButton.module.scss @@ -148,36 +148,30 @@ } /* Confirmation Dialog */ -.confirmationOverlay { - position: fixed; - inset: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; +.confirmationBody { align-items: center; - justify-content: center; - z-index: 10001; - animation: fadeIn 150ms ease-out; + text-align: center; + color: var(--heading-color); } -.confirmationDialog { - background-color: var(--foreground-color); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 24px; - min-width: 400px; - max-width: 500px; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +.confirmationIcon { + margin-top: 40px; + width: 70px; + height: 70px; + color: var(--red-500); } .confirmationTitle { - font-size: 18px; + margin-top: 28px; + margin-bottom: 14px; + font-size: 28px; font-weight: 600; - color: var(--heading-color); - margin-bottom: 8px; } .confirmationMessage { - font-size: 14px; + margin: 0 20px 28px; color: var(--paragraph-color); + font-size: 15px; line-height: 1.5; + text-align: center; } diff --git a/apps/frontend/src/components/NotificationButton/index.tsx b/apps/frontend/src/components/NotificationButton/index.tsx index f6649f223..6c71ad401 100644 --- a/apps/frontend/src/components/NotificationButton/index.tsx +++ b/apps/frontend/src/components/NotificationButton/index.tsx @@ -1,8 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import classNames from "classnames"; -import { BellNotification, BellNotificationSolid, NavArrowDown, Check } from "iconoir-react"; +import { + BellNotification, + BellNotificationSolid, + NavArrowDown, + Check, + WarningTriangleSolid, +} from "iconoir-react"; import { Popover, Checkbox } from "radix-ui"; -import { Flex, IconButton, Text, Tooltip, Button } from "@repo/theme"; +import { Flex, IconButton, Text, Tooltip, Button, Dialog } from "@repo/theme"; import { NOTIFICATION_THRESHOLDS, @@ -134,6 +140,17 @@ export default function NotificationButton({ className ); + const handleCloseRemoveConfirmation = useCallback(() => { + setTempThresholds(thresholds); + setShowRemoveConfirmation(false); + }, [thresholds]); + + const handleConfirmRemove = useCallback(async () => { + if (!onRemove) return; + await onRemove(); + setShowRemoveConfirmation(false); + }, [onRemove]); + return ( <> @@ -224,37 +241,42 @@ export default function NotificationButton({ - {onRemove && showRemoveConfirmation && ( -
-
- - Stop tracking this class? - - - This will remove all notification thresholds for this class. - - - - - -
-
+ {onRemove && ( + { + if (!open) { + handleCloseRemoveConfirmation(); + } + }} + > + + + + + +
+ Stop tracking this class? +
+
+ You will no longer receive any notifications for this course. +
+
+ + + + +
+
+
)} ); From 74c3b171c6c01cd83aa09d832d025c61f93a12a8 Mon Sep 17 00:00:00 2001 From: PineND Date: Thu, 13 Nov 2025 00:35:57 -0800 Subject: [PATCH 13/14] linting --- .../src/app/Profile/Notifications/index.tsx | 35 ++++++++---- apps/frontend/src/components/Class/index.tsx | 46 ++++++++++++---- .../components/NotificationButton/index.tsx | 55 +++++++++++++------ apps/frontend/src/types/notifications.ts | 10 +++- 4 files changed, 105 insertions(+), 41 deletions(-) diff --git a/apps/frontend/src/app/Profile/Notifications/index.tsx b/apps/frontend/src/app/Profile/Notifications/index.tsx index 820643bf6..e5bdb3572 100644 --- a/apps/frontend/src/app/Profile/Notifications/index.tsx +++ b/apps/frontend/src/app/Profile/Notifications/index.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; + import classNames from "classnames"; + import { Text } from "@repo/theme"; -import { useReadUser } from "@/hooks/api"; -import { IMonitoredClass } from "@/lib/api/users"; -import { IClass } from "@/lib/api/classes"; import ClassCard from "@/components/ClassCard"; import NotificationButton from "@/components/NotificationButton"; +import { useReadUser } from "@/hooks/api"; +import { IClass } from "@/lib/api/classes"; +import { IMonitoredClass } from "@/lib/api/users"; import styles from "./Notifications.module.scss"; @@ -141,7 +143,9 @@ export default function Notifications() { const [toastMessage, setToastMessage] = useState(""); const [isToastVisible, setToastVisible] = useState(false); const toastTimeoutRef = useRef | null>(null); - const toastHideTimeoutRef = useRef | null>(null); + const toastHideTimeoutRef = useRef | null>( + null + ); const prevThresholdMapRef = useRef>(new Map()); const currentToastKeyRef = useRef(null); @@ -248,21 +252,30 @@ export default function Notifications() { const key = getClassKey(monitoredClass); seenKeys.add(key); - const sortedThresholds = [...monitoredClass.thresholds].sort((a, b) => a - b); + const sortedThresholds = [...monitoredClass.thresholds].sort( + (a, b) => a - b + ); const prevThresholds = prevThresholdMapRef.current.get(key); const hasChanged = !prevThresholds || sortedThresholds.length !== prevThresholds.length || - sortedThresholds.some((value, thresholdIndex) => value !== prevThresholds[thresholdIndex]); + sortedThresholds.some( + (value, thresholdIndex) => value !== prevThresholds[thresholdIndex] + ); if (hasChanged) { prevThresholdMapRef.current.set(key, sortedThresholds); if (sortedThresholds.length > 0) { - const thresholdsText = sortedThresholds.map((threshold) => `${threshold}%`).join(", "); + const thresholdsText = sortedThresholds + .map((threshold) => `${threshold}%`) + .join(", "); const courseLabel = `${monitoredClass.class.subject} ${monitoredClass.class.courseNumber}`; - showToast(key, `You'll be notified about ${courseLabel} enrollment milestones (${thresholdsText}).`); + showToast( + key, + `You'll be notified about ${courseLabel} enrollment milestones (${thresholdsText}).` + ); } else if (currentToastKeyRef.current === key) { hideToast(() => { currentToastKeyRef.current = null; @@ -349,9 +362,9 @@ export default function Notifications() {

Add/Drop Deadline Notifications

- Get notified about key academic deadlines, including add/drop and late - change of class schedule for the semester. - + Get notified about key academic deadlines, including add/drop and late + change of class schedule for the semester. +