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 (
-
- {/* Keep label the same as the default option below to reduce loading jank */}
-
-
+
+ Filter by building:
+
+ None
+
+
);
}
@@ -27,14 +29,17 @@ function SelectLocation({ setLocationFilterQuery, locations }: SelectLocationPro
const dedeupedLocationStrings = [...new Set(locationStrings)];
return (
- setLocationFilterQuery(e.target.value)} className="select">
-
- {dedeupedLocationStrings.map((location) => (
-
- {location}
-
- ))}
-
+
+ Filter by building:
+ setLocationFilterQuery(e.target.value)} className="select">
+ None
+ {dedeupedLocationStrings.map((location) => (
+
+ {location}
+
+ ))}
+
+
);
}
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:
+ setSortBy(e.target.value)} value={sortBy} className="select sort-select">
+ Closing time
+ Location
+
+
+ );
+}
+
+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({