diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 58258b86..ccbe90bd 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -124,8 +124,8 @@ export type QuestionForm = { export type QuestionFormWithId = QuestionForm & Id; -export type Folder = { +export type Tag = { readonly name: string; - readonly userId: string; - readonly apartments: string[]; }; + +export type TagWithId = Tag & Id; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44d080b2..96bb92eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import LocationPage from './pages/LocationPage'; import axios from 'axios'; import { colors } from './colors'; import SearchResultsPage from './pages/SearchResultsPage'; +import ComparisonPage from './pages/ComparisonPage'; import { isAdmin } from './utils/adminTool'; const theme = createTheme({ @@ -172,6 +173,10 @@ const App = (): ReactElement => { path="/apartment/:aptId" component={() => } /> + } + /> void; + onConfirm: (apt: CardData) => void; + excludeIds: string[]; + user: firebase.User | null; +}; + +const useStyles = makeStyles(() => ({ + dialogPaper: { + maxWidth: 640, + width: '100%', + borderRadius: 12, + padding: 0, + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + padding: '24px 24px 0', + }, + title: { + fontWeight: 600, + fontSize: 22, + lineHeight: '32px', + color: colors.black, + }, + subtitle: { + fontSize: 14, + lineHeight: '20px', + color: colors.gray1, + marginTop: 4, + }, + tabRow: { + display: 'flex', + gap: 8, + padding: '16px 24px', + }, + tab: { + padding: '8px 20px', + borderRadius: 20, + border: '1px solid #eaeaea', + backgroundColor: 'transparent', + cursor: 'pointer', + fontSize: 14, + fontWeight: 500, + color: colors.gray1, + fontFamily: '"Work Sans", sans-serif', + transition: 'all 0.15s', + '&:hover': { + borderColor: colors.red1, + }, + }, + tabActive: { + borderColor: colors.red1, + color: colors.red1, + backgroundColor: 'transparent', + }, + content: { + padding: '0 24px', + minHeight: 300, + maxHeight: 420, + overflowY: 'auto' as const, + }, + searchField: { + marginBottom: 16, + }, + resultsList: { + display: 'flex', + flexDirection: 'column' as const, + gap: 12, + }, + savedGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: 12, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + gap: 12, + padding: '16px 24px 24px', + }, + cancelBtn: { + borderRadius: 20, + padding: '10px 28px', + textTransform: 'none' as const, + fontSize: 14, + fontWeight: 500, + color: colors.gray1, + backgroundColor: '#eaeaea', + '&:hover': { + backgroundColor: '#d5d5d5', + }, + }, + confirmBtn: { + borderRadius: 20, + padding: '10px 28px', + textTransform: 'none' as const, + fontSize: 14, + fontWeight: 500, + color: colors.white, + backgroundColor: colors.red1, + '&:hover': { + backgroundColor: colors.red7, + }, + '&:disabled': { + backgroundColor: colors.gray2, + color: colors.white, + }, + }, + emptyText: { + textAlign: 'center' as const, + color: colors.gray1, + padding: '40px 0', + }, + loading: { + display: 'flex', + justifyContent: 'center', + padding: '40px 0', + }, +})); + +const AddApartmentModal = ({ open, onClose, onConfirm, excludeIds, user }: Props) => { + const classes = useStyles(); + const [activeTab, setActiveTab] = useState<'saved' | 'search'>('saved'); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [savedApts, setSavedApts] = useState([]); + const [selectedApt, setSelectedApt] = useState(null); + const [loadingSaved, setLoadingSaved] = useState(false); + const [loadingSearch, setLoadingSearch] = useState(false); + const debounceRef = useRef(null); + + useEffect(() => { + if (!open) { + setSelectedApt(null); + setSearchQuery(''); + setSearchResults([]); + return; + } + if (user) { + fetchSavedApartments(); + } + }, [open, user]); + + const fetchSavedApartments = async () => { + setLoadingSaved(true); + try { + const curUser = await getUser(); + if (!curUser) return; + const token = await curUser.getIdToken(true); + const response = await axios.get( + '/api/saved-apartments', + createAuthHeaders(token) + ); + setSavedApts(response.data); + } catch (err) { + console.error('Error fetching saved apartments:', err); + } finally { + setLoadingSaved(false); + } + }; + + const fetchSearchResults = useCallback(async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + return; + } + setLoadingSearch(true); + try { + const response = await axios.get( + `/api/search-results?q=${encodeURIComponent(query)}` + ); + setSearchResults(response.data); + } catch (err) { + console.error('Error searching apartments:', err); + } finally { + setLoadingSearch(false); + } + }, []); + + const handleSearchChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setSearchQuery(val); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => fetchSearchResults(val), 300); + }; + + const handleSelect = (apt: CardData) => { + if (selectedApt?.buildingData.id === apt.buildingData.id) { + setSelectedApt(null); + } else { + setSelectedApt(apt); + } + }; + + const handleConfirm = () => { + if (selectedApt) { + onConfirm(selectedApt); + onClose(); + } + }; + + const filterExcluded = (items: CardData[]) => + items.filter((item) => !excludeIds.includes(item.buildingData.id)); + + const filteredSaved = filterExcluded(savedApts); + const filteredSearch = filterExcluded(searchResults); + + return ( + +
+
+ Add an apartment to compare + + Choose from your saved properties or search to add new apartments to your side-by-side + view + +
+ + + +
+ +
+ + +
+ +
+ {activeTab === 'search' && ( + <> + + + + + ), + }} + /> + {loadingSearch ? ( +
+ +
+ ) : filteredSearch.length > 0 ? ( +
+ {filteredSearch.map((apt) => ( + handleSelect(apt)} + /> + ))} +
+ ) : searchQuery.trim() ? ( + + No apartments found for "{searchQuery}" + + ) : ( + + Start typing to search for apartments + + )} + + )} + + {activeTab === 'saved' && ( + <> + {!user ? ( + + Sign in to see your saved properties + + ) : loadingSaved ? ( +
+ +
+ ) : filteredSaved.length > 0 ? ( +
+ {filteredSaved.map((apt) => ( + handleSelect(apt)} + /> + ))} +
+ ) : ( + You have no saved properties + )} + + )} +
+ +
+ + +
+
+ ); +}; + +export default AddApartmentModal; diff --git a/frontend/src/components/Comparison/AmenityIcon.tsx b/frontend/src/components/Comparison/AmenityIcon.tsx new file mode 100644 index 00000000..d1ee77dc --- /dev/null +++ b/frontend/src/components/Comparison/AmenityIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import CheckIcon from '@material-ui/icons/Check'; +import CloseIcon from '@material-ui/icons/Close'; +import RemoveIcon from '@material-ui/icons/Remove'; +import { colors } from '../../colors'; + +type Props = { + value: boolean | null | undefined; +}; + +const AmenityIcon = ({ value }: Props) => { + if (value === true) { + return ; + } + if (value === false) { + return ; + } + return ; +}; + +export default AmenityIcon; diff --git a/frontend/src/components/Comparison/SavedAptCard.tsx b/frontend/src/components/Comparison/SavedAptCard.tsx new file mode 100644 index 00000000..976b246d --- /dev/null +++ b/frontend/src/components/Comparison/SavedAptCard.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Typography, makeStyles } from '@material-ui/core'; +import { colors } from '../../colors'; +import HeartRating from '../utils/HeartRating'; +import { CardData } from '../../App'; +import savedIconFilled from '../../assets/saved-icon-filled.svg'; + +type Props = { + data: CardData; + selected: boolean; + onClick: () => void; +}; + +const useStyles = makeStyles(() => ({ + card: { + borderRadius: 8, + border: '1px solid #eaeaea', + overflow: 'hidden', + cursor: 'pointer', + transition: 'border-color 0.15s, background-color 0.15s', + '&:hover': { + borderColor: colors.red1, + }, + }, + cardSelected: { + borderColor: colors.red1, + borderWidth: 2, + backgroundColor: colors.red5, + }, + photoContainer: { + position: 'relative' as const, + width: '100%', + height: 140, + }, + photo: { + width: '100%', + height: '100%', + objectFit: 'cover' as const, + backgroundColor: '#eaeaea', + display: 'block', + }, + bookmarkIcon: { + position: 'absolute' as const, + top: 8, + right: 8, + width: 16, + height: 22, + }, + content: { + padding: '10px 12px', + }, + nameRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + name: { + fontWeight: 600, + fontSize: 14, + lineHeight: '20px', + color: colors.black, + }, + ratingSmall: { + display: 'flex', + alignItems: 'center', + gap: 2, + fontSize: 14, + fontWeight: 600, + color: colors.red1, + }, + address: { + fontSize: 12, + lineHeight: '18px', + color: colors.gray1, + }, + reviews: { + fontSize: 12, + lineHeight: '18px', + color: colors.gray1, + marginBottom: 6, + }, + metaRow: { + display: 'flex', + alignItems: 'center', + gap: 12, + marginTop: 4, + }, + metaItem: { + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 12, + color: colors.gray1, + }, + progressBar: { + width: '100%', + height: 4, + backgroundColor: '#eaeaea', + borderRadius: 2, + marginTop: 8, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: '#a8c8f0', + borderRadius: 2, + }, +})); + +const SavedAptCard = ({ data, selected, onClick }: Props) => { + const classes = useStyles(); + const { buildingData, numReviews, avgRating, avgPrice } = data; + + const priceDisplay = avgPrice + ? `$${Math.round((avgPrice / 1000) * 10) / 10}K` + : buildingData.price + ? `$${buildingData.price}` + : null; + + const bedDisplay = + buildingData.numBeds != null && buildingData.numBeds > 0 ? `${buildingData.numBeds} Bed` : null; + + return ( +
e.key === 'Enter' && onClick()} + > +
+ {buildingData.photos && buildingData.photos.length > 0 ? ( + {buildingData.name} + ) : ( +
+ )} + saved +
+ +
+
+
+ {buildingData.name} + {buildingData.address} + {numReviews} Reviews +
+ {avgRating && ( +
+ + {avgRating.toFixed(1)} +
+ )} +
+ +
+ {priceDisplay && {priceDisplay}} + {bedDisplay && {bedDisplay}} +
+ +
+
+
+
+
+ ); +}; + +export default SavedAptCard; diff --git a/frontend/src/components/Comparison/SearchResultCard.tsx b/frontend/src/components/Comparison/SearchResultCard.tsx new file mode 100644 index 00000000..740efc61 --- /dev/null +++ b/frontend/src/components/Comparison/SearchResultCard.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { Typography, makeStyles } from '@material-ui/core'; +import { colors } from '../../colors'; +import HeartRating from '../utils/HeartRating'; +import { CardData } from '../../App'; + +type Props = { + data: CardData; + selected: boolean; + onClick: () => void; +}; + +const useStyles = makeStyles(() => ({ + card: { + display: 'flex', + alignItems: 'center', + padding: 16, + borderRadius: 8, + border: '1px solid #eaeaea', + cursor: 'pointer', + transition: 'border-color 0.15s, background-color 0.15s', + '&:hover': { + borderColor: colors.red1, + }, + }, + cardSelected: { + borderColor: colors.red1, + borderWidth: 2, + backgroundColor: colors.red5, + }, + photo: { + width: 120, + height: 90, + borderRadius: 8, + objectFit: 'cover' as const, + backgroundColor: '#eaeaea', + flexShrink: 0, + }, + info: { + flex: 1, + marginLeft: 16, + minWidth: 0, + }, + name: { + fontWeight: 600, + fontSize: 18, + lineHeight: '26px', + color: colors.black, + }, + address: { + fontSize: 14, + lineHeight: '20px', + color: colors.gray1, + }, + reviews: { + fontSize: 14, + lineHeight: '20px', + color: colors.gray1, + }, + metaRow: { + display: 'flex', + alignItems: 'center', + gap: 16, + marginTop: 6, + }, + metaItem: { + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 14, + color: colors.gray1, + }, + ratingSection: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + flexShrink: 0, + marginLeft: 16, + }, + ratingValue: { + fontWeight: 700, + fontSize: 36, + lineHeight: 'normal', + color: colors.black, + }, +})); + +const SearchResultCard = ({ data, selected, onClick }: Props) => { + const classes = useStyles(); + const { buildingData, numReviews, avgRating, avgPrice } = data; + + const priceDisplay = avgPrice + ? `$${Math.round((avgPrice / 1000) * 10) / 10}K` + : buildingData.price + ? `$${buildingData.price}` + : null; + + const bedDisplay = + buildingData.numBeds != null && buildingData.numBeds > 0 ? `${buildingData.numBeds} Bed` : null; + + return ( +
e.key === 'Enter' && onClick()} + > + {buildingData.photos && buildingData.photos.length > 0 ? ( + {buildingData.name} + ) : ( +
+ )} + +
+ {buildingData.name} + {buildingData.address} + {numReviews} Reviews +
+ {priceDisplay && {priceDisplay}} + {bedDisplay && {bedDisplay}} +
+
+ +
+ + {avgRating ? avgRating.toFixed(1) : 'N/A'} + + +
+
+ ); +}; + +export default SearchResultCard; diff --git a/frontend/src/pages/ApartmentPage.tsx b/frontend/src/pages/ApartmentPage.tsx index 7ab7d85f..464406e6 100644 --- a/frontend/src/pages/ApartmentPage.tsx +++ b/frontend/src/pages/ApartmentPage.tsx @@ -8,6 +8,7 @@ import { Typography, makeStyles, Box, + Chip, } from '@material-ui/core'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; @@ -27,6 +28,7 @@ import { ApartmentWithId, DetailedRating, LocationTravelTimes, + TagWithId, } from '../../../common/types/db-types'; import Toast from '../components/utils/Toast'; import LinearProgress from '../components/utils/LinearProgress'; @@ -181,6 +183,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { showToast(setShowLandlordEmailError); }; const [showSaveSuccess, setShowSaveSuccess] = useState(false); + const [tags, setTags] = useState([]); const handleLike = async (likedId: string, targetType: 'review' | 'blogPost') => { try { @@ -234,6 +237,12 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { }); }, [aptId]); + useEffect(() => { + get(`/api/apts/${aptId}/tags`, { + callback: setTags, + }); + }, [aptId]); + // Fetch travel times data for the current apartment useEffect(() => { get(`/api/travel-times-by-id/${aptId}`, { @@ -770,6 +779,22 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { const InfoSection = landlordData && ( + + Tags + + {tags.length === 0 ? ( + No tags yet. + ) : ( + + {tags.map((tag) => ( + + ))} + + )} Location diff --git a/frontend/src/pages/ComparisonPage.tsx b/frontend/src/pages/ComparisonPage.tsx new file mode 100644 index 00000000..eb3a341e --- /dev/null +++ b/frontend/src/pages/ComparisonPage.tsx @@ -0,0 +1,582 @@ +import React, { ReactElement, useState, useEffect, useCallback } from 'react'; +import { Container, Typography, IconButton, Button, makeStyles } from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import AddIcon from '@material-ui/icons/Add'; +import { useHistory, useLocation } from 'react-router-dom'; +import { get } from '../utils/call'; +import { ApartmentWithId, LocationTravelTimes } from '../../../common/types/db-types'; +import { CardData } from '../App'; +import { useTitle } from '../utils'; +import { colors } from '../colors'; +import HeartRating from '../components/utils/HeartRating'; +import AmenityIcon from '../components/Comparison/AmenityIcon'; +import AddApartmentModal from '../components/Comparison/AddApartmentModal'; +import blackPinIcon from '../assets/ph_map-pin-fill.svg'; + +type Props = { + user: firebase.User | null; + setUser: React.Dispatch>; +}; + +type ComparisonApt = CardData & { + travelTimes?: LocationTravelTimes; +}; + +const MAX_SLOTS = 4; + +const amenityKeys = [ + 'parking', + 'heat', + 'internet', + 'furnished', + 'laundry', + 'kitchen', + 'pets', +] as const; + +const amenityLabels: Record = { + parking: 'Parking', + heat: 'Heat', + internet: 'Internet', + furnished: 'Furnished', + laundry: 'Laundry', + kitchen: 'Kitchen', + pets: 'Pets', +}; + +const useStyles = makeStyles(() => ({ + pageContainer: { + marginTop: 34, + marginBottom: 48, + }, + title: { + fontWeight: 600, + fontSize: 26, + lineHeight: '36px', + color: colors.black, + }, + subtitle: { + fontSize: 18, + lineHeight: '28px', + color: '#292929', + marginTop: 8, + }, + comparisonGrid: { + display: 'flex', + marginTop: 32, + maxWidth: '100%', + }, + labelColumn: { + minWidth: 93, + maxWidth: 93, + width: 93, + flexShrink: 0, + marginRight: 20, + }, + labelCell: { + height: 58, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + textAlign: 'right', + }, + labelText: { + fontSize: 18, + lineHeight: '28px', + fontWeight: 400, + color: colors.black, + whiteSpace: 'nowrap', + }, + locationLabelCell: { + height: 512, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + textAlign: 'right', + }, + columnsWrapper: { + display: 'flex', + gap: 16, + alignItems: 'flex-start', + flexWrap: 'nowrap' as const, + overflowX: 'auto' as const, + flex: 1, + minWidth: 0, + paddingBottom: 8, + }, + // Filled apartment column + aptColumn: { + border: '1px solid #eaeaea', + borderRadius: 12, + width: 287, + minWidth: 287, + maxWidth: 287, + flexShrink: 0, + flexGrow: 0, + position: 'relative' as const, + paddingTop: 36, + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + }, + closeButton: { + position: 'absolute' as const, + top: 8, + right: 8, + }, + aptName: { + fontSize: 26, + lineHeight: '36px', + fontWeight: 400, + textAlign: 'center' as const, + width: '100%', + color: colors.black, + padding: '0 32px', + }, + aptAddress: { + fontSize: 18, + lineHeight: '28px', + fontWeight: 400, + textAlign: 'center' as const, + width: '100%', + color: colors.black, + marginTop: 4, + }, + aptPhoto: { + width: '100%', + height: 218, + objectFit: 'cover' as const, + borderRadius: 4, + backgroundColor: '#eaeaea', + marginTop: 20, + }, + openPropertyButton: { + backgroundColor: colors.red1, + color: colors.white, + borderRadius: 123, + width: 'calc(100% - 24px)', + margin: '0 12px', + marginTop: 20, + padding: '12px 16px', + textTransform: 'none' as const, + fontWeight: 500, + fontSize: 16, + letterSpacing: '-0.32px', + '&:hover': { + backgroundColor: colors.red7, + }, + }, + ratingRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + width: '100%', + marginTop: 32, + marginBottom: 32, + }, + ratingValue: { + fontWeight: 700, + fontSize: 46, + color: colors.black, + lineHeight: 'normal', + }, + dataRow: { + height: 58, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + dataRowGray: { + backgroundColor: '#f5f5f5', + }, + dataRowWhite: { + backgroundColor: colors.white, + }, + dataText: { + fontSize: 18, + lineHeight: '28px', + fontWeight: 400, + color: colors.black, + }, + locationCell: { + width: '100%', + padding: 16, + backgroundColor: colors.white, + }, + mapPlaceholder: { + width: '100%', + height: 200, + borderRadius: 10, + backgroundColor: '#eaeaea', + marginBottom: 16, + }, + locationAddress: { + fontWeight: 600, + fontSize: 15.76, + color: colors.black, + lineHeight: 'normal', + marginBottom: 10, + }, + distanceLabel: { + fontSize: 12.8, + lineHeight: '22.4px', + fontWeight: 400, + color: colors.black, + marginBottom: 4, + }, + distanceRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 5, + }, + distanceName: { + display: 'flex', + alignItems: 'center', + gap: 6, + }, + pinIcon: { + width: 19, + height: 19, + }, + distanceText: { + fontSize: 12.8, + lineHeight: '22.4px', + fontWeight: 400, + color: colors.black, + }, + // Empty slot + emptySlot: { + border: '1px solid #eaeaea', + borderRadius: 12, + width: 287, + minWidth: 287, + maxWidth: 287, + flexShrink: 0, + flexGrow: 0, + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + minHeight: 300, + transition: 'border-color 0.15s', + '&:hover': { + borderColor: colors.gray2, + }, + }, + emptySlotIcon: { + fontSize: 32, + color: colors.gray2, + marginBottom: 8, + }, + emptySlotText: { + fontSize: 16, + color: colors.gray1, + textAlign: 'center' as const, + lineHeight: '24px', + padding: '0 24px', + }, +})); + +const WalkDistanceRow = ({ + location, + walkMinutes, + classes, +}: { + location: string; + walkMinutes: number; + classes: ReturnType; +}) => ( +
+
+ pin + {location} +
+ {walkMinutes} min walk +
+); + +const ComparisonPage = ({ user, setUser }: Props): ReactElement => { + const classes = useStyles(); + const history = useHistory(); + const location = useLocation(); + const [slots, setSlots] = useState<(ComparisonApt | null)[]>(Array(MAX_SLOTS).fill(null)); + const [modalOpen, setModalOpen] = useState(false); + const [modalSlotIndex, setModalSlotIndex] = useState(0); + const [initialLoaded, setInitialLoaded] = useState(false); + + useTitle('Compare Apartments'); + + const parseAptIds = useCallback((): string[] => { + const params = new URLSearchParams(location.search); + const ids = params.get('ids'); + return ids ? ids.split(',').filter(Boolean) : []; + }, [location.search]); + + // Load initial apartments from URL + useEffect(() => { + if (initialLoaded) return; + const aptIds = parseAptIds(); + if (aptIds.length === 0) { + setInitialLoaded(true); + return; + } + + get(`/api/apts/${aptIds.join(',')}`, { + callback: (apts) => { + const newSlots: (ComparisonApt | null)[] = Array(MAX_SLOTS).fill(null); + apts.forEach((apt, i) => { + if (i < MAX_SLOTS) { + newSlots[i] = { buildingData: apt, numReviews: 0 }; + } + }); + setSlots(newSlots); + setInitialLoaded(true); + + // Fetch travel times for each loaded apartment + apts.forEach((apt, i) => { + if (i < MAX_SLOTS) { + get(`/api/travel-times-by-id/${apt.id}`, { + callback: (times) => { + setSlots((prev) => + prev.map((s, idx) => (idx === i && s ? { ...s, travelTimes: times } : s)) + ); + }, + }); + } + }); + }, + errorHandler: () => setInitialLoaded(true), + }); + }, [parseAptIds, initialLoaded]); + + const updateUrl = (newSlots: (ComparisonApt | null)[]) => { + const ids = newSlots + .filter((s): s is ComparisonApt => s !== null) + .map((s) => s.buildingData.id) + .join(','); + history.replace(`/compare${ids ? `?ids=${ids}` : ''}`); + }; + + const removeApartment = (index: number) => { + const newSlots = [...slots]; + newSlots[index] = null; + setSlots(newSlots); + updateUrl(newSlots); + }; + + const openModal = (slotIndex: number) => { + setModalSlotIndex(slotIndex); + setModalOpen(true); + }; + + const handleAddApartment = (cardData: CardData) => { + const newSlots = [...slots]; + const newApt: ComparisonApt = { ...cardData }; + newSlots[modalSlotIndex] = newApt; + setSlots(newSlots); + updateUrl(newSlots); + + // Fetch travel times + get(`/api/travel-times-by-id/${cardData.buildingData.id}`, { + callback: (times) => { + setSlots((prev) => + prev.map((s, i) => (i === modalSlotIndex && s ? { ...s, travelTimes: times } : s)) + ); + }, + }); + }; + + const excludeIds = slots + .filter((s): s is ComparisonApt => s !== null) + .map((s) => s.buildingData.id); + + return ( + + Compare Apartments Side-by-Side + + Analyze key details and amenities across multiple listings to find the apartment that checks + every box + + +
+ {/* Row labels column - only show if at least one apartment is loaded */} + {slots.some((s) => s !== null) && ( +
+
+ Price +
+
+ Room Size +
+
+ Year Built +
+
+ Location +
+ {amenityKeys.map((key) => ( +
+ {amenityLabels[key]} +
+ ))} +
+ )} + + {/* 4 column slots */} +
+ {slots.map((apt, slotIndex) => + apt ? ( + removeApartment(slotIndex)} + onOpen={() => history.push(`/apartment/${apt.buildingData.id}`)} + /> + ) : ( +
openModal(slotIndex)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && openModal(slotIndex)} + > + + Add Apartment to compare +
+ ) + )} +
+
+ + setModalOpen(false)} + onConfirm={handleAddApartment} + excludeIds={excludeIds} + user={user} + /> +
+ ); +}; + +// Extracted filled column component to keep JSX manageable +const FilledColumn = ({ + apt, + classes, + onRemove, + onOpen, +}: { + apt: ComparisonApt; + classes: ReturnType; + onRemove: () => void; + onOpen: () => void; +}) => { + const { buildingData, travelTimes, avgRating, avgPrice } = apt; + + return ( +
+ + + + + {buildingData.name} + {buildingData.address} + + {buildingData.photos && buildingData.photos.length > 0 ? ( + {buildingData.name} + ) : ( +
+ )} + + + +
+ + {avgRating ? avgRating.toFixed(1) : 'N/A'} + + +
+ +
+ {/* Price */} +
+ + {avgPrice + ? `$${Math.round(avgPrice)}` + : buildingData.price + ? `$${buildingData.price}` + : '—'} + +
+ + {/* Room Size - placeholder */} +
+ +
+ + {/* Year Built - placeholder */} +
+ +
+ + {/* Location */} +
+
+ {buildingData.address} + Distance from Campus + + + + + Distance to Transportation + + +
+ + {/* Amenity rows - all placeholder */} + {amenityKeys.map((key, i) => ( +
+ +
+ ))} +
+
+ ); +}; + +export default ComparisonPage;