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;
|
||||
Reference in New Issue
Block a user