Updated search bar to remove location. Will get or ask for user's location. Removed Start Earning button. Works on desktop and mobile

This commit is contained in:
jackiettran
2025-12-23 18:09:12 -05:00
parent 07e5a2a320
commit 347f709f72
5 changed files with 531 additions and 214 deletions

View File

@@ -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;
}
// 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)

View File

@@ -8,11 +8,6 @@ main {
flex: 1;
}
.navbar-brand i {
font-size: 1.5rem;
}
.dropdown-toggle::after {
display: none;
}
@@ -22,3 +17,41 @@ main {
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;
}
}

View File

@@ -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<LocationPromptModalProps> = ({
show,
onClose,
onLocationSelect,
}) => {
const [manualLocation, setManualLocation] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!show) return null;
const handleUseMyLocation = async () => {
setLoading(true);
setError(null);
try {
const position = await new Promise<GeolocationPosition>(
(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<HTMLInputElement>) => {
if (e.key === "Enter") {
handleManualSubmit();
}
};
return (
<div
className="modal d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-geo-alt me-2"></i>
Where are you looking?
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
disabled={loading}
></button>
</div>
<div className="modal-body">
<p className="text-muted mb-4">
Help us show you items nearby
</p>
<button
className="btn btn-outline-primary w-100 mb-3 d-flex align-items-center justify-content-center"
onClick={handleUseMyLocation}
disabled={loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Getting location...
</>
) : (
<>
<i className="bi bi-crosshair me-2"></i>
Use my current location
</>
)}
</button>
{error && (
<div className="alert alert-warning py-2 small">
<i className="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
)}
<div className="d-flex align-items-center my-3">
<hr className="flex-grow-1" />
<span className="px-3 text-muted">or</span>
<hr className="flex-grow-1" />
</div>
<div className="mb-3">
<input
type="text"
className="form-control"
placeholder="Enter city or ZIP code"
value={manualLocation}
onChange={(e) => setManualLocation(e.target.value)}
onKeyDown={handleKeyDown}
disabled={loading}
/>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
Skip
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleManualSubmit}
disabled={loading || !manualLocation.trim()}
>
Search
</button>
</div>
</div>
</div>
</div>
);
};
export default LocationPromptModal;

View File

