feat(nearby): add city-based search via Foursquare near param
- Add SearchLocation enum to support coordinates or named location - Accept optional `near` query param as alternative to lat/lng - Add city text input with checkbox toggle in cafes template - Foursquare `near` param enables searching any city worldwide
This commit is contained in:
parent
ecb010e812
commit
5f3507fa92
4 changed files with 133 additions and 20 deletions
|
|
@ -189,9 +189,10 @@ define_list_fragment_renderer!(
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct NearbyQuery {
|
pub struct NearbyQuery {
|
||||||
lat: f64,
|
lat: Option<f64>,
|
||||||
lng: f64,
|
lng: Option<f64>,
|
||||||
q: String,
|
q: String,
|
||||||
|
near: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
|
@ -200,15 +201,30 @@ pub(crate) async fn nearby_cafes(
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
Query(query): Query<NearbyQuery>,
|
Query(query): Query<NearbyQuery>,
|
||||||
) -> Result<Json<Vec<NearbyCafe>>, ApiError> {
|
) -> Result<Json<Vec<NearbyCafe>>, ApiError> {
|
||||||
if !(-90.0..=90.0).contains(&query.lat) || !(-180.0..=180.0).contains(&query.lng) {
|
|
||||||
return Err(AppError::validation("lat must be -90..90, lng must be -180..180").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let q = query.q.trim();
|
let q = query.q.trim();
|
||||||
if q.is_empty() || q.len() < 2 {
|
if q.is_empty() || q.len() < 2 {
|
||||||
return Err(AppError::validation("q must be at least 2 characters").into());
|
return Err(AppError::validation("q must be at least 2 characters").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let location = if let Some(near) = &query.near {
|
||||||
|
let near = near.trim();
|
||||||
|
if near.len() < 2 {
|
||||||
|
return Err(AppError::validation("near must be at least 2 characters").into());
|
||||||
|
}
|
||||||
|
foursquare::SearchLocation::Near(near.to_string())
|
||||||
|
} else {
|
||||||
|
let lat = query
|
||||||
|
.lat
|
||||||
|
.ok_or_else(|| AppError::validation("lat is required when near is not provided"))?;
|
||||||
|
let lng = query
|
||||||
|
.lng
|
||||||
|
.ok_or_else(|| AppError::validation("lng is required when near is not provided"))?;
|
||||||
|
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lng) {
|
||||||
|
return Err(AppError::validation("lat must be -90..90, lng must be -180..180").into());
|
||||||
|
}
|
||||||
|
foursquare::SearchLocation::Coordinates { lat, lng }
|
||||||
|
};
|
||||||
|
|
||||||
let api_key = state
|
let api_key = state
|
||||||
.foursquare_api_key
|
.foursquare_api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -218,8 +234,7 @@ pub(crate) async fn nearby_cafes(
|
||||||
&state.http_client,
|
&state.http_client,
|
||||||
&state.foursquare_url,
|
&state.foursquare_url,
|
||||||
api_key,
|
api_key,
|
||||||
query.lat,
|
&location,
|
||||||
query.lng,
|
|
||||||
q,
|
q,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,43 @@ pub struct NearbyCafe {
|
||||||
pub distance_meters: u32,
|
pub distance_meters: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Searches for places matching `query` near the given coordinates via Foursquare.
|
/// Location mode for Foursquare search.
|
||||||
|
pub enum SearchLocation {
|
||||||
|
/// Search near GPS coordinates with a fixed radius.
|
||||||
|
Coordinates { lat: f64, lng: f64 },
|
||||||
|
/// Search near a named location (e.g. "London", "Tokyo, Japan").
|
||||||
|
Near(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for places matching `query` near the given location via Foursquare.
|
||||||
pub async fn search_nearby(
|
pub async fn search_nearby(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
lat: f64,
|
location: &SearchLocation,
|
||||||
lng: f64,
|
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<Vec<NearbyCafe>, AppError> {
|
) -> Result<Vec<NearbyCafe>, AppError> {
|
||||||
let ll = format!("{lat},{lng}");
|
let mut request = client
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(base_url)
|
.get(base_url)
|
||||||
.header("User-Agent", USER_AGENT)
|
.header("User-Agent", USER_AGENT)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.header("X-Places-Api-Version", API_VERSION)
|
.header("X-Places-Api-Version", API_VERSION)
|
||||||
.timeout(REQUEST_TIMEOUT)
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.query(&[("query", query), ("limit", MAX_RESULTS), ("fields", FIELDS)])
|
.query(&[("query", query), ("limit", MAX_RESULTS), ("fields", FIELDS)]);
|
||||||
.query(&[("ll", ll.as_str()), ("radius", RADIUS)])
|
|
||||||
|
let ll;
|
||||||
|
match location {
|
||||||
|
SearchLocation::Coordinates { lat, lng } => {
|
||||||
|
ll = format!("{lat},{lng}");
|
||||||
|
request = request.query(&[("ll", ll.as_str()), ("radius", RADIUS)]);
|
||||||
|
}
|
||||||
|
SearchLocation::Near(place) => {
|
||||||
|
request = request.query(&[("near", place.as_str())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::unexpected(format!("Foursquare search failed: {e}")))?;
|
.map_err(|e| AppError::unexpected(format!("Foursquare search failed: {e}")))?;
|
||||||
|
|
@ -82,9 +99,13 @@ pub async fn search_nearby(
|
||||||
.map(country_name)
|
.map(country_name)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let distance = place
|
let distance = place.distance.unwrap_or_else(|| {
|
||||||
.distance
|
if let SearchLocation::Coordinates { lat, lng } = location {
|
||||||
.unwrap_or_else(|| haversine_distance(lat, lng, place_lat, place_lng) as u32);
|
haversine_distance(*lat, *lng, place_lat, place_lng) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let website = place.website.filter(|w| !w.trim().is_empty());
|
let website = place.website.filter(|w| !w.trim().is_empty());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,31 @@
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCitySearch(checkbox) {
|
||||||
|
const locateBtn = document.getElementById('locate-btn');
|
||||||
|
const cityWrap = document.getElementById('city-search-wrap');
|
||||||
|
const searchWrap = document.getElementById('nearby-search-wrap');
|
||||||
|
const resultsEl = document.getElementById('nearby-results');
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
locateBtn.classList.add('hidden');
|
||||||
|
cityWrap.classList.remove('hidden');
|
||||||
|
searchWrap.classList.remove('hidden');
|
||||||
|
document.getElementById('city-input').focus();
|
||||||
|
} else {
|
||||||
|
cityWrap.classList.add('hidden');
|
||||||
|
document.getElementById('city-input').value = '';
|
||||||
|
if (_userLat === null) {
|
||||||
|
searchWrap.classList.add('hidden');
|
||||||
|
locateBtn.classList.remove('hidden');
|
||||||
|
locateBtn.textContent = 'Find nearby';
|
||||||
|
locateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
resultsEl.classList.add('hidden');
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function searchNearby(query) {
|
async function searchNearby(query) {
|
||||||
const resultsEl = document.getElementById('nearby-results');
|
const resultsEl = document.getElementById('nearby-results');
|
||||||
const errorEl = document.getElementById('nearby-error');
|
const errorEl = document.getElementById('nearby-error');
|
||||||
|
|
@ -70,7 +95,14 @@
|
||||||
spinnerEl.classList.remove('hidden');
|
spinnerEl.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
|
const cityInput = document.getElementById('city-input');
|
||||||
|
const cityValue = cityInput ? cityInput.value.trim() : '';
|
||||||
|
let url;
|
||||||
|
if (cityValue.length >= 2) {
|
||||||
|
url = `/api/v1/nearby-cafes?near=${encodeURIComponent(cityValue)}&q=${encodeURIComponent(query)}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
const resp = await fetch(url, { credentials: 'same-origin' });
|
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
|
||||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||||
|
|
@ -184,6 +216,19 @@
|
||||||
</svg>
|
</svg>
|
||||||
Find nearby
|
Find nearby
|
||||||
</button>
|
</button>
|
||||||
|
<label class="inline-flex items-center gap-1.5 text-sm text-stone-600 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" onchange="toggleCitySearch(this)" class="accent-amber-600" />
|
||||||
|
Search by city
|
||||||
|
</label>
|
||||||
|
<div id="city-search-wrap" class="hidden flex-1 min-w-[140px]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="city-input"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="e.g. Tokyo, London"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
|
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -172,3 +172,35 @@ async fn nearby_search_returns_500_on_upstream_failure() {
|
||||||
|
|
||||||
assert_eq!(response.status(), 500);
|
assert_eq!(response.status(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nearby_search_with_near_param() {
|
||||||
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
|
let mock_server = app.mock_server.as_ref().unwrap();
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/places/search"))
|
||||||
|
.and(query_param("query", "coffee"))
|
||||||
|
.and(query_param("near", "London"))
|
||||||
|
.and(header("Authorization", "Bearer test-api-key"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(foursquare_two_results()))
|
||||||
|
.expect(1)
|
||||||
|
.mount(mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(app.api_url("/nearby-cafes"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.query(&[("near", "London"), ("q", "coffee")])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let cafes: Vec<NearbyCafe> = response.json().await.expect("Failed to parse response");
|
||||||
|
assert_eq!(cafes.len(), 2);
|
||||||
|
assert_eq!(cafes[0].name, "Prufrock Coffee");
|
||||||
|
assert_eq!(cafes[0].city, "London");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue