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/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/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(); diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..ef250825 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'; @@ -34,6 +36,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'); @@ -506,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'); @@ -1302,6 +1377,516 @@ 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; + } + + // Validate and process roomTypes if provided + if (updatedApartmentData.roomTypes !== undefined) { + const roomTypes = updatedApartmentData.roomTypes as RoomType[]; + + // 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'); + } +}); + +/** + * 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, + roomTypes = [], + 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; + } + + // 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(); + 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, + roomTypes: processedRoomTypes as readonly RoomType[], + photos, + area, + latitude, + longitude, + 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'); + } +}); + +/** + * 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. * @@ -1427,7 +2012,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/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..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,27 +354,46 @@ const ApartmentCard = ({ > {`Rating: ${avgRating.toFixed(1)}`} - - {`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/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index a5510374..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, numBeds = 0, 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,13 +325,7 @@ const NewApartmentCard = ({
money - $2K - $3K -
-
- bed - - {numBeds ? `${numBeds - 1}-${numBeds + 1}` : '0'} bed - + {priceDisplay}
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/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 0584489d..2d717910 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -12,6 +12,18 @@ import { Tabs, Tab, IconButton, + Button, + TextField, + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableBody, + TableCell, + TableHead, + TableRow, } from '@material-ui/core'; import { CantFindApartmentFormWithId, @@ -24,12 +36,17 @@ 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 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: { @@ -51,16 +68,39 @@ 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), + }, + apartmentCard: { + borderBottom: '1px solid #e0e0e0', + padding: '15px 0', + position: 'relative', + }, + editButton: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + }, })); /** * 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: + * + * 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 * - * @returns The rendered AdminPage component. + * 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'); @@ -81,7 +121,72 @@ const AdminPage = (): ReactElement => { const [declinedExpanded, setDeclinedExpanded] = useState(true); const [reportedData, setReportedData] = useState([]); const [reportedExpanded, setReportedExpanded] = useState(true); - const { container, sectionHeader, expand, expandOpen } = useStyles(); + const [apartments, setApartments] = useState([]); + + // Sorting and editing state + 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); + 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' + >('idle'); + 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); + }, [apartments]); + const { + container, + sectionHeader, + expand, + expandOpen, + headerContainer, + sortButton, + apartmentCard, + editButton, + } = useStyles(); const { carouselPhotos, carouselStartIndex, @@ -92,6 +197,407 @@ const AdminPage = (): ReactElement => { useTitle('Admin'); + // Helper functions for sorting and editing + const toggleSortOrder = () => { + setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); + }; + + // 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; + const bNum = parseInt(bId, 10) || 0; + return aNum - bNum; // Always sort ascending to get correct page ranges + }); + + // 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) => { + 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; + } + }); + + // 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 { + 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) => { + 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); + }; + + const handleAddRoomType = () => { + setEditingRoomTypes((prev) => [ + ...prev, + { id: '', beds: 1, baths: 1, price: 1 }, // id will be generated by backend + ]); + }; + + const handleRemoveRoomType = (index: number) => { + 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 = ( + index: number, + field: 'beds' | 'baths' | 'price', + value: string + ) => { + const numValue = parseInt(value) || 1; + if (numValue < 1) return; // Enforce >= 1 constraint + + 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) { + alert('You must be logged in to edit apartments'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.put( + `/api/admin/update-apartment/${editingApartment}`, + { + name: editingName.trim(), + address: editingAddress.trim(), + landlordId: editingLandlordId.trim() || null, + area: editingArea, + photos: editingPhotos, + latitude: editingLatitude, + longitude: editingLongitude, + distanceToCampus: editingDistance, + roomTypes: editingRoomTypes, + }, + createAuthHeaders(token) + ); + + // Update local state with the response (which includes generated UUIDs) + setApartments((prev) => + prev.map((apt) => { + if (apt.buildingData?.id === editingApartment) { + return { + ...apt, + buildingData: response.data.apartment, + }; + } + return apt; + }) + ); + + alert('Room types updated successfully!'); + handleCancelEdit(); + } catch (error: any) { + console.error('Error updating apartment:', error); + const errorMsg = error.response?.data || 'Failed to update apartment. Please try again.'; + alert(errorMsg); + } + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const getPageRange = (page: number) => { + 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>>([ @@ -154,6 +660,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 = ( <> { ); + // 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 = ( + + + +
+
+ + All Apartments ({apartments.length}) + + + Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | Showing:{' '} + {getPageRange(currentPage)} of {filteredApartments.length} filtered ( + {apartments.length} total) + +
+
+ + +
+
+ + {/* 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 */} +
+ {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 ( + + {/* Edit Button */} + handleEditClick(apartment)} + size="small" + title="Edit apartment" + > + + + + {/* Apartment Name and ID */} + + {aptData?.name || 'N/A'} + + ID: {aptData?.id || 'N/A'} + + + + {/* Main Info Grid */} + + {/* Left Column - Location Info */} + + + + ADDRESS + + + {aptData?.address || 'N/A'} + + + AREA + + {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'} + + + + + + + + ); + })} +
+
+
+
+ ); + + // Room Types Edit Modal + const roomTypesModal = ( + + + Edit Apartment + {editingApartment && ( + + Apartment ID: {editingApartment} + + )} + + + {/* 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. + + ) : ( + + + + + 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)}... + + )} + + + ))} + +
+ )} + + + + + + {/* Validation Errors */} + {validationErrors.length > 0 && ( + + + Please fix the following errors: + + {validationErrors.map((error, idx) => ( + + • {error} + + ))} + + )} +
+ + + + +
+ ); + return (
setSelectedTab(newValue)} + onChange={(_event, newValue) => setSelectedTab(newValue)} aria-label="navigation tabs" variant="fullWidth" > + + {selectedTab === 'Reviews' && reviews} {selectedTab === 'Contact' && contact} + {selectedTab === 'Data' && data} + {selectedTab === 'DevTools' && developerTools} {Modals} + {roomTypesModal} + + {/* Create New Apartment Modal */} + + Create New Apartment + + + + setNewApartmentName(e.target.value)} + placeholder="e.g., 112 Edgemoor Lane" + /> + + + setNewApartmentAddress(e.target.value)} + placeholder="e.g., 112 Edgemoor Lane, Ithaca, NY 14850" + helperText="Full address will be used to calculate location and distance" + /> + + + setNewApartmentLandlordId(e.target.value)} + placeholder="Enter landlord ID" + /> + + + setNewApartmentArea(e.target.value as any)} + SelectProps={{ native: true }} + > + + + + + + + + + {/* Error Display */} + {createError && ( + + + {createError} + + + )} + + {/* Preview Data Display */} + {previewData && ( + + + + Preview - Calculated Location Data + + + Latitude: {previewData.coordinates?.latitude.toFixed(6)} + + + Longitude: {previewData.coordinates?.longitude.toFixed(6)} + + + Distance to Campus:{' '} + {previewData.apartmentData?.distanceToCampus.toFixed(2)} miles + + + Click "Confirm & Create" to save this apartment to the database. + + + + )} + + + + + {!previewData ? ( + + ) : ( + + )} + +
); }; diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index 4c14f447..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', @@ -77,6 +95,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 +155,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 +189,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,14 +237,14 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Lowest Price', callback: () => { - setSortBy('price'); + setSortBy('avgPrice'); setSortLowToHigh(true); }, }, { item: 'Highest Price', callback: () => { - setSortBy('price'); + setSortBy('avgPrice'); setSortLowToHigh(false); }, }, @@ -219,13 +286,91 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { {!isMobile ? (
- + {/* 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 && ( + <> + + {getAdditionalSectionTitle('location')} + + + + )} + + {/* Additional Price Section */} + {additionalPrice.length > 0 && ( + <> + + {getAdditionalSectionTitle('price')} + + + + )} + + {/* Additional Bed/Bath Section */} + {additionalBedBath.length > 0 && ( + <> + + {getAdditionalSectionTitle('bedBath')} + + + + )}
{ />
- + {/* 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 && ( + <> + + {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/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; +}; 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;