@@ -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,57 +109,18 @@ const Navbar: React.FC = () => {
<nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div className="container-fluid" style={{ maxWidth: "1800px" }}>
<Link className="navbar-brand fw-bold" to="/">
<i className="bi bi-box-seam me-2"></i>
Village Share
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<div className="d-flex align-items-center w-100">
<div className="position-absolute start-50 translate-middle-x">
<div>
<div className="input-group" style={{ width: "520px" }}>
{/* Search bar - always visible, centered */}
<div className="navbar-search">
<div className="input-group">
<input
type="text"
className="form-control"
placeholder="Search items..."
value={searchFilters.search}
onChange={(e) =>
handleSearchInputChange("search", e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch(e);
}
}}
/>
<span
className="input-group-text text-muted"
style={{
borderLeft: "0",
borderRight: "1px solid #dee2e6",
backgroundColor: "#f8f9fa",
}}
>
in
</span>
<input
type="text"
className="form-control"
placeholder="City or ZIP"
value={searchFilters.location}
onChange={(e) =>
handleSearchInputChange("location", e.target.value)
}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch(e);
@@ -191,14 +136,56 @@ const Navbar: React.FC = () => {
</button>
</div>
</div>
</div>
<div className="ms-auto d-flex align-items-center">
<Link
className="btn btn-outline-primary btn-sm me-3 text-nowrap"
to="/create-item"
{/* Mobile avatar toggle */}
<button
className="navbar-toggler border-0 p-0"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
Start Earning
</Link>
<span
style={{
display: "flex",
alignItems: "center",
position: "relative",
}}
>
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
<span
className="mobile-notification-badge"
style={{
position: "absolute",
right: "-5px",
top: "-5px",
backgroundColor: "#dc3545",
color: "white",
borderRadius: "50%",
width: "1.2em",
height: "1.2em",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.7em",
fontWeight: "bold",
border: "2px solid white",
zIndex: 1,
}}
>
{pendingRequestsCount + unreadMessagesCount}
</span>
)}
<i
className="bi bi-person-circle"
style={{ fontSize: "1.5rem", color: "#333" }}
></i>
</span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<div className="d-flex align-items-center ms-auto">
<ul className="navbar-nav flex-row">
{user ? (
<>
@@ -211,8 +198,15 @@ const Navbar: React.FC = () => {
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span style={{ display: "flex", alignItems: "center", position: "relative" }}>
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
<span
style={{
display: "flex",
alignItems: "center",
position: "relative",
}}
>
{(pendingRequestsCount > 0 ||
unreadMessagesCount > 0) && (
<span
style={{
position: "absolute",
@@ -317,7 +311,6 @@ const Navbar: React.FC = () => {
</div>
</div>
</div>
</div>
</nav>
</>
);

View File

@@ -1,23 +1,61 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Item } from "../types";
import { itemAPI } from "../services/api";
import ItemCard from "../components/ItemCard";
import SearchResultsMap from "../components/SearchResultsMap";
import LocationPromptModal from "../components/LocationPromptModal";
import { useAuth } from "../contexts/AuthContext";
const ItemList: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'map'>('list');
const [showLocationPrompt, setShowLocationPrompt] = useState(false);
const locationCheckDone = useRef(false);
const [filters, setFilters] = useState({
search: searchParams.get("search") || "",
city: searchParams.get("city") || "",
zipCode: searchParams.get("zipCode") || "",
lat: searchParams.get("lat") || "",
lng: searchParams.get("lng") || "",
radius: searchParams.get("radius") || "",
});
// Check if location is needed and handle accordingly
useEffect(() => {
// Only run this check once per mount
if (locationCheckDone.current) return;
const hasLocation = searchParams.has("lat") || searchParams.has("city") || searchParams.has("zipCode");
if (!hasLocation) {
// Check user's saved address for lat/lng
const userLat = user?.addresses?.[0]?.latitude;
const userLng = user?.addresses?.[0]?.longitude;
if (userLat && userLng) {
// Use saved address coordinates
const params = new URLSearchParams(searchParams);
params.set("lat", userLat.toString());
params.set("lng", userLng.toString());
params.set("radius", "25");
locationCheckDone.current = true;
navigate(`/items?${params.toString()}`, { replace: true });
} else {
// No saved address with coordinates - show location prompt
locationCheckDone.current = true;
setShowLocationPrompt(true);
}
} else {
locationCheckDone.current = true;
}
}, [user, searchParams, navigate]);
useEffect(() => {
fetchItems();
}, [filters]);
@@ -28,9 +66,37 @@ const ItemList: React.FC = () => {
search: searchParams.get("search") || "",
city: searchParams.get("city") || "",
zipCode: searchParams.get("zipCode") || "",
lat: searchParams.get("lat") || "",
lng: searchParams.get("lng") || "",
radius: searchParams.get("radius") || "",
});
}, [searchParams]);
const handleLocationSelect = (
location: { lat: number; lng: number } | { city?: string; zipCode?: string }
) => {
const params = new URLSearchParams(searchParams);
if ("lat" in location) {
params.set("lat", location.lat.toString());
params.set("lng", location.lng.toString());
params.set("radius", "25");
// Remove city/zipCode if using coordinates
params.delete("city");
params.delete("zipCode");
} else {
if (location.city) params.set("city", location.city);
if (location.zipCode) params.set("zipCode", location.zipCode);
// Remove lat/lng if using city/zip
params.delete("lat");
params.delete("lng");
params.delete("radius");
}
navigate(`/items?${params.toString()}`, { replace: true });
setShowLocationPrompt(false);
};
const fetchItems = async () => {
try {
setLoading(true);
@@ -66,6 +132,10 @@ const ItemList: React.FC = () => {
};
const getSearchLocationString = () => {
if (filters.lat && filters.lng) {
// When using coordinates, return them as a string for the map
return `${filters.lat},${filters.lng}`;
}
if (filters.city) return filters.city;
if (filters.zipCode) return filters.zipCode;
return '';
@@ -152,6 +222,12 @@ const ItemList: React.FC = () => {
/>
</div>
)}
<LocationPromptModal
show={showLocationPrompt}
onClose={() => setShowLocationPrompt(false)}
onLocationSelect={handleLocationSelect}
/>
</div>
);
};