location filter
This commit is contained in:
222
frontend/src/components/FilterPanel.tsx
Normal file
222
frontend/src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import AddressAutocomplete from "./AddressAutocomplete";
|
||||
import { PlaceDetails } from "../services/placesService";
|
||||
|
||||
interface FilterPanelProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
currentFilters: {
|
||||
lat: string;
|
||||
lng: string;
|
||||
radius: string;
|
||||
locationName?: string;
|
||||
};
|
||||
onApplyFilters: (filters: {
|
||||
lat: string;
|
||||
lng: string;
|
||||
radius: string;
|
||||
locationName: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const RADIUS_OPTIONS = [
|
||||
{ value: "5", label: "5 miles" },
|
||||
{ value: "10", label: "10 miles" },
|
||||
{ value: "25", label: "25 miles" },
|
||||
{ value: "50", label: "50 miles" },
|
||||
{ value: "100", label: "100 miles" },
|
||||
];
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
show,
|
||||
onClose,
|
||||
currentFilters,
|
||||
onApplyFilters,
|
||||
}) => {
|
||||
const [locationInput, setLocationInput] = useState("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<{
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [radius, setRadius] = useState(currentFilters.radius || "25");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initialize from current filters
|
||||
useEffect(() => {
|
||||
if (currentFilters.locationName) {
|
||||
setLocationInput(currentFilters.locationName);
|
||||
if (currentFilters.lat && currentFilters.lng) {
|
||||
setSelectedLocation({
|
||||
lat: parseFloat(currentFilters.lat),
|
||||
lng: parseFloat(currentFilters.lng),
|
||||
name: currentFilters.locationName,
|
||||
});
|
||||
}
|
||||
}
|
||||
setRadius(currentFilters.radius || "25");
|
||||
}, [currentFilters]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
panelRef.current &&
|
||||
!panelRef.current.contains(event.target as Node)
|
||||
) {
|
||||
// Check if click is on the filter button (parent handles this)
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest("[data-filter-button]")) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (show) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [show, onClose]);
|
||||
|
||||
const handlePlaceSelect = (place: PlaceDetails) => {
|
||||
if (place.geometry?.latitude && place.geometry?.longitude) {
|
||||
const locationName = place.formattedAddress || locationInput;
|
||||
setSelectedLocation({
|
||||
lat: place.geometry.latitude,
|
||||
lng: place.geometry.longitude,
|
||||
name: locationName,
|
||||
});
|
||||
setLocationInput(locationName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (selectedLocation) {
|
||||
onApplyFilters({
|
||||
lat: selectedLocation.lat.toString(),
|
||||
lng: selectedLocation.lng.toString(),
|
||||
radius,
|
||||
locationName: selectedLocation.name,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setLocationInput("");
|
||||
setSelectedLocation(null);
|
||||
setRadius("25");
|
||||
onApplyFilters({
|
||||
lat: "",
|
||||
lng: "",
|
||||
radius: "",
|
||||
locationName: "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.filter-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
background: white;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
z-index: 1050;
|
||||
margin-top: 0.5rem;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
animation: filterPanelSlideDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes filterPanelSlideDown {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.filter-panel {
|
||||
position: fixed;
|
||||
width: calc(100vw - 2rem);
|
||||
min-width: unset;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
top: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div ref={panelRef} className="filter-panel">
|
||||
<div className="p-3">
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-medium mb-2">Location</label>
|
||||
<AddressAutocomplete
|
||||
value={locationInput}
|
||||
onChange={setLocationInput}
|
||||
onPlaceSelect={handlePlaceSelect}
|
||||
placeholder="City, ZIP Code, or Address"
|
||||
types={["(regions)"]}
|
||||
countryRestriction="us"
|
||||
id="filter-location"
|
||||
/>
|
||||
{selectedLocation && (
|
||||
<div className="mt-2 small text-muted d-flex align-items-center">
|
||||
<i className="bi bi-check-circle text-success me-1"></i>
|
||||
{selectedLocation.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-medium mb-2">Search Radius</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(e.target.value)}
|
||||
>
|
||||
{RADIUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-top p-3 d-flex justify-content-between">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link text-muted text-decoration-none px-0"
|
||||
onClick={handleClear}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleApply}
|
||||
disabled={!selectedLocation}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
@@ -1,185 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { mapsAPI } from "../services/api";
|
||||
|
||||
interface LocationPromptModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onLocationSelect: (location: { lat: number; lng: number }) => 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 = async () => {
|
||||
const trimmed = manualLocation.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Geocode the input (works for both ZIP codes and city names)
|
||||
const response = await mapsAPI.geocode({
|
||||
address: trimmed,
|
||||
componentRestrictions: { country: "US" },
|
||||
});
|
||||
|
||||
const { latitude, longitude } = response.data;
|
||||
|
||||
if (latitude && longitude) {
|
||||
onLocationSelect({ lat: latitude, lng: longitude });
|
||||
} else {
|
||||
setError("Could not find that location. Please try a different city or ZIP code.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError("Could not find that location. Please try a different city or ZIP code.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -4,7 +4,7 @@ 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 FilterPanel from "../components/FilterPanel";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const ItemList: React.FC = () => {
|
||||
@@ -15,8 +15,10 @@ const ItemList: React.FC = () => {
|
||||
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 [showFilterPanel, setShowFilterPanel] = useState(false);
|
||||
const [locationName, setLocationName] = useState(searchParams.get("locationName") || "");
|
||||
const locationCheckDone = useRef(false);
|
||||
const filterButtonRef = useRef<HTMLDivElement>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
search: searchParams.get("search") || "",
|
||||
city: searchParams.get("city") || "",
|
||||
@@ -26,13 +28,12 @@ const ItemList: React.FC = () => {
|
||||
radius: searchParams.get("radius") || "",
|
||||
});
|
||||
|
||||
// Check if location is needed and handle accordingly
|
||||
// Auto-apply user's saved address if no location is set
|
||||
useEffect(() => {
|
||||
// Only run this check once per mount
|
||||
if (locationCheckDone.current) return;
|
||||
|
||||
const hasLocation = searchParams.has("lat") || searchParams.has("city") || searchParams.has("zipCode");
|
||||
const hasSearchTerm = searchParams.has("search");
|
||||
|
||||
if (!hasLocation) {
|
||||
// Check user's saved address for lat/lng
|
||||
@@ -47,12 +48,7 @@ const ItemList: React.FC = () => {
|
||||
params.set("radius", "25");
|
||||
locationCheckDone.current = true;
|
||||
navigate(`/items?${params.toString()}`, { replace: true });
|
||||
} else if (!hasSearchTerm) {
|
||||
// No saved address and no search term - show location prompt
|
||||
locationCheckDone.current = true;
|
||||
setShowLocationPrompt(true);
|
||||
} else {
|
||||
// Has search term but no location - just show results without location filter
|
||||
locationCheckDone.current = true;
|
||||
}
|
||||
} else {
|
||||
@@ -74,22 +70,39 @@ const ItemList: React.FC = () => {
|
||||
lng: searchParams.get("lng") || "",
|
||||
radius: searchParams.get("radius") || "",
|
||||
});
|
||||
setLocationName(searchParams.get("locationName") || "");
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLocationSelect = (location: { lat: number; lng: number }) => {
|
||||
const handleApplyFilters = (newFilters: {
|
||||
lat: string;
|
||||
lng: string;
|
||||
radius: string;
|
||||
locationName: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set("lat", location.lat.toString());
|
||||
params.set("lng", location.lng.toString());
|
||||
params.set("radius", "25");
|
||||
if (newFilters.lat && newFilters.lng) {
|
||||
params.set("lat", newFilters.lat);
|
||||
params.set("lng", newFilters.lng);
|
||||
params.set("radius", newFilters.radius || "25");
|
||||
params.set("locationName", newFilters.locationName);
|
||||
// Remove city/zipCode since we're using coordinates
|
||||
params.delete("city");
|
||||
params.delete("zipCode");
|
||||
} else {
|
||||
// Clear location filters
|
||||
params.delete("lat");
|
||||
params.delete("lng");
|
||||
params.delete("radius");
|
||||
params.delete("locationName");
|
||||
}
|
||||
|
||||
setLocationName(newFilters.locationName);
|
||||
navigate(`/items?${params.toString()}`, { replace: true });
|
||||
setShowLocationPrompt(false);
|
||||
};
|
||||
|
||||
const hasActiveLocationFilter = filters.lat && filters.lng;
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -164,6 +177,37 @@ const ItemList: React.FC = () => {
|
||||
<span className="text-muted">{items.length} items found</span>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{/* Filter Button */}
|
||||
<div className="position-relative" ref={filterButtonRef} style={{ overflow: 'visible' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-outline-secondary ${hasActiveLocationFilter ? 'active' : ''}`}
|
||||
onClick={() => setShowFilterPanel(!showFilterPanel)}
|
||||
data-filter-button
|
||||
>
|
||||
<i className="bi bi-filter me-1 me-md-2 align-middle"></i>
|
||||
<span className="d-none d-md-inline">Filters</span>
|
||||
{hasActiveLocationFilter && (
|
||||
<span className="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
|
||||
<span className="visually-hidden">Active filters</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<FilterPanel
|
||||
show={showFilterPanel}
|
||||
onClose={() => setShowFilterPanel(false)}
|
||||
currentFilters={{
|
||||
lat: filters.lat,
|
||||
lng: filters.lng,
|
||||
radius: filters.radius,
|
||||
locationName: locationName,
|
||||
}}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
{items.length > 0 && (
|
||||
<div className="btn-group" role="group" aria-label="View toggle">
|
||||
<button
|
||||
@@ -185,6 +229,7 @@ const ItemList: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
@@ -215,12 +260,6 @@ const ItemList: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocationPromptModal
|
||||
show={showLocationPrompt}
|
||||
onClose={() => setShowLocationPrompt(false)}
|
||||
onLocationSelect={handleLocationSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user