location filter

This commit is contained in:
jackiettran
2025-12-30 14:23:21 -05:00
parent 546c881701
commit 6cf8a009ff
3 changed files with 299 additions and 223 deletions

View 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;

View File

@@ -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;

View File

@@ -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");
// Remove city/zipCode since we're using coordinates
params.delete("city");
params.delete("zipCode");
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,26 +177,58 @@ const ItemList: React.FC = () => {
<span className="text-muted">{items.length} items found</span>
</div>
{items.length > 0 && (
<div className="btn-group" role="group" aria-label="View toggle">
<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 ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
className={`btn btn-outline-secondary ${hasActiveLocationFilter ? 'active' : ''}`}
onClick={() => setShowFilterPanel(!showFilterPanel)}
data-filter-button
>
<i className="bi bi-list-ul me-1 me-md-2"></i>
<span className="d-none d-md-inline">List</span>
</button>
<button
type="button"
className={`btn btn-outline-secondary ${viewMode === 'map' ? 'active' : ''}`}
onClick={() => setViewMode('map')}
>
<i className="bi bi-geo-alt-fill me-1 me-md-2"></i>
<span className="d-none d-md-inline">Map</span>
<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
type="button"
className={`btn btn-outline-secondary ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
>
<i className="bi bi-list-ul me-1 me-md-2"></i>
<span className="d-none d-md-inline">List</span>
</button>
<button
type="button"
className={`btn btn-outline-secondary ${viewMode === 'map' ? 'active' : ''}`}
onClick={() => setViewMode('map')}
>
<i className="bi bi-geo-alt-fill me-1 me-md-2"></i>
<span className="d-none d-md-inline">Map</span>
</button>
</div>
)}
</div>
</div>
{items.length === 0 ? (
@@ -215,12 +260,6 @@ const ItemList: React.FC = () => {
/>
</div>
)}
<LocationPromptModal
show={showLocationPrompt}
onClose={() => setShowLocationPrompt(false)}
onLocationSelect={handleLocationSelect}
/>
</div>
);
};