├── .gitattributes ├── _redirects ├── .env.test ├── public ├── favicon.ico ├── checks_example.png ├── favicons │ ├── favicon.ico │ ├── mstile-70x70.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── android-chrome-192x192-maskable.png │ ├── android-chrome-512x512-maskable.png │ ├── browserconfig.xml │ └── safari-pinned-tab.svg ├── splash_screens │ ├── icon.png │ ├── 10.2__iPad_landscape.png │ ├── 10.2__iPad_portrait.png │ ├── 10.5__iPad_Air_portrait.png │ ├── 10.9__iPad_Air_portrait.png │ ├── 12.9__iPad_Pro_portrait.png │ ├── 8.3__iPad_Mini_portrait.png │ ├── iPhone_14_Pro_landscape.png │ ├── iPhone_14_Pro_portrait.png │ ├── 10.5__iPad_Air_landscape.png │ ├── 10.9__iPad_Air_landscape.png │ ├── 12.9__iPad_Pro_landscape.png │ ├── 8.3__iPad_Mini_landscape.png │ ├── iPhone_14_Pro_Max_portrait.png │ ├── iPhone_14_Pro_Max_landscape.png │ ├── iPhone_11__iPhone_XR_landscape.png │ ├── iPhone_11__iPhone_XR_portrait.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_landscape.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_portrait.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png │ └── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png ├── manifest.json └── iOS_splash_screens.txt ├── robots.txt ├── .env.example ├── src ├── assets │ ├── miku │ │ ├── miku.jpg │ │ └── miku2.png │ ├── bocchi-error.webp │ ├── logos │ │ ├── dedalus-logo.webp │ │ ├── visa-logo.svg │ │ ├── citadel-logo.svg │ │ ├── accenture-logo.svg │ │ ├── conway-logo.svg │ │ ├── deshaw-logo.svg │ │ ├── scaleai-logo.svg │ │ ├── ripple-logo.svg │ │ ├── agentuity-logo.svg │ │ ├── optiver-logo.svg │ │ └── flyio-logo.svg │ ├── banner │ │ ├── close-button.svg │ │ ├── bg-wave.svg │ │ ├── fg-wave.svg │ │ └── scotty-dog.svg │ ├── select.svg │ ├── control_button │ │ ├── dropdown_arrow.svg │ │ ├── eye.svg │ │ ├── eye-off.svg │ │ ├── unpinned.svg │ │ └── pinned.svg │ └── scottydog.svg ├── util │ ├── assert.ts │ ├── misc.ts │ ├── constants.ts │ ├── network.ts │ ├── slack.ts │ ├── string.ts │ ├── localStorage.ts │ ├── safeStorage.ts │ ├── storage.ts │ ├── greeting.ts │ └── queryLocations.ts ├── types │ ├── interfaces.ts │ ├── joiLocationTypes.ts │ └── locationTypes.ts ├── setupTests.ts ├── react.d.ts ├── vite.env.d.ts ├── env.ts ├── components │ ├── NoResultsError.tsx │ ├── SelectLocation.css │ ├── Footer.module.css │ ├── SelectLocation.tsx │ ├── Navbar.module.css │ ├── EateryCardSkeleton.tsx │ ├── SponsorCarousel.tsx │ ├── SearchBar.module.css │ ├── SearchBar.tsx │ ├── EateryCard.css │ ├── SponsorCarousel.module.css │ ├── Navbar.tsx │ └── Footer.tsx ├── pages │ ├── MapPage.css │ ├── EateryCardGrid.module.css │ ├── NotFoundPage.tsx │ ├── useFilteredLocations.ts │ ├── ListPage.css │ ├── MapPage.tsx │ ├── EateryCardGrid.tsx │ └── ListPage.tsx ├── style.ts ├── ErrorFallback.tsx ├── ThemeProvider.tsx ├── index.tsx ├── constants │ └── colors.ts ├── App.tsx └── App.css ├── .prettierignore ├── tests ├── happydom.ts ├── util │ ├── assert.test.ts │ ├── misc.test.ts │ ├── string.test.ts │ ├── helper.ts │ ├── greeting.test.ts │ └── safeStorage.test.ts └── setup.ts ├── vercel.json ├── bunfig.toml ├── .git-blame-ignore-revs ├── vitest.config.ts ├── .eslintignore ├── tsconfig.node.json ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── lint.yml ├── .prettierrc ├── tsconfig.json ├── .eslintrc ├── package.json └── vite.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /*.json /:splat 200 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | VITE_AUTO_GENERATED_MAPKITJS_TOKEN=NA 2 | VITE_API_URL=https://fake-url.com -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MAPKIT_JS_TEAM_ID= 2 | MAPKIT_JS_AUTH_KEY= 3 | MAPKIT_JS_KEY_ID= 4 | MAPKIT_JS_ORIGIN= 5 | -------------------------------------------------------------------------------- /src/assets/miku/miku.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/src/assets/miku/miku.jpg -------------------------------------------------------------------------------- /public/checks_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/checks_example.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/favicon.ico -------------------------------------------------------------------------------- /src/assets/miku/miku2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/src/assets/miku/miku2.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | dist 4 | coverage 5 | 6 | *.json 7 | *.node.json 8 | .github -------------------------------------------------------------------------------- /src/assets/bocchi-error.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/src/assets/bocchi-error.webp -------------------------------------------------------------------------------- /public/favicons/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/mstile-70x70.png -------------------------------------------------------------------------------- /public/splash_screens/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/icon.png -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/mstile-144x144.png -------------------------------------------------------------------------------- /public/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicons/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/mstile-310x150.png -------------------------------------------------------------------------------- /public/favicons/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/mstile-310x310.png -------------------------------------------------------------------------------- /src/assets/logos/dedalus-logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/src/assets/logos/dedalus-logo.webp -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /tests/happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | 3 | GlobalRegistrator.register(); 4 | -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /public/splash_screens/10.2__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/10.2__iPad_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.2__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/10.2__iPad_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/10.5__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/10.5__iPad_Air_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/10.9__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/10.9__iPad_Air_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/12.9__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/12.9__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/8.3__iPad_Mini_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/8.3__iPad_Mini_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14_Pro_portrait.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/android-chrome-192x192-maskable.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-512x512-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/favicons/android-chrome-512x512-maskable.png -------------------------------------------------------------------------------- /public/splash_screens/10.5__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/10.5__iPad_Air_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.9__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/10.9__iPad_Air_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/12.9__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/12.9__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/8.3__iPad_Mini_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/8.3__iPad_Mini_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Pro_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14_Pro_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14_Pro_Max_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11__iPhone_XR_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_11__iPhone_XR_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11__iPhone_XR_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_11__iPhone_XR_portrait.png -------------------------------------------------------------------------------- /tests/util/assert.test.ts: -------------------------------------------------------------------------------- 1 | import assert from '../../src/util/assert'; 2 | 3 | test('throw error', () => { 4 | expect(() => assert(false, 'woot')).toThrow('woot'); 5 | }); 6 | -------------------------------------------------------------------------------- /public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | # preload = "./tests/happydom.ts" 3 | # preload temporarily commented out due to an issue with Bun test (see the issue I filed at https://github.com/oven-sh/bun/issues/8383) -------------------------------------------------------------------------------- /src/util/assert.ts: -------------------------------------------------------------------------------- 1 | export default function assert(condition: boolean, message?: string) { 2 | if (!condition) { 3 | throw new Error(message || 'Assertion failed'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Run this command to always ignore formatting commits in `git blame` 2 | # git config blame.ignoreRevsFile .git-blame-ignore-revs 3 | 4 | 1746e018ad6cd6c92343a1f29ced9284afe63fd1 -------------------------------------------------------------------------------- /public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png -------------------------------------------------------------------------------- /src/types/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { LocationState } from './locationTypes'; 2 | 3 | export default interface TextProps { 4 | variant: 'subtitle1'; 5 | state: LocationState; 6 | children: React.ReactNode; 7 | } 8 | -------------------------------------------------------------------------------- /public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | setupFiles: './tests/setup.ts', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # .eslintignore 2 | *.json 3 | *.rc 4 | *.config.ts 5 | *.node.json 6 | .eslint* 7 | .prettier* 8 | .vscode/* 9 | .settings.json 10 | .gitignore 11 | .gitattributes 12 | /node_modules 13 | /build 14 | /dist 15 | /tests -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png -------------------------------------------------------------------------------- /src/assets/banner/close-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png -------------------------------------------------------------------------------- /src/assets/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScottyLabs/cmueats/HEAD/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png -------------------------------------------------------------------------------- /src/util/misc.ts: -------------------------------------------------------------------------------- 1 | import assert from './assert'; 2 | 3 | /** 4 | * @returns true iff n (int) is in [a,b) 5 | */ 6 | export default function bounded(n: number, a: number, b: number) { 7 | assert(a <= b); 8 | return n >= a && n < b; 9 | } 10 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/react.d.ts: -------------------------------------------------------------------------------- 1 | import 'react'; // eslint-disable-line react/no-typos 2 | // (we need this import to properly extend the type) 3 | declare module 'react' { 4 | interface CSSProperties { 5 | // allow css variable manipulation in style prop 6 | [key: `--${string}`]: string | number; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /public/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ff0000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/util/constants.ts: -------------------------------------------------------------------------------- 1 | const today = new Date(); 2 | 3 | // global conditional for whether or not to render Miku theme toggle 4 | const IS_MIKU_DAY = 5 | (today.getDate() === 9 && today.getMonth() === 2) || 6 | (typeof window !== 'undefined' && window.location.search.endsWith('force-miku-day')); 7 | export default IS_MIKU_DAY; 8 | -------------------------------------------------------------------------------- /tests/util/misc.test.ts: -------------------------------------------------------------------------------- 1 | import bounded from '../../src/util/misc'; 2 | 3 | test('bounded', () => { 4 | expect(bounded(2, 2, 2)).toEqual(false); 5 | expect(bounded(2, 2, 3)).toEqual(true); 6 | expect(bounded(2.5, 2.2, 3)).toEqual(true); 7 | expect(() => bounded(2, 2, -2)).toThrow(); 8 | expect(() => bounded(2, 2, 1.99)).toThrow(); 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "composite": true, 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "types": ["bun-types"], 9 | "moduleResolution": "bundler", 10 | "allowSyntheticDefaultImports": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } -------------------------------------------------------------------------------- /tests/util/string.test.ts: -------------------------------------------------------------------------------- 1 | import toTitleCase from '../../src/util/string'; 2 | 3 | test.each([ 4 | ['asdf', 'Asdf'], 5 | ['so trUe', 'So True'], 6 | ['o K k', 'o k k'], 7 | ['火鍋', '火鍋'], 8 | ['', ''], 9 | [' ', ''], 10 | [' asdf ', 'Asdf'], 11 | ['ii', 'II'], 12 | ])('toTitleCasetest', (before, after) => { 13 | expect(toTitleCase(before)).toBe(after); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/util/helper.ts: -------------------------------------------------------------------------------- 1 | import { DateTime, WeekdayNumbers } from "luxon"; 2 | 3 | /** 4 | * 5 | * @param day 0-6 (0 is Sunday) 6 | * @param hour 0-23 7 | * @param minute 0-59 8 | * @returns 9 | */ 10 | export default function makeDateTime(day: number, hour: number, minute: number) { 11 | return DateTime.fromObject({ 12 | hour, 13 | minute, 14 | weekday: (day === 0 ? 7 : day) as WeekdayNumbers, 15 | }); 16 | } -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach } from 'vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import matchers from '@testing-library/jest-dom/matchers'; 4 | 5 | // extends Vitest's expect method with methods from react-testing-library 6 | // console.log(matchers); 7 | // expect.extend(matchers); 8 | 9 | // runs a cleanup after each test case (e.g. clearing jsdom) 10 | afterEach(() => { 11 | cleanup(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/vite.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | 6 | import { FC, SVGProps } from 'react'; 7 | 8 | declare module '*.png'; 9 | declare module '*.jpeg'; 10 | declare module '*.jpg'; 11 | declare module '*.svg' { 12 | const content: FC>; 13 | export default content; 14 | } 15 | -------------------------------------------------------------------------------- /tests/util/greeting.test.ts: -------------------------------------------------------------------------------- 1 | import { getGreeting, getGreetingMobile } from '../../src/util/greeting'; 2 | test('non-empty greeting for all hours', () => { 3 | for ( 4 | let i = 0; 5 | i < 100; 6 | i++ // let the rng do its thing 7 | ) 8 | for (let hr = 0; hr < 24; hr++) { 9 | expect(getGreeting(hr).length > 0).toBe(true); 10 | expect(getGreetingMobile(hr).length > 0).toBe(true); 11 | } 12 | expect(() => getGreeting(-1)).toThrow(); 13 | expect(() => getGreeting(24)).toThrow(); 14 | }); 15 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .vscode 22 | 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | /dist 27 | /dev-dist 28 | 29 | # for testing only 30 | /public/example-response.json -------------------------------------------------------------------------------- /src/util/network.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function useRefreshWhenBackOnline() { 4 | useEffect(() => { 5 | function handleOnline() { 6 | if (navigator.onLine) { 7 | // Refresh the page 8 | window.location.reload(); 9 | } 10 | } 11 | 12 | window.addEventListener('online', handleOnline); 13 | 14 | return () => window.removeEventListener('online', handleOnline); 15 | }, []); 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v2 # Setup pnpm 14 | with: 15 | version: latest 16 | - run: pnpm install 17 | - name: Run Unit Tests 18 | run: pnpm test 19 | -------------------------------------------------------------------------------- /src/assets/control_button/dropdown_arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v2 # Setup pnpm 14 | with: 15 | version: latest 16 | - run: pnpm install 17 | - name: Check TypeScript 18 | run: pnpm tsc 19 | - name: Run ESLint 20 | run: pnpm lint 21 | -------------------------------------------------------------------------------- /src/util/slack.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import env from '../env'; 3 | 4 | export default async function notifySlack(message: string) { 5 | await axios.post( 6 | `${env.VITE_API_URL}/api/sendSlackMessage`, // we can't directly call the Slack webhook because they don't support CORS preflight checking https://github.com/slackapi/node-slack-sdk/issues/1568 7 | { message: `${message}\nBrowser info: \`${navigator.userAgent}\`` }, 8 | { 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | }, 12 | }, 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const envSchema = z.object({ 4 | VITE_API_URL: z 5 | .url() 6 | .transform((url) => { 7 | const urlObj = new URL(url); 8 | return `${urlObj.protocol}//${urlObj.host}`; 9 | }) 10 | .or(z.string('locations.json')), 11 | VITE_POSTHOG_KEY: z.string().optional(), 12 | VITE_AUTO_GENERATED_MAPKITJS_TOKEN: z.string(), 13 | }); 14 | 15 | // see preInitEnvSchema in vite.config.ts for variables needed to generate AUTO_GENERATED_VITE_MAPKITJS_TOKEN 16 | const env = envSchema.parse(import.meta.env); 17 | export default env; 18 | -------------------------------------------------------------------------------- /src/util/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a string to title case 3 | * @param {string} str string to convert to title case 4 | * @returns the same string, but in title case (single characters aren't upper-cased) 5 | */ 6 | export default function toTitleCase(str: string) { 7 | return str 8 | .trim() 9 | .toLowerCase() 10 | .split(' ') 11 | .map((word) => { 12 | if (word === 'ii') return 'II'; // special case 13 | if (word.length > 1) { 14 | return word[0]!.toUpperCase() + word.slice(1); 15 | } 16 | return word; 17 | }) 18 | .join(' '); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/NoResultsError.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@mui/material'; 2 | 3 | function EateryCard({ onClear }: { onClear: () => unknown }) { 4 | return ( 5 |
6 |

