diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index 6eae450..e84e885 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -189,9 +189,10 @@ define_list_fragment_renderer!( #[derive(Debug, Deserialize)] pub struct NearbyQuery { - lat: f64, - lng: f64, + lat: Option, + lng: Option, q: String, + near: Option, } #[tracing::instrument(skip(state, _auth_user))] @@ -200,15 +201,30 @@ pub(crate) async fn nearby_cafes( _auth_user: AuthenticatedUser, Query(query): Query, ) -> Result>, 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(); if q.is_empty() || q.len() < 2 { 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 .foursquare_api_key .as_deref() @@ -218,8 +234,7 @@ pub(crate) async fn nearby_cafes( &state.http_client, &state.foursquare_url, api_key, - query.lat, - query.lng, + &location, q, ) .await diff --git a/src/infrastructure/foursquare.rs b/src/infrastructure/foursquare.rs index 3ff73a4..3d9731f 100644 --- a/src/infrastructure/foursquare.rs +++ b/src/infrastructure/foursquare.rs @@ -24,26 +24,43 @@ pub struct NearbyCafe { 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( client: &reqwest::Client, base_url: &str, api_key: &str, - lat: f64, - lng: f64, + location: &SearchLocation, query: &str, ) -> Result, AppError> { - let ll = format!("{lat},{lng}"); - - let response = client + let mut request = client .get(base_url) .header("User-Agent", USER_AGENT) .header("Accept", "application/json") .header("Authorization", format!("Bearer {api_key}")) .header("X-Places-Api-Version", API_VERSION) .timeout(REQUEST_TIMEOUT) - .query(&[("query", query), ("limit", MAX_RESULTS), ("fields", FIELDS)]) - .query(&[("ll", ll.as_str()), ("radius", RADIUS)]) + .query(&[("query", query), ("limit", MAX_RESULTS), ("fields", FIELDS)]); + + 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() .await .map_err(|e| AppError::unexpected(format!("Foursquare search failed: {e}")))?; @@ -82,9 +99,13 @@ pub async fn search_nearby( .map(country_name) .unwrap_or_default(); - let distance = place - .distance - .unwrap_or_else(|| haversine_distance(lat, lng, place_lat, place_lng) as u32); + let distance = place.distance.unwrap_or_else(|| { + if let SearchLocation::Coordinates { lat, lng } = location { + haversine_distance(*lat, *lng, place_lat, place_lng) as u32 + } else { + 0 + } + }); let website = place.website.filter(|w| !w.trim().is_empty()); diff --git a/templates/cafes.html b/templates/cafes.html index b968108..42982b9 100644 --- a/templates/cafes.html +++ b/templates/cafes.html @@ -62,6 +62,31 @@ }, 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) { const resultsEl = document.getElementById('nearby-results'); const errorEl = document.getElementById('nearby-error'); @@ -70,7 +95,14 @@ spinnerEl.classList.remove('hidden'); 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' }); if (!resp.ok) throw new Error(`Server returned ${resp.status}`); @@ -184,6 +216,19 @@ Find nearby + +