├── .nvmrc ├── docs ├── .nojekyll ├── search-logo-red.png ├── infrastructure │ └── infra.md ├── _sidebar.md ├── deployment │ └── deployment.md ├── bounty │ └── bounty.md ├── dev-styles │ └── style-guide.md ├── setup │ ├── prereqs.md │ ├── development.md │ └── setup.md ├── README.md ├── styling-css │ └── styling.md └── index.html ├── .yarnrc.yml ├── utils ├── important.rkt ├── api │ ├── README.md │ └── rollbar.ts ├── README.md ├── campusToColor.tsx ├── courseAPIClient.ts ├── TermInfoProvider.tsx ├── gtag.ts ├── QueryParamProvider.tsx └── useUserInfo.ts ├── .prettierrc.json ├── components ├── tests │ ├── exportsAString.js │ ├── macros.test.js │ ├── util.js │ ├── __mocks__ │ │ └── randomstring.js │ ├── pages │ │ ├── Home.test.js │ │ ├── Result.test.tsx │ │ └── __snapshots__ │ │ │ └── Home.test.js.snap │ └── FeedbackModal.test.js ├── icons │ ├── back_icon.svg │ ├── DropdownArrow.svg │ ├── X.svg │ ├── requisites_tree.svg │ ├── info-icon.svg │ ├── IconCheckmark.tsx │ ├── arrow-left.svg │ ├── FilterButton.svg │ ├── magnifying-glass.svg │ ├── IconArrow.tsx │ ├── IconCollapseExpand.tsx │ ├── exit.svg │ ├── circular.svg │ ├── IconNotepad.tsx │ ├── pillClose.svg │ ├── NavArrow.tsx │ ├── IconClose.tsx │ ├── boston.svg │ ├── IconScale.tsx │ ├── IconTie.tsx │ └── IconGlobe.tsx ├── panels │ ├── chevron-right.svg │ ├── chevron-down.svg │ ├── tests │ │ ├── __snapshots__ │ │ │ └── EmployeePanel.test.js.snap │ │ └── EmployeePanel.test.js │ └── globe.svg ├── common │ ├── InfoIconTooltip.tsx │ ├── CreditsDisplay.tsx │ ├── LastUpdated.tsx │ └── AlertBanner.tsx ├── ClassPage │ ├── HeaderBody.tsx │ ├── ClassPageInfoHeader.tsx │ ├── SectionsTermNav.tsx │ ├── PageContent.tsx │ └── PrereqsDisplay.tsx ├── ResultsPage │ ├── Results │ │ ├── useSectionPanelDetail.tsx │ │ ├── NotifSignUpButton.tsx │ │ ├── WeekdayBoxes.tsx │ │ ├── useShowAll.ts │ │ ├── MobileCollapsableDetail.tsx │ │ └── SearchResult.tsx │ ├── useAtTop.ts │ ├── LoadingContainer.tsx │ ├── useClickOutside.ts │ ├── ToggleFilter.tsx │ ├── SearchDropdown.tsx │ ├── CheckboxFilter.tsx │ ├── useFeedbackSchedule.ts │ ├── CheckboxGroup.tsx │ ├── EmptyResultsContainer.tsx │ ├── RangeFilter.tsx │ ├── CampusSelection.tsx │ ├── FilterPanel.tsx │ ├── SemesterDropdown.tsx │ ├── MobileSearchOverlay.tsx │ └── FilterPills.tsx ├── SubscriptionsPage │ └── CRNBadge.tsx ├── Tooltip.tsx ├── Modal.tsx ├── Testimonial │ ├── TestimonialToast.tsx │ └── TestimonialModal.tsx ├── Toast.tsx ├── HomePage │ └── ExploratorySearchButton.tsx ├── notifications │ ├── DisabledNotificationsModal.tsx │ ├── modal │ │ ├── GoSignIn.tsx │ │ └── PhoneNumber.tsx │ ├── SubscriptionPageModal.tsx │ └── SignUpForSectionNotifications.tsx ├── GivingDay │ └── GivingDayModal.tsx └── Footer.tsx ├── public ├── favicon.ico ├── husky-red.png ├── images │ ├── link.png │ ├── github.png │ ├── linkedin.png │ ├── ood-mobile-1.png │ ├── ood-mobile-2.png │ ├── sandbox-logo.png │ ├── cs2500-mobile.png │ ├── cs2510-desktop.png │ ├── lerner-mobile.png │ ├── engw1111-desktop.png │ └── cs2500-results-mobile.png ├── aouuuuuuuuun.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── search-logo-red.png ├── favicon-16x16-OLD.png ├── favicon-32x32-OLD.png ├── faviconsearchingLarge.png ├── faviconsearchingSmall.png ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── browserconfig.xml ├── manifest.json ├── robots.txt ├── alert-banners.yml ├── unsubscribe.svg └── safari-pinned-tab.svg ├── pages ├── api │ ├── termIdsByCollege.graphql │ ├── sectionByHash.graphql │ ├── sitemap.graphql │ ├── classByHash.graphql │ ├── classPage.graphql │ └── searchResult.graphql ├── [campus] │ └── [termId] │ │ └── search │ │ └── index.tsx ├── 404.tsx ├── [campus].tsx ├── _error.tsx ├── _app.tsx ├── down.tsx └── error.tsx ├── .env.development ├── styles ├── _LoadingContainer.scss ├── _LastUpdated.scss ├── _zIndexes.scss ├── panels │ ├── _MobileClassPanel.scss │ ├── _WeekdayBoxes.scss │ ├── _DesktopSectionPanel.scss │ ├── _DesktopClassPanel.scss │ ├── _MobileSectionPanel.scss │ └── _BaseClassPanel.scss ├── _FilterPanel.scss ├── home │ └── _ExploratorySearchButton.scss ├── _exports.module.scss ├── _CRNBadge.scss ├── _Footer.scss ├── results │ ├── _NotifSignUpButton.scss │ ├── _WeekdayBoxes.scss │ ├── _MobileSectionPanel.scss │ ├── _NotifCheckBox.scss │ └── _DesktopSectionPanel.scss ├── _Modal.scss ├── pages │ ├── _404.scss │ └── _Down.scss ├── _variables.scss ├── _InfoIconTooltip.scss ├── _Tooltip.scss ├── _Toast.scss ├── _SearchBar.scss ├── _SearchDropdown.scss ├── _MobileSearchOverlay.scss ├── _AlertBanner.scss ├── _EmailInput.scss ├── _NotifSignUpSwitch.scss ├── _SearchHeader.scss ├── _CheckboxFilter.scss ├── base.scss ├── _FilterPills.scss └── _SignUpForNotifications.scss ├── next-env.d.ts ├── __mocks__ └── style.js ├── tsconfig.eslint.json ├── index.d.ts ├── jest.config.js ├── .github ├── workflows │ ├── test.yml │ ├── tsc.yml │ └── lint.yml └── pull_request_template.md ├── .stylelintrc.json ├── codegen.yml ├── .gitignore ├── .babelrc ├── tsconfig.json ├── next.config.js └── .eslintrc.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.x 2 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /utils/important.rkt: -------------------------------------------------------------------------------- 1 | #lang racket 2 | 3 | (+ 2 2) -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /components/tests/exportsAString.js: -------------------------------------------------------------------------------- 1 | module.exports = 'file'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/husky-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/husky-red.png -------------------------------------------------------------------------------- /public/images/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/link.png -------------------------------------------------------------------------------- /docs/search-logo-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/docs/search-logo-red.png -------------------------------------------------------------------------------- /public/aouuuuuuuuun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/aouuuuuuuuun.png -------------------------------------------------------------------------------- /public/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/github.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/linkedin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/linkedin.png -------------------------------------------------------------------------------- /public/search-logo-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/search-logo-red.png -------------------------------------------------------------------------------- /public/favicon-16x16-OLD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/favicon-16x16-OLD.png -------------------------------------------------------------------------------- /public/favicon-32x32-OLD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/favicon-32x32-OLD.png -------------------------------------------------------------------------------- /public/images/ood-mobile-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/ood-mobile-1.png -------------------------------------------------------------------------------- /public/images/ood-mobile-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/ood-mobile-2.png -------------------------------------------------------------------------------- /public/images/sandbox-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/sandbox-logo.png -------------------------------------------------------------------------------- /public/faviconsearchingLarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/faviconsearchingLarge.png -------------------------------------------------------------------------------- /public/faviconsearchingSmall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/faviconsearchingSmall.png -------------------------------------------------------------------------------- /public/images/cs2500-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/cs2500-mobile.png -------------------------------------------------------------------------------- /public/images/cs2510-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/cs2510-desktop.png -------------------------------------------------------------------------------- /public/images/lerner-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/lerner-mobile.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /public/images/engw1111-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/engw1111-desktop.png -------------------------------------------------------------------------------- /utils/api/README.md: -------------------------------------------------------------------------------- 1 | Middleware functions to wrap the next.js API routes. Here, we can access server-side code like Prisma. 2 | -------------------------------------------------------------------------------- /public/images/cs2500-results-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/searchneu-v1/HEAD/public/images/cs2500-results-mobile.png -------------------------------------------------------------------------------- /pages/api/termIdsByCollege.graphql: -------------------------------------------------------------------------------- 1 | query getTermIDsByCollege($subCollege: String!) { 2 | termInfos(subCollege: $subCollege) { 3 | text 4 | termId 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pages/[campus]/[termId]/search/index.tsx: -------------------------------------------------------------------------------- 1 | import Results from './[query]'; 2 | 3 | // To support empty search query at "searchneu.com/[termId]" 4 | export default Results; 5 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | ROLLBAR_SERVER_TOKEN=djsifo 2 | DEV=true 3 | NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://api.searchneu.com 4 | NEXT_PUBLIC_NOTIFS_ENDPOINT=https://notifs.searchneu.com 5 | -------------------------------------------------------------------------------- /styles/_LoadingContainer.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | text-align: center; 3 | margin-top: 10px; 4 | margin-bottom: 5px; 5 | 6 | span { 7 | display: inline-block; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /utils/README.md: -------------------------------------------------------------------------------- 1 | For cross-cutting code. 2 | 3 | **Only put stuff that is used across the app.** Hooks specific to a component or page should be placed right next to where they are used. 4 | -------------------------------------------------------------------------------- /components/icons/back_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/icons/DropdownArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /styles/_LastUpdated.scss: -------------------------------------------------------------------------------- 1 | .classPageLastUpdated .bannerPageLink { 2 | display: flex; 3 | align-items: center; 4 | margin-right: 13px; 5 | } 6 | 7 | .updatedText { 8 | font-family: Lato; 9 | font-weight: 400; 10 | } 11 | -------------------------------------------------------------------------------- /utils/api/rollbar.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | 3 | import Rollbar from 'rollbar'; 4 | 5 | export const serverRollbar = new Rollbar({ 6 | accessToken: process.env.ROLLBAR_SERVER_TOKEN, 7 | }); 8 | -------------------------------------------------------------------------------- /components/icons/X.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /__mocks__/style.js: -------------------------------------------------------------------------------- 1 | // This mock is used by the Jest config to resolve the issue of CSS imports 2 | // attempting to be parsed as JavaScript, causing "unexpected token" errors. 3 | // https://stackoverflow.com/questions/54627028/jest-unexpected-token-when-importing-css 4 | 5 | module.exports = {}; 6 | -------------------------------------------------------------------------------- /utils/campusToColor.tsx: -------------------------------------------------------------------------------- 1 | import { Campus } from '../components/types'; 2 | import Colors from '../styles/_exports.module.scss'; 3 | 4 | export const campusToColor: Record = { 5 | [Campus.NEU]: Colors.neu_red, 6 | [Campus.CPS]: Colors.cps_yellow, 7 | [Campus.LAW]: Colors.law_blue, 8 | }; 9 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #1d3557 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | /* 10-16-23: This file currently isn't being used for linting, as it's typically only for monorepos. 2 | Since we want all typescript files to be linted with the same rules, we don't need it! */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] 6 | } 7 | -------------------------------------------------------------------------------- /components/panels/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /components/panels/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /components/panels/tests/__snapshots__/EmployeePanel.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render a desktop employee panel 1`] = `""`; 4 | 5 | exports[`should render a mobile employee panel 1`] = `""`; 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const ReactComponent: React.FC>; 3 | export default ReactComponent; 4 | } 5 | 6 | declare module '*.yml' { 7 | const data: any; 8 | export default data; 9 | } 10 | 11 | declare module '*.scss' { 12 | const content: any; 13 | export default content; 14 | } 15 | -------------------------------------------------------------------------------- /components/icons/requisites_tree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/babel-jest', 4 | '^.+\\.svg$': 'jest-svg-transformer', 5 | '^.+\\.yml$': '/node_modules/yaml-jest', 6 | }, 7 | moduleNameMapper: { 8 | '^.+\\.(css|less|scss)$': '/__mocks__/style.js', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: install node v18 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: '18' 14 | - run: yarn install 15 | - run: yarn test 16 | -------------------------------------------------------------------------------- /pages/api/sectionByHash.graphql: -------------------------------------------------------------------------------- 1 | query getSectionInfoByHash($hash: String!) { 2 | sectionByHash(hash: $hash) { 3 | subject 4 | classId 5 | crn 6 | profs 7 | meetings 8 | seatsRemaining 9 | seatsCapacity 10 | waitCapacity 11 | waitRemaining 12 | honors 13 | campus 14 | lastUpdateTime 15 | url 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended-scss", 3 | "rules": { 4 | "color-named": [ 5 | "never", 6 | { 7 | "severity": "error" 8 | } 9 | ], 10 | "color-no-hex": [ 11 | true, 12 | { 13 | "severity": "error" 14 | } 15 | ] 16 | }, 17 | "defaultSeverity": "warning" 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/tsc.yml: -------------------------------------------------------------------------------- 1 | name: Check Typescript Types 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tsc: 7 | name: tsc 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: install node v18 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 18 15 | - run: yarn install 16 | - run: yarn tsc 17 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: ${NEXT_PUBLIC_GRAPHQL_ENDPOINT} 3 | documents: 4 | - 'pages/**/*.graphql' 5 | - 'components/**/*.graphql' 6 | - 'utils/**/*.graphql' 7 | generates: 8 | generated/graphql.ts: 9 | plugins: 10 | - 'typescript' 11 | - 'typescript-operations' 12 | - 'typescript-graphql-request' 13 | hooks: 14 | afterAllFileWrite: 15 | - prettier --write 16 | -------------------------------------------------------------------------------- /styles/_zIndexes.scss: -------------------------------------------------------------------------------- 1 | /* 2 | This file contains global variables for z-indexes, 3 | meant to be used accross all styles. 4 | */ 5 | 6 | //Levels of Z-indexes 7 | $One: 10; 8 | $Two: 20; 9 | $Three: 30; 10 | $Four: 40; 11 | $Five: 50; 12 | $Six: 60; 13 | $Seven: 70; 14 | $Eight: 80; 15 | $Nine: 90; 16 | $Ten: 100; 17 | $Eleven: 110; 18 | $Twelve: 120; 19 | $Thirteen: 130; 20 | $Fourteen: 140; 21 | $Fifteen: 150; 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: install node v18 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 18 15 | - uses: bahmutov/npm-install@v1 16 | - run: yarn lint 17 | - run: yarn sass-lint 18 | -------------------------------------------------------------------------------- /pages/api/sitemap.graphql: -------------------------------------------------------------------------------- 1 | query getPagesForSitemap($termId: String!, $offset: Int!) { 2 | search(termId: $termId, offset: $offset, first: 1000) { 3 | pageInfo { 4 | hasNextPage 5 | } 6 | nodes { 7 | __typename 8 | ... on ClassOccurrence { 9 | subject 10 | classId 11 | name 12 | } 13 | ... on Employee { 14 | name 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/infrastructure/infra.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | Search NEU uses the following 3rd party services. The site depends on most of these being up to run correctly. Some are just used for analytics. 4 | 5 | ## Deployment 6 | 7 | - Vercel for frontend deployment 8 | - Cloudflare 9 | 10 | ## Analytics 11 | 12 | - Amplitude 13 | - Google Analytics 14 | - Full Story 15 | - Google Search Console 16 | 17 | ## APM 18 | 19 | - Rollbar 20 | -------------------------------------------------------------------------------- /components/common/InfoIconTooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import IconInfo from '../icons/info-icon.svg'; 3 | import Tooltip, { TooltipProps } from '../Tooltip'; 4 | 5 | export default function SearchInfoIcon(props: TooltipProps) { 6 | return ( 7 |
8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/tests/macros.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | import macros from '../macros'; 7 | 8 | it('should run without crashing', () => { 9 | // This should not print anything to the console. 10 | macros.log('Test.'); 11 | 12 | // This shoudn't send anything to amplitude. 13 | macros.logAmplitudeEvent('test', {}); 14 | }); 15 | -------------------------------------------------------------------------------- /utils/courseAPIClient.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | import { getSdk } from '../generated/graphql'; 3 | 4 | const ENDPOINT = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT; 5 | 6 | // GraphQL codegen creates a typed SDK by pulling out all operations in *.graphql files in the project. 7 | // Run `yarn generate:graphql` to regenerate the client, or let `yarn dev` auto regen constantly. 8 | export const gqlClient = getSdk(new GraphQLClient(ENDPOINT)); 9 | -------------------------------------------------------------------------------- /styles/panels/_MobileClassPanel.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | .mobile { 7 | .chevron { 8 | width: 18px; 9 | opacity: 0.6; 10 | vertical-align: top; 11 | margin-right: 10px; 12 | margin-top: 2px; 13 | } 14 | 15 | .classTitle { 16 | display: inline-block; 17 | max-width: calc(100% - 30px); 18 | font-size: 19px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pages/api/classByHash.graphql: -------------------------------------------------------------------------------- 1 | query getCourseInfoByHash($hash: String!) { 2 | classByHash(hash: $hash) { 3 | subject 4 | classId 5 | name 6 | host 7 | termId 8 | lastUpdateTime 9 | sections { 10 | campus 11 | classType 12 | crn 13 | honors 14 | lastUpdateTime 15 | meetings 16 | profs 17 | seatsCapacity 18 | seatsRemaining 19 | url 20 | waitCapacity 21 | waitRemaining 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import Link from 'next/link'; 3 | import Four04 from '../components/icons/404.svg'; 4 | 5 | export default function NotFoundPage(): ReactElement { 6 | return ( 7 |
8 |
something's borked
9 | 10 | 11 |
BACK TO HOME
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /styles/_FilterPanel.scss: -------------------------------------------------------------------------------- 1 | .FilterPanel { 2 | display: grid; 3 | grid-auto-flow: row; 4 | grid-auto-rows: min-content; 5 | grid-row-gap: 25px; 6 | } 7 | 8 | .SemesterDropdown { 9 | margin-bottom: 25px; 10 | } 11 | 12 | .filter__title { 13 | font-family: Lato, sans-serif; 14 | font-size: 16px; 15 | font-style: normal; 16 | font-weight: 900; 17 | margin-bottom: 12px; 18 | margin-top: 36px; 19 | } 20 | 21 | .ui.dropdown .menu > .item { 22 | width: 100%; 23 | font-size: 12px; 24 | } 25 | -------------------------------------------------------------------------------- /styles/home/_ExploratorySearchButton.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | 3 | .searchByFilters { 4 | pointer-events: initial; 5 | cursor: pointer; 6 | margin: 36px 0px 0px 50px; 7 | width: fit-content; 8 | font-weight: 400; 9 | color: Colors.$Navy; 10 | 11 | &:hover { 12 | text-decoration: underline; 13 | } 14 | 15 | @media (max-width: 500px) { 16 | margin: 0px auto; 17 | margin-top: 10px; 18 | } 19 | } 20 | 21 | .selectedCampusAndTerm { 22 | font-style: italic; 23 | } 24 | -------------------------------------------------------------------------------- /components/tests/util.js: -------------------------------------------------------------------------------- 1 | import { act } from 'react-dom/test-utils'; 2 | 3 | /* 4 | A function to suppress a warning with state changes on 5 | enzyme mounted components. Enzyme said they fixed this but it 6 | doesn't seem to have changed (and we're on latest version 3.11) 7 | See issue: https://github.com/enzymejs/enzyme/issues/2073 8 | */ 9 | export async function waitForComponentToPaint(wrapper) { 10 | await act(async () => { 11 | await new Promise((resolve) => setTimeout(resolve, 0)); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Search NEU", 3 | "icons": [ 4 | { 5 | "src": "/android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image/png" 8 | }, 9 | { 10 | "src": "/android-chrome-256x256.png", 11 | "sizes": "256x256", 12 | "type": "image/png" 13 | } 14 | ], 15 | "short_name": "Search NEU", 16 | "start_url": "https://searchneu.com", 17 | "theme_color": "#fff", 18 | "background_color": "#fff", 19 | "display": "standalone" 20 | } 21 | -------------------------------------------------------------------------------- /components/ClassPage/HeaderBody.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | type HeaderBodyProps = { 4 | header: string | ReactElement; 5 | body: ReactElement | ReactElement[]; 6 | className?: string; 7 | }; 8 | 9 | export default function HeaderBody({ 10 | header, 11 | body, 12 | className, 13 | }: HeaderBodyProps): ReactElement { 14 | return ( 15 |
16 |

{header}

17 | {body} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Overview](README.md) 2 | 3 | --- 4 | 5 | - [🔗 Backend/API Docs](https://apidocs.searchneu.com) 6 | 7 | --- 8 | 9 | - Setup 10 | - [Prerequisites](setup/prereqs.md) 11 | - [Running](setup/setup.md) 12 | - [Development](setup/development.md) 13 | - [Styles (CSS)](styling-css/styling.md) 14 | - [Deployment](deployment/deployment.md) 15 | - [Infrastructure](infrastructure/infra.md) 16 | - [Notifications](notifications/notifs.md) 17 | - [Developer Style Guide](dev-styles/style-guide.md) 18 | - [Bug Bounty](bounty/bounty.md) 19 | -------------------------------------------------------------------------------- /styles/_exports.module.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | //These variables are used for colors in .tsx files. Regular Sass variables don't work! 3 | 4 | :export { 5 | neu_red: $NEU_Red; 6 | cps_yellow: $CPS_Yellow; 7 | law_blue: $LAW_Blue; 8 | navy: $Navy; 9 | blue: $Blue; 10 | aqua: $Aqua; 11 | white: $White; 12 | off_white: $Off_White; 13 | black: $Black; 14 | grey: $Grey; 15 | dark_grey: $Dark_Grey; 16 | light_grey: $Light_Grey; 17 | green: $Green; 18 | dark_red: $Dark_Red; 19 | light_red: $Light_Red; 20 | light_green: $Light_Green; 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | **/.idea 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | .vscode 34 | 35 | # vercel 36 | .vercel 37 | 38 | cache 39 | -------------------------------------------------------------------------------- /styles/_CRNBadge.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .CRNBadge { 4 | display: inline-flex; 5 | align-items: center; 6 | background-color: Colors.$White; 7 | border-radius: 900px; 8 | padding: 2px 6px; 9 | max-width: fit-content; 10 | margin-left: 10px; 11 | 12 | &__text { 13 | font-size: 14px; 14 | margin-left: 10px; 15 | } 16 | 17 | &__subscribed { 18 | width: 10px; 19 | height: 10px; 20 | border-radius: 50%; 21 | background-color: Colors.$Light_Grey; 22 | 23 | &--active { 24 | background-color: Colors.$NEU_Red; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /styles/panels/_WeekdayBoxes.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | @use '../variables' as Colors; 6 | 7 | $box-size: 12px; 8 | 9 | .weekday-indicator { 10 | display: inline-block; 11 | 12 | .weekday-box { 13 | width: $box-size; 14 | height: $box-size; 15 | display: inline-block; 16 | border: 2px solid Colors.$Light_Grey; 17 | border-radius: 3px; 18 | margin: 0 1px; 19 | } 20 | 21 | .weekday-box-checked { 22 | background: Colors.$Light_Grey; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/[campus].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticPathsResult, GetStaticProps } from 'next'; 2 | import { Campus } from '../components/types'; 3 | import Home from './[campus]/[termId]'; 4 | 5 | export default Home; 6 | 7 | export function getStaticPaths(): GetStaticPathsResult { 8 | const result: GetStaticPathsResult = { paths: [], fallback: false }; 9 | 10 | for (const campus of Object.values(Campus)) { 11 | result.paths.push({ 12 | params: { campus }, 13 | }); 14 | } 15 | return result; 16 | } 17 | 18 | export const getStaticProps: GetStaticProps = async () => { 19 | return { props: {} }; 20 | }; 21 | -------------------------------------------------------------------------------- /components/icons/info-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /components/tests/__mocks__/randomstring.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | // This file is responsible for mocking out the randomstring module. 7 | // This will always return the same string for a given input. 8 | 9 | import macros from '../../macros'; 10 | 11 | export default { 12 | generate: (length) => { 13 | if (!macros.isNumeric(length)) { 14 | length = 10; 15 | } 16 | 17 | let output = ''; 18 | while (output.length < length) { 19 | output += '0'; 20 | } 21 | return output; 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "next/babel", 5 | { 6 | "class-properties": { 7 | "loose": true 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | [ 14 | "@babel/plugin-proposal-decorators", 15 | { 16 | "legacy": true 17 | }, 18 | "@emotion" // needed for react-spinners 19 | ], 20 | [ 21 | "@babel/plugin-proposal-private-property-in-object", 22 | { 23 | "loose": true 24 | } 25 | ], 26 | [ 27 | "@babel/plugin-proposal-private-methods", 28 | { 29 | "loose": true 30 | } 31 | ] 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /components/ResultsPage/Results/useSectionPanelDetail.tsx: -------------------------------------------------------------------------------- 1 | interface UseSectionPanelDetailReturn { 2 | getSeatsClass: () => string; 3 | } 4 | 5 | export default function useSectionPanelDetail( 6 | seatsRemaining: number, 7 | seatsCapacity: number 8 | ): UseSectionPanelDetailReturn { 9 | const getSeatsClass = (): string => { 10 | const seatingPercentage = seatsRemaining / seatsCapacity; 11 | if (seatingPercentage > 2 / 3) { 12 | return 'green'; 13 | } 14 | if (seatingPercentage > 1 / 3) { 15 | return 'yellow'; 16 | } 17 | return 'red'; 18 | }; 19 | 20 | return { 21 | getSeatsClass: getSeatsClass, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "strictNullChecks": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true 19 | }, 20 | "include": ["**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /components/icons/IconCheckmark.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | const IconCheckmark = ({ 4 | width = '12', 5 | height = '9', 6 | className, 7 | }: { 8 | width?: string; 9 | height?: string; 10 | className?: string; 11 | }): ReactElement => ( 12 | 20 | 27 | 28 | ); 29 | 30 | export default IconCheckmark; 31 | -------------------------------------------------------------------------------- /components/icons/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /styles/_Footer.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | /* Footer. */ 4 | .footer { 5 | .credits { 6 | margin-bottom: 0 !important; 7 | padding-bottom: 0 !important; 8 | } 9 | 10 | .affiliation { 11 | margin: 0 !important; 12 | padding-bottom: 0 !important; 13 | font-size: 14px !important; 14 | padding-top: 0 !important; 15 | color: Colors.$Dark_Grey; 16 | } 17 | 18 | .contact { 19 | margin: 0 !important; 20 | padding: 0 !important; 21 | font-size: 14px !important; 22 | padding-bottom: 30px !important; 23 | } 24 | 25 | .contact > a { 26 | padding-right: 1px; 27 | padding-left: 1px; 28 | outline: 0; 29 | cursor: pointer; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /components/icons/FilterButton.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /styles/results/_NotifSignUpButton.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | 3 | .button { 4 | display: flex; 5 | align-items: center; 6 | border-radius: 5px; 7 | background: Colors.$White; 8 | border: 1px solid Colors.$Grey; 9 | align-items: center; 10 | padding: 8px 10px; 11 | width: max-content; 12 | height: auto; 13 | margin-top: 10px; 14 | margin-bottom: 10px; 15 | cursor: pointer; 16 | 17 | &:hover { 18 | background: Colors.$Off_White; 19 | } 20 | 21 | .icon { 22 | margin-right: 8px; 23 | height: 13px; 24 | } 25 | 26 | span { 27 | display: inline-block; 28 | vertical-align: 5px; 29 | font-style: normal; 30 | font-weight: normal; 31 | font-size: 14px; 32 | line-height: 25px; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /components/SubscriptionsPage/CRNBadge.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import { UserInfo } from '../types'; 3 | 4 | type CRNBadgeProps = { 5 | crn: string; 6 | userInfo: UserInfo; 7 | }; 8 | 9 | export const CRNBadge = ({ crn, userInfo }: CRNBadgeProps): ReactElement => { 10 | const subscribed = userInfo?.sectionIds.map((str) => 11 | str.substring(str.lastIndexOf('/') + 1) 12 | ); 13 | 14 | return ( 15 |
16 |
23 |
{crn}
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /components/icons/magnifying-glass.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/tests/pages/Home.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import Enzyme, { shallow } from 'enzyme'; 9 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 10 | 11 | import Home from '../../../pages/[campus]/[termId]'; 12 | 13 | jest.mock('next/router', () => ({ 14 | useRouter: () => ({ query: { campus: 'NEU' } }), 15 | })); 16 | 17 | jest.mock('use-query-params', () => ({ 18 | useQueryParam: () => ['202030'], 19 | })); 20 | 21 | Enzyme.configure({ adapter: new Adapter() }); 22 | 23 | it('should render a section', () => { 24 | const result = shallow(); 25 | expect(result.debug()).toMatchSnapshot(); 26 | }); 27 | -------------------------------------------------------------------------------- /docs/deployment/deployment.md: -------------------------------------------------------------------------------- 1 | # Git branches and deploying to production 2 | 3 | The master branch is the main branch for all the development. Merging into master deploys to searchneu.vercel.app. Releasing to searchneu.com must be done by merging `master` into the `prod` branch and pushing; this can only be done by someone on the team with admin privileges. Once changes in `prod` are pushed, Vercel will automatically deploy prod. 4 | 5 | If the frontend has new environment variables, they can be set by going to Vercel -> Settings -> Environment Variables. 6 | 7 | !> If any environment variable needs to be exposed to the browser (for example, the backend endpoint the browser hits), the variable should be prefixed with `NEXT_PUBLIC_`. See [Next.js environment variables](NEXT_PUBLIC_GRAPHQL_ENDPOINT). 8 | -------------------------------------------------------------------------------- /components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export enum TooltipDirection { 4 | Up = 'UP', 5 | Down = 'DOWN', 6 | } 7 | 8 | export type TooltipProps = { 9 | text: string; 10 | direction: TooltipDirection; 11 | /** 12 | * Decides the orientation of the Tooltip box; by default, the orientation has text 13 | * expanding on the right side. If this variable is set to T, the box will have 14 | * text expanding the left side, making it "flipped". 15 | */ 16 | flipLeft?: boolean; 17 | }; 18 | 19 | export default function Tooltip(props: TooltipProps) { 20 | return ( 21 |
22 | {props.text} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /styles/panels/_DesktopSectionPanel.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | @use '../variables' as Colors; 6 | 7 | .wideOnlineCell { 8 | position: relative; 9 | } 10 | 11 | .onlineDivLineContainer { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | height: 100%; 16 | width: 100%; 17 | } 18 | 19 | .onlineDivLine { 20 | vertical-align: middle; 21 | display: inline-block; 22 | height: 2px; 23 | background-color: Colors.$Light_Grey; 24 | width: calc(50% - 102px); 25 | margin: 21px; 26 | } 27 | 28 | .onlineLeftLine { 29 | margin-left: 36px; 30 | } 31 | 32 | .inlineBlock { 33 | display: inline-block; 34 | } 35 | 36 | .sectionGlobe { 37 | opacity: 0.7; 38 | } 39 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: Bingbot 2 | User-agent: Googlebot 3 | User-Agent: Googlebot-Image 4 | User-Agent: Googlebot-Mobile 5 | User-agent: Mediapartners-Google 6 | User-agent: MSNbot 7 | User-agent: Slurp 8 | User-agent: Twitterbot 9 | User-agent: DuckDuckBot 10 | Disallow: 11 | 12 | # There's no point in allowing spiders to hit the api 13 | # It will just generate logs unnecessarily 14 | Disallow: /data 15 | 16 | # 17 | # Everyone else is banned. If you operate a search engine and would like to crawl Search NEU, 18 | # please contact hey@searchneu.com before crawling! 19 | # This was modeled after bluegolf.com/robots.txt 20 | # 21 | 22 | User-agent: * 23 | Disallow: / 24 | 25 | # 26 | # some specific bans 27 | # 28 | User-agent: Aboundexbot 29 | Disallow: / 30 | 31 | Sitemap: https://searchneu.com/sitemap.xml 32 | -------------------------------------------------------------------------------- /components/ResultsPage/useAtTop.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | * 5 | * Little hook just to keep state on whether you're at the top of the screen 6 | */ 7 | 8 | import { useState, useEffect } from 'react'; 9 | 10 | export default function useAtTop(): boolean { 11 | const [atTop, setAtTop] = useState(true); 12 | useEffect(() => { 13 | const handleScroll = (): void => { 14 | const pageY = 15 | document.body.scrollTop || document.documentElement.scrollTop; 16 | setAtTop(pageY === 0); 17 | }; 18 | window.addEventListener('scroll', handleScroll); 19 | handleScroll(); 20 | return () => { 21 | window.removeEventListener('scroll', handleScroll); 22 | }; 23 | }, []); 24 | return atTop; 25 | } 26 | -------------------------------------------------------------------------------- /public/alert-banners.yml: -------------------------------------------------------------------------------- 1 | # This file is used to render banner alerts on the front page of search 2 | # To add an alert, add a new entry at the top level, preferrably with a descriptive name for the alert 3 | # Then, add key-value pair entries to that object, text, alertLevel, isVisible, and optional link. Example: 4 | # notificationsAlert: 5 | # text: "Due to a technical issue with our notification pathway through Facebook, notifications have been temporarily disabled. We're working on a new notification flow, and apologize for any inconvenience." 6 | # alertLevel: "error" 7 | # link: "https://www.w3schools.com" 8 | # For more info, check out the AlertBanner.tsx file 9 | --- 10 | notificationsAlert: 11 | text: 'New feature: SMS notifications are now available and have replaced Facebook Messenger notifications :D' 12 | alertLevel: 'info' 13 | -------------------------------------------------------------------------------- /pages/api/classPage.graphql: -------------------------------------------------------------------------------- 1 | query getClassPageInfo($subject: String!, $classId: String!) { 2 | class(subject: $subject, classId: $classId) { 3 | name 4 | subject 5 | classId 6 | latestOccurrence { 7 | desc 8 | prereqs 9 | coreqs 10 | prereqsFor 11 | optPrereqsFor 12 | maxCredits 13 | minCredits 14 | classAttributes 15 | url 16 | prettyUrl 17 | lastUpdateTime 18 | feeAmount 19 | nupath 20 | host 21 | termId 22 | } 23 | allOccurrences { 24 | termId 25 | sections { 26 | classType 27 | crn 28 | seatsCapacity 29 | seatsRemaining 30 | waitCapacity 31 | waitRemaining 32 | campus 33 | profs 34 | meetings 35 | url 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components/ResultsPage/Results/NotifSignUpButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import macros from '../../macros'; 3 | 4 | interface NotifSignUpButtonProps { 5 | onNotifSignUp: () => void; 6 | } 7 | 8 | export default function NotifSignUpButton({ 9 | onNotifSignUp, 10 | }: NotifSignUpButtonProps): ReactElement { 11 | const onClickWithAmplitudeHook = (): void => { 12 | onNotifSignUp(); 13 | macros.logAmplitudeEvent('Notifs Button'); 14 | }; 15 | 16 | const NOTIFICATIONS_ARE_DISABLED = false; 17 | 18 | return ( 19 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | interface ModalProps { 4 | visible: boolean; 5 | onCancel: () => void; 6 | } 7 | 8 | export default function Modal({ 9 | visible, 10 | onCancel, 11 | children, 12 | }: React.PropsWithChildren): ReactElement { 13 | React.useEffect(() => { 14 | const handleKeyEvent = (event: KeyboardEvent): void => { 15 | if (event.key === 'Escape') { 16 | onCancel(); 17 | } 18 | }; 19 | 20 | document.addEventListener('keydown', handleKeyEvent); 21 | 22 | return () => document.removeEventListener('keydown', handleKeyEvent); 23 | }, [onCancel]); 24 | 25 | return visible ? ( 26 |
27 |
e.stopPropagation()}> 28 | {children} 29 |
30 |
31 | ) : null; 32 | } 33 | -------------------------------------------------------------------------------- /public/unsubscribe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /styles/_Modal.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | @use 'zIndexes' as Indexes; 3 | 4 | .modal-wrapper { 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | height: 100vh; 9 | width: 100vw; 10 | z-index: Indexes.$Fourteen; 11 | background-color: Colors.$Modal_Background; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | 17 | .modal { 18 | display: flex; 19 | align-items: center; 20 | flex-direction: column; 21 | border-radius: 8px; 22 | background: Colors.$White; 23 | overflow: hidden; 24 | // max-width: 285px; 25 | 26 | animation-duration: 0.2s; 27 | animation-name: popIn; 28 | animation-timing-function: ease; 29 | 30 | @keyframes popIn { 31 | 0% { 32 | transform: scale(0.95); 33 | } 34 | 35 | 50% { 36 | transform: scale(1.05); 37 | } 38 | 39 | 100% { 40 | transform: scale(1); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import Error, { ErrorProps } from 'next/error'; 2 | import Macros from '../components/abstractMacros'; 3 | 4 | Error.getInitialProps = ({ req, res, err }): ErrorProps => { 5 | const statusCode = res ? res.statusCode : err ? err.statusCode : 404; 6 | if (!process.browser && Macros.PROD) { 7 | console.log('Reporting error to Rollbar...'); 8 | // dynamic import 9 | // eslint-disable-next-line @typescript-eslint/no-var-requires 10 | const Rollbar = require('rollbar'); 11 | const rollbar = new Rollbar(process.env.ROLLBAR_SERVER_TOKEN); 12 | rollbar.error(err, req, (rollbarError) => { 13 | if (rollbarError) { 14 | console.error('Rollbar error reporting failed:'); 15 | console.error(rollbarError); 16 | return; 17 | } 18 | console.log('Reported error to Rollbar'); 19 | }); 20 | } 21 | return { statusCode }; 22 | }; 23 | 24 | export default Error; 25 | -------------------------------------------------------------------------------- /components/ResultsPage/LoadingContainer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | import React, { ReactElement, useEffect, useState } from 'react'; 6 | import FadeLoader from 'react-spinners/FadeLoader'; 7 | import Colors from '../../styles/_exports.module.scss'; 8 | 9 | /** 10 | * Page that displays while results aren't ready 11 | */ 12 | export default function LoadingContainer(): ReactElement { 13 | const [doAnimation, setDoAnimation] = useState(true); 14 | const halfSecond = 500; 15 | 16 | useEffect(() => { 17 | const timer = setTimeout(() => setDoAnimation(false), halfSecond); 18 | return () => clearTimeout(timer); 19 | }, []); 20 | 21 | return doAnimation ? ( 22 |
23 | ) : ( 24 |
25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /styles/pages/_404.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | @import url('https://fonts.googleapis.com/css2?family=Montserrat+Alternates:wght@700&display=swap'); 3 | 4 | .four04-container { 5 | width: 100vw; 6 | height: 100vh; 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | .four04-title-text { 14 | font-family: Montserrat Alternates; 15 | font-size: 50px; 16 | font-weight: 700; 17 | color: Colors.$NEU_Red; 18 | margin-bottom: 100px; 19 | } 20 | 21 | .four04-return-button { 22 | margin-top: 50px; 23 | font-family: Lato; 24 | font-size: 22px; 25 | font-weight: 700; 26 | padding: 20px 40px; 27 | color: Colors.$NEU_Red; 28 | border: 1px solid Colors.$NEU_Red; 29 | border-radius: 28px; 30 | transition: color, background-color 0.15s linear; 31 | &:hover { 32 | background-color: Colors.$NEU_Red; 33 | color: Colors.$White; 34 | cursor: pointer; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/bounty/bounty.md: -------------------------------------------------------------------------------- 1 | # Bug Bounty 2 | 3 | If you find a security vulnerability on Search NEU I'll buy you a burrito from Amelia's 🌯🎉! Make sure to keep the vulnerability details secret when letting us know about it. A great way to let us know is to email us at sandboxnu@gmail.com or to use the contact form on the SearchNEU page (at the very bottom of the page). Feel free to do both! (just to make extra sure we get it :) 4 | 5 | Security vulnerabilities include: 6 | 7 | - Changing the code on production 8 | - XSS vulnerabilities/running code in other people's browsers 9 | - Accessing the environmental variables on Vercel 10 | 11 | Vulnerabilities do not include: 12 | 13 | - DDOSing the server (please don't spam the server with requests 😖). 14 | - Social engineering attempts on our team or trying to change things with unauthorized access to a team member's computer 15 | 16 | In the end the ultimate decision over the burrito award 🌯 is a decision that is entirely up to the team. 17 | -------------------------------------------------------------------------------- /styles/_variables.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | * 5 | * This file contains global variables, used across all styles. 6 | */ 7 | 8 | /* stylelint-disable */ 9 | 10 | //SearchNEU Color Palette 11 | $NEU_Red: #e63946; 12 | $CPS_Yellow: #ff9f1c; 13 | $LAW_Blue: #457b9d; 14 | $Navy: #1d3557; 15 | $Blue: #338ef1; 16 | $Aqua: #a8dadc; 17 | $Dark_Red: #8a222a; 18 | $Light_Red: #fad7da; 19 | 20 | //Utility Colors 21 | $White: #fff; 22 | $Off_White: #f8f9f9; 23 | $Black: #212427; 24 | $Grey: #6a7079; 25 | $Dark_Grey: #4e525a; 26 | $Light_Grey: #d1d3d7; 27 | $Green: #1f8b2a; 28 | $Light_Green: #a9dfae; 29 | $Light_Blue: #b5cad8; 30 | 31 | // Utility Buttons 32 | $Secondary_Hover: #f1f3f5; 33 | 34 | // Modal Background 35 | $Modal_Background: #21212166; 36 | 37 | //Expanded System 38 | $NEU2: #f8f9f9; 39 | $NEU4: #d7d9dc; 40 | $NEU5: #888d96; 41 | $NEU7: #4e525a; 42 | $NEU9: #212121; 43 | -------------------------------------------------------------------------------- /components/tests/FeedbackModal.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import Enzyme, { shallow } from 'enzyme'; 9 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 10 | 11 | import FeedbackModal from '../FeedbackModal'; 12 | 13 | Enzyme.configure({ adapter: new Adapter() }); 14 | 15 | it('should render the form', () => { 16 | const result = shallow( 17 | 22 | ).debug(); 23 | expect(result).toMatchSnapshot(); 24 | }); 25 | 26 | it('should render form is closed', () => { 27 | const result = shallow( 28 | 33 | ).debug(); 34 | expect(result).toMatchSnapshot(); 35 | }); 36 | -------------------------------------------------------------------------------- /styles/_InfoIconTooltip.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | @use 'zIndexes' as Indexes; 3 | 4 | .InfoIconTooltip { 5 | background: transparent; 6 | position: relative; 7 | width: 15px !important; 8 | margin-top: 10px; 9 | margin-left: 5px; 10 | 11 | pointer-events: initial; 12 | &:hover { 13 | cursor: pointer; 14 | } 15 | 16 | &__icon { 17 | &:hover + .tooltip { 18 | display: block; 19 | background: Colors.$Navy; 20 | white-space: wrap; 21 | width: 225px; 22 | padding: 7px; 23 | z-index: Indexes.$Thirteen; 24 | line-height: 20px; 25 | margin-left: -5px; 26 | 27 | & > .tooltip__arrow--DOWN { 28 | border-color: transparent transparent Colors.$Navy transparent; 29 | } 30 | 31 | & > .tooltip__arrow--UP { 32 | border-color: Colors.$Navy transparent transparent transparent; 33 | } 34 | } 35 | 36 | &:hover + .flip_tooltip { 37 | margin-right: -3px; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/tests/pages/Result.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 7 | import Enzyme, { mount } from 'enzyme'; 8 | import { waitForComponentToPaint } from '../util'; 9 | import React from 'react'; 10 | import { QueryParamProvider } from 'use-query-params'; 11 | import Results from '../../../pages/[campus]/[termId]/search/[query]'; 12 | 13 | jest.mock('next/router', () => ({ 14 | useRouter: () => ({ 15 | query: { campus: 'NEU', termId: '202160', query: 'cs' }, 16 | }), 17 | useQueryParam: () => false, 18 | })); 19 | 20 | Enzyme.configure({ adapter: new Adapter() }); 21 | 22 | it('should render a section', () => { 23 | const result = mount( 24 | 25 | 26 | 27 | ); 28 | waitForComponentToPaint(result); 29 | 30 | expect(result.debug()).toMatchSnapshot(); 31 | }); 32 | -------------------------------------------------------------------------------- /components/Testimonial/TestimonialToast.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import Toast from '../Toast'; 3 | import CryingHusky3 from '../icons/crying-husky-3.svg'; 4 | 5 | interface TestimonialMessageProps { 6 | position?: string; 7 | } 8 | 9 | const TestimonialMessage: ReactElement = ( 10 | 11 | We need your help!{' '} 12 | 18 | Leave a testimonial 19 | 20 | 21 | ); 22 | 23 | export default function TestimonialToast({ 24 | position = 'toast-bottom-right', 25 | }: TestimonialMessageProps): ReactElement { 26 | return ( 27 | } 31 | infiniteLength={true} 32 | /> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /styles/_Tooltip.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .tooltip { 4 | color: Colors.$White; 5 | font-size: 11px; 6 | border-radius: 10px; 7 | padding: 3px; 8 | display: none; 9 | position: absolute; 10 | pointer-events: none; 11 | background: Colors.$Blue; 12 | 13 | &__arrow { 14 | &--DOWN { 15 | position: absolute; 16 | width: 0; 17 | height: 0; 18 | border-left: 5px solid transparent; 19 | border-right: 5px solid transparent; 20 | border-bottom: 5px solid Colors.$Blue; 21 | top: -4px; 22 | } 23 | 24 | &--UP { 25 | position: absolute; 26 | width: 0; 27 | height: 0; 28 | border-left: 5px solid transparent; 29 | border-right: 5px solid transparent; 30 | border-top: 5px solid Colors.$Blue; 31 | bottom: -4px; 32 | } 33 | } 34 | } 35 | 36 | .flip_tooltip { 37 | right: 0; 38 | 39 | & > .tooltip__arrow--DOWN { 40 | right: 8px; 41 | } 42 | 43 | & > .tooltip__arrow--UP { 44 | right: 8px; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/icons/IconArrow.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Colors from '../../styles/_exports.module.scss'; 3 | 4 | const IconArrow = ({ 5 | width = '11', 6 | height = '10', 7 | fill = Colors.dark_grey, 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | fill?: string; 13 | className?: string; 14 | }): ReactElement => ( 15 | 23 | 27 | 28 | ); 29 | 30 | export default IconArrow; 31 | -------------------------------------------------------------------------------- /components/ResultsPage/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * Hook for closing components when clicked outside 5 | * @param ref ref for the component to close 6 | * @param flag boolean representing the open state of the component 7 | * @param setFlag function to set the open state of the component 8 | */ 9 | 10 | export default function useClickOutside( 11 | ref: React.RefObject, 12 | flag: boolean, 13 | setFlag: (b: boolean) => void 14 | ): void { 15 | useEffect(() => { 16 | const handleClickOutside = (e): void => { 17 | if (ref.current.contains(e.target)) { 18 | return; 19 | } 20 | setFlag(false); 21 | }; 22 | 23 | if (flag) { 24 | document.addEventListener('mousedown', handleClickOutside); 25 | } else { 26 | document.removeEventListener('mousedown', handleClickOutside); 27 | } 28 | return () => { 29 | document.removeEventListener('mousedown', handleClickOutside); 30 | }; 31 | }, [flag, ref, setFlag]); 32 | } 33 | -------------------------------------------------------------------------------- /styles/_Toast.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | @use 'zIndexes' as Indexes; 3 | 4 | .toast-body { 5 | display: inline-flex; 6 | padding: 12px; 7 | justify-content: center; 8 | align-items: center; 9 | gap: 8px; 10 | border-radius: 8px; 11 | background: Colors.$White; 12 | position: fixed; 13 | z-index: Indexes.$Three; 14 | box-shadow: 0px 97px 27px 0px rgba(138, 138, 138, 0), 15 | 0px 62px 25px 0px rgba(138, 138, 138, 0.01), 16 | 0px 35px 21px 0px rgba(138, 138, 138, 0.02), 17 | 0px 16px 16px 0px rgba(138, 138, 138, 0.03), 18 | 0px 4px 9px 0px rgba(138, 138, 138, 0.04); 19 | } 20 | 21 | .toast-message { 22 | color: Colors.$NEU9; 23 | font-family: Lato; 24 | font-size: 14px; 25 | font-style: normal; 26 | font-weight: 700; 27 | line-height: 14px; 28 | white-space: nowrap; 29 | } 30 | 31 | .link { 32 | text-decoration: underline; 33 | } 34 | 35 | .toast-bottom-left { 36 | bottom: 40px; 37 | left: 48px; 38 | } 39 | 40 | .toast-bottom-right { 41 | bottom: 40px; 42 | right: 24px; 43 | } 44 | -------------------------------------------------------------------------------- /styles/_SearchBar.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .searchbar { 4 | height: 40px; 5 | pointer-events: initial; 6 | display: flex; 7 | align-items: stretch; 8 | border: 1px solid Colors.$Light_Grey; 9 | border-radius: 5px; 10 | width: 100%; 11 | 12 | &:focus-within { 13 | box-shadow: 0 0 5px Colors.$Light_Grey; 14 | } 15 | 16 | &__input { 17 | flex-grow: 2; 18 | margin: 2px 15px 2px; 19 | border: none; 20 | font-size: 16px; 21 | color: Colors.$Dark_Grey; 22 | 23 | &::placeholder { 24 | color: Colors.$Light_Grey; 25 | } 26 | 27 | &:focus { 28 | outline: none; 29 | } 30 | } 31 | 32 | &__button { 33 | display: flex; 34 | align-items: center; 35 | justify-content: center; 36 | border-radius: 0 4px 4px 0; 37 | width: 50px; 38 | margin: -1px; 39 | cursor: pointer; 40 | 41 | &:focus { 42 | outline: none; 43 | } 44 | } 45 | 46 | @media only screen and (max-width: 767px) { 47 | border: 1px solid Colors.$NEU_Red; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const withPlugins = require('next-compose-plugins'); 3 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 4 | enabled: process.env.ANALYZE === 'true', 5 | }); 6 | 7 | module.exports = { 8 | webpack(config) { 9 | // Support svg import as react component 10 | config.module.rules.push({ 11 | test: /\.svg$/, 12 | issuer: /\.(js|ts)x?$/, 13 | use: ['@svgr/webpack'], 14 | }); 15 | 16 | config.module.rules.push({ 17 | test: /\.ya?ml$/, 18 | use: 'js-yaml-loader', 19 | }); 20 | return config; 21 | }, 22 | async redirects() { 23 | return [ 24 | { 25 | source: '/', 26 | destination: '/NEU', 27 | permanent: true, 28 | }, 29 | ]; 30 | }, 31 | async rewrites() { 32 | return [ 33 | { 34 | source: '/sitemap.xml', 35 | destination: '/api/sitemap.xml', 36 | }, 37 | { 38 | source: '/graphql', 39 | destination: 'https://api.searchneu.com', 40 | }, 41 | ]; 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /components/ResultsPage/Results/WeekdayBoxes.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { MeetingType } from '../../types'; 3 | 4 | interface WeekdayBoxesProps { 5 | meetingDays: boolean[]; 6 | meetingType: MeetingType; 7 | } 8 | 9 | function WeekdayBoxes({ 10 | meetingDays, 11 | meetingType, 12 | }: WeekdayBoxesProps): ReactElement { 13 | const days = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; 14 | 15 | return ( 16 |
17 | {meetingDays.map((box, index) => { 18 | return ( 19 | // eslint-disable-next-line react/no-array-index-key 20 | 30 | {days[index]} 31 | 32 | ); 33 | })} 34 |
35 | ); 36 | } 37 | 38 | export default WeekdayBoxes; 39 | -------------------------------------------------------------------------------- /components/icons/IconCollapseExpand.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Colors from '../../styles/_exports.module.scss'; 3 | 4 | const IconCollapseExpand = ({ 5 | width = '11', 6 | height = '18', 7 | fill = Colors.dark_grey, 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | fill?: string; 13 | className?: string; 14 | }): ReactElement => ( 15 | 23 | 27 | 28 | ); 29 | 30 | export default IconCollapseExpand; 31 | -------------------------------------------------------------------------------- /components/ResultsPage/Results/useShowAll.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Section } from '../../types'; 3 | 4 | interface UseShowAllReturn { 5 | showAll: boolean; 6 | setShowAll: (b: boolean) => void; 7 | renderedSections: Section[]; 8 | hideShowAll: boolean; 9 | } 10 | 11 | export default function useShowAll(sections: Section[]): UseShowAllReturn { 12 | const [showAll, setShowAll] = useState(false); 13 | 14 | const sectionsShownByDefault = sections.length < 3 ? sections.length : 3; 15 | const [renderedSections, setRenderedSections] = useState( 16 | sections.slice(0, sectionsShownByDefault) 17 | ); 18 | const hideShowAll = sectionsShownByDefault === sections.length; 19 | 20 | useEffect(() => { 21 | if (showAll) { 22 | setRenderedSections(sections); 23 | } else { 24 | setRenderedSections(sections.slice(0, sectionsShownByDefault)); 25 | } 26 | }, [sections, sectionsShownByDefault, showAll]); 27 | 28 | return { 29 | showAll: showAll, 30 | setShowAll: setShowAll, 31 | renderedSections: renderedSections, 32 | hideShowAll: hideShowAll, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useEffect, useState } from 'react'; 2 | import X from './icons/X.svg'; 3 | 4 | interface ToastProps { 5 | message: ReactElement; 6 | duration?: number; 7 | infiniteLength?: boolean; 8 | image?: ReactElement; 9 | position: string; 10 | } 11 | 12 | export default function Toast({ 13 | message, 14 | duration = 20, 15 | infiniteLength = false, 16 | image = null, 17 | position, 18 | }: ToastProps): ReactElement { 19 | const [isVisible, setIsVisible] = useState(true); 20 | 21 | useEffect(() => { 22 | if (!infiniteLength) { 23 | const timeout = setTimeout(() => { 24 | setIsVisible(false); 25 | }, duration); 26 | 27 | return () => clearTimeout(timeout); 28 | } 29 | }, [duration, infiniteLength]); 30 | return isVisible ? ( 31 |
32 | {image} 33 | {message} 34 | 40 |
41 | ) : null; 42 | } 43 | -------------------------------------------------------------------------------- /docs/dev-styles/style-guide.md: -------------------------------------------------------------------------------- 1 | # Commit Message Guidelines 2 | 3 | Here are the 7 steps for a good git commit message, as per [Chris Beam's blog](https://chris.beams.io/posts/git-commit/) 4 | 5 | 1. Separate subject from body with a blank line 6 | 2. Limit the subject line to 50 characters 7 | 3. Capitalize the subject line 8 | 4. Do not end the subject line with a period 9 | 5. Use the imperative mood in the subject line 10 | 6. Wrap the body at 72 characters 11 | 7. Use the body to explain what and why vs. how 12 | 13 | ### Examples: 14 | 15 | _Fix failing CompositePropertySourceTests_
16 | _Rework @PropertySource early parsing logic_
17 | _Add tests for ImportSelector meta-data_
18 | _Update docbook dependency and generate epub_
19 | _Polish mockito usage_
20 | 21 | # Squashing Pull Requests 22 | 23 | As a team, we've agreed to squash all of our pull requests for this project. The reason is because it will make our logs generally much cleaner and more concise. With the PR template, most code changes should be explained enough by the PR message, and smaller PR's will also keep this more in line. 24 | -------------------------------------------------------------------------------- /components/panels/tests/EmployeePanel.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import Enzyme, { shallow } from 'enzyme'; 9 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 10 | import macros from '../../macros'; 11 | 12 | import mockData from './mockData'; 13 | import EmployeeResult from '../../ResultsPage/Results/EmployeeResult'; 14 | 15 | Enzyme.configure({ adapter: new Adapter() }); 16 | 17 | it('should render a desktop employee panel', () => { 18 | const orig = macros.isMobile; 19 | macros.isMobile = false; 20 | 21 | const result = shallow(); 22 | expect(result.debug()).toMatchSnapshot(); 23 | 24 | macros.isMobile = orig; 25 | }); 26 | 27 | it('should render a mobile employee panel', () => { 28 | const orig = macros.isMobile; 29 | macros.isMobile = true; 30 | 31 | const result = shallow(); 32 | expect(result.debug()).toMatchSnapshot(); 33 | 34 | macros.isMobile = orig; 35 | }); 36 | -------------------------------------------------------------------------------- /components/ResultsPage/ToggleFilter.tsx: -------------------------------------------------------------------------------- 1 | import { uniqueId } from 'lodash'; 2 | import React, { ChangeEvent, ReactElement, useState } from 'react'; 3 | 4 | interface ToggleFilterProps { 5 | title: string; 6 | selected: boolean; 7 | setActive: (a: boolean) => void; 8 | } 9 | 10 | export default function ToggleFilter({ 11 | title, 12 | selected, 13 | setActive, 14 | }: ToggleFilterProps): ReactElement { 15 | const [id] = useState(uniqueId('react-switch-')); 16 | const onChange = (event: ChangeEvent): void => 17 | setActive(event.target.checked); 18 | return ( 19 |
20 |
21 |

{title}

22 |
23 |
24 | 31 | 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import Head from 'next/head'; 3 | import React, { ReactElement } from 'react'; 4 | import 'semantic-ui-css/semantic.min.css'; 5 | import '../styles/base.scss'; 6 | import { useGoogleAnalyticsOnPageChange } from '../utils/gtag'; 7 | import { QueryParamProvider } from '../utils/QueryParamProvider'; 8 | import { TermInfoProvider } from '../utils/TermInfoProvider'; 9 | import Colors from '../styles/_exports.module.scss'; 10 | 11 | function MyApp({ Component, pageProps }: AppProps): ReactElement { 12 | useGoogleAnalyticsOnPageChange(); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export default MyApp; 32 | -------------------------------------------------------------------------------- /components/icons/exit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /components/icons/circular.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /utils/TermInfoProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext, ReactElement } from 'react'; 2 | import { fetchTermInfo, TermInfo } from '../components/terms'; 3 | import { Campus } from '../components/types'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const emptyTermInfos: Record = { 7 | [Campus.NEU]: [], 8 | [Campus.CPS]: [], 9 | [Campus.LAW]: [], 10 | }; 11 | 12 | const emptyTermInfosWithError = { 13 | error: null, 14 | termInfos: emptyTermInfos, 15 | }; 16 | 17 | const termInfoReactContext = React.createContext(emptyTermInfosWithError); 18 | 19 | export const TermInfoProvider = ({ children }): ReactElement => { 20 | const [termInfos, setTermInfos] = useState(emptyTermInfosWithError); 21 | 22 | useEffect(() => { 23 | fetchTermInfo().then((result) => setTermInfos(result)); 24 | }, []); 25 | 26 | const { Provider } = termInfoReactContext; 27 | return {children}; 28 | }; 29 | 30 | TermInfoProvider.propTypes = { 31 | children: PropTypes.any, 32 | }; 33 | 34 | const GetTermInfosWithError = (): { 35 | error: Error; 36 | termInfos: Record; 37 | } => useContext(termInfoReactContext); 38 | export default GetTermInfosWithError; 39 | -------------------------------------------------------------------------------- /components/ResultsPage/Results/MobileCollapsableDetail.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import IconCollapseExpand from '../../icons/IconCollapseExpand'; 3 | import Colors from '../../../styles/_exports.module.scss'; 4 | 5 | interface MobileCollapsableDetailProps { 6 | title: string; 7 | expand: boolean; 8 | setExpand: (b: boolean) => void; 9 | renderChildren: () => JSX.Element | JSX.Element[]; 10 | } 11 | 12 | function MobileCollapsableDetail({ 13 | title, 14 | expand, 15 | setExpand, 16 | renderChildren, 17 | }: MobileCollapsableDetailProps): ReactElement { 18 | return ( 19 |
setExpand(!expand)} 24 | > 25 |
26 | 32 | {title} 33 |
34 | {expand &&
{renderChildren()}
} 35 |
36 | ); 37 | } 38 | 39 | export default MobileCollapsableDetail; 40 | -------------------------------------------------------------------------------- /utils/gtag.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | import Macros from '../components/macros'; 4 | 5 | export const GA_TRACKING_ID = ''; 6 | 7 | type GTagEvent = { 8 | action: string; 9 | category: string; 10 | label: string; 11 | value: number; 12 | }; 13 | 14 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events 15 | export const event = ({ action, category, label, value }: GTagEvent): void => { 16 | window.gtag('event', action, { 17 | event_category: category, 18 | event_label: label, 19 | value, 20 | }); 21 | }; 22 | 23 | /** 24 | * Send page views to google analytics. Track when router changes and log. 25 | */ 26 | export function useGoogleAnalyticsOnPageChange(): void { 27 | const router = useRouter(); 28 | 29 | useEffect(() => { 30 | const handleRouteChange = (url: URL): void => { 31 | if (Macros.PROD) { 32 | window.gtag('config', GA_TRACKING_ID, { 33 | page_path: url, 34 | }); 35 | } 36 | }; 37 | router.events.on('routeChangeComplete', handleRouteChange); 38 | return (): void => { 39 | router.events.off('routeChangeComplete', handleRouteChange); 40 | }; 41 | }, [router.events]); 42 | } 43 | -------------------------------------------------------------------------------- /components/common/CreditsDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | interface CreditsDisplayProps { 4 | maxCredits: number; 5 | minCredits: number; 6 | } 7 | 8 | export function creditsDescription(maxCredits: number): string { 9 | return maxCredits > 1 || maxCredits === 0 ? 'CREDITS' : 'CREDIT'; 10 | } 11 | 12 | export function creditsNumericDisplay( 13 | maxCredits: number, 14 | minCredits: number 15 | ): string { 16 | return maxCredits === minCredits 17 | ? `${maxCredits}` 18 | : `${minCredits}-${maxCredits}`; 19 | } 20 | 21 | export const creditsString = ( 22 | maxCredits: number, 23 | minCredits: number 24 | ): string => { 25 | return `${creditsNumericDisplay(maxCredits, minCredits)} ${creditsDescription( 26 | maxCredits 27 | )}`; 28 | }; 29 | 30 | export function CreditsDisplay({ 31 | maxCredits, 32 | minCredits, 33 | }: CreditsDisplayProps): ReactElement { 34 | return ( 35 | 36 | {creditsString(maxCredits, minCredits)} 37 | 38 | ); 39 | } 40 | 41 | export function CreditsDisplayMobile({ 42 | maxCredits, 43 | minCredits, 44 | }: CreditsDisplayProps): ReactElement { 45 | return {creditsString(maxCredits, minCredits)}; 46 | } 47 | -------------------------------------------------------------------------------- /styles/_SearchDropdown.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .searchDropdown { 4 | display: inline-block; 5 | max-width: 240px; 6 | color: Colors.$Dark_Grey !important; 7 | background: transparent !important; 8 | box-shadow: none !important; 9 | border: none !important; 10 | pointer-events: initial; 11 | min-width: max-content !important; 12 | 13 | & * { 14 | font-size: 17px; 15 | font-weight: initial !important; 16 | border: none !important; 17 | color: Colors.$Dark_Grey !important; 18 | line-height: 1.3em; 19 | } 20 | 21 | & > i { 22 | line-height: 1.8em !important; 23 | } 24 | 25 | &--compact { 26 | flex-basis: 200px; 27 | flex-shrink: 1; 28 | height: 60px; 29 | margin: 0; 30 | padding: 0 0 0 10px !important; 31 | text-align: right; 32 | display: flex !important; 33 | justify-content: center; 34 | align-items: center; 35 | 36 | & * { 37 | font-size: 15px; 38 | line-height: 20px; 39 | 40 | @media only screen and (min-width: 767px) { 41 | font-size: 17px; 42 | } 43 | } 44 | 45 | & > i { 46 | position: static !important; 47 | top: initial !important; 48 | right: initial !important; 49 | margin: 0 !important; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /styles/panels/_DesktopClassPanel.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | @use '../variables' as Colors; 6 | 7 | .resultsTable { 8 | border: none !important; 9 | margin-bottom: 0 !important; 10 | 11 | th { 12 | border-left: none !important; 13 | background: none !important; 14 | } 15 | } 16 | 17 | .leftPanel { 18 | text-align: left; 19 | display: inline-block; 20 | width: calc(100% - 300px); 21 | } 22 | 23 | .rightPanel { 24 | text-align: right; 25 | display: inline-block; 26 | float: right; 27 | white-space: normal; 28 | max-width: 300px; 29 | min-width: 53px; 30 | } 31 | 32 | .classGlobeLink { 33 | opacity: 0.7; 34 | float: right; 35 | display: inline-block; 36 | } 37 | 38 | .classTitle { 39 | display: inline-block; 40 | max-width: calc(100% - 20px); 41 | } 42 | 43 | .classGlobeLinkContainer { 44 | vertical-align: top; 45 | display: inline-block; 46 | float: right; 47 | } 48 | 49 | .sectionTableFirstRow { 50 | display: none; 51 | padding-top: 0; 52 | padding-bottom: 1px; 53 | } 54 | 55 | .Collapsible__trigger { 56 | color: Colors.$Blue; 57 | cursor: pointer; 58 | 59 | &:hover { 60 | color: Colors.$Blue; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /components/HomePage/ExploratorySearchButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { Campus } from '../types'; 3 | import { useRouter } from 'next/router'; 4 | import { getTermName } from '../terms'; 5 | import getTermInfosWithError from '../../utils/TermInfoProvider'; 6 | 7 | interface ExploratorySearchButtonProps { 8 | termId: string; 9 | campus: Campus; 10 | } 11 | 12 | const ExploratorySearchButton = ({ 13 | termId, 14 | campus, 15 | }: ExploratorySearchButtonProps): ReactElement => { 16 | const router = useRouter(); 17 | 18 | const termInfosWithError = getTermInfosWithError(); 19 | const termInfosError = termInfosWithError.error; 20 | const termInfos = termInfosWithError.termInfos; 21 | const termName = getTermName(termInfos, termId); 22 | 23 | return ( 24 |
router.push(`/${campus}/${termId}/search`)} 27 | > 28 | {(!campus || !termName) && !termInfosError && 'Loading semester data...'} 29 | {campus && termName && ( 30 | 31 | View all classes for 32 | {` ${campus} ${termName}`} 33 | 34 | )} 35 |
36 | ); 37 | }; 38 | 39 | export default ExploratorySearchButton; 40 | -------------------------------------------------------------------------------- /components/ResultsPage/SearchDropdown.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React, { ReactElement } from 'react'; 3 | import { Dropdown } from 'semantic-ui-react'; 4 | 5 | interface ItemProps { 6 | text: string; 7 | value: string; 8 | link: string; 9 | } 10 | interface DropdownProps { 11 | options: ItemProps[]; 12 | value: string; 13 | className: string; 14 | compact: boolean; 15 | } 16 | 17 | function SearchDropdown({ 18 | options, 19 | value: currentValue, 20 | className = 'searchDropdown', 21 | compact = false, 22 | }: DropdownProps): ReactElement { 23 | const currentOption = options.find((o) => o.value == currentValue); 24 | const currentText = currentOption ? currentOption.text : ''; 25 | return ( 26 | 34 | 35 | {options.map(({ text, value, link }) => ( 36 | 37 | 38 | 39 | ))} 40 | 41 | 42 | ); 43 | } 44 | 45 | export default React.memo(SearchDropdown); 46 | -------------------------------------------------------------------------------- /styles/_MobileSearchOverlay.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | $EXECUTE_BTN_HEIGHT: 40px; 4 | 5 | .msearch-overlay { 6 | min-height: 100vh; 7 | display: flex; 8 | flex-direction: column; 9 | 10 | &__back { 11 | padding: 5px 15px 5px 15px; 12 | height: 30px; 13 | width: 50px; 14 | float: right; 15 | margin-top: 10px; 16 | } 17 | 18 | &__search { 19 | flex-grow: 1; 20 | margin-right: 10px; 21 | } 22 | 23 | &__content { 24 | flex-grow: 1; 25 | margin-bottom: $EXECUTE_BTN_HEIGHT; 26 | background-color: Colors.$Off_White; 27 | padding: 0 15px 25px 15px; 28 | padding-left: 30px; 29 | } 30 | 31 | &__pills { 32 | // min-height: 50px; 33 | display: flex; 34 | align-items: center; 35 | } 36 | 37 | &__execute { 38 | position: fixed; 39 | bottom: 0; 40 | height: $EXECUTE_BTN_HEIGHT; 41 | width: 100%; 42 | font-size: 14px; 43 | line-height: $EXECUTE_BTN_HEIGHT; 44 | text-align: center; 45 | background: Colors.$Blue; 46 | color: Colors.$White; 47 | } 48 | } 49 | 50 | .overlay-search { 51 | flex-grow: 1; 52 | display: flex; 53 | 54 | &__input { 55 | flex-grow: 1; 56 | border: none; 57 | font-size: 16px; 58 | } 59 | 60 | &__button { 61 | padding-right: 20px; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /styles/_AlertBanner.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | @use 'zIndexes' as Indexes; 3 | 4 | .alertBannerContainer { 5 | z-index: Indexes.$Thirteen; 6 | top: 0px; 7 | width: 100%; 8 | } 9 | 10 | .alertBanner { 11 | filter: drop-shadow(3px 1px 4px Colors.$Grey); 12 | padding: 15px 15px 15px 15px; 13 | text-align: center; 14 | font-size: 15px; 15 | width: 100%; 16 | display: flex; 17 | 18 | &__parent { 19 | display: flex; 20 | } 21 | 22 | //when its a mobile device, turn it to columns. 23 | &__mobileParent { 24 | display: flex; 25 | flex-direction: column; 26 | } 27 | 28 | &__text { 29 | display: flex; 30 | flex: 5; 31 | align-items: center; 32 | justify-content: center; 33 | } 34 | 35 | &__logo { 36 | margin-right: 5px; 37 | } 38 | &__back { 39 | height: 15px; 40 | width: 15px; 41 | float: right; 42 | margin-right: 30px; 43 | } 44 | 45 | &__back:hover { 46 | cursor: pointer; 47 | } 48 | } 49 | 50 | .errorBanner { 51 | background-color: Colors.$Off_White; 52 | color: Colors.$NEU_Red; 53 | } 54 | .warningBanner { 55 | background-color: Colors.$Off_White; 56 | color: Colors.$CPS_Yellow; 57 | } 58 | .infoBanner { 59 | background-color: Colors.$Off_White; 60 | color: Colors.$Navy; 61 | display: flex; 62 | align-items: center; 63 | } 64 | -------------------------------------------------------------------------------- /styles/_EmailInput.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | /* 4 | * This file is part of Search NEU and licensed under AGPL3. 5 | * See the license file in the root folder for details. 6 | */ 7 | 8 | .enterEmailContainer { 9 | .emailTopString { 10 | margin-bottom: 20px !important; 11 | } 12 | 13 | .enterEmail { 14 | padding-bottom: 5px; 15 | color: Colors.$Grey; 16 | width: 350px; 17 | 18 | input { 19 | pointer-events: initial; 20 | } 21 | 22 | button { 23 | pointer-events: initial; 24 | color: Colors.$Grey; 25 | font-weight: 500; 26 | background: Colors.$Light_Grey; 27 | font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif; 28 | border: 1px solid Colors.$Black; 29 | border-left: 0; 30 | } 31 | } 32 | 33 | .statusContainer { 34 | width: 350px; 35 | margin-left: auto; 36 | margin-right: auto; 37 | } 38 | 39 | .emailStatus { 40 | opacity: 0; 41 | transition: opacity 0.75s ease-in-out; 42 | text-align: left; 43 | font-size: 15px !important; 44 | min-height: 25px; 45 | color: Colors.$Green; 46 | } 47 | 48 | .emailStatus.success { 49 | opacity: 1; 50 | color: Colors.$Green; 51 | } 52 | 53 | .emailStatus.error { 54 | opacity: 1; 55 | color: Colors.$NEU_Red; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /components/notifications/DisabledNotificationsModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Modal from '../Modal'; 3 | import X from '../icons/X.svg'; 4 | import CryingHusky3 from '../icons/crying-husky-3.svg'; 5 | 6 | interface DisabledNotificationsModalProps { 7 | visible: boolean; 8 | onCancel: () => void; 9 | } 10 | 11 | export default function DisabledNotificationsModal({ 12 | visible, 13 | onCancel, 14 | }: DisabledNotificationsModalProps): ReactElement { 15 | return ( 16 | 17 |
18 |
19 |
20 | 26 |
27 | 28 | Notifications have paused for the summer 29 | 30 | 31 | Due to cost issues, we{`'`}re putting notifications on pause until we acquire additional funding in the fall. 32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /styles/_NotifSignUpSwitch.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .wrapper { 4 | display: flex; 5 | align-items: center; 6 | gap: 1rem; 7 | } 8 | 9 | .label { 10 | max-width: 132px; 11 | font-size: 14px; 12 | line-height: 18px; 13 | } 14 | 15 | .switch { 16 | position: relative; 17 | display: inline-block; 18 | width: 40px; 19 | height: 16px; 20 | } 21 | 22 | .switch input { 23 | opacity: 0; 24 | width: 0; 25 | height: 0; 26 | } 27 | 28 | .slider { 29 | position: absolute; 30 | cursor: pointer; 31 | top: 0; 32 | left: 0; 33 | right: 0; 34 | bottom: 0; 35 | background-color: Colors.$Off_White; 36 | box-shadow: 0px 2px 4px 0px Colors.$Light_Grey; 37 | } 38 | 39 | .slider:before { 40 | position: absolute; 41 | content: ''; 42 | height: 24px; 43 | width: 24px; 44 | left: -4px; 45 | top: -4px; 46 | background-color: Colors.$Off_White; 47 | -webkit-transition: 0.1s; 48 | transition: 0.1s; 49 | } 50 | 51 | input:checked + .slider { 52 | background-color: Colors.$Aqua; 53 | } 54 | 55 | input:checked + .slider:before { 56 | background-color: Colors.$Aqua; 57 | -webkit-transform: translateX(24px); 58 | -ms-transform: translateX(24px); 59 | transform: translateX(24px); 60 | } 61 | 62 | .slider.round { 63 | border-radius: 34px; 64 | } 65 | 66 | .slider.round:before { 67 | border-radius: 50%; 68 | } 69 | -------------------------------------------------------------------------------- /styles/panels/_MobileSectionPanel.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | @use '../variables' as Colors; 6 | 7 | .section-container { 8 | border-top: 1px solid Colors.$Light_Grey; 9 | line-height: 1.8em; 10 | 11 | tr { 12 | border-bottom: 1px solid Colors.$Off_White; 13 | } 14 | 15 | &:nth-child(even) { 16 | background: Colors.$White; 17 | } 18 | 19 | table { 20 | width: 100%; 21 | border-collapse: collapse; 22 | } 23 | 24 | .globe { 25 | opacity: 0.6; 26 | float: right; 27 | padding: 8px; 28 | } 29 | 30 | .firstColumn { 31 | padding: 2px; 32 | padding-right: 2px; 33 | color: Colors.$Light_Grey; 34 | border-right: 1px solid Colors.$Off_White; 35 | width: 53px; 36 | } 37 | 38 | .secondColumn { 39 | padding: 5px; 40 | } 41 | 42 | .firstRow { 43 | border-top: 1px solid Colors.$Off_White; 44 | vertical-align: bottom; 45 | } 46 | 47 | .lastRow { 48 | height: 50px; 49 | vertical-align: top; 50 | border-bottom: none !important; 51 | } 52 | 53 | .mobile-section-title { 54 | font-weight: bold; 55 | color: Colors.$Light_Grey; 56 | font-size: 20px; 57 | padding-top: 20px; 58 | padding-bottom: 3px; 59 | padding-left: 4px; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/common/LastUpdated.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | import React, { ReactElement } from 'react'; 4 | import Tooltip, { TooltipDirection } from '../Tooltip'; 5 | import IconGlobe from '../icons/IconGlobe'; 6 | 7 | dayjs.extend(relativeTime); 8 | 9 | interface LastUpdatedProps { 10 | lastUpdateTime: number; 11 | iconHeight?: string; 12 | iconWidth?: string; 13 | className?: string; 14 | } 15 | 16 | export function getLastUpdateString(lastUpdateTime: number): string { 17 | return lastUpdateTime ? dayjs(lastUpdateTime).fromNow() : null; 18 | } 19 | 20 | export function LastUpdated({ 21 | lastUpdateTime, 22 | iconHeight, 23 | iconWidth, 24 | className, 25 | }: LastUpdatedProps): ReactElement { 26 | return ( 27 |
28 | 29 | 33 | {`Updated ${getLastUpdateString( 34 | lastUpdateTime 35 | )}`} 36 |
37 | ); 38 | } 39 | 40 | export function LastUpdatedMobile({ 41 | lastUpdateTime, 42 | }: LastUpdatedProps): ReactElement { 43 | return <>Updated {getLastUpdateString(lastUpdateTime)}; 44 | } 45 | -------------------------------------------------------------------------------- /styles/results/_WeekdayBoxes.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | 3 | .WeekdayBoxes { 4 | display: inline-flex; 5 | flex-direction: row; 6 | width: 126px; 7 | font-family: Lato, sans-serif; 8 | font-style: normal; 9 | font-weight: normal; 10 | font-size: 10px; 11 | line-height: 16px; 12 | text-align: center; 13 | align-items: center; 14 | 15 | &__box { 16 | display: block; 17 | width: 18px; 18 | height: 18px; 19 | background: Colors.$White; 20 | color: Colors.$Grey; 21 | border: 0.5px solid Colors.$Grey; 22 | 23 | &--checked-class { 24 | display: block; 25 | width: 18px; 26 | height: 18px; 27 | background: Colors.$Light_Blue; 28 | color: Colors.$Black; 29 | border: 0.5px solid Colors.$Grey; 30 | } 31 | 32 | &--checked-final { 33 | display: block; 34 | width: 18px; 35 | height: 18px; 36 | background: Colors.$Light_Green; 37 | color: Colors.$Black; 38 | border: 0.5px solid Colors.$Grey; 39 | } 40 | } 41 | 42 | &__box:nth-child(even) { 43 | border-left: none; 44 | border-right: none; 45 | } 46 | 47 | &__box--checked-class:nth-child(even) { 48 | border-left: none; 49 | border-right: none; 50 | } 51 | &__box--checked-final:nth-child(even) { 52 | border-left: none; 53 | border-right: none; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /components/ResultsPage/CheckboxFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import CheckboxGroup from './CheckboxGroup'; 3 | import { Option } from './filters'; 4 | 5 | interface CheckboxFilterProps { 6 | title: string; 7 | options: Option[]; 8 | selected: string[]; 9 | setActive: (a: string[]) => void; 10 | } 11 | 12 | export default function CheckboxFilter({ 13 | title, 14 | options, 15 | selected, 16 | setActive, 17 | }: CheckboxFilterProps): ReactElement { 18 | return ( 19 |
20 | {title} 21 | 26 | {(Checkbox) => ( 27 | <> 28 | {options.map((option) => ( 29 |
30 | 36 |
37 | ))} 38 | 39 | )} 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/icons/IconNotepad.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | /* eslint-disable no-restricted-syntax */ 4 | 5 | const IconNotepad = ({ 6 | width = '17', 7 | height = '24', 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | className?: string; 13 | }): ReactElement => ( 14 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | 36 | export default IconNotepad; 37 | -------------------------------------------------------------------------------- /styles/panels/_BaseClassPanel.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | @use '../variables' as Colors; 6 | 7 | .class-panel-container { 8 | padding: 0 !important; 9 | font-size: 16px !important; 10 | 11 | .header { 12 | outline: 0; 13 | border: none; 14 | background-color: Colors.$Off_White; 15 | padding: 11px; 16 | margin-top: 0; 17 | border-radius: 5px; 18 | font-size: 20px; 19 | font-weight: 700; 20 | } 21 | 22 | .panel-body { 23 | padding: 20px; 24 | 25 | // For some reason, the div causes this panel-body to have 26 | // and extra 5px as its body, when collapsed. 27 | // Offset that here. 28 | padding-bottom: 15px; 29 | } 30 | 31 | .hint-text { 32 | color: Colors.$Grey; 33 | } 34 | 35 | .more-sections-button { 36 | cursor: pointer; 37 | padding: 10px; 38 | color: Colors.$Grey; 39 | font-weight: 600; 40 | outline: 0; 41 | border: none; 42 | border-top: 1px solid Colors.$Light_Grey; 43 | } 44 | 45 | .prereq-show-more { 46 | cursor: pointer; 47 | padding: 1px; 48 | display: inline-block; 49 | outline: 0; 50 | border: none; 51 | 52 | &::after { 53 | overflow: hidden; 54 | content: '\279C'; 55 | padding-left: 3px; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /components/notifications/modal/GoSignIn.tsx: -------------------------------------------------------------------------------- 1 | import 'react-phone-number-input/style.css'; 2 | import React, { ReactElement } from 'react'; 3 | import X from '../../icons/X.svg'; 4 | import OneMoreStep from '../../icons/one-more-step.svg'; 5 | 6 | interface GoSignInProps { 7 | onCancel: () => void; 8 | onSubmit: () => void; 9 | } 10 | 11 | export default function GoSignIn({ 12 | onCancel, 13 | onSubmit, 14 | }: GoSignInProps): ReactElement { 15 | return ( 16 | <> 17 |
18 |
19 | 25 |
26 | 27 | One more step... 28 | 29 | 30 | Sign in with your phone number to be the first to know when seats open 31 | up. 32 | 33 | 34 |
35 | 42 |
43 |
44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Purpose 2 | 3 | _In 2-3 sentences - what does this code actually do, and why?_ 4 | 5 | # Tickets 6 | 7 | _What Trello tickets (if any) are associated with this PR?_ 8 | 9 | - 10 | 11 | # Contributors 12 | 13 | _Who worked on this PR? Tag them with their Github `@username` for future reference_ 14 | 15 | Use the **"Assignees"** feature in Github 16 | 17 | # Feature List 18 | 19 | _Expand on the purpose - what does this code do, and how? This should be a list of the changes, more in-depth and technical_ 20 | 21 | - 22 | - 23 | - 24 | 25 | # Pictures 26 | 27 | _If there are visual changes, show a before/after view, and add a link to the after view using the staging environment._ 28 | 29 | # Reviewers 30 | 31 | Primary reviewer: 32 | 33 | - Pick one primary reviewer 34 | - The team member with the most relevant knowledge about the code area this PR touches 35 | - **NOT** an author of the PR 36 | - If the primary reviewer is the project lead, _select two primary reviewers_ 37 | - Goal: facilitate knowledge transfer to other team members 38 | - Primary reviewers **are required to approve the PR** before it can be merged 39 | 40 | **Primary**: 41 | 42 | Use the **"Reviewers"** feature in Github 43 | 44 | Secondary reviewers: 45 | 46 | - Pick as many as appropriate — anyone who would benefit from reading the PR 47 | - Tag them using their Github usernames below 48 | 49 | **Secondary**: 50 | -------------------------------------------------------------------------------- /components/ResultsPage/useFeedbackSchedule.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Hook for enabling a component to only render at scheduled intervals 5 | * @param keyString key string to set flag in localstorage 6 | * @param timeout desired duration until component appears again in milliseconds 7 | */ 8 | export default function useFeedbackSchedule( 9 | keyString: string, 10 | timeout: number 11 | ): [boolean, () => void] { 12 | const [show, setShow] = useState(true); 13 | 14 | const setFinished = (): void => { 15 | setTimeout(() => { 16 | localStorage.setItem(keyString, 'true'); 17 | }, 2000); 18 | }; 19 | 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | useEffect(() => { 22 | if (localStorage.getItem(keyString)) { 23 | if (!localStorage.getItem(`SEEN_${keyString}`)) { 24 | const today = new Date(); 25 | localStorage.setItem(`SEEN_${keyString}`, today.toString()); 26 | } else { 27 | const current = new Date(); 28 | if ( 29 | current.getTime() >= 30 | Date.parse(localStorage.getItem(`SEEN_${keyString}`)) + timeout 31 | ) { 32 | localStorage.removeItem(`SEEN_${keyString}`); 33 | localStorage.removeItem(keyString); 34 | } 35 | } 36 | } 37 | if (localStorage.getItem(keyString)) { 38 | setShow(false); 39 | } 40 | }); 41 | 42 | return [show, setFinished]; 43 | } 44 | -------------------------------------------------------------------------------- /styles/_SearchHeader.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | @use 'zIndexes' as Indexes; 3 | .SearchHeader { 4 | width: 100%; 5 | height: 88px; 6 | left: 0; 7 | top: 0; 8 | z-index: Indexes.$Twelve; 9 | background: Colors.$White; 10 | box-shadow: 0 2px 4px Colors.$Grey; 11 | flex-direction: row; 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-around; 15 | position: fixed; 16 | } 17 | 18 | .SearchHeader_Input { 19 | margin-left: 104px; 20 | color: Colors.$Dark_Grey; 21 | transition: all 300ms ease; 22 | border: 1px solid Colors.$Grey; 23 | border-radius: 9px; 24 | z-index: Indexes.$Twelve; 25 | order: 0; 26 | width: 539px; 27 | height: 53px; 28 | } 29 | 30 | .SearchHeader_TermDropDown { 31 | height: 38px; 32 | min-width: 128px; 33 | width: 121px; 34 | box-shadow: none !important; 35 | border: none !important; 36 | pointer-events: initial; 37 | order: 1; 38 | font-family: Lato, sans-serif; 39 | font-style: normal; 40 | font-weight: normal; 41 | font-size: 22px; 42 | line-height: 26px; 43 | align-items: center; 44 | letter-spacing: 1.5px; 45 | color: Colors.$Dark_Grey !important; 46 | text-transform: uppercase; 47 | white-space: nowrap !important; 48 | display: flex !important; 49 | 50 | & i { 51 | align-content: flex-end; 52 | } 53 | } 54 | 55 | .SearchHeader_Logo { 56 | height: 30px; 57 | z-index: Indexes.$Five; 58 | cursor: pointer; 59 | order: 2; 60 | } 61 | -------------------------------------------------------------------------------- /components/notifications/SubscriptionPageModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Modal from '../Modal'; 3 | import X from '../icons/X.svg'; 4 | import Boston from '../icons/boston.svg'; 5 | 6 | interface NewSubscriptionsLimitModalProps { 7 | visible: boolean; 8 | onCancel: () => void; 9 | } 10 | 11 | export default function SubscriptionsPageModal({ 12 | visible, 13 | onCancel, 14 | }: NewSubscriptionsLimitModalProps): ReactElement { 15 | return ( 16 | 17 |
18 |
19 |
20 | 26 |
27 | 28 | Notifications Update 29 | 30 | 31 | Users can now easily subscribe or unsubscribe through the 32 | notifications page! 33 | 34 |
35 | 36 | To view previously subscribed sections, simply click the 37 | Subscriptions button next to the sign-out option. 38 | 39 |
40 |
41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /docs/setup/prereqs.md: -------------------------------------------------------------------------------- 1 | # Prerequisites/Dependencies 2 | 3 | This setup guide tries its best not to assume people have background knowledge, and provides basic setup instructions. 4 | 5 | ### Terminal 6 | 7 | To work on this project, you\'ll need a UNIX-based terminal. Mac/Linux users already have this. Windows users should install WSL, the Windows Subsystem for Linux. 8 | 9 | [WSL installation instructions](https://docs.microsoft.com/en-us/windows/wsl/install). Also make sure to [upgrade to version 2](https://docs.microsoft.com/en-us/windows/wsl/install#upgrade-version-from-wsl-1-to-wsl-2). 10 | 11 | ?> **Tip:** We recommend installing [Windows Terminal](https://docs.microsoft.com/en-us/windows/terminal/install) for a better development experience than the Command Prompt. 12 | 13 | ### Fast Node Manager (fnm) 14 | 15 | - Install [FNM](https://github.com/Schniz/fnm) - this helps manage Node versions 16 | - Don't install anything else yet - we'll do that in a bit 17 | 18 | ### Source code 19 | 20 | - Clone the repo: `git clone https://github.com/sandboxnu/searchneu` 21 | - Change into the repo directory: `cd ./searchneu` 22 | - Switch Node versions: `fnm use` 23 | - There is a file called `.nvmrc` in the repository, which tells `fnm` which version to use 24 | 25 | ### Yarn 26 | 27 | - `yarn` is our package manager of choice - we use it to manage all of the dependencies we are using for this project. 28 | - Run `npm i -g yarn` 29 | - If you ever switch Node versions, you'll have to reinstall this (see this [issue](https://github.com/Schniz/fnm/issues/109)) 30 | -------------------------------------------------------------------------------- /components/ResultsPage/CheckboxGroup.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import React from 'react'; 3 | 4 | interface ICheckboxProps { 5 | disabled?: boolean; 6 | value: any; 7 | } 8 | interface ICheckboxGroupProps { 9 | children: (Checkbox: React.FC) => JSX.Element; 10 | name: string; 11 | value: any[]; 12 | onChange: (newValue) => any; 13 | } 14 | 15 | const CheckboxGroup: React.FC = (props) => { 16 | const { children, name, value: checkedValues, onChange } = props; 17 | 18 | const onCheckboxChange = ( 19 | checkboxValue, 20 | event: React.ChangeEvent 21 | ): void => { 22 | if (event.target.checked) { 23 | onChange(checkedValues.concat(checkboxValue)); 24 | } else { 25 | onChange(checkedValues.filter((v) => v !== checkboxValue)); 26 | } 27 | }; 28 | 29 | const Checkbox: React.FC = (checkboxProps) => { 30 | const { value: cbValue, disabled, ...rest } = checkboxProps; 31 | 32 | const checked = checkedValues ? checkedValues.indexOf(cbValue) >= 0 : false; 33 | 34 | return ( 35 | 45 | ); 46 | }; 47 | 48 | return children(Checkbox); 49 | }; 50 | 51 | export default CheckboxGroup; 52 | -------------------------------------------------------------------------------- /components/ResultsPage/Results/SearchResult.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from 'react'; 2 | import IconCollapseExpand from '../../icons/IconCollapseExpand'; 3 | 4 | interface SearchResultProps { 5 | headerLeft: ReactElement; 6 | headerRight?: ReactElement; 7 | body: ReactElement; 8 | afterBody?: ReactElement; 9 | } 10 | 11 | export function SearchResult({ 12 | headerLeft, 13 | headerRight, 14 | body, 15 | afterBody, 16 | }: SearchResultProps): ReactElement { 17 | return ( 18 |
19 |
20 |
{headerLeft}
21 | {headerRight} 22 |
23 |
{body}
24 | {afterBody} 25 |
26 | ); 27 | } 28 | 29 | export function MobileSearchResult({ 30 | headerLeft, 31 | headerRight, 32 | body, 33 | afterBody, 34 | }: SearchResultProps): ReactElement { 35 | const [expanded, setExpanded] = useState(false); 36 | 37 | return ( 38 |
39 |
setExpanded(!expanded)} 48 | > 49 | {headerLeft} {headerRight} 50 |
51 | {expanded &&
{body}
} 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /styles/results/_MobileSectionPanel.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | 3 | .MobileSectionPanel { 4 | border: 1px solid Colors.$Light_Grey; 5 | box-sizing: border-box; 6 | border-radius: 20px; 7 | margin-bottom: 16px; 8 | padding: 16px; 9 | color: Colors.$Black; 10 | 11 | &__meetings { 12 | &--online { 13 | margin-top: 0; 14 | } 15 | 16 | &--time { 17 | margin-top: 16px; 18 | display: inline-block; 19 | } 20 | } 21 | 22 | &__header { 23 | display: flex; 24 | justify-content: space-between; 25 | margin-bottom: 16px; 26 | 27 | & > span:first-child { 28 | max-width: 50%; 29 | } 30 | } 31 | 32 | &__firstRow { 33 | display: flex; 34 | align-items: center; 35 | margin-bottom: 16px; 36 | justify-content: space-between; 37 | 38 | & > div:first-of-type { 39 | display: flex; 40 | align-items: center; 41 | 42 | & > a { 43 | margin-right: 8px; 44 | } 45 | 46 | & > span:first-of-type { 47 | margin-bottom: 2px; 48 | margin-right: 12px; 49 | } 50 | } 51 | } 52 | 53 | &__row { 54 | display: flex; 55 | justify-content: space-between; 56 | 57 | &:not(:last-of-type) { 58 | margin-bottom: 16px; 59 | } 60 | } 61 | &__waitlist-text { 62 | color: Colors.$Grey; 63 | white-space: nowrap; 64 | } 65 | 66 | .MobileSectionPanel__row { 67 | .green { 68 | color: Colors.$Green; 69 | } 70 | 71 | .yellow { 72 | color: Colors.$CPS_Yellow; 73 | } 74 | 75 | .red { 76 | color: Colors.$NEU_Red; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /components/icons/pillClose.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/ClassPage/ClassPageInfoHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { GetClassPageInfoQuery } from '../../generated/graphql'; 3 | import { 4 | creditsDescription, 5 | creditsNumericDisplay, 6 | } from '../common/CreditsDisplay'; 7 | import { LastUpdated } from '../common/LastUpdated'; 8 | 9 | type ClassPageInfoHeaderProps = { 10 | classPageInfo: GetClassPageInfoQuery; 11 | }; 12 | 13 | export default function ClassPageInfoHeader({ 14 | classPageInfo, 15 | }: ClassPageInfoHeaderProps): ReactElement { 16 | const { subject, name, classId, latestOccurrence } = classPageInfo.class; 17 | return ( 18 |
19 |
20 |
21 |

{`${subject.toUpperCase()}${classId}`}

22 |

{name}

23 |
24 |
25 |
26 | 32 |
33 | 34 | {creditsNumericDisplay( 35 | latestOccurrence.maxCredits, 36 | latestOccurrence.minCredits 37 | )} 38 | 39 |

40 | 41 | {creditsDescription(latestOccurrence.maxCredits)} 42 | 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/GivingDay/GivingDayModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Modal from '../Modal'; 3 | import X from '../icons/X.svg'; 4 | import DonateHusky from '../icons/donate-husky.svg'; 5 | 6 | interface GivingDayModalProps { 7 | visible: boolean; 8 | onCancel: () => void; 9 | } 10 | 11 | export default function GivingDayModal({ 12 | visible, 13 | onCancel, 14 | }: GivingDayModalProps): ReactElement { 15 | return ( 16 | 17 |
18 |
19 |
20 | 26 |
27 | 28 | It{`'`}s Giving Day! 29 | 30 | 31 | Make a donation today to Sandbox to help keep SearchNEU running! 32 | 33 | 34 | { 35 |
36 | 46 |
47 | } 48 |
49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Welcome to the SearchNEU documentation! 2 | 3 | ### What is SearchNEU? 4 | 5 | Banner — the site Northeastern uses for its course catalog — is .... less than ideal. It's tedious, requires a login, works poorly on mobile, can be difficult to search with, and has not the best UI/UX. 6 | 7 | SearchNEU, created by a student, was designed to help resolve some of these issues. It is a search engine built for easier navigation of class and professor information to help students with course registration. Users can search for and explore all class offerings within a semester, all faculty of the University, sections for each class, and other important information. Additionally, SearchNEU allows students to subscribe to notifications for a class with no remaining seats, to get notified when an opening appears in the class. All of our data is public information we scrape from Northeastern, so you can access any info with a quick search on searchneu.com. 8 | 9 | ### Tech Overview 10 | 11 | **"SearchNEU"**, as a complete application, exists in two parts: 12 | 13 | - Backend: The backend is our API server, which does all of the heavy lifting. This stores all of the course data - names, IDs, sections, descriptions, etc. It also handles notifications. The user can interact with this data using the frontend. 14 | - The backend is also used by other applications (like GraduateNU). 15 | - Frontend: The frontend is what a user sees when they go to [searchneu.com](https://searchneu.com). It does not have any data on its own - whenever a user searches for a course, the frontend sends a request to the backend, which returns the data. The frontend handles display; the backend handles data processing. 16 | 17 | This is the documentation for the **frontend**. 18 | -------------------------------------------------------------------------------- /utils/QueryParamProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import React, { memo, ReactElement, useMemo } from 'react'; 3 | import { QueryParamProvider as ContextProvider } from 'use-query-params'; 4 | 5 | export const QueryParamProviderComponent = (props: { 6 | children?: React.ReactNode; 7 | }): ReactElement => { 8 | const { children, ...rest } = props; 9 | const router = useRouter(); 10 | const match = router.asPath.match(/[^?]+/); 11 | const pathname = match ? match[0] : router.asPath; 12 | 13 | const location = useMemo( 14 | () => 15 | process.browser 16 | ? window.location 17 | : ({ 18 | search: router.asPath.replace(/[^?]+/u, ''), 19 | } as Location), 20 | [router.asPath] 21 | ); 22 | 23 | const history = useMemo( 24 | () => ({ 25 | push: ({ search }: Location): Promise => 26 | router.push( 27 | { pathname: router.pathname, query: router.query }, 28 | { search, pathname }, 29 | { shallow: true } 30 | ), 31 | replace: ({ search }: Location): Promise => 32 | router.replace( 33 | { pathname: router.pathname, query: router.query }, 34 | { search, pathname }, 35 | { shallow: true } 36 | ), 37 | }), 38 | // yeah we need this since we don't no want reference equality 39 | // eslint-disable-next-line react-hooks/exhaustive-deps 40 | [pathname, router.pathname, router.query, location.pathname] 41 | ); 42 | 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | }; 49 | 50 | export const QueryParamProvider = memo(QueryParamProviderComponent); 51 | -------------------------------------------------------------------------------- /styles/pages/_Down.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | @use '../zIndexes' as Indexes; 3 | @import url('https://fonts.googleapis.com/css2?family=Montserrat+Alternates:wght@700&display=swap'); 4 | 5 | /* Styles for Down Page. */ 6 | .cryingHusky { 7 | width: 75% !important; 8 | height: 45% !important; 9 | position: absolute; 10 | right: -260px; 11 | bottom: 0px; 12 | z-index: Indexes.$Eight; 13 | } 14 | 15 | .huskyDollar { 16 | width: 75% !important; 17 | height: 45% !important; 18 | position: absolute; 19 | right: -90px; 20 | bottom: -150px; 21 | z-index: Indexes.$Eight; 22 | } 23 | 24 | .down-text-container { 25 | text-align: left; 26 | margin-left: 50px; 27 | margin-right: 50px; 28 | 29 | @media only screen and (min-width: 830px) { 30 | margin-left: 75px; 31 | margin-bottom: 200px; 32 | } 33 | } 34 | 35 | .down-title-text { 36 | font-family: Montserrat Alternates; 37 | color: Colors.$NEU_Red; 38 | font-size: 32px; 39 | font-weight: 800; 40 | margin-bottom: 20px; 41 | 42 | @media only screen and (min-width: 830px) { 43 | font-size: 55px; 44 | margin-bottom: 30px; 45 | } 46 | } 47 | 48 | .down-sub-title-text { 49 | font-family: Lato; 50 | font-size: 20px; 51 | font-weight: 600; 52 | color: Colors.$Navy; 53 | margin-bottom: 20px; 54 | margin-right: 20px; 55 | 56 | @media only screen and (min-width: 830px) { 57 | font-size: 25px; 58 | margin-bottom: 40px; 59 | } 60 | } 61 | 62 | .down-text { 63 | font-family: Lato; 64 | font-size: 16px; 65 | font-weight: 400; 66 | color: Colors.$Navy; 67 | width: 85%; 68 | white-space: pre-line; 69 | 70 | @media only screen and (min-width: 830px) { 71 | height: 112px; 72 | width: 45%; 73 | margin-right: 20px; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /components/tests/pages/__snapshots__/Home.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render a section 1`] = ` 4 | " 5 |
6 |
7 | 8 |
9 |
10 | 11 | 12 | Search NEU - 13 | NEU 14 | 15 | 16 | 17 | 18 | \\"sandbox 19 | 20 |
21 | 22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 |
41 |
42 |
" 43 | `; 44 | -------------------------------------------------------------------------------- /utils/useUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { UserInfo } from '../components/types'; 3 | import Cookies from 'universal-cookie'; 4 | import axios from 'axios'; 5 | 6 | type useUserInfoReturn = { 7 | userInfo: UserInfo | null; 8 | isUserInfoLoading: boolean; 9 | fetchUserInfo: () => void; 10 | onSignOut: () => void; 11 | onSignIn: (token: string) => void; 12 | }; 13 | 14 | // Custom hook to maintain all userInfo related utility functions 15 | const useUserInfo = (): useUserInfoReturn => { 16 | const cookies = new Cookies(); 17 | const [userInfo, setUserInfo] = useState(null); 18 | const [isUserInfoLoading, setIsUserInfoLoading] = useState(true); 19 | 20 | const onSignOut = (): void => { 21 | cookies.remove('SearchNEU JWT', { path: '/' }); 22 | setUserInfo(null); 23 | }; 24 | 25 | const onSignIn = (token: string): void => { 26 | cookies.set('SearchNEU JWT', token, { path: '/' }); 27 | fetchUserInfo(); 28 | }; 29 | 30 | const fetchUserInfo = async (): Promise => { 31 | const token = cookies.get('SearchNEU JWT'); 32 | if (token) { 33 | await axios 34 | .get( 35 | `${process.env.NEXT_PUBLIC_NOTIFS_ENDPOINT}/user/subscriptions/${token}` 36 | ) 37 | .then(({ data }) => { 38 | setUserInfo({ token, ...data }); 39 | }) 40 | .catch((e) => { 41 | console.log(e); 42 | }); 43 | } 44 | }; 45 | 46 | useEffect(() => { 47 | const fetchData = async (): Promise => { 48 | await fetchUserInfo(); 49 | setIsUserInfoLoading(false); 50 | }; 51 | fetchData(); 52 | }, []); 53 | 54 | return { userInfo, isUserInfoLoading, fetchUserInfo, onSignOut, onSignIn }; 55 | }; 56 | 57 | export default useUserInfo; 58 | -------------------------------------------------------------------------------- /docs/styling-css/styling.md: -------------------------------------------------------------------------------- 1 | # Styling 2 | 3 | SearchNEU is powered by Sass. Sass is a superscript of CSS that provides some really nice features that help us with keeping our styles consistent and distinct. 4 | 5 | If you're unfamiliar with Sass, I'd highly recommend checking out their [getting started page][sass-getting-started]. 6 | 7 | ## Layout 8 | 9 | All our styling files are in `/styles`, and are parallel to the content in `/components`. Partials, `.scss` files whose first character is an underscore `_`, should never affect styles outside that specific file. To resolve this, we must namespace and ecapsulate our styles. Practically, this means that every partial should have one root style, and all styling is nested within that style. 10 | 11 | ## Exceptions 12 | 13 | There are three exceptions to the above layout, `css/base.scss`, `_variables.scss`, and `_zIndexes.css`. The variables partial keeps track of all our common themes and colors. This file is imported first, so all following partials can use any variables. 14 | 15 | `base.scss` itself has two purposes, 1) to import any partial styles, and 2) normalizing styles. This file should not contain anything else. 16 | 17 | ## Z-Indexes 18 | 19 | In addition to utilizing variables for themes and colors, we use variables for our Z-indexes as well. Located in `/styles` in the `_zIndexes.scss` partial, our Z-index levels range from 1-15 and should be used instead of raw index numbers. 20 | 21 | ## Adding new styles 22 | 23 | Adding a new style file is easy. First, create a Sass file that reflects the file path relative to the `components` folder. Make sure it's a partial (`_.scss`). Next, import it in `base.scss`. Finally, make sure you namespace that file. We do not like having leaking styles! :c 24 | 25 | [sass-getting-started]: http://sass-lang.com/guide 26 | -------------------------------------------------------------------------------- /components/icons/NavArrow.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Colors from '../../styles/_exports.module.scss'; 3 | 4 | export const LeftNavArrow = ({ 5 | width = '9', 6 | height = '20', 7 | fill = Colors.black, 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | fill?: string; 13 | className?: string; 14 | }): ReactElement => ( 15 | 23 | 27 | 28 | ); 29 | 30 | export const RightNavArrow = ({ 31 | width = '9', 32 | height = '20', 33 | fill = Colors.black, 34 | className, 35 | }: { 36 | width?: string; 37 | height?: string; 38 | fill?: string; 39 | className?: string; 40 | }): ReactElement => ( 41 | 49 | 53 | 54 | ); 55 | -------------------------------------------------------------------------------- /components/Testimonial/TestimonialModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Modal from '../Modal'; 3 | import X from '../icons/X.svg'; 4 | import CryingHusky3 from '../icons/crying-husky-3.svg'; 5 | 6 | interface TestimonialModalProps { 7 | visible: boolean; 8 | onCancel: () => void; 9 | } 10 | 11 | export default function TestimonialModal({ 12 | visible, 13 | onCancel, 14 | }: TestimonialModalProps): ReactElement { 15 | return ( 16 | 17 |
18 |
19 |
20 | 26 |
27 | 28 | We need your help! 29 | 30 | 31 | Share your testimonial about how we{`'`}ve helped you. Your feedback 32 | is valuable to us! 33 | 34 | 35 |
36 | 49 |
50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/notifications/SignUpForSectionNotifications.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | 6 | import React, { ReactElement } from 'react'; 7 | import { UserInfo } from '../types'; 8 | import CourseCheckBox from '../panels/CourseCheckBox'; 9 | import { Course } from '../types'; 10 | 11 | type SignUpForSectionNotificationsProps = { 12 | course: Course; 13 | userInfo: UserInfo; 14 | showNotificationSignup: boolean; 15 | fetchUserInfo: () => void; 16 | onSignIn: (token: string) => void; 17 | }; 18 | 19 | export default function SignUpForSectionNotifications({ 20 | course, 21 | userInfo, 22 | showNotificationSignup, 23 | fetchUserInfo, 24 | onSignIn, 25 | }: SignUpForSectionNotificationsProps): ReactElement { 26 | const numOpenSections = course.sections.reduce((prev, cur) => { 27 | if (cur.seatsRemaining > 0) { 28 | return prev + 1; 29 | } 30 | return prev; 31 | }, 0); 32 | 33 | const openSectionsText = 34 | numOpenSections === 1 35 | ? 'There is 1 section with seats left.' 36 | : `There are ${numOpenSections} sections with seats left.`; 37 | 38 | return showNotificationSignup ? ( 39 | userInfo ? ( 40 |
41 | 42 | Notify me when new sections are added: 43 | 44 | 50 |
51 | ) : ( 52 | // Need to replace this once mobile notifs are finalized 53 | <>Sign in for new section notifications. 54 | ) 55 | ) : ( 56 |
57 | {openSectionsText} 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /styles/results/_NotifCheckBox.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | 3 | .notif-switch-checkbox { 4 | height: 0; 5 | width: 0; 6 | visibility: hidden; 7 | } 8 | 9 | .notif-switch-label { 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | cursor: pointer; 14 | width: 48px; 15 | height: 16px; 16 | background: Colors.$Off_White; 17 | border-radius: 20px; 18 | transition: background-color 0.2s ease-in-out; 19 | margin-left: 12px; 20 | 21 | .notif-switch-button { 22 | content: ''; 23 | position: relative; 24 | width: 24px; 25 | height: 24px; 26 | border-radius: 24px; 27 | transition: transform 0.04s ease-out; 28 | background: Colors.$Off_White; 29 | box-shadow: 0 0 2px 0 Colors.$Light_Grey; 30 | } 31 | } 32 | 33 | .notif-switch-checkbox:checked + .notif-switch-label { 34 | background: Colors.$Aqua; 35 | 36 | .notif-switch-button { 37 | background: Colors.$Aqua; 38 | transform: translateX(24px); 39 | } 40 | } 41 | 42 | .notifSwitch { 43 | display: flex; 44 | } 45 | 46 | .notifSubscribeButton { 47 | background: Colors.$Blue; 48 | border-radius: 4px; 49 | display: flex; 50 | align-items: center; 51 | padding: 4px 8px 4px 8px; 52 | cursor: pointer; 53 | 54 | & > span { 55 | font-size: 14px; 56 | line-height: 17px; 57 | color: Colors.$White; 58 | } 59 | 60 | &:focus, 61 | &:active { 62 | outline: none; 63 | } 64 | } 65 | 66 | .notifSubscribeButton--checked { 67 | background: Colors.$Green; 68 | border-radius: 4px; 69 | display: flex; 70 | align-items: center; 71 | padding: 4px 8px 4px 8px; 72 | cursor: pointer; 73 | 74 | & > svg { 75 | margin-right: 4px; 76 | } 77 | 78 | & > span { 79 | font-size: 14px; 80 | line-height: 17px; 81 | color: Colors.$White; 82 | } 83 | 84 | &:focus, 85 | &:active { 86 | outline: none; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pages/down.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Boston from '../components/icons/boston.svg'; 3 | import CryingHusky from '../components/icons/crying-husky.svg'; 4 | import HuskyDollar from '../components/icons/husky-dollar.svg'; 5 | 6 | /** 7 | * Page to indicate the site is down due to AWS migration. During the migration, all "/[campus]/*" 8 | * paths are temporally redirected to this page. 9 | */ 10 | export default function DownPage(): ReactElement { 11 | const containerClassnames = 'home-container'; 12 | const text = 13 | 'SearchNEU relies on AWS credits to maintain our infrastructure. ' + 14 | "Unfortunately, we've run out of credits on our current account. " + 15 | "Due to how AWS distributes new credits to our organization, we'll have " + 16 | 'to migrate all of our infrastructure to a new AWS account. ' + 17 | 'During the migration process, our service will be unavailable.\n ' + 18 | 'Thanks for your patience!'; 19 | 20 | return ( 21 |
22 |
23 |
26 |
27 |
Ran out of Husky Dollars...
28 |
29 | Don’t worry! We should be back in a few hours. 30 |
31 |
{text}
32 |
33 | 34 | 35 | 36 |
37 | 38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /components/ClassPage/SectionsTermNav.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { GetClassPageInfoQuery } from '../../generated/graphql'; 3 | import { getSeason, getYear } from '../terms'; 4 | import { LeftNavArrow, RightNavArrow } from '../icons/NavArrow'; 5 | import Colors from '../../styles/_exports.module.scss'; 6 | 7 | type sectionsTermNavProps = { 8 | currTermIndex: number; 9 | setCurrTermIndex: (number) => void; 10 | classPageInfo: GetClassPageInfoQuery; 11 | }; 12 | 13 | export default function SectionsTermNav({ 14 | currTermIndex, 15 | setCurrTermIndex, 16 | classPageInfo, 17 | }: sectionsTermNavProps): ReactElement { 18 | const allOccurrences = classPageInfo.class.allOccurrences; 19 | const currTermId = allOccurrences[currTermIndex].termId; 20 | const leftNavDisabled = (termIndex): boolean => 21 | termIndex === allOccurrences.length - 1; 22 | const rightNavDisabled = (termIndex): boolean => termIndex === 0; 23 | return ( 24 |
25 | 27 | setCurrTermIndex( 28 | Math.min(currTermIndex + 1, allOccurrences.length - 1) 29 | ) 30 | } 31 | className={`navArrow ${ 32 | leftNavDisabled(currTermIndex) ? 'disabled' : '' 33 | }`} 34 | > 35 | 38 | 39 | {`${getSeason(`${currTermId}`)} ${getYear( 40 | `${currTermId}` 41 | )}`.toUpperCase()} 42 | setCurrTermIndex(Math.max(currTermIndex - 1, 0))} 44 | className={`navArrow ${ 45 | rightNavDisabled(currTermIndex) ? 'disabled' : '' 46 | }`} 47 | > 48 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/icons/IconClose.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Colors from '../../styles/_exports.module.scss'; 3 | 4 | const IconClose = ({ 5 | fill = Colors.white, 6 | }: { 7 | fill?: string; 8 | }): ReactElement => ( 9 | 10 | 11 | 12 | ); 13 | 14 | export default IconClose; 15 | -------------------------------------------------------------------------------- /components/ResultsPage/EmptyResultsContainer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | import React, { ReactElement } from 'react'; 6 | import macros from '../macros'; 7 | import { DEFAULT_FILTER_SELECTION, FilterSelection } from './filters'; 8 | 9 | interface EmptyResultsProps { 10 | query: string; 11 | filtersAreSet: boolean; 12 | setFilters: (f: FilterSelection) => void; 13 | } 14 | 15 | /** 16 | * Empty page that signifies to user no results were found. If filters are applied, suggests clearing them. 17 | * If no filters are applied, suggests search on Google. 18 | */ 19 | 20 | export default function EmptyResultsContainer({ 21 | query, 22 | filtersAreSet, 23 | setFilters, 24 | }: EmptyResultsProps): ReactElement { 25 | return ( 26 |
27 |

No Results Found

{' '} 28 | {filtersAreSet ? ( 29 |
30 | {' '} 31 | Try 32 |
setFilters(DEFAULT_FILTER_SELECTION)} 37 | > 38 | clearing 39 |
40 | some filters to expand your search! 41 |
42 | ) : ( 43 | 58 | )} 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 12 | 16 | 20 | 21 | 22 |
23 | 24 | 25 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /components/ResultsPage/RangeFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useState } from 'react'; 2 | import { ClassRange } from './filters'; 3 | import Slider from 'rc-slider'; 4 | import 'rc-slider/assets/index.css'; 5 | 6 | interface RangeFilterProps { 7 | title: string; 8 | selected: ClassRange; 9 | setActive: (a: ClassRange) => void; 10 | } 11 | 12 | export default function RangeFilter({ 13 | title, 14 | selected, 15 | setActive, 16 | }: RangeFilterProps): ReactElement { 17 | const [controlledInput, setControlledInput] = useState(selected); 18 | 19 | const courseIDs = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000]; 20 | const marks = {}; 21 | courseIDs.forEach((id) => { 22 | marks[id] = id.toString(); 23 | }); 24 | 25 | useEffect(() => { 26 | setControlledInput(selected); 27 | }, [selected]); 28 | 29 | return ( 30 |
31 |
32 |

{title}

33 |
34 |
37 | setActive({ 38 | min: controlledInput.min || courseIDs[0], 39 | max: controlledInput.max || courseIDs[courseIDs.length - 1], 40 | }) 41 | } 42 | > 43 | { 52 | setControlledInput({ 53 | min: event[0], 54 | max: event[1], 55 | }); 56 | }} 57 | className="RangeFilter__slider" 58 | value={[ 59 | controlledInput.min || courseIDs[0], 60 | controlledInput.max || courseIDs[courseIDs.length - 1], 61 | ]} 62 | /> 63 |

64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /components/common/AlertBanner.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from 'react'; 2 | import macros from '../macros'; 3 | import IconClose from '../icons/IconClose'; 4 | import Colors from '../../styles/_exports.module.scss'; 5 | import GraduateLogo from '../icons/GraduateLogo'; 6 | 7 | type AlertLevel = 'error' | 'warning' | 'info'; 8 | 9 | export type AlertBannerData = { 10 | text: string; 11 | alertLevel: AlertLevel; 12 | link?: string; 13 | linkText?: string; 14 | logo?: () => ReactElement; 15 | }; 16 | 17 | type AlertBannerProps = { 18 | alertBannerData: AlertBannerData; 19 | }; 20 | 21 | export default function AlertBanner({ alertBannerData }: AlertBannerProps) { 22 | const [isVisible, setIsVisible] = useState(true); 23 | 24 | return ( 25 | isVisible && ( 26 | //if on mobile, it'll put the text and button into columns. 27 |
32 |
33 |
34 | {alertBannerData.logo && ( 35 | 36 | 37 | 38 | )} 39 | 40 | {alertBannerData.text} 41 | {alertBannerData.link && ( 42 | 43 | {' '} 44 | {alertBannerData.linkText 45 | ? alertBannerData.linkText 46 | : 'Learn More'} 47 | 48 | )} 49 | 50 |
51 |
setIsVisible(false)} 56 | > 57 | 58 |
59 |
60 |
61 | ) 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /components/ResultsPage/CampusSelection.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useCallback } from 'react'; 2 | 3 | import { useRouter } from 'next/router'; 4 | import { termAndCampusToURL } from '../Header'; 5 | import { TermInfo, getRoundedTerm } from '../terms'; 6 | import { Campus } from '../types'; 7 | 8 | interface CampusSelectionProps { 9 | query: string; 10 | termId: string; 11 | termInfos: Record; 12 | campus: string; 13 | } 14 | function CampusSelection({ 15 | query, 16 | termId, 17 | termInfos, 18 | campus, 19 | }: CampusSelectionProps): ReactElement { 20 | const router = useRouter(); 21 | 22 | const termAndCampusToURLCallback = useCallback( 23 | (t: string, newCampus: string) => { 24 | return termAndCampusToURL(t, newCampus, query); 25 | }, 26 | [query] 27 | ); 28 | 29 | return ( 30 |
31 | 44 | 57 | 70 |
71 | ); 72 | } 73 | 74 | export default React.memo(CampusSelection); 75 | -------------------------------------------------------------------------------- /components/ResultsPage/FilterPanel.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import React, { ReactElement } from 'react'; 3 | import CheckboxFilter from './CheckboxFilter'; 4 | import DropdownFilter from './DropdownFilter'; 5 | import { FilterOptions, FilterSelection, FILTERS_IN_ORDER } from './filters'; 6 | import RangeFilter from './RangeFilter'; 7 | import ToggleFilter from './ToggleFilter'; 8 | 9 | export interface FilterPanelProps { 10 | options: FilterOptions; 11 | selected: FilterSelection; 12 | setActive: (f: FilterSelection) => void; 13 | } 14 | 15 | function FilterPanel({ 16 | options, 17 | selected, 18 | setActive, 19 | }: FilterPanelProps): ReactElement { 20 | return ( 21 |
22 | {FILTERS_IN_ORDER.map(({ key, display, category }, index) => { 23 | const aFilter = selected[key]; 24 | const setActiveFilter = (a): void => setActive({ [key]: a }); 25 | return ( 26 | 27 | {category === 'Toggle' && ( 28 | 33 | )} 34 | {category === 'Dropdown' && ( 35 | 41 | )} 42 | {category === 'Checkboxes' && ( 43 | 49 | )} 50 | {category === 'Range' && ( 51 | 56 | )} 57 | 58 | ); 59 | })} 60 |
61 | ); 62 | } 63 | 64 | export default React.memo(FilterPanel, _.isEqual); 65 | -------------------------------------------------------------------------------- /components/icons/boston.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/icons/IconScale.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | /* eslint-disable no-restricted-syntax */ 4 | 5 | const IconScale = ({ 6 | width = '24', 7 | height = '24', 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | className?: string; 13 | }): ReactElement => ( 14 | 22 | 38 | 39 | ); 40 | 41 | export default IconScale; 42 | -------------------------------------------------------------------------------- /styles/_CheckboxFilter.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .CheckboxFilter { 4 | display: flex; 5 | flex-direction: column; 6 | 7 | &__title { 8 | font-family: Lato, sans-serif; 9 | font-size: 16px; 10 | font-style: normal; 11 | font-weight: 900; 12 | margin-bottom: 12px; 13 | margin-top: 20px; 14 | } 15 | 16 | &__element { 17 | position: relative; 18 | display: flex; 19 | flex-direction: row; 20 | align-items: center; 21 | margin-left: 2px; 22 | margin-bottom: 5px; 23 | } 24 | 25 | &__text { 26 | padding-left: 28px; 27 | padding-top: 2px; 28 | padding-bottom: 2px; 29 | flex-grow: 1; 30 | cursor: pointer; 31 | border-radius: 3px; 32 | 33 | &:hover { 34 | background-color: Colors.$White; 35 | } 36 | } 37 | 38 | &__count { 39 | float: right; 40 | color: Colors.$Grey; 41 | } 42 | 43 | &__checkbox { 44 | display: block; 45 | position: absolute; 46 | top: 5px; 47 | left: 0; 48 | height: 16px; 49 | width: 16px; 50 | background-color: transparent; 51 | border-radius: 5px; 52 | border: 1px solid Colors.$Grey; 53 | 54 | &:hover { 55 | cursor: pointer; 56 | } 57 | 58 | &::after { 59 | /* Styles for the check */ 60 | content: ''; 61 | position: absolute; 62 | display: none; 63 | left: 4px; 64 | top: 1px; 65 | width: 6px; 66 | height: 9px; 67 | border: solid Colors.$White; 68 | border-radius: 1px; 69 | border-width: 0 2px 2px 0; 70 | -webkit-transform: rotate(40deg); 71 | -ms-transform: rotate(40deg); 72 | transform: rotate(40deg); 73 | } 74 | } 75 | 76 | &__text > input { 77 | position: absolute; 78 | opacity: 0; 79 | cursor: pointer; 80 | height: 16px; 81 | width: 16px; 82 | } 83 | 84 | &__text > input:checked + .CheckboxFilter__checkbox { 85 | background-color: Colors.$NEU_Red; 86 | border-color: Colors.$NEU_Red; 87 | } 88 | 89 | &__text:hover > input:not(:checked) + .CheckboxFilter__checkbox { 90 | background-color: Colors.$White; 91 | } 92 | 93 | &__text > input:checked + .CheckboxFilter__checkbox::after { 94 | display: block; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /components/notifications/modal/PhoneNumber.tsx: -------------------------------------------------------------------------------- 1 | import 'react-phone-number-input/style.css'; 2 | import PhoneInput from 'react-phone-number-input'; 3 | import React, { ReactElement } from 'react'; 4 | import X from '../../icons/X.svg'; 5 | 6 | interface PhoneNumberProps { 7 | setPhoneNumber: React.Dispatch>; 8 | onCancel: () => void; 9 | onSubmit: () => void; 10 | error?: string; 11 | } 12 | 13 | export default function PhoneNumber({ 14 | setPhoneNumber, 15 | onCancel, 16 | onSubmit, 17 | error, 18 | }: PhoneNumberProps): ReactElement { 19 | const validatePhoneNumber = (number: string | undefined): void => { 20 | if (typeof number === 'string') setPhoneNumber(number); 21 | }; 22 | 23 | return ( 24 | <> 25 |
26 |
27 | 33 |
34 | Sign in for notifications 35 | 36 | 37 | Your phone number will be used for class notifications and nothing 38 | else. 39 | 40 | 41 | 47 | {error && {error}} 48 |
49 | 61 | 64 |
65 |
66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /components/icons/IconTie.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | /* eslint-disable no-restricted-syntax */ 4 | 5 | const IconTie = ({ 6 | width = '24', 7 | height = '21', 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | className?: string; 13 | }): ReactElement => ( 14 | 22 | 32 | 33 | 37 | 41 | 45 | 49 | 53 | 54 | ); 55 | 56 | export default IconTie; 57 | -------------------------------------------------------------------------------- /docs/setup/development.md: -------------------------------------------------------------------------------- 1 | ## Front-end only 2 | 3 | If the GraphQL endpoint exposed by production `course-catalog-api` supports the query you want to make, you just have to: 4 | 5 | 1. Create a `.graphql` file somewhere in the project (it can be anywhere but we'd suggest the in `pages/api`) and write your GraphQL query (or edit an existing `.graphql` file to modify an existing query). 6 | 2. Run `yarn generate:graphql` to auto-generate types -- this will hit the production endpoint `https://api.searchneu.com` 7 | - running `yarn dev` also works since it runs the `generate:graphql` command 8 | 3. In your code, you can use `gqlClient` from `utils/courseAPIClient` to make the GraphQL query by doing something like 9 | `await gqlClient.whateverYouNamedYourQuery()` 10 | 4. Start up SearchNEU locally by running `yarn dev` 11 | 12 | ## Full-stack 13 | 14 | If the GraphQL endpoint exposed by production `course-catalog-api` does NOT support the query you want to make, you have to: 15 | 16 | 1. Make changes to your local version of `course-catalog-api` so it supports your query 17 | 2. Start up `course-catalog-api` locally (it should run at `localhost:4000`) 18 | 3. Steps 1 and 3 from the front-end only flow are the same 19 | 4. Run `yarn generate:graphql:fullstack` to auto-generate types -- this will hit your local GraphQL endpoint which is `http://localhost:4000`. If it's not up, you'll get an error. 20 | - running `yarn dev:fullstack` also works since it will run `generate:graphql` with the local GraphQL endpoint 21 | 5. Start up SearchNEU locally by running `yarn dev:fullstack` 22 | 23 | # Why does it work this way? 24 | 25 | The GraphQL endpoint is currently used in 2 places in the code: 26 | 27 | - In `codegen.yml` so when we run `yarn generate:graphql`, the codegen knows what endpoint to hit to generate types 28 | - In `courseAPIClient.ts` so the `gqlClient` used in other places of our code knows what endpoint to hit when making GraphQL queries 29 | 30 | This endpoint is an environment variable that we set in `.env.development`. By default, we have it pointing to our production endpoint `https://api.searchneu.com`. We can override this endpoint with `http://localhost:4000` by setting `NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000` when we run commands like `yarn dev` and `yarn generate:graphql`. In fact, this is exactly what `yarn dev:fullstack` and `yarn generate:graphql:fullstack` do! 31 | -------------------------------------------------------------------------------- /components/ClassPage/PageContent.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import React, { ReactElement } from 'react'; 3 | import { GetClassPageInfoQuery } from '../../generated/graphql'; 4 | import ClassPageInfoBody from './ClassPageInfoBody'; 5 | import ClassPageInfoHeader from './ClassPageInfoHeader'; 6 | import ClassPageReqsBody from './ClassPageReqsBody'; 7 | import ClassPageSections from './ClassPageSections'; 8 | 9 | type PageContentProps = { 10 | termId: string; 11 | campus: string; 12 | subject: string; 13 | classId: string; 14 | classPageInfo: GetClassPageInfoQuery; 15 | isCoreq: boolean; 16 | }; 17 | 18 | export default function PageContent({ 19 | termId, 20 | campus, 21 | subject, 22 | classId, 23 | classPageInfo, 24 | isCoreq, 25 | }: PageContentProps): ReactElement { 26 | const router = useRouter(); 27 | 28 | // TODO: hacky front-end solution because for some reason allOccurrences includes 29 | // termIds where there are no sections. This should probably be fixed on the backend. 30 | if (classPageInfo && classPageInfo.class) { 31 | classPageInfo.class.allOccurrences = classPageInfo.class.allOccurrences.filter( 32 | (occurrence) => occurrence.sections.length > 0 33 | ); 34 | } 35 | return ( 36 |
37 | {isCoreq ? ( 38 |

39 | COREQUISITES for 40 | {` ${subject}${classId}`} 41 |

42 | ) : ( 43 |
router.back()}> 44 | Back to Search Results 45 |
46 | )} 47 | {classPageInfo && classPageInfo.class && ( 48 |
49 | 50 |
51 | 52 |
53 | 58 |
59 | 60 |
61 |
62 | )} 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/panels/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /pages/api/searchResult.graphql: -------------------------------------------------------------------------------- 1 | query searchResults( 2 | $termId: String! 3 | $query: String 4 | $offset: Int = 0 5 | $first: Int = 10 6 | $subject: [String!] 7 | $nupath: [String!] 8 | $honors: Boolean 9 | $campus: [String!] 10 | $classType: [String!] 11 | $classIdRange: IntRange 12 | ) { 13 | search( 14 | termId: $termId 15 | query: $query 16 | offset: $offset 17 | first: $first 18 | subject: $subject 19 | nupath: $nupath 20 | honors: $honors 21 | campus: $campus 22 | classType: $classType 23 | classIdRange: $classIdRange 24 | ) { 25 | pageInfo { 26 | hasNextPage 27 | } 28 | filterOptions { 29 | nupath { 30 | value 31 | count 32 | description 33 | } 34 | subject { 35 | value 36 | count 37 | description 38 | } 39 | classType { 40 | value 41 | count 42 | description 43 | } 44 | campus { 45 | value 46 | count 47 | description 48 | } 49 | honors { 50 | value 51 | count 52 | description 53 | } 54 | } 55 | nodes { 56 | type: __typename 57 | ... on Employee { 58 | email 59 | firstName 60 | lastName 61 | name 62 | officeRoom 63 | phone 64 | primaryDepartment 65 | primaryRole 66 | } 67 | ... on ClassOccurrence { 68 | name 69 | subject 70 | classId 71 | termId 72 | host 73 | desc 74 | nupath 75 | prereqs 76 | coreqs 77 | prereqsFor 78 | optPrereqsFor 79 | maxCredits 80 | minCredits 81 | classAttributes 82 | url 83 | prettyUrl 84 | lastUpdateTime 85 | feeAmount 86 | feeDescription 87 | sections { 88 | campus 89 | classId 90 | classType 91 | crn 92 | honors 93 | host 94 | lastUpdateTime 95 | meetings 96 | profs 97 | seatsCapacity 98 | seatsRemaining 99 | subject 100 | termId 101 | url 102 | waitCapacity 103 | waitRemaining 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "jest": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "prettier/@typescript-eslint", 14 | "prettier/react", 15 | "prettier/prettier", 16 | "plugin:react-hooks/recommended" 17 | ], 18 | "parser": "@typescript-eslint/parser", 19 | // "parserOptions": { 20 | // "project": "./tsconfig.eslint.json" 21 | // }, 22 | "plugins": ["@typescript-eslint"], 23 | "rules": { 24 | "react/react-in-jsx-scope": "off", 25 | "@typescript-eslint/explicit-function-return-type": "off", 26 | "@typescript-eslint/explicit-module-boundary-types": "off", 27 | "no-restricted-syntax": [ 28 | "error", 29 | { 30 | "selector": "Literal[value=/^#[a-zA-Z0-9]/i]", 31 | "message": "This project uses color variables for our styling to keep everything consistent with a single, unifying pallette; if the feature you're working on is using colors, please import Colors and use those variables instead! (_variables.scss for color variables in scss, _exports.module.scss for color variables in .tsx)" 32 | }, 33 | { 34 | "selector": "Literal[value=/^rgb[(]/i]", 35 | "message": "This project uses color variables for our styling to keep everything consistent with a single, unifying pallette; if the feature you're working on is using colors, please import Colors and use those variables instead! (_variables.scss for color variables in scss, _exports.module.scss for color variables in .tsx)" 36 | }, 37 | { 38 | "selector": "Literal[value=/^rgba[(]/i]", 39 | "message": "This project uses color variables for our styling to keep everything consistent with a single, unifying pallette; if the feature you're working on is using colors, please import Colors and use those variables instead! (_variables.scss for color variables in scss, _exports.module.scss for color variables in .tsx)" 40 | } 41 | ] 42 | }, 43 | "overrides": [ 44 | { 45 | "files": ["*.ts", "*.tsx"], 46 | "rules": { 47 | "@typescript-eslint/explicit-function-return-type": [ 48 | "warn", 49 | { 50 | "allowExpressions": true 51 | } 52 | ], 53 | "@typescript-eslint/explicit-module-boundary-types": "off" 54 | } 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /components/ResultsPage/SemesterDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useRef, useState } from 'react'; 2 | import useClickOutside from './useClickOutside'; 3 | import DropdownArrow from '../icons/DropdownArrow.svg'; 4 | import { ItemProps } from 'semantic-ui-react'; 5 | import { useRouter } from 'next/router'; 6 | 7 | interface SemesterDropdownProps { 8 | title: string; 9 | options: ItemProps[]; 10 | value: string; 11 | } 12 | function SemesterDropdown({ 13 | title, 14 | options, 15 | value: currentValue, 16 | }: SemesterDropdownProps): ReactElement { 17 | const currentOption = options.find((o) => o.value == currentValue); 18 | const currentText = currentOption ? currentOption.text : ''; 19 | const router = useRouter(); 20 | 21 | const [isOpen, setIsOpen] = useState(false); 22 | 23 | const dropdown = useRef(null); 24 | 25 | useClickOutside(dropdown, isOpen, setIsOpen); 26 | 27 | function getDropdownStatus(): string { 28 | if (isOpen) { 29 | return 'expanded'; 30 | } 31 | return ''; 32 | } 33 | 34 | return ( 35 |
36 |
{title}
37 |
setIsOpen(!isOpen)} 43 | > 44 |
45 |
{currentText}
46 | 50 |
51 |
52 | {isOpen && ( 53 | <> 54 | {options.map(({ text, value, link }) => ( 55 |
router.push(link)} 58 | className="DropdownFilter__element" 59 | aria-selected="true" 60 | aria-checked="false" 61 | > 62 |
63 | {text} 64 |
65 |
66 | ))} 67 | 68 | )} 69 |
70 |
71 |
72 | ); 73 | } 74 | 75 | export default React.memo(SemesterDropdown); 76 | -------------------------------------------------------------------------------- /docs/setup/setup.md: -------------------------------------------------------------------------------- 1 | ### Installing the dependencies 2 | 3 | Almost every Node.js project has a lot of dependencies. These include React, Lodash, Webpack, and usually a bunch of other libraries. Lets install them. 4 | 5 | ```bash 6 | yarn 7 | ``` 8 | 9 | If you get installation errors, try deleting the `node_modules` folder and running the install command again. 10 | 11 | ### Start the server 12 | 13 | This will start Search NEU in development mode locally. It will listen on port 5000. If you make any changes to the frontend code while the server is running, webpack will automatically recompile the code and send the updates to the browser. Most of the time, the changes should appear in the browser without needing to reload the page ([More info about Hot Module Reloading](https://webpack.js.org/concepts/hot-module-replacement/)). Sometimes this will fail and a message will appear in Chrome's developer tools asking you to reload the page to see the changes. 14 | 15 | ```bash 16 | yarn dev 17 | ``` 18 | 19 | ### React Dev tools 20 | 21 | Also, install the React Developer tools browser extension ([Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/react-devtools/)). This helps a lot with debugging the frontend React code. More about debugging below. 22 | 23 | ### Debugging 24 | 25 | Chrome dev tools are great for debugging both Node.js code and JavaScript code in a browser. You can debug a Node.js script by running `babel-node` (or `node`) with these arguments: 26 | 27 | ```bash 28 | babel-node --debug --inspect-brk filename.js 29 | ``` 30 | 31 | ### Run the tests 32 | 33 | ```bash 34 | # Run the tests once and exit 35 | yarn test 36 | 37 | # Run just the files that have changed since the last git commit 38 | yarn test --watch 39 | 40 | # Run all the tests 41 | yarn test --watchAll 42 | 43 | # Run all the tests and generate a code coverage report. 44 | # An overview is shown in the termal and a more detailed report is saved in the coverage directory. 45 | yarn jest --coverage --watchAll 46 | ``` 47 | 48 | ### Build the code for production 49 | 50 | This command will build the frontend. 51 | 52 | ```bash 53 | yarn build 54 | ``` 55 | 56 | ### Linting 57 | 58 | Some of the code follows the ESLint config. All the code in the codebase should pass these linting checks. 59 | 60 | ```bash 61 | yarn lint 62 | ``` 63 | 64 | Prettier formats code automatically when you git commit, so don't waste time manual formatting. 65 | -------------------------------------------------------------------------------- /components/ResultsPage/MobileSearchOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import IconClose from '../icons/IconClose'; 3 | import macros from '../macros'; 4 | import FilterPanel from './FilterPanel'; 5 | import FilterPills from './FilterPills'; 6 | import { areFiltersSet, FilterOptions, FilterSelection } from './filters'; 7 | import Colors from '../../styles/_exports.module.scss'; 8 | 9 | /** 10 | * setFilterPills sets the selected filters 11 | * onExecute indicates the query should be run and we should return to the results page 12 | * onClose indicates the user wants to close the overlay and return to wherever we were before 13 | * filterSelection is the list of selected filters 14 | * filterOptions is the available options for the filters 15 | * query is the search query 16 | */ 17 | interface MobileSearchOverlayProps { 18 | setFilterPills: (f: FilterSelection) => void; 19 | onExecute: () => void; 20 | filterSelection: FilterSelection; 21 | filterOptions: FilterOptions; 22 | } 23 | 24 | export default function MobileSearchOverlay({ 25 | setFilterPills, 26 | filterSelection, 27 | filterOptions, 28 | onExecute, 29 | }: MobileSearchOverlayProps): ReactElement { 30 | // Hide keyboard and execute search 31 | const search = (): void => { 32 | if (macros.isMobile) { 33 | if ( 34 | document.activeElement && 35 | document.activeElement instanceof HTMLElement 36 | ) { 37 | document.activeElement.blur(); 38 | } 39 | } 40 | onExecute(); 41 | }; 42 | return ( 43 |
44 |
45 |
46 | {areFiltersSet(filterSelection) && ( 47 | 51 | )} 52 |
53 |
59 | 60 |
61 | 66 |
67 |
73 | View all results 74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /components/ClassPage/PrereqsDisplay.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React, { ReactElement } from 'react'; 3 | import { 4 | isCompositeReq, 5 | isCourseReq, 6 | } from '../ResultsPage/Results/useResultDetail'; 7 | import { CompositeReq, Requisite } from '../types'; 8 | 9 | type PrereqsDisplayProps = { 10 | termId: string; 11 | campus: string; 12 | prereqs: Requisite; 13 | level: number; 14 | }; 15 | 16 | export default function PrereqsDisplay({ 17 | termId, 18 | campus, 19 | prereqs, 20 | level, 21 | }: PrereqsDisplayProps): ReactElement { 22 | if (isCompositeReq(prereqs)) { 23 | const prereq: CompositeReq = prereqs as CompositeReq; 24 | if (prereq.values.length === 0) { 25 | return None; 26 | } else if (prereq.values.length === 1) { 27 | return ( 28 | 34 | ); 35 | } else { 36 | return ( 37 | <> 38 | {level > 0 ? ( 39 |
  • 40 | {prereq.type === 'and' ? 'Each of' : 'One of'} 41 |
      42 | {prereq.values.map((value, index) => ( 43 |
    • 44 | 51 |
    • 52 | ))} 53 |
    54 |
  • 55 | ) : ( 56 | <> 57 | {prereq.type === 'and' ? 'Each of' : 'One of'} 58 |
      59 | {prereq.values.map((value, index) => ( 60 |
    • 61 | 68 |
    • 69 | ))} 70 |
    71 | 72 | )} 73 | 74 | ); 75 | } 76 | } else if (isCourseReq(prereqs)) { 77 | return ( 78 |
  • 79 | {`${prereqs.subject} ${prereqs.classId}`} 82 |
  • 83 | ); 84 | } else { 85 | return ( 86 |
  • 87 | {prereqs} 88 |
  • 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /styles/base.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | * 5 | * This file contains normalizing styles, and is the primary file 6 | * that uses all partials. 7 | */ 8 | 9 | @use 'variables' as Colors; 10 | @use 'panels/BaseClassPanel'; 11 | @use 'panels/DesktopClassPanel'; 12 | @use 'panels/DesktopSectionPanel' as panels_DesktopSectionPanel; 13 | @use 'panels/MobileClassPanel'; 14 | @use 'panels/MobileSectionPanel' as panels_MobileSectionPanel; 15 | @use 'panels/SplashPage'; 16 | @use 'panels/WeekdayBoxes' as panels_WeekdayBoxes; 17 | @use 'pages/Home'; 18 | @use 'pages/Results'; 19 | @use 'pages/ClassPage'; 20 | @use 'pages/404' as four04; 21 | @use 'pages/Down'; 22 | @use 'pages/Error'; 23 | @use 'SignUpForNotifications'; 24 | @use 'EmailInput'; 25 | @use 'SearchHeader'; 26 | @use 'Footer'; 27 | @use 'SearchBar'; 28 | @use 'SearchDropdown'; 29 | @use 'CheckboxFilter'; 30 | @use 'FilterPanel'; 31 | @use 'FilterPills'; 32 | @use 'Filters'; 33 | @use 'MobileSearchOverlay'; 34 | @use 'FeedbackModal'; 35 | @use 'results/SearchResult'; 36 | @use 'results/WeekdayBoxes' as results_WeekdayBoxes; 37 | @use 'results/DesktopSectionPanel' as results_DesktopSectionPanel; 38 | @use 'results/MobileSearchResult'; 39 | @use 'results/MobileSectionPanel' as results_MobileSectionPanel; 40 | @use 'results/NotifCheckBox'; 41 | @use 'results/NotifSignUpButton'; 42 | @use 'home/HomeSearch'; 43 | @use 'DropdownFilter'; 44 | @use 'home/ExploratorySearchButton'; 45 | @use 'Tooltip'; 46 | @use 'LastUpdated'; 47 | @use 'AlertBanner'; 48 | @use '../node_modules/antd/dist/antd.css'; 49 | @use 'LoadingContainer'; 50 | @use 'PhoneModal'; 51 | @use 'Modal'; 52 | @use 'InfoIconTooltip'; 53 | @use 'Toast'; 54 | @use 'EmptyCard'; 55 | @use 'CRNBadge'; 56 | 57 | html, 58 | body, 59 | div, 60 | h1, 61 | h2, 62 | h3, 63 | h4, 64 | h5, 65 | h6, 66 | ul, 67 | ol, 68 | dl, 69 | li, 70 | dt, 71 | dd, 72 | p, 73 | blockquote, 74 | pre, 75 | form, 76 | fieldset, 77 | table, 78 | th, 79 | td { 80 | margin: 0; 81 | padding: 0; 82 | } 83 | 84 | html, 85 | body { 86 | -webkit-box-sizing: border-box; 87 | -moz-box-sizing: border-box; 88 | box-sizing: border-box; 89 | height: 100%; 90 | } 91 | 92 | html { 93 | font-size: 18px; 94 | } 95 | 96 | *, 97 | *::before, 98 | *::after { 99 | -webkit-box-sizing: inherit; 100 | -moz-box-sizing: inherit; 101 | box-sizing: inherit; 102 | } 103 | 104 | img { 105 | border: none; 106 | } 107 | 108 | a { 109 | color: Colors.$Blue; 110 | text-decoration: none; 111 | } 112 | 113 | body { 114 | font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 115 | color: Colors.$Dark_Grey; 116 | line-height: 22px; 117 | background: none; 118 | } 119 | 120 | p { 121 | margin: 1em 0; 122 | } 123 | -------------------------------------------------------------------------------- /styles/results/_DesktopSectionPanel.scss: -------------------------------------------------------------------------------- 1 | @use '../variables' as Colors; 2 | 3 | .DesktopSectionPanel { 4 | font-family: Lato, sans-serif; 5 | font-style: normal; 6 | font-weight: normal; 7 | font-size: 13px; 8 | line-height: 10px; 9 | 10 | &__meetings { 11 | display: flex; 12 | margin-bottom: 6px; 13 | 14 | & > .WeekdayBoxes { 15 | margin-right: 24px; 16 | } 17 | 18 | &--times > span { 19 | line-height: 20px; 20 | letter-spacing: 0.25px; 21 | display: inline-block; 22 | margin-bottom: 4px; 23 | text-align: left; 24 | width: max-content; 25 | } 26 | } 27 | 28 | &__meetings:last-child { 29 | margin-bottom: 0; 30 | } 31 | 32 | & > td:first-child { 33 | position: relative; 34 | 35 | & > a { 36 | position: relative; 37 | } 38 | } 39 | 40 | // Meetings 41 | > td:nth-child(3) { 42 | width: 35%; 43 | } 44 | 45 | // Notifications 46 | > td:nth-child(6) { 47 | width: 10%; 48 | } 49 | 50 | &__green { 51 | color: Colors.$Green; 52 | } 53 | 54 | &__yellow { 55 | color: Colors.$CPS_Yellow; 56 | } 57 | 58 | &__red { 59 | color: Colors.$NEU_Red; 60 | } 61 | 62 | .InfoIconTooltip__icon { 63 | width: 16px; 64 | height: 16px; 65 | margin-bottom: 4px; 66 | margin-left: -2px; 67 | } 68 | 69 | & > td:nth-child(5) { 70 | > div { 71 | display: flex; 72 | flex-direction: row; 73 | justify-content: flex-start; 74 | align-items: center; 75 | width: 25%; 76 | } 77 | 78 | & span:nth-child(3) { 79 | font-style: italic; 80 | line-height: 16px; 81 | } 82 | } 83 | 84 | &__notifs { 85 | display: flex; 86 | align-items: center; 87 | justify-content: center; 88 | 89 | .infoIcon { 90 | cursor: pointer; 91 | position: relative; 92 | &:hover .tooltip { 93 | display: block; 94 | top: -30px; 95 | right: -20px; 96 | background: Colors.$Blue; 97 | white-space: nowrap; 98 | & > div { 99 | border-color: Colors.$Blue transparent transparent transparent; 100 | right: 29px; 101 | top: 24px; 102 | } 103 | } 104 | } 105 | 106 | .signUpSwitch { 107 | cursor: pointer; 108 | position: relative; 109 | &:hover .tooltip { 110 | display: block; 111 | bottom: 30px; 112 | right: -14px; 113 | background: Colors.$Blue; 114 | white-space: nowrap; 115 | & > div { 116 | border-color: Colors.$Blue transparent transparent transparent; 117 | right: 29px; 118 | top: 24px; 119 | } 120 | } 121 | } 122 | } 123 | // If notifications present, last td should't have a right border 124 | > td:last-child { 125 | border-right: 0px; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /components/icons/IconGlobe.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import Colors from '../../styles/_exports.module.scss'; 3 | 4 | const IconGlobe = ({ 5 | width = '14', 6 | height = '14', 7 | fill = Colors.dark_grey, 8 | className, 9 | }: { 10 | width?: string; 11 | height?: string; 12 | fill?: string; 13 | className?: string; 14 | }): ReactElement => ( 15 | 23 | 40 | 41 | ); 42 | 43 | export default IconGlobe; 44 | -------------------------------------------------------------------------------- /pages/error.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from 'react'; 2 | import Boston from '../components/icons/boston.svg'; 3 | import FooterFeedbackModal from '../components/FeedbackModal'; 4 | import CryingHusky2 from '../components/icons/crying-husky-2.svg'; 5 | import Image from 'next/image'; 6 | 7 | /** 8 | * Page to indicate the api is down. 9 | */ 10 | export default function ApiErrorPage(): ReactElement { 11 | const containerClassnames = 'home-container'; 12 | 13 | const [modalOpen, setModalOpen] = useState(false); 14 | 15 | const toggleModal = () => setModalOpen(!modalOpen); 16 | 17 | return ( 18 |
    19 |
    20 | 26 | sandbox logo 32 | 33 | 34 |
    35 |
    36 |
    37 |
    38 |
    39 |
    Oh man!
    40 |
    41 | Something went wrong... 42 |
    43 | 44 |
    45 | {"Don't worry, we're on it"} 46 |
    47 |
    48 | 55 | 61 | Create issue on GitHub 62 | 63 |
    64 |
    65 |
    66 |
    67 | 71 |
    72 |
    73 | 74 |
    75 | 76 |
    77 |
    78 | 79 | 83 |
    84 |
    85 |
    86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /styles/_FilterPills.scss: -------------------------------------------------------------------------------- 1 | @use 'variables' as Colors; 2 | 3 | .selected-filters { 4 | display: flex; 5 | align-items: center; 6 | padding: 10px 0; 7 | 8 | &__label { 9 | font-weight: bold; 10 | margin-right: 7px; 11 | flex-shrink: 0; 12 | } 13 | 14 | &__row { 15 | display: flex; 16 | margin-bottom: -5px; 17 | padding: 10px 15px; 18 | max-width: 75vw; 19 | margin-left: 5px; 20 | padding-left: 0; 21 | padding-bottom: 0; 22 | 23 | // overflow behavior for mobile is a scrollbar (767px is max mobile horizontal width) 24 | @media (max-width: 767px) { 25 | overflow-x: scroll; 26 | } 27 | 28 | // overflow behavior for desktop is stacking (768 is tablet and larger device width) 29 | @media (min-width: 768px) { 30 | flex-wrap: wrap; 31 | } 32 | } 33 | 34 | &__clear { 35 | cursor: pointer; 36 | color: Colors.$NEU_Red; 37 | padding: 0 10px; 38 | line-height: 30px; 39 | background: transparent; 40 | display: inline-block; 41 | } 42 | 43 | &__clear:hover { 44 | text-decoration: underline; 45 | } 46 | } 47 | 48 | /* for clearing filters when there are no results */ 49 | .no-results__clear { 50 | cursor: pointer; 51 | color: Colors.$NEU_Red; 52 | padding: 0 5px; 53 | display: inline; 54 | line-height: 30px; 55 | } 56 | 57 | .no-results__clear:hover { 58 | text-decoration: underline; 59 | } 60 | 61 | .FilterPill { 62 | $root: &; 63 | 64 | &__icon { 65 | margin-left: 25px; 66 | position: relative; 67 | 68 | &::before, 69 | &::after { 70 | top: 50%; 71 | right: 50%; 72 | position: absolute; 73 | content: ''; 74 | width: 14px; /* x size */ 75 | height: 1.5px; /* x thickness */ 76 | border-radius: 25px; 77 | background-color: Colors.$NEU_Red; 78 | } 79 | 80 | &::before { 81 | transform: rotate(45deg); 82 | } 83 | 84 | &::after { 85 | transform: rotate(-45deg); 86 | } 87 | } 88 | 89 | &__close { 90 | height: 30px; 91 | background: Colors.$White; 92 | white-space: nowrap; 93 | color: Colors.$Grey; 94 | border-radius: 7px; 95 | border: 0.5px solid Colors.$Light_Grey; 96 | cursor: pointer; 97 | padding-left: 10px; 98 | margin-left: 5px; 99 | margin-bottom: 5px; 100 | 101 | &:focus { 102 | outline: none; 103 | } 104 | 105 | &:hover { 106 | background-color: Colors.$NEU_Red; 107 | border-color: Colors.$NEU_Red; 108 | color: Colors.$White; 109 | } 110 | 111 | &:hover .FilterPill__icon::before, 112 | &:hover .FilterPill__icon::after { 113 | background-color: Colors.$White; 114 | } 115 | } 116 | 117 | &__verbose { 118 | display: none; 119 | 120 | @media only screen and (min-width: 700px) { 121 | display: inline; 122 | } 123 | } 124 | 125 | &__compact { 126 | @media only screen and (min-width: 700px) { 127 | display: none; 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | import React, { useState, memo } from 'react'; 6 | import FeedbackModal from './FeedbackModal'; 7 | 8 | function Footer() { 9 | const [modalOpen, setModalOpen] = useState(false); 10 | 11 | const toggleModal = () => { 12 | setModalOpen(!modalOpen); 13 | }; 14 | return ( 15 | <> 16 |
    17 |
    18 | Interested in how SearchNEU works? Check out our 19 | 24 |  documentation! 25 | 26 |
    27 | 28 |
    29 | 30 |
    31 | See an issue or want to add to this website? Fork it or create an 32 | issue on 33 | 38 |  GitHub 39 | 40 | . 41 |
    42 | 43 |
    44 | 45 |
    46 | A  47 | 52 | Sandbox 53 | 54 |  Project (founded by  55 | 60 | Ryan Hughes 61 | 62 | , with some awesome  63 | 68 | contributors 69 | 70 | ) 71 |
    72 |
    73 | Search NEU is built for students by students & is not affiliated 74 | with NEU. 75 |
    76 |
    77 | 78 | Feedback 79 | 80 |  •  81 | 82 | Report a bug 83 | 84 |  •  85 | 86 | Contact 87 | 88 |
    89 |
    90 | 91 | 92 | ); 93 | } 94 | export default memo(Footer); 95 | -------------------------------------------------------------------------------- /styles/_SignUpForNotifications.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of Search NEU and licensed under AGPL3. 3 | * See the license file in the root folder for details. 4 | */ 5 | @use 'variables' as Colors; 6 | 7 | .sign-up-for-notifications-container { 8 | .disabledButton { 9 | font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif; 10 | margin: 0 0.25em 0 0; 11 | padding: 0.78571429em 1.5em 0.78571429em; 12 | line-height: 1em; 13 | text-align: center; 14 | color: Colors.$Grey; 15 | font-weight: 400; 16 | border-radius: 0.28571429rem; 17 | -webkit-box-shadow: 0 0 0 1px Colors.$Grey inset; 18 | box-shadow: 0 0 0 1px Colors.$Grey inset; 19 | opacity: 0.55; 20 | } 21 | 22 | .notificationButton { 23 | margin-top: 3px !important; 24 | margin-left: 5px !important; 25 | margin-right: 0; 26 | } 27 | 28 | .toggleCTA { 29 | margin-top: 4px; 30 | color: Colors.$Blue; 31 | width: 60%; 32 | margin-left: 40%; 33 | text-align: center; 34 | line-height: 16px; 35 | font-size: 14px; 36 | } 37 | 38 | .allSeatsAvailable { 39 | margin-top: 4px; 40 | color: Colors.$Green; 41 | width: 60%; 42 | margin-left: 40%; 43 | text-align: center; 44 | line-height: 16px; 45 | font-size: 14px; 46 | } 47 | 48 | .initialNotificationButton { 49 | cursor: pointer; 50 | border-radius: 5px; 51 | background: Colors.$White; 52 | border: 1px solid Colors.$Grey; 53 | display: flex; 54 | align-items: center; 55 | padding: 10px; 56 | width: max-content; 57 | 58 | &:hover { 59 | background: Colors.$Off_White; 60 | } 61 | 62 | &:focus { 63 | outline: none; 64 | } 65 | 66 | & > svg { 67 | margin-right: 8px; 68 | } 69 | 70 | & > span { 71 | font-style: normal; 72 | font-weight: normal; 73 | font-size: 14px; 74 | line-height: 25px; 75 | } 76 | } 77 | 78 | @media only screen and (min-width: 767px) { 79 | .notificationButton { 80 | font-size: 15px !important; 81 | width: 200px !important; 82 | } 83 | } 84 | 85 | @media only screen and (min-width: 767px) { 86 | .diableAdblockButton { 87 | font-size: 15px !important; 88 | width: 160px !important; 89 | } 90 | } 91 | 92 | .inlineBlock { 93 | display: inline-block; 94 | } 95 | 96 | @media only screen and (max-width: 600px) { 97 | .initialNotificationButton { 98 | width: max-content; 99 | } 100 | 101 | .toggleCTA { 102 | margin-top: 0; 103 | margin-left: 0; 104 | width: 100%; 105 | 106 | & > span { 107 | width: 60%; 108 | display: inline-block; 109 | } 110 | } 111 | 112 | .allSeatsAvailable { 113 | margin-top: 0; 114 | margin-left: 0; 115 | width: 100%; 116 | 117 | & > span { 118 | width: 60%; 119 | display: inline-block; 120 | } 121 | } 122 | } 123 | } 124 | 125 | .checkboxLabel { 126 | max-width: 164px; 127 | } 128 | -------------------------------------------------------------------------------- /components/ResultsPage/FilterPills.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import React, { ReactElement } from 'react'; 3 | import { 4 | DEFAULT_FILTER_SELECTION, 5 | FilterSelection, 6 | FILTERS_BY_CATEGORY, 7 | } from './filters'; 8 | 9 | interface PillProps { 10 | verbose: string; // for desktop 11 | compact: string; // for mobile 12 | onClose: () => void; 13 | } 14 | 15 | function FilterPill({ verbose, compact, onClose }: PillProps): ReactElement { 16 | return ( 17 |
    18 | 23 |
    24 | ); 25 | } 26 | 27 | interface FilterPillsProps { 28 | filters: FilterSelection; 29 | setFilters: (f: FilterSelection) => void; 30 | } 31 | 32 | const OPTIONS_FILTERS = { 33 | ...FILTERS_BY_CATEGORY.Dropdown, 34 | ...FILTERS_BY_CATEGORY.Checkboxes, 35 | }; 36 | 37 | export default function FilterPills({ 38 | filters, 39 | setFilters, 40 | }: FilterPillsProps): ReactElement { 41 | const crumbs: PillProps[] = []; 42 | 43 | // Add all the selected option filters 44 | for (const [key, spec] of Object.entries(OPTIONS_FILTERS)) { 45 | for (const s of filters[key]) { 46 | crumbs.push({ 47 | verbose: `${spec.display}: ${s}`, 48 | compact: s, 49 | onClose: () => setFilters({ [key]: _.without(filters[key], s) }), 50 | }); 51 | } 52 | } 53 | 54 | for (const [key, spec] of Object.entries(FILTERS_BY_CATEGORY.Toggle)) { 55 | if (filters[key]) { 56 | crumbs.push({ 57 | verbose: spec.display, 58 | compact: spec.display, 59 | onClose: () => setFilters({ [key]: false }), 60 | }); 61 | } 62 | } 63 | 64 | for (const [key, spec] of Object.entries(FILTERS_BY_CATEGORY.Range)) { 65 | if (filters[key] && (filters[key].min || filters[key].max)) { 66 | crumbs.push({ 67 | verbose: `${spec.display}: ${filters[key].min} - ${filters[key].max}`, 68 | compact: `${filters[key].min} - ${filters[key].max}`, 69 | onClose: () => setFilters({ [key]: {} }), 70 | }); 71 | } 72 | } 73 | 74 | return ( 75 |
    76 |
    77 | 78 | Applied ({crumbs.length}): 79 | 80 |
    81 | {crumbs.map((crumb: PillProps) => ( 82 | 88 | ))} 89 |
    90 |
    91 |
    setFilters(DEFAULT_FILTER_SELECTION)} 96 | > 97 | Clear All 98 |
    99 |
    100 | ); 101 | } 102 | --------------------------------------------------------------------------------