No results found

7 |

8 | Try searching for a name (e.g. “Schatz”) or location (e.g. “Cohon”). 9 |

10 | 13 |
14 | ); 15 | } 16 | 17 | export default EateryCard; 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 120, 4 | "proseWrap": "preserve", 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "tabWidth": 4, 9 | "useTabs": false, 10 | "arrowParens": "always", 11 | "endOfLine": "lf", 12 | "overrides": [ 13 | { 14 | "files": "*.json", 15 | "options": { 16 | "singleQuote": false 17 | } 18 | }, 19 | { 20 | "files": ".*rc", 21 | "options": { 22 | "singleQuote": false, 23 | "parser": "json" 24 | } 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /src/pages/MapPage.css: -------------------------------------------------------------------------------- 1 | .MapPage { 2 | background-color: var(--main-bg); 3 | display: grid; 4 | grid-template-rows: 1fr auto; 5 | height: 100%; 6 | } 7 | 8 | .MapDrawer { 9 | background: var(--map-drawer-bg); 10 | overflow: hidden; 11 | box-sizing: content-box; 12 | & > div { 13 | padding: 14px; 14 | } 15 | } 16 | 17 | @media (prefers-reduced-motion: no-preference) { 18 | .MapDrawer { 19 | transition: max-height ease-in 300ms; 20 | } 21 | } 22 | 23 | .DrawerTransition-exit { 24 | max-height: 300px; 25 | } 26 | 27 | .DrawerTransition-enter, 28 | .DrawerTransition-exit-active { 29 | max-height: 0; 30 | } 31 | 32 | .DrawerTransition-enter-active { 33 | max-height: 300px; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/EateryCardGrid.module.css: -------------------------------------------------------------------------------- 1 | .dropdown-button { 2 | display: flex; 3 | color: var(--black-400); 4 | gap: 16px; 5 | 6 | cursor: pointer; 7 | 8 | align-items: center; 9 | border: none; 10 | background-color: transparent; 11 | 12 | svg { 13 | rotate: -90deg; 14 | transition: rotate 0.2s ease; 15 | } 16 | 17 | & p { 18 | font-size: 16px; 19 | margin-left: -3px; 20 | } 21 | } 22 | 23 | .dropdown-button--up { 24 | svg { 25 | rotate: 0deg; 26 | } 27 | } 28 | 29 | .supergrid { 30 | display: flex; 31 | flex-direction: column; 32 | gap: 16px; 33 | } 34 | 35 | .section { 36 | display: flex; 37 | flex-direction: column; 38 | gap: 16px; 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { Button } from '@mui/material'; 3 | 4 | function NotFoundPage() { 5 | const navigate = useNavigate(); 6 | 7 | return ( 8 |
9 |

Oops!

10 |

We couldn’t find the page you are looking for.

11 | 19 |
20 | ); 21 | } 22 | 23 | export default NotFoundPage; 24 | -------------------------------------------------------------------------------- /src/assets/logos/visa-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | Created by potrace 1.11, written by Peter Selinger 2001-2013 -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "CMUEats", 3 | "name": "CMUEats", 4 | "icons": [ 5 | { 6 | "src": "./favicons/android-chrome-192x192.png", 7 | "type": "image/png", 8 | "sizes": "192x192", 9 | "purpose": "any" 10 | }, 11 | { 12 | "src": "./favicons/android-chrome-512x512.png", 13 | "type": "image/png", 14 | "sizes": "512x512", 15 | "purpose": "any" 16 | }, 17 | { 18 | "src": "./favicons/android-chrome-192x192-maskable.png", 19 | "type": "image/png", 20 | "sizes": "192x192", 21 | "purpose": "maskable" 22 | }, 23 | { 24 | "src": "./favicons/android-chrome-512x512-maskable.png", 25 | "type": "image/png", 26 | "sizes": "512x512", 27 | "purpose": "maskable" 28 | } 29 | ], 30 | "scope": "/", 31 | "start_url": "/", 32 | "display": "standalone", 33 | "theme_color": "#ffffff", 34 | "background_color": "#ffffff" 35 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true, 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": false, 22 | "noUnusedParameters": false, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedIndexedAccess": true, // this should honestly be the default, since you're not guaranteed to get a value when you key into an object 25 | 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } -------------------------------------------------------------------------------- /src/util/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import notifySlack from './slack'; 3 | import { safeGetItem, safeSetItem } from './safeStorage'; 4 | 5 | export default function useLocalStorage(key: string) { 6 | const [value, setValue] = useState(() => safeGetItem(key)); 7 | useEffect(() => { 8 | const onStorageUpdate = () => { 9 | setValue(safeGetItem(key)); 10 | }; 11 | window.addEventListener('storage', onStorageUpdate); // gets called when other windows modify storage 12 | return () => window.removeEventListener('storage', onStorageUpdate); 13 | }, [key]); 14 | 15 | return [ 16 | value, 17 | (newVal: string) => { 18 | const success = safeSetItem(key, newVal); 19 | if (!success) { 20 | notifySlack(`Failed to set localStorage key "${key}": cookies may be disabled`); 21 | } 22 | setValue(newVal); 23 | }, 24 | ] as const; 25 | } 26 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import { Button, styled, Typography } from '@mui/material'; 2 | 3 | const ErrorTitle = styled(Typography)({ 4 | color: 'white', 5 | marginBottom: 12, 6 | fontSize: 24, 7 | fontFamily: 8 | '"Zilla Slab", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", ' + 9 | '"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", ' + 10 | '"Droid Sans", "Helvetica Neue", sans-serif', 11 | fontWeight: 600, 12 | }); 13 | 14 | const ErrorText = styled(Typography)({ 15 | color: '#d4d4d8', 16 | marginBottom: 20, 17 | fontSize: 16, 18 | }); 19 | 20 | const ErrorButton = styled(Button)({ 21 | fontWeight: 600, 22 | fontFamily: 23 | '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", ' + 24 | '"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", ' + 25 | '"Helvetica Neue", sans-serif', 26 | color: 'white', 27 | backgroundColor: '#1D1F21', 28 | elevation: 30, 29 | }); 30 | 31 | export { ErrorTitle, ErrorText, ErrorButton }; 32 | -------------------------------------------------------------------------------- /src/ErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary } from 'react-error-boundary'; 2 | import bocchiError from './assets/bocchi-error.webp'; 3 | 4 | function ErrorBoundaryFallback() { 5 | return ( 6 |
7 | oh... uhhh... well this is awkward. we have encountered an issue while rendering this page{' '} 8 | 9 | the error has been automatically reported to the cmueats team 10 |
11 | Please refresh the page or check dining hours on GrubHub or{' '} 12 | 13 | https://apps.studentaffairs.cmu.edu/dining/conceptinfo/ 14 | {' '} 15 | for now 16 |
17 |
18 | ); 19 | } 20 | export default function GlobalErrorBoundary({ children }: { children: React.ReactNode }) { 21 | return }>{children}; 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "airbnb-typescript", "plugin:react/jsx-runtime", "prettier", "plugin:import/typescript"], 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "browser": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module", 11 | "ecmaFeatures": { 12 | "jsx": true 13 | }, 14 | "project": ["./tsconfig.json"] 15 | }, 16 | "parser": "@typescript-eslint/parser", 17 | "plugins": ["@typescript-eslint", "react", "prettier"], 18 | "rules": { 19 | "react/jsx-uses-react": "error", 20 | "react/jsx-uses-vars": "error", 21 | "react/prop-types": "off", 22 | "react/require-default-props": "off", // we don't use prop-types for prop validation https://stackoverflow.com/a/64041197/13171687 23 | "no-console": ["warn", { "allow": ["warn"] }], 24 | "prettier/prettier": ["warn"], 25 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": false }] 26 | }, 27 | 28 | "settings": { 29 | "react": { "version": "detect" } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext, createContext, useLayoutEffect, useMemo } from 'react'; 2 | import IS_MIKU_DAY from './util/constants'; 3 | import { safeGetItem, safeSetItem } from './util/safeStorage'; 4 | 5 | type Theme = 'none' | 'miku'; 6 | 7 | const ThemeContext = createContext<{ 8 | theme: Theme; 9 | updateTheme: (theme: Theme) => void; 10 | }>({ theme: 'none', updateTheme: () => {} }); 11 | 12 | export function ThemeProvider({ children }: { children: React.ReactNode }) { 13 | const [theme, setTheme] = useState(() => (IS_MIKU_DAY && safeGetItem('theme') === 'miku' ? 'miku' : 'none')); 14 | useLayoutEffect(() => { 15 | document.body.className = theme; 16 | }, [theme]); 17 | 18 | const updateTheme = (_theme: Theme) => { 19 | safeSetItem('theme', _theme); 20 | setTheme(_theme); 21 | }; 22 | const exportedContext = useMemo(() => ({ theme, updateTheme }), [theme, updateTheme]); 23 | // listen for localstorage changes 24 | return {children}; 25 | } 26 | export function useThemeContext() { 27 | const theme = useContext(ThemeContext); 28 | return theme; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/SelectLocation.css: -------------------------------------------------------------------------------- 1 | .select { 2 | appearance: none; 3 | cursor: pointer; 4 | display: block; 5 | min-width: 500px; 6 | width: fit-content; 7 | 8 | padding: 12.8px 14.4px; 9 | border-radius: 16px; 10 | background: var(--input-bg) url(../assets/select.svg) no-repeat calc(100% - 10px) 50%; 11 | outline: none; 12 | border: 1px solid transparent; 13 | box-shadow: 0 0 0 2px rgba(255, 255, 255, 0); 14 | transition: all 0.2s; 15 | font-family: inherit; 16 | font-size: 16px; 17 | color: var(--input-text); 18 | font-weight: 500; 19 | animation: slide-in 1.2s forwards; 20 | animation-timing-function: cubic-bezier(0.04, 0.34, 0.5, 1.02); 21 | /* we add a delay so it doesn't look jittery on page load */ 22 | animation-delay: 0.1s; 23 | opacity: 0; 24 | } 25 | 26 | .select:hover { 27 | transition: all 0.5s; 28 | box-shadow: 0 0 40px var(--hover-accent-color); 29 | border-color: var(--hover-accent-color); 30 | outline: none; 31 | } 32 | 33 | @media screen and (max-width: 900px) { 34 | /* don't animate in the select component on mobile */ 35 | .select { 36 | opacity: 1; 37 | animation: none; 38 | width: 100%; 39 | min-width: 0; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Footer.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | margin-top: 80px; 3 | background-color: var(--black-1050); 4 | padding: 40px 0 0; 5 | min-height: 130px; 6 | position: relative; 7 | overflow: hidden; 8 | &:has(.footer__miku) { 9 | min-height: 530px; 10 | /* we create a new stacking context so miku doesn't go below the bg */ 11 | isolation: isolate; 12 | } 13 | } 14 | .footer__text-section { 15 | color: var(--black-0); 16 | margin-bottom: 20px; 17 | font-size: 16px; 18 | padding: 0 40px; 19 | } 20 | .footer__logo { 21 | font-family: var(--text-primary-font); 22 | font-weight: 800; 23 | font-size: 34px; 24 | margin: 20px 0; 25 | & :nth-child(1) { 26 | color: var(--logo-first-half); 27 | } 28 | & :nth-child(2) { 29 | color: var(--logo-second-half); 30 | } 31 | } 32 | .footer__miku { 33 | position: absolute; 34 | right: 20px; 35 | top: -56px; 36 | height: 100%; 37 | z-index: -1; 38 | @media screen and (max-width: 900px) { 39 | & { 40 | top: 0; 41 | } 42 | } 43 | } 44 | .sponsors-spacer { 45 | position: relative; 46 | height: 35px; 47 | @media (max-width: 900px) { 48 | height: 20px; 49 | } 50 | @media (max-width: 600px) { 51 | height: 50px; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/util/safeStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safe localStorage utilities that handle disabled cookies/storage gracefully 3 | */ 4 | 5 | export function safeGetItem(key: string): string | null { 6 | try { 7 | return localStorage.getItem(key); 8 | } catch (error) { 9 | console.warn(`localStorage.getItem failed for key "${key}":`, error); 10 | return null; 11 | } 12 | } 13 | 14 | export function safeSetItem(key: string, value: string): boolean { 15 | try { 16 | localStorage.setItem(key, value); 17 | return true; 18 | } catch (error) { 19 | console.warn(`localStorage.setItem failed for key "${key}":`, error); 20 | return false; 21 | } 22 | } 23 | 24 | export function safeRemoveItem(key: string): boolean { 25 | try { 26 | localStorage.removeItem(key); 27 | return true; 28 | } catch (error) { 29 | console.warn(`localStorage.removeItem failed for key "${key}":`, error); 30 | return false; 31 | } 32 | } 33 | 34 | /** 35 | * Check if localStorage is available and working 36 | */ 37 | export function isStorageAvailable(): boolean { 38 | try { 39 | const testKey = '__storage_test__'; 40 | localStorage.setItem(testKey, 'test'); 41 | localStorage.removeItem(testKey); 42 | return true; 43 | } catch { 44 | return false; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.css'; 4 | 5 | import posthog from 'posthog-js'; 6 | import { PostHogProvider } from 'posthog-js/react'; 7 | import App from './App'; 8 | import { ThemeProvider } from './ThemeProvider'; 9 | import env from './env'; 10 | import notifySlack from './util/slack'; 11 | import GlobalErrorBoundary from './ErrorFallback'; 12 | 13 | posthog.init(env.VITE_POSTHOG_KEY || '', { 14 | person_profiles: 'identified_only', 15 | }); 16 | // error handling 17 | // catches synchronous errors 18 | window.addEventListener('error', (event) => { 19 | notifySlack(` ${event.error?.message}\n${event.error?.stack}`).catch(console.error); // ignore failure to prevent infinite loop 20 | }); 21 | 22 | // catches errors raised in try-catch blocks 23 | window.addEventListener('unhandledrejection', (er) => 24 | notifySlack(` ${er.reason}\n${er.reason?.stack}`).catch(console.error), 25 | ); // ignore failure to prevent infinite loop 26 | const rootElement = document.getElementById('root'); 27 | 28 | if (rootElement) { 29 | createRoot(rootElement).render( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | , 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/SelectLocation.tsx: -------------------------------------------------------------------------------- 1 | import { IReadOnlyLocation_FromAPI_PostProcessed } from '../types/locationTypes'; 2 | 3 | import './SelectLocation.css'; 4 | 5 | type SelectLocationProps = { 6 | setLocationFilterQuery: React.Dispatch; 7 | locations: IReadOnlyLocation_FromAPI_PostProcessed[] | undefined; 8 | }; 9 | 10 | function getPrimaryLocation(locationString: string) { 11 | return locationString.indexOf(',') === -1 ? locationString : locationString.slice(0, locationString.indexOf(',')); 12 | } 13 | 14 | function SelectLocation({ setLocationFilterQuery, locations }: SelectLocationProps) { 15 | if (locations === undefined) { 16 | return ( 17 | 21 | ); 22 | } 23 | 24 | let locationStrings = locations.map((locationObj) => locationObj.location); 25 | locationStrings = locations.map((locationObj) => getPrimaryLocation(locationObj.location)); 26 | 27 | const dedeupedLocationStrings = [...new Set(locationStrings)]; 28 | 29 | return ( 30 | 38 | ); 39 | } 40 | 41 | export default SelectLocation; 42 | -------------------------------------------------------------------------------- /src/pages/useFilteredLocations.ts: -------------------------------------------------------------------------------- 1 | import Fuse, { IFuseOptions } from 'fuse.js'; 2 | import { useMemo } from 'react'; 3 | import { IReadOnlyLocation_Combined, IReadOnlyLocation_FromAPI_PostProcessed } from '../types/locationTypes'; 4 | 5 | const FUSE_OPTIONS: IFuseOptions = { 6 | // keys to perform the search on 7 | keys: ['name', 'location', 'shortDescription', 'description'], 8 | ignoreLocation: true, 9 | threshold: 0.2, 10 | }; 11 | 12 | export default function useFilteredLocations({ 13 | locations, 14 | searchQuery, 15 | locationFilterQuery, 16 | }: { 17 | locations: IReadOnlyLocation_Combined[] | undefined; 18 | searchQuery: string; 19 | locationFilterQuery: string; 20 | }) { 21 | const fuse = useMemo(() => new Fuse(locations ?? [], FUSE_OPTIONS), [locations]); // only update fuse when the raw data actually changes (we don't care about the extra info status (like time until close) changing) 22 | const processedSearchQuery = searchQuery.trim().toLowerCase(); 23 | 24 | const filteredLocations = useMemo(() => { 25 | const searchResults = 26 | processedSearchQuery.length === 0 27 | ? (locations ?? []) 28 | : fuse.search(processedSearchQuery).map((result) => result.item); 29 | 30 | const locationFilterResults = new Set(fuse.search(locationFilterQuery).map((results) => results.item)); 31 | 32 | const intersection = 33 | locationFilterQuery === '' 34 | ? searchResults 35 | : searchResults.filter((item) => locationFilterResults.has(item)); 36 | 37 | return locations !== undefined ? intersection : undefined; 38 | }, [fuse, searchQuery, locationFilterQuery]); 39 | return filteredLocations; 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/control_button/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/constants/colors.ts: -------------------------------------------------------------------------------- 1 | import { LocationState } from '../types/locationTypes'; 2 | 3 | export const textColors: Record = { 4 | [LocationState.OPEN]: 'var(--location-open-text-color)', 5 | [LocationState.CLOSED]: 'var(--location-closed-text-color)', 6 | [LocationState.CLOSED_LONG_TERM]: 'var(--location-closed-long-term-text-color)', 7 | [LocationState.OPENS_SOON]: 'var(--location-opens-soon-text-color)', 8 | [LocationState.CLOSES_SOON]: 'var(--location-closes-soon-text-color)', 9 | }; 10 | 11 | // highlight is for both the underline and dot color 12 | export const highlightColors: Record = { 13 | [LocationState.OPEN]: 'var(--location-open-highlight)', 14 | [LocationState.CLOSED]: 'var(--location-closed-highlight)', 15 | [LocationState.CLOSED_LONG_TERM]: 'var(--location-closed-long-term-highlight)', 16 | [LocationState.OPENS_SOON]: 'var(--location-opens-soon-highlight)', 17 | [LocationState.CLOSES_SOON]: 'var(--location-closes-soon-highlight)', 18 | }; 19 | export const mapMarkerBackgroundColors: Record = { 20 | [LocationState.OPEN]: 'var(--location-open-text-color)', 21 | [LocationState.CLOSED]: 'var(--location-closed-text-color)', 22 | [LocationState.CLOSED_LONG_TERM]: 'var(--location-closed-long-term-text-color)', 23 | [LocationState.OPENS_SOON]: 'var(--location-opens-soon-text-color)', 24 | [LocationState.CLOSES_SOON]: 'var(--location-closes-soon-text-color)', 25 | }; 26 | 27 | export const mapMarkerTextColors: Record = { 28 | [LocationState.OPEN]: 'var(--map-open-text-color)', 29 | [LocationState.CLOSED]: 'var(--map-closed-text-color)', 30 | [LocationState.CLOSED_LONG_TERM]: 'var(--map-closed-long-term-text-color)', 31 | [LocationState.OPENS_SOON]: 'var(--map-opens-soon-text-color)', 32 | [LocationState.CLOSES_SOON]: 'var(--map-closes-soon-text-color)', 33 | }; 34 | -------------------------------------------------------------------------------- /src/types/joiLocationTypes.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import { isValidTimeSlotArray } from '../util/time'; 3 | import { IReadOnlyLocation_FromAPI_PreProcessed } from './locationTypes'; 4 | import assert from '../util/assert'; 5 | 6 | const { string, number, boolean } = Joi.types(); 7 | const ITimeSlotJoiSchema = Joi.object({ 8 | day: number.min(0).max(6).required(), 9 | hour: number.min(0).max(23).required(), 10 | minute: number.min(0).max(59).required(), 11 | }); 12 | const ITimeRangeJoiSchema = Joi.object({ 13 | start: ITimeSlotJoiSchema.required(), 14 | end: ITimeSlotJoiSchema.required(), 15 | }); 16 | const ISpecialJoiSchema = Joi.object({ 17 | title: string.required(), 18 | description: string.required().allow(''), 19 | }); 20 | 21 | // Note: Keys without .required() are optional by default 22 | export const ILocationAPIJoiSchema = Joi.object({ 23 | conceptId: number.required(), 24 | name: string, 25 | shortDescription: string, 26 | description: string.required(), 27 | url: string.required(), 28 | menu: string, 29 | location: string.required(), 30 | coordinates: { 31 | lat: number.required(), 32 | lng: number.required(), 33 | }, 34 | acceptsOnlineOrders: boolean.required(), 35 | times: Joi.array() 36 | .items(ITimeRangeJoiSchema) 37 | .required() 38 | .custom((val) => { 39 | assert(isValidTimeSlotArray(val)); 40 | return val; 41 | }) 42 | .message('Received invalid (probably improperly sorted) time slots!'), 43 | todaysSpecials: Joi.array().items(ISpecialJoiSchema), 44 | todaysSoups: Joi.array().items(ISpecialJoiSchema), 45 | }); 46 | export const IAPIResponseJoiSchema = Joi.object<{ locations: any[] }>({ 47 | locations: Joi.array().required(), 48 | }); // shallow validation to make sure we have the locations field. That's it. 49 | -------------------------------------------------------------------------------- /src/util/storage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import z from 'zod'; 3 | import { safeGetItem, safeSetItem, safeRemoveItem } from './safeStorage'; 4 | 5 | const stringToJSONSchema = z.string().transform((str) => { 6 | try { 7 | return JSON.parse(str); 8 | } catch (e) { 9 | return {}; 10 | } 11 | }); 12 | 13 | const OldCardViewPreferences = stringToJSONSchema.pipe(z.array(z.string()).catch([])); 14 | const CardViewPreferences = stringToJSONSchema.pipe( 15 | z.record(z.string(), z.enum(['pinned', 'normal', 'hidden'])).catch({}), 16 | ); 17 | 18 | export type OldCardViewPreferencesType = z.infer; 19 | export type CardViewPreferencesType = z.infer; 20 | export type CardViewPreference = CardViewPreferencesType[string]; 21 | 22 | function upgradeToCardStateMapFromOldFormat(oldPreferences: OldCardViewPreferencesType): CardViewPreferencesType { 23 | return Object.fromEntries(oldPreferences.map((id) => [id, 'pinned'])); 24 | } 25 | export function useUserCardViewPreferences() { 26 | const [preferences, setPreferences] = useState(() => getPreferences()); 27 | return [ 28 | preferences, 29 | (newPreferences: CardViewPreferencesType) => { 30 | setPreferences(newPreferences); 31 | safeSetItem('eateryStates', JSON.stringify(newPreferences)); 32 | }, 33 | ] as const; 34 | } 35 | export function getPreferences() { 36 | const oldPreferences = safeGetItem('pinnedEateries'); 37 | 38 | if (oldPreferences !== null) { 39 | const newPreferences = upgradeToCardStateMapFromOldFormat(OldCardViewPreferences.parse(oldPreferences)); 40 | safeRemoveItem('pinnedEateries'); 41 | safeSetItem('eateryStates', JSON.stringify(newPreferences)); 42 | return newPreferences; 43 | } 44 | return CardViewPreferences.parse(safeGetItem('eateryStates') ?? '{}'); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Navbar.module.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | position: sticky; 3 | bottom: 0; 4 | flex-shrink: 0; 5 | background-color: var(--nav-bg); 6 | padding: 13px 0; 7 | border-top: 2px solid var(--nav-border-top); 8 | @media (max-width: 800px) { 9 | padding: 10px; 10 | padding-bottom: max(env(safe-area-inset-bottom), 14px); 11 | } 12 | overflow-x: auto; 13 | } 14 | 15 | .navbar-links { 16 | position: relative; 17 | display: flex; 18 | margin: auto; 19 | max-width: 550px; 20 | justify-content: space-between; 21 | gap: 7px; 22 | isolation: isolate; 23 | } 24 | 25 | .navbar-link { 26 | height: 40px; 27 | padding: 2px 10px; 28 | display: flex; 29 | flex-grow: 1; 30 | flex-basis: 0; 31 | position: relative; 32 | align-items: center; 33 | justify-content: center; 34 | border-radius: 10px; 35 | outline-offset: -3px; 36 | outline: 2px solid transparent; 37 | 38 | transition: 0.1s all; 39 | font-family: var(--text-primary-font); 40 | font-weight: 500; 41 | color: var(--text-primary); 42 | font-size: 16px; 43 | text-decoration: none; 44 | svg { 45 | width: 24px; 46 | height: 24px; 47 | margin-right: 0.4em; 48 | flex-shrink: 0; 49 | } 50 | span { 51 | text-wrap: nowrap; 52 | } 53 | } 54 | 55 | @media screen and (min-width: 800px) { 56 | .navbar-link:not(.navbar-link--active):hover { 57 | background-color: var(--selected-tab-bg); 58 | border-radius: 10px; 59 | transition: 0.2s ease-in-out; 60 | } 61 | } 62 | 63 | .navbar-link__active-backdrop { 64 | position: absolute; 65 | z-index: -10; 66 | inset: 0; 67 | background-color: var(--selected-tab-bg); 68 | box-shadow: 69 | 0 0 5px var(--hover-accent-color), 70 | 0 0 10px var(--hover-accent-color), 71 | 0 0 15px var(--hover-accent-color); 72 | } 73 | -------------------------------------------------------------------------------- /src/assets/banner/bg-wave.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/assets/banner/fg-wave.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/assets/scottydog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/banner/scotty-dog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/logos/citadel-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/EateryCardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardHeader, styled, Grid, CardContent, Avatar, Skeleton } from '@mui/material'; 2 | 3 | const StyledCard = styled(Card)({ 4 | backgroundColor: 'var(--card-bg)', 5 | border: 'var(--card-border-width) solid var(--card-border-color)', 6 | textAlign: 'left', 7 | borderRadius: 7, 8 | height: '100%', 9 | justifyContent: 'flex-start', 10 | }); 11 | 12 | const StyledCardHeader = styled(CardHeader)({ 13 | fontWeight: 500, 14 | backgroundColor: 'var(--card-header-bg)', 15 | }); 16 | 17 | const SkeletonText = styled(Skeleton)({ 18 | marginBottom: '12px', 19 | }); 20 | 21 | function EateryCardSkeleton({ index }: { index: number }) { 22 | return ( 23 | 24 | 30 | } 32 | avatar={ 33 | 40 | 41 | 42 | } 43 | /> 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | export default EateryCardSkeleton; 55 | -------------------------------------------------------------------------------- /src/components/SponsorCarousel.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import clsx from 'clsx'; 3 | import css from './SponsorCarousel.module.css'; 4 | import DropdownArrow from '../assets/control_button/dropdown_arrow.svg?react'; 5 | import ScottyDog from '../assets/scottydog.svg?react'; 6 | 7 | function SponsorCarousel({ darkMode, openByDefault }: { darkMode: boolean; openByDefault: boolean }) { 8 | const [carouselVisible, setCarouselVisible] = useState(openByDefault); 9 | 10 | const logos = Object.values( 11 | import.meta.glob('../assets/logos/*', { 12 | eager: true, 13 | query: 'url', 14 | }) as Record, 15 | ).map(({ default: logoUrl }) => { 16 | const filename = logoUrl.split('/').pop() || ''; 17 | return { 18 | src: logoUrl, 19 | alt: filename, 20 | }; 21 | }); 22 | 23 | // double it to make it loop seamlessly 24 | const doubleLogos = [...logos, ...logos.map((l) => ({ ...l, alt: `${l.alt}2` }))]; 25 | 26 | return ( 27 |
28 |
29 | 38 |
39 |
    40 | {doubleLogos.map((logo) => ( 41 |
  • 42 | {logo.alt} 43 |
  • 44 | ))} 45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | 52 | export default SponsorCarousel; 53 | -------------------------------------------------------------------------------- /src/components/SearchBar.module.css: -------------------------------------------------------------------------------- 1 | .locations-search-wrapper { 2 | display: block; 3 | position: relative; 4 | } 5 | 6 | .locations-search { 7 | display: block; 8 | width: 100%; 9 | /* stays at the top when greeting text wraps */ 10 | align-self: start; 11 | padding: 12.8px 16px; 12 | padding-left: 48px; 13 | border-radius: 16px; 14 | background: var(--input-bg); 15 | outline: none; 16 | border: 1px solid transparent; 17 | box-shadow: 0 0 0 2px rgba(255, 255, 255, 0); 18 | transition: all 0.2s; 19 | font-family: inherit; 20 | font-size: 16px; 21 | /* Heroicons v2.0.12 by Refactoring UI Inc., used under MIT license */ 22 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255, 255, 255, .6)' class='w-5 h-5'%3E%3Cpath fill-rule='evenodd' d='M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z' clip-rule='evenodd'/%3E%3C/svg%3E"); 23 | background-size: 20px; 24 | background-repeat: no-repeat; 25 | background-position: 16px center; 26 | color: var(--input-text); 27 | font-weight: 500; 28 | &::-webkit-search-decoration { 29 | display: none; 30 | } 31 | &:focus { 32 | transition: all 0.2s; 33 | box-shadow: 0 0 40px var(--hover-accent-color); 34 | border-color: var(--hover-accent-color); 35 | outline: none; 36 | } 37 | &::placeholder { 38 | color: var(--input-text-placeholder); 39 | } 40 | } 41 | 42 | .locations-search-hint { 43 | display: flex; 44 | position: absolute; 45 | inset: 0; 46 | left: calc(48px + 1px); 47 | align-items: center; 48 | color: var(--input-text-placeholder); 49 | pointer-events: none; 50 | opacity: 1; 51 | transition: opacity 0.2s ease-in; 52 | 53 | kbd { 54 | border: 1px solid; 55 | border-radius: 5px; 56 | padding: 0px 3px; 57 | font-family: monospace; 58 | display: inline-flex; 59 | .line { 60 | width: 1px; 61 | margin: 0 4px; 62 | background-color: rgba(255, 255, 255, 0.285); 63 | } 64 | } 65 | } 66 | 67 | .locations-search:focus + .locations-search-hint, 68 | .locations-search:not(:placeholder-shown) + .locations-search-hint { 69 | opacity: 0; 70 | } 71 | -------------------------------------------------------------------------------- /src/assets/control_button/eye-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logos/accenture-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 21 | 23 | 27 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/assets/logos/conway-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logos/deshaw-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/logos/scaleai-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/control_button/unpinned.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import css from './SearchBar.module.css'; 3 | 4 | function SearchBar({ searchQuery, setSearchQuery }: { searchQuery: string; setSearchQuery: React.Dispatch }) { 5 | const inputRef = useRef(null); 6 | const isDesktop = window.innerWidth >= 900; 7 | const isMac = navigator.platform.includes('Mac'); 8 | 9 | useEffect(() => { 10 | function handleKeyDown(event: KeyboardEvent) { 11 | const target = event.target as HTMLElement; 12 | const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; 13 | 14 | if ( 15 | // `cmd/ctrl` + `k` 16 | ((((isMac && event.metaKey) || (!isMac && event.ctrlKey)) && event.key === 'k') || 17 | // only `/` 18 | (!event.metaKey && !event.ctrlKey && event.key === '/') || 19 | // only 's` 20 | (!event.metaKey && !event.ctrlKey && event.key === 's')) && 21 | document.activeElement !== inputRef.current && 22 | !isTyping 23 | ) { 24 | event.preventDefault(); 25 | inputRef.current?.focus(); 26 | } 27 | 28 | if (event.key === 'Escape' && document.activeElement === inputRef.current) { 29 | inputRef.current?.blur(); 30 | } 31 | } 32 | 33 | document.addEventListener('keydown', handleKeyDown); 34 | return () => document.removeEventListener('keydown', handleKeyDown); 35 | }, []); 36 | 37 | return ( 38 |
39 | setSearchQuery(e.target.value)} 45 | placeholder=" " 46 | // this needs to be nonempty for the :placeholder-shown css to work 47 | /> 48 |
49 | {isDesktop ? ( 50 | 51 | Type / or{' '} 52 | {isMac ? ( 53 | 54 | 55 | 56 | K 57 | 58 | ) : ( 59 | 60 | ^ 61 | 62 | K 63 | 64 | )}{' '} 65 | to search 66 | 67 | ) : ( 68 | Search... 69 | )} 70 |
71 |
72 | ); 73 | } 74 | 75 | export default SearchBar; 76 | -------------------------------------------------------------------------------- /src/util/greeting.ts: -------------------------------------------------------------------------------- 1 | import assert from './assert'; 2 | import bounded from './misc'; 3 | 4 | const graveyard = [ 5 | 'Staying up all night?', 6 | 'Want a late-night snack?', 7 | "Don't stay up too late!", 8 | 'Delivery too expensive?', 9 | 'Pulling an all-nighter? Let us fuel your focus!', 10 | 'Late night genius? Keep it going with a bite!', 11 | 'Need a boost for your midnight grind?', 12 | ]; 13 | const graveyardShort = [ 14 | 'Staying up all night?', 15 | 'Want a late-night snack?', 16 | "Don't stay up too late!", 17 | 'Delivery too expensive?', 18 | ]; 19 | const morning = [ 20 | 'Fancy some breakfast?', 21 | 'Is breakfast really the most important meal of the day?', 22 | 'What do you want to eat?', 23 | 'Have a good morning!', 24 | 'Start your day with a delicious meal!', 25 | 'Time to refuel for the day ahead!', 26 | ]; 27 | const morningShort = ['Fancy some breakfast?', 'What do you want to eat?', 'Have a good morning!']; 28 | const afternoon = [ 29 | 'What do you want for lunch?', 30 | 'What do you want to eat?', 31 | 'Have a good afternoon!', 32 | 'Use those blocks!', 33 | 'Fuel up for the afternoon!', 34 | 'Lunch options galore!', 35 | 'Satisfy your midday hunger!', 36 | 'Craving something savory for lunch?', 37 | "Midday munchies? We've got you covered!", 38 | 'Halfway through the day—time for a lunch break!', 39 | ]; 40 | const afternoonShort = [ 41 | 'Have a good afternoon!', 42 | 'Use those blocks!', 43 | 'Fuel up for the afternoon!', 44 | 'Lunch options galore!', 45 | ]; 46 | const evening = [ 47 | 'What do you want for dinner?', 48 | 'What do you want to eat?', 49 | 'Have a good evening!', 50 | 'Grab a bite to eat!', 51 | 'Hungry night owl?', 52 | ]; 53 | const eveningShort = ['Have a good evening!', 'Grab a bite to eat!', 'Hungry night owl?']; 54 | interface Special { 55 | isMikuDay: boolean; 56 | } 57 | const getRandomStringFrom = (greetings: string[]) => { 58 | if (greetings.length === 0) return 'Welcome to CMUEats!'; 59 | return greetings[Math.floor(Math.random() * greetings.length)]; 60 | }; 61 | const getGreeting = (hours: number, special?: Special) => { 62 | assert(bounded(hours, 0, 24)); 63 | 64 | if (special?.isMikuDay) return 'Happy Miku Day! (March 9th)'; 65 | if (hours < 6) { 66 | return getRandomStringFrom(graveyard); 67 | } 68 | if (hours < 12) { 69 | return getRandomStringFrom(morning); 70 | } 71 | if (hours < 17) { 72 | return getRandomStringFrom(afternoon); 73 | } 74 | if (hours < 24) { 75 | return getRandomStringFrom(evening); 76 | } 77 | 78 | return 'Welcome to CMUEats!'; 79 | }; 80 | const getGreetingMobile = (hours: number, special?: Special) => { 81 | if (special?.isMikuDay) return 'Happy Miku Day!'; 82 | if (hours < 6) { 83 | return getRandomStringFrom(graveyardShort); 84 | } 85 | if (hours < 12) { 86 | return getRandomStringFrom(morningShort); 87 | } 88 | if (hours < 17) { 89 | return getRandomStringFrom(afternoonShort); 90 | } 91 | if (hours < 24) { 92 | return getRandomStringFrom(eveningShort); 93 | } 94 | return 'Welcome to CMUEats!'; 95 | }; 96 | const getGreetings = (hours: number, special?: Special) => ({ 97 | desktopGreeting: getGreeting(hours, special), 98 | mobileGreeting: getGreetingMobile(hours, special), 99 | }); 100 | export { getGreeting, getGreetingMobile, getGreetings }; 101 | -------------------------------------------------------------------------------- /src/components/EateryCard.css: -------------------------------------------------------------------------------- 1 | .card { 2 | --card-glow-animation: glow-animation 1.5s ease 0s infinite; 3 | 4 | box-sizing: border-box; 5 | height: 100%; 6 | 7 | background-color: var(--card-bg); 8 | border: var(--card-border-width) solid var(--card-border-color); 9 | border-radius: 7px; 10 | 11 | text-align: left; 12 | display: flex; 13 | flex-direction: column; 14 | overflow: hidden; 15 | 16 | &:hover { 17 | animation: var(--card-glow-animation); 18 | } 19 | 20 | position: relative; 21 | } 22 | 23 | .card__header { 24 | background: var(--card-header-bg); 25 | } 26 | 27 | /* additional .card__header needed to override MUI styles. We can discard when we finish migration */ 28 | .card__header .card__header__text { 29 | line-height: 1.6; 30 | text-underline-offset: 20px; 31 | } 32 | 33 | .card .card__content { 34 | padding: 24px 16px 32px; 35 | 36 | @media screen and (max-width: 900px) { 37 | padding: 24px 16px; 38 | } 39 | } 40 | 41 | .card__actions { 42 | /* makes sure footer is actually on the bottom (we're in a flexbox column layout) */ 43 | margin-top: auto; 44 | padding: 0 16px 16px 16px; 45 | margin-left: -2px; 46 | /* the illusion of alignment */ 47 | 48 | display: flex; 49 | gap: 12px; 50 | } 51 | 52 | /* the overlay card. modifiers (in this case --dialog) actually cascade to relevant children */ 53 | .card--dialog { 54 | background: var(--dialog-bg); 55 | 56 | .card__header { 57 | background: var(--dialog-header-bg); 58 | } 59 | } 60 | 61 | @keyframes fade-in { 62 | 0% { 63 | opacity: 0; 64 | transform: translate(-10px, 0); 65 | filter: blur(3px); 66 | } 67 | 68 | 55% { 69 | filter: blur(0); 70 | } 71 | 72 | 100% { 73 | transform: translate(0, 0); 74 | opacity: 1; 75 | filter: blur(0); 76 | } 77 | } 78 | 79 | @keyframes glow-animation { 80 | 0% { 81 | box-shadow: 0 0 5px var(--hover-accent-color); 82 | } 83 | 84 | 50% { 85 | box-shadow: 0 0 20px var(--hover-accent-color); 86 | } 87 | 88 | 100% { 89 | box-shadow: 0 0 5px var(--hover-accent-color); 90 | } 91 | } 92 | 93 | @keyframes oscillate-opacity { 94 | 0% { 95 | opacity: 1; 96 | } 97 | 98 | 30% { 99 | opacity: 0.6; 100 | } 101 | 102 | 90% { 103 | opacity: 1; 104 | } 105 | } 106 | 107 | .card__header__dot { 108 | width: 12px; 109 | height: 12px; 110 | margin-top: 7px; 111 | border-radius: 50%; 112 | } 113 | 114 | .card__header__dot--blinking { 115 | animation: blinking 1s infinite; 116 | animation-play-state: paused; 117 | } 118 | 119 | @keyframes blinking { 120 | 0% { 121 | opacity: 0; 122 | } 123 | 124 | 50% { 125 | opacity: 1; 126 | } 127 | 128 | 75% { 129 | opacity: 1; 130 | } 131 | 132 | 100% { 133 | opacity: 0; 134 | } 135 | } 136 | 137 | .card__pin-container { 138 | position: absolute; 139 | bottom: 14px; 140 | right: 10px; 141 | display: flex; 142 | flex-direction: row; 143 | } 144 | 145 | .card__pin-button { 146 | background: transparent; 147 | box-shadow: none; 148 | border: none; 149 | cursor: pointer; 150 | img { 151 | height: 19px; 152 | width: 22px; 153 | padding: 5px 2px; 154 | opacity: 0.6; 155 | transition: 0.05s opacity; 156 | -webkit-user-drag: none; 157 | user-drag: none; 158 | } 159 | &:hover { 160 | img { 161 | opacity: 1; 162 | } 163 | } 164 | &.card__pin-button--pinned { 165 | img { 166 | opacity: 1; 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/components/SponsorCarousel.module.css: -------------------------------------------------------------------------------- 1 | .sponsors { 2 | /* default unset colors */ 3 | --sponsors-border-color: red; 4 | --sponsors-background-color: red; 5 | --sponsors-text-color: red; 6 | } 7 | .sponsors--dark { 8 | --sponsors-text-color: rgba(255, 255, 255, 0.7); 9 | --sponsors-background-color: var(--black-1000); 10 | --sponsors-border-color: var(--black-800); 11 | 12 | .carousel__image { 13 | filter: brightness(0) invert(1); 14 | opacity: 30%; 15 | } 16 | } 17 | 18 | .sponsors--light { 19 | --sponsors-text-color: rgba(0, 0, 0, 0.5); 20 | --sponsors-background-color: #ababab; 21 | --sponsors-border-color: rgb(136, 136, 136); 22 | 23 | .carousel__image { 24 | filter: brightness(0) invert(0); 25 | opacity: 50%; 26 | } 27 | } 28 | 29 | .sponsors { 30 | position: absolute; 31 | bottom: 0; 32 | left: 0; 33 | right: 0; 34 | } 35 | .sponsors-button { 36 | position: absolute; 37 | bottom: 100%; 38 | right: 80px; 39 | display: block; 40 | padding: 10px 14px; 41 | border-radius: 10px 10px 0px 0px; 42 | font-size: 16px; 43 | white-space: nowrap; 44 | 45 | color: var(--sponsors-text-color); 46 | background: var(--sponsors-background-color); 47 | border: 1px solid var(--sponsors-border-color); 48 | border-bottom: 0; 49 | transition: 0.1s filter; 50 | &:hover { 51 | filter: brightness(1.1); 52 | } 53 | &::after { 54 | /* removes the top border of the carousel, where this tab is located */ 55 | content: ''; 56 | position: absolute; 57 | left: 0; 58 | right: 0; 59 | bottom: -1px; 60 | height: 1px; 61 | background-color: var(--sponsors-background-color); 62 | } 63 | } 64 | .sponsors-button__dog { 65 | stroke: var(--sponsors-text-color); 66 | stroke-width: 0.3px; /* make the image slightly thicker */ 67 | color: var(--sponsors-text-color); 68 | } 69 | .sponsors-button__arrow { 70 | margin-left: 8px; 71 | transition: transform 0.3s ease; 72 | will-change: rotate; /* prevents visual glitch */ 73 | transform: rotate(180deg); 74 | [aria-hidden='false'] & { 75 | transform: rotate(0deg); 76 | } 77 | } 78 | 79 | .carousel-container { 80 | transform: translateY(100%); 81 | transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); 82 | &[aria-hidden='false'] { 83 | transform: translateY(0); 84 | } 85 | position: relative; 86 | } 87 | 88 | .carousel { 89 | overflow: hidden; 90 | border-top: 1px solid var(--sponsors-border-color); 91 | } 92 | 93 | .carousel__track { 94 | display: flex; 95 | align-items: center; 96 | list-style: none; 97 | padding: 0; 98 | margin: 0; 99 | width: max-content; 100 | animation: carousel 90s linear infinite; 101 | background-color: var(--sponsors-background-color); 102 | .carousel__item { 103 | flex: 0 0 auto; 104 | .carousel__image { 105 | height: 40px; 106 | width: auto; 107 | object-fit: contain; 108 | display: block; 109 | padding: 22px 20px; 110 | } 111 | } 112 | } 113 | @media (max-width: 600px) { 114 | .carousel__track { 115 | animation: carousel 30s linear infinite; 116 | .carousel__item { 117 | .carousel__image { 118 | padding: 15px 10px; 119 | height: 35px; 120 | } 121 | } 122 | } 123 | .sponsors-button { 124 | left: 40px; 125 | right: auto; 126 | } 127 | } 128 | 129 | @keyframes carousel { 130 | from { 131 | transform: translate(0, 0); 132 | } 133 | to { 134 | transform: translate(-50%, 0); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cmueats", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@emotion/react": "^11.11.4", 8 | "@emotion/styled": "^11.11.0", 9 | "@million/lint": "latest", 10 | "@mui/icons-material": "^5.15.14", 11 | "@mui/material": "^5.15.14", 12 | "@tanstack/react-query": "^5.28.6", 13 | "@testing-library/jest-dom": "^6.4.2", 14 | "@testing-library/react": "^14.2.2", 15 | "@testing-library/user-event": "^14.5.2", 16 | "@types/material-ui": "^0.21.17", 17 | "@types/react": "^18.2.68", 18 | "@types/react-router-dom": "^5.3.3", 19 | "axios": "^1.6.8", 20 | "clsx": "^2.1.1", 21 | "css-loader": "^6.10.0", 22 | "fuse.js": "^7.0.0", 23 | "joi": "^17.12.2", 24 | "jest": "^30.2.0", 25 | "jsonwebtoken": "^9.0.2", 26 | "luxon": "^3.4.4", 27 | "mapkit-react": "^1.14.1", 28 | "million": "latest", 29 | "motion": "^12.23.12", 30 | "netlify-plugin-mapkitjs-token": "^1.2.0", 31 | "posthog-js": "^1.181.0", 32 | "prettier-eslint": "^16.3.0", 33 | "prettier-eslint-cli": "^8.0.1", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "react-error-boundary": "^6.0.0", 37 | "react-router-dom": "^6.30.1", 38 | "react-transition-group": "^4.4.5", 39 | "serve": "^14.2.1", 40 | "vitest": "^1.6.1", 41 | "web-vitals": "^3.5.2", 42 | "zod": "^4.0.5" 43 | }, 44 | "scripts": { 45 | "start": "vite --host", 46 | "dev": "vite --host", 47 | "build": "tsc && vite build", 48 | "typecheck": "tsc", 49 | "test": "dotenv -e .env.test -- vitest --coverage", 50 | "eject": "vite eject", 51 | "lint": "pnpm eslint src", 52 | "lint-fix": "pnpm eslint --fix src", 53 | "format": "pnpm prettier --write src", 54 | "coverage": "pnpm vitest --coverage" 55 | }, 56 | "packageManager": "pnpm@10.18.0", 57 | "eslintConfig": { 58 | "extends": [ 59 | "react-app", 60 | "react-app/jest" 61 | ] 62 | }, 63 | "browserslist": { 64 | "production": [ 65 | ">0.2%", 66 | "not dead", 67 | "not op_mini all" 68 | ], 69 | "development": [ 70 | "last 1 chrome version", 71 | "last 1 firefox version", 72 | "last 1 safari version" 73 | ] 74 | }, 75 | "devDependencies": { 76 | "@happy-dom/global-registrator": "^14.3.1", 77 | "@types/jest": "^29.5.12", 78 | "@types/jsonwebtoken": "^9.0.6", 79 | "@types/luxon": "^3.4.2", 80 | "@types/react-dom": "^19.0.2", 81 | "@types/react-transition-group": "^4.4.12", 82 | "@typescript-eslint/eslint-plugin": "^7.3.1", 83 | "@typescript-eslint/parser": "^7.3.1", 84 | "@vitejs/plugin-react": "^4.2.1", 85 | "@vitejs/plugin-react-swc": "^3.6.0", 86 | "@vitest/coverage-v8": "^1.4.0", 87 | "dotenv-cli": "^10.0.0", 88 | "eslint": "^8.57.0", 89 | "eslint-config-airbnb": "^19.0.4", 90 | "eslint-config-airbnb-typescript": "^18.0.0", 91 | "eslint-config-prettier": "^9.1.0", 92 | "eslint-config-react-app": "^7.0.1", 93 | "eslint-plugin-import": "^2.29.1", 94 | "eslint-plugin-jsx-a11y": "^6.8.0", 95 | "eslint-plugin-prettier": "^5.1.3", 96 | "eslint-plugin-react": "^7.34.1", 97 | "eslint-plugin-react-hooks": "^4.6.0", 98 | "jsdom": "^24.0.0", 99 | "prettier": "^3.2.5", 100 | "typescript": "^5.7.2", 101 | "vite": "^5.4.19", 102 | "vite-plugin-checker": "^0.6.4", 103 | "vite-plugin-pwa": "^0.19.6", 104 | "vite-plugin-svgr": "^4.2.0", 105 | "vite-tsconfig-paths": "^4.3.2" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/assets/logos/ripple-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/ListPage.css: -------------------------------------------------------------------------------- 1 | .ListPage { 2 | display: flex; 3 | flex-direction: column; 4 | min-height: 100%; 5 | /* So the contact info footer actually stays at the bottom */ 6 | } 7 | 8 | .ListPage__container { 9 | padding: 72px 48px 0; 10 | flex-grow: 1; 11 | 12 | @media screen and (max-width: 900px) { 13 | & { 14 | padding: 50px 15px; 15 | } 16 | } 17 | } 18 | 19 | .badge-accent { 20 | color: #105c03; 21 | background: #19b875; 22 | padding: 10px; 23 | border-radius: 30; 24 | } 25 | 26 | .Locations-header { 27 | display: grid; 28 | grid-gap: 16px; 29 | padding-bottom: 48px; 30 | position: relative; 31 | } 32 | 33 | .Locations-header__miku-toggle { 34 | position: absolute; 35 | top: -290px; 36 | left: min(55%, 630px); 37 | mask-image: url('../assets/miku/miku-keychain.svg'); 38 | mask-position: bottom; 39 | mask-size: contain; 40 | padding: 0; 41 | background-color: transparent; 42 | border: none; 43 | line-height: 0; 44 | cursor: pointer; 45 | transition: 0.3s all; 46 | transform-origin: top; 47 | animation: 3s sway infinite cubic-bezier(0.364212, 0, 0.635788, 1); 48 | 49 | &:hover { 50 | top: -280px; 51 | 52 | &:active { 53 | top: -272px; 54 | } 55 | } 56 | 57 | & img { 58 | height: 410px; 59 | } 60 | } 61 | 62 | @media screen and (max-width: 900px) { 63 | .Locations-header__miku-toggle { 64 | right: -20px; 65 | left: auto; 66 | } 67 | } 68 | 69 | @keyframes sway { 70 | 0%, 71 | 100% { 72 | transform: rotate(1deg); 73 | } 74 | 75 | 50% { 76 | transform: rotate(-1deg); 77 | } 78 | } 79 | 80 | .Locations-header__greeting-container { 81 | container-type: inline-size; 82 | 83 | & .Locations-header__greeting--desktop { 84 | display: none; 85 | } 86 | 87 | padding-left: 2px; 88 | 89 | /* 600px is the approximate width of the location dropdown selector (we are also assuming that the desktop greeting is less than 600px wide) */ 90 | @container (min-width: 600px) { 91 | & .Locations-header__greeting--desktop { 92 | display: block; 93 | } 94 | 95 | & .Locations-header__greeting--mobile { 96 | display: none; 97 | } 98 | } 99 | } 100 | 101 | /* we need this to properly animate the mask gradient */ 102 | @property --right-cutoff { 103 | syntax: ''; 104 | inherits: false; 105 | initial-value: 0%; 106 | } 107 | 108 | .Locations-header__greeting { 109 | color: var(--text-greeting); 110 | margin: 0; 111 | font-family: var(--text-primary-font); 112 | font-weight: 800; 113 | font-size: 40px; 114 | 115 | --right-cutoff: 100%; 116 | width: fit-content; 117 | animation: slide-in 1.2s forwards; 118 | animation-timing-function: cubic-bezier(0.04, 0.34, 0.5, 1.02); 119 | /* we add a delay so it doesn't look jittery on page load */ 120 | animation-delay: 0.1s; 121 | opacity: 0; 122 | 123 | mask-image: linear-gradient( 124 | to right, 125 | rgba(0, 0, 0, 1) 0%, 126 | rgba(0, 0, 0, 1) var(--right-cutoff), 127 | rgba(0, 0, 0, 0) calc(var(--right-cutoff) + 10%) 128 | ); 129 | 130 | &.Locations-header__greeting--mobile { 131 | font-size: 32px; 132 | } 133 | } 134 | 135 | @keyframes slide-in { 136 | 0% { 137 | opacity: 0; 138 | /* clip-path: inset(0px 100% 0px 0px); */ 139 | transform: translate(-10px, 0); 140 | --right-cutoff: 0%; 141 | } 142 | 143 | 20% { 144 | opacity: 1; 145 | } 146 | 147 | 100% { 148 | opacity: 1; 149 | transform: translate(0, 0); 150 | --right-cutoff: 100%; 151 | } 152 | } 153 | 154 | @media screen and (min-width: 900px) { 155 | .Locations-header { 156 | grid-template-columns: 1fr 300px; 157 | align-items: center; 158 | } 159 | } 160 | 161 | .locations__error-text { 162 | color: var(--text-primary); 163 | font-family: var(--text-primary-font); 164 | font-size: 24px; 165 | width: fit-content; 166 | word-break: break-word; 167 | } 168 | 169 | .skeleton-card--animated { 170 | opacity: 0; 171 | --oscillate-delay: 0s; 172 | animation: 173 | fade-in 1s cubic-bezier(0.08, 0.67, 0.64, 1.01) 1s forwards, 174 | oscillate-opacity 2s ease-in-out var(--oscillate-delay) infinite; 175 | } 176 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite'; 2 | import { checker } from 'vite-plugin-checker'; 3 | import { VitePWA, VitePWAOptions } from 'vite-plugin-pwa'; 4 | import react from '@vitejs/plugin-react-swc'; 5 | import viteTsconfigPaths from 'vite-tsconfig-paths'; 6 | import svgrPlugin from 'vite-plugin-svgr'; 7 | // import jwt from 'jsonwebtoken'; 8 | import { z } from 'zod'; 9 | import { createRequire } from 'module'; 10 | const require = createRequire(import.meta.url); 11 | const jwt = require('jsonwebtoken'); 12 | const preInitEnvSchema = z.object({ 13 | MAPKIT_JS_TEAM_ID: z.string(), 14 | MAPKIT_JS_KEY_ID: z.string(), 15 | MAPKIT_JS_AUTH_KEY: z.string(), 16 | MAPKIT_JS_TTL: z.coerce.number().default(31_536_000), // 1 year 17 | MAPKIT_ALLOWED_ORIGINS: z 18 | .string() 19 | .default('') 20 | .transform((origins) => 21 | process.env.VERCEL_URL !== undefined ? origins + ',' + process.env.VERCEL_URL : origins, 22 | ), 23 | }); 24 | const manifestForPlugin: Partial = { 25 | registerType: 'prompt', 26 | includeAssets: ['favicon.ico', '/favicons/apple-touch-icon.png'], 27 | manifest: { 28 | short_name: 'CMUEats', 29 | name: 'CMUEats', 30 | icons: [ 31 | { 32 | src: '/favicons/android-chrome-192x192.png', 33 | type: 'image/png', 34 | sizes: '192x192', 35 | purpose: 'any', 36 | }, 37 | { 38 | src: '/favicons/android-chrome-512x512.png', 39 | type: 'image/png', 40 | sizes: '512x512', 41 | purpose: 'any', 42 | }, 43 | { 44 | src: '/favicons/android-chrome-192x192-maskable.png', 45 | type: 'image/png', 46 | sizes: '192x192', 47 | purpose: 'maskable', 48 | }, 49 | { 50 | src: '/favicons/android-chrome-512x512-maskable.png', 51 | type: 'image/png', 52 | sizes: '512x512', 53 | purpose: 'maskable', 54 | }, 55 | ], 56 | scope: '/', 57 | start_url: '/', 58 | display: 'standalone', 59 | theme_color: '#ffffff', 60 | background_color: '#ffffff', 61 | }, 62 | }; 63 | 64 | export default defineConfig(({ command, mode }) => { 65 | const parsedResult = preInitEnvSchema.safeParse(loadEnv(mode, process.cwd(), '')); 66 | if (parsedResult.error) { 67 | console.error( 68 | 'Missing one or more .env variables! Make sure you have a .env file locally.', 69 | parsedResult.error, 70 | ); 71 | process.exit(1); 72 | } 73 | const env = parsedResult.data; 74 | 75 | const iat = Date.now() / 1000; 76 | const payload = { 77 | iat, 78 | exp: iat + env.MAPKIT_JS_TTL, 79 | iss: env.MAPKIT_JS_TEAM_ID, 80 | origin: env.MAPKIT_ALLOWED_ORIGINS, 81 | }; 82 | 83 | const header = { 84 | typ: 'JWT', 85 | alg: 'ES256', 86 | kid: env.MAPKIT_JS_KEY_ID, 87 | }; 88 | 89 | try { 90 | const token = jwt.sign(payload, atob(env.MAPKIT_JS_AUTH_KEY), { header }); 91 | // eslint-disable-next-line no-console 92 | console.info({ 93 | title: 'MapKit JS token generated successfully', 94 | summary: `Origin: ${env.MAPKIT_ALLOWED_ORIGINS}, expires in ${env.MAPKIT_JS_TTL} seconds.`, 95 | text: `process.env.VITE_AUTO_GENERATED_MAPKITJS_TOKEN = '${token}';`, 96 | }); 97 | process.env.VITE_AUTO_GENERATED_MAPKITJS_TOKEN = token; 98 | } catch (error) { 99 | console.error(error); 100 | process.exit(1); 101 | } 102 | 103 | return { 104 | plugins: [ 105 | react(), 106 | viteTsconfigPaths(), 107 | svgrPlugin(), 108 | VitePWA({ 109 | manifest: manifestForPlugin, 110 | registerType: 'autoUpdate', 111 | workbox: { 112 | cleanupOutdatedCaches: true, 113 | skipWaiting: true, 114 | }, 115 | selfDestroying: true, 116 | }), 117 | checker({ 118 | typescript: true, 119 | }), 120 | ], 121 | build: { 122 | rollupOptions: { 123 | external: ['jsonwebtoken'], 124 | }, 125 | }, 126 | }; 127 | }); 128 | -------------------------------------------------------------------------------- /src/assets/control_button/pinned.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/types/locationTypes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | 3 | import { CardViewPreference } from '../util/storage'; 4 | 5 | /** Note that everything being exported here is readonly */ 6 | 7 | export type RecursiveReadonly = T extends object 8 | ? { 9 | readonly [P in keyof T]: RecursiveReadonly; 10 | } 11 | : T; 12 | 13 | /** 14 | * Describes either start or end time in any given ITimeSlot 15 | */ 16 | export interface ITimeSlot { 17 | /** 0-6 (0 is Sunday, 6 is Saturday) */ 18 | readonly day: number; 19 | /** 0-23 - 0 means 12AM */ 20 | readonly hour: number; 21 | /** 0-59 */ 22 | readonly minute: number; 23 | } 24 | 25 | /** 26 | * Start and end are both inclusive in 27 | * denoting when a location is open. so [2AM today, 4AM today] 28 | * includes both 2AM and 4AM. So, [2AM Tue,2AM Tue] is inferred to be open at exactly 2AM. 29 | * Something like [2AM Tue, 1AM Tue] means open from 2AM Tue 30 | * this week to 1AM Tue next week (notation: [start,end]), although it seems that the 31 | * API returns a wrapped around time iff the location closes at midnight on Saturday 32 | * (ex. dining website says "SATURDAY: 11:00 AM - 12:00 AM" which gets parsed to 33 | * [Sat 10AM, Sun 12AM] = [day:6,hour:10 -> day:0, hour: 0]) (recall that Sunday has a day-value 34 | * of 0 while Saturday has a day value of 6, so this is a wrap around) Any other time 35 | * would be constrained to that day and possibly 12AM on the day after. 36 | */ 37 | export interface ITimeRange { 38 | readonly start: ITimeSlot; 39 | readonly end: ITimeSlot; 40 | } 41 | 42 | /** 43 | * We expect this to be sorted (by start time), non-overlapping, 44 | * and not wrapping (aka end time found in minutesFromSunday is less than 45 | * start time) except for possibly the last entry 46 | */ 47 | export type ITimeRangeList = ReadonlyArray; 48 | 49 | interface ISpecial { 50 | title: string; 51 | description: string; 52 | } 53 | 54 | // Ordered by priority - affects how tiles are displayed in the grid (first to last) 55 | export enum LocationState { 56 | OPEN, 57 | CLOSES_SOON, 58 | OPENS_SOON, 59 | CLOSED, 60 | CLOSED_LONG_TERM, 61 | } 62 | 63 | /** 64 | * Raw type directly from API - types below extend this 65 | * (note: if you're updating this, you should 66 | * update the Joi Schema in joiLocationTypes.ts as well) 67 | */ 68 | interface ILocation_FromAPI_PreProcessed { 69 | conceptId: number; 70 | name?: string; 71 | shortDescription?: string; 72 | description: string; 73 | url: string; 74 | /** Menu link */ 75 | menu?: string; 76 | location: string; 77 | coordinates?: { 78 | lat: number; 79 | lng: number; 80 | }; 81 | acceptsOnlineOrders: boolean; 82 | times: ITimeRange[]; 83 | todaysSpecials?: ISpecial[]; 84 | todaysSoups?: ISpecial[]; 85 | } 86 | interface ILocation_FromAPI_PostProcessed extends ILocation_FromAPI_PreProcessed { 87 | name: string; // This field is now guaranteed to be defined 88 | } 89 | // All of the following are extended from the base API type 90 | 91 | // 'closedLongTerm' here refers to closed for the next 7 days (no timeslots available) 92 | interface ILocation_TimeStatusData_Base { 93 | /** No forseeable opening times after *now* */ 94 | closedLongTerm: boolean; 95 | statusMsg: string; 96 | locationState: LocationState; 97 | } 98 | interface ILocation_TimeStateData_NotPermanentlyClosed extends ILocation_TimeStatusData_Base { 99 | closedLongTerm: false; 100 | isOpen: boolean; 101 | timeUntil: number; 102 | changesSoon: boolean; 103 | locationState: Exclude; 104 | } 105 | interface ILocation_TimeStatusData_PermanentlyClosed extends ILocation_TimeStatusData_Base { 106 | closedLongTerm: true; 107 | locationState: LocationState.CLOSED_LONG_TERM; 108 | } 109 | 110 | export type ILocation_TimeStatusData = 111 | | ILocation_TimeStateData_NotPermanentlyClosed 112 | | ILocation_TimeStatusData_PermanentlyClosed; 113 | 114 | /** What we get directly from the API (single location data) */ 115 | export type IReadOnlyLocation_FromAPI_PreProcessed = RecursiveReadonly; 116 | 117 | export type IReadOnlyLocation_FromAPI_PostProcessed = RecursiveReadonly; 118 | 119 | /** Extra data derived from a single location */ 120 | export type IReadOnlyLocation_ExtraData = RecursiveReadonly< 121 | ILocation_TimeStatusData & { 122 | cardViewPreference: CardViewPreference; 123 | } 124 | >; 125 | 126 | /** we'll typically pass this into components for efficient look-up of extra data (like time until close) */ 127 | export type IReadOnlyLocation_ExtraData_Map = { 128 | [conceptId: number]: IReadOnlyLocation_ExtraData; 129 | }; 130 | 131 | /** once we combine extraDataMap with our base api data */ 132 | export type IReadOnlyLocation_Combined = IReadOnlyLocation_FromAPI_PostProcessed & IReadOnlyLocation_ExtraData; 133 | -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation } from 'react-router-dom'; 2 | import * as motion from 'motion/react-client'; 3 | import clsx from 'clsx'; 4 | import css from './Navbar.module.css'; 5 | 6 | const tabs = [ 7 | { 8 | icon: ( 9 | 16 | 24 | 25 | ), 26 | text: 'Locations', 27 | link: '/', 28 | }, 29 | { 30 | icon: ( 31 | 38 | 47 | 48 | ), 49 | text: 'Map', 50 | link: '/map', 51 | }, 52 | { 53 | icon: ( 54 | 61 | 71 | 72 | ), 73 | text: 'Feedback and Issues', 74 | link: 'https://forms.gle/7JxgdgDhWMznQJdk9', 75 | external: true, 76 | }, 77 | ]; 78 | function Navbar() { 79 | const location = useLocation(); 80 | 81 | return ( 82 | 113 | ); 114 | } 115 | 116 | export default Navbar; 117 | -------------------------------------------------------------------------------- /src/pages/MapPage.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useRef } from 'react'; 2 | import { Map, Marker, ColorScheme, PointOfInterestCategory } from 'mapkit-react'; 3 | import { CSSTransition } from 'react-transition-group'; 4 | import EateryCard from '../components/EateryCard'; 5 | import './MapPage.css'; 6 | import { IReadOnlyLocation_Combined } from '../types/locationTypes'; 7 | import { mapMarkerBackgroundColors, mapMarkerTextColors } from '../constants/colors'; 8 | import env from '../env'; 9 | 10 | function abbreviate(longName: string) { 11 | const importantPart = longName.split(/(-|\(|'|&| at )/i)[0]!.trim(); 12 | return importantPart 13 | .split(' ') 14 | .map((word) => word.charAt(0)) 15 | .join(''); 16 | } 17 | 18 | /** 19 | * 20 | * @param varString if input is var(XXX), we get back XXX 21 | * @returns 22 | */ 23 | const stripVarFromString = (varString: string) => varString.match(/var\((.+)\)/)?.[1] ?? ''; 24 | 25 | function MapPage({ locations }: { locations: IReadOnlyLocation_Combined[] | undefined }) { 26 | const [selectedLocationIndex, setSelectedLocationIndex] = useState(); 27 | const [isDrawerVisible, setDrawerVisible] = useState(false); 28 | const drawerRef = useRef(null); 29 | 30 | const cameraBoundary = useMemo( 31 | () => ({ 32 | centerLatitude: 40.444, 33 | centerLongitude: -79.945, 34 | latitudeDelta: 0.006, 35 | longitudeDelta: 0.01, 36 | }), 37 | [], 38 | ); 39 | 40 | const initialRegion = useMemo( 41 | () => ({ 42 | centerLatitude: 40.44316701238923, 43 | centerLongitude: -79.9431147637379, 44 | latitudeDelta: 0.006337455593801167, 45 | longitudeDelta: 0.011960061265583022, 46 | }), 47 | [], 48 | ); 49 | const derivedRootColors = useMemo(() => window.getComputedStyle(document.body), []); 50 | return ( 51 |
52 | {locations && ( 53 | <> 54 | 65 | {locations.map((location, locationIndex) => { 66 | if (!location.coordinates) return undefined; 67 | const bgColor = derivedRootColors.getPropertyValue( 68 | stripVarFromString(mapMarkerBackgroundColors[location.locationState]), 69 | ); // mapkit doesn't accept css variables, so we'll go ahead and get the actual color value from :root first 70 | const textColor = derivedRootColors.getPropertyValue( 71 | stripVarFromString(mapMarkerTextColors[location.locationState]), 72 | ); 73 | return ( 74 | { 82 | setSelectedLocationIndex(locationIndex); 83 | setDrawerVisible(true); 84 | }} 85 | onDeselect={() => { 86 | if (selectedLocationIndex === locationIndex) { 87 | setDrawerVisible(false); 88 | } 89 | }} 90 | /> 91 | ); 92 | })} 93 | 94 | 102 |
103 | {selectedLocationIndex !== undefined && ( 104 | {}} 107 | /> 108 | )} 109 |
110 |
111 | 112 | )} 113 |
114 | ); 115 | } 116 | 117 | export default MapPage; 118 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { DateTime } from 'luxon'; 3 | import env from '../env'; 4 | import css from './Footer.module.css'; 5 | import SponsorCarousel from './SponsorCarousel'; 6 | import { useThemeContext } from '../ThemeProvider'; 7 | import footerMikuUrl from '../assets/miku/miku2.png'; 8 | 9 | export default function Footer({ now }: { now: DateTime }) { 10 | const [emails, setEmails] = useState<{ name: string; email: string }[]>([]); 11 | const { theme } = useThemeContext(); 12 | const nowString = now.toLocaleString({ 13 | weekday: 'short', 14 | month: 'short', 15 | day: '2-digit', 16 | hour: 'numeric', 17 | minute: '2-digit', 18 | second: '2-digit', 19 | }); 20 | 21 | // Fetch emails on mount 22 | useEffect(() => { 23 | async function fetchEmails() { 24 | try { 25 | const res = await fetch(`${env.VITE_API_URL}/api/emails`); 26 | const json = await res.json(); 27 | setEmails(json); 28 | } catch (err) { 29 | console.error('Failed to fetch emails:', err); 30 | } 31 | } 32 | fetchEmails(); 33 | }, []); 34 | return ( 35 |
36 |
37 | {theme === 'miku' ? ( 38 |

39 | Blue hair, blue tie, hiding in your wifi 40 |
41 | All times are displayed in Pittsburgh local time ({nowString}). 42 |

43 | ) : ( 44 | <> 45 |

All times are displayed in Pittsburgh local time ({nowString}).

46 |

47 | If you encounter any problems, please fill out our{' '} 48 | 49 | feedback form 50 | {' '} 51 | (the fastest way to reach us!). 52 |

53 |

54 | Otherwise, reach out to{' '} 55 | {emails.length > 0 ? ( 56 | emails.map((person, idx) => ( 57 | 58 | 59 | {person.name} 60 | 61 | {idx < emails.length - 2 ? ', ' : ''} 62 | {/* eslint-disable-next-line no-nested-ternary */} 63 | {idx === emails.length - 2 ? (emails.length > 2 ? ', or ' : ' or ') : ''} 64 | 65 | )) 66 | ) : ( 67 | 68 | 69 | ScottyLabs 70 | 71 | 72 | )} 73 | . 74 |

75 |

76 | To provide feedback on your dining experience, please contact{' '} 77 | 78 | Dining Services 79 | {' '} 80 | or take the{' '} 81 | 82 | dining survey 83 | 84 | . 85 |

86 |

87 | Made with ❤️ by the{' '} 88 | 89 | ScottyLabs 90 | {' '} 91 | Tech Committee (not the official{' '} 92 | 98 | dining website 99 | 100 | ). 101 |

102 | 103 | )} 104 |

105 | cmu 106 | :eats 107 |

108 |
109 |
110 | 111 |
112 | {theme === 'miku' && miku!} 113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/assets/logos/agentuity-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/EateryCardGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@mui/material'; 2 | import { useState } from 'react'; 3 | import { AnimatePresence } from 'motion/react'; 4 | import EateryCard from '../components/EateryCard'; 5 | import EateryCardSkeleton from '../components/EateryCardSkeleton'; 6 | import NoResultsError from '../components/NoResultsError'; 7 | import { LocationState, IReadOnlyLocation_Combined } from '../types/locationTypes'; 8 | import assert from '../util/assert'; 9 | 10 | import css from './EateryCardGrid.module.css'; 11 | 12 | import DropdownArrow from '../assets/control_button/dropdown_arrow.svg?react'; 13 | import { CardViewPreference } from '../util/storage'; 14 | 15 | const compareLocations = (location1: IReadOnlyLocation_Combined, location2: IReadOnlyLocation_Combined) => { 16 | const state1 = location1.locationState; 17 | const state2 = location2.locationState; 18 | 19 | if (state1 !== state2) return state1 - state2; 20 | 21 | // this if statement is janky but otherwise TS won't 22 | // realize that the timeUntil property exists on both l1 and l2 23 | if (location1.closedLongTerm || location2.closedLongTerm) { 24 | assert(location1.closedLongTerm && location2.closedLongTerm); 25 | return location1.name.localeCompare(location2.name); 26 | } 27 | if (state1 === LocationState.OPEN || state1 === LocationState.CLOSES_SOON) { 28 | return location2.timeUntil - location1.timeUntil; 29 | } 30 | return location1.timeUntil - location2.timeUntil; 31 | }; 32 | 33 | export default function EateryCardGrid({ 34 | locations, 35 | setSearchQuery, 36 | shouldAnimateCards, 37 | apiError, 38 | updateCardViewPreference, 39 | }: { 40 | locations: IReadOnlyLocation_Combined[] | undefined; 41 | setSearchQuery: React.Dispatch; 42 | shouldAnimateCards: boolean; 43 | apiError: boolean; 44 | updateCardViewPreference: (id: string, newStatus: CardViewPreference) => void; 45 | }) { 46 | const [showHiddenSection, setShowHiddenSection] = useState(false); 47 | 48 | if (locations === undefined) { 49 | // Display skeleton cards while loading 50 | return ( 51 | 52 | {Array(36) 53 | .fill(null) 54 | .map((_, index) => ( 55 | 60 | ))} 61 | 62 | ); 63 | } 64 | 65 | if (apiError) 66 | return ( 67 |

68 | Oops! We received an invalid API response (or no data at all). If this problem persists, please visit 69 | GrubHub or{' '} 70 | 71 | https://apps.studentaffairs.cmu.edu/dining/conceptinfo/ 72 | {' '} 73 | for now 74 |

75 | ); 76 | 77 | if (locations.length === 0) return setSearchQuery('')} />; 78 | 79 | const sortedLocations = [...locations].sort(compareLocations); // we make a copy to avoid mutating the original array 80 | 81 | function locationToCard(location: IReadOnlyLocation_Combined) { 82 | return ( 83 | { 89 | updateCardViewPreference(location.conceptId.toString(), newPreference); 90 | }} 91 | /> 92 | ); 93 | } 94 | 95 | const hiddenLocations = sortedLocations.filter((location) => location.cardViewPreference === 'hidden'); 96 | 97 | return ( 98 |
99 | 100 | 101 | {[ 102 | ...sortedLocations.filter((location) => location.cardViewPreference === 'pinned'), 103 | ...sortedLocations.filter((location) => location.cardViewPreference === 'normal'), 104 | ].map(locationToCard)} 105 | 106 | 107 | 108 | {hiddenLocations.length > 0 && ( 109 |
110 | 122 | {showHiddenSection && ( 123 | 124 | {hiddenLocations.map(locationToCard)} 125 | 126 | )} 127 |
128 | )} 129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 3 | 4 | import { DateTime } from 'luxon'; 5 | import { motion } from 'motion/react'; 6 | import Navbar from './components/Navbar'; 7 | import ListPage from './pages/ListPage'; 8 | import MapPage from './pages/MapPage'; 9 | import NotFoundPage from './pages/NotFoundPage'; 10 | import { queryLocations, getLocationStatus } from './util/queryLocations'; 11 | import './App.css'; 12 | import { IReadOnlyLocation_FromAPI_PostProcessed, IReadOnlyLocation_Combined } from './types/locationTypes'; 13 | import { useUserCardViewPreferences } from './util/storage'; 14 | import env from './env'; 15 | import scottyDog from './assets/banner/scotty-dog.svg'; 16 | import closeButton from './assets/banner/close-button.svg'; 17 | import useLocalStorage from './util/localStorage'; 18 | import useRefreshWhenBackOnline from './util/network'; 19 | 20 | const BACKEND_LOCATIONS_URL = 21 | env.VITE_API_URL === 'locations.json' ? '/locations.json' : `${env.VITE_API_URL}/locations`; 22 | 23 | function App() { 24 | // Load locations 25 | const [locations, setLocations] = useState(); 26 | const [now, setNow] = useState(DateTime.now().setZone('America/New_York')); 27 | const [cardViewPreferences, setCardViewPreferences] = useUserCardViewPreferences(); 28 | 29 | useRefreshWhenBackOnline(); 30 | 31 | useEffect(() => { 32 | queryLocations(BACKEND_LOCATIONS_URL).then(setLocations); 33 | }, []); 34 | 35 | useEffect(() => { 36 | const intervalId = setInterval(() => setNow(DateTime.now().setZone('America/New_York')), 1000); 37 | return () => clearInterval(intervalId); 38 | }, []); 39 | 40 | const fullLocationData: IReadOnlyLocation_Combined[] | undefined = locations?.map((location) => ({ 41 | ...location, 42 | ...getLocationStatus(location.times, now), 43 | cardViewPreference: cardViewPreferences[location.conceptId] ?? 'normal', 44 | })); 45 | 46 | return ( 47 | 48 | 49 |
50 | {/* */} 51 | {/*
52 | CMUEats is now up to date with the official dining website! Sorry for the inconvenience. 53 | >_< 54 |
*/} 55 |
56 | 57 | { 63 | const newPreferences = { ...cardViewPreferences, [id]: preference }; 64 | setCardViewPreferences(newPreferences); 65 | }} 66 | now={now} 67 | /> 68 | } 69 | /> 70 | } /> 71 | } /> 72 | 73 |
74 | 75 |
76 |
77 |
78 | ); 79 | } 80 | 81 | /* eslint-disable @typescript-eslint/no-unused-vars */ 82 | // @ts-ignore 83 | function Banner() { 84 | const [closed, setIsClosed] = useLocalStorage('welcome-banner-closed'); 85 | const closeBanner = () => { 86 | setIsClosed('true'); 87 | }; 88 | 89 | return ( 90 | 95 |
96 |
97 |
98 | 99 | 100 | 101 | 106 | Register 107 | {' '} 108 | for Nova, ScottyLabs' GenAI Hackathon by Nov. 1st! 109 | 110 | 111 | 112 | 117 | Register 118 | {' '} 119 | for Nova by Nov. 1st! 120 | 121 |
122 |
123 | 126 |
127 |
128 | 129 | ); 130 | } 131 | export default App; 132 | -------------------------------------------------------------------------------- /src/util/queryLocations.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { DateTime } from 'luxon'; 4 | 5 | import { 6 | LocationState, 7 | ITimeSlot, 8 | IReadOnlyLocation_FromAPI_PostProcessed, 9 | ITimeRangeList, 10 | IReadOnlyLocation_FromAPI_PreProcessed, 11 | ILocation_TimeStatusData, 12 | } from '../types/locationTypes'; 13 | import { 14 | diffInMinutes, 15 | currentlyOpen, 16 | getNextTimeSlot, 17 | isTimeSlot, 18 | isValidTimeSlotArray, 19 | getTimeString, 20 | minutesSinceStartOfSundayTimeSlot, 21 | minutesSinceStartOfSundayDateTime, 22 | getApproximateTimeStringFromMinutes, 23 | } from './time'; 24 | import toTitleCase from './string'; 25 | import assert from './assert'; 26 | import { IAPIResponseJoiSchema, ILocationAPIJoiSchema } from '../types/joiLocationTypes'; 27 | import notifySlack from './slack'; 28 | 29 | const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 30 | /** 31 | * Return the status message for a dining location, given the current or next available 32 | * time slot, and whether or not the location is currently open 33 | * @param isOpen 34 | * @param nextTime Time data entry of next closing/opening time 35 | * @returns {string} The status message for the location 36 | */ 37 | export function getStatusMessage(isOpen: boolean, nextTime: ITimeSlot, now: DateTime): string { 38 | assert(isTimeSlot(nextTime)); 39 | const diff = diffInMinutes(nextTime, now); 40 | const weekdayDiff = 41 | nextTime.day - 42 | (now.weekday % 7) + // now.weekday returns 1-7 [mon-sun] instead of 0-6 [sun-sat] 43 | (minutesSinceStartOfSundayTimeSlot(nextTime) < minutesSinceStartOfSundayDateTime(now) ? 7 : 0); // nextTime wraps around to next week? Add 7 days to nextTime.day 44 | 45 | const time = getTimeString(nextTime); 46 | 47 | const action = isOpen ? 'Closes' : 'Opens'; 48 | let day = WEEKDAYS[nextTime.day]; 49 | 50 | if (weekdayDiff === 1) { 51 | day = 'tomorrow'; 52 | } else if (weekdayDiff === 0) { 53 | day = 'today'; 54 | } 55 | 56 | let relTimeDiff = getApproximateTimeStringFromMinutes(diff); 57 | const weekEdgeCase = Math.round(Math.floor(diff / 60) / 24); 58 | 59 | if (weekEdgeCase === 7) { 60 | relTimeDiff = 'a week'; 61 | } 62 | 63 | if (relTimeDiff === '0 minutes') { 64 | return `${action} now (${day} at ${time})`; 65 | } 66 | return `${action} in ${relTimeDiff} (${day} at ${time})`; 67 | } 68 | 69 | /** 70 | * changesSoon is if location closes/opens within 60 minutes 71 | * @param timeSlots 72 | * @param now 73 | * @returns 74 | */ 75 | export function getLocationStatus(timeSlots: ITimeRangeList, now: DateTime): ILocation_TimeStatusData { 76 | assert(isValidTimeSlotArray(timeSlots), `${JSON.stringify(timeSlots)} is invalid!`); 77 | const MINUTES_IN_A_WEEK = 60 * 24 * 7; 78 | const nextTimeSlot = getNextTimeSlot(timeSlots, now); 79 | if (nextTimeSlot === null) 80 | return { 81 | statusMsg: 'Closed until further notice', 82 | closedLongTerm: true, 83 | locationState: LocationState.CLOSED_LONG_TERM, 84 | }; 85 | if ( 86 | minutesSinceStartOfSundayTimeSlot(nextTimeSlot.start) === 0 && 87 | minutesSinceStartOfSundayTimeSlot(nextTimeSlot.end) === MINUTES_IN_A_WEEK - 1 88 | ) { 89 | // the very special case where the time interval represents the entire week 90 | return { 91 | statusMsg: 'Open 24/7', 92 | closedLongTerm: false, 93 | changesSoon: false, 94 | timeUntil: Infinity, 95 | locationState: LocationState.OPEN, 96 | isOpen: true, 97 | }; 98 | } 99 | const isOpen = currentlyOpen(nextTimeSlot, now); 100 | const relevantTime = isOpen ? nextTimeSlot.end : nextTimeSlot.start; // when will the next closing/opening event happen? 101 | const timeUntil = diffInMinutes(relevantTime, now); 102 | const statusMsg = getStatusMessage(isOpen, relevantTime, now); 103 | const changesSoon = timeUntil <= 60; 104 | // eslint-disable-next-line no-nested-ternary 105 | const locationState = isOpen 106 | ? changesSoon 107 | ? LocationState.CLOSES_SOON 108 | : LocationState.OPEN 109 | : changesSoon 110 | ? LocationState.OPENS_SOON 111 | : LocationState.CLOSED; 112 | 113 | return { 114 | closedLongTerm: false, 115 | isOpen, 116 | statusMsg, 117 | timeUntil, 118 | changesSoon, 119 | locationState, 120 | }; 121 | } 122 | 123 | export async function queryLocations(cmuEatsAPIUrl: string): Promise { 124 | try { 125 | // Query locations 126 | const { data } = await axios.get(cmuEatsAPIUrl); 127 | if (!data) { 128 | return []; 129 | } 130 | const { locations: rawLocations } = await IAPIResponseJoiSchema.validateAsync(data); 131 | 132 | // Check for invalid location data 133 | const validLocations = rawLocations.filter((location) => { 134 | const { error } = ILocationAPIJoiSchema.validate(location); 135 | if (error !== undefined) { 136 | console.error('Validation error!', error.details); 137 | // eslint-disable-next-line no-underscore-dangle 138 | console.error('original obj', error._original); 139 | notifySlack( 140 | ` ${location.name} has invalid data! Ignoring location and continuing validation. ${error}`, 141 | ); 142 | } 143 | return error === undefined; 144 | }) as IReadOnlyLocation_FromAPI_PreProcessed[]; 145 | return validLocations.map((location) => ({ 146 | ...location, 147 | name: toTitleCase(location.name ?? 'Untitled'), // Convert names to title case 148 | })); 149 | } catch (err: any) { 150 | console.error(err); 151 | notifySlack(` queryLocations failed with error ${err}`); 152 | return []; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/assets/logos/optiver-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/util/safeStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | import { safeGetItem, safeSetItem, safeRemoveItem, isStorageAvailable } from '../../src/util/safeStorage'; 3 | 4 | // Mock console.warn to avoid noise in test output 5 | const originalWarn = console.warn; 6 | beforeEach(() => { 7 | console.warn = vi.fn(); 8 | }); 9 | afterEach(() => { 10 | console.warn = originalWarn; 11 | }); 12 | 13 | describe('safeStorage utilities', () => { 14 | // Store original localStorage methods 15 | const originalGetItem = Storage.prototype.getItem; 16 | const originalSetItem = Storage.prototype.setItem; 17 | const originalRemoveItem = Storage.prototype.removeItem; 18 | 19 | afterEach(() => { 20 | // Restore original localStorage methods after each test 21 | Storage.prototype.getItem = originalGetItem; 22 | Storage.prototype.setItem = originalSetItem; 23 | Storage.prototype.removeItem = originalRemoveItem; 24 | localStorage.clear(); 25 | }); 26 | 27 | describe('safeGetItem', () => { 28 | test('returns value when localStorage works', () => { 29 | localStorage.setItem('testKey', 'testValue'); 30 | expect(safeGetItem('testKey')).toBe('testValue'); 31 | }); 32 | 33 | test('returns null when key does not exist', () => { 34 | expect(safeGetItem('nonExistentKey')).toBeNull(); 35 | }); 36 | 37 | test('returns null and logs warning when localStorage.getItem throws', () => { 38 | Storage.prototype.getItem = vi.fn(() => { 39 | throw new Error('localStorage disabled'); 40 | }); 41 | 42 | const result = safeGetItem('testKey'); 43 | expect(result).toBeNull(); 44 | expect(console.warn).toHaveBeenCalled(); 45 | }); 46 | }); 47 | 48 | describe('safeSetItem', () => { 49 | test('returns true and sets value when localStorage works', () => { 50 | const result = safeSetItem('testKey', 'testValue'); 51 | expect(result).toBe(true); 52 | expect(localStorage.getItem('testKey')).toBe('testValue'); 53 | }); 54 | 55 | test('returns false and logs warning when localStorage.setItem throws', () => { 56 | Storage.prototype.setItem = vi.fn(() => { 57 | throw new Error('localStorage disabled'); 58 | }); 59 | 60 | const result = safeSetItem('testKey', 'testValue'); 61 | expect(result).toBe(false); 62 | expect(console.warn).toHaveBeenCalled(); 63 | }); 64 | 65 | test('handles QuotaExceededError gracefully', () => { 66 | Storage.prototype.setItem = vi.fn(() => { 67 | const error = new Error('QuotaExceededError'); 68 | error.name = 'QuotaExceededError'; 69 | throw error; 70 | }); 71 | 72 | const result = safeSetItem('testKey', 'testValue'); 73 | expect(result).toBe(false); 74 | expect(console.warn).toHaveBeenCalled(); 75 | }); 76 | }); 77 | 78 | describe('safeRemoveItem', () => { 79 | test('returns true and removes item when localStorage works', () => { 80 | localStorage.setItem('testKey', 'testValue'); 81 | const result = safeRemoveItem('testKey'); 82 | expect(result).toBe(true); 83 | expect(localStorage.getItem('testKey')).toBeNull(); 84 | }); 85 | 86 | test('returns false and logs warning when localStorage.removeItem throws', () => { 87 | Storage.prototype.removeItem = vi.fn(() => { 88 | throw new Error('localStorage disabled'); 89 | }); 90 | 91 | const result = safeRemoveItem('testKey'); 92 | expect(result).toBe(false); 93 | expect(console.warn).toHaveBeenCalled(); 94 | }); 95 | }); 96 | 97 | describe('isStorageAvailable', () => { 98 | test('returns true when localStorage is working', () => { 99 | expect(isStorageAvailable()).toBe(true); 100 | }); 101 | 102 | test('returns false when localStorage.setItem throws', () => { 103 | Storage.prototype.setItem = vi.fn(() => { 104 | throw new Error('localStorage disabled'); 105 | }); 106 | 107 | expect(isStorageAvailable()).toBe(false); 108 | }); 109 | 110 | test('returns false when localStorage.removeItem throws', () => { 111 | Storage.prototype.removeItem = vi.fn(() => { 112 | throw new Error('localStorage disabled'); 113 | }); 114 | 115 | expect(isStorageAvailable()).toBe(false); 116 | }); 117 | 118 | test('leaves localStorage clean after successful availability check', () => { 119 | const result = isStorageAvailable(); 120 | expect(result).toBe(true); 121 | expect(localStorage.length).toBe(0); 122 | }); 123 | }); 124 | 125 | describe('integration scenarios', () => { 126 | test('handles completely disabled localStorage', () => { 127 | // Simulate completely disabled localStorage 128 | Storage.prototype.getItem = vi.fn(() => { 129 | throw new Error('localStorage disabled'); 130 | }); 131 | Storage.prototype.setItem = vi.fn(() => { 132 | throw new Error('localStorage disabled'); 133 | }); 134 | Storage.prototype.removeItem = vi.fn(() => { 135 | throw new Error('localStorage disabled'); 136 | }); 137 | 138 | expect(isStorageAvailable()).toBe(false); 139 | expect(safeGetItem('key')).toBeNull(); 140 | expect(safeSetItem('key', 'value')).toBe(false); 141 | expect(safeRemoveItem('key')).toBe(false); 142 | 143 | // Verify warnings were logged 144 | expect(console.warn).toHaveBeenCalledTimes(3); 145 | }); 146 | 147 | test('handles partial localStorage failures', () => { 148 | // Only setItem fails (like in private browsing with quota exceeded) 149 | Storage.prototype.setItem = vi.fn(() => { 150 | throw new Error('QuotaExceededError'); 151 | }); 152 | 153 | expect(isStorageAvailable()).toBe(false); 154 | expect(safeGetItem('existingKey')).toBeNull(); // No existing data 155 | expect(safeSetItem('key', 'value')).toBe(false); 156 | expect(safeRemoveItem('key')).toBe(true); // This should still work 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/pages/ListPage.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, styled } from '@mui/material'; 2 | import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'; 3 | import { DateTime } from 'luxon'; 4 | 5 | import { getGreetings } from '../util/greeting'; 6 | import './ListPage.css'; 7 | import { IReadOnlyLocation_Combined } from '../types/locationTypes'; 8 | import SelectLocation from '../components/SelectLocation'; 9 | import SearchBar from '../components/SearchBar'; 10 | import { useThemeContext } from '../ThemeProvider'; 11 | import IS_MIKU_DAY from '../util/constants'; 12 | import mikuKeychainUrl from '../assets/miku/miku-keychain.svg'; 13 | import mikuBgUrl from '../assets/miku/miku.jpg'; 14 | import EateryCardGrid from './EateryCardGrid'; 15 | import useFilteredLocations from './useFilteredLocations'; 16 | import { CardViewPreference } from '../util/storage'; 17 | import Footer from '../components/Footer'; 18 | 19 | const StyledAlert = styled(Alert)({ 20 | backgroundColor: 'var(--main-bg-accent)', 21 | color: 'var(--text-primary)', 22 | }); 23 | 24 | function ListPage({ 25 | locations, 26 | updateCardViewPreference, 27 | now, 28 | }: { 29 | locations: IReadOnlyLocation_Combined[] | undefined; 30 | now: DateTime; 31 | updateCardViewPreference: (id: string, newStatus: CardViewPreference) => void; 32 | }) { 33 | const shouldAnimateCards = useRef(true); 34 | const { theme, updateTheme } = useThemeContext(); 35 | 36 | // permanently cut out animation when user filters cards, 37 | // so we don't end up with some cards (but not others) 38 | // re-animating in when filter gets cleared 39 | const [searchQuery, setSearchQuery] = useReducer<(_: string, updated: string) => string>((_, newState) => { 40 | shouldAnimateCards.current = false; 41 | return newState; 42 | }, ''); 43 | 44 | const [locationFilterQuery, setLocationFilterQuery] = useReducer<(_: string, x: string) => string>( 45 | (_, newState) => { 46 | shouldAnimateCards.current = false; 47 | return newState; 48 | }, 49 | '', 50 | ); 51 | const mainContainerRef = useRef(null); 52 | useEffect(() => { 53 | mainContainerRef.current?.focus(); 54 | }, []); 55 | const [showOfflineAlert, setShowOfflineAlert] = useState(!navigator.onLine); 56 | 57 | const { mobileGreeting, desktopGreeting } = useMemo( 58 | () => getGreetings(new Date().getHours(), { isMikuDay: IS_MIKU_DAY }), 59 | [], 60 | ); 61 | 62 | const filteredLocations = useFilteredLocations({ 63 | locations, 64 | searchQuery, 65 | locationFilterQuery, 66 | }); 67 | 68 | // Load query from URL 69 | useLayoutEffect(() => { 70 | const urlQuery = new URLSearchParams(window.location.search).get('search'); 71 | if (urlQuery) { 72 | setSearchQuery(urlQuery); 73 | } 74 | }, []); 75 | 76 | // Monitor for the user being online 77 | useEffect(() => { 78 | const handleOnlineStatus = () => { 79 | setShowOfflineAlert(!navigator.onLine); 80 | }; 81 | 82 | window.addEventListener('online', handleOnlineStatus); 83 | window.addEventListener('offline', handleOnlineStatus); 84 | 85 | return () => { 86 | window.removeEventListener('online', handleOnlineStatus); 87 | window.removeEventListener('offline', handleOnlineStatus); 88 | }; 89 | }, []); 90 | 91 | return ( 92 |
93 | {/* showAlert && 94 | setShowAlert(false)}> 95 | 🚧 [Issue Description] 96 | Please remain patient while we work on a fix. Thank you. 🚧 97 | */} 98 | {showOfflineAlert && ( 99 | setShowOfflineAlert(false)}> 100 | 🚫🌐 We are temporarily unable to provide the latest available dining information or the map while 101 | you are offline. We apologize for any inconvenience. 🌐🚫 102 | 103 | )} 104 | 105 |
106 |
107 |
108 |

109 | {desktopGreeting} 110 |

111 |

112 | {mobileGreeting} 113 |

114 |
115 | 116 | 117 | {IS_MIKU_DAY && ( 118 | 129 | )} 130 |
131 | 132 | { 139 | shouldAnimateCards.current = false; 140 | updateCardViewPreference(id, preference); 141 | }} 142 | /> 143 |
144 |
145 | 146 |
147 | ); 148 | } 149 | 150 | export default ListPage; 151 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root, 4 | .App { 5 | height: 100%; 6 | } 7 | 8 | html, 9 | body { 10 | margin: 0; 11 | padding: 0; 12 | font-family: 13 | 'Inter', 14 | -apple-system, 15 | BlinkMacSystemFont, 16 | 'Segoe UI', 17 | 'Roboto', 18 | 'Oxygen', 19 | 'Ubuntu', 20 | 'Cantarell', 21 | 'Fira Sans', 22 | 'Droid Sans', 23 | 'Helvetica Neue', 24 | sans-serif; 25 | text-rendering: optimizeLegibility; 26 | -moz-osx-font-smoothing: grayscale; 27 | -webkit-font-smoothing: antialiased; 28 | -webkit-text-size-adjust: 100%; 29 | background: var(--main-bg); 30 | } 31 | 32 | .App { 33 | text-align: left; 34 | display: flex; 35 | flex-direction: column; 36 | } 37 | 38 | .MainContent { 39 | flex: 1; 40 | /* min-height: 0; */ 41 | /* overflow-y: auto; */ 42 | &:focus { 43 | outline: none; 44 | } 45 | } 46 | 47 | .App-header { 48 | background-color: #282c34; 49 | min-height: 100vh; 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | justify-content: center; 54 | font-size: calc(10px + 2vmin); 55 | color: white; 56 | } 57 | 58 | .App-link { 59 | color: #61dafb; 60 | } 61 | 62 | .MuiCard-root { 63 | display: flex; 64 | flex-direction: column; 65 | justify-content: space-between; 66 | } 67 | 68 | .announcement { 69 | padding: 16px; 70 | font-size: 1.2em; 71 | color: white; 72 | text-align: center; 73 | background-color: #23272a; 74 | } 75 | 76 | .AdBanner { 77 | /* display: none; */ 78 | padding: 16px; 79 | font-size: 20.8px; 80 | @media (max-width: 386px) { 81 | font-size: 17.6px; 82 | } 83 | color: white; 84 | text-align: center; 85 | background-color: hsl(155, 90%, 30%); 86 | border-bottom: 2px solid hsl(120, 70%, 15%); 87 | font-weight: 600; 88 | } 89 | 90 | .AdBannerLink { 91 | color: white; 92 | } 93 | 94 | .welcome-banner-container { 95 | overflow: hidden; 96 | position: sticky; 97 | top: 0%; 98 | z-index: 727; 99 | } 100 | .welcome-banner-padding { 101 | padding: 20px 15px; 102 | &.welcome-banner-padding--button { 103 | padding-left: 0; 104 | padding-right: 0; 105 | /* so it grows the same amt as spacer on left */ 106 | } 107 | } 108 | .welcome-banner { 109 | display: flex; 110 | align-items: start; 111 | font-family: var(--text-primary-font); 112 | font-size: 28px; 113 | font-weight: 200; 114 | background-image: url('./assets/banner/bg-wave.svg?inline'); 115 | background-position: top center; 116 | background-color: #1c1d20; 117 | border-bottom: 2px solid hsl(180, 3%, 8%); 118 | 119 | background-size: max(100vw, 1400px) 100%; 120 | background-repeat: no-repeat; 121 | 122 | & > .welcome-banner__spacer { 123 | flex-grow: 1; 124 | } 125 | & > .welcome-banner__text { 126 | isolation: isolate; 127 | align-self: stretch; 128 | position: relative; 129 | font-size: 20px; 130 | width: fit-content; 131 | display: flex; 132 | align-items: center; 133 | 134 | /* for a fade-out illusion, we create a second copy of the lines with the color the same as the bg but with dimensions equal to the text */ 135 | &::before { 136 | content: ''; 137 | position: absolute; 138 | inset: 0; 139 | z-index: -1; 140 | background-image: url('./assets/banner/fg-wave.svg?inline'); 141 | background-position: top center; 142 | background-size: max(100vw, 1400px) 100%; 143 | background-repeat: no-repeat; 144 | 145 | mask-image: linear-gradient( 146 | to right, 147 | transparent 0%, 148 | rgba(0, 0, 0, 0.88) 10%, 149 | rgba(0, 0, 0, 0.88) 90%, 150 | transparent 100% 151 | ); 152 | @media (max-width: 538px) { 153 | /* at around this point the container is no longer centered and thus can't act as a opacity filter */ 154 | content: none; 155 | } 156 | } 157 | 158 | & > .welcome-banner__text--long { 159 | display: flex; 160 | align-items: center; 161 | gap: 15px; 162 | & img { 163 | height: 1.3em; 164 | } 165 | & span { 166 | /* firefox support something something */ 167 | text-wrap: nowrap; 168 | } 169 | } 170 | & > .welcome-banner__text--long { 171 | display: flex; 172 | } 173 | & > .welcome-banner__text--short { 174 | display: none; 175 | } 176 | @media (max-width: 1073px) { 177 | & > .welcome-banner__text--long { 178 | display: none; 179 | } 180 | & > .welcome-banner__text--short { 181 | display: inline; 182 | } 183 | } 184 | } 185 | 186 | & > .welcome-banner__close { 187 | flex-grow: 1; 188 | flex-shrink: 0; 189 | flex-basis: 0; /* so it grows the same amt as the spacer on the other side (instead of like slightly more) */ 190 | align-self: center; 191 | & > button { 192 | display: block; 193 | margin-left: auto; 194 | margin-right: 60px; 195 | @media (max-width: 900px) { 196 | margin-right: 10px; 197 | } 198 | background-color: transparent; 199 | border: none; 200 | cursor: pointer; 201 | & img { 202 | transition: 0.2s all ease-in-out; 203 | height: 20px; 204 | &:hover { 205 | filter: brightness(150%); 206 | rotate: 90deg; 207 | &:active { 208 | filter: brightness(120%); 209 | } 210 | } 211 | } 212 | } 213 | } 214 | } 215 | 216 | .outer-error-container { 217 | font-size: 20px; 218 | font-family: var(--text-primary-font); 219 | padding: 30px; 220 | margin: auto; 221 | text-align: center; 222 | & > img { 223 | display: block; 224 | margin: 20px auto; 225 | max-width: 100%; 226 | max-height: 300px; 227 | border-radius: 10px; 228 | } 229 | & > .outer-error-container__small-text { 230 | margin-top: 5px; 231 | font-size: 16px; 232 | color: hsl(206, 9%, 55%); 233 | word-break: break-word; 234 | @media screen and (max-width: 900px) { 235 | font-size: 16px; 236 | } 237 | } 238 | @media screen and (max-width: 900px) { 239 | font-size: 16px; 240 | text-align: left; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/assets/logos/flyio-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/iOS_splash_screens.txt: -------------------------------------------------------------------------------- 1 | Copy the tags below between the and tags in your HTML template 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | --------------------------------------------------------------------------------