diff --git a/src/App.tsx b/src/App.tsx index c0b46f48..f0928ef5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,7 +88,7 @@ function App() { }>
- + {/* */} {/*
CMUEats is now up to date with the official dining website! Sorry for the inconvenience. >_< diff --git a/src/assets/select.svg b/src/assets/select.svg index 9ce8e6a5..ae1a3ecd 100644 --- a/src/assets/select.svg +++ b/src/assets/select.svg @@ -1,4 +1,12 @@ - - - + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/SelectLocation.css b/src/components/SelectLocation.css index 44efa772..d7ba4079 100644 --- a/src/components/SelectLocation.css +++ b/src/components/SelectLocation.css @@ -1,13 +1,33 @@ +.filter-container { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filter-label { + color: var(--text-primary); + font-family: var(--text-primary-font); + font-weight: 500; + font-size: 1rem; + white-space: nowrap; +} + +@media screen and (min-width: 900px) { + .filter-label { + font-size: 1.2rem; + } +} + .select { appearance: none; cursor: pointer; display: block; - min-width: 500px; + min-width: 220px; width: fit-content; padding: 0.8rem 0.9rem; border-radius: 1rem; - background: var(--input-bg) url(../assets/select.svg) no-repeat calc(100% - 10px) 50%; + background: var(--input-bg) url(../assets/select.svg) no-repeat calc(100% - 5px) 50%; outline: none; border: 1px solid transparent; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0); @@ -23,6 +43,12 @@ opacity: 0; } +@media screen and (min-width: 900px) { + .select { + min-width: 300px; + } +} + .select:hover { transition: all 0.5s; box-shadow: 0 0 40px var(--hover-accent-color); diff --git a/src/components/SelectLocation.tsx b/src/components/SelectLocation.tsx index b1da29e3..aea9eb9c 100644 --- a/src/components/SelectLocation.tsx +++ b/src/components/SelectLocation.tsx @@ -14,10 +14,12 @@ function getPrimaryLocation(locationString: string) { function SelectLocation({ setLocationFilterQuery, locations }: SelectLocationProps) { if (locations === undefined) { return ( - +
+ Filter by building: + +
); } @@ -27,14 +29,17 @@ function SelectLocation({ setLocationFilterQuery, locations }: SelectLocationPro const dedeupedLocationStrings = [...new Set(locationStrings)]; return ( - +
+ Filter by building: + +
); } diff --git a/src/components/SortBy.css b/src/components/SortBy.css new file mode 100644 index 00000000..ae96f438 --- /dev/null +++ b/src/components/SortBy.css @@ -0,0 +1,34 @@ +.sort-container { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sort-label { + color: var(--text-primary); + font-family: var(--text-primary-font); + font-weight: 500; + font-size: 1rem; + white-space: nowrap; +} + +@media screen and (min-width: 900px) { + .sort-label { + font-size: 1.2rem; + } +} + +.sort-select { + width: 180px; + min-width: 0; +} + +.Locations-header__filters .select:not(.sort-select) { + width: 220px; +} + +@media screen and (min-width: 900px) { + .Locations-header__filters .select:not(.sort-select) { + width: 440px; + } +} diff --git a/src/components/SortBy.tsx b/src/components/SortBy.tsx new file mode 100644 index 00000000..0f8d7353 --- /dev/null +++ b/src/components/SortBy.tsx @@ -0,0 +1,119 @@ +import { useEffect } from 'react'; +import './SortBy.css'; +import { getUserToDestinationPath } from '../util/cmuMapsApi'; +import { IReadOnlyLocation_FromAPI_PostProcessed } from '../types/locationTypes'; + +interface SortByProps { + setSortBy: (sortBy: string) => void; + sortBy: string; + locations?: IReadOnlyLocation_FromAPI_PostProcessed[]; + onLocationDistancesCalculated?: (distances: Map) => void; +} + +function SortBy({ setSortBy, sortBy, locations, onLocationDistancesCalculated }: SortByProps) { + useEffect(() => { + if (sortBy === 'location' && locations && onLocationDistancesCalculated) { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + async (position) => { + const { latitude, longitude } = position.coords; + const distances = new Map(); + + const stackUnderground = locations.find((loc) => loc.name === "Stack'd Underground"); + const exchange = locations.find((loc) => loc.name === 'The Exchange'); + const tasteOfIndia = locations.find((loc) => loc.name === 'Taste Of India'); + const tepperTaqueria = locations.find((loc) => loc.name === 'Tepper Taqueria'); + const tahini = locations.find((loc) => loc.name === 'Tahini'); + + const modifiedLocations = locations.map((location) => { + if (location.name === "Stack'd Dessert Bar" && stackUnderground?.coordinates) { + return { + ...location, + coordinates: stackUnderground.coordinates, + }; + } + if (location.name === 'Zebra Lounge' && exchange?.coordinates) { + return { + ...location, + coordinates: exchange.coordinates, + }; + } + if (location.name === 'Sweet Plantain' && tasteOfIndia?.coordinates) { + return { + ...location, + coordinates: tasteOfIndia.coordinates, + }; + } + if (location.name === "De Fer Coffee & Tea At Resnik" && tasteOfIndia?.coordinates) { + return { + ...location, + coordinates: tasteOfIndia.coordinates, + }; + } + if (location.name === 'E.a.t. (evenings At Tepper) - Rohr Commons' && tepperTaqueria?.coordinates) { + return { + ...location, + coordinates: tepperTaqueria.coordinates, + }; + } + if (location.name === 'Fire And Stone' && tahini?.coordinates) { + return { + ...location, + coordinates: tahini.coordinates, + }; + } + return location; + }); + + const pathPromises = modifiedLocations + .filter((location) => location.coordinates) + .map(async (location) => { + try { + const pathData = await getUserToDestinationPath( + latitude, + longitude, + location.coordinates!.lat, + location.coordinates!.lng, + ); + + if (pathData) { + return { + conceptId: location.conceptId, + distance: pathData.Fastest.path.distance, + }; + } + return null; + } catch (error) { + return null; + } + }); + + const pathResults = await Promise.all(pathPromises); + pathResults.forEach((result) => { + if (result) { + distances.set(result.conceptId, result.distance); + } + }); + + onLocationDistancesCalculated(distances); + }, + () => { + // Silently handle geolocation errors + }, + ); + } + } + }, [sortBy, locations, onLocationDistancesCalculated]); + + return ( +
+ Sort by: + +
+ ); +} + +export default SortBy; diff --git a/src/pages/EateryCardGrid.tsx b/src/pages/EateryCardGrid.tsx index 374b6b0c..2a6fb1d2 100644 --- a/src/pages/EateryCardGrid.tsx +++ b/src/pages/EateryCardGrid.tsx @@ -18,6 +18,8 @@ export default function EateryCardGrid({ apiError, pinnedIds, updatePinnedIds, + sortBy, + locationDistances, }: { locations: IReadOnlyLocation_FromAPI_PostProcessed[] | undefined; extraLocationData: IReadOnlyLocation_ExtraData_Map | undefined; @@ -26,6 +28,8 @@ export default function EateryCardGrid({ apiError: boolean; pinnedIds: Record; updatePinnedIds: (newPinnedIds: Record) => void; + sortBy: string; + locationDistances: Map; }) { if (locations === undefined || extraLocationData === undefined) { // Display skeleton cards while loading @@ -59,6 +63,39 @@ export default function EateryCardGrid({ if (locations.length === 0) return setSearchQuery('')} />; const compareLocations = (location1: IReadOnlyLocation_Combined, location2: IReadOnlyLocation_Combined) => { + if (sortBy === 'location') { + const state1 = location1.locationState; + const state2 = location2.locationState; + + const getPriority = (state: LocationState) => { + if (state === LocationState.OPEN + || state === LocationState.CLOSES_SOON) return 0; + if (state === LocationState.OPENS_SOON) return 1; + if (state === LocationState.CLOSED + || state === LocationState.CLOSED_LONG_TERM) return 2; + return 3; + }; + + const priority1 = getPriority(state1); + const priority2 = getPriority(state2); + + if (priority1 !== priority2) { + return priority1 - priority2; + } + + const distance1 = locationDistances.get(location1.conceptId); + const distance2 = locationDistances.get(location2.conceptId); + + if (distance1 !== undefined && distance2 !== undefined) { + return distance1 - distance2; + } + + if (distance1 !== undefined) return -1; + if (distance2 !== undefined) return 1; + + return location1.name.localeCompare(location2.name); + } + const state1 = location1.locationState; const state2 = location2.locationState; diff --git a/src/pages/ListPage.css b/src/pages/ListPage.css index f47e2c63..95eb13f4 100644 --- a/src/pages/ListPage.css +++ b/src/pages/ListPage.css @@ -128,11 +128,83 @@ --right-cutoff: 100%; } } +.Locations-header__filters { + display: flex; + gap: 1rem; + align-items: center; +} + +.Locations-header__filters .sort-container { + order: 1; +} + +.Locations-header__filters .filter-container { + order: 2; +} + +@media screen and (max-width: 900px) { + .Locations-header__filters { + flex-direction: column; + gap: 0.5rem; + } + + .Locations-header__filters .sort-container, + .Locations-header__filters .filter-container { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .Locations-header__filters .select { + flex: 1; + min-width: 0; + } +} + @media screen and (min-width: 900px) { .Locations-header { grid-template-columns: 1fr 300px; align-items: center; } + + .Locations-header__filters { + justify-content: flex-start; + } +} + +.Locations-search { + display: block; + width: 100%; + align-self: start; + padding: 0.8rem 1rem; + padding-left: 3rem; + border-radius: 1rem; + background: var(--input-bg); + outline: none; + border: 1px solid transparent; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0); + transition: all 0.2s; + font-family: inherit; + font-size: 1rem; + 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"); + background-size: 20px; + background-repeat: no-repeat; + background-position: 1rem center; + color: var(--input-text); + font-weight: 500; + &::-webkit-search-decoration { + display: none; + } + &:focus { + transition: all 0.5s; + box-shadow: 0 0 40px var(--hover-accent-color); + border-color: var(--hover-accent-color); + outline: none; + } + &::placeholder { + color: var(--input-text-placeholder); + } } .card { diff --git a/src/pages/ListPage.tsx b/src/pages/ListPage.tsx index 6dcc8e3e..cbf66e5c 100644 --- a/src/pages/ListPage.tsx +++ b/src/pages/ListPage.tsx @@ -6,6 +6,7 @@ import { IReadOnlyLocation_ExtraData_Map, IReadOnlyLocation_FromAPI_PostProcesse import SelectLocation from '../components/SelectLocation'; import SearchBar from '../components/SearchBar'; +import SortBy from '../components/SortBy'; import { useTheme } from '../ThemeProvider'; import IS_MIKU_DAY from '../util/constants'; import mikuKeychainUrl from '../assets/miku/miku-keychain.svg'; @@ -74,6 +75,12 @@ function ListPage({ }, '', ); + + const [sortBy, setSortBy] = useReducer<(_: string, x: string) => string>((_, newState) => { + shouldAnimateCards.current = false; + return newState; + }, 'closing-time'); + const [locationDistances, setLocationDistances] = useState>(new Map()); const [emails, setEmails] = useState<{ name: string; email: string }[]>([]); const [showOfflineAlert, setShowOfflineAlert] = useState(!navigator.onLine); @@ -150,7 +157,17 @@ function ListPage({
- +
+ + +
{IS_MIKU_DAY && (