From ee3d9d92d46f85533b3389aeb5e6b480a8e54187 Mon Sep 17 00:00:00 2001 From: charles Date: Fri, 27 Feb 2026 18:03:44 -0800 Subject: [PATCH 01/12] added sonner to grade and enrollment for pop-ups --- apps/frontend/package.json | 1 + apps/frontend/src/App.tsx | 9 ++- apps/frontend/src/_sonner.scss | 9 +++ .../CourseManager/CourseInput/index.tsx | 3 + .../GradeDistributions/CourseInput/index.tsx | 3 + .../src/hooks/api/targeted-message/index.ts | 1 + .../showTargetedMessageToast.ts | 77 +++++++++++++++++++ .../useTargetedMessagesForCourse.ts | 6 +- apps/frontend/src/main.scss | 3 + package-lock.json | 11 +++ 10 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/_sonner.scss create mode 100644 apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4ae4b1fa7..4c870fb95 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -38,6 +38,7 @@ "react-select": "^5.10.2", "recharts": "^3.2.1", "rxjs": "^7.8.2", + "sonner": "^2.0.7", "suncalc": "^1.9.0" }, "devDependencies": { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1364d4535..27fc96648 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -7,8 +7,9 @@ import { createBrowserRouter, redirect, } from "react-router-dom"; +import { Toaster } from "sonner"; -import { ThemeProvider } from "@repo/theme"; +import { ThemeProvider, useTheme } from "@repo/theme"; import CatalogSkeleton from "@/app/Catalog/Skeleton"; import Layout from "@/components/Layout"; @@ -47,6 +48,11 @@ const GradTrakOnboarding = lazy(() => import("@/app/GradTrak/Onboarding")); const GradTrakDashboard = lazy(() => import("@/app/GradTrak/Dashboard")); const NotFound = lazy(() => import("@/app/NotFound")); +function ThemedToaster() { + const { theme } = useTheme(); + return ; +} + const router = createBrowserRouter([ { element: , @@ -378,6 +384,7 @@ export default function App() { + diff --git a/apps/frontend/src/_sonner.scss b/apps/frontend/src/_sonner.scss new file mode 100644 index 000000000..93dfd78ef --- /dev/null +++ b/apps/frontend/src/_sonner.scss @@ -0,0 +1,9 @@ +[data-sonner-toaster] { + --normal-bg: var(--background-color); + --normal-border: var(--border-color); + --normal-text: var(--heading-color); +} + +[data-sonner-toaster] [data-description] { + color: var(--paragraph-color) !important; +} diff --git a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx index 228984258..32bfc1255 100644 --- a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx +++ b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx @@ -15,6 +15,7 @@ import { import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import { useReadCourseWithInstructor } from "@/hooks/api"; +import { showTargetedMessageToast } from "@/hooks/api/targeted-message"; import { ICourseWithInstructorClass } from "@/lib/api"; import { sortByTermDescending } from "@/lib/classes"; import { GetEnrollmentDocument, Semester } from "@/lib/generated/graphql"; @@ -253,6 +254,8 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { return; } + + showTargetedMessageToast(client, selectedCourse.courseId); }; const disabled = useMemo( diff --git a/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx b/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx index e3a0e9345..9abb712b4 100644 --- a/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx +++ b/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx @@ -16,6 +16,7 @@ import { } from "@/components/CourseAnalytics/types"; import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import { useReadCourseWithInstructor } from "@/hooks/api"; +import { showTargetedMessageToast } from "@/hooks/api/targeted-message"; import { type IGradeDistribution } from "@/lib/api"; import { sortByTermDescending } from "@/lib/classes"; import { @@ -362,6 +363,8 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { return; } + + showTargetedMessageToast(client, selectedCourse.courseId); }; const disabled = useMemo( diff --git a/apps/frontend/src/hooks/api/targeted-message/index.ts b/apps/frontend/src/hooks/api/targeted-message/index.ts index ab712039f..522ce2cb7 100644 --- a/apps/frontend/src/hooks/api/targeted-message/index.ts +++ b/apps/frontend/src/hooks/api/targeted-message/index.ts @@ -1,2 +1,3 @@ export * from "./useTargetedMessagesForCourse"; export * from "./useIncrementTargetedMessageDismiss"; +export * from "./showTargetedMessageToast"; diff --git a/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts new file mode 100644 index 000000000..01df382c2 --- /dev/null +++ b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts @@ -0,0 +1,77 @@ +import { toast } from "sonner"; + +import type { ApolloClient } from "@apollo/client"; + +import "@/lib/api/targeted-message"; +import { + GetTargetedMessagesForCourseDocument, + GetTargetedMessagesForCourseQuery, + IncrementTargetedMessageDismissDocument, +} from "@/lib/generated/graphql"; +import { + isTargetedMessageDismissed, + isTargetedMessageSessionDismissed, + markTargetedMessageAsDismissed, + markTargetedMessageAsSessionDismissed, + syncDismissedTargetedMessages, +} from "@/lib/targeted-message"; + +// Persists for the entire page session — prevents re-showing the same ad +const shownMessageIds = new Set(); + +export const showTargetedMessageToast = async ( + client: ApolloClient, + courseId: string +) => { + if (!courseId) return; + + const { data } = await client.query({ + query: GetTargetedMessagesForCourseDocument, + variables: { courseId }, + fetchPolicy: "cache-first", + }); + + const messages = data?.targetedMessagesForCourse; + if (!messages?.length) return; + + syncDismissedTargetedMessages(messages.map((m) => m.id)); + + const message = messages.find((m) => { + if (!m.persistent && !m.reappearing) return !isTargetedMessageDismissed(m.id); + if (m.reappearing) return !isTargetedMessageSessionDismissed(m.id); + return true; // persistent always shows + }); + + if (!message) return; + + // Never show the same message twice in one page session + if (shownMessageIds.has(message.id)) return; + shownMessageIds.add(message.id); + + toast(message.title, { + description: message.description ?? undefined, + duration: 8000, + action: message.link + ? { + label: message.linkText || "Learn more", + onClick: () => { + window.open( + `/message/click/${message.id}?courseId=${courseId}`, + "_blank" + ); + }, + } + : undefined, + onDismiss: () => { + client.mutate({ + mutation: IncrementTargetedMessageDismissDocument, + variables: { messageId: message.id }, + }); + if (message.reappearing) { + markTargetedMessageAsSessionDismissed(message.id); + } else if (!message.persistent) { + markTargetedMessageAsDismissed(message.id); + } + }, + }); +}; diff --git a/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts b/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts index 0cec36cf1..c8a0d109a 100644 --- a/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts +++ b/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts @@ -6,12 +6,16 @@ import { GetTargetedMessagesForCourseQuery, } from "@/lib/generated/graphql"; -export const useTargetedMessagesForCourse = (courseId: string) => { +export const useTargetedMessagesForCourse = ( + courseId: string, + options?: { skip?: boolean } +) => { const query = useQuery( GetTargetedMessagesForCourseDocument, { variables: { courseId }, fetchPolicy: "cache-first", + skip: options?.skip, } ); diff --git a/apps/frontend/src/main.scss b/apps/frontend/src/main.scss index ee37e29be..78ca52a53 100644 --- a/apps/frontend/src/main.scss +++ b/apps/frontend/src/main.scss @@ -1,3 +1,5 @@ +@use "./_sonner.scss"; + :root { --header-height: 90px; /* Default, overridden by JS */ } @@ -59,3 +61,4 @@ a { body { scrollbar-color: var(--label-color) transparent; } + diff --git a/package-lock.json b/package-lock.json index 50b18b147..e99067b0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -194,6 +194,7 @@ "react-select": "^5.10.2", "recharts": "^3.2.1", "rxjs": "^7.8.2", + "sonner": "^2.0.7", "suncalc": "^1.9.0" }, "devDependencies": { @@ -17143,6 +17144,16 @@ "tslib": "^2.0.3" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", From c4f5effe5cd34e439eb3c8d5980e059cc47d807b Mon Sep 17 00:00:00 2001 From: charles Date: Sat, 28 Feb 2026 13:22:57 -0800 Subject: [PATCH 02/12] added sonner to grades and enrollment --- apps/frontend/src/App.tsx | 4 +++- .../hooks/api/targeted-message/showTargetedMessageToast.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 27fc96648..4236653a3 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -50,7 +50,9 @@ const NotFound = lazy(() => import("@/app/NotFound")); function ThemedToaster() { const { theme } = useTheme(); - return ; + return ( + + ); } const router = createBrowserRouter([ diff --git a/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts index 01df382c2..df2539aa6 100644 --- a/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts +++ b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts @@ -1,6 +1,5 @@ -import { toast } from "sonner"; - import type { ApolloClient } from "@apollo/client"; +import { toast } from "sonner"; import "@/lib/api/targeted-message"; import { @@ -37,7 +36,8 @@ export const showTargetedMessageToast = async ( syncDismissedTargetedMessages(messages.map((m) => m.id)); const message = messages.find((m) => { - if (!m.persistent && !m.reappearing) return !isTargetedMessageDismissed(m.id); + if (!m.persistent && !m.reappearing) + return !isTargetedMessageDismissed(m.id); if (m.reappearing) return !isTargetedMessageSessionDismissed(m.id); return true; // persistent always shows }); From 2ca8429e843f4d1674641583ac6298da02f5382a Mon Sep 17 00:00:00 2001 From: charles Date: Sat, 28 Feb 2026 14:25:58 -0800 Subject: [PATCH 03/12] type-check for sonner --- apps/frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 4236653a3..5b6f5a920 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -51,7 +51,7 @@ const NotFound = lazy(() => import("@/app/NotFound")); function ThemedToaster() { const { theme } = useTheme(); return ( - + ); } From 5a3c8baa1c13917dcd9d3a48ed3d2f2d42f175d8 Mon Sep 17 00:00:00 2001 From: charles Date: Fri, 27 Feb 2026 18:03:44 -0800 Subject: [PATCH 04/12] added sonner to grade and enrollment for pop-ups --- apps/frontend/package.json | 1 + apps/frontend/src/App.tsx | 9 ++- apps/frontend/src/_sonner.scss | 9 +++ .../CourseManager/CourseInput/index.tsx | 3 + .../GradeDistributions/CourseInput/index.tsx | 3 + .../src/hooks/api/targeted-message/index.ts | 1 + .../showTargetedMessageToast.ts | 77 +++++++++++++++++++ .../useTargetedMessagesForCourse.ts | 6 +- apps/frontend/src/main.scss | 3 + package-lock.json | 11 +++ 10 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/_sonner.scss create mode 100644 apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4ae4b1fa7..4c870fb95 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -38,6 +38,7 @@ "react-select": "^5.10.2", "recharts": "^3.2.1", "rxjs": "^7.8.2", + "sonner": "^2.0.7", "suncalc": "^1.9.0" }, "devDependencies": { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1364d4535..27fc96648 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -7,8 +7,9 @@ import { createBrowserRouter, redirect, } from "react-router-dom"; +import { Toaster } from "sonner"; -import { ThemeProvider } from "@repo/theme"; +import { ThemeProvider, useTheme } from "@repo/theme"; import CatalogSkeleton from "@/app/Catalog/Skeleton"; import Layout from "@/components/Layout"; @@ -47,6 +48,11 @@ const GradTrakOnboarding = lazy(() => import("@/app/GradTrak/Onboarding")); const GradTrakDashboard = lazy(() => import("@/app/GradTrak/Dashboard")); const NotFound = lazy(() => import("@/app/NotFound")); +function ThemedToaster() { + const { theme } = useTheme(); + return ; +} + const router = createBrowserRouter([ { element: , @@ -378,6 +384,7 @@ export default function App() { + diff --git a/apps/frontend/src/_sonner.scss b/apps/frontend/src/_sonner.scss new file mode 100644 index 000000000..93dfd78ef --- /dev/null +++ b/apps/frontend/src/_sonner.scss @@ -0,0 +1,9 @@ +[data-sonner-toaster] { + --normal-bg: var(--background-color); + --normal-border: var(--border-color); + --normal-text: var(--heading-color); +} + +[data-sonner-toaster] [data-description] { + color: var(--paragraph-color) !important; +} diff --git a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx index 228984258..32bfc1255 100644 --- a/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx +++ b/apps/frontend/src/app/Enrollment/CourseManager/CourseInput/index.tsx @@ -15,6 +15,7 @@ import { import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import { useReadCourseWithInstructor } from "@/hooks/api"; +import { showTargetedMessageToast } from "@/hooks/api/targeted-message"; import { ICourseWithInstructorClass } from "@/lib/api"; import { sortByTermDescending } from "@/lib/classes"; import { GetEnrollmentDocument, Semester } from "@/lib/generated/graphql"; @@ -253,6 +254,8 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { return; } + + showTargetedMessageToast(client, selectedCourse.courseId); }; const disabled = useMemo( diff --git a/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx b/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx index e3a0e9345..9abb712b4 100644 --- a/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx +++ b/apps/frontend/src/app/GradeDistributions/CourseInput/index.tsx @@ -16,6 +16,7 @@ import { } from "@/components/CourseAnalytics/types"; import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import { useReadCourseWithInstructor } from "@/hooks/api"; +import { showTargetedMessageToast } from "@/hooks/api/targeted-message"; import { type IGradeDistribution } from "@/lib/api"; import { sortByTermDescending } from "@/lib/classes"; import { @@ -362,6 +363,8 @@ export default function CourseInput({ outputs, setOutputs }: CourseInputProps) { return; } + + showTargetedMessageToast(client, selectedCourse.courseId); }; const disabled = useMemo( diff --git a/apps/frontend/src/hooks/api/targeted-message/index.ts b/apps/frontend/src/hooks/api/targeted-message/index.ts index ab712039f..522ce2cb7 100644 --- a/apps/frontend/src/hooks/api/targeted-message/index.ts +++ b/apps/frontend/src/hooks/api/targeted-message/index.ts @@ -1,2 +1,3 @@ export * from "./useTargetedMessagesForCourse"; export * from "./useIncrementTargetedMessageDismiss"; +export * from "./showTargetedMessageToast"; diff --git a/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts new file mode 100644 index 000000000..01df382c2 --- /dev/null +++ b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts @@ -0,0 +1,77 @@ +import { toast } from "sonner"; + +import type { ApolloClient } from "@apollo/client"; + +import "@/lib/api/targeted-message"; +import { + GetTargetedMessagesForCourseDocument, + GetTargetedMessagesForCourseQuery, + IncrementTargetedMessageDismissDocument, +} from "@/lib/generated/graphql"; +import { + isTargetedMessageDismissed, + isTargetedMessageSessionDismissed, + markTargetedMessageAsDismissed, + markTargetedMessageAsSessionDismissed, + syncDismissedTargetedMessages, +} from "@/lib/targeted-message"; + +// Persists for the entire page session — prevents re-showing the same ad +const shownMessageIds = new Set(); + +export const showTargetedMessageToast = async ( + client: ApolloClient, + courseId: string +) => { + if (!courseId) return; + + const { data } = await client.query({ + query: GetTargetedMessagesForCourseDocument, + variables: { courseId }, + fetchPolicy: "cache-first", + }); + + const messages = data?.targetedMessagesForCourse; + if (!messages?.length) return; + + syncDismissedTargetedMessages(messages.map((m) => m.id)); + + const message = messages.find((m) => { + if (!m.persistent && !m.reappearing) return !isTargetedMessageDismissed(m.id); + if (m.reappearing) return !isTargetedMessageSessionDismissed(m.id); + return true; // persistent always shows + }); + + if (!message) return; + + // Never show the same message twice in one page session + if (shownMessageIds.has(message.id)) return; + shownMessageIds.add(message.id); + + toast(message.title, { + description: message.description ?? undefined, + duration: 8000, + action: message.link + ? { + label: message.linkText || "Learn more", + onClick: () => { + window.open( + `/message/click/${message.id}?courseId=${courseId}`, + "_blank" + ); + }, + } + : undefined, + onDismiss: () => { + client.mutate({ + mutation: IncrementTargetedMessageDismissDocument, + variables: { messageId: message.id }, + }); + if (message.reappearing) { + markTargetedMessageAsSessionDismissed(message.id); + } else if (!message.persistent) { + markTargetedMessageAsDismissed(message.id); + } + }, + }); +}; diff --git a/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts b/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts index 0cec36cf1..c8a0d109a 100644 --- a/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts +++ b/apps/frontend/src/hooks/api/targeted-message/useTargetedMessagesForCourse.ts @@ -6,12 +6,16 @@ import { GetTargetedMessagesForCourseQuery, } from "@/lib/generated/graphql"; -export const useTargetedMessagesForCourse = (courseId: string) => { +export const useTargetedMessagesForCourse = ( + courseId: string, + options?: { skip?: boolean } +) => { const query = useQuery( GetTargetedMessagesForCourseDocument, { variables: { courseId }, fetchPolicy: "cache-first", + skip: options?.skip, } ); diff --git a/apps/frontend/src/main.scss b/apps/frontend/src/main.scss index ee37e29be..78ca52a53 100644 --- a/apps/frontend/src/main.scss +++ b/apps/frontend/src/main.scss @@ -1,3 +1,5 @@ +@use "./_sonner.scss"; + :root { --header-height: 90px; /* Default, overridden by JS */ } @@ -59,3 +61,4 @@ a { body { scrollbar-color: var(--label-color) transparent; } + diff --git a/package-lock.json b/package-lock.json index 50b18b147..e99067b0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -194,6 +194,7 @@ "react-select": "^5.10.2", "recharts": "^3.2.1", "rxjs": "^7.8.2", + "sonner": "^2.0.7", "suncalc": "^1.9.0" }, "devDependencies": { @@ -17143,6 +17144,16 @@ "tslib": "^2.0.3" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", From 0fcfa3e55ab18c53fc05ead64297437b7abf7a14 Mon Sep 17 00:00:00 2001 From: charles Date: Sat, 28 Feb 2026 13:22:57 -0800 Subject: [PATCH 05/12] added sonner to grades and enrollment --- apps/frontend/src/App.tsx | 4 +++- .../hooks/api/targeted-message/showTargetedMessageToast.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 27fc96648..4236653a3 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -50,7 +50,9 @@ const NotFound = lazy(() => import("@/app/NotFound")); function ThemedToaster() { const { theme } = useTheme(); - return ; + return ( + + ); } const router = createBrowserRouter([ diff --git a/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts index 01df382c2..df2539aa6 100644 --- a/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts +++ b/apps/frontend/src/hooks/api/targeted-message/showTargetedMessageToast.ts @@ -1,6 +1,5 @@ -import { toast } from "sonner"; - import type { ApolloClient } from "@apollo/client"; +import { toast } from "sonner"; import "@/lib/api/targeted-message"; import { @@ -37,7 +36,8 @@ export const showTargetedMessageToast = async ( syncDismissedTargetedMessages(messages.map((m) => m.id)); const message = messages.find((m) => { - if (!m.persistent && !m.reappearing) return !isTargetedMessageDismissed(m.id); + if (!m.persistent && !m.reappearing) + return !isTargetedMessageDismissed(m.id); if (m.reappearing) return !isTargetedMessageSessionDismissed(m.id); return true; // persistent always shows }); From 89e59ec82579249cac414bd542bbe8b94e9cbefb Mon Sep 17 00:00:00 2001 From: charles Date: Sat, 28 Feb 2026 14:25:58 -0800 Subject: [PATCH 06/12] type-check for sonner --- apps/frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 4236653a3..5b6f5a920 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -51,7 +51,7 @@ const NotFound = lazy(() => import("@/app/NotFound")); function ThemedToaster() { const { theme } = useTheme(); return ( - + ); } From e2a9c4e292a577a60639d6c515ae4103f7d19df7 Mon Sep 17 00:00:00 2001 From: charles Date: Fri, 6 Mar 2026 20:14:53 -0800 Subject: [PATCH 07/12] added targeted banners to grades and enrollment --- .claude/settings.local.json | 7 + apps/frontend/src/app/Enrollment/index.tsx | 20 +- apps/frontend/src/app/Grades/index.tsx | 19 +- .../CourseAnalyticsLayout.module.scss | 8 + .../CourseAnalyticsLayout/index.tsx | 3 + .../TargetedMessageBanner.module.scss | 67 ++++++ .../TargetedMessageBanner/index.tsx | 198 ++++++++++++++++++ 7 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/TargetedMessageBanner.module.scss create mode 100644 apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..e7a2d7ca2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(docker exec:*)" + ] + } +} diff --git a/apps/frontend/src/app/Enrollment/index.tsx b/apps/frontend/src/app/Enrollment/index.tsx index 699f3f477..ecc149e45 100644 --- a/apps/frontend/src/app/Enrollment/index.tsx +++ b/apps/frontend/src/app/Enrollment/index.tsx @@ -17,6 +17,7 @@ import { BAR_CHART_COLORS } from "@/components/CourseAnalytics/types"; import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import CourseSelectionCard from "@/components/CourseSelectionCard"; import { useReadCourseWithInstructor } from "@/hooks/api"; +import TargetedMessageBanner from "@/components/CourseAnalytics/TargetedMessageBanner"; import useEnterToAdd from "@/hooks/useEnterToAdd"; import useRafHoverIndex from "@/hooks/useRafHoverIndex"; import type { ICourseWithInstructorClass } from "@/lib/api/courses"; @@ -234,6 +235,16 @@ function EnrollmentSidebar({ onEditDraftConsumed, }: EnrollmentSidebarProps) { const client = useApolloClient(); + + const displayedCourses = useMemo( + () => + outputs.map((o) => ({ + subject: o.input.subject, + courseNumber: o.input.courseNumber, + })), + [outputs] + ); + const [selectedCourse, setSelectedCourse] = useState( null ); @@ -551,7 +562,14 @@ function EnrollmentSidebar({ }; return ( - + 0 ? ( + + ) : undefined + } + > + outputs.map((o) => ({ + subject: o.input.subject, + courseNumber: o.input.courseNumber, + })), + [outputs] + ); + const [selectedCourse, setSelectedCourse] = useState( null ); @@ -541,7 +551,14 @@ function FilterPanel({ useEnterToAdd(() => void add(), !isAddButtonDisabled); return ( - + 0 ? ( + + ) : undefined + } + > @@ -81,6 +83,7 @@ export function CourseAnalyticsSidebar({

{title}

{children}
+ {footer &&
{footer}
} ); } diff --git a/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/TargetedMessageBanner.module.scss b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/TargetedMessageBanner.module.scss new file mode 100644 index 000000000..114c31f1f --- /dev/null +++ b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/TargetedMessageBanner.module.scss @@ -0,0 +1,67 @@ +.messageBox { + display: block; + border: none; + border-radius: 8px; + padding: 12px 16px; + background: rgba(59, 130, 246, 0.1); + text-decoration: none; + cursor: pointer; + + .messageHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + } + + .messageTitle { + font-weight: var(--font-medium); + font-size: var(--text-14); + line-height: 20px; + color: var(--blue-500); + text-wrap: balance; + } + + .dismissButton { + background: none; + border: none; + color: var(--blue-500); + cursor: pointer; + padding: 0 0 0 8px; + font-size: var(--text-14); + line-height: 1; + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + .messageDescription { + font-size: var(--text-14); + color: var(--blue-500); + line-height: 1.5; + text-wrap: pretty; + } + + .applyButton { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 8px; + padding: 6px 14px; + border: 1px solid var(--blue-500); + border-radius: 6px; + background: var(--blue-500); + color: white; + font-size: var(--text-12); + font-weight: var(--font-medium); + cursor: pointer; + line-height: 1; + transition: opacity 150ms ease; + + &:hover { + opacity: 0.85; + } + } +} diff --git a/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx new file mode 100644 index 000000000..6742db6fc --- /dev/null +++ b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useState } from "react"; + +import { useApolloClient } from "@apollo/client/react"; +import { ArrowUpRight } from "iconoir-react"; + +import { useIncrementTargetedMessageDismiss } from "@/hooks/api/targeted-message"; +import { + GetCourseTitleDocument, + GetTargetedMessagesForCourseDocument, + GetTargetedMessagesForCourseQuery, +} from "@/lib/generated/graphql"; +import { + isTargetedMessageDismissed, + isTargetedMessageSessionDismissed, + markTargetedMessageAsDismissed, + markTargetedMessageAsSessionDismissed, + syncDismissedTargetedMessages, +} from "@/lib/targeted-message"; + +import styles from "./TargetedMessageBanner.module.scss"; + +interface TargetedMessage { + id: string; + title: string; + description?: string | null; + link?: string | null; + linkText?: string | null; + persistent: boolean; + reappearing: boolean; +} + +interface ResolvedMessage { + message: TargetedMessage; + courseId: string; + courseNumber: string; +} + +interface CourseIdentifier { + subject: string; + courseNumber: string; +} + +interface TargetedMessageBannerProps { + courses: CourseIdentifier[]; +} + +export default function TargetedMessageBanner({ + courses, +}: TargetedMessageBannerProps) { + const client = useApolloClient(); + const [localDismissed, setLocalDismissed] = useState>(new Set()); + const [resolved, setResolved] = useState(null); + const { incrementDismiss } = useIncrementTargetedMessageDismiss(); + + const coursesKey = useMemo( + () => + [...courses] + .map((c) => `${c.subject}-${c.courseNumber}`) + .sort() + .join(","), + [courses] + ); + + useEffect(() => { + if (courses.length === 0) { + setResolved(null); + return; + } + + let cancelled = false; + + const findMessage = async () => { + // Deduplicate by subject+courseNumber + const seen = new Set(); + const unique = courses.filter((c) => { + const key = `${c.subject}-${c.courseNumber}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + for (const course of unique) { + if (cancelled) return; + + try { + // Resolve courseId from subject+courseNumber + const courseResult = await client.query({ + query: GetCourseTitleDocument, + variables: { + subject: course.subject, + number: course.courseNumber, + }, + fetchPolicy: "cache-first", + }); + + const courseId = courseResult.data?.course?.courseId; + if (!courseId) continue; + + // Query targeted messages for this courseId + const { data } = + await client.query({ + query: GetTargetedMessagesForCourseDocument, + variables: { courseId }, + fetchPolicy: "cache-first", + }); + + const messages = data?.targetedMessagesForCourse; + if (!messages?.length) continue; + + syncDismissedTargetedMessages(messages.map((m) => m.id)); + + const message = messages.find((m) => { + if (!m.persistent && !m.reappearing) + return !isTargetedMessageDismissed(m.id); + if (m.reappearing) + return !isTargetedMessageSessionDismissed(m.id); + return true; + }); + + if (message) { + if (!cancelled) + setResolved({ + message, + courseId, + courseNumber: course.courseNumber, + }); + return; + } + } catch { + // continue to next course + } + } + + if (!cancelled) setResolved(null); + }; + + void findMessage(); + + return () => { + cancelled = true; + }; + }, [client, coursesKey]); + + const message = useMemo(() => { + if (!resolved) return null; + if (localDismissed.has(resolved.message.id)) return null; + return resolved; + }, [resolved, localDismissed]); + + if (!message) return null; + + const handleDismiss = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setLocalDismissed((prev) => new Set(prev).add(message.message.id)); + incrementDismiss(message.message.id); + + if (message.message.reappearing) { + markTargetedMessageAsSessionDismissed(message.message.id); + } else if (!message.message.persistent) { + markTargetedMessageAsDismissed(message.message.id); + } + }; + + return ( + +
+

+ Taking {message.courseNumber}? +

+ {!message.message.persistent && ( + + )} +
+ {message.message.description && ( +

+ {message.message.description} +

+ )} +
+ {message.message.linkText || "Learn more"}{" "} + +
+
+ ); +} From 6081a1d214a6e08b78c7f3f9cff6d1f7cad9fac8 Mon Sep 17 00:00:00 2001 From: charles Date: Sat, 7 Mar 2026 13:54:32 -0800 Subject: [PATCH 08/12] targeted banner for grades and enrollment pages --- apps/frontend/src/app/Enrollment/index.tsx | 2 +- apps/frontend/src/app/Grades/index.tsx | 2 +- .../CourseAnalytics/TargetedMessageBanner/index.tsx | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/app/Enrollment/index.tsx b/apps/frontend/src/app/Enrollment/index.tsx index ecc149e45..54ce67361 100644 --- a/apps/frontend/src/app/Enrollment/index.tsx +++ b/apps/frontend/src/app/Enrollment/index.tsx @@ -13,11 +13,11 @@ import { CourseAnalyticsSidebar, } from "@/components/CourseAnalytics/CourseAnalyticsLayout"; import { useCourseAnalyticsIsDesktop } from "@/components/CourseAnalytics/CourseAnalyticsLayout/useCourseAnalyticsIsDesktop"; +import TargetedMessageBanner from "@/components/CourseAnalytics/TargetedMessageBanner"; import { BAR_CHART_COLORS } from "@/components/CourseAnalytics/types"; import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import CourseSelectionCard from "@/components/CourseSelectionCard"; import { useReadCourseWithInstructor } from "@/hooks/api"; -import TargetedMessageBanner from "@/components/CourseAnalytics/TargetedMessageBanner"; import useEnterToAdd from "@/hooks/useEnterToAdd"; import useRafHoverIndex from "@/hooks/useRafHoverIndex"; import type { ICourseWithInstructorClass } from "@/lib/api/courses"; diff --git a/apps/frontend/src/app/Grades/index.tsx b/apps/frontend/src/app/Grades/index.tsx index 1d9e6902c..adae961f9 100644 --- a/apps/frontend/src/app/Grades/index.tsx +++ b/apps/frontend/src/app/Grades/index.tsx @@ -13,6 +13,7 @@ import { CourseAnalyticsSidebar, } from "@/components/CourseAnalytics/CourseAnalyticsLayout"; import { useCourseAnalyticsIsDesktop } from "@/components/CourseAnalytics/CourseAnalyticsLayout/useCourseAnalyticsIsDesktop"; +import TargetedMessageBanner from "@/components/CourseAnalytics/TargetedMessageBanner"; import { BAR_CHART_COLORS, type CourseOutput, @@ -24,7 +25,6 @@ import { import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import CourseSelectionCard from "@/components/CourseSelectionCard"; import { useReadCourseWithInstructor } from "@/hooks/api"; -import TargetedMessageBanner from "@/components/CourseAnalytics/TargetedMessageBanner"; import useEnterToAdd from "@/hooks/useEnterToAdd"; import useRafHoverIndex from "@/hooks/useRafHoverIndex"; import { type IGradeDistribution } from "@/lib/api"; diff --git a/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx index 6742db6fc..282cf1b42 100644 --- a/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx +++ b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx @@ -112,8 +112,7 @@ export default function TargetedMessageBanner({ const message = messages.find((m) => { if (!m.persistent && !m.reappearing) return !isTargetedMessageDismissed(m.id); - if (m.reappearing) - return !isTargetedMessageSessionDismissed(m.id); + if (m.reappearing) return !isTargetedMessageSessionDismissed(m.id); return true; }); @@ -171,9 +170,7 @@ export default function TargetedMessageBanner({ className={styles.messageBox} >
-

- Taking {message.courseNumber}? -

+

Taking {message.courseNumber}?

{!message.message.persistent && (