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:
Jon Seager 2026-02-03 20:36:59 +00:00
parent ecb010e812
commit 5f3507fa92
No known key found for this signature in database
4 changed files with 133 additions and 20 deletions

View file

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

View file

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

View file

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

View file

@ -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");
}