From 347f709f72eaa0baa7eb1faeeb6ebd2ec6470d74 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:09:12 -0500 Subject: [PATCH] Updated search bar to remove location. Will get or ask for user's location. Removed Start Earning button. Works on desktop and mobile --- backend/routes/items.js | 55 ++- frontend/src/App.css | 43 +- .../src/components/LocationPromptModal.tsx | 170 ++++++++ frontend/src/components/Navbar.tsx | 393 +++++++++--------- frontend/src/pages/ItemList.tsx | 84 +++- 5 files changed, 531 insertions(+), 214 deletions(-) create mode 100644 frontend/src/components/LocationPromptModal.tsx diff --git a/backend/routes/items.js b/backend/routes/items.js index 5141ad5..d75a7f5 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -1,6 +1,6 @@ const express = require("express"); -const { Op } = require("sequelize"); -const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations +const { Op, Sequelize } = require("sequelize"); +const { Item, User, Rental, sequelize } = require("../models"); // Import from models/index.js to get models with associations const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth"); const logger = require("../utils/logger"); const { validateS3Keys } = require("../utils/s3KeyValidator"); @@ -61,6 +61,9 @@ router.get("/", async (req, res, next) => { city, zipCode, search, + lat, + lng, + radius = 25, page = 1, limit = 20, } = req.query; @@ -74,8 +77,50 @@ router.get("/", async (req, res, next) => { if (minPrice) where.pricePerDay[Op.gte] = minPrice; if (maxPrice) where.pricePerDay[Op.lte] = maxPrice; } - if (city) where.city = { [Op.iLike]: `%${city}%` }; - if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` }; + + // Location filtering: Radius search OR city/ZIP fallback + if (lat && lng) { + // Parse and validate coordinates + const latNum = parseFloat(lat); + const lngNum = parseFloat(lng); + const radiusNum = parseFloat(radius); + + if (!isNaN(latNum) && !isNaN(lngNum) && !isNaN(radiusNum)) { + // Bounding box pre-filter (fast, uses indexes) + // ~69 miles per degree latitude, longitude varies by latitude + const latDelta = radiusNum / 69; + const lngDelta = radiusNum / (69 * Math.cos(latNum * Math.PI / 180)); + + where.latitude = { + [Op.and]: [ + { [Op.gte]: latNum - latDelta }, + { [Op.lte]: latNum + latDelta }, + { [Op.ne]: null } + ] + }; + where.longitude = { + [Op.and]: [ + { [Op.gte]: lngNum - lngDelta }, + { [Op.lte]: lngNum + lngDelta }, + { [Op.ne]: null } + ] + }; + + // Haversine formula for exact distance (applied after bounding box) + // 3959 = Earth's radius in miles + where[Op.and] = sequelize.literal(` + (3959 * acos( + cos(radians(${latNum})) * cos(radians("Item"."latitude")) * + cos(radians("Item"."longitude") - radians(${lngNum})) + + sin(radians(${latNum})) * sin(radians("Item"."latitude")) + )) <= ${radiusNum} + `); + } + } else { + // Fallback to city/ZIP string matching + if (city) where.city = { [Op.iLike]: `%${city}%` }; + if (zipCode) where.zipCode = { [Op.iLike]: `%${zipCode}%` }; + } if (search) { where[Op.or] = [ { name: { [Op.iLike]: `%${search}%` } }, @@ -113,7 +158,7 @@ router.get("/", async (req, res, next) => { const reqLogger = logger.withRequestId(req.id); reqLogger.info("Items search completed", { - filters: { minPrice, maxPrice, city, zipCode, search }, + filters: { minPrice, maxPrice, city, zipCode, search, lat, lng, radius }, resultsCount: count, page: parseInt(page), limit: parseInt(limit) diff --git a/frontend/src/App.css b/frontend/src/App.css index af72036..0703e76 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -8,11 +8,6 @@ main { flex: 1; } -.navbar-brand i { - font-size: 1.5rem; -} - - .dropdown-toggle::after { display: none; } @@ -21,4 +16,42 @@ main { position: absolute; right: 0; left: auto; +} + +/* Navbar search - centered */ +.navbar-search { + max-width: 500px; + margin: 0 auto; + flex: 1; +} + +/* Navbar layout - center search bar */ +.navbar .container-fluid { + display: flex; + justify-content: center; + align-items: center; +} + +.navbar .navbar-collapse { + flex-grow: 0; +} + +@media (max-width: 991.98px) { + .navbar-search { + max-width: none; + flex: 1; + margin: 0 1rem 0 0; + } + + .navbar-search .form-control { + font-size: 0.875rem; + } + + .navbar-brand { + font-size: 0.9rem; + } + + .navbar .container-fluid { + justify-content: space-between; + } } \ No newline at end of file diff --git a/frontend/src/components/LocationPromptModal.tsx b/frontend/src/components/LocationPromptModal.tsx new file mode 100644 index 0000000..142d2bd --- /dev/null +++ b/frontend/src/components/LocationPromptModal.tsx @@ -0,0 +1,170 @@ +import React, { useState } from "react"; + +interface LocationPromptModalProps { + show: boolean; + onClose: () => void; + onLocationSelect: ( + location: { lat: number; lng: number } | { city?: string; zipCode?: string } + ) => void; +} + +const LocationPromptModal: React.FC = ({ + show, + onClose, + onLocationSelect, +}) => { + const [manualLocation, setManualLocation] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + if (!show) return null; + + const handleUseMyLocation = async () => { + setLoading(true); + setError(null); + + try { + const position = await new Promise( + (resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 10000, + maximumAge: 300000, // Cache for 5 minutes + }); + } + ); + + onLocationSelect({ + lat: position.coords.latitude, + lng: position.coords.longitude, + }); + } catch (err: any) { + if (err.code === 1) { + setError("Location access denied. Please enter your city or ZIP code."); + } else if (err.code === 2) { + setError("Location unavailable. Please enter your city or ZIP code."); + } else if (err.code === 3) { + setError("Location request timed out. Please enter your city or ZIP code."); + } else { + setError("Could not get location. Please enter manually."); + } + } finally { + setLoading(false); + } + }; + + const handleManualSubmit = () => { + const trimmed = manualLocation.trim(); + if (!trimmed) return; + + // Check if it looks like a ZIP code + if (/^\d{5}(-\d{4})?$/.test(trimmed)) { + onLocationSelect({ zipCode: trimmed }); + } else { + onLocationSelect({ city: trimmed }); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleManualSubmit(); + } + }; + + return ( +
+
+
+
+
+ + Where are you looking? +
+ +
+
+

+ Help us show you items nearby +

+ + + + {error && ( +
+ + {error} +
+ )} + +
+
+ or +
+
+ +
+ setManualLocation(e.target.value)} + onKeyDown={handleKeyDown} + disabled={loading} + /> +
+
+
+ + +
+
+
+
+ ); +}; + +export default LocationPromptModal; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 148d410..0ff721a 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -8,10 +8,7 @@ const Navbar: React.FC = () => { const { user, logout, openAuthModal } = useAuth(); const { onNewMessage, onMessageRead } = useSocket(); const navigate = useNavigate(); - const [searchFilters, setSearchFilters] = useState({ - search: "", - location: "", - }); + const [searchTerm, setSearchTerm] = useState(""); const [pendingRequestsCount, setPendingRequestsCount] = useState(0); const [unreadMessagesCount, setUnreadMessagesCount] = useState(0); @@ -40,7 +37,10 @@ const Navbar: React.FC = () => { window.addEventListener("rentalStatusChanged", handleRentalStatusChange); return () => { - window.removeEventListener("rentalStatusChanged", handleRentalStatusChange); + window.removeEventListener( + "rentalStatusChanged", + handleRentalStatusChange + ); }; }, [user]); @@ -93,31 +93,15 @@ const Navbar: React.FC = () => { e?.preventDefault(); const params = new URLSearchParams(); - if (searchFilters.search.trim()) { - params.append("search", searchFilters.search.trim()); - } - if (searchFilters.location.trim()) { - // Check if location looks like a zip code (5 digits) or city name - const location = searchFilters.location.trim(); - if (/^\d{5}(-\d{4})?$/.test(location)) { - params.append("zipCode", location); - } else { - params.append("city", location); - } + if (searchTerm.trim()) { + params.append("search", searchTerm.trim()); } - const queryString = params.toString(); - navigate(`/items${queryString ? `?${queryString}` : ""}`); + // Location is handled in ItemList + navigate(`/items${params.toString() ? `?${params.toString()}` : ""}`); // Clear search after navigating - setSearchFilters({ search: "", location: "" }); - }; - - const handleSearchInputChange = ( - field: "search" | "location", - value: string - ) => { - setSearchFilters((prev) => ({ ...prev, [field]: value })); + setSearchTerm(""); }; return ( @@ -125,11 +109,37 @@ const Navbar: React.FC = () => {