From 175c655643970ac9ff4db2f745fc3e9db0559cf7 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Sun, 21 Sep 2025 23:03:30 -0400 Subject: [PATCH 01/32] Added data tab with apartment data in admin page --- frontend/src/pages/AdminPage.tsx | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 0584489d..dfe73fdf 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -17,6 +17,7 @@ import { CantFindApartmentFormWithId, QuestionFormWithId, ReviewWithId, + ApartmentWithId, } from '../../../common/types/db-types'; import { get } from '../utils/call'; import AdminReviewComponent from '../components/Admin/AdminReview'; @@ -81,6 +82,12 @@ const AdminPage = (): ReactElement => { const [declinedExpanded, setDeclinedExpanded] = useState(true); const [reportedData, setReportedData] = useState([]); const [reportedExpanded, setReportedExpanded] = useState(true); + const [apartments, setApartments] = useState([]); + + // Debug apartments state changes + useEffect(() => { + console.log('Apartments loaded:', apartments.length); + }, [apartments]); const { container, sectionHeader, expand, expandOpen } = useStyles(); const { carouselPhotos, @@ -154,6 +161,19 @@ const AdminPage = (): ReactElement => { }); }, [toggle]); + // Load all apartments data + useEffect(() => { + get(`/api/page-data/home/1000/numReviews`, { + callback: (data) => { + if (data && data.buildingData && Array.isArray(data.buildingData)) { + setApartments(data.buildingData); + } else { + console.error('Failed to load apartments data'); + } + }, + }); + }, []); + const Modals = ( <> { ); + // Data tab + const data = ( + + + + + All Apartments ({apartments.length}) + + + {apartments.map((apartment, index) => ( + + + {apartment.buildingData?.name || 'N/A'} + + } + secondary={ +
+ + Address: {apartment.buildingData?.address || 'N/A'} + + + Location: {apartment.buildingData?.area || 'N/A'} + + + Bedrooms: {apartment.buildingData?.numBeds || 'N/A'} + + + Bathrooms: {apartment.buildingData?.numBaths || 'N/A'} + + + Company: {apartment.company || 'N/A'} + + + Reviews: {apartment.numReviews || 0} + + + Avg Rating:{' '} + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + Avg Price:{' '} + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + +
+ } + /> +
+ ))} +
+
+
+
+ ); + return (
@@ -358,12 +437,14 @@ const AdminPage = (): ReactElement => { > + {selectedTab === 'Reviews' && reviews} {selectedTab === 'Contact' && contact} + {selectedTab === 'Data' && data} {Modals}
); From fca6e1ce9f7c9ec88a13e124f7d69f15be75c9ac Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Sun, 21 Sep 2025 23:34:11 -0400 Subject: [PATCH 02/32] doc for data tab --- frontend/src/pages/AdminPage.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index dfe73fdf..2f33b895 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -57,11 +57,15 @@ const useStyles = makeStyles((theme) => ({ /** * AdminPage Component * - * This component represents a page that only authorized admins can view. The page displays information about reviews, allows admins - * to approve or decline reviews. The page also displays contact information from the contact modals ("Can't Find Your Apartment" form - * and "Ask Us a Question" form). + * This component represents a page that only authorized admins can view. The page has three main tabs: * - * @returns The rendered AdminPage component. + * 1. Reviews - Displays review information and allows admins to approve/decline reviews + * 2. Contact - Shows contact form submissions ("Can't Find Your Apartment" and "Ask Us a Question") + * 3. Data - Displays a comprehensive list of all apartments with their key details + * + * Admins can manage content, respond to user inquiries, and view apartment data all in one place. + * + * @returns The rendered AdminPage component with tabbed navigation between Reviews, Contact, and Data sections. */ const AdminPage = (): ReactElement => { const [selectedTab, setSelectedTab] = useState('Reviews'); From 1f98ab212efd3000301923130cc587943d898103 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Wed, 24 Sep 2025 19:35:26 -0400 Subject: [PATCH 03/32] add export apartment name script --- backend/package.json | 1 + backend/scripts/export_apartment_names.ts | 89 +++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 backend/scripts/export_apartment_names.ts diff --git a/backend/package.json b/backend/package.json index e68559a6..aed810fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "add_buildings": "ts-node scripts/add_buildings.ts", "add_landlords": "ts-node scripts/add_landlords.ts", "add_reviews": "ts-node scripts/add_reviews_nodups.ts", + "export_apartment_names": "ts-node scripts/export_apartment_names.ts", "build": "tsc", "tsc": "tsc", "start": "node dist/backend/src/server.js", diff --git a/backend/scripts/export_apartment_names.ts b/backend/scripts/export_apartment_names.ts new file mode 100644 index 00000000..89b3cd84 --- /dev/null +++ b/backend/scripts/export_apartment_names.ts @@ -0,0 +1,89 @@ +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Define the structure of the API response based on the AdminPage usage +interface ApartmentData { + buildingData: { + id: string; + name: string; + address: string; + area: string; + numBeds: number; + numBaths: number; + landlordId: string; + photos: string[]; + urlName: string; + latitude: number; + longitude: number; + distanceToCampus: number; + }; + numReviews: number; + company: string; + avgRating: number; + avgPrice: number; +} + +interface ApiResponse { + buildingData: ApartmentData[]; + isEnded: boolean; +} + +/** + * Script to export apartment names to a CSV file + * + * This script fetches all apartment data from the API endpoint used in AdminPage + * and exports just the apartment names to a CSV file. + */ +const exportApartmentNames = async () => { + try { + console.log('Fetching apartment data...'); + + // Use the same API endpoint as AdminPage + const response = await axios.get( + 'http://localhost:8080/api/page-data/home/1000/numReviews' + ); + + if (!response.data || !response.data.buildingData) { + throw new Error('No apartment data received from API'); + } + + const apartments = response.data.buildingData; + console.log(`Found ${apartments.length} apartments`); + + // Extract apartment names + const apartmentNames = apartments.map((apt) => apt.buildingData.name); + + // Create CSV content + const csvHeader = 'Apartment Name\n'; + const csvContent = apartmentNames.map((name) => `"${name}"`).join('\n'); + const fullCsvContent = csvHeader + csvContent; + + // Write to CSV file + const outputPath = path.join(__dirname, 'apartment_names.csv'); + fs.writeFileSync(outputPath, fullCsvContent, 'utf8'); + + console.log(`Successfully exported ${apartmentNames.length} apartment names to: ${outputPath}`); + console.log('First few apartment names:'); + apartmentNames.slice(0, 5).forEach((name, index) => { + console.log(`${index + 1}. ${name}`); + }); + } catch (error) { + console.error('Error exporting apartment names:', error); + + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNREFUSED') { + console.error( + 'Could not connect to the server. Make sure the backend server is running on localhost:3000' + ); + } else { + console.error('API request failed:', error.message); + } + } + + process.exit(1); + } +}; + +// Run the script +exportApartmentNames(); From 590a533eaffc3bcb46b9dcdf3f85401083021491 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Sun, 5 Oct 2025 22:37:15 -0400 Subject: [PATCH 04/32] Implemented Admin Page Apartment Editing UI\ \ - Implemented apartment information edit endpoint - Implemented create new apartment endpoint - Implemented admin page apartment pagination, sorting, and editing functionalities --- backend/src/app.ts | 260 ++++++++++++++++++- frontend/src/pages/AdminPage.tsx | 420 +++++++++++++++++++++++++++---- 2 files changed, 629 insertions(+), 51 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..53acf3e8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,9 @@ import { admins } from '../../frontend/src/constants/HomeConsts'; const cuaptsEmail = process.env.CUAPTS_EMAIL; const cuaptsEmailPassword = process.env.CUAPTS_EMAIL_APP_PASSWORD; +// Google Maps API key +const { REACT_APP_MAPS_API_KEY } = process.env; + // Collections in the Firestore database const reviewCollection = db.collection('reviews'); const landlordCollection = db.collection('landlords'); @@ -495,10 +498,10 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { // Extract all query parameters const query = req.query.q as string; const locations = req.query.locations as string; - const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; - const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; - const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; - const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; + // const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; + // const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; + // const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; + // const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; const size = req.query.size ? parseInt(req.query.size as string, 10) : null; const sortBy = req.query.sortBy || 'numReviews'; @@ -1302,6 +1305,254 @@ app.post('/api/add-pending-building', authenticate, async (req, res) => { } }); +/** + * Update Apartment Information - Updates the information of an existing apartment. + * + * @remarks + * This endpoint allows admins to update apartment information including name, address, + * landlord, amenities, photos, and other details. Only admins can access this endpoint. + * + * @route PUT /api/admin/update-apartment/:apartmentId + * + * @input {string} req.params.apartmentId - The ID of the apartment to update + * @input {Partial} req.body - The updated apartment information + * + * @status + * - 200: Successfully updated apartment information + * - 400: Invalid apartment data or missing required fields + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 404: Apartment not found + * - 500: Server error while updating apartment + */ +app.put('/api/admin/update-apartment/:apartmentId', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { apartmentId } = req.params; + const updatedApartmentData = req.body as Partial; + + // Check if apartment exists + const apartmentDoc = buildingsCollection.doc(apartmentId); + const apartmentSnapshot = await apartmentDoc.get(); + + if (!apartmentSnapshot.exists) { + res.status(404).send('Apartment not found'); + return; + } + + // Validate required fields if they're being updated + if (updatedApartmentData.name !== undefined && updatedApartmentData.name === '') { + res.status(400).send('Error: Apartment name cannot be empty'); + return; + } + + if (updatedApartmentData.address !== undefined && updatedApartmentData.address === '') { + res.status(400).send('Error: Apartment address cannot be empty'); + return; + } + + // Update the apartment document + await apartmentDoc.update(updatedApartmentData); + + res.status(200).send('Apartment updated successfully'); + } catch (err) { + console.error(err); + res.status(500).send('Error updating apartment'); + } +}); + +/** + * geocodeAddress – Converts an address to latitude and longitude coordinates using Google Geocoding API. + * + * @remarks + * Makes an HTTP request to the Google Geocoding API to convert an address string into + * precise latitude and longitude coordinates. + * + * @param {string} address - The address to geocode + * @return {Promise<{latitude: number, longitude: number}>} - Object containing latitude and longitude + */ +async function geocodeAddress(address: string): Promise<{ latitude: number; longitude: number }> { + const response = await axios.get( + `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent( + address + )}&key=${REACT_APP_MAPS_API_KEY}` + ); + + if (response.data.status !== 'OK' || !response.data.results.length) { + throw new Error('Geocoding failed: Invalid address or no results found'); + } + + const {location} = response.data.results[0].geometry; + return { + latitude: location.lat, + longitude: location.lng, + }; +} + +/** + * Add New Apartment - Creates a new apartment with duplicate checking and distance calculation. + * + * @remarks + * This endpoint allows admins to create new apartments. It includes a multi-step process: + * 1. Validates input data and checks for existing apartments at the same coordinates + * 2. Calculates latitude/longitude and distance to campus using Google Maps API + * 3. Returns preliminary data for admin confirmation + * 4. Creates the apartment in the database after confirmation + * + * @route POST /api/admin/add-apartment + * + * @input {object} req.body - Apartment creation data containing: + * - name: string (required) + * - address: string (required) + * - landlordId: string (required) + * - numBaths: number (optional) + * - numBeds: number (optional) + * - photos: string[] (optional) + * - area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' (required) + * - confirm: boolean (optional) - if true, creates the apartment; if false, returns preliminary data + * + * @status + * - 200: Successfully created apartment (when confirm=true) + * - 201: Preliminary data returned for confirmation (when confirm=false) + * - 400: Invalid apartment data, missing required fields, or duplicate apartment found + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 500: Server error while processing request + */ +app.post('/api/admin/add-apartment', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { + name, + address, + landlordId, + numBaths, + numBeds, + photos = [], + area, + confirm = false, + } = req.body; + + // Validate required fields + if (!name || !address || !landlordId || !area) { + res.status(400).send('Error: Missing required fields (name, address, landlordId, area)'); + return; + } + + // Validate area is one of the allowed values + const validAreas = ['COLLEGETOWN', 'WEST', 'NORTH', 'DOWNTOWN', 'OTHER']; + if (!validAreas.includes(area)) { + res + .status(400) + .send('Error: Invalid area. Must be one of: COLLEGETOWN, WEST, NORTH, DOWNTOWN, OTHER'); + return; + } + + // Check if landlord exists + const landlordDoc = landlordCollection.doc(landlordId); + const landlordSnapshot = await landlordDoc.get(); + if (!landlordSnapshot.exists) { + res.status(400).send('Error: Landlord not found'); + return; + } + + // Geocode the address to get coordinates + const coordinates = await geocodeAddress(address); + const { latitude, longitude } = coordinates; + + // Calculate travel times using the coordinates + const { protocol } = req; + const host = req.get('host'); + const baseUrl = `${protocol}://${host}`; + + const travelTimesResponse = await axios.post(`${baseUrl}/api/calculate-travel-times`, { + origin: `${latitude},${longitude}`, + }); + + const travelTimes = travelTimesResponse.data; + const distanceToCampus = travelTimes.hoPlazaWalking; + + // Check for existing apartments at the same coordinates (with small tolerance) + const tolerance = 0.0001; // Approximately 11 meters + const existingApartments = await buildingsCollection + .where('latitude', '>=', latitude - tolerance) + .where('latitude', '<=', latitude + tolerance) + .where('longitude', '>=', longitude - tolerance) + .where('longitude', '<=', longitude + tolerance) + .get(); + + if (!existingApartments.empty) { + res.status(400).send('Error: An apartment already exists at this location'); + return; + } + + // Prepare apartment data + const apartmentData: Apartment = { + name, + address, + landlordId, + numBaths: numBaths || null, + numBeds: numBeds || null, + photos, + area, + latitude, + longitude, + price: 0, // Default price, can be updated later + distanceToCampus, + }; + + if (!confirm) { + // Return preliminary data for admin confirmation + res.status(201).json({ + message: 'Preliminary data calculated. Please confirm to create apartment.', + apartmentData, + travelTimes, + coordinates: { latitude, longitude }, + }); + return; + } + + // Create the apartment in the database + const apartmentDoc = buildingsCollection.doc(); + await apartmentDoc.set(apartmentData); + + // Update landlord's properties list + const landlordData = landlordSnapshot.data(); + const updatedProperties = [...(landlordData?.properties || []), apartmentDoc.id]; + await landlordDoc.update({ properties: updatedProperties }); + + // Store travel times for the new apartment + await travelTimesCollection.doc(apartmentDoc.id).set(travelTimes); + + res.status(200).json({ + message: 'Apartment created successfully', + apartmentId: apartmentDoc.id, + apartmentData, + }); + } catch (err) { + console.error(err); + res.status(500).send('Error creating apartment'); + } +}); + /** * Update Pending Building Status - Updates the status of a pending building report. * @@ -1427,7 +1678,6 @@ app.put( } ); -const { REACT_APP_MAPS_API_KEY } = process.env; const LANDMARKS = { eng_quad: '42.4445,-76.4836', // Duffield Hall ag_quad: '42.4489,-76.4780', // Mann Library diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2f33b895..0ac223c6 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -12,6 +12,10 @@ import { Tabs, Tab, IconButton, + Button, + TextField, + Box, + Fab, } from '@material-ui/core'; import { CantFindApartmentFormWithId, @@ -25,12 +29,18 @@ import { useTitle } from '../utils'; import { Chart } from 'react-google-charts'; import { sortReviews } from '../utils/sortReviews'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import EditIcon from '@material-ui/icons/Edit'; +import SaveIcon from '@material-ui/icons/Save'; +import CancelIcon from '@material-ui/icons/Cancel'; +import SortIcon from '@material-ui/icons/Sort'; import clsx from 'clsx'; import { colors } from '../colors'; import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; import AdminCantFindApt from '../components/Admin/AdminCantFindApt'; import AdminContactQuestion from '../components/Admin/AdminContactQuestion'; +import axios from 'axios'; +import { createAuthHeaders, getUser } from '../utils/firebase'; const useStyles = makeStyles((theme) => ({ container: { @@ -52,6 +62,34 @@ const useStyles = makeStyles((theme) => ({ expandOpen: { transform: 'rotate(180deg)', }, + headerContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: theme.spacing(3), + }, + sortButton: { + marginLeft: theme.spacing(2), + }, + editField: { + marginBottom: theme.spacing(1), + width: '100%', + }, + editButtons: { + display: 'flex', + gap: theme.spacing(1), + marginTop: theme.spacing(1), + }, + apartmentCard: { + borderBottom: '1px solid #e0e0e0', + padding: '15px 0', + position: 'relative', + }, + editButton: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + }, })); /** @@ -88,11 +126,35 @@ const AdminPage = (): ReactElement => { const [reportedExpanded, setReportedExpanded] = useState(true); const [apartments, setApartments] = useState([]); + // Sorting and editing state + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [editingApartment, setEditingApartment] = useState(null); + const [editFormData, setEditFormData] = useState<{ + numBeds: number | null; + numBaths: number | null; + price: number; + }>({ numBeds: null, numBaths: null, price: 0 }); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const apartmentsPerPage = 50; + // Debug apartments state changes useEffect(() => { console.log('Apartments loaded:', apartments.length); }, [apartments]); - const { container, sectionHeader, expand, expandOpen } = useStyles(); + const { + container, + sectionHeader, + expand, + expandOpen, + headerContainer, + sortButton, + editField, + editButtons, + apartmentCard, + editButton, + } = useStyles(); const { carouselPhotos, carouselStartIndex, @@ -103,6 +165,146 @@ const AdminPage = (): ReactElement => { useTitle('Admin'); + // Helper functions for sorting and editing + const toggleSortOrder = () => { + setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); + }; + + // First, sort all apartments by ID to get the correct page ranges + const allApartmentsSorted = [...apartments].sort((a, b) => { + const aId = a.buildingData?.id || ''; + const bId = b.buildingData?.id || ''; + const aNum = parseInt(aId, 10) || 0; + const bNum = parseInt(bId, 10) || 0; + return aNum - bNum; // Always sort ascending to get correct page ranges + }); + + // Filter test apartments (IDs > 1000) + const testApartments = allApartmentsSorted.filter((apt) => { + const id = parseInt(apt.buildingData?.id || '0', 10); + return id > 1000; + }); + + // Determine if we're showing test apartments or regular apartments + const isTestPage = currentPage > Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const apartmentsToShow = isTestPage ? testApartments : allApartmentsSorted; + + // Pagination logic - get the correct apartments for the current page + const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const testPages = Math.ceil(testApartments.length / apartmentsPerPage); + const totalPages = regularPages + (testPages > 0 ? 1 : 0); // +1 for test apartments page + + const startIndex = isTestPage ? 0 : (currentPage - 1) * apartmentsPerPage; + const endIndex = isTestPage ? apartmentsPerPage : startIndex + apartmentsPerPage; + const pageApartments = apartmentsToShow.slice(startIndex, endIndex); + + // Then sort within the page based on the sort order + const currentPageApartments = [...pageApartments].sort((a, b) => { + const aId = a.buildingData?.id || ''; + const bId = b.buildingData?.id || ''; + const aNum = parseInt(aId, 10) || 0; + const bNum = parseInt(bId, 10) || 0; + + if (sortOrder === 'asc') { + return aNum - bNum; + } else { + return bNum - aNum; + } + }); + + const handleEditClick = (apartment: any) => { + setEditingApartment(apartment.buildingData?.id); + setEditFormData({ + numBeds: apartment.buildingData?.numBeds || null, + numBaths: apartment.buildingData?.numBaths || null, + price: apartment.avgPrice || 0, + }); + }; + + const handleCancelEdit = () => { + setEditingApartment(null); + setEditFormData({ numBeds: null, numBaths: null, price: 0 }); + }; + + const handleSaveEdit = async (apartmentId: string) => { + try { + const user = await getUser(); + if (!user) { + alert('You must be logged in to edit apartments'); + return; + } + + const token = await user.getIdToken(true); + const updateData: any = {}; + + if (editFormData.numBeds !== null) { + updateData.numBeds = editFormData.numBeds; + } + if (editFormData.numBaths !== null) { + updateData.numBaths = editFormData.numBaths; + } + if (editFormData.price !== 0) { + updateData.price = editFormData.price; + } + + await axios.put( + `/api/admin/update-apartment/${apartmentId}`, + updateData, + createAuthHeaders(token) + ); + + // Update local state + setApartments((prev) => + prev.map((apt) => { + if (apt.buildingData?.id === apartmentId) { + return { + ...apt, + buildingData: { + ...apt.buildingData, + numBeds: editFormData.numBeds, + numBaths: editFormData.numBaths, + }, + avgPrice: editFormData.price, + }; + } + return apt; + }) + ); + + setEditingApartment(null); + setEditFormData({ numBeds: null, numBaths: null, price: 0 }); + } catch (error) { + console.error('Error updating apartment:', error); + alert('Failed to update apartment. Please try again.'); + } + }; + + const handleInputChange = (field: 'numBeds' | 'numBaths' | 'price', value: string) => { + const numValue = field === 'price' ? parseFloat(value) || 0 : parseInt(value) || null; + setEditFormData((prev) => ({ + ...prev, + [field]: numValue, + })); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const getPageRange = (page: number) => { + const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + + if (page > regularPages) { + // This is the test apartments page + return `Test (${testApartments.length})`; + } else { + // Regular page + const start = (page - 1) * apartmentsPerPage + 1; + const end = Math.min(page * apartmentsPerPage, allApartmentsSorted.length); + return `${start}-${end}`; + } + }; + // calls the APIs and the callback function to set the reviews for each review type useEffect(() => { const reviewTypes = new Map>>([ @@ -375,54 +577,180 @@ const AdminPage = (): ReactElement => { - - All Apartments ({apartments.length}) - - - {apartments.map((apartment, index) => ( - +
+ + All Apartments ({apartments.length}) + + + Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | + {isTestPage ? ( + <>Showing: Test Apartments ({testApartments.length}) + ) : ( + <> + Showing: {getPageRange(currentPage)} of {apartments.length} + + )} + +
+
+ +
+ + + {/* Pagination Buttons */} +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const isTestPageButton = page > regularPages; + + return ( + + ); + })} +
+ + {currentPageApartments.map((apartment, index) => { + const isEditing = editingApartment === apartment.buildingData?.id; + + return ( + + {!isEditing && ( + handleEditClick(apartment)} + size="small" + title="Edit apartment" + > + + + )} + + + {apartment.buildingData?.name || 'N/A'} - - } - /> - - ))} + } + secondary={ +
+ + Apartment ID: {apartment.buildingData?.id || 'N/A'} + + + Address: {apartment.buildingData?.address || 'N/A'} + + + Location: {apartment.buildingData?.area || 'N/A'} + + + {isEditing ? ( +
+ handleInputChange('numBeds', e.target.value)} + size="small" + /> + handleInputChange('numBaths', e.target.value)} + size="small" + /> + handleInputChange('price', e.target.value)} + size="small" + /> + + + + +
+ ) : ( +
+ + Bedrooms: {apartment.buildingData?.numBeds || 'N/A'} + + + Bathrooms:{' '} + {apartment.buildingData?.numBaths || 'N/A'} + + + Company: {apartment.company || 'N/A'} + + + Reviews: {apartment.numReviews || 0} + + + Avg Rating:{' '} + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + Avg Price:{' '} + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + +
+ )} +
+ } + /> +
+ ); + })}
From 9799fa16cf049d7a16fc5934132e8c74e2697a28 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Wed, 22 Oct 2025 17:26:04 -0400 Subject: [PATCH 05/32] Implement folder feature endpoints --- backend/src/app.ts | 164 ++++++++++++++++++++++++++++++++++++++- common/types/db-types.ts | 6 ++ 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..48ed3b41 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -17,6 +17,7 @@ import { QuestionForm, QuestionFormWithId, LocationTravelTimes, + Folder, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; @@ -42,7 +43,7 @@ const likesCollection = db.collection('likes'); const usersCollection = db.collection('users'); const pendingBuildingsCollection = db.collection('pendingBuildings'); const contactQuestionsCollection = db.collection('contactQuestions'); - +const folderCollection = db.collection('folders'); const travelTimesCollection = db.collection('travelTimes'); // Middleware setup @@ -219,7 +220,7 @@ app.get('/api/review/like/:userId', authenticate, async (req, res) => { }); /** - * Takes in the location type in the URL and returns the number of reviews made forr that location + * Takes in the location type in the URL and returns the number of reviews made for that location */ app.get('/api/review/:location/count', async (req, res) => { const { location } = req.params; @@ -1733,4 +1734,163 @@ app.post('/api/create-distance-to-campus', async (req, res) => { } }); +// Endpoint to add a new folder for a user +app.post('/api/folders', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderName } = req.body; + if (!folderName || folderName.trim() === '') { + return res.status(400).send('Folder name is required'); + } + const newFolderRef = folderCollection.doc(); + + // Create a new folder document + await newFolderRef.set({ + name: folderName, + userId: uid, + createdAt: new Date(), + }); + + return res.status(201).json({ id: newFolderRef.id, name: folderName }); + } catch (err) { + console.error(err); + return res.status(500).send('Error creating folder'); + } +}); + +// Endpoint to get all folders for a user +app.get('/api/folders', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + + // Fetch all folders for this user + const folderSnapshot = await folderCollection.where('userId', '==', uid).get(); + + const folders = folderSnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })); + + return res.status(200).json(folders); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching folders'); + } +}); + +// Endpoint to delete a folder by ID +app.delete('/api/folders/:folderId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to delete this folder'); + } + + await folderRef.delete(); + return res.status(200).send('Folder deleted successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error deleting folder'); + } +}); + +// Endpoint to rename a folder by ID +app.put('/api/folders/:folderId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + const { newName } = req.body; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to rename this folder'); + } + + await folderRef.update({ name: newName }); + return res.status(200).send('Folder renamed successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error renaming folder'); + } +}); + +// Endpoint to add an apartment to a folder +app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId, aptId } = req.body; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to modify this folder'); + } + + const apartments = folderDoc.data()?.apartments || []; + if (apartments.includes(aptId)) { + return res.status(400).send('Apartment already in folder'); + } + + apartments.push(aptId); + await folderRef.update({ apartments }); + return res.status(200).send('Apartment added to folder successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error adding apartment to folder'); + } +}); + +// Endpoint to remove an apartment from a folder +app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId, aptId } = req.body; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to modify this folder'); + } + + let apartments = folderDoc.data()?.apartments || []; + apartments = apartments.filter((id: string) => id !== aptId); + await folderRef.update({ apartments }); + return res.status(200).send('Apartment removed from folder successfully'); + } catch (err) { + console.error(err); + return res.status(500).send('Error removing apartment from folder'); + } +}); + export default app; diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 20463657..509e4715 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -100,3 +100,9 @@ export type QuestionForm = { }; export type QuestionFormWithId = QuestionForm & Id; + +export type Folder = { + readonly name: string; + readonly userId: string; + readonly apartmentIds: string[]; +}; From dc20dc90737a36b97b87a5c3b26f51eb34f4e164 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sun, 26 Oct 2025 21:47:26 -0400 Subject: [PATCH 06/32] Implement frontend for the folder system - Implement folder page to display user folders - Add folder card component for folder representation - Added 'add to folder' button in apartment page - Debugged authentication issues in folder operations --- backend/src/app.ts | 38 ++- frontend/src/App.tsx | 11 + .../components/Folder/AddToFolderModal.tsx | 294 +++++++++++++++++ frontend/src/components/Folder/FolderCard.tsx | 213 ++++++++++++ .../src/components/utils/NavBar/index.tsx | 20 +- frontend/src/pages/ApartmentPage.tsx | 37 ++- frontend/src/pages/FolderDetailPage.tsx | 223 +++++++++++++ frontend/src/pages/FolderPage.tsx | 310 ++++++++++++++++++ 8 files changed, 1137 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/Folder/AddToFolderModal.tsx create mode 100644 frontend/src/components/Folder/FolderCard.tsx create mode 100644 frontend/src/pages/FolderDetailPage.tsx create mode 100644 frontend/src/pages/FolderPage.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index 48ed3b41..4d3e218b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1764,7 +1764,6 @@ app.get('/api/folders', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); const { uid } = req.user; - // Fetch all folders for this user const folderSnapshot = await folderCollection.where('userId', '==', uid).get(); @@ -1834,11 +1833,12 @@ app.put('/api/folders/:folderId', authenticate, async (req, res) => { }); // Endpoint to add an apartment to a folder -app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, res) => { +app.post('/api/folders/:folderId/apartments', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); const { uid } = req.user; - const { folderId, aptId } = req.body; + const { folderId } = req.params; + const { aptId } = req.body; const folderRef = folderCollection.doc(folderId); const folderDoc = await folderRef.get(); @@ -1866,11 +1866,11 @@ app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, r }); // Endpoint to remove an apartment from a folder -app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, res) => { +app.delete('/api/folders/:folderId/apartments/:apartmentId', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); const { uid } = req.user; - const { folderId, aptId } = req.body; + const { folderId, apartmentId } = req.params; const folderRef = folderCollection.doc(folderId); const folderDoc = await folderRef.get(); @@ -1884,7 +1884,7 @@ app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, r } let apartments = folderDoc.data()?.apartments || []; - apartments = apartments.filter((id: string) => id !== aptId); + apartments = apartments.filter((id: string) => id !== apartmentId); await folderRef.update({ apartments }); return res.status(200).send('Apartment removed from folder successfully'); } catch (err) { @@ -1893,4 +1893,30 @@ app.post('/api/folders/:id/apartments/:apartmentId', authenticate, async (req, r } }); +// Endpoint to get all apartments in a folder +app.get('/api/folders/:folderId/apartments', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to access this folder'); + } + + const apartments = folderDoc.data()?.apartments || []; + return res.status(200).json(apartments); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching apartments from folder'); + } +}); + export default app; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66dd0aa0..20a86906 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import FAQPage from './pages/FAQPage'; import ReviewPage from './pages/ReviewPage'; import LandlordPage from './pages/LandlordPage'; import ProfilePage from './pages/ProfilePage'; +import FolderPage from './pages/FolderPage'; +import FolderDetailPage from './pages/FolderDetailPage'; import BookmarksPage from './pages/BookmarksPage'; import { ThemeProvider } from '@material-ui/core'; import { createTheme } from '@material-ui/core/styles'; @@ -137,6 +139,15 @@ const App = (): ReactElement => { path="/bookmarks" component={() => } /> + } + /> + } + /> } diff --git a/frontend/src/components/Folder/AddToFolderModal.tsx b/frontend/src/components/Folder/AddToFolderModal.tsx new file mode 100644 index 00000000..6a3e603c --- /dev/null +++ b/frontend/src/components/Folder/AddToFolderModal.tsx @@ -0,0 +1,294 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + List, + ListItem, + ListItemText, + Checkbox, + TextField, + Typography, + Box, + CircularProgress, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import AddIcon from '@material-ui/icons/Add'; +import axios from 'axios'; +import { getUser, createAuthHeaders } from '../../utils/firebase'; +import { colors } from '../../colors'; + +const useStyles = makeStyles({ + dialogContent: { + minHeight: '300px', + maxHeight: '400px', + }, + folderItem: { + border: `1px solid ${colors.gray3}`, + borderRadius: '8px', + marginBottom: '8px', + '&:hover': { + backgroundColor: colors.gray3, + }, + }, + createFolderSection: { + padding: '16px', + backgroundColor: colors.gray3, + borderRadius: '8px', + marginTop: '16px', + }, + addButton: { + backgroundColor: colors.red1, + color: 'white', + '&:hover': { + backgroundColor: colors.red2, + }, + }, +}); + +type Folder = { + id: string; + name: string; + userId: string; + createdAt: any; + apartments?: string[]; +}; + +type Props = { + open: boolean; + onClose: () => void; + apartmentId: string; + apartmentName: string; + user: firebase.User | null; + setUser: React.Dispatch>; + onSuccess: () => void; +}; + +const AddToFolderModal = ({ + open, + onClose, + apartmentId, + apartmentName, + user, + setUser, + onSuccess, +}: Props) => { + const classes = useStyles(); + const [folders, setFolders] = useState([]); + const [selectedFolders, setSelectedFolders] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [showCreateNew, setShowCreateNew] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + fetchFolders(); + } + }, [open]); + + const fetchFolders = async () => { + try { + setLoading(true); + if (!user) { + const loggedInUser = await getUser(true); + setUser(loggedInUser); + if (!loggedInUser) return; + } + const token = await user!.getIdToken(true); + const response = await axios.get('/api/folders', createAuthHeaders(token)); + setFolders(response.data); + + // Pre-select folders that already contain this apartment + const preSelected = new Set(); + response.data.forEach((folder: Folder) => { + if (folder.apartments?.includes(apartmentId)) { + preSelected.add(folder.id); + } + }); + setSelectedFolders(preSelected); + } catch (err) { + console.error('Error fetching folders:', err); + setError('Failed to load folders'); + } finally { + setLoading(false); + } + }; + + const handleToggleFolder = (folderId: string) => { + setSelectedFolders((prev) => { + const newSet = new Set(prev); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); + } + return newSet; + }); + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + setError('Folder name cannot be empty'); + return; + } + try { + if (!user) { + const loggedInUser = await getUser(true); + setUser(loggedInUser); + if (!loggedInUser) return; + } + const token = await user!.getIdToken(true); + const response = await axios.post( + '/api/folders', + { folderName: newFolderName }, + createAuthHeaders(token) + ); + const newFolder = response.data; + setFolders([...folders, newFolder]); + setSelectedFolders((prev) => new Set(prev).add(newFolder.id)); + setNewFolderName(''); + setShowCreateNew(false); + } catch (err) { + console.error('Error creating folder:', err); + setError('Failed to create folder'); + } + }; + + const handleSave = async () => { + try { + setLoading(true); + if (!user) { + const loggedInUser = await getUser(true); + setUser(loggedInUser); + if (!loggedInUser) return; + } + const token = await user!.getIdToken(true); + + // Determine which folders need to be added/removed + const currentFolders = folders.filter((f) => f.apartments?.includes(apartmentId)); + const currentFolderIds = new Set(currentFolders.map((f) => f.id)); + + // Add apartment to newly selected folders + for (const folderId of selectedFolders) { + if (!currentFolderIds.has(folderId)) { + await axios.post( + `/api/folders/${folderId}/apartments`, + { aptId: apartmentId }, + createAuthHeaders(token) + ); + } + } + + // Remove apartment from deselected folders + for (const folder of currentFolders) { + if (!selectedFolders.has(folder.id)) { + await axios.delete( + `/api/folders/${folder.id}/apartments/${apartmentId}`, + createAuthHeaders(token) + ); + } + } + + onSuccess(); + onClose(); + } catch (err) { + console.error('Error updating folders:', err); + setError('Failed to update folders'); + } finally { + setLoading(false); + } + }; + + return ( + + Add "{apartmentName}" to Folder + + {loading && folders.length === 0 ? ( + + + + ) : folders.length === 0 ? ( + + No folders yet. Create one below! + + ) : ( + + {folders.map((folder) => ( + handleToggleFolder(folder.id)} + className={classes.folderItem} + > + handleToggleFolder(folder.id)} + color="primary" + /> + + + ))} + + )} + + {error && ( + + {error} + + )} + + + {!showCreateNew ? ( + + ) : ( + + setNewFolderName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleCreateFolder(); + } + }} + variant="outlined" + size="small" + /> + + + + + + )} + + + + + + + + ); +}; + +export default AddToFolderModal; diff --git a/frontend/src/components/Folder/FolderCard.tsx b/frontend/src/components/Folder/FolderCard.tsx new file mode 100644 index 00000000..eb0bf703 --- /dev/null +++ b/frontend/src/components/Folder/FolderCard.tsx @@ -0,0 +1,213 @@ +import React, { ReactElement, useState } from 'react'; +import { + Card, + CardContent, + CardActions, + Typography, + IconButton, + Menu, + MenuItem, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + makeStyles, +} from '@material-ui/core'; +import { MoreVert as MoreVertIcon, Folder as FolderIcon } from '@material-ui/icons'; +import { useHistory } from 'react-router-dom'; +import { colors } from '../../colors'; + +type Folder = { + id: string; + name: string; + userId: string; + createdAt: any; + apartments?: string[]; +}; + +type Props = { + folder: Folder; + onDelete: (folderId: string) => void; + onRename: (folderId: string, newName: string) => void; +}; + +const useStyles = makeStyles((theme) => ({ + card: { + height: '100%', + display: 'flex', + flexDirection: 'column', + cursor: 'pointer', + transition: 'transform 0.2s, box-shadow 0.2s', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: '0 4px 20px rgba(0,0,0,0.1)', + }, + }, + cardContent: { + flexGrow: 1, + display: 'flex', + alignItems: 'center', + gap: '1em', + }, + folderIcon: { + fontSize: '3em', + color: colors.red1, + }, + folderInfo: { + flex: 1, + }, + folderName: { + fontWeight: 600, + marginBottom: '0.5em', + }, + apartmentCount: { + color: colors.gray2, + fontSize: '0.9em', + }, + cardActions: { + justifyContent: 'flex-end', + padding: '8px 16px', + }, +})); + +/** + * FolderCard Component + * + * This component represents a folder in the user's folder list. + * + * @component + * @returns ReactElement: The FolderCard component. + */ +const FolderCard = ({ folder, onDelete, onRename }: Props): ReactElement => { + const classes = useStyles(); + const history = useHistory(); + const [anchorEl, setAnchorEl] = useState(null); + const [showRenameDialog, setShowRenameDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [newName, setNewName] = useState(folder.name); + + const handleMenuOpen = (event: React.MouseEvent) => { + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleRenameClick = () => { + setNewName(folder.name); + setShowRenameDialog(true); + handleMenuClose(); + }; + + const handleDeleteClick = () => { + setShowDeleteDialog(true); + handleMenuClose(); + }; + + const handleRenameConfirm = () => { + if (newName.trim() && newName !== folder.name) { + onRename(folder.id, newName.trim()); + } + setShowRenameDialog(false); + }; + + const handleDeleteConfirm = () => { + onDelete(folder.id); + setShowDeleteDialog(false); + }; + + const handleCardClick = () => { + // Navigate to folder detail page using React Router + history.push(`/folders/${folder.id}`); + }; + + const apartmentCount = folder.apartments?.length || 0; + + return ( + <> + + + +
+ + {folder.name} + + + {apartmentCount} {apartmentCount === 1 ? 'apartment' : 'apartments'} + +
+
+ + + + + +
+ + {/* Menu */} + + Rename + + Delete + + + + {/* Rename Dialog */} + setShowRenameDialog(false)} + onClick={(e) => e.stopPropagation()} + > + Rename Folder + + setNewName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleRenameConfirm(); + } + }} + /> + + + + + + + + {/* Delete Confirmation Dialog */} + setShowDeleteDialog(false)} + onClick={(e) => e.stopPropagation()} + > + Delete Folder + + + Are you sure you want to delete "{folder.name}"? This action cannot be undone. + + + + + + + + + ); +}; + +export default FolderCard; diff --git a/frontend/src/components/utils/NavBar/index.tsx b/frontend/src/components/utils/NavBar/index.tsx index 3d6e7db1..5714b10b 100644 --- a/frontend/src/components/utils/NavBar/index.tsx +++ b/frontend/src/components/utils/NavBar/index.tsx @@ -29,6 +29,7 @@ import defaultProfilePic from '../../../assets/cuapts-bear.png'; import { ReactComponent as ProfileIcon } from '../../../assets/profile-icon.svg'; import { ReactComponent as BookmarkIcon } from '../../../assets/bookmark.svg'; import { ReactComponent as SignOutIcon } from '../../../assets/signout.svg'; +import { Folder } from '@material-ui/icons'; export type NavbarButton = { label: string; @@ -305,9 +306,13 @@ const NavBar = ({ headersData, user, setUser }: Props): ReactElement => { label: 'Bookmarks', href: `/bookmarks`, }; + const folders: NavbarButton = { + label: 'folders', + href: `/folders`, + }; //Sets headers data depending on whether menu drawer is open or not. If open, add profile and bookmarks page buttons. const displayHeadersData = - drawerOpen && user ? [...headersData, profile, bookmarks] : headersData; + drawerOpen && user ? [...headersData, profile, bookmarks, folders] : headersData; return ( @@ -348,11 +353,13 @@ const NavBar = ({ headersData, user, setUser }: Props): ReactElement => { }); /** This function navigates the user to the page depending on the dropdown button pressed */ - const dropDownButtonClick = async (button: 'profile' | 'bookmarks' | 'signOut') => { + const dropDownButtonClick = async (button: 'profile' | 'bookmarks' | 'folders' | 'signOut') => { if (button === 'profile') { history.push(`/profile`); } else if (button === 'bookmarks') { history.push(`/bookmarks`); + } else if (button === 'folders') { + history.push(`/folders`); } else { signOut(); history.push('/'); @@ -418,6 +425,15 @@ const NavBar = ({ headersData, user, setUser }: Props): ReactElement => { Bookmarks +
  • + +
  • {' '} + + + + ); + } + + return ( +
    + + + + + +
    + + {folder.name} + + + {apartments.length} {apartments.length === 1 ? 'apartment' : 'apartments'} + +
    +
    + + {apartments.length === 0 ? ( + + + No apartments in this folder + + + Add apartments to this folder from the apartment pages + + + ) : ( + + {apartments.map((aptId) => ( + + history.push(`/apartment/${aptId}`)} + > + Apartment {aptId} + + Click to view details + + + + ))} + + )} + + {showErrorToast && ( + + )} +
    +
    + ); +}; + +export default FolderDetailPage; diff --git a/frontend/src/pages/FolderPage.tsx b/frontend/src/pages/FolderPage.tsx new file mode 100644 index 00000000..391f4cf9 --- /dev/null +++ b/frontend/src/pages/FolderPage.tsx @@ -0,0 +1,310 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import { + Button, + Grid, + makeStyles, + Typography, + Box, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from '@material-ui/core'; +import Toast from '../components/utils/Toast'; +import { colors } from '../colors'; +import FolderCard from '../components/Folder/FolderCard'; +import axios from 'axios'; +import { createAuthHeaders, getUser } from '../utils/firebase'; + +type Props = { + user: firebase.User | null; + setUser: React.Dispatch>; +}; + +type Folder = { + id: string; + name: string; + userId: string; + createdAt: any; + apartments?: string[]; +}; + +const useStyles = makeStyles((theme) => ({ + background: { + backgroundColor: colors.gray3, + minHeight: '100vh', + width: '100%', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + marginTop: '10px', + }, + gridContainer: { + display: 'flex', + alignItems: 'flex-start', + marginBottom: '3em', + }, + headerStyle: { + fontFamily: 'Work Sans', + fontWeight: 800, + }, + headerContainer: { + marginTop: '2em', + marginBottom: '2em', + }, + createButton: { + backgroundColor: colors.red1, + color: 'white', + '&:hover': { + backgroundColor: colors.red2, + }, + marginBottom: '2em', + }, +})); + +/** + * FolderPage Component + * + * This component represents a page for user folders. + * It displays all folders saved by a user, with the option to edit, rename, or delete existing folders. + * + * @component + * @returns ReactElement: The folder page component. + */ +const FolderPage = ({ user, setUser }: Props): ReactElement => { + const toastTime = 3500; + const { background, headerStyle, headerContainer, gridContainer, createButton } = useStyles(); + + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [showDeleteFolderToast, setShowDeleteFolderToast] = useState(false); + const [showRenameFolderToast, setShowRenameFolderToast] = useState(false); + const [showCreateFolderToast, setShowCreateFolderToast] = useState(false); + const [showErrorToast, setShowErrorToast] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + const showToast = (setState: (value: React.SetStateAction) => void) => { + setState(true); + setTimeout(() => { + setState(false); + }, toastTime); + }; + + const showDeleteSuccessConfirmationToast = () => { + showToast(setShowDeleteFolderToast); + }; + + const showRenameSuccessConfirmationToast = () => { + showToast(setShowRenameFolderToast); + }; + + const showCreateSuccessConfirmationToast = () => { + showToast(setShowCreateFolderToast); + }; + + const showError = (message: string) => { + setErrorMessage(message); + showToast(setShowErrorToast); + }; + + // Fetch folders on component mount + useEffect(() => { + fetchFolders(); + }, []); + + const fetchFolders = async () => { + try { + setLoading(true); + if (!user) { + let user = await getUser(true); + setUser(user); + } + if (!user) { + throw new Error('Failed to login'); + } + const token = await user.getIdToken(true); + const response = await axios.get('/api/folders', createAuthHeaders(token)); + setFolders(response.data); + } catch (error) { + console.error('Error fetching folders:', error); + showError('Failed to load folders'); + } finally { + setLoading(false); + } + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + showError('Folder name cannot be empty'); + return; + } + try { + if (!user) { + let user = await getUser(true); + setUser(user); + } + if (!user) { + throw new Error('Failed to login'); + } + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/folders', + { folderName: newFolderName }, + createAuthHeaders(token) + ); + setFolders([...folders, response.data]); + setShowCreateDialog(false); + setNewFolderName(''); + showCreateSuccessConfirmationToast(); + } catch (error) { + console.error('Error creating folder:', error); + showError('Failed to create folder'); + } + }; + + const handleDeleteFolder = async (folderId: string) => { + try { + if (!user) { + let user = await getUser(true); + setUser(user); + } + if (!user) { + throw new Error('Failed to login'); + } + const token = await user.getIdToken(true); + await axios.delete(`/api/folders/${folderId}`, createAuthHeaders(token)); + setFolders(folders.filter((f) => f.id !== folderId)); + showDeleteSuccessConfirmationToast(); + } catch (error) { + console.error('Error deleting folder:', error); + showError('Failed to delete folder'); + } + }; + + const handleRenameFolder = async (folderId: string, newName: string) => { + try { + if (!user) { + let user = await getUser(true); + setUser(user); + } + if (!user) { + throw new Error('Failed to login'); + } + const token = await user.getIdToken(true); + await axios.put(`/api/folders/${folderId}`, { newName }, createAuthHeaders(token)); + setFolders(folders.map((f) => (f.id === folderId ? { ...f, name: newName } : f))); + showRenameSuccessConfirmationToast(); + } catch (error) { + console.error('Error renaming folder:', error); + showError('Failed to rename folder'); + } + }; + + return ( +
    + + + + My Folders + + + Organize your saved apartments into folders + + + + + + {loading ? ( + Loading folders... + ) : folders.length === 0 ? ( + + + No folders yet + + + Create your first folder to start organizing apartments + + + ) : ( + + {folders.map((folder) => ( + + + + ))} + + )} + + {/* Create Folder Dialog */} + setShowCreateDialog(false)}> + Create New Folder + + setNewFolderName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleCreateFolder(); + } + }} + /> + + + + + + + + {showDeleteFolderToast && ( + + )} + {showRenameFolderToast && ( + + )} + {showCreateFolderToast && ( + + )} + {showErrorToast && ( + + )} + +
    + ); +}; + +export default FolderPage; From 95c56c75b0ed0720982642d43307b6e954b447c5 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Mon, 27 Oct 2025 14:59:15 -0400 Subject: [PATCH 07/32] Update FolderDetailPage to display apartment cards --- backend/src/app.ts | 17 +++++++++++++- .../SearchResultsPageApartmentCards.tsx | 2 +- frontend/src/pages/FolderDetailPage.tsx | 23 ++++--------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 4d3e218b..ce8d0e8c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1912,7 +1912,22 @@ app.get('/api/folders/:folderId/apartments', authenticate, async (req, res) => { } const apartments = folderDoc.data()?.apartments || []; - return res.status(200).json(apartments); + const aptsArr = await Promise.all( + apartments.map(async (id: string) => { + const snapshot = await buildingsCollection.doc(id).get(); + if (!snapshot.exists) { + console.warn(`Apartment ${id} not found`); + return null; + } + return { id, ...snapshot.data() }; + }) + ); + + // Filter out any null values from non-existent apartments + const validApartments = aptsArr.filter((apt) => apt !== null); + const enrichedResults = await pageData(validApartments); + console.log('Enriched Results:', enrichedResults); + return res.status(200).json(enrichedResults); } catch (err) { console.error(err); return res.status(500).send('Error fetching apartments from folder'); diff --git a/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx b/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx index 0fc0773b..d832502d 100644 --- a/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx +++ b/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx @@ -1,6 +1,6 @@ import React, { ReactElement, useEffect } from 'react'; import NewApartmentCard from './NewApartmentCard'; -import { Grid, Link, makeStyles, Button, Box, Typography } from '@material-ui/core'; +import { Link, makeStyles } from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; import { CardData } from '../../App'; import { loadingLength } from '../../constants/HomeConsts'; diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index c2f2ae69..68400c43 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -14,6 +14,8 @@ import Toast from '../components/utils/Toast'; import { colors } from '../colors'; import axios from 'axios'; import { createAuthHeaders, getUser } from '../utils/firebase'; +import ApartmentCards from '../components/ApartmentCard/SearchResultsPageApartmentCards'; +import { CardData } from '../App'; type Props = { user: firebase.User | null; @@ -75,7 +77,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const { background, headerStyle, headerContainer, backButton, gridContainer } = useStyles(); const [folder, setFolder] = useState(null); - const [apartments, setApartments] = useState([]); + const [apartments, setApartments] = useState([]); const [loading, setLoading] = useState(true); const [showErrorToast, setShowErrorToast] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -191,24 +193,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { ) : ( - {apartments.map((aptId) => ( - - history.push(`/apartment/${aptId}`)} - > - Apartment {aptId} - - Click to view details - - - - ))} + )} From 23f065673eb5bf2b64365b9d9a7ddaf6775e561a Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Wed, 29 Oct 2025 19:08:59 -0400 Subject: [PATCH 08/32] Add documentation for folder api routes --- backend/src/app.ts | 96 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index ce8d0e8c..d329d777 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1734,7 +1734,18 @@ app.post('/api/create-distance-to-campus', async (req, res) => { } }); -// Endpoint to add a new folder for a user +/** + * Add Folder - Creates a new folder assigned to a specific user. + * + * @route POST /api/folders + * + * @input {string} req.body - The name of the new folder to be created + * + * @status + * - 201: Successfully created the folder + * - 400: Folder name is missing or invalid + * - 500: Error creating folder + */ app.post('/api/folders', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -1759,7 +1770,15 @@ app.post('/api/folders', authenticate, async (req, res) => { } }); -// Endpoint to get all folders for a user +/** + * Get Folders - Fetches all folders assigned to a specific user. + * + * @route GET /api/folders + * + * @status + * - 200: Successfully retrieved folders + * - 500: Error fetching folders + */ app.get('/api/folders', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -1779,7 +1798,19 @@ app.get('/api/folders', authenticate, async (req, res) => { } }); -// Endpoint to delete a folder by ID +/** + * Delete Folder - Deletes a folder by ID. + * + * @route DELETE /api/folders/:folderId + * + * @input {string} req.params.folderId - The ID of the folder to be deleted + * + * @status + * - 200: Successfully deleted folder + * - 403: Unauthorized to delete this folder (not the owner) + * - 404: Folder not found + * - 500: Error deleting folder + */ app.delete('/api/folders/:folderId', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -1805,7 +1836,19 @@ app.delete('/api/folders/:folderId', authenticate, async (req, res) => { } }); -// Endpoint to rename a folder by ID +/** + * Rename Folder - Renames a folder by ID. + * + * @route PUT /api/folders/:folderId + * + * @input {string} req.params.folderId - The ID of the folder to be renamed + * + * @status + * - 200: Successfully renamed folder + * - 403: Unauthorized to rename this folder (not the owner) + * - 404: Folder not found + * - 500: Error renaming folder + */ app.put('/api/folders/:folderId', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -1832,7 +1875,21 @@ app.put('/api/folders/:folderId', authenticate, async (req, res) => { } }); -// Endpoint to add an apartment to a folder +/** + * Add Apartment - Adds an apartment to a folder. + * + * @route POST /api/folders/:folderId/apartments + * + * @input {string} req.body - The id of the apartment to be added + * @input {string} req.params.folderId - The ID of the folder to add the apartment to + * + * @status + * - 200: Successfully added apartment to folder + * - 403: Unauthorized to modify this folder (not the owner) + * - 404: Folder not found + * - 400: Apartment already in folder + * - 500: Error adding apartment to folder + */ app.post('/api/folders/:folderId/apartments', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -1865,7 +1922,20 @@ app.post('/api/folders/:folderId/apartments', authenticate, async (req, res) => } }); -// Endpoint to remove an apartment from a folder +/** + * Remove Apartment - Removes an apartment from a folder. + * + * @route DELETE /api/folders/:folderId/apartments/:apartmentId + * + * @input {string} req.body - The id of the apartment to be removed + * @input {string} req.params.folderId - The ID of the folder to remove the apartment from + * + * @status + * - 200: Successfully removed apartment from folder + * - 403: Unauthorized to modify this folder (not the owner) + * - 404: Folder not found + * - 500: Error removing apartment from folder + */ app.delete('/api/folders/:folderId/apartments/:apartmentId', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); @@ -1893,7 +1963,19 @@ app.delete('/api/folders/:folderId/apartments/:apartmentId', authenticate, async } }); -// Endpoint to get all apartments in a folder +/** + * Get Apartments in Folder - Retrieves all apartments in a specific folder. + * + * @route GET /api/folders/:folderId/apartments + * + * @input {string} req.params - The folderId of the folder to get apartments from + * + * @status + * - 200: Successfully retrieved apartments from folder + * - 403: Unauthorized to access this folder (not the owner) + * - 404: Folder not found + * - 500: Error fetching apartments from folder + */ app.get('/api/folders/:folderId/apartments', authenticate, async (req, res) => { try { if (!req.user) throw new Error('Not authenticated'); From 03b89391a8f592b71f08ea78489104a3e30a22cd Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sun, 2 Nov 2025 16:01:39 -0500 Subject: [PATCH 09/32] Implement folder editing --- frontend/src/pages/FolderDetailPage.tsx | 97 +++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index 68400c43..a123a748 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -8,12 +8,18 @@ import { Box, IconButton, CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Checkbox, + FormGroup, } from '@material-ui/core'; import { ArrowBack as ArrowBackIcon } from '@material-ui/icons'; import Toast from '../components/utils/Toast'; import { colors } from '../colors'; import axios from 'axios'; -import { createAuthHeaders, getUser } from '../utils/firebase'; +import { createAuthHeaders } from '../utils/firebase'; import ApartmentCards from '../components/ApartmentCard/SearchResultsPageApartmentCards'; import { CardData } from '../App'; @@ -81,6 +87,8 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const [loading, setLoading] = useState(true); const [showErrorToast, setShowErrorToast] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [apartmentIdsToRemove, setApartmentIdsToRemove] = useState([]); + const [showRemoveApartmentModal, setShowRemoveApartmentModal] = useState(false); const showToast = (setState: (value: React.SetStateAction) => void) => { setState(true); @@ -101,10 +109,6 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const fetchFolderDetails = async () => { try { setLoading(true); - if (!user) { - let user = await getUser(true); - setUser(user); - } if (!user) { throw new Error('Failed to login'); } @@ -141,6 +145,29 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { history.push('/folders'); }; + const handleRemoveApartments = async (apartmentIds: string[]) => { + try { + if (!user) { + throw new Error('User not authenticated'); + } + const token = await user.getIdToken(true); + + await apartmentIds.map((id) => + axios.delete(`/api/folders/${folderId}/apartments/${id}`, { + data: { apartmentIds }, + ...createAuthHeaders(token), + }) + ); + + // Refresh folder details + fetchFolderDetails(); + } catch (error) { + console.error('Error removing apartments from folder:', error); + showError('Failed to remove apartments from folder'); + } + setShowRemoveApartmentModal(false); + }; + if (loading) { return (
    @@ -167,6 +194,59 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { return (
    + setShowRemoveApartmentModal(false)}> + Remove Apartments from Folder + + + Select apartments to remove from the folder or click "Remove All" to remove all + apartments. + + + {apartments.map((apt) => ( + + { + const aptId = apt.buildingData.id; + if (e.target.checked) { + setApartmentIdsToRemove((prev) => [...prev, aptId]); + } else { + setApartmentIdsToRemove((prev) => prev.filter((id) => id !== aptId)); + } + }} + /> + + {apt.buildingData.name} - {apt.buildingData.address} + + + ))} + + + + + + + + + @@ -180,6 +260,13 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { {apartments.length} {apartments.length === 1 ? 'apartment' : 'apartments'}
    + {apartments.length === 0 ? ( From 8b5356766c4cac904e0bfc904bf8324a1a0cf57b Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sun, 2 Nov 2025 16:03:29 -0500 Subject: [PATCH 10/32] Update apartment removal --- frontend/src/pages/FolderDetailPage.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index a123a748..e88d4eb4 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -197,10 +197,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { setShowRemoveApartmentModal(false)}> Remove Apartments from Folder - - Select apartments to remove from the folder or click "Remove All" to remove all - apartments. - + Select apartments to remove from the folder. {apartments.map((apt) => ( @@ -238,13 +235,6 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { > Remove Selected - From 85b0d8b80cd4d524062b15948fbb6d1f88a96b36 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Tue, 18 Nov 2025 23:27:25 -0500 Subject: [PATCH 11/32] Add documentation for Folder components --- .../src/components/Folder/AddToFolderModal.tsx | 15 +++++++++++++++ frontend/src/components/Folder/FolderCard.tsx | 5 ++++- frontend/src/pages/FolderDetailPage.tsx | 4 +++- frontend/src/pages/FolderPage.tsx | 4 +++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Folder/AddToFolderModal.tsx b/frontend/src/components/Folder/AddToFolderModal.tsx index 6a3e603c..510fbb0e 100644 --- a/frontend/src/components/Folder/AddToFolderModal.tsx +++ b/frontend/src/components/Folder/AddToFolderModal.tsx @@ -66,6 +66,21 @@ type Props = { onSuccess: () => void; }; +/** + * AddToFolderModal + * Allows users to add an apartment to their folders or create new folders. + * + * @param {Props} props- Component props + * @param {boolean} props.open - Whether the modal is open + * @param {function} props.onClose - Function to call when closing the modal + * @param {string} props.apartmentId - ID of the apartment to add to folders + * @param {string} props.apartmentName - Name of the apartment to display + * @param {firebase.User | null} props.user - Current logged-in user + * @param {React.Dispatch>} props.setUser - Function to update the user state + * @param {function} props.onSuccess - Function to call on successful addition to folders + * @returns + */ + const AddToFolderModal = ({ open, onClose, diff --git a/frontend/src/components/Folder/FolderCard.tsx b/frontend/src/components/Folder/FolderCard.tsx index eb0bf703..25abba6a 100644 --- a/frontend/src/components/Folder/FolderCard.tsx +++ b/frontend/src/components/Folder/FolderCard.tsx @@ -77,7 +77,10 @@ const useStyles = makeStyles((theme) => ({ * * This component represents a folder in the user's folder list. * - * @component + * @param {Props} props - Component props + * @param {Folder} props.folder - The folder data to display + * @param {function} props.onDelete - Callback function when the folder is deleted + * @param {function} props.onRename - Callback function when the folder is renamed * @returns ReactElement: The FolderCard component. */ const FolderCard = ({ folder, onDelete, onRename }: Props): ReactElement => { diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index e88d4eb4..21b84cea 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -73,7 +73,9 @@ const useStyles = makeStyles((theme) => ({ * This component displays the contents of a specific folder, * showing all apartments saved in that folder. * - * @component + * @oaram {Props} props - Component props + * @param {firebase.User | null} props.user - The current logged-in user + * @param {function} props.setUser - Function to update the user state * @returns ReactElement: The folder detail page component. */ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { diff --git a/frontend/src/pages/FolderPage.tsx b/frontend/src/pages/FolderPage.tsx index 391f4cf9..6462fd6a 100644 --- a/frontend/src/pages/FolderPage.tsx +++ b/frontend/src/pages/FolderPage.tsx @@ -69,7 +69,9 @@ const useStyles = makeStyles((theme) => ({ * This component represents a page for user folders. * It displays all folders saved by a user, with the option to edit, rename, or delete existing folders. * - * @component + * @param {Props} props - Component props + * @param {firebase.User | null} props.user - The current logged-in user + * @param {function} props.setUser - Function to update the user state * @returns ReactElement: The folder page component. */ const FolderPage = ({ user, setUser }: Props): ReactElement => { From f5f8c653f2d4e18e580e5b6b11576942d79eff56 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 00:13:39 -0500 Subject: [PATCH 12/32] Refactor apartment schema to support multiple room types Backend Changes: - Add RoomType interface with UUID-based IDs (beds, baths, price) - Replace single numBeds/numBaths/price fields with roomTypes array - Add migration endpoint POST /api/admin/migrate-all-apartments-schema - Supports dry run mode for preview - Batch processing (100 apartments per batch) - Initializes roomTypes as empty array and removes old fields - Update PUT /api/admin/update-apartment to handle roomTypes - Generate UUIDs for new room types - Validate beds/baths/price >= 1 (integers only) - Check for duplicate room type combinations - Update POST /api/admin/add-apartment to accept roomTypes - Same validation and UUID generation - Allow empty roomTypes array - Update add_buildings.ts script to use new schema Frontend Changes (Temporary): - Comment out old bed/bath/price displays in ApartmentCard - Comment out bed display in NewApartmentCard - Update price sorting to use avgPrice temporarily - Proper room type display will be implemented in future commits - Proper room type sorting will be implemented in future commits Note: All apartments will start with empty roomTypes after migration. Frontend integer validation for admin UI will be added in following commits. --- backend/scripts/add_buildings.ts | 4 +- backend/src/app.ts | 282 +++++++++++++++++- common/types/db-types.ts | 11 +- .../ApartmentCard/ApartmentCard.tsx | 5 +- .../ApartmentCard/NewApartmentCard.tsx | 9 +- frontend/src/pages/SearchResultsPage.tsx | 6 +- 6 files changed, 294 insertions(+), 23 deletions(-) diff --git a/backend/scripts/add_buildings.ts b/backend/scripts/add_buildings.ts index a40cf380..11657eb8 100644 --- a/backend/scripts/add_buildings.ts +++ b/backend/scripts/add_buildings.ts @@ -43,13 +43,11 @@ const formatBuilding = ({ name, address, landlordId: landlordId.toString(), - numBaths: 0, - numBeds: 0, + roomTypes: [], // Initialize with empty room types photos: [], area: getAreaType(area), latitude, longitude, - price: 0, distanceToCampus: 0, }); diff --git a/backend/src/app.ts b/backend/src/app.ts index 53acf3e8..5f8c3105 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,6 +2,7 @@ import express, { Express, RequestHandler } from 'express'; import cors from 'cors'; import Fuse from 'fuse.js'; import morgan from 'morgan'; +import { randomUUID } from 'crypto'; import { Review, Landlord, @@ -17,6 +18,7 @@ import { QuestionForm, QuestionFormWithId, LocationTravelTimes, + RoomType, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; @@ -1360,10 +1362,75 @@ app.put('/api/admin/update-apartment/:apartmentId', authenticate, async (req, re return; } - // Update the apartment document - await apartmentDoc.update(updatedApartmentData); + // Validate and process roomTypes if provided + if (updatedApartmentData.roomTypes !== undefined) { + const roomTypes = updatedApartmentData.roomTypes as RoomType[]; - res.status(200).send('Apartment updated successfully'); + // Generate UUIDs for room types without IDs and validate + const processedRoomTypes: RoomType[] = []; + const seen = new Set(); + + // Validate each room type + const validationErrors = roomTypes + .map((rt: RoomType): string | null => { + // Validate beds, baths, price + if (!Number.isInteger(rt.beds) || rt.beds < 1) { + return 'Error: Beds must be an integer >= 1'; + } + if (!Number.isInteger(rt.baths) || rt.baths < 1) { + return 'Error: Baths must be an integer >= 1'; + } + if (!Number.isInteger(rt.price) || rt.price < 1) { + return 'Error: Price must be an integer >= 1'; + } + + // Check for duplicates using (beds, baths, price) combination + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + return 'Duplicate room type exists'; + } + seen.add(key); + + // Generate UUID if not provided + const id = rt.id && rt.id.trim() !== '' ? rt.id : randomUUID(); + + processedRoomTypes.push({ + id, + beds: rt.beds, + baths: rt.baths, + price: rt.price, + }); + + return null; + }) + .filter((error: string | null): error is string => error !== null); + + if (validationErrors.length > 0) { + res.status(400).send(validationErrors[0]); + return; + } + + // Create updated data with processed room types + const dataToUpdate = { + ...updatedApartmentData, + roomTypes: processedRoomTypes, + }; + + // Update the apartment document + await apartmentDoc.update(dataToUpdate); + } else { + // Update the apartment document without roomTypes changes + await apartmentDoc.update(updatedApartmentData); + } + + // Fetch the updated apartment to return with generated UUIDs + const updatedSnapshot = await apartmentDoc.get(); + const updatedApartment = { id: updatedSnapshot.id, ...updatedSnapshot.data() }; + + res.status(200).json({ + message: 'Apartment updated successfully', + apartment: updatedApartment, + }); } catch (err) { console.error(err); res.status(500).send('Error updating apartment'); @@ -1391,7 +1458,7 @@ async function geocodeAddress(address: string): Promise<{ latitude: number; long throw new Error('Geocoding failed: Invalid address or no results found'); } - const {location} = response.data.results[0].geometry; + const { location } = response.data.results[0].geometry; return { latitude: location.lat, longitude: location.lng, @@ -1444,8 +1511,7 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { name, address, landlordId, - numBaths, - numBeds, + roomTypes = [], photos = [], area, confirm = false, @@ -1466,6 +1532,50 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { return; } + // Validate and process roomTypes if provided + const processedRoomTypes: RoomType[] = []; + if (roomTypes && roomTypes.length > 0) { + const seen = new Set(); + + // Validate each room type + const validationErrors = roomTypes + .map((rt: RoomType): string | null => { + // Validate beds, baths, price + if (!Number.isInteger(rt.beds) || rt.beds < 1) { + return 'Error: Beds must be an integer >= 1'; + } + if (!Number.isInteger(rt.baths) || rt.baths < 1) { + return 'Error: Baths must be an integer >= 1'; + } + if (!Number.isInteger(rt.price) || rt.price < 1) { + return 'Error: Price must be an integer >= 1'; + } + + // Check for duplicates using (beds, baths, price) combination + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + return 'Duplicate room type exists'; + } + seen.add(key); + + // Generate UUID for each room type + processedRoomTypes.push({ + id: randomUUID(), + beds: rt.beds, + baths: rt.baths, + price: rt.price, + }); + + return null; + }) + .filter((error: string | null): error is string => error !== null); + + if (validationErrors.length > 0) { + res.status(400).send(validationErrors[0]); + return; + } + } + // Check if landlord exists const landlordDoc = landlordCollection.doc(landlordId); const landlordSnapshot = await landlordDoc.get(); @@ -1509,13 +1619,11 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { name, address, landlordId, - numBaths: numBaths || null, - numBeds: numBeds || null, + roomTypes: processedRoomTypes as readonly RoomType[], photos, area, latitude, longitude, - price: 0, // Default price, can be updated later distanceToCampus, }; @@ -1553,6 +1661,162 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { } }); +/** + * Migrate All Apartments Schema - Migrates all apartments from old schema to new room types schema. + * + * @remarks + * This endpoint performs a one-time migration of all apartments in the database: + * 1. Initializes roomTypes as empty array [] + * 2. Deletes old fields: numBeds, numBaths, price + * 3. Processes apartments in batches of 100 (3 batches total for ~300 apartments) + * 4. Returns summary of migration success/failures + * + * @route POST /api/admin/migrate-all-apartments-schema + * + * @input {object} req.body - Migration options containing: + * - dryRun: boolean (optional) - if true, returns count without making changes + * + * @status + * - 200: Migration completed successfully (returns summary) + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 500: Server error during migration + */ +app.post('/api/admin/migrate-all-apartments-schema', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { dryRun = false } = req.body; + + // Fetch all apartments + const apartmentsSnapshot = await buildingsCollection.get(); + const totalApartments = apartmentsSnapshot.size; + + if (dryRun) { + // Dry run: just return count and preview + const sampleApartments = apartmentsSnapshot.docs.slice(0, 3).map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: data.name, + hasOldSchema: 'numBeds' in data || 'numBaths' in data || 'price' in data, + currentData: { numBeds: data.numBeds, numBaths: data.numBaths, price: data.price }, + }; + }); + + res.status(200).json({ + dryRun: true, + totalApartments, + message: `Would migrate ${totalApartments} apartments`, + sampleApartments, + }); + return; + } + + // Actual migration + const startTime = Date.now(); + const batchSize = 100; + const batches = Math.ceil(totalApartments / batchSize); + + let migrated = 0; + let failed = 0; + const errors: string[] = []; + + console.log(`Starting migration of ${totalApartments} apartments in ${batches} batches...`); + + // Process in batches + const batchIndices = Array.from({ length: batches }, (_, i) => i); + + await batchIndices.reduce(async (promise, batchIndex) => { + await promise; + + const batchStartTime = Date.now(); + const start = batchIndex * batchSize; + const end = Math.min(start + batchSize, totalApartments); + const batchDocs = apartmentsSnapshot.docs.slice(start, end); + + console.log( + `Processing batch ${batchIndex + 1}/${batches} (${batchDocs.length} apartments)...` + ); + + // Process batch + const batch = db.batch(); + + batchDocs.forEach((doc) => { + try { + const data = doc.data(); + + // Check for corrupted data + if (!data.name || !data.address) { + errors.push(`Apartment ${doc.id}: Missing required fields (name or address)`); + failed += 1; + return; + } + + // Create migrated apartment data + const migratedData: Record = { + name: data.name, + address: data.address, + landlordId: data.landlordId || null, + roomTypes: [], // Initialize as empty array + photos: data.photos || [], + area: data.area || 'OTHER', + latitude: data.latitude || 0, + longitude: data.longitude || 0, + distanceToCampus: data.distanceToCampus || 0, + }; + + // Use batch.set to replace the document entirely (deletes old fields) + batch.set(doc.ref, migratedData); + migrated += 1; + } catch (err) { + const errorMsg = `Apartment ${doc.id}: ${ + err instanceof Error ? err.message : 'Unknown error' + }`; + errors.push(errorMsg); + failed += 1; + console.error(errorMsg); + } + }); + + // Commit batch + await batch.commit(); + + const batchDuration = Date.now() - batchStartTime; + console.log(`Batch ${batchIndex + 1}/${batches} completed in ${batchDuration}ms`); + }, Promise.resolve()); + + const totalDuration = Date.now() - startTime; + + const summary = { + success: true, + totalApartments, + migrated, + failed, + errors, + durationMs: totalDuration, + message: `Migration completed: ${migrated} migrated, ${failed} failed`, + }; + + console.log('Migration summary:', summary); + + res.status(200).json(summary); + } catch (err) { + console.error('Migration error:', err); + res + .status(500) + .send(`Error during migration: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}); + /** * Update Pending Building Status - Updates the status of a pending building report. * diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 20463657..09bcca94 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -51,17 +51,22 @@ export type Landlord = { export type LandlordWithId = Landlord & Id; export type LandlordWithLabel = LandlordWithId & { readonly label: 'LANDLORD' }; +export type RoomType = { + readonly id: string; // UUID generated by backend + readonly beds: number; // >= 1, integer + readonly baths: number; // >= 1, integer + readonly price: number; // >= 1, integer (in dollars) +}; + export type Apartment = { readonly name: string; readonly address: string; // may change to placeID for Google Maps integration readonly landlordId: string | null; - readonly numBaths: number | null; - readonly numBeds: number | null; + readonly roomTypes: readonly RoomType[]; // can be empty array readonly photos: readonly string[]; // can be empty readonly area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER'; readonly latitude: number; readonly longitude: number; - readonly price: number; readonly distanceToCampus: number; // walking distance to ho plaza in minutes }; diff --git a/frontend/src/components/ApartmentCard/ApartmentCard.tsx b/frontend/src/components/ApartmentCard/ApartmentCard.tsx index 12dfa90e..4384e359 100644 --- a/frontend/src/components/ApartmentCard/ApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/ApartmentCard.tsx @@ -353,7 +353,8 @@ const ApartmentCard = ({ > {`Rating: ${avgRating.toFixed(1)}`} - {`Price: ${buildingData.price}`} - + */} )} {/* Sample Review */} diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index a5510374..e1b8281a 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -193,7 +193,7 @@ const NewApartmentCard = ({ user, setUser, }: Props): ReactElement => { - const { id, name, photos, address, numBeds = 0, distanceToCampus = 0 } = buildingData; + const { id, name, photos, address, distanceToCampus = 0 } = buildingData; const saved = savedIcon; const unsaved = unsavedIcon; const img = photos.length > 0 ? photos[0] : ApartmentImg; @@ -320,12 +320,13 @@ const NewApartmentCard = ({ money $2K - $3K
    -
    + {/* TODO: Room type display - will be implemented in Phase 3 */} + {/*
    bed - {numBeds ? `${numBeds - 1}-${numBeds + 1}` : '0'} bed + TBD bed -
    +
    */} diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index 4c14f447..4fe82aa3 100644 --- a/frontend/src/pages/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResultsPage.tsx @@ -170,14 +170,16 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Lowest Price', callback: () => { - setSortBy('price'); + // TODO: Phase 4 - Update to sort by room type prices + setSortBy('avgPrice'); setSortLowToHigh(true); }, }, { item: 'Highest Price', callback: () => { - setSortBy('price'); + // TODO: Phase 4 - Update to sort by room type prices + setSortBy('avgPrice'); setSortLowToHigh(false); }, }, From f7a2dfbf8246e62b4d4e57531615553d4e93c586 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 00:33:23 -0500 Subject: [PATCH 13/32] Implement Room Types Admin UI and Display Utilities Admin Page Room Type Management - Added room types editing modal with table UI for beds/baths/price - Displays existing room types with inline editing and delete functionality - Includes "Add Room Type" button with validation (>= 1 for all fields) - Checks for duplicate room types (same beds/baths/price combination) - Backend generates UUIDs for new room types automatically - Shows room type count in Data tab instead of old numBeds/numBaths fields - Displays room type IDs (truncated) for debugging Room Type Display Utilities and Card Updates - Created roomTypeUtils.ts with comprehensive display functions: - formatPrice: Formats prices with K suffix (e.g., $1.5K, $3K) - formatBeds/formatBaths: Formats bed/bath counts (e.g., "2 beds", "1 bath") - getRoomTypeRange: Gets min/max values for beds/baths/price - formatPriceRange: Formats price ranges (e.g., "$1.5K - $3K") - formatRoomTypesDisplay: Complete display string for cards - getMinPrice/getMaxPrice: Helper functions for sorting - Updated NewApartmentCard to display dynamic price ranges from room types - Shows "Coming soon" when no room types are available --- .../ApartmentCard/NewApartmentCard.tsx | 18 +- frontend/src/pages/AdminPage.tsx | 551 ++++++++++++++---- frontend/src/utils/roomTypeUtils.ts | 181 ++++++ 3 files changed, 612 insertions(+), 138 deletions(-) create mode 100644 frontend/src/utils/roomTypeUtils.ts diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index e1b8281a..59d3c5cc 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -18,6 +18,7 @@ import { createAuthHeaders, getUser } from '../../utils/firebase'; import { ApartmentWithId } from '../../../../common/types/db-types'; import FavoriteIcon from '@material-ui/icons/Favorite'; import { colors } from '../../colors'; +import { formatPriceRange, getRoomTypeRange } from '../../utils/roomTypeUtils'; type Props = { buildingData: ApartmentWithId; @@ -193,7 +194,7 @@ const NewApartmentCard = ({ user, setUser, }: Props): ReactElement => { - const { id, name, photos, address, distanceToCampus = 0 } = buildingData; + const { id, name, photos, address, distanceToCampus = 0, roomTypes } = buildingData; const saved = savedIcon; const unsaved = unsavedIcon; const img = photos.length > 0 ? photos[0] : ApartmentImg; @@ -202,6 +203,12 @@ const NewApartmentCard = ({ const [isHovered, setIsHovered] = useState(false); const [savedIsHovered, setSavedIsHovered] = useState(false); + // Get price range from room types + const roomTypeRange = getRoomTypeRange(roomTypes); + const priceDisplay = roomTypeRange + ? formatPriceRange(roomTypeRange.minPrice, roomTypeRange.maxPrice) + : 'Coming soon'; + const { root, redHighlight, @@ -318,15 +325,8 @@ const NewApartmentCard = ({
    money - $2K - $3K + {priceDisplay}
    - {/* TODO: Room type display - will be implemented in Phase 3 */} - {/*
    - bed - - TBD bed - -
    */}
    diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 0ac223c6..2a441175 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -16,6 +16,15 @@ import { TextField, Box, Fab, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableBody, + TableCell, + TableHead, + TableRow, } from '@material-ui/core'; import { CantFindApartmentFormWithId, @@ -129,16 +138,20 @@ const AdminPage = (): ReactElement => { // Sorting and editing state const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [editingApartment, setEditingApartment] = useState(null); - const [editFormData, setEditFormData] = useState<{ - numBeds: number | null; - numBaths: number | null; - price: number; - }>({ numBeds: null, numBaths: null, price: 0 }); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingRoomTypes, setEditingRoomTypes] = useState([]); // Pagination state const [currentPage, setCurrentPage] = useState(1); const apartmentsPerPage = 50; + // Migration state + const [migrationStatus, setMigrationStatus] = useState< + 'idle' | 'preview' | 'running' | 'complete' | 'error' + >('idle'); + const [migrationSummary, setMigrationSummary] = useState(null); + const [migrationProgress, setMigrationProgress] = useState(''); + // Debug apartments state changes useEffect(() => { console.log('Apartments loaded:', apartments.length); @@ -212,21 +225,118 @@ const AdminPage = (): ReactElement => { } }); + // Migration handler functions + const handleMigrationPreview = async () => { + try { + setMigrationStatus('preview'); + setMigrationProgress('Running dry run...'); + + const user = await getUser(); + if (!user) { + alert('You must be logged in to run migration'); + setMigrationStatus('idle'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/migrate-all-apartments-schema', + { dryRun: true }, + createAuthHeaders(token) + ); + + setMigrationSummary(response.data); + setMigrationProgress('Preview complete'); + setMigrationStatus('idle'); + } catch (error) { + console.error('Migration preview error:', error); + setMigrationProgress(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + setMigrationStatus('error'); + } + }; + + const handleMigrationRun = async () => { + if ( + !window.confirm( + 'Are you sure you want to run the migration? This will modify ALL apartment records in the database.' + ) + ) { + return; + } + + try { + setMigrationStatus('running'); + setMigrationProgress('Migrating apartments...'); + + const user = await getUser(); + if (!user) { + alert('You must be logged in to run migration'); + setMigrationStatus('idle'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/migrate-all-apartments-schema', + { dryRun: false }, + createAuthHeaders(token) + ); + + setMigrationSummary(response.data); + setMigrationProgress('Migration complete'); + setMigrationStatus('complete'); + + // Reload apartments after migration + setTimeout(() => { + window.location.reload(); + }, 3000); + } catch (error) { + console.error('Migration error:', error); + setMigrationProgress(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + setMigrationStatus('error'); + } + }; + + // Room Types Edit Handlers const handleEditClick = (apartment: any) => { setEditingApartment(apartment.buildingData?.id); - setEditFormData({ - numBeds: apartment.buildingData?.numBeds || null, - numBaths: apartment.buildingData?.numBaths || null, - price: apartment.avgPrice || 0, - }); + setEditingRoomTypes(apartment.buildingData?.roomTypes || []); + setEditModalOpen(true); }; const handleCancelEdit = () => { setEditingApartment(null); - setEditFormData({ numBeds: null, numBaths: null, price: 0 }); + setEditingRoomTypes([]); + setEditModalOpen(false); + }; + + const handleAddRoomType = () => { + setEditingRoomTypes((prev) => [ + ...prev, + { id: '', beds: 1, baths: 1, price: 1 }, // id will be generated by backend + ]); + }; + + const handleRemoveRoomType = (index: number) => { + setEditingRoomTypes((prev) => prev.filter((_, i) => i !== index)); }; - const handleSaveEdit = async (apartmentId: string) => { + const handleRoomTypeChange = ( + index: number, + field: 'beds' | 'baths' | 'price', + value: string + ) => { + const numValue = parseInt(value) || 1; + if (numValue < 1) return; // Enforce >= 1 constraint + + setEditingRoomTypes((prev) => + prev.map((rt, i) => (i === index ? { ...rt, [field]: numValue } : rt)) + ); + }; + + const handleSaveEdit = async () => { + if (!editingApartment) return; + try { const user = await getUser(); if (!user) { @@ -234,59 +344,46 @@ const AdminPage = (): ReactElement => { return; } - const token = await user.getIdToken(true); - const updateData: any = {}; - - if (editFormData.numBeds !== null) { - updateData.numBeds = editFormData.numBeds; - } - if (editFormData.numBaths !== null) { - updateData.numBaths = editFormData.numBaths; - } - if (editFormData.price !== 0) { - updateData.price = editFormData.price; + // Check for duplicate room types + const seen = new Set(); + for (const rt of editingRoomTypes) { + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + alert(`Duplicate room type exists: ${rt.beds} beds, ${rt.baths} baths, $${rt.price}`); + return; + } + seen.add(key); } - await axios.put( - `/api/admin/update-apartment/${apartmentId}`, - updateData, + const token = await user.getIdToken(true); + const response = await axios.put( + `/api/admin/update-apartment/${editingApartment}`, + { roomTypes: editingRoomTypes }, createAuthHeaders(token) ); - // Update local state + // Update local state with the response (which includes generated UUIDs) setApartments((prev) => prev.map((apt) => { - if (apt.buildingData?.id === apartmentId) { + if (apt.buildingData?.id === editingApartment) { return { ...apt, - buildingData: { - ...apt.buildingData, - numBeds: editFormData.numBeds, - numBaths: editFormData.numBaths, - }, - avgPrice: editFormData.price, + buildingData: response.data.apartment, }; } return apt; }) ); - setEditingApartment(null); - setEditFormData({ numBeds: null, numBaths: null, price: 0 }); - } catch (error) { + alert('Room types updated successfully!'); + handleCancelEdit(); + } catch (error: any) { console.error('Error updating apartment:', error); - alert('Failed to update apartment. Please try again.'); + const errorMsg = error.response?.data || 'Failed to update apartment. Please try again.'; + alert(errorMsg); } }; - const handleInputChange = (field: 'numBeds' | 'numBaths' | 'price', value: string) => { - const numValue = field === 'price' ? parseFloat(value) || 0 : parseInt(value) || null; - setEditFormData((prev) => ({ - ...prev, - [field]: numValue, - })); - }; - const handlePageChange = (page: number) => { setCurrentPage(page); }; @@ -572,6 +669,136 @@ const AdminPage = (): ReactElement => { ); + // Developer Tools tab + const developerTools = ( + + + + + Developer Tools + + + {/* Migration Section */} + + + Schema Migration + + + Migrate all apartments from old schema (numBeds, numBaths, price) to new room types + schema. All apartments will be initialized with empty roomTypes array. + + + + + + + + {/* Migration Progress */} + {migrationProgress && ( + + + Status: {migrationProgress} + + + )} + + {/* Migration Summary */} + {migrationSummary && ( + + + {migrationSummary.dryRun ? 'Dry Run Results:' : 'Migration Results:'} + + + Total Apartments: {migrationSummary.totalApartments} + + {!migrationSummary.dryRun && ( + <> + + Migrated: {migrationSummary.migrated} + + + Failed: {migrationSummary.failed} + + + Duration: {migrationSummary.durationMs}ms + + + )} + {migrationSummary.errors && migrationSummary.errors.length > 0 && ( + + + Errors: + + {migrationSummary.errors.slice(0, 10).map((error: string, idx: number) => ( + + {error} + + ))} + + )} + {migrationSummary.sampleApartments && ( + + + Sample Apartments (first 3): + + {migrationSummary.sampleApartments.map((apt: any, idx: number) => ( + + {apt.id}: {apt.name} (hasOldSchema: {apt.hasOldSchema ? 'yes' : 'no'}) + + ))} + + )} + + )} + + + + + ); + // Data tab const data = ( @@ -641,20 +868,16 @@ const AdminPage = (): ReactElement => { {currentPageApartments.map((apartment, index) => { - const isEditing = editingApartment === apartment.buildingData?.id; - return ( - {!isEditing && ( - handleEditClick(apartment)} - size="small" - title="Edit apartment" - > - - - )} + handleEditClick(apartment)} + size="small" + title="Edit room types" + > + + { Location: {apartment.buildingData?.area || 'N/A'} - {isEditing ? ( -
    - handleInputChange('numBeds', e.target.value)} - size="small" - /> - handleInputChange('numBaths', e.target.value)} - size="small" - /> - handleInputChange('price', e.target.value)} - size="small" - /> - - - - -
    - ) : ( -
    - - Bedrooms: {apartment.buildingData?.numBeds || 'N/A'} - - - Bathrooms:{' '} - {apartment.buildingData?.numBaths || 'N/A'} - - - Company: {apartment.company || 'N/A'} - - - Reviews: {apartment.numReviews || 0} - - - Avg Rating:{' '} - {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} - - - Avg Price:{' '} - {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} - -
    - )} +
    + + Room Types:{' '} + {apartment.buildingData?.roomTypes && + apartment.buildingData.roomTypes.length > 0 + ? `${apartment.buildingData.roomTypes.length} type${ + apartment.buildingData.roomTypes.length > 1 ? 's' : '' + }` + : 'No room types'} + + + Company: {apartment.company || 'N/A'} + + + Reviews: {apartment.numReviews || 0} + + + Avg Rating:{' '} + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + Avg Price:{' '} + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + +
    } /> @@ -757,6 +934,119 @@ const AdminPage = (): ReactElement => {
    ); + // Room Types Edit Modal + const roomTypesModal = ( + + + Edit Room Types + {editingApartment && ( + + Apartment ID: {editingApartment} + + )} + + + {editingRoomTypes.length === 0 ? ( + + No room types. Click "Add Room Type" below to add one. + + ) : ( + + + + + Beds + + + Baths + + + Price + + + Actions + + + + + {editingRoomTypes.map((roomType, index) => ( + + + handleRoomTypeChange(index, 'beds', e.target.value)} + inputProps={{ min: 1, step: 1 }} + size="small" + style={{ width: '80px' }} + /> + + + handleRoomTypeChange(index, 'baths', e.target.value)} + inputProps={{ min: 1, step: 1 }} + size="small" + style={{ width: '80px' }} + /> + + + handleRoomTypeChange(index, 'price', e.target.value)} + inputProps={{ min: 1, step: 1 }} + size="small" + style={{ width: '100px' }} + /> + + + handleRemoveRoomType(index)} + size="small" + color="secondary" + title="Remove this room type" + > + + + {roomType.id && ( + + ID: {roomType.id.slice(0, 8)}... + + )} + + + ))} + +
    + )} + + + + +
    + + + + +
    + ); + return (
    @@ -770,6 +1060,7 @@ const AdminPage = (): ReactElement => { + @@ -777,7 +1068,9 @@ const AdminPage = (): ReactElement => { {selectedTab === 'Reviews' && reviews} {selectedTab === 'Contact' && contact} {selectedTab === 'Data' && data} + {selectedTab === 'DevTools' && developerTools} {Modals} + {roomTypesModal}
    ); }; diff --git a/frontend/src/utils/roomTypeUtils.ts b/frontend/src/utils/roomTypeUtils.ts new file mode 100644 index 00000000..9c73eb5f --- /dev/null +++ b/frontend/src/utils/roomTypeUtils.ts @@ -0,0 +1,181 @@ +import { RoomType } from '../../../common/types/db-types'; + +/** + * Formats a price value into a readable string with K suffix for thousands. + * Examples: 1500 → "$1.5K", 3200 → "$3.2K", 800 → "$800" + * + * @param {number} price - The price in dollars + * @returns {string} The formatted price string + */ +export const formatPrice = (price: number): string => { + if (price >= 1000) { + const priceInK = price / 1000; + // Format with 1 decimal place, but remove .0 if it's a whole number + const formatted = priceInK % 1 === 0 ? priceInK.toFixed(0) : priceInK.toFixed(1); + return `$${formatted}K`; + } + return `$${price}`; +}; + +/** + * Formats a bedroom count into a readable string. + * Examples: 1 → "1 bed", 2 → "2 beds", 0 → "Studio" + * + * @param {number} beds - The number of bedrooms + * @returns {string} The formatted bedroom string + */ +export const formatBeds = (beds: number): string => { + if (beds === 0) return 'Studio'; + if (beds === 1) return '1 bed'; + return `${beds} beds`; +}; + +/** + * Formats a bathroom count into a readable string. + * Examples: 1 → "1 bath", 2 → "2 baths", 1.5 → "1.5 baths" + * + * @param {number} baths - The number of bathrooms + * @returns {string} The formatted bathroom string + */ +export const formatBaths = (baths: number): string => { + if (baths === 1) return '1 bath'; + return `${baths} baths`; +}; + +/** + * Gets the min and max values for beds, baths, and price from a room types array. + * Returns null if the array is empty. + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {{ minBeds: number; maxBeds: number; minBaths: number; maxBaths: number; minPrice: number; maxPrice: number } | null} + */ +export const getRoomTypeRange = ( + roomTypes: readonly RoomType[] +): { + minBeds: number; + maxBeds: number; + minBaths: number; + maxBaths: number; + minPrice: number; + maxPrice: number; +} | null => { + if (!roomTypes || roomTypes.length === 0) { + return null; + } + + const beds = roomTypes.map((rt) => rt.beds); + const baths = roomTypes.map((rt) => rt.baths); + const prices = roomTypes.map((rt) => rt.price); + + return { + minBeds: Math.min(...beds), + maxBeds: Math.max(...beds), + minBaths: Math.min(...baths), + maxBaths: Math.max(...baths), + minPrice: Math.min(...prices), + maxPrice: Math.max(...prices), + }; +}; + +/** + * Formats a price range into a readable string. + * Examples: (1500, 3000) → "$1.5K - $3K", (2000, 2000) → "$2K" + * + * @param {number} minPrice - The minimum price + * @param {number} maxPrice - The maximum price + * @returns {string} The formatted price range string + */ +export const formatPriceRange = (minPrice: number, maxPrice: number): string => { + const min = formatPrice(minPrice); + const max = formatPrice(maxPrice); + + if (minPrice === maxPrice) { + return min; + } + + return `${min} - ${max}`; +}; + +/** + * Formats a bedroom range into a readable string. + * Examples: (1, 3) → "1-3 beds", (2, 2) → "2 beds", (0, 2) → "Studio-2 beds" + * + * @param {number} minBeds - The minimum number of bedrooms + * @param {number} maxBeds - The maximum number of bedrooms + * @returns {string} The formatted bedroom range string + */ +export const formatBedsRange = (minBeds: number, maxBeds: number): string => { + if (minBeds === maxBeds) { + return formatBeds(minBeds); + } + + // Handle studio to X beds case + if (minBeds === 0) { + return `Studio-${maxBeds} beds`; + } + + return `${minBeds}-${maxBeds} beds`; +}; + +/** + * Formats a bathroom range into a readable string. + * Examples: (1, 2) → "1-2 baths", (1.5, 1.5) → "1.5 baths" + * + * @param {number} minBaths - The minimum number of bathrooms + * @param {number} maxBaths - The maximum number of bathrooms + * @returns {string} The formatted bathroom range string + */ +export const formatBathsRange = (minBaths: number, maxBaths: number): string => { + if (minBaths === maxBaths) { + return formatBaths(minBaths); + } + + return `${minBaths}-${maxBaths} baths`; +}; + +/** + * Formats room type information for display on apartment cards. + * Returns "Coming soon" if no room types, otherwise returns formatted ranges. + * Example outputs: + * - No room types: "Coming soon" + * - Single room type: "$2K | 2 beds | 1 bath" + * - Multiple room types: "$1.5K - $3K | 1-3 beds | 1-2 baths" + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {string} The formatted display string + */ +export const formatRoomTypesDisplay = (roomTypes: readonly RoomType[]): string => { + const range = getRoomTypeRange(roomTypes); + + if (!range) { + return 'Coming soon'; + } + + const priceStr = formatPriceRange(range.minPrice, range.maxPrice); + const bedsStr = formatBedsRange(range.minBeds, range.maxBeds); + const bathsStr = formatBathsRange(range.minBaths, range.maxBaths); + + return `${priceStr} | ${bedsStr} | ${bathsStr}`; +}; + +/** + * Gets the minimum price from a room types array, or null if empty. + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {number | null} The minimum price or null if no room types + */ +export const getMinPrice = (roomTypes: readonly RoomType[]): number | null => { + const range = getRoomTypeRange(roomTypes); + return range ? range.minPrice : null; +}; + +/** + * Gets the maximum price from a room types array, or null if empty. + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {number | null} The maximum price or null if no room types + */ +export const getMaxPrice = (roomTypes: readonly RoomType[]): number | null => { + const range = getRoomTypeRange(roomTypes); + return range ? range.maxPrice : null; +}; From 887d5ca80f000f405877fe47d93b0ec44ff4f1e6 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 15:24:31 -0500 Subject: [PATCH 14/32] Implement apartment card display and search filtering for room types - Update ApartmentCard component to display room type information - Add imports for formatPriceRange, formatBedsRange, getRoomTypeRange utilities - Replace TODO section with dynamic room types display - Show "Coming soon" message for apartments with empty roomTypes - Display formatted price and bed ranges for apartments with room types data - Implement comprehensive search filtering with room types support - Re-enable price, bedroom, and bathroom filter parameters - Add exact match filtering for beds/baths (not >=) - Implement additional search result sections (location, price, bed/bath) - Return structured response with main results + 3 additional sections - Filter apartments based on roomTypes array instead of old schema - Handle empty roomTypes arrays in filter logic - Update search results page to support additional sections - Add state variables for additionalLocation, additionalPrice, additionalBedBath - Prepare frontend for displaying multiple result sections - Enhance apartment sorting with room types - Use getMinPrice/getMaxPrice utilities for sorting by price - Sort by minimum price (cheapest) or maximum price (most expensive) - Handle apartments with empty roomTypes arrays This completes the frontend display implementation for the room types refactor. Backend migration tools and admin CRUD are already in place from previous commits. Ready for data migration and testing. --- backend/src/app.ts | 166 +++++++--- .../ApartmentCard/ApartmentCard.tsx | 63 ++-- frontend/src/pages/AdminPage.tsx | 284 +++++++++++++++--- frontend/src/pages/SearchResultsPage.tsx | 171 ++++++++++- frontend/src/utils/sortApartments.ts | 21 +- 5 files changed, 595 insertions(+), 110 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 5f8c3105..ef250825 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -500,10 +500,10 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { // Extract all query parameters const query = req.query.q as string; const locations = req.query.locations as string; - // const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; - // const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; - // const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; - // const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; + const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; + const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; + const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; + const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; const size = req.query.size ? parseInt(req.query.size as string, 10) : null; const sortBy = req.query.sortBy || 'numReviews'; @@ -511,78 +511,148 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { const apts = req.app.get('apts'); const aptsWithType: ApartmentWithId[] = apts; - // Start with text search if query is provided - let filteredResults: ApartmentWithId[] = []; + // STEP 1: Apply text search first + let baseResults: ApartmentWithId[] = []; if (query && query.trim() !== '') { const options = { keys: ['name', 'address'], }; const fuse = new Fuse(aptsWithType, options); const searchResults = fuse.search(query); - filteredResults = searchResults.map((result) => result.item); + baseResults = searchResults.map((result) => result.item); } else { // If no query, start with all apartments - filteredResults = aptsWithType; + baseResults = aptsWithType; } - // Apply location filter if provided - if (locations && locations.trim() !== '') { + // STEP 2: Count active filter categories + const hasLocation = locations && locations.trim() !== ''; + const hasPrice = minPrice !== null || maxPrice !== null; + const hasBedBath = (bedrooms !== null && bedrooms > 0) || (bathrooms !== null && bathrooms > 0); + const activeFilterCount = [hasLocation, hasPrice, hasBedBath].filter(Boolean).length; + + // Helper: Filter by location only + const filterByLocation = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasLocation) return []; const locationArray = locations.split(',').map((loc) => loc.toUpperCase()); - filteredResults = filteredResults.filter((apt) => - locationArray.includes(apt.area ? apt.area.toUpperCase() : '') - ); + return apts.filter((apt) => locationArray.includes(apt.area ? apt.area.toUpperCase() : '')); + }; + + // Helper: Filter by price only + const filterByPrice = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasPrice) return []; + return apts.filter((apt) => { + if (!apt.roomTypes || apt.roomTypes.length === 0) return false; + return apt.roomTypes.some((roomType) => { + if (minPrice !== null && roomType.price < minPrice) return false; + if (maxPrice !== null && roomType.price > maxPrice) return false; + return true; + }); + }); + }; + + // Helper: Filter by bed/bath only + const filterByBedBath = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasBedBath) return []; + return apts.filter((apt) => { + if (!apt.roomTypes || apt.roomTypes.length === 0) return false; + return apt.roomTypes.some((roomType) => { + if (bedrooms !== null && bedrooms > 0 && roomType.beds !== bedrooms) return false; + if (bathrooms !== null && bathrooms > 0 && roomType.baths !== bathrooms) return false; + return true; + }); + }); + }; + + // Helper: Filter by ALL criteria (main results) + const filterByAll = (apts: ApartmentWithId[]): ApartmentWithId[] => { + let filtered = apts; + + // Apply location filter + if (hasLocation) { + filtered = filterByLocation(filtered); + } + + // Apply price filter + if (hasPrice) { + filtered = filterByPrice(filtered); + } + + // Apply bed/bath filter + if (hasBedBath) { + filtered = filterByBedBath(filtered); + } + + return filtered; + }; + + // STEP 3: Calculate results based on active filter count + let mainResults: ApartmentWithId[] = []; + let additionalLocation: ApartmentWithId[] = []; + let additionalPrice: ApartmentWithId[] = []; + let additionalBedBath: ApartmentWithId[] = []; + + if (activeFilterCount === 0) { + // No filters: return all base results as main + mainResults = baseResults; + } else if (activeFilterCount === 1) { + // 1 filter: main results only, no additional sections + mainResults = filterByAll(baseResults); + } else { + // 2+ filters: calculate main + additional sections + // Main: matches ALL filters + mainResults = filterByAll(baseResults); + const mainIds = new Set(mainResults.map((apt) => apt.id)); + + // Additional: each individual filter (excluding main results) + if (hasLocation) { + additionalLocation = filterByLocation(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + if (hasPrice) { + additionalPrice = filterByPrice(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + if (hasBedBath) { + additionalBedBath = filterByBedBath(baseResults).filter((apt) => !mainIds.has(apt.id)); + } } - // TODO: Right now we disable the price filter because of lack of data - // Apply price range filters - // if (minPrice !== null) { - // filteredResults = filteredResults.filter((apt) => apt.price >= minPrice); - // } - - // if (maxPrice !== null) { - // filteredResults = filteredResults.filter((apt) => apt.price <= maxPrice); - // } - - // Apply bedroom filter - // TODO: Right now we disable the bedroom filter because of lack of data - // if (bedrooms !== null && bedrooms > 0) { - // filteredResults = filteredResults.filter( - // (apt) => apt.numBeds !== null && apt.numBeds >= bedrooms - // ); - // } - - // // Apply bathroom filter - // if (bathrooms !== null && bathrooms > 0) { - // filteredResults = filteredResults.filter( - // (apt) => apt.numBaths !== null && apt.numBaths >= bathrooms - // ); - // } - - // Process the filtered results through pageData to include reviews, ratings, etc. - let enrichedResults = await pageData(filteredResults); - - // If size is specified and less than available, slice the results - if (size && size > 0 && enrichedResults.length > size) { + // STEP 4: Enrich all result sections with pageData + const enrichMain = await pageData(mainResults); + const enrichLocation = await pageData(additionalLocation); + const enrichPrice = await pageData(additionalPrice); + const enrichBedBath = await pageData(additionalBedBath); + + // STEP 5: Apply sorting and size limit to main results + let finalMain = enrichMain; + if (size && size > 0 && finalMain.length > size) { console.log('endpoint sortBy', sortBy); switch (sortBy) { case 'numReviews': - enrichedResults.sort((a, b) => b.numReviews - a.numReviews); + finalMain.sort((a, b) => b.numReviews - a.numReviews); break; case 'avgRating': - enrichedResults.sort((a, b) => b.avgRating - a.avgRating); + finalMain.sort((a, b) => b.avgRating - a.avgRating); break; case 'distanceToCampus': - enrichedResults.sort( + finalMain.sort( (a, b) => a.buildingData.distanceToCampus - b.buildingData.distanceToCampus ); break; default: break; } - enrichedResults = enrichedResults.slice(0, size); + finalMain = finalMain.slice(0, size); } - res.status(200).send(JSON.stringify(enrichedResults)); + // STEP 6: Return structured response + const response = { + main: finalMain, + additionalLocation: enrichLocation, + additionalPrice: enrichPrice, + additionalBedBath: enrichBedBath, + }; + + res.status(200).send(JSON.stringify(response)); } catch (err) { console.error(err); res.status(400).send('Error'); diff --git a/frontend/src/components/ApartmentCard/ApartmentCard.tsx b/frontend/src/components/ApartmentCard/ApartmentCard.tsx index 4384e359..ce155f79 100644 --- a/frontend/src/components/ApartmentCard/ApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/ApartmentCard.tsx @@ -21,6 +21,7 @@ import HeartRating from '../utils/HeartRating'; import { getAverageRating } from '../../utils/average'; import { colors } from '../../colors'; import { Link as RouterLink } from 'react-router-dom'; +import { formatPriceRange, formatBedsRange, getRoomTypeRange } from '../../utils/roomTypeUtils'; type Props = { buildingData: ApartmentWithId; @@ -353,28 +354,46 @@ const ApartmentCard = ({ > {`Rating: ${avgRating.toFixed(1)}`} - {/* TODO: Room type display - will be implemented in Phase 3 */} - {/* - {`Beds: ${buildingData.numBeds}`} - - - {`Baths: ${buildingData.numBaths}`} - - - {`Price: ${buildingData.price}`} - */} + {/* Room Types Display */} + {(() => { + const roomTypeRange = getRoomTypeRange(buildingData.roomTypes); + if (!roomTypeRange) { + // No room types - show coming soon message + return ( + + Room type information coming soon + + ); + } + // Has room types - show price and beds + return ( + <> + + {formatPriceRange(roomTypeRange.minPrice, roomTypeRange.maxPrice)} + + + {formatBedsRange(roomTypeRange.minBeds, roomTypeRange.maxBeds)} + + + ); + })()} )} {/* Sample Review */} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2a441175..c3de50b1 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -15,7 +15,6 @@ import { Button, TextField, Box, - Fab, Dialog, DialogTitle, DialogContent, @@ -30,7 +29,6 @@ import { CantFindApartmentFormWithId, QuestionFormWithId, ReviewWithId, - ApartmentWithId, } from '../../../common/types/db-types'; import { get } from '../utils/call'; import AdminReviewComponent from '../components/Admin/AdminReview'; @@ -39,7 +37,6 @@ import { Chart } from 'react-google-charts'; import { sortReviews } from '../utils/sortReviews'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import EditIcon from '@material-ui/icons/Edit'; -import SaveIcon from '@material-ui/icons/Save'; import CancelIcon from '@material-ui/icons/Cancel'; import SortIcon from '@material-ui/icons/Sort'; import clsx from 'clsx'; @@ -80,15 +77,6 @@ const useStyles = makeStyles((theme) => ({ sortButton: { marginLeft: theme.spacing(2), }, - editField: { - marginBottom: theme.spacing(1), - width: '100%', - }, - editButtons: { - display: 'flex', - gap: theme.spacing(1), - marginTop: theme.spacing(1), - }, apartmentCard: { borderBottom: '1px solid #e0e0e0', padding: '15px 0', @@ -139,7 +127,21 @@ const AdminPage = (): ReactElement => { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [editingApartment, setEditingApartment] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); + const [isCreatingNew, setIsCreatingNew] = useState(false); const [editingRoomTypes, setEditingRoomTypes] = useState([]); + const [validationErrors, setValidationErrors] = useState([]); + + // Apartment field editing state + const [editingName, setEditingName] = useState(''); + const [editingAddress, setEditingAddress] = useState(''); + const [editingLandlordId, setEditingLandlordId] = useState(''); + const [editingArea, setEditingArea] = useState< + 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' + >('COLLEGETOWN'); + const [editingPhotos, setEditingPhotos] = useState([]); + const [editingLatitude, setEditingLatitude] = useState(0); + const [editingLongitude, setEditingLongitude] = useState(0); + const [editingDistance, setEditingDistance] = useState(0); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -163,8 +165,6 @@ const AdminPage = (): ReactElement => { expandOpen, headerContainer, sortButton, - editField, - editButtons, apartmentCard, editButton, } = useStyles(); @@ -299,14 +299,32 @@ const AdminPage = (): ReactElement => { // Room Types Edit Handlers const handleEditClick = (apartment: any) => { - setEditingApartment(apartment.buildingData?.id); - setEditingRoomTypes(apartment.buildingData?.roomTypes || []); + const aptData = apartment.buildingData; + setEditingApartment(aptData?.id || null); + setEditingRoomTypes(aptData?.roomTypes || []); + setEditingName(aptData?.name || ''); + setEditingAddress(aptData?.address || ''); + setEditingLandlordId(aptData?.landlordId || ''); + setEditingArea(aptData?.area || 'COLLEGETOWN'); + setEditingPhotos(aptData?.photos || []); + setEditingLatitude(aptData?.latitude || 0); + setEditingLongitude(aptData?.longitude || 0); + setEditingDistance(aptData?.distanceToCampus || 0); setEditModalOpen(true); }; const handleCancelEdit = () => { setEditingApartment(null); setEditingRoomTypes([]); + setValidationErrors([]); + setEditingName(''); + setEditingAddress(''); + setEditingLandlordId(''); + setEditingArea('COLLEGETOWN'); + setEditingPhotos([]); + setEditingLatitude(0); + setEditingLongitude(0); + setEditingDistance(0); setEditModalOpen(false); }; @@ -318,7 +336,44 @@ const AdminPage = (): ReactElement => { }; const handleRemoveRoomType = (index: number) => { - setEditingRoomTypes((prev) => prev.filter((_, i) => i !== index)); + const newRoomTypes = editingRoomTypes.filter((_, i) => i !== index); + setEditingRoomTypes(newRoomTypes); + setValidationErrors(validateRoomTypes(newRoomTypes)); + }; + + // Validation function + const validateRoomTypes = (roomTypes: any[]): string[] => { + const errors: string[] = []; + + // Check for invalid values + roomTypes.forEach((rt, index) => { + if (!rt.beds || rt.beds < 1 || !Number.isInteger(rt.beds)) { + errors.push(`Row ${index + 1}: Beds must be a whole number ≥ 1`); + } + if (!rt.baths || rt.baths < 1 || !Number.isInteger(rt.baths)) { + errors.push(`Row ${index + 1}: Baths must be a whole number ≥ 1`); + } + if (!rt.price || rt.price < 1 || !Number.isInteger(rt.price)) { + errors.push(`Row ${index + 1}: Price must be a whole number ≥ 1`); + } + }); + + // Check for duplicates + const seen = new Map(); + roomTypes.forEach((rt, index) => { + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + errors.push( + `Duplicate room type: ${rt.beds} bed${rt.beds > 1 ? 's' : ''}, ${rt.baths} bath${ + rt.baths > 1 ? 's' : '' + }, $${rt.price} (rows ${seen.get(key)! + 1} and ${index + 1})` + ); + } else { + seen.set(key, index); + } + }); + + return errors; }; const handleRoomTypeChange = ( @@ -329,14 +384,33 @@ const AdminPage = (): ReactElement => { const numValue = parseInt(value) || 1; if (numValue < 1) return; // Enforce >= 1 constraint - setEditingRoomTypes((prev) => - prev.map((rt, i) => (i === index ? { ...rt, [field]: numValue } : rt)) + const newRoomTypes = editingRoomTypes.map((rt, i) => + i === index ? { ...rt, [field]: numValue } : rt ); + setEditingRoomTypes(newRoomTypes); + setValidationErrors(validateRoomTypes(newRoomTypes)); }; const handleSaveEdit = async () => { if (!editingApartment) return; + // Validate before saving + const errors = validateRoomTypes(editingRoomTypes); + if (errors.length > 0) { + setValidationErrors(errors); + return; + } + + // Validate required fields + if (!editingName.trim()) { + alert('Apartment name is required'); + return; + } + if (!editingAddress.trim()) { + alert('Address is required'); + return; + } + try { const user = await getUser(); if (!user) { @@ -344,21 +418,20 @@ const AdminPage = (): ReactElement => { return; } - // Check for duplicate room types - const seen = new Set(); - for (const rt of editingRoomTypes) { - const key = `${rt.beds}-${rt.baths}-${rt.price}`; - if (seen.has(key)) { - alert(`Duplicate room type exists: ${rt.beds} beds, ${rt.baths} baths, $${rt.price}`); - return; - } - seen.add(key); - } - const token = await user.getIdToken(true); const response = await axios.put( `/api/admin/update-apartment/${editingApartment}`, - { roomTypes: editingRoomTypes }, + { + name: editingName.trim(), + address: editingAddress.trim(), + landlordId: editingLandlordId.trim() || null, + area: editingArea, + photos: editingPhotos, + latitude: editingLatitude, + longitude: editingLongitude, + distanceToCampus: editingDistance, + roomTypes: editingRoomTypes, + }, createAuthHeaders(token) ); @@ -936,9 +1009,9 @@ const AdminPage = (): ReactElement => { // Room Types Edit Modal const roomTypesModal = ( - + - Edit Room Types + Edit Apartment {editingApartment && ( Apartment ID: {editingApartment} @@ -946,6 +1019,113 @@ const AdminPage = (): ReactElement => { )} + {/* Apartment Information Section */} + + + Apartment Information + + + + setEditingName(e.target.value)} + required + size="small" + /> + + + setEditingAddress(e.target.value)} + required + size="small" + /> + + + setEditingLandlordId(e.target.value)} + size="small" + /> + + + setEditingArea(e.target.value as any)} + SelectProps={{ native: true }} + size="small" + > + + + + + + + + + setEditingLatitude(parseFloat(e.target.value) || 0)} + size="small" + /> + + + setEditingLongitude(parseFloat(e.target.value) || 0)} + size="small" + /> + + + setEditingDistance(parseFloat(e.target.value) || 0)} + size="small" + /> + + + + setEditingPhotos( + e.target.value + .split(',') + .map((url) => url.trim()) + .filter(Boolean) + ) + } + size="small" + multiline + rows={2} + /> + + + + + {/* Room Types Section */} + + Room Types + {editingRoomTypes.length === 0 ? ( No room types. Click "Add Room Type" below to add one. @@ -1035,12 +1215,46 @@ const AdminPage = (): ReactElement => { + Add Room Type + + {/* Validation Errors */} + {validationErrors.length > 0 && ( + + + Please fix the following errors: + + {validationErrors.map((error, idx) => ( + + • {error} + + ))} + + )} - @@ -1053,7 +1267,7 @@ const AdminPage = (): ReactElement => { setSelectedTab(newValue)} + onChange={(_event, newValue) => setSelectedTab(newValue)} aria-label="navigation tabs" variant="fullWidth" > diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index 4fe82aa3..cd548139 100644 --- a/frontend/src/pages/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResultsPage.tsx @@ -77,6 +77,9 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { const path = useLocation(); const [pathName] = useState(path.pathname); const [searchResults, setSearchResults] = useState([]); + const [additionalLocation, setAdditionalLocation] = useState([]); + const [additionalPrice, setAdditionalPrice] = useState([]); + const [additionalBedBath, setAdditionalBedBath] = useState([]); const [sortBy, setSortBy] = useState( 'originalOrder' ); @@ -134,8 +137,28 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { params.append('sortLowToHigh', filters.initialSortLowToHigh.toString()); - get(`/api/search-with-query-and-filters?${params.toString()}&size=21`, { - callback: setSearchResults, + get<{ + main: CardData[]; + additionalLocation: CardData[]; + additionalPrice: CardData[]; + additionalBedBath: CardData[]; + }>(`/api/search-with-query-and-filters?${params.toString()}&size=21`, { + callback: (data) => { + // Defensive: handle both old and new API response formats + if (data && typeof data === 'object' && 'main' in data) { + setSearchResults(data.main || []); + setAdditionalLocation(data.additionalLocation || []); + setAdditionalPrice(data.additionalPrice || []); + setAdditionalBedBath(data.additionalBedBath || []); + } else { + // Fallback for unexpected response format + console.warn('Unexpected API response format:', data); + setSearchResults([]); + setAdditionalLocation([]); + setAdditionalPrice([]); + setAdditionalBedBath([]); + } + }, }); setSortBy(filters.initialSortBy); @@ -148,6 +171,32 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { sessionStorage.setItem(`resultsCount_search_${query}`, count.toString()); }; + // Helper: Generate section title for additional results + const getAdditionalSectionTitle = (type: 'location' | 'price' | 'bedBath'): string => { + if (type === 'location' && filters.locations && filters.locations.length > 0) { + return `More at ${filters.locations.join(', ')}`; + } + if (type === 'price' && (filters.minPrice || filters.maxPrice)) { + const min = filters.minPrice ? `$${filters.minPrice}` : ''; + const max = filters.maxPrice ? `$${filters.maxPrice}` : ''; + if (min && max) return `More within ${min} - ${max}`; + if (min) return `More above ${min}`; + if (max) return `More under ${max}`; + } + if (type === 'bedBath') { + const bedText = + filters.bedrooms > 0 ? `${filters.bedrooms} bedroom${filters.bedrooms > 1 ? 's' : ''}` : ''; + const bathText = + filters.bathrooms > 0 + ? `${filters.bathrooms} bathroom${filters.bathrooms > 1 ? 's' : ''}` + : ''; + if (bedText && bathText) return `More with ${bedText} and ${bathText}`; + if (bedText) return `More with ${bedText}`; + if (bathText) return `More with ${bathText}`; + } + return ''; + }; + const sortSection = () => { return (
    @@ -170,7 +219,6 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Lowest Price', callback: () => { - // TODO: Phase 4 - Update to sort by room type prices setSortBy('avgPrice'); setSortLowToHigh(true); }, @@ -178,7 +226,6 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Highest Price', callback: () => { - // TODO: Phase 4 - Update to sort by room type prices setSortBy('avgPrice'); setSortLowToHigh(false); }, @@ -221,6 +268,7 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { {!isMobile ? (
    + {/* Main Results */} { sortMethod={sortBy} orderLowToHigh={sortLowToHigh} /> + + {/* Additional Location Section */} + {additionalLocation.length > 0 && ( + <> + + {getAdditionalSectionTitle('location')} + + + + )} + + {/* Additional Price Section */} + {additionalPrice.length > 0 && ( + <> + + {getAdditionalSectionTitle('price')} + + + + )} + + {/* Additional Bed/Bath Section */} + {additionalBedBath.length > 0 && ( + <> + + {getAdditionalSectionTitle('bedBath')} + + + + )}
    { />
    + {/* Main Results */} { sortMethod={sortBy} orderLowToHigh={sortLowToHigh} /> + + {/* Additional Location Section */} + {additionalLocation.length > 0 && ( + <> + + {getAdditionalSectionTitle('location')} + + + + )} + + {/* Additional Price Section */} + {additionalPrice.length > 0 && ( + <> + + {getAdditionalSectionTitle('price')} + + + + )} + + {/* Additional Bed/Bath Section */} + {additionalBedBath.length > 0 && ( + <> + + {getAdditionalSectionTitle('bedBath')} + + + + )}
    )} diff --git a/frontend/src/utils/sortApartments.ts b/frontend/src/utils/sortApartments.ts index d3b43dad..cc2038b7 100644 --- a/frontend/src/utils/sortApartments.ts +++ b/frontend/src/utils/sortApartments.ts @@ -1,5 +1,7 @@ import { CardData } from '../App'; import { ApartmentWithId } from '../../../common/types/db-types'; +import { getMinPrice, getMaxPrice } from './roomTypeUtils'; + export type AptSortFields = keyof CardData | keyof ApartmentWithId | 'originalOrder'; /** @@ -8,6 +10,9 @@ export type AptSortFields = keyof CardData | keyof ApartmentWithId | 'originalOr * @remarks * Creates a shallow copy of the input array and sorts it based on either CardData or ApartmentWithId properties. * If the property values are equal, sorts by apartment ID as a tiebreaker. + * For price sorting, uses room types data to get min/max prices: + * - orderLowToHigh = true: Uses minimum price from room types (cheapest option) + * - orderLowToHigh = false: Uses maximum price from room types (most expensive option) * * @param {CardData[]} arr - Array of apartment card data to sort * @param {AptSortFields} property - Property to sort apartments by @@ -24,8 +29,22 @@ const sortApartments = (arr: CardData[], property: AptSortFields, orderLowToHigh return clonedArr.sort((r1, r2) => { let first, second; + // Special handling for price sorting with room types + if (property === 'avgPrice') { + const roomTypes1 = r1.buildingData?.roomTypes; + const roomTypes2 = r2.buildingData?.roomTypes; + + // For lowest price sorting, use min price; for highest price, use max price + if (orderLowToHigh) { + first = getMinPrice(roomTypes1 || []) ?? r1.avgPrice ?? 0; + second = getMinPrice(roomTypes2 || []) ?? r2.avgPrice ?? 0; + } else { + first = getMaxPrice(roomTypes1 || []) ?? r1.avgPrice ?? 0; + second = getMaxPrice(roomTypes2 || []) ?? r2.avgPrice ?? 0; + } + } //if property is a key of ApartmentWithId, then sort by that property using r1?.buildingData[property] - if (property in r1.buildingData) { + else if (property in r1.buildingData) { const prop = property as keyof ApartmentWithId; first = r1.buildingData?.[prop] ?? 0; second = r2.buildingData?.[prop] ?? 0; From 38314891420955ae6c15bfe7101e4f21d23f328f Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 15:43:54 -0500 Subject: [PATCH 15/32] Enhance Admin Apartment Data tab with search, filters, and improved layout - Rename "Data" tab to "Apartment Data" for clarity - Remove test apartments pagination and filtering logic - Add real-time search functionality: - Search by apartment name - Search by apartment address - Both searches reset pagination to first page - Add room types filter dropdown: - All Apartments (default) - With Room Types (show only apartments with room types data) - Without Room Types (show only apartments missing room types) - Reorganize apartment display with horizontal card layout: - Replace vertical list with compact card-based design - Organize information in 3 responsive columns (Location, Details, Stats) - Add visual distinction for apartments without room types (gray italic) - Position edit button in top-right corner of each card - Reduce vertical space usage by ~50% - Update display counter to show filtered vs total apartment counts - Simplify pagination logic by removing test apartment handling - Improve visual hierarchy with section labels and consistent spacing This makes it easier for admins to find and manage apartments, especially when populating room types data after migration. --- frontend/src/pages/AdminPage.tsx | 338 +++++++++++++++++++++---------- 1 file changed, 228 insertions(+), 110 deletions(-) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index c3de50b1..1511b434 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -147,6 +147,11 @@ const AdminPage = (): ReactElement => { const [currentPage, setCurrentPage] = useState(1); const apartmentsPerPage = 50; + // Search and filter state for Apartment Data tab + const [searchName, setSearchName] = useState(''); + const [searchAddress, setSearchAddress] = useState(''); + const [roomTypeFilter, setRoomTypeFilter] = useState<'all' | 'with' | 'without'>('all'); + // Migration state const [migrationStatus, setMigrationStatus] = useState< 'idle' | 'preview' | 'running' | 'complete' | 'error' @@ -183,8 +188,34 @@ const AdminPage = (): ReactElement => { setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); }; - // First, sort all apartments by ID to get the correct page ranges - const allApartmentsSorted = [...apartments].sort((a, b) => { + // Apply search and filter + const filteredApartments = apartments.filter((apt) => { + const aptData = apt.buildingData; + if (!aptData) return false; + + // Apply name search + if (searchName && !aptData.name?.toLowerCase().includes(searchName.toLowerCase())) { + return false; + } + + // Apply address search + if (searchAddress && !aptData.address?.toLowerCase().includes(searchAddress.toLowerCase())) { + return false; + } + + // Apply room type filter + if (roomTypeFilter === 'with' && (!aptData.roomTypes || aptData.roomTypes.length === 0)) { + return false; + } + if (roomTypeFilter === 'without' && aptData.roomTypes && aptData.roomTypes.length > 0) { + return false; + } + + return true; + }); + + // Sort all apartments by ID to get the correct page ranges + const allApartmentsSorted = [...filteredApartments].sort((a, b) => { const aId = a.buildingData?.id || ''; const bId = b.buildingData?.id || ''; const aNum = parseInt(aId, 10) || 0; @@ -192,24 +223,11 @@ const AdminPage = (): ReactElement => { return aNum - bNum; // Always sort ascending to get correct page ranges }); - // Filter test apartments (IDs > 1000) - const testApartments = allApartmentsSorted.filter((apt) => { - const id = parseInt(apt.buildingData?.id || '0', 10); - return id > 1000; - }); - - // Determine if we're showing test apartments or regular apartments - const isTestPage = currentPage > Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - const apartmentsToShow = isTestPage ? testApartments : allApartmentsSorted; - - // Pagination logic - get the correct apartments for the current page - const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - const testPages = Math.ceil(testApartments.length / apartmentsPerPage); - const totalPages = regularPages + (testPages > 0 ? 1 : 0); // +1 for test apartments page - - const startIndex = isTestPage ? 0 : (currentPage - 1) * apartmentsPerPage; - const endIndex = isTestPage ? apartmentsPerPage : startIndex + apartmentsPerPage; - const pageApartments = apartmentsToShow.slice(startIndex, endIndex); + // Pagination logic + const totalPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const startIndex = (currentPage - 1) * apartmentsPerPage; + const endIndex = startIndex + apartmentsPerPage; + const pageApartments = allApartmentsSorted.slice(startIndex, endIndex); // Then sort within the page based on the sort order const currentPageApartments = [...pageApartments].sort((a, b) => { @@ -462,17 +480,9 @@ const AdminPage = (): ReactElement => { }; const getPageRange = (page: number) => { - const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - - if (page > regularPages) { - // This is the test apartments page - return `Test (${testApartments.length})`; - } else { - // Regular page - const start = (page - 1) * apartmentsPerPage + 1; - const end = Math.min(page * apartmentsPerPage, allApartmentsSorted.length); - return `${start}-${end}`; - } + const start = (page - 1) * apartmentsPerPage + 1; + const end = Math.min(page * apartmentsPerPage, allApartmentsSorted.length); + return `${start}-${end}`; }; // calls the APIs and the callback function to set the reviews for each review type @@ -883,14 +893,9 @@ const AdminPage = (): ReactElement => { All Apartments ({apartments.length}) - Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | - {isTestPage ? ( - <>Showing: Test Apartments ({testApartments.length}) - ) : ( - <> - Showing: {getPageRange(currentPage)} of {apartments.length} - - )} + Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | Showing:{' '} + {getPageRange(currentPage)} of {filteredApartments.length} filtered ( + {apartments.length} total)
    @@ -906,6 +911,67 @@ const AdminPage = (): ReactElement => {
    + {/* Search and Filter Controls */} + + + + { + setSearchName(e.target.value); + setCurrentPage(1); // Reset to first page on search + }} + placeholder="Enter apartment name..." + /> + + + { + setSearchAddress(e.target.value); + setCurrentPage(1); // Reset to first page on search + }} + placeholder="Enter address..." + /> + + + { + setRoomTypeFilter(e.target.value as 'all' | 'with' | 'without'); + setCurrentPage(1); // Reset to first page on filter change + }} + SelectProps={{ native: true }} + > + + + + + + + + {/* Pagination Buttons */}
    { justifyContent: 'center', }} > - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { - const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - const isTestPageButton = page > regularPages; + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} +
    +
    + {currentPageApartments.map((apartment, index) => { + const aptData = apartment.buildingData; + const hasRoomTypes = aptData?.roomTypes && aptData.roomTypes.length > 0; return ( - - ); - })} -
    - - {currentPageApartments.map((apartment, index) => { - return ( - + {/* Edit Button */} handleEditClick(apartment)} size="small" - title="Edit room types" + title="Edit apartment" > - + - - {apartment.buildingData?.name || 'N/A'} - - } - secondary={ -
    - - Apartment ID: {apartment.buildingData?.id || 'N/A'} + {/* Apartment Name and ID */} + + {aptData?.name || 'N/A'} + + ID: {aptData?.id || 'N/A'} + + + + {/* Main Info Grid */} + + {/* Left Column - Location Info */} + + + + ADDRESS - - Address: {apartment.buildingData?.address || 'N/A'} + + {aptData?.address || 'N/A'} - - Location: {apartment.buildingData?.area || 'N/A'} + + AREA - -
    - - Room Types:{' '} - {apartment.buildingData?.roomTypes && - apartment.buildingData.roomTypes.length > 0 - ? `${apartment.buildingData.roomTypes.length} type${ - apartment.buildingData.roomTypes.length > 1 ? 's' : '' - }` - : 'No room types'} - - - Company: {apartment.company || 'N/A'} - - - Reviews: {apartment.numReviews || 0} - - - Avg Rating:{' '} - {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} - - - Avg Price:{' '} - {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} - -
    -
    - } - /> -
    + {aptData?.area || 'N/A'} + + + + {/* Middle Column - Room Types & Company */} + + + + ROOM TYPES + + + {hasRoomTypes + ? `${aptData.roomTypes.length} type${ + aptData.roomTypes.length > 1 ? 's' : '' + }` + : 'No room types'} + + + COMPANY + + {apartment.company || 'N/A'} + + + + {/* Right Column - Stats */} + + + + + + REVIEWS + + {apartment.numReviews || 0} + + + + AVG RATING + + + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + + + AVG PRICE + + + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + + + + + + + ); })} -
    + @@ -1273,7 +1391,7 @@ const AdminPage = (): ReactElement => { > - +
    From f26cff8f53c0eae5b73dd1ba7be2a822ce7ef4b4 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 15:59:47 -0500 Subject: [PATCH 16/32] Add Create New Apartment functionality to Admin Data tab - Add "Create New Apartment" button in Apartment Data tab header - Implement two-step apartment creation workflow: 1. Preview mode: Calculate location data from address 2. Confirm mode: Create apartment in database - Create modal dialog with required fields: - Apartment Name (text input) - Full Address (text input with geocoding support) - Landlord ID (text input) - Area (dropdown: Collegetown, West, North, Downtown, Other) - Display preview of calculated location data before creation: - Show latitude and longitude from geocoded address - Show calculated distance to campus - Confirm before final creation - Add comprehensive error handling and validation: - Form validation for required fields - Backend error display in modal - Loading states for preview and create actions - Integrate with existing `/api/admin/add-apartment` endpoint - Endpoint automatically geocodes address to get coordinates - Calculates walking distance to Ho Plaza - Validates landlord exists - Checks for duplicate locations - Auto-reload apartment list after successful creation - Show success message with generated apartment ID This streamlines the apartment creation process for admins by automating location data calculation and providing validation before committing changes to the database. --- frontend/src/pages/AdminPage.tsx | 249 +++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 1511b434..2d717910 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -159,6 +159,20 @@ const AdminPage = (): ReactElement => { const [migrationSummary, setMigrationSummary] = useState(null); const [migrationProgress, setMigrationProgress] = useState(''); + // Create new apartment state + const [createModalOpen, setCreateModalOpen] = useState(false); + const [newApartmentName, setNewApartmentName] = useState(''); + const [newApartmentAddress, setNewApartmentAddress] = useState(''); + const [newApartmentLandlordId, setNewApartmentLandlordId] = useState(''); + const [newApartmentArea, setNewApartmentArea] = useState< + 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' + >('COLLEGETOWN'); + const [createStatus, setCreateStatus] = useState<'idle' | 'preview' | 'creating' | 'error'>( + 'idle' + ); + const [previewData, setPreviewData] = useState(null); + const [createError, setCreateError] = useState(''); + // Debug apartments state changes useEffect(() => { console.log('Apartments loaded:', apartments.length); @@ -243,6 +257,105 @@ const AdminPage = (): ReactElement => { } }); + // Create new apartment handlers + const handleOpenCreateModal = () => { + setCreateModalOpen(true); + setCreateStatus('idle'); + setPreviewData(null); + setCreateError(''); + setNewApartmentName(''); + setNewApartmentAddress(''); + setNewApartmentLandlordId(''); + setNewApartmentArea('COLLEGETOWN'); + }; + + const handleCloseCreateModal = () => { + setCreateModalOpen(false); + setCreateStatus('idle'); + setPreviewData(null); + setCreateError(''); + }; + + const handlePreviewApartment = async () => { + if (!newApartmentName.trim() || !newApartmentAddress.trim() || !newApartmentLandlordId.trim()) { + setCreateError('Please fill in all required fields'); + return; + } + + try { + setCreateStatus('preview'); + setCreateError(''); + + const user = await getUser(); + if (!user) { + setCreateError('You must be logged in'); + setCreateStatus('error'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/add-apartment', + { + name: newApartmentName.trim(), + address: newApartmentAddress.trim(), + landlordId: newApartmentLandlordId.trim(), + area: newApartmentArea, + confirm: false, // Preview mode + }, + createAuthHeaders(token) + ); + + setPreviewData(response.data); + setCreateStatus('idle'); + } catch (error: any) { + console.error('Preview error:', error); + setCreateError(error.response?.data || error.message || 'Error previewing apartment'); + setCreateStatus('error'); + } + }; + + const handleConfirmCreate = async () => { + try { + setCreateStatus('creating'); + setCreateError(''); + + const user = await getUser(); + if (!user) { + setCreateError('You must be logged in'); + setCreateStatus('error'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/add-apartment', + { + name: newApartmentName.trim(), + address: newApartmentAddress.trim(), + landlordId: newApartmentLandlordId.trim(), + area: newApartmentArea, + confirm: true, // Create mode + }, + createAuthHeaders(token) + ); + + alert(`Apartment created successfully! ID: ${response.data.apartmentId}`); + setCreateModalOpen(false); + setCreateStatus('idle'); + setPreviewData(null); + + // Reload apartments list + setTimeout(() => { + window.location.reload(); + }, 1000); + } catch (error: any) { + console.error('Create error:', error); + setCreateError(error.response?.data || error.message || 'Error creating apartment'); + setCreateStatus('error'); + } + }; + // Migration handler functions const handleMigrationPreview = async () => { try { @@ -899,6 +1012,14 @@ const AdminPage = (): ReactElement => {
    + + {!previewData ? ( + + ) : ( + + )} + +
    ); }; From e2839fe2f3697711ab508d83858fdc339fe5a82a Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 22:37:30 -0500 Subject: [PATCH 17/32] Fix search UX and improve results page layout - Persist filter state and query text across search navigation - Auto-close autocomplete dropdown after search submission - Add red color indicator for active filter sections on search results page - Improve search results layout: scrollable apartment list (3/5 width) with sticky map (2/5 width, max 650px) - Reduce apartment card gaps (6px column, 12px row) and fix clickable regions - Show empty state message when no exact matches found - Only display "No search results" in autocomplete when user has typed text --- .../SearchResultsPageApartmentCards.tsx | 51 ++++------ .../src/components/Search/Autocomplete.tsx | 70 +++++++++++--- .../src/components/Search/FilterDropDown.tsx | 16 +++- .../src/components/Search/FilterSection.tsx | 28 +++++- frontend/src/pages/SearchResultsPage.tsx | 96 +++++++++++++++---- 5 files changed, 197 insertions(+), 64 deletions(-) diff --git a/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx b/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx index 0fc0773b..ba05055f 100644 --- a/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx +++ b/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx @@ -1,6 +1,6 @@ import React, { ReactElement, useEffect } from 'react'; import NewApartmentCard from './NewApartmentCard'; -import { Grid, Link, makeStyles, Button, Box, Typography } from '@material-ui/core'; +import { Link, makeStyles } from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; import { CardData } from '../../App'; import { loadingLength } from '../../constants/HomeConsts'; @@ -45,16 +45,9 @@ const useStyles = makeStyles({ cardsContainer: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', - gap: '12px', + columnGap: '6px', + rowGap: '12px', padding: '4px', - height: '600px', - overflowY: 'auto', - scrollbarWidth: 'none', // Firefox - '&::-webkit-scrollbar': { - // Chrome, Safari, Edge - display: 'none', - }, - '-ms-overflow-style': 'none', '@media (max-width: 600px)': { gridTemplateColumns: 'repeat(2, 1fr)', gap: '4px', @@ -127,27 +120,23 @@ const ApartmentCards = ({ ({ buildingData, numReviews, company, avgRating }, index) => { const { id } = buildingData; return ( - <> -
    - - - -
    - + + + ); } )} diff --git a/frontend/src/components/Search/Autocomplete.tsx b/frontend/src/components/Search/Autocomplete.tsx index ddd02569..75b56acd 100644 --- a/frontend/src/components/Search/Autocomplete.tsx +++ b/frontend/src/components/Search/Autocomplete.tsx @@ -55,12 +55,27 @@ const defaultFilters: FilterState = { const Autocomplete = ({ drawerOpen }: Props): ReactElement => { const [isMobile, setIsMobile] = useState(false); - const [filters, setFilters] = useState(defaultFilters); - const [openFilter, setOpenFilter] = useState(false); const location = useLocation(); const isHome = location.pathname === '/'; const isSearchResults = location.pathname.startsWith('/search'); + // Initialize filters and query from URL if on search results page + const getInitialState = () => { + if (isSearchResults) { + const params = new URLSearchParams(location.search); + const query = params.get('q') || ''; + const filtersParam = params.get('filters'); + const filters = filtersParam ? JSON.parse(decodeURIComponent(filtersParam)) : defaultFilters; + return { query, filters }; + } + return { query: '', filters: defaultFilters }; + }; + + const initialState = getInitialState(); + const [filters, setFilters] = useState(initialState.filters); + const [initialQuery] = useState(initialState.query); + const [openFilter, setOpenFilter] = useState(false); + const useStyles = makeStyles((theme) => ({ menuList: { position: 'absolute', @@ -181,14 +196,43 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { } = useStyles(); const inputRef = useRef(document.createElement('div')); const [loading, setLoading] = useState(false); - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(initialQuery); const [width, setWidth] = useState(inputRef.current?.offsetWidth); const [focus, setFocus] = useState(false); const [openMenu, setOpenMenu] = useState(false); const [options, setOptions] = useState([]); const [selected, setSelected] = useState(null); + const [isUserTyping, setIsUserTyping] = useState(false); const history = useHistory(); + // Update query and filters when URL changes (for search results page) + useEffect(() => { + if (isSearchResults) { + const params = new URLSearchParams(location.search); + const urlQuery = params.get('q') || ''; + const filtersParam = params.get('filters'); + const urlFilters = filtersParam + ? JSON.parse(decodeURIComponent(filtersParam)) + : defaultFilters; + + // Mark that this update is from URL, not user typing + setIsUserTyping(false); + setQuery(urlQuery); + setFilters(urlFilters); + setOpenMenu(false); + + // Blur the input to ensure dropdown doesn't show + setTimeout(() => { + if (inputRef.current) { + const textField = inputRef.current.querySelector('input'); + if (textField) { + textField.blur(); + } + } + }, 0); + } + }, [location.search, isSearchResults]); + useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth <= 600); handleResize(); @@ -217,13 +261,13 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { if (event.key === 'ArrowDown') { setFocus(true); } else if (event.key === 'Enter' && checkIfSearchable()) { - setFocus(true); + setFocus(false); console.log('Current filter state:', filters); const filterParams = encodeURIComponent(JSON.stringify(filters)); console.log('Encoded filter params:', filterParams); - history.push(`/search?q=${query}&filters=${filterParams}`); - setQuery(''); + setIsUserTyping(false); setOpenMenu(false); + history.push(`/search?q=${query}&filters=${filterParams}`); } } @@ -232,8 +276,9 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { console.log('Current filter state:', filters); const filterParams = encodeURIComponent(JSON.stringify(filters)); console.log('Encoded filter params:', filterParams); + setIsUserTyping(false); + setOpenMenu(false); history.push(`/search?q=${query}&filters=${filterParams}`); - setQuery(''); } }; @@ -251,6 +296,7 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { setQuery(query); setSelected(null); setOpenFilter(false); + setIsUserTyping(true); // Mark that user is actively typing if (query !== '') { setLoading(true); } else { @@ -288,9 +334,9 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { autoFocusItem={focus} onKeyDown={handleListKeyDown} > - {options.length === 0 ? ( + {options.length === 0 && query.trim().length > 0 ? ( No search results. - ) : ( + ) : options.length === 0 ? null : ( options.map(({ id, name, address, label }, index) => { return ( { useEffect(() => { if (query === '') { setOpenMenu(false); - } else if (selected === null) { + } else if (selected === null && isUserTyping) { + // Only open menu if user is actively typing, not from URL setOpenMenu(true); } else { setOpenMenu(false); } - }, [query, selected]); + }, [query, selected, isUserTyping]); useEffect(() => { const handleResize = () => { @@ -578,6 +625,7 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { open={openFilter} handleSearch={handleSearchIconClick} isMobile={isMobile} + isSearchResultsPage={isSearchResults} /> diff --git a/frontend/src/components/Search/FilterDropDown.tsx b/frontend/src/components/Search/FilterDropDown.tsx index fd3c4c93..44c5a564 100644 --- a/frontend/src/components/Search/FilterDropDown.tsx +++ b/frontend/src/components/Search/FilterDropDown.tsx @@ -193,7 +193,21 @@ export default function FilterDropDown({ filters, onChange, label, isMobile, onA amenitiesRow, } = useStyles(); - const color = open ? '#000' : '#898989'; + // Check if this filter has active selections + const hasActiveFilters = () => { + switch (label) { + case 'Location': + return filters.locations.length > 0; + case 'Price': + return filters.minPrice !== '' || filters.maxPrice !== ''; + case 'Beds & Baths': + return filters.bedrooms > 0 || filters.bathrooms > 0; + default: + return false; + } + }; + + const color = hasActiveFilters() ? '#B94630' : open ? '#000' : '#898989'; const handleClick = (event: React.MouseEvent) => { setOpen(!open); diff --git a/frontend/src/components/Search/FilterSection.tsx b/frontend/src/components/Search/FilterSection.tsx index 90919df7..078d9b79 100644 --- a/frontend/src/components/Search/FilterSection.tsx +++ b/frontend/src/components/Search/FilterSection.tsx @@ -258,6 +258,7 @@ export type FilterSectionProps = { open: boolean; handleSearch: () => void; isMobile: boolean | undefined; + isSearchResultsPage?: boolean; }; const LOCATIONS: LocationType[] = ['Collegetown', 'North', 'West', 'Downtown']; @@ -317,6 +318,7 @@ const FilterSection: React.FC = ({ open, handleSearch, isMobile, + isSearchResultsPage = false, }) => { const { filterContainer, @@ -338,6 +340,13 @@ const FilterSection: React.FC = ({ searchButton, } = useStyles(); + // Check if each section has selections (only for search results page) + const hasLocationSelection = isSearchResultsPage && filters.locations.length > 0; + const hasPriceSelection = + isSearchResultsPage && (filters.minPrice !== '' || filters.maxPrice !== ''); + const hasBedBathSelection = + isSearchResultsPage && (filters.bedrooms > 0 || filters.bathrooms > 0); + const handleLocationChange = (location: LocationType) => { const newLocations = filters.locations.includes(location) ? filters.locations.filter((loc: LocationType) => loc !== location) @@ -365,6 +374,7 @@ const FilterSection: React.FC = ({ className={sectionTitle} style={{ marginLeft: '10px', + color: hasLocationSelection ? '#B94630' : 'rgba(0, 0, 0, 0.50)', }} > Location @@ -391,7 +401,14 @@ const FilterSection: React.FC = ({
    - Price + + Price +
    = ({
    - Beds & Baths + + Beds & Baths +
    Bedrooms
    diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index cd548139..8a772110 100644 --- a/frontend/src/pages/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResultsPage.tsx @@ -28,16 +28,34 @@ const useStyles = makeStyles({ display: 'flex', flexDirection: 'row', gap: '12px', - height: '80vh', + height: 'calc(100vh - 200px)', + marginBottom: '40px', }, searchResultsContainer: { - flex: 1, + flex: 3, + overflowY: 'auto', + paddingRight: '8px', + '&::-webkit-scrollbar': { + width: '8px', + }, + '&::-webkit-scrollbar-track': { + background: '#f1f1f1', + borderRadius: '4px', + }, + '&::-webkit-scrollbar-thumb': { + background: '#888', + borderRadius: '4px', + '&:hover': { + background: '#555', + }, + }, }, mapContainer: { - flex: 1, + flex: 2, height: '100%', - width: '100%', - minHeight: '300px', + maxWidth: '650px', + position: 'sticky', + top: 0, }, sortDropDown: { display: 'flex', @@ -269,13 +287,33 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => {
    {/* Main Results */} - + {searchResults.length === 0 ? ( +
    + + No exact matching apartments found + + + Try adjusting your filters or check the additional results below for similar + apartments. + +
    + ) : ( + + )} {/* Additional Location Section */} {additionalLocation.length > 0 && ( @@ -366,13 +404,33 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => {
    {/* Main Results */} - + {searchResults.length === 0 ? ( +
    + + No exact matching apartments found + + + Try adjusting your filters or check the additional results below for similar + apartments. + +
    + ) : ( + + )} {/* Additional Location Section */} {additionalLocation.length > 0 && ( From c7b1f20900cddeac0a66f384b8c056e5210cdf20 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sat, 22 Nov 2025 10:32:37 -0500 Subject: [PATCH 18/32] Put folders in Bookmarks page --- backend/src/app.ts | 36 ++++ frontend/src/App.tsx | 14 +- .../components/Folder/AddToFolderModal.tsx | 99 +++++++++-- frontend/src/components/Folder/FolderCard.tsx | 5 +- .../Folder/FolderSection.tsx} | 24 ++- .../src/components/utils/NavBar/index.tsx | 21 +-- frontend/src/pages/ApartmentPage.tsx | 69 ++------ frontend/src/pages/BookmarksPage.tsx | 118 +------------ frontend/src/pages/FolderDetailPage.tsx | 156 ++++++++++++++---- 9 files changed, 277 insertions(+), 265 deletions(-) rename frontend/src/{pages/FolderPage.tsx => components/Folder/FolderSection.tsx} (93%) diff --git a/backend/src/app.ts b/backend/src/app.ts index d329d777..1c1418ee 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1798,6 +1798,42 @@ app.get('/api/folders', authenticate, async (req, res) => { } }); +/** Get Folder By ID - Fetches a specific folder by ID. + * + * @route GET /api/folders/:folderId + * + * @input {string} req.params.folderId - The ID of the folder to be fetched + * + * @status + * - 200: Successfully retrieved folder + * - 403: Unauthorized to access this folder (not the owner) + * - 404: Folder not found + * - 500: Error fetching folder + */ +app.get('/api/folders/:folderId', authenticate, async (req, res) => { + try { + if (!req.user) throw new Error('Not authenticated'); + const { uid } = req.user; + const { folderId } = req.params; + + const folderRef = folderCollection.doc(folderId); + const folderDoc = await folderRef.get(); + + if (!folderDoc.exists) { + return res.status(404).send('Folder not found'); + } + + if (folderDoc.data()?.userId !== uid) { + return res.status(403).send('Unauthorized to access this folder'); + } + + return res.status(200).json({ id: folderDoc.id, ...folderDoc.data() }); + } catch (err) { + console.error(err); + return res.status(500).send('Error fetching folder'); + } +}); + /** * Delete Folder - Deletes a folder by ID. * diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20a86906..c203967b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,6 @@ import FAQPage from './pages/FAQPage'; import ReviewPage from './pages/ReviewPage'; import LandlordPage from './pages/LandlordPage'; import ProfilePage from './pages/ProfilePage'; -import FolderPage from './pages/FolderPage'; import FolderDetailPage from './pages/FolderDetailPage'; import BookmarksPage from './pages/BookmarksPage'; import { ThemeProvider } from '@material-ui/core'; @@ -135,19 +134,14 @@ const App = (): ReactElement => { path="/profile" component={() => } /> + + + + } /> - } - /> - } - /> } diff --git a/frontend/src/components/Folder/AddToFolderModal.tsx b/frontend/src/components/Folder/AddToFolderModal.tsx index 510fbb0e..7e440327 100644 --- a/frontend/src/components/Folder/AddToFolderModal.tsx +++ b/frontend/src/components/Folder/AddToFolderModal.tsx @@ -93,26 +93,38 @@ const AddToFolderModal = ({ const classes = useStyles(); const [folders, setFolders] = useState([]); const [selectedFolders, setSelectedFolders] = useState>(new Set()); + const [initialFolders, setInitialFolders] = useState>(new Set()); const [loading, setLoading] = useState(false); const [showCreateNew, setShowCreateNew] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [error, setError] = useState(''); + // Reset state when modal opens useEffect(() => { if (open) { fetchFolders(); + setError(''); + setShowCreateNew(false); + setNewFolderName(''); } - }, [open]); + }, [open, apartmentId]); const fetchFolders = async () => { try { setLoading(true); - if (!user) { + let currentUser = user; + + if (!currentUser) { const loggedInUser = await getUser(true); setUser(loggedInUser); - if (!loggedInUser) return; + currentUser = loggedInUser; + if (!currentUser) { + setError('Please sign in to save apartments'); + return; + } } - const token = await user!.getIdToken(true); + + const token = await currentUser.getIdToken(true); const response = await axios.get('/api/folders', createAuthHeaders(token)); setFolders(response.data); @@ -124,6 +136,7 @@ const AddToFolderModal = ({ } }); setSelectedFolders(preSelected); + setInitialFolders(new Set(preSelected)); // Store initial state to detect changes } catch (err) { console.error('Error fetching folders:', err); setError('Failed to load folders'); @@ -149,43 +162,68 @@ const AddToFolderModal = ({ setError('Folder name cannot be empty'); return; } + try { - if (!user) { + setLoading(true); + let currentUser = user; + + if (!currentUser) { const loggedInUser = await getUser(true); setUser(loggedInUser); - if (!loggedInUser) return; + currentUser = loggedInUser; + if (!currentUser) { + setError('Please sign in to create folders'); + return; + } } - const token = await user!.getIdToken(true); + + const token = await currentUser.getIdToken(true); const response = await axios.post( '/api/folders', - { folderName: newFolderName }, + { folderName: newFolderName.trim() }, createAuthHeaders(token) ); + const newFolder = response.data; setFolders([...folders, newFolder]); setSelectedFolders((prev) => new Set(prev).add(newFolder.id)); setNewFolderName(''); setShowCreateNew(false); + setError(''); } catch (err) { console.error('Error creating folder:', err); setError('Failed to create folder'); + } finally { + setLoading(false); } }; const handleSave = async () => { try { setLoading(true); - if (!user) { + setError(''); + + let currentUser = user; + + if (!currentUser) { const loggedInUser = await getUser(true); setUser(loggedInUser); - if (!loggedInUser) return; + currentUser = loggedInUser; + if (!currentUser) { + setError('Please sign in to save apartments'); + return; + } } - const token = await user!.getIdToken(true); - // Determine which folders need to be added/removed + const token = await currentUser.getIdToken(true); + + // Determine which folders currently contain this apartment const currentFolders = folders.filter((f) => f.apartments?.includes(apartmentId)); const currentFolderIds = new Set(currentFolders.map((f) => f.id)); + // Track if any changes were made + let changesMade = false; + // Add apartment to newly selected folders for (const folderId of selectedFolders) { if (!currentFolderIds.has(folderId)) { @@ -194,6 +232,7 @@ const AddToFolderModal = ({ { aptId: apartmentId }, createAuthHeaders(token) ); + changesMade = true; } } @@ -204,19 +243,32 @@ const AddToFolderModal = ({ `/api/folders/${folder.id}/apartments/${apartmentId}`, createAuthHeaders(token) ); + changesMade = true; } } - onSuccess(); + // Only trigger success callback if changes were made + if (changesMade) { + onSuccess(); + } + onClose(); } catch (err) { console.error('Error updating folders:', err); - setError('Failed to update folders'); + setError('Failed to update folders. Please try again.'); } finally { setLoading(false); } }; + const hasChanges = () => { + if (selectedFolders.size !== initialFolders.size) return true; + for (const folderId of selectedFolders) { + if (!initialFolders.has(folderId)) return true; + } + return false; + }; + return ( Add "{apartmentName}" to Folder @@ -245,7 +297,9 @@ const AddToFolderModal = ({ /> ))} @@ -288,7 +342,14 @@ const AddToFolderModal = ({ - @@ -297,8 +358,10 @@ const AddToFolderModal = ({ - - + diff --git a/frontend/src/components/Folder/FolderCard.tsx b/frontend/src/components/Folder/FolderCard.tsx index 25abba6a..f14240a4 100644 --- a/frontend/src/components/Folder/FolderCard.tsx +++ b/frontend/src/components/Folder/FolderCard.tsx @@ -75,7 +75,7 @@ const useStyles = makeStyles((theme) => ({ /** * FolderCard Component * - * This component represents a folder in the user's folder list. + * A card component that displays folder information and provides options to rename or delete the folder. * * @param {Props} props - Component props * @param {Folder} props.folder - The folder data to display @@ -124,8 +124,7 @@ const FolderCard = ({ folder, onDelete, onRename }: Props): ReactElement => { }; const handleCardClick = () => { - // Navigate to folder detail page using React Router - history.push(`/folders/${folder.id}`); + history.push(`/bookmarks/${folder.id}`); }; const apartmentCount = folder.apartments?.length || 0; diff --git a/frontend/src/pages/FolderPage.tsx b/frontend/src/components/Folder/FolderSection.tsx similarity index 93% rename from frontend/src/pages/FolderPage.tsx rename to frontend/src/components/Folder/FolderSection.tsx index 6462fd6a..d3c1905a 100644 --- a/frontend/src/pages/FolderPage.tsx +++ b/frontend/src/components/Folder/FolderSection.tsx @@ -11,11 +11,11 @@ import { DialogContent, DialogActions, } from '@material-ui/core'; -import Toast from '../components/utils/Toast'; -import { colors } from '../colors'; -import FolderCard from '../components/Folder/FolderCard'; +import Toast from '../utils/Toast'; +import { colors } from '../../colors'; +import FolderCard from './FolderCard'; import axios from 'axios'; -import { createAuthHeaders, getUser } from '../utils/firebase'; +import { createAuthHeaders, getUser } from '../../utils/firebase'; type Props = { user: firebase.User | null; @@ -32,9 +32,6 @@ type Folder = { const useStyles = makeStyles((theme) => ({ background: { - backgroundColor: colors.gray3, - minHeight: '100vh', - width: '100%', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', @@ -64,17 +61,16 @@ const useStyles = makeStyles((theme) => ({ })); /** - * FolderPage Component + * FolderSection Component * - * This component represents a page for user folders. - * It displays all folders saved by a user, with the option to edit, rename, or delete existing folders. + * This component displays all folders saved by a user, with the option to edit, rename, or delete existing folders. * * @param {Props} props - Component props * @param {firebase.User | null} props.user - The current logged-in user * @param {function} props.setUser - Function to update the user state * @returns ReactElement: The folder page component. */ -const FolderPage = ({ user, setUser }: Props): ReactElement => { +const FolderSection = ({ user, setUser }: Props): ReactElement => { const toastTime = 3500; const { background, headerStyle, headerContainer, gridContainer, createButton } = useStyles(); @@ -207,10 +203,10 @@ const FolderPage = ({ user, setUser }: Props): ReactElement => { return (
    - + - My Folders + My Folders ({folders.length}) Organize your saved apartments into folders @@ -309,4 +305,4 @@ const FolderPage = ({ user, setUser }: Props): ReactElement => { ); }; -export default FolderPage; +export default FolderSection; diff --git a/frontend/src/components/utils/NavBar/index.tsx b/frontend/src/components/utils/NavBar/index.tsx index 5714b10b..770e5f30 100644 --- a/frontend/src/components/utils/NavBar/index.tsx +++ b/frontend/src/components/utils/NavBar/index.tsx @@ -29,7 +29,6 @@ import defaultProfilePic from '../../../assets/cuapts-bear.png'; import { ReactComponent as ProfileIcon } from '../../../assets/profile-icon.svg'; import { ReactComponent as BookmarkIcon } from '../../../assets/bookmark.svg'; import { ReactComponent as SignOutIcon } from '../../../assets/signout.svg'; -import { Folder } from '@material-ui/icons'; export type NavbarButton = { label: string; @@ -306,13 +305,9 @@ const NavBar = ({ headersData, user, setUser }: Props): ReactElement => { label: 'Bookmarks', href: `/bookmarks`, }; - const folders: NavbarButton = { - label: 'folders', - href: `/folders`, - }; //Sets headers data depending on whether menu drawer is open or not. If open, add profile and bookmarks page buttons. const displayHeadersData = - drawerOpen && user ? [...headersData, profile, bookmarks, folders] : headersData; + drawerOpen && user ? [...headersData, profile, bookmarks] : headersData; return ( @@ -353,13 +348,11 @@ const NavBar = ({ headersData, user, setUser }: Props): ReactElement => { }); /** This function navigates the user to the page depending on the dropdown button pressed */ - const dropDownButtonClick = async (button: 'profile' | 'bookmarks' | 'folders' | 'signOut') => { + const dropDownButtonClick = async (button: 'profile' | 'bookmarks' | 'signOut') => { if (button === 'profile') { history.push(`/profile`); } else if (button === 'bookmarks') { history.push(`/bookmarks`); - } else if (button === 'folders') { - history.push(`/folders`); } else { signOut(); history.push('/'); @@ -425,15 +418,7 @@ const NavBar = ({ headersData, user, setUser }: Props): ReactElement => { Bookmarks
  • -
  • - -
  • +
  • {' '} - + ); +}; const useStyles = makeStyles((theme) => ({ background: { @@ -82,6 +112,13 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const { folderId } = useParams<{ folderId: string }>(); const history = useHistory(); const toastTime = 3500; + const defaultShow = 6; + const [aptsToShow, setAptsToShow] = useState(defaultShow); + + const [isMobile, setIsMobile] = useState(false); + const [savedAptsData, setSavedAptsData] = useState([]); + + const [sortAptsBy, setSortAptsBy] = useState('numReviews'); const { background, headerStyle, headerContainer, backButton, gridContainer } = useStyles(); const [folder, setFolder] = useState(null); @@ -91,6 +128,19 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const [errorMessage, setErrorMessage] = useState(''); const [apartmentIdsToRemove, setApartmentIdsToRemove] = useState([]); const [showRemoveApartmentModal, setShowRemoveApartmentModal] = useState(false); + // handle toggle + const handleViewAll = () => { + setAptsToShow(aptsToShow + (savedAptsData.length - defaultShow)); + }; + const handleCollapse = () => { + setAptsToShow(defaultShow); + }; + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth <= 600); + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); const showToast = (setState: (value: React.SetStateAction) => void) => { setState(true); @@ -106,7 +156,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { useEffect(() => { fetchFolderDetails(); - }, [folderId]); + }, [folderId, user]); const fetchFolderDetails = async () => { try { @@ -116,25 +166,9 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { } const token = await user.getIdToken(true); - // Fetch folder info - const folderResponse = await axios.get('/api/folders', createAuthHeaders(token)); - const folders = folderResponse.data; - const currentFolder = folders.find((f: Folder) => f.id === folderId); - - if (!currentFolder) { - showError('Folder not found'); - history.push('/folders'); - return; - } - - setFolder(currentFolder); - - // Fetch apartments in folder - const apartmentsResponse = await axios.get( - `/api/folders/${folderId}/apartments`, - createAuthHeaders(token) - ); - setApartments(apartmentsResponse.data); + // Fetch specific folder by ID + const folderResponse = await axios.get(`/api/folders/${folderId}`, createAuthHeaders(token)); + setFolder(folderResponse.data); } catch (error) { console.error('Error fetching folder details:', error); showError('Failed to load folder details'); @@ -144,7 +178,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { }; const handleBackClick = () => { - history.push('/folders'); + history.push('/bookmarks'); }; const handleRemoveApartments = async (apartmentIds: string[]) => { @@ -259,21 +293,73 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { > Edit Folder + + { + setSortAptsBy('numReviews'); + }, + }, + { + item: 'Rating', + callback: () => { + setSortAptsBy('avgRating'); + }, + }, + ]} + isMobile={isMobile} + /> + - {apartments.length === 0 ? ( - - - No apartments in this folder - - - Add apartments to this folder from the apartment pages - - - ) : ( - - + {apartments.length > 0 ? ( + + {apartments && + sortApartments(apartments, sortAptsBy, false) + .slice(0, aptsToShow) + .map(({ buildingData, numReviews, company }, index) => { + const { id } = buildingData; + return ( + + + + + + ); + })} + + {savedAptsData.length > defaultShow && + (savedAptsData.length > aptsToShow ? ( + } + /> + ) : ( + } + /> + ))} + + ) : ( + You have not saved any apartments. )} {showErrorToast && ( From 51c4576e2180cfd738a9330404bedb9b98a3441c Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sat, 22 Nov 2025 10:59:25 -0500 Subject: [PATCH 19/32] Fix fetching apartments --- frontend/src/pages/FolderDetailPage.tsx | 36 ++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index 6621d6d5..e8a0d73e 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -166,7 +166,6 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { } const token = await user.getIdToken(true); - // Fetch specific folder by ID const folderResponse = await axios.get(`/api/folders/${folderId}`, createAuthHeaders(token)); setFolder(folderResponse.data); } catch (error) { @@ -188,11 +187,13 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { } const token = await user.getIdToken(true); - await apartmentIds.map((id) => - axios.delete(`/api/folders/${folderId}/apartments/${id}`, { - data: { apartmentIds }, - ...createAuthHeaders(token), - }) + await Promise.all( + apartmentIds.map((id) => + axios.delete(`/api/folders/${folderId}/apartments/${id}`, { + data: { apartmentIds }, + ...createAuthHeaders(token), + }) + ) ); // Refresh folder details @@ -204,6 +205,29 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { setShowRemoveApartmentModal(false); }; + const fetchApartmentsInFolder = async (folder: Folder) => { + try { + if (!user) { + throw new Error('Failed to login'); + } + + const token = await user.getIdToken(true); + const res = await axios.get(`/api/folders/${folder.id}/apartments`, createAuthHeaders(token)); + + setApartments(res.data); + setSavedAptsData(res.data); + } catch (error) { + console.error('Error fetching apartments in folder:', error); + showError('Failed to load apartments in folder'); + } + }; + + useEffect(() => { + if (folder) { + fetchApartmentsInFolder(folder); + } + }, [folder]); + if (loading) { return (
    From 6b373e25b5372829c25b9951f2477605c8dd9af7 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Tue, 25 Nov 2025 15:17:43 -0500 Subject: [PATCH 20/32] fix routing to saved apartments --- frontend/src/pages/FolderDetailPage.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index e8a0d73e..83b20e8b 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -348,20 +348,14 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const { id } = buildingData; return ( - +
    history.push(`/apartment/${id}`)}> - +
    ); })} From 4a7427337d2a61b6fdcd1e1da06179ad83fd8335 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Thu, 4 Dec 2025 21:23:04 -0500 Subject: [PATCH 21/32] Implement add to folder popover --- .../ApartmentCard/LargeApartmentCard.tsx | 50 ++- .../ApartmentCard/NewApartmentCard.tsx | 182 +++++---- .../components/Folder/AddToFolderModal.tsx | 372 ----------------- .../components/Folder/AddToFolderPopover.tsx | 379 ++++++++++++++++++ frontend/src/pages/ApartmentPage.tsx | 20 +- 5 files changed, 542 insertions(+), 461 deletions(-) delete mode 100644 frontend/src/components/Folder/AddToFolderModal.tsx create mode 100644 frontend/src/components/Folder/AddToFolderPopover.tsx diff --git a/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx b/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx index 8aa8f5b3..4deb8d54 100644 --- a/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx @@ -21,6 +21,7 @@ import { colors } from '../../colors'; import HeartRating from '../utils/HeartRating'; import ReviewHeader from '../Review/ReviewHeader'; import { RatingInfo } from '../../pages/ApartmentPage'; +import AddToFolderPopover from '../Folder/AddToFolderPopover'; type Props = { buildingData: ApartmentWithId; @@ -218,6 +219,7 @@ const NewApartmentCard = ({ const [isSaved, setIsSaved] = useState(false); const [isHovered, setIsHovered] = useState(false); const [savedIsHovered, setSavedIsHovered] = useState(false); + const [folderAnchorEl, setFolderAnchorEl] = useState(null); const { root, @@ -281,6 +283,25 @@ const NewApartmentCard = ({ throw new Error(newIsSaved ? 'Error with saving apartment' : 'Error with unsaving apartment'); } }; + function handleFolderSuccess(): void { + // Refresh the saved state after folder operations + const checkIfSaved = async () => { + try { + if (user) { + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/check-saved-apartment', + { apartmentId: id }, + createAuthHeaders(token) + ); + setIsSaved(response.data.result); + } + } catch (err) { + console.error('Error checking if apartment is saved'); + } + }; + checkIfSaved(); + } useEffect(() => { // Fetches approved reviews for the current apartment. @@ -322,9 +343,20 @@ const NewApartmentCard = ({ {name} setSavedIsHovered(true)} - onMouseLeave={() => setSavedIsHovered(false)} + onClick={(e) => { + handleSaveToggle(e); + e.stopPropagation(); + e.preventDefault(); + }} + onMouseEnter={(e) => { + e.stopPropagation(); + setSavedIsHovered(true); + setFolderAnchorEl(e.currentTarget); + }} + onMouseLeave={(e) => { + e.stopPropagation(); + setSavedIsHovered(false); + }} className={saveRibbonIcon} > {isSaved + { + setFolderAnchorEl(null); + setSavedIsHovered(false); + }} + apartmentId={buildingData.id} + apartmentName={buildingData.name} + user={user} + setUser={setUser} + onSuccess={handleFolderSuccess} + />
    {address}
    diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index a5510374..ed955e80 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -1,5 +1,4 @@ import React, { ReactElement, useState, useEffect } from 'react'; -import { get } from '../../utils/call'; import ApartmentImg from '../../assets/apartment-placeholder.svg'; import { Card, @@ -16,6 +15,7 @@ import moneyIcon from '../../assets/apartment-card-money-icon.svg'; import axios from 'axios'; import { createAuthHeaders, getUser } from '../../utils/firebase'; import { ApartmentWithId } from '../../../../common/types/db-types'; +import AddToFolderPopover from '../Folder/AddToFolderPopover'; import FavoriteIcon from '@material-ui/icons/Favorite'; import { colors } from '../../colors'; @@ -201,6 +201,7 @@ const NewApartmentCard = ({ const [isSaved, setIsSaved] = useState(false); const [isHovered, setIsHovered] = useState(false); const [savedIsHovered, setSavedIsHovered] = useState(false); + const [folderAnchorEl, setFolderAnchorEl] = useState(null); const { root, @@ -246,90 +247,117 @@ const NewApartmentCard = ({ checkIfSaved(); }, [user, setUser, id]); - const handleSaveToggle = async (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - const newIsSaved = !isSaved; - try { - if (!user) { - let user = await getUser(true); - setUser(user); - } - if (!user) { - throw new Error('Failed to login'); + function handleFolderSuccess(): void { + // Refresh the saved state after folder operations + const checkIfSaved = async () => { + try { + if (user) { + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/check-saved-apartment', + { apartmentId: id }, + createAuthHeaders(token) + ); + setIsSaved(response.data.result); + } + } catch (err) { + console.error('Error checking if apartment is saved'); } - const token = await user.getIdToken(true); - const endpoint = newIsSaved ? '/api/add-saved-apartment' : '/api/remove-saved-apartment'; - await axios.post(endpoint, { apartmentId: id }, createAuthHeaders(token)); - setIsSaved((prevIsSaved) => !prevIsSaved); - } catch (err) { - throw new Error(newIsSaved ? 'Error with saving apartment' : 'Error with unsaving apartment'); - } - }; + }; + checkIfSaved(); + } return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
    - - setSavedIsHovered(true)} - onMouseLeave={() => setSavedIsHovered(false)} - className={saveRibbonIcon} - > - {isSaved - - apartment - -
    -
    -
    - {/* {distanceToCampus} miles away */} - 19 ? '13px' : '14px' }} - > - {name.slice(0, 20) + (name.length > 20 ? '...' : '')} - - 19 ? '12.5px' : '14px' }} + <> + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
    + + {isHovered && ( + { + e.stopPropagation(); + e.preventDefault(); + }} + onMouseEnter={(e) => { + e.stopPropagation(); + setSavedIsHovered(true); + setFolderAnchorEl(e.currentTarget); + }} + onMouseLeave={(e) => { + e.stopPropagation(); + setSavedIsHovered(false); + }} + className={saveRibbonIcon} > - {address} - - - {numReviews} {numReviews === 1 ? 'review' : 'reviews'} - -
    -
    - - {avgRating.toFixed(1)} -
    -
    -
    -
    - money - $2K - $3K + Save + + )} + + apartment + { + setFolderAnchorEl(null); + setSavedIsHovered(false); + }} + apartmentId={buildingData.id} + apartmentName={buildingData.name} + user={user} + setUser={setUser} + onSuccess={handleFolderSuccess} + /> + +
    +
    +
    + {/* {distanceToCampus} miles away */} + 19 ? '13px' : '14px' }} + > + {name.slice(0, 20) + (name.length > 20 ? '...' : '')} + + 19 ? '12.5px' : '14px' }} + > + {address} + + + {numReviews} {numReviews === 1 ? 'review' : 'reviews'} + +
    +
    + + {avgRating.toFixed(1)} +
    -
    - bed - - {numBeds ? `${numBeds - 1}-${numBeds + 1}` : '0'} bed - +
    +
    + money + $2K - $3K +
    +
    + bed + + {numBeds ? `${numBeds - 1}-${numBeds + 1}` : '0'} bed + +
    -
    - + + ); }; diff --git a/frontend/src/components/Folder/AddToFolderModal.tsx b/frontend/src/components/Folder/AddToFolderModal.tsx deleted file mode 100644 index 7e440327..00000000 --- a/frontend/src/components/Folder/AddToFolderModal.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - List, - ListItem, - ListItemText, - Checkbox, - TextField, - Typography, - Box, - CircularProgress, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import AddIcon from '@material-ui/icons/Add'; -import axios from 'axios'; -import { getUser, createAuthHeaders } from '../../utils/firebase'; -import { colors } from '../../colors'; - -const useStyles = makeStyles({ - dialogContent: { - minHeight: '300px', - maxHeight: '400px', - }, - folderItem: { - border: `1px solid ${colors.gray3}`, - borderRadius: '8px', - marginBottom: '8px', - '&:hover': { - backgroundColor: colors.gray3, - }, - }, - createFolderSection: { - padding: '16px', - backgroundColor: colors.gray3, - borderRadius: '8px', - marginTop: '16px', - }, - addButton: { - backgroundColor: colors.red1, - color: 'white', - '&:hover': { - backgroundColor: colors.red2, - }, - }, -}); - -type Folder = { - id: string; - name: string; - userId: string; - createdAt: any; - apartments?: string[]; -}; - -type Props = { - open: boolean; - onClose: () => void; - apartmentId: string; - apartmentName: string; - user: firebase.User | null; - setUser: React.Dispatch>; - onSuccess: () => void; -}; - -/** - * AddToFolderModal - * Allows users to add an apartment to their folders or create new folders. - * - * @param {Props} props- Component props - * @param {boolean} props.open - Whether the modal is open - * @param {function} props.onClose - Function to call when closing the modal - * @param {string} props.apartmentId - ID of the apartment to add to folders - * @param {string} props.apartmentName - Name of the apartment to display - * @param {firebase.User | null} props.user - Current logged-in user - * @param {React.Dispatch>} props.setUser - Function to update the user state - * @param {function} props.onSuccess - Function to call on successful addition to folders - * @returns - */ - -const AddToFolderModal = ({ - open, - onClose, - apartmentId, - apartmentName, - user, - setUser, - onSuccess, -}: Props) => { - const classes = useStyles(); - const [folders, setFolders] = useState([]); - const [selectedFolders, setSelectedFolders] = useState>(new Set()); - const [initialFolders, setInitialFolders] = useState>(new Set()); - const [loading, setLoading] = useState(false); - const [showCreateNew, setShowCreateNew] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); - const [error, setError] = useState(''); - - // Reset state when modal opens - useEffect(() => { - if (open) { - fetchFolders(); - setError(''); - setShowCreateNew(false); - setNewFolderName(''); - } - }, [open, apartmentId]); - - const fetchFolders = async () => { - try { - setLoading(true); - let currentUser = user; - - if (!currentUser) { - const loggedInUser = await getUser(true); - setUser(loggedInUser); - currentUser = loggedInUser; - if (!currentUser) { - setError('Please sign in to save apartments'); - return; - } - } - - const token = await currentUser.getIdToken(true); - const response = await axios.get('/api/folders', createAuthHeaders(token)); - setFolders(response.data); - - // Pre-select folders that already contain this apartment - const preSelected = new Set(); - response.data.forEach((folder: Folder) => { - if (folder.apartments?.includes(apartmentId)) { - preSelected.add(folder.id); - } - }); - setSelectedFolders(preSelected); - setInitialFolders(new Set(preSelected)); // Store initial state to detect changes - } catch (err) { - console.error('Error fetching folders:', err); - setError('Failed to load folders'); - } finally { - setLoading(false); - } - }; - - const handleToggleFolder = (folderId: string) => { - setSelectedFolders((prev) => { - const newSet = new Set(prev); - if (newSet.has(folderId)) { - newSet.delete(folderId); - } else { - newSet.add(folderId); - } - return newSet; - }); - }; - - const handleCreateFolder = async () => { - if (!newFolderName.trim()) { - setError('Folder name cannot be empty'); - return; - } - - try { - setLoading(true); - let currentUser = user; - - if (!currentUser) { - const loggedInUser = await getUser(true); - setUser(loggedInUser); - currentUser = loggedInUser; - if (!currentUser) { - setError('Please sign in to create folders'); - return; - } - } - - const token = await currentUser.getIdToken(true); - const response = await axios.post( - '/api/folders', - { folderName: newFolderName.trim() }, - createAuthHeaders(token) - ); - - const newFolder = response.data; - setFolders([...folders, newFolder]); - setSelectedFolders((prev) => new Set(prev).add(newFolder.id)); - setNewFolderName(''); - setShowCreateNew(false); - setError(''); - } catch (err) { - console.error('Error creating folder:', err); - setError('Failed to create folder'); - } finally { - setLoading(false); - } - }; - - const handleSave = async () => { - try { - setLoading(true); - setError(''); - - let currentUser = user; - - if (!currentUser) { - const loggedInUser = await getUser(true); - setUser(loggedInUser); - currentUser = loggedInUser; - if (!currentUser) { - setError('Please sign in to save apartments'); - return; - } - } - - const token = await currentUser.getIdToken(true); - - // Determine which folders currently contain this apartment - const currentFolders = folders.filter((f) => f.apartments?.includes(apartmentId)); - const currentFolderIds = new Set(currentFolders.map((f) => f.id)); - - // Track if any changes were made - let changesMade = false; - - // Add apartment to newly selected folders - for (const folderId of selectedFolders) { - if (!currentFolderIds.has(folderId)) { - await axios.post( - `/api/folders/${folderId}/apartments`, - { aptId: apartmentId }, - createAuthHeaders(token) - ); - changesMade = true; - } - } - - // Remove apartment from deselected folders - for (const folder of currentFolders) { - if (!selectedFolders.has(folder.id)) { - await axios.delete( - `/api/folders/${folder.id}/apartments/${apartmentId}`, - createAuthHeaders(token) - ); - changesMade = true; - } - } - - // Only trigger success callback if changes were made - if (changesMade) { - onSuccess(); - } - - onClose(); - } catch (err) { - console.error('Error updating folders:', err); - setError('Failed to update folders. Please try again.'); - } finally { - setLoading(false); - } - }; - - const hasChanges = () => { - if (selectedFolders.size !== initialFolders.size) return true; - for (const folderId of selectedFolders) { - if (!initialFolders.has(folderId)) return true; - } - return false; - }; - - return ( - - Add "{apartmentName}" to Folder - - {loading && folders.length === 0 ? ( - - - - ) : folders.length === 0 ? ( - - No folders yet. Create one below! - - ) : ( - - {folders.map((folder) => ( - handleToggleFolder(folder.id)} - className={classes.folderItem} - > - handleToggleFolder(folder.id)} - color="primary" - /> - - - ))} - - )} - - {error && ( - - {error} - - )} - - - {!showCreateNew ? ( - - ) : ( - - setNewFolderName(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') { - handleCreateFolder(); - } - }} - variant="outlined" - size="small" - /> - - - - - - )} - - - - - - - - ); -}; - -export default AddToFolderModal; diff --git a/frontend/src/components/Folder/AddToFolderPopover.tsx b/frontend/src/components/Folder/AddToFolderPopover.tsx new file mode 100644 index 00000000..f6d3a60c --- /dev/null +++ b/frontend/src/components/Folder/AddToFolderPopover.tsx @@ -0,0 +1,379 @@ +import React, { useState, useEffect } from 'react'; +import { + Popover, + List, + Button, + TextField, + Box, + CircularProgress, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import axios from 'axios'; +import { getUser, createAuthHeaders } from '../../utils/firebase'; +import { colors } from '../../colors'; + +const useStyles = makeStyles({ + popoverPaper: { + width: 240, + borderRadius: 8, + padding: '16px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + }, + headerRow: { + marginBottom: 12, + }, + headerTitle: { + fontSize: 14, + fontWeight: 700, + color: '#333', + }, + listItem: { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '8px 0', + cursor: 'pointer', + borderBottom: `1px solid #f0f0f0`, + '&:last-child': { + borderBottom: 'none', + }, + }, + checkbox: { + width: 18, + height: 18, + borderRadius: 3, + border: '2px solid #999', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'all 0.2s', + }, + checkboxChecked: { + backgroundColor: colors.red1, + borderColor: colors.red1, + }, + checkmark: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, + folderName: { + fontSize: 14, + color: '#333', + flex: 1, + }, + createNewRow: { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '12px 0 4px', + cursor: 'pointer', + marginTop: 8, + '&:hover $addIcon': { + backgroundColor: '#e0e0e0', + }, + }, + addIcon: { + width: 18, + height: 18, + borderRadius: 3, + border: '2px solid #999', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 18, + color: '#999', + lineHeight: 1, + transition: 'background-color 0.2s', + }, + createFolderModal: { + padding: 0, + }, + footerError: { + padding: '8px 0 0', + color: colors.red1, + fontSize: 12, + }, + modalActions: { + marginTop: 12, + display: 'flex', + justifyContent: 'flex-end', + gap: 8, + }, + cancelButton: { + textTransform: 'none', + fontSize: 13, + color: '#666', + padding: '4px 12px', + }, + redButton: { + color: 'white', + backgroundColor: colors.red1, + borderRadius: 4, + textTransform: 'none', + padding: '6px 16px', + fontWeight: 600, + fontSize: 13, + '&:hover': { + backgroundColor: '#c41e3a', + }, + }, + inputField: { + '& .MuiOutlinedInput-root': { + borderRadius: 4, + fontSize: 14, + }, + }, +}); + +type Folder = { + id: string; + name: string; + apartments?: string[]; +}; + +type Props = { + anchorEl: HTMLElement | null; + onClose: () => void; + apartmentId: string; + apartmentName: string; + user: firebase.User | null; + setUser: React.Dispatch>; + onSuccess: () => void; +}; + +export default function AddToFolderPopover({ + anchorEl, + onClose, + apartmentId, + user, + setUser, + onSuccess, +}: Props) { + const classes = useStyles(); + const open = Boolean(anchorEl); + + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(false); + + const [error, setError] = useState(''); + const [createMode, setCreateMode] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + + // ------------------------------- + // FETCH FOLDERS + // ------------------------------- + useEffect(() => { + if (open) { + fetchFolders(); + setError(''); + setCreateMode(false); + setNewFolderName(''); + } + }, [open]); + + const fetchFolders = async () => { + try { + setLoading(true); + let u = user; + + if (!u) { + const logged = await getUser(true); + setUser(logged); + u = logged; + if (!u) { + setError('Please sign in to save apartments.'); + return; + } + } + + const token = await u.getIdToken(true); + const response = await axios.get('/api/folders', createAuthHeaders(token)); + setFolders(response.data); + } catch (err) { + setError('Failed to load folders.'); + } finally { + setLoading(false); + } + }; + + // ------------------------------- + // SAVE/UNSAVE HANDLER + // ------------------------------- + const handleSave = async (folderId: string) => { + try { + let u = user; + if (!u) { + const logged = await getUser(true); + setUser(logged); + u = logged; + if (!u) { + setError('Please sign in.'); + return; + } + } + + const token = await u.getIdToken(true); + const folder = folders.find((f) => f.id === folderId); + const alreadySaved = folder?.apartments?.includes(apartmentId); + + if (alreadySaved) { + await axios.delete( + `/api/folders/${folderId}/apartments/${apartmentId}`, + createAuthHeaders(token) + ); + } else { + await axios.post( + `/api/folders/${folderId}/apartments`, + { aptId: apartmentId }, + createAuthHeaders(token) + ); + } + + await fetchFolders(); + onSuccess(); + } catch (err) { + setError('Failed to update folder.'); + } + }; + + // ------------------------------- + // CREATE NEW FOLDER + // ------------------------------- + const handleCreateFolder = async () => { + if (!newFolderName.trim()) { + setError('Folder name cannot be empty.'); + return; + } + + try { + let u = user; + if (!u) { + const logged = await getUser(true); + setUser(logged); + u = logged; + if (!u) { + setError('Please sign in.'); + return; + } + } + + const token = await u.getIdToken(true); + const response = await axios.post( + '/api/folders', + { folderName: newFolderName.trim() }, + createAuthHeaders(token) + ); + + const newFolder = response.data; + + // automatically add apartment + await axios.post( + `/api/folders/${newFolder.id}/apartments`, + { aptId: apartmentId }, + createAuthHeaders(token) + ); + + await fetchFolders(); + onSuccess(); + + setCreateMode(false); + setNewFolderName(''); + setError(''); + } catch (err) { + setError('Failed to create folder.'); + } + }; + + return ( + e.stopPropagation(), + }} + > + {/* HEADER */} + + Save to Folder + + + {/* CREATE MODE */} + {createMode ? ( + + setNewFolderName(e.target.value)} + autoFocus + className={classes.inputField} + /> + +
    + + +
    + + {error &&
    {error}
    } +
    + ) : ( + <> + {/* LOADING */} + {loading ? ( + + + + ) : ( + + {/* LIST OF FOLDERS */} + + {folders.map((folder) => { + const saved = folder.apartments?.includes(apartmentId); + + return ( +
    handleSave(folder.id)} + > +
    + {saved && } +
    + {folder.name} +
    + ); + })} + + {/* + CREATE NEW */} +
    setCreateMode(true)}> +
    +
    + Create New Folder +
    +
    +
    + )} + + {error &&
    {error}
    } + + )} +
    + ); +} diff --git a/frontend/src/pages/ApartmentPage.tsx b/frontend/src/pages/ApartmentPage.tsx index 36833990..44ec0d24 100644 --- a/frontend/src/pages/ApartmentPage.tsx +++ b/frontend/src/pages/ApartmentPage.tsx @@ -45,7 +45,7 @@ import savedIcon from '../assets/saved-icon-filled.svg'; import unsavedIcon from '../assets/saved-icon-unfilled.svg'; import MapModal from '../components/Apartment/MapModal'; import DropDownWithLabel from '../components/utils/DropDownWithLabel'; -import AddToFolderModal from '../components/Folder/AddToFolderModal'; +import AddToFolderPopover from '../components/Folder/AddToFolderPopover'; import { set } from 'date-fns'; type Props = { @@ -159,7 +159,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { const saved = savedIcon; const unsaved = unsavedIcon; const [isSaved, setIsSaved] = useState(false); - const [showFolderModal, setShowFolderModal] = useState(false); + const [folderAnchorEl, setFolderAnchorEl] = useState(null); const [showAddToFolderSuccess, setShowAddToFolderSuccess] = useState(false); const [mapToggle, setMapToggle] = useState(false); @@ -465,14 +465,16 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { const Modals = landlordData && apt && ( <> - setShowFolderModal(false)} - apartmentId={aptId} + setFolderAnchorEl(null)} + apartmentId={apt.id} apartmentName={apt.name} user={user} setUser={setUser} - onSuccess={showAddToFolderSuccessToast} + onSuccess={() => { + setIsSaved(true); + }} /> { - {loading ? ( Loading folders... ) : folders.length === 0 ? ( @@ -233,16 +239,22 @@ const FolderSection = ({ user, setUser }: Props): ReactElement => { ) : ( - + {folders.map((folder) => ( ))} + + setShowCreateDialog(true)}> + + + )} diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index 83b20e8b..fc0d2acc 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -95,6 +95,12 @@ const useStyles = makeStyles((theme) => ({ alignItems: 'flex-start', marginBottom: '3em', }, + editButton: { + backgroundColor: colors.gray5, + border: `0px solid ${colors.gray4}`, + padding: '10px 19px', + borderRadius: 7, + }, })); /** @@ -109,6 +115,7 @@ const useStyles = makeStyles((theme) => ({ * @returns ReactElement: The folder detail page component. */ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { + const classes = useStyles(); const { folderId } = useParams<{ folderId: string }>(); const history = useHistory(); const toastTime = 3500; @@ -116,7 +123,6 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const [aptsToShow, setAptsToShow] = useState(defaultShow); const [isMobile, setIsMobile] = useState(false); - const [savedAptsData, setSavedAptsData] = useState([]); const [sortAptsBy, setSortAptsBy] = useState('numReviews'); const { background, headerStyle, headerContainer, backButton, gridContainer } = useStyles(); @@ -130,7 +136,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const [showRemoveApartmentModal, setShowRemoveApartmentModal] = useState(false); // handle toggle const handleViewAll = () => { - setAptsToShow(aptsToShow + (savedAptsData.length - defaultShow)); + setAptsToShow(aptsToShow + (apartments.length - defaultShow)); }; const handleCollapse = () => { setAptsToShow(defaultShow); @@ -215,7 +221,6 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { const res = await axios.get(`/api/folders/${folder.id}/apartments`, createAuthHeaders(token)); setApartments(res.data); - setSavedAptsData(res.data); } catch (error) { console.error('Error fetching apartments in folder:', error); showError('Failed to load apartments in folder'); @@ -310,14 +315,8 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { {apartments.length} {apartments.length === 1 ? 'apartment' : 'apartments'}
    - - + + { isMobile={isMobile} /> + {apartments.length > 0 ? ( @@ -360,8 +362,8 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { ); })} - {savedAptsData.length > defaultShow && - (savedAptsData.length > aptsToShow ? ( + {apartments.length > defaultShow && + (apartments.length > aptsToShow ? ( Date: Fri, 5 Dec 2025 15:24:20 -0500 Subject: [PATCH 23/32] Update popover styling --- .../ApartmentCard/NewApartmentCard.tsx | 2 +- .../components/Folder/AddToFolderPopover.tsx | 263 +++++++++++------- frontend/src/pages/ApartmentPage.tsx | 1 - 3 files changed, 170 insertions(+), 96 deletions(-) diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index ed955e80..9f4a80c0 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -296,7 +296,7 @@ const NewApartmentCard = ({ className={saveRibbonIcon} > Save diff --git a/frontend/src/components/Folder/AddToFolderPopover.tsx b/frontend/src/components/Folder/AddToFolderPopover.tsx index f6d3a60c..9b930403 100644 --- a/frontend/src/components/Folder/AddToFolderPopover.tsx +++ b/frontend/src/components/Folder/AddToFolderPopover.tsx @@ -7,125 +7,190 @@ import { Box, CircularProgress, Typography, + IconButton, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import axios from 'axios'; import { getUser, createAuthHeaders } from '../../utils/firebase'; import { colors } from '../../colors'; +import CloseIcon from '@material-ui/icons/Close'; +import AddIcon from '@material-ui/icons/Add'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import { FolderRounded } from '@material-ui/icons'; const useStyles = makeStyles({ popoverPaper: { - width: 240, + width: 300, borderRadius: 8, - padding: '16px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + padding: '20px', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.45)', + zIndex: 1300, }, - headerRow: { - marginBottom: 12, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 16, }, headerTitle: { - fontSize: 14, + fontSize: 16, fontWeight: 700, - color: '#333', + color: '#1a1a1a', + }, + closeButton: { + padding: 4, + marginRight: -8, }, listItem: { display: 'flex', alignItems: 'center', - gap: 10, - padding: '8px 0', - cursor: 'pointer', - borderBottom: `1px solid #f0f0f0`, - '&:last-child': { - borderBottom: 'none', + gap: 12, + padding: '12px 0', + borderBottom: '1px solid #f0f0f0', + transition: 'background-color 0.2s', + borderRadius: 4, + marginBottom: 4, + '&:hover': { + backgroundColor: '#f9f9f9', + }, + '&:hover $saveButton': { + opacity: 1, }, }, - checkbox: { - width: 18, - height: 18, - borderRadius: 3, - border: '2px solid #999', + folderIcon: { + width: 30, + height: 30, + borderRadius: 4, + color: colors.red1, + flexShrink: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: 'all 0.2s', }, - checkboxChecked: { - backgroundColor: colors.red1, - borderColor: colors.red1, - }, - checkmark: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', + folderInfo: { + flex: 1, + minWidth: 0, }, folderName: { fontSize: 14, - color: '#333', - flex: 1, + fontWeight: 500, + color: '#1a1a1a', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + saveButton: { + minWidth: 70, + height: 32, + borderRadius: 16, + textTransform: 'none', + fontSize: 13, + fontWeight: 600, + flexShrink: 0, + transition: 'all 0.2s', + opacity: 0, + }, + saveButtonUnsaved: { + border: `1.5px solid ${colors.red1}`, + color: colors.red1, + backgroundColor: 'white', + '&:hover': { + backgroundColor: '#fff5f5', + }, + }, + saveButtonSaved: { + backgroundColor: colors.red1, + color: 'white', + opacity: 1, + '&:hover': { + backgroundColor: '#c41e3a', + }, }, createNewRow: { display: 'flex', alignItems: 'center', - gap: 10, - padding: '12px 0 4px', + gap: 12, + padding: '12px 0', cursor: 'pointer', marginTop: 8, - '&:hover $addIcon': { - backgroundColor: '#e0e0e0', + transition: 'background-color 0.2s', + borderRadius: 4, + '&:hover': { + backgroundColor: '#f9f9f9', }, }, - addIcon: { - width: 18, - height: 18, - borderRadius: 3, - border: '2px solid #999', - flexShrink: 0, + addIconWrapper: { + width: 40, + height: 40, + borderRadius: 4, + backgroundColor: '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: 18, - color: '#999', - lineHeight: 1, - transition: 'background-color 0.2s', + flexShrink: 0, }, createFolderModal: { padding: 0, }, - footerError: { - padding: '8px 0 0', - color: colors.red1, - fontSize: 12, + modalHeader: { + display: 'flex', + alignItems: 'center', + marginBottom: 16, + }, + backButton: { + padding: 4, + marginLeft: -8, + marginRight: 8, + }, + modalTitle: { + fontSize: 16, + fontWeight: 700, + color: '#1a1a1a', + }, + inputLabel: { + fontSize: 13, + fontWeight: 600, + color: '#333', + marginBottom: 8, + }, + inputField: { + '& .MuiOutlinedInput-root': { + borderRadius: 4, + fontSize: 14, + }, }, modalActions: { - marginTop: 12, + marginTop: 16, display: 'flex', justifyContent: 'flex-end', - gap: 8, + gap: 12, }, cancelButton: { textTransform: 'none', - fontSize: 13, + fontSize: 14, + fontWeight: 600, color: '#666', - padding: '4px 12px', + padding: '8px 20px', + borderRadius: 20, + border: '1.5px solid #ddd', + '&:hover': { + backgroundColor: '#f5f5f5', + }, }, - redButton: { + createButton: { color: 'white', backgroundColor: colors.red1, - borderRadius: 4, + borderRadius: 20, textTransform: 'none', - padding: '6px 16px', + padding: '8px 24px', fontWeight: 600, - fontSize: 13, + fontSize: 14, '&:hover': { backgroundColor: '#c41e3a', }, }, - inputField: { - '& .MuiOutlinedInput-root': { - borderRadius: 4, - fontSize: 14, - }, + footerError: { + padding: '8px 0 0', + color: colors.red1, + fontSize: 12, }, }); @@ -163,9 +228,6 @@ export default function AddToFolderPopover({ const [createMode, setCreateMode] = useState(false); const [newFolderName, setNewFolderName] = useState(''); - // ------------------------------- - // FETCH FOLDERS - // ------------------------------- useEffect(() => { if (open) { fetchFolders(); @@ -200,9 +262,6 @@ export default function AddToFolderPopover({ } }; - // ------------------------------- - // SAVE/UNSAVE HANDLER - // ------------------------------- const handleSave = async (folderId: string) => { try { let u = user; @@ -240,9 +299,6 @@ export default function AddToFolderPopover({ } }; - // ------------------------------- - // CREATE NEW FOLDER - // ------------------------------- const handleCreateFolder = async () => { if (!newFolderName.trim()) { setError('Folder name cannot be empty.'); @@ -302,14 +358,23 @@ export default function AddToFolderPopover({ onClick: (e) => e.stopPropagation(), }} > - {/* HEADER */} - - Save to Folder - - - {/* CREATE MODE */} {createMode ? ( + + setCreateMode(false)} + size="small" + > + + + Create Folder + + + + Name + + setCreateMode(false)}> Cancel -
    @@ -334,38 +399,48 @@ export default function AddToFolderPopover({ ) : ( <> - {/* LOADING */} + + Save to Folder + + + + + {loading ? ( ) : ( - {/* LIST OF FOLDERS */} {folders.map((folder) => { const saved = folder.apartments?.includes(apartmentId); return ( -
    handleSave(folder.id)} - > -
    - {saved && } +
    + +
    +
    {folder.name}
    - {folder.name} +
    ); })} - {/* + CREATE NEW */}
    setCreateMode(true)}> -
    +
    - Create New Folder +
    + +
    +
    +
    Create New Folder
    +
    diff --git a/frontend/src/pages/ApartmentPage.tsx b/frontend/src/pages/ApartmentPage.tsx index 44ec0d24..56b7d34e 100644 --- a/frontend/src/pages/ApartmentPage.tsx +++ b/frontend/src/pages/ApartmentPage.tsx @@ -46,7 +46,6 @@ import unsavedIcon from '../assets/saved-icon-unfilled.svg'; import MapModal from '../components/Apartment/MapModal'; import DropDownWithLabel from '../components/utils/DropDownWithLabel'; import AddToFolderPopover from '../components/Folder/AddToFolderPopover'; -import { set } from 'date-fns'; type Props = { user: firebase.User | null; From b83326dd52ed091081399c9b8f9f1b1ccbeafe4f Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sun, 7 Dec 2025 20:23:45 -0500 Subject: [PATCH 24/32] Fix repeated re-rendering --- backend/src/app.ts | 1 - .../ApartmentCard/LargeApartmentCard.tsx | 46 +++++++++---------- .../ApartmentCard/NewApartmentCard.tsx | 42 ++++++++--------- .../components/Folder/AddToFolderPopover.tsx | 10 ++-- frontend/src/components/Folder/FolderCard.tsx | 9 ++-- .../src/components/Folder/FolderSection.tsx | 19 ++++---- frontend/src/pages/FolderDetailPage.tsx | 17 ++++--- 7 files changed, 76 insertions(+), 68 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 1c1418ee..3aa3dec3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2044,7 +2044,6 @@ app.get('/api/folders/:folderId/apartments', authenticate, async (req, res) => { // Filter out any null values from non-existent apartments const validApartments = aptsArr.filter((apt) => apt !== null); const enrichedResults = await pageData(validApartments); - console.log('Enriched Results:', enrichedResults); return res.status(200).json(enrichedResults); } catch (err) { console.error(err); diff --git a/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx b/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx index 4deb8d54..e4eab144 100644 --- a/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx @@ -242,26 +242,26 @@ const NewApartmentCard = ({ sampleReviewText, } = useStyles(); - useEffect(() => { - const checkIfSaved = async () => { - try { - if (user) { - const token = await user.getIdToken(true); - const response = await axios.post( - '/api/check-saved-apartment', - { apartmentId: id }, - createAuthHeaders(token) - ); - setIsSaved(response.data.result); - } else { - setIsSaved(false); - } - } catch (err) { - throw new Error('Error with checking if apartment is saved'); - } - }; - checkIfSaved(); - }, [user, setUser, id]); + // useEffect(() => { + // const checkIfSaved = async () => { + // try { + // if (user) { + // const token = await user.getIdToken(false); + // const response = await axios.post( + // '/api/check-saved-apartment', + // { apartmentId: id }, + // createAuthHeaders(token) + // ); + // setIsSaved(response.data.result); + // } else { + // setIsSaved(false); + // } + // } catch (err) { + // throw new Error('Error with checking if apartment is saved'); + // } + // }; + // checkIfSaved(); + // }, [user, setUser, id]); const handleSaveToggle = async (event: React.MouseEvent) => { event.stopPropagation(); @@ -269,13 +269,13 @@ const NewApartmentCard = ({ const newIsSaved = !isSaved; try { if (!user) { - let user = await getUser(true); + let user = await getUser(false); setUser(user); } if (!user) { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const endpoint = newIsSaved ? '/api/add-saved-apartment' : '/api/remove-saved-apartment'; await axios.post(endpoint, { apartmentId: id }, createAuthHeaders(token)); setIsSaved((prevIsSaved) => !prevIsSaved); @@ -288,7 +288,7 @@ const NewApartmentCard = ({ const checkIfSaved = async () => { try { if (user) { - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const response = await axios.post( '/api/check-saved-apartment', { apartmentId: id }, diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index 9f4a80c0..110ef3af 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -226,33 +226,33 @@ const NewApartmentCard = ({ apartmentStatsText, } = useStyles(); - useEffect(() => { - const checkIfSaved = async () => { - try { - if (user) { - const token = await user.getIdToken(true); - const response = await axios.post( - '/api/check-saved-apartment', - { apartmentId: id }, - createAuthHeaders(token) - ); - setIsSaved(response.data.result); - } else { - setIsSaved(false); - } - } catch (err) { - throw new Error('Error with checking if apartment is saved'); - } - }; - checkIfSaved(); - }, [user, setUser, id]); + // useEffect(() => { + // const checkIfSaved = async () => { + // try { + // if (user) { + // const token = await user.getIdToken(false); + // const response = await axios.post( + // '/api/check-saved-apartment', + // { apartmentId: id }, + // createAuthHeaders(token) + // ); + // setIsSaved(response.data.result); + // } else { + // setIsSaved(false); + // } + // } catch (err) { + // throw new Error('Error with checking if apartment is saved'); + // } + // }; + // checkIfSaved(); + // }, [user, setUser, id]); function handleFolderSuccess(): void { // Refresh the saved state after folder operations const checkIfSaved = async () => { try { if (user) { - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const response = await axios.post( '/api/check-saved-apartment', { apartmentId: id }, diff --git a/frontend/src/components/Folder/AddToFolderPopover.tsx b/frontend/src/components/Folder/AddToFolderPopover.tsx index 9b930403..74548fd8 100644 --- a/frontend/src/components/Folder/AddToFolderPopover.tsx +++ b/frontend/src/components/Folder/AddToFolderPopover.tsx @@ -243,7 +243,7 @@ export default function AddToFolderPopover({ let u = user; if (!u) { - const logged = await getUser(true); + const logged = await getUser(false); setUser(logged); u = logged; if (!u) { @@ -252,7 +252,7 @@ export default function AddToFolderPopover({ } } - const token = await u.getIdToken(true); + const token = await u.getIdToken(false); const response = await axios.get('/api/folders', createAuthHeaders(token)); setFolders(response.data); } catch (err) { @@ -266,7 +266,7 @@ export default function AddToFolderPopover({ try { let u = user; if (!u) { - const logged = await getUser(true); + const logged = await getUser(false); setUser(logged); u = logged; if (!u) { @@ -275,7 +275,7 @@ export default function AddToFolderPopover({ } } - const token = await u.getIdToken(true); + const token = await u.getIdToken(false); const folder = folders.find((f) => f.id === folderId); const alreadySaved = folder?.apartments?.includes(apartmentId); @@ -317,7 +317,7 @@ export default function AddToFolderPopover({ } } - const token = await u.getIdToken(true); + const token = await u.getIdToken(false); const response = await axios.post( '/api/folders', { folderName: newFolderName.trim() }, diff --git a/frontend/src/components/Folder/FolderCard.tsx b/frontend/src/components/Folder/FolderCard.tsx index 4b9f6b76..581c3134 100644 --- a/frontend/src/components/Folder/FolderCard.tsx +++ b/frontend/src/components/Folder/FolderCard.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import apartmentDefaultImage from '../../assets/apartment-placeholder.svg'; import { Card, @@ -162,14 +162,17 @@ const FolderCard = ({ folder, onDelete, onRename, user }: Props): ReactElement = const fetchApartmentInformation = async () => { if (!user) return; - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const res = await axios.get(`/api/folders/${folder.id}/apartments`, createAuthHeaders(token)); setSavedAptsData(res.data); }; + useEffect(() => { + fetchApartmentInformation(); + }, []); + const displayFolderThumbnail = () => { try { - fetchApartmentInformation(); if (savedAptsData && savedAptsData.length > 0) { const numPlaceholders = savedAptsData.length < 4 ? 4 - savedAptsData.length : 0; return ( diff --git a/frontend/src/components/Folder/FolderSection.tsx b/frontend/src/components/Folder/FolderSection.tsx index 6e9df948..2dcf8127 100644 --- a/frontend/src/components/Folder/FolderSection.tsx +++ b/frontend/src/components/Folder/FolderSection.tsx @@ -52,6 +52,7 @@ const useStyles = makeStyles((theme) => ({ marginBottom: '2em', }, createButton: { + marginTop: '1em', backgroundColor: colors.white, color: colors.gray1, borderRadius: 4, @@ -134,13 +135,13 @@ const FolderSection = ({ user, setUser }: Props): ReactElement => { try { setLoading(true); if (!user) { - let user = await getUser(true); + let user = await getUser(false); setUser(user); } if (!user) { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const response = await axios.get('/api/folders', createAuthHeaders(token)); setFolders(response.data); } catch (error) { @@ -158,13 +159,13 @@ const FolderSection = ({ user, setUser }: Props): ReactElement => { } try { if (!user) { - let user = await getUser(true); + let user = await getUser(false); setUser(user); } if (!user) { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const response = await axios.post( '/api/folders', { folderName: newFolderName }, @@ -183,32 +184,32 @@ const FolderSection = ({ user, setUser }: Props): ReactElement => { const handleDeleteFolder = async (folderId: string) => { try { if (!user) { - let user = await getUser(true); + let user = await getUser(false); setUser(user); } if (!user) { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); await axios.delete(`/api/folders/${folderId}`, createAuthHeaders(token)); setFolders(folders.filter((f) => f.id !== folderId)); showDeleteSuccessConfirmationToast(); } catch (error) { console.error('Error deleting folder:', error); - showError('Failed to delete folder'); + showError('Failed to delete folder: ' + (error as Error).message); } }; const handleRenameFolder = async (folderId: string, newName: string) => { try { if (!user) { - let user = await getUser(true); + let user = await getUser(false); setUser(user); } if (!user) { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); await axios.put(`/api/folders/${folderId}`, { newName }, createAuthHeaders(token)); setFolders(folders.map((f) => (f.id === folderId ? { ...f, name: newName } : f))); showRenameSuccessConfirmationToast(); diff --git a/frontend/src/pages/FolderDetailPage.tsx b/frontend/src/pages/FolderDetailPage.tsx index fc0d2acc..65d59f89 100644 --- a/frontend/src/pages/FolderDetailPage.tsx +++ b/frontend/src/pages/FolderDetailPage.tsx @@ -162,7 +162,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { useEffect(() => { fetchFolderDetails(); - }, [folderId, user]); + }, [folderId]); const fetchFolderDetails = async () => { try { @@ -170,10 +170,15 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { if (!user) { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const folderResponse = await axios.get(`/api/folders/${folderId}`, createAuthHeaders(token)); - setFolder(folderResponse.data); + setFolder((prev) => { + if (JSON.stringify(prev) === JSON.stringify(folderResponse.data)) { + return prev; // No change, avoid re-render + } + return folderResponse.data; + }); } catch (error) { console.error('Error fetching folder details:', error); showError('Failed to load folder details'); @@ -191,7 +196,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { if (!user) { throw new Error('User not authenticated'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); await Promise.all( apartmentIds.map((id) => @@ -217,7 +222,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { throw new Error('Failed to login'); } - const token = await user.getIdToken(true); + const token = await user.getIdToken(false); const res = await axios.get(`/api/folders/${folder.id}/apartments`, createAuthHeaders(token)); setApartments(res.data); @@ -231,7 +236,7 @@ const FolderDetailPage = ({ user, setUser }: Props): ReactElement => { if (folder) { fetchApartmentsInFolder(folder); } - }, [folder]); + }, [folder?.id]); if (loading) { return ( From 5fa50b176ec645f5d4427b9c859b426736f35530 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sun, 7 Dec 2025 20:58:25 -0500 Subject: [PATCH 25/32] Finalize FolderSection frontend --- .../ApartmentCard/LargeApartmentCard.tsx | 2 - .../ApartmentCard/NewApartmentCard.tsx | 2 +- frontend/src/components/Folder/FolderCard.tsx | 197 +++++++----------- .../src/components/Folder/FolderSection.tsx | 117 ++++++----- 4 files changed, 137 insertions(+), 181 deletions(-) diff --git a/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx b/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx index e4eab144..fc571d2f 100644 --- a/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/LargeApartmentCard.tsx @@ -12,8 +12,6 @@ import { } from '@material-ui/core'; import savedIcon from '../../assets/apartment-card-saved-icon-filled.svg'; import unsavedIcon from '../../assets/apartment-card-saved-icon-unfilled.svg'; -import bedIcon from '../../assets/apartment-card-bedroom-icon.svg'; -import moneyIcon from '../../assets/apartment-card-money-icon.svg'; import axios from 'axios'; import { createAuthHeaders, getUser } from '../../utils/firebase'; import { ApartmentWithId, DetailedRating, ReviewWithId } from '../../../../common/types/db-types'; diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index 110ef3af..3feaa0ca 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useState, useEffect } from 'react'; +import React, { ReactElement, useState } from 'react'; import ApartmentImg from '../../assets/apartment-placeholder.svg'; import { Card, diff --git a/frontend/src/components/Folder/FolderCard.tsx b/frontend/src/components/Folder/FolderCard.tsx index 581c3134..5db6c0a0 100644 --- a/frontend/src/components/Folder/FolderCard.tsx +++ b/frontend/src/components/Folder/FolderCard.tsx @@ -1,9 +1,6 @@ import React, { ReactElement, useEffect, useState } from 'react'; import apartmentDefaultImage from '../../assets/apartment-placeholder.svg'; import { - Card, - CardContent, - CardActions, Typography, IconButton, Menu, @@ -15,9 +12,8 @@ import { TextField, Button, makeStyles, - Box, } from '@material-ui/core'; -import { MoreVert as MoreVertIcon, Folder as FolderIcon } from '@material-ui/icons'; +import { MoreVert as MoreVertIcon } from '@material-ui/icons'; import { useHistory } from 'react-router-dom'; import { colors } from '../../colors'; import axios from 'axios'; @@ -40,65 +36,57 @@ type Props = { }; const useStyles = makeStyles((theme) => ({ - card: { - height: '300px', - width: '300px', + folderContainer: { display: 'flex', flexDirection: 'column', - cursor: 'pointer', - transition: 'transform 0.2s, box-shadow 0.2s', - '&:hover': { - transform: 'translateY(-4px)', - boxShadow: '0 4px 20px rgba(0,0,0,0.1)', - }, + width: '100%', + marginBottom: '1.5em', }, - cardContent: { - flexGrow: 1, + folderHeader: { display: 'flex', alignItems: 'center', - gap: '1em', - }, - folderIcon: { - fontSize: '3em', - color: colors.red1, - }, - folderInfo: { - flex: 1, - width: '300px', + justifyContent: 'space-between', + marginBottom: '0.5em', + paddingLeft: '4px', }, folderName: { fontWeight: 600, - marginBottom: '0.1em', + fontSize: '1rem', + color: '#000', }, apartmentCount: { - color: colors.gray2, - fontSize: '0.9em', - marginBottom: '0.5em', - }, - cardActions: { - justifyContent: 'flex-end', - padding: '8px 16px', - }, - apartmentThumbnail: { - width: '47%', - height: '47%', - borderRadius: 4, - margin: '0.2em', + color: '#666', + fontSize: '0.875rem', + marginTop: '0.25em', }, - apartmentThumbnails: { - display: 'flex', - flexWrap: 'wrap', - width: '300px', - height: '300px', - overflow: 'hidden', - alignContent: 'center', - justifyContent: 'center', + thumbnailGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '8px', + width: '100%', + aspectRatio: '1', cursor: 'pointer', - transition: 'transform 0.2s', + borderRadius: '8px', + overflow: 'hidden', + backgroundColor: '#f5f5f5', + transition: 'transform 0.2s ease, box-shadow 0.2s ease', + '&:hover': { transform: 'translateY(-4px)', + boxShadow: '0 4px 20px rgba(0,0,0,0.1)', }, }, + apartmentThumbnail: { + width: '100%', + height: '100%', + objectFit: 'cover', + }, + placeholderThumbnail: { + width: '100%', + height: '100%', + backgroundColor: '#fff', + borderRadius: '8px', + }, })); /** @@ -172,93 +160,52 @@ const FolderCard = ({ folder, onDelete, onRename, user }: Props): ReactElement = }, []); const displayFolderThumbnail = () => { - try { - if (savedAptsData && savedAptsData.length > 0) { - const numPlaceholders = savedAptsData.length < 4 ? 4 - savedAptsData.length : 0; - return ( -
    - {savedAptsData.slice(0, 4).map((apartment) => ( - 0 - ? apartment.buildingData.photos[0] - : apartmentDefaultImage - } - alt="Apartment Thumbnail" - style={{ width: '48%', height: '48%', objectFit: 'cover', borderRadius: '4px' }} - /> - ))} - {Array.from({ length: numPlaceholders }).map((_, idx) => ( -
    - ))} -
    - ); - } - } catch (error) { - return ( - <> - Apartment Thumbnail - Apartment Thumbnail + const thumbnails = []; + const maxThumbnails = 4; + + if (savedAptsData && savedAptsData.length > 0) { + // Add actual apartment images + for (let i = 0; i < Math.min(savedAptsData.length, maxThumbnails); i++) { + const apartment = savedAptsData[i]; + thumbnails.push( 0 + ? apartment.buildingData.photos[0] + : apartmentDefaultImage + } alt="Apartment Thumbnail" - /> - Apartment Thumbnail - - ); + ); + } } + + // Fill remaining slots with placeholders + const remainingSlots = maxThumbnails - thumbnails.length; + for (let i = 0; i < remainingSlots; i++) { + thumbnails.push(
    ); + } + + return thumbnails; }; return ( - <> -
    -
    - - {folder.name} - - - - +
    +
    +
    + {folder.name} + {apartmentCount} Saved
    - - - {apartmentCount} {apartmentCount === 1 ? 'apartment' : 'apartments'} - + + +
    - -
    {displayFolderThumbnail()}
    -
    +
    + {displayFolderThumbnail()} +
    {/* Menu */} @@ -318,7 +265,7 @@ const FolderCard = ({ folder, onDelete, onRename, user }: Props): ReactElement = - +
    ); }; diff --git a/frontend/src/components/Folder/FolderSection.tsx b/frontend/src/components/Folder/FolderSection.tsx index 2dcf8127..5f741baf 100644 --- a/frontend/src/components/Folder/FolderSection.tsx +++ b/frontend/src/components/Folder/FolderSection.tsx @@ -1,7 +1,6 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { Button, - Grid, makeStyles, Typography, Box, @@ -12,7 +11,6 @@ import { DialogActions, } from '@material-ui/core'; import Toast from '../utils/Toast'; -import { colors } from '../../colors'; import FolderCard from './FolderCard'; import axios from 'axios'; import { createAuthHeaders, getUser } from '../../utils/firebase'; @@ -36,46 +34,62 @@ const useStyles = makeStyles((theme) => ({ display: 'flex', alignItems: 'flex-start', justifyContent: 'center', - marginTop: '10px', + padding: '0 20px', + marginTop: '20px', }, - gridContainer: { - display: 'flex', - alignItems: 'flex-start', - marginBottom: '3em', - }, - headerStyle: { - fontFamily: 'Work Sans', - fontWeight: 800, + container: { + width: '100%', + maxWidth: '1200px', }, headerContainer: { - marginTop: '2em', marginBottom: '2em', }, + headerStyle: { + fontFamily: 'Work Sans, sans-serif', + fontWeight: 600, + fontSize: '1.5rem', + color: '#000', + }, + gridContainer: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: '24px', + marginBottom: '3em', + }, createButton: { - marginTop: '1em', - backgroundColor: colors.white, - color: colors.gray1, - borderRadius: 4, - textTransform: 'none', - padding: '40px 40px', - fontSize: 75, - border: `1px solid ${colors.gray3}`, + width: '100%', + aspectRatio: '1', + backgroundColor: '#fff', + border: '2px dashed #d0d0d0', + borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', - - boxShadow: '0 2px 2px rgba(0,0,0,0.1)', - - height: '200px', - width: '200px', - minWidth: '80px', - transition: 'transform 0.2s, box-shadow 0.2s', + transition: 'all 0.2s ease', '&:hover': { - transform: 'translateY(-4px)', - boxShadow: '0 4px 20px rgba(0,0,0,0.1)', + backgroundColor: '#f9f9f9', + borderColor: '#a0a0a0', }, }, + createIcon: { + fontSize: '64px', + color: '#999', + }, + emptyState: { + textAlign: 'center', + marginTop: '4em', + padding: '2em', + }, + emptyStateTitle: { + color: '#666', + fontWeight: 500, + marginBottom: '0.5em', + }, + emptyStateText: { + color: '#999', + fontSize: '0.95rem', + }, })); /** @@ -90,7 +104,7 @@ const useStyles = makeStyles((theme) => ({ */ const FolderSection = ({ user, setUser }: Props): ReactElement => { const toastTime = 3500; - const { background, headerStyle, headerContainer, gridContainer, createButton } = useStyles(); + const classes = useStyles(); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); @@ -220,43 +234,40 @@ const FolderSection = ({ user, setUser }: Props): ReactElement => { }; return ( -
    - - - - My Folders ({folders.length}) +
    + + + + Saved Properties and Landlords ({folders.length}) {loading ? ( Loading folders... ) : folders.length === 0 ? ( - - + + No folders yet - + Create your first folder to start organizing apartments ) : ( - +
    {folders.map((folder) => ( - - - + ))} - - setShowCreateDialog(true)}> - - - - + setShowCreateDialog(true)}> + + +
    )} {/* Create Folder Dialog */} From 3d8b6b01ec7cfe59ed080d453ec3176b7c4f3b8e Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Sun, 7 Dec 2025 21:59:20 -0500 Subject: [PATCH 26/32] Fix margin for create folder button --- frontend/src/components/Folder/FolderSection.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Folder/FolderSection.tsx b/frontend/src/components/Folder/FolderSection.tsx index 5f741baf..abf382c1 100644 --- a/frontend/src/components/Folder/FolderSection.tsx +++ b/frontend/src/components/Folder/FolderSection.tsx @@ -57,10 +57,11 @@ const useStyles = makeStyles((theme) => ({ marginBottom: '3em', }, createButton: { + marginTop: '55px', width: '100%', aspectRatio: '1', backgroundColor: '#fff', - border: '2px dashed #d0d0d0', + border: '1px solid #d0d0d0', borderRadius: '8px', cursor: 'pointer', display: 'flex', @@ -68,8 +69,8 @@ const useStyles = makeStyles((theme) => ({ justifyContent: 'center', transition: 'all 0.2s ease', '&:hover': { - backgroundColor: '#f9f9f9', - borderColor: '#a0a0a0', + transform: 'translateY(-4px)', + boxShadow: '0 4px 20px rgba(0,0,0,0.1)', }, }, createIcon: { @@ -149,7 +150,7 @@ const FolderSection = ({ user, setUser }: Props): ReactElement => { try { setLoading(true); if (!user) { - let user = await getUser(false); + let user = await getUser(true); setUser(user); } if (!user) { From 456bb39d8b14db865bfd94242d011d6b2f9e75f9 Mon Sep 17 00:00:00 2001 From: Lauren Pothuru Date: Fri, 20 Feb 2026 00:11:32 -0500 Subject: [PATCH 27/32] Update frontend --- frontend/src/components/Folder/AddToFolderPopover.tsx | 1 + frontend/src/pages/ApartmentPage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Folder/AddToFolderPopover.tsx b/frontend/src/components/Folder/AddToFolderPopover.tsx index 74548fd8..f38b17d3 100644 --- a/frontend/src/components/Folder/AddToFolderPopover.tsx +++ b/frontend/src/components/Folder/AddToFolderPopover.tsx @@ -25,6 +25,7 @@ const useStyles = makeStyles({ padding: '20px', boxShadow: '0 4px 20px rgba(0, 0, 0, 0.45)', zIndex: 1300, + border: '1px solid #e0e0e0', }, header: { display: 'flex', diff --git a/frontend/src/pages/ApartmentPage.tsx b/frontend/src/pages/ApartmentPage.tsx index 56b7d34e..943edee6 100644 --- a/frontend/src/pages/ApartmentPage.tsx +++ b/frontend/src/pages/ApartmentPage.tsx @@ -551,7 +551,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => {