diff --git a/apps/frontend/src/app/Enrollment/index.tsx b/apps/frontend/src/app/Enrollment/index.tsx index 668b719e3..4a2dd1163 100644 --- a/apps/frontend/src/app/Enrollment/index.tsx +++ b/apps/frontend/src/app/Enrollment/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 } from "@/components/CourseAnalytics/types"; import CourseSelect, { CourseOption } from "@/components/CourseSelect"; import CourseSelectionCard from "@/components/CourseSelectionCard"; @@ -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 ); @@ -518,7 +529,14 @@ function EnrollmentSidebar({ }; return ( - + 0 ? ( + + ) : undefined + } + > + outputs.map((o) => ({ + subject: o.input.subject, + courseNumber: o.input.courseNumber, + })), + [outputs] + ); + const [selectedCourse, setSelectedCourse] = useState( null ); @@ -551,7 +561,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..4b2914eac --- /dev/null +++ b/apps/frontend/src/components/CourseAnalytics/TargetedMessageBanner/index.tsx @@ -0,0 +1,208 @@ +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; +} + +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; + setResolved(null); + setLocalDismissed(new Set()); + + 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; + }); + + // Resolve courseIds for all courses in parallel + const courseResults = await Promise.all( + unique.map(async (course) => { + try { + const result = await client.query({ + query: GetCourseTitleDocument, + variables: { + subject: course.subject, + number: course.courseNumber, + }, + fetchPolicy: "cache-first", + }); + const courseId = result.data?.course?.courseId; + return courseId ? { courseId } : null; + } catch { + return null; + } + }) + ); + + if (cancelled) return; + + // Fetch targeted messages for all resolved courses in parallel + const messageResults = await Promise.all( + courseResults.map(async (result) => { + if (!result) return null; + try { + const { data } = + await client.query({ + query: GetTargetedMessagesForCourseDocument, + variables: { courseId: result.courseId }, + fetchPolicy: "cache-first", + }); + const messages = data?.targetedMessagesForCourse; + if (!messages?.length) return null; + return { courseId: result.courseId, messages }; + } catch { + return null; + } + }) + ); + + if (cancelled) return; + + // Find the first visible message across all courses + for (const entry of messageResults) { + if (!entry) continue; + + syncDismissedTargetedMessages(entry.messages.map((m) => m.id)); + + const message = entry.messages.find((m) => { + if (!m.persistent && !m.reappearing) + return !isTargetedMessageDismissed(m.id); + if (m.reappearing) return !isTargetedMessageSessionDismissed(m.id); + return true; + }); + + if (message) { + setResolved({ message, courseId: entry.courseId }); + return; + } + } + + 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 ( + +
+

{message.message.title}

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

+ {message.message.description} +

+ )} +
+ {message.message.linkText || "Learn more"}{" "} + +
+
+ ); +}