diff --git a/components/ResultsPage/DropdownFilter.tsx b/components/ResultsPage/DropdownFilter.tsx index 64d4d0b3..0acb890e 100644 --- a/components/ResultsPage/DropdownFilter.tsx +++ b/components/ResultsPage/DropdownFilter.tsx @@ -101,7 +101,6 @@ export default function DropdownFilter({ setIsOpen(true); } }} - /> { return (
-
-
{headerLeft}
- {headerRight} -
+
{headerLeft}
+ {headerRight} + {body} {afterBody}
@@ -39,13 +39,13 @@ type ClassCardType = { onSignIn: (token: string) => void; }; -export const ClassCard = ({ +export function ClassCard({ course, sections, userInfo, fetchUserInfo, onSignIn, -}: ClassCardType): ReactElement => { +}: ClassCardType): ReactElement { const sectionsFormatted: Section[] = getFormattedSections(sections); const [areSectionsHidden, setAreSectionsHidden] = useState(true); @@ -66,6 +66,13 @@ export const ClassCard = ({ lastUpdateTime={course.lastUpdateTime} className="SearchResult__header--sub" /> + {course.sections.map((section) => ( + + ))} } headerRight={} @@ -146,4 +153,4 @@ export const ClassCard = ({ } /> ); -}; +} diff --git a/components/SubscriptionsPage/EmptyCard.tsx b/components/SubscriptionsPage/EmptyCard.tsx new file mode 100644 index 00000000..3830ff59 --- /dev/null +++ b/components/SubscriptionsPage/EmptyCard.tsx @@ -0,0 +1,74 @@ +import { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { ClassCardWrapper } from './ClassCard'; +import { useRouter } from 'next/router'; +import Circular from '../icons/circular.svg'; +import CryingHusky from '../icons/crying-husky.svg'; +import HappyHusky from '../icons/happy-husky.svg'; +import getTermInfosWithError from '../../utils/TermInfoProvider'; +import { getTermName } from '../terms'; + +export const EmptyCard = (): ReactElement => { + const router = useRouter(); + const [isHovering, setIsHovering] = useState(false); + + const termInfos = getTermInfosWithError().termInfos; + const termId = router.query.termId as string; + const termName = getTermName(termInfos, termId).replace('Semester', ''); + + return ( + <> +
+
+
+
+
+
+
+ {termName} Notifications +
+
+ {isHovering ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ +
+ You currently have no notifications. Hoosky sad :( +
+
+ Be the first to know when new classes and sections drop! +
+
+ } + headerRight={ +
{ + router.push(`/NEU/${termId}/search`); + }} + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + +
+ } + /> +
+
+
+ + + ); +}; diff --git a/components/SubscriptionsPage/SectionPill.tsx b/components/SubscriptionsPage/SectionPill.tsx new file mode 100644 index 00000000..c7d876f7 --- /dev/null +++ b/components/SubscriptionsPage/SectionPill.tsx @@ -0,0 +1,26 @@ +import { ReactElement } from 'react'; +import Keys from '../Keys'; +import { UserInfo } from '../types'; + +type SectionPillProps = { + crn: string; + userInfo: UserInfo; +}; + +export const SectionPill = ({ + crn, + userInfo, +}: SectionPillProps): ReactElement => { + return ( +
+
+
{crn}
+
+ ); +}; diff --git a/components/icons/circular.svg b/components/icons/circular.svg new file mode 100644 index 00000000..7d773fac --- /dev/null +++ b/components/icons/circular.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/components/icons/crying-husky.svg b/components/icons/crying-husky.svg index 0713db8b..8f5a35e3 100644 --- a/components/icons/crying-husky.svg +++ b/components/icons/crying-husky.svg @@ -1,44 +1,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/icons/happy-husky.svg b/components/icons/happy-husky.svg new file mode 100644 index 00000000..7801beb4 --- /dev/null +++ b/components/icons/happy-husky.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/subscriptions.tsx b/pages/subscriptions.tsx index de90a712..ac4d1e28 100644 --- a/pages/subscriptions.tsx +++ b/pages/subscriptions.tsx @@ -6,8 +6,102 @@ import { ClassCard } from '../components/SubscriptionsPage/ClassCard'; import { SubscriptionCourse } from '../components/types'; import { gqlClient } from '../utils/courseAPIClient'; import useUserInfo from '../utils/useUserInfo'; +import { EmptyCard } from '../components/SubscriptionsPage/EmptyCard'; + +async function fetchCourseNotifs(classMapping, courseIds) { + for (const courseId of courseIds) { + const result = await gqlClient.getCourseInfoByHash({ + hash: courseId, + }); + + // Creates a string of subject and id like CS2500 + const courseCode = result.classByHash.subject + result.classByHash.classId; + + const subject = result.classByHash.subject; + const classId = result.classByHash.classId; + const host = result.classByHash.host; + const termId = result.classByHash.termId; + + // The subscription page should only show sections that we can subscribe to. + // We identify such sections as those with less than 5 seats remaining. + const filteredSections = result.classByHash.sections + .filter((s) => { + return s.seatsRemaining <= 5; + }) + .map((s) => { + return { + ...s, + online: false, + subject: subject, + classId: classId, + host: host, + termId: termId, + }; + }); + + classMapping.set(courseCode, { + subject: subject, + classId: classId, + termId: termId, + host: host, + name: result.classByHash.name, + lastUpdateTime: result.classByHash.lastUpdateTime, + sections: filteredSections, + }); + } +} + +async function fetchSectionNotifs(classMapping, sectionIds) { + for (const sectionId of sectionIds) { + const result = await gqlClient.getSectionInfoByHash({ + hash: sectionId, + }); + const courseCode = + result.sectionByHash.subject + result.sectionByHash.classId; + + // If course has already been found in fetchCourseNotifs(), continue + if (!classMapping.has(courseCode)) { + const sectionHashSlice = sectionId.split('/'); + const courseHash = sectionHashSlice.slice(0, -1).join('/'); + + const courseResult = await gqlClient.getCourseInfoByHash({ + hash: courseHash, + }); + + const subject = courseResult.classByHash.subject; + const classId = courseResult.classByHash.classId; + const host = courseResult.classByHash.host; + const termId = courseResult.classByHash.termId; + + const filteredSections = courseResult.classByHash.sections + .filter((s) => { + return s.seatsRemaining <= 5; + }) + .map((s) => { + return { + ...s, + online: false, + subject: subject, + classId: classId, + host: host, + termId: termId, + }; + }); + classMapping.set(courseCode, { + subject: subject, + classId: classId, + termId: termId, + host: host, + name: courseResult.classByHash.name, + lastUpdateTime: courseResult.classByHash.lastUpdateTime, + sections: filteredSections, + }); + } + } +} export default function SubscriptionsPage(): ReactElement { + const router = useRouter(); const { userInfo, isUserInfoLoading, @@ -19,127 +113,58 @@ export default function SubscriptionsPage(): ReactElement { // is the course / section data still fetching const [isFetching, setIsFetching] = useState(true); - const router = useRouter(); + // is the user subscribed to at least one class + const [isSubscribed, setIsSubscribed] = useState(false); useEffect(() => { if (isUserInfoLoading) { return; } + // not logged in if (!userInfo && !isUserInfoLoading) { router.push('/'); return; } const classMapping = new Map(); - - // This uses subscribed course ids to find the associated courses and their sections - const fetchCourseNotifs = async (): Promise => { - for (const courseId of userInfo.courseIds) { - const result = await gqlClient.getCourseInfoByHash({ - hash: courseId, - }); - - // Creates a string of subject and id like CS2500 - const courseCode = - result.classByHash.subject + result.classByHash.classId; - - const subject = result.classByHash.subject; - const classId = result.classByHash.classId; - const host = result.classByHash.host; - const termId = result.classByHash.termId; - - // The subscription page should only show sections that we can subscribe to. - // We identify such sections as those with less than 5 seats remaining. - const filteredSections = result.classByHash.sections - .filter((s) => { - return s.seatsRemaining <= 5; - }) - .map((s) => { - return { - ...s, - online: false, - subject: subject, - classId: classId, - host: host, - termId: termId, - }; - }); - classMapping.set(courseCode, { - subject: subject, - classId: classId, - termId: termId, - host: host, - name: result.classByHash.name, - lastUpdateTime: result.classByHash.lastUpdateTime, - sections: filteredSections, - }); - } - }; - - // This uses subscribed section ids to find the associated section and the associated course for that section - const fetchSectionNotifs = async (): Promise => { - for (const sectionId of userInfo.sectionIds) { - const result = await gqlClient.getSectionInfoByHash({ - hash: sectionId, - }); - const courseCode = - result.sectionByHash.subject + result.sectionByHash.classId; - - // If course has already been found in fetchCourseNotifs(), continue - if (!classMapping.has(courseCode)) { - const sectionHashSlice = sectionId.split('/'); - const courseHash = sectionHashSlice.slice(0, -1).join('/'); - - const courseResult = await gqlClient.getCourseInfoByHash({ - hash: courseHash, - }); - - const subject = courseResult.classByHash.subject; - const classId = courseResult.classByHash.classId; - const host = courseResult.classByHash.host; - const termId = courseResult.classByHash.termId; - - const filteredSections = courseResult.classByHash.sections - .filter((s) => { - return s.seatsRemaining <= 5; - }) - .map((s) => { - return { - ...s, - online: false, - subject: subject, - classId: classId, - host: host, - termId: termId, - }; - }); - classMapping.set(courseCode, { - subject: subject, - classId: classId, - termId: termId, - host: host, - name: courseResult.classByHash.name, - lastUpdateTime: courseResult.classByHash.lastUpdateTime, - sections: filteredSections, - }); - } - } - }; - const fetchSubscriptions = async (): Promise => { try { - await fetchCourseNotifs(); - await fetchSectionNotifs(); + await fetchCourseNotifs(classMapping, userInfo.courseIds); + await fetchSectionNotifs(classMapping, userInfo.sectionIds); setClasses(classMapping); setIsFetching(false); + // are there classes the user is subscribed to? + if (classMapping.size > 0) { + setIsSubscribed(true); + } } catch (e) { console.log(e); } }; fetchSubscriptions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userInfo?.phoneNumber, isUserInfoLoading]); // Only depends on userInfo data + }, [userInfo?.phoneNumber, isUserInfoLoading]); + + if (isFetching) { + return ( + <> +
+
+
+ + + + ); + } return ( <> @@ -155,28 +180,32 @@ export default function SubscriptionsPage(): ReactElement { onSignOut={onSignOut} /> - {isFetching ? ( - - ) : ( -
-
-
-

Subscriptions

- {Array.from(classes).map(([courseCode, course]) => { - return ( - - ); - })} + {isSubscribed ? ( + <> +
+
+
+

Subscriptions

+ {Array.from(classes) + .sort((a, b) => (a > b ? 1 : -1)) // Sort to ensure the sub order doesn't change + .map(([courseCode, course]) => { + return ( + + ); + })} +
-
+ + ) : ( + )} ); diff --git a/styles/_SectionPill.scss b/styles/_SectionPill.scss new file mode 100644 index 00000000..610a7f4d --- /dev/null +++ b/styles/_SectionPill.scss @@ -0,0 +1,27 @@ +@use 'variables' as Colors; + +.SectionPill { + display: inline-flex; + align-items: center; + background-color: Colors.$White; + border-radius: 900px; + padding: 2px 6px; + max-width: fit-content; + margin-left: 10px; + + &__text { + font-size: 14px; + margin-left: 10px; + } + + &__subscribed { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: Colors.$Light_Grey; + + &--active { + background-color: Colors.$NEU_Red; + } + } +} diff --git a/styles/base.scss b/styles/base.scss index 4fdde59b..2e8b211d 100644 --- a/styles/base.scss +++ b/styles/base.scss @@ -51,6 +51,8 @@ @use 'Modal'; @use 'InfoIconTooltip'; @use 'Toast'; +@use 'pages/EmptyCard'; +@use 'SectionPill'; html, body, diff --git a/styles/pages/_EmptyCard.scss b/styles/pages/_EmptyCard.scss new file mode 100644 index 00000000..a92f3e3f --- /dev/null +++ b/styles/pages/_EmptyCard.scss @@ -0,0 +1,141 @@ +@use '../variables' as Colors; +@use '../zIndexes' as Indexes; + +.Empty_Container { + display: flex; + + @media only screen and (max-width: 767px) { + padding-top: 0; + } +} + +.Empty_Wrapper { + display: flex; + justify-content: center; + width: 100%; +} + +.Empty_Main { + .Empty_Main__EmptyCard { + display: flex; + flex-direction: column; + align-items: flex-start; + + .Empty_Main_EmptyCard_Header { + display: flex; + flex-direction: row; + width: 1000px; + justify-content: space-between; + align-items: flex-start; + margin-bottom: -43px; + z-index: Indexes.$Twelve; + + .Empty_Main__EmptyCard_Header_Spacer { + display: flex; + padding-top: 12px; + align-items: center; + } + + .Empty_Main__EmptyCard_Header_Title { + color: Colors.$NEU9; + font-family: Lato; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: 28.8px; // 120% + } + + .Empty_Main__EmptyCard_Header_Body { + display: flex; + width: 476px; + height: 11px; + flex-direction: column; + justify-content: center; + color: Colors.$NEU9; + font-family: Lato; + font-size: 14px; + font-style: normal; + font-weight: 400; + } + } + } + + &__EmptyCard { + margin: 0px 220px; + + .Empty_Main__EmptyCard_Text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; + + .Empty_Main__EmptyCard_Text_Body { + line-height: 10px; + } + } + + .Empty_Main__EmptyCard_Button { + line-height: 10px; + padding: 12px 20px; + align-items: center; + } + + .Empty_Main__EmptyCard_Divider { + padding-bottom: 0px; + } + + .SearchResult { + display: flex; + width: 1000px; + padding: 24px 16px; + justify-content: space-between; + align-items: center; + border-radius: 8px; + border: 0.5px solid Colors.$NEU4; + background: Colors.$NEU2; + z-index: Indexes.$Ten; + } + + p { + font-weight: 400; + } + + button { + padding: 8px 18px; + border-radius: 8px; + background-color: Colors.$Off_White; + border: 1px solid Colors.$Navy; + } + + button:hover { + background-color: Colors.$Light_Grey; + } + + &_Button { + display: flex; + align-items: center; + column-gap: 8px; + } + + &_Divider { + padding-bottom: 5px; + } + + &_Text { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18.2px; + + &_Title { + font-style: normal; + line-height: 13px; + font-size: 18px; + + b { + font-weight: 700; + } + } + } + } +} diff --git a/styles/pages/_Results.scss b/styles/pages/_Results.scss index 6458607f..e4335bd8 100644 --- a/styles/pages/_Results.scss +++ b/styles/pages/_Results.scss @@ -16,11 +16,9 @@ $SIDEBAR_WIDTH: 268px; align-items: center; justify-content: left; position: fixed; - box-shadow: 0 0 10px Colors.$Light_Grey; border-bottom: 1px solid Colors.$Light_Grey; &-top { - box-shadow: none; border-bottom: 1px solid Colors.$Light_Grey; } @@ -176,6 +174,113 @@ $SIDEBAR_WIDTH: 268px; padding-right: 10px; padding-left: 10px; } + + .Results_Main__EmptyCard { + display: flex; + flex-direction: column; + align-items: flex-start; + .Results_Main_EmptyCard_Header { + display: flex; + flex-direction: row; + width: 1000px; + justify-content: space-between; + align-items: flex-start; + margin-bottom: -43px; + z-index: Indexes.$Twelve; + .Results_Main__EmptyCard_Header_Spacer { + display: flex; + padding-top: 12px; + align-items: center; + } + .Results_Main__EmptyCard_Header_Title { + color: Colors.$NEU9; + /* Desktop/Heading/H1 */ + font-family: Lato; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: 28.8px; /* 120% */ + } + .Results_Main__EmptyCard_Header_Body { + display: flex; + width: 476px; + height: 11px; + flex-direction: column; + justify-content: center; + color: Colors.$NEU9; + /* Desktop/Body/B2 */ + font-family: Lato; + font-size: 14px; + font-style: normal; + font-weight: 400; + } + } + } + &__EmptyCard { + margin: 0px 220px; + .Results_Main__EmptyCard_Text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; + .Results_Main__EmptyCard_Text_Body { + line-height: 10px; + } + } + .Results_Main__EmptyCard_Button { + line-height: 10px; + padding: 12px 20px; + align-items: center; + } + .Results_Main__EmptyCard_Divider { + padding-bottom: 0px; + } + .SearchResult { + display: flex; + width: 1000px; + padding: 24px 16px; + justify-content: space-between; + align-items: center; + border-radius: 8px; + border: 0.5px solid Colors.$NEU4; + background: Colors.$NEU2; + z-index: Indexes.$Ten; + } + p { + font-weight: 400; + } + button { + padding: 8px 18px; + border-radius: 8px; + background-color: Colors.$Off_White; + border: 1px solid Colors.$Navy; + } + button:hover { + background-color: Colors.$Light_Grey; + } + &_Button { + display: flex; + align-items: center; + column-gap: 8px; + } + &_Divider { + padding-bottom: 5px; + } + &_Text { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18.2px; + &_Title { + font-style: normal; + line-height: 13px; + font-size: 18px; + b { + font-weight: 700; + } + } + } + } } // TODO make this a spiner, but need to fix ResultsLoader rerender issue first