feat(cafes): add geolocation search UI to new cafe form

- Add "Find nearby" button that requests browser geolocation
- Show search input with debounced type-ahead after locating user
- Render clickable results with name, location, and distance
- Auto-fill form fields (name, city, country, lat, lng, website)
  when a result is selected
- Show loading spinner in search input during API requests
This commit is contained in:
Jon Seager 2026-02-03 15:56:35 +00:00
parent 4cd1203864
commit a938e9bcd3
No known key found for this signature in database

View file

@ -1,4 +1,137 @@
{% extends "base.html" %} {% block title %}Brewlog · Cafes{% endblock %} {% block content %} {% extends "base.html" %} {% block title %}Brewlog · Cafes{% endblock %}
{% block head %}
{% if is_authenticated %}
<script>
var _userLat = null;
var _userLng = null;
var _searchTimeout = null;
var _nearbyCafes = [];
function locateUser(btn) {
var errorEl = document.getElementById('nearby-error');
var searchWrap = document.getElementById('nearby-search-wrap');
errorEl.classList.add('hidden');
errorEl.textContent = '';
if (!navigator.geolocation) {
errorEl.textContent = 'Geolocation is not supported by your browser.';
errorEl.classList.remove('hidden');
return;
}
btn.textContent = 'Locating\u2026';
btn.disabled = true;
navigator.geolocation.getCurrentPosition(
function (pos) {
_userLat = pos.coords.latitude;
_userLng = pos.coords.longitude;
btn.classList.add('hidden');
searchWrap.classList.remove('hidden');
document.getElementById('nearby-search').focus();
},
function (err) {
btn.textContent = 'Find nearby';
btn.disabled = false;
if (err.code === 1) {
errorEl.textContent = 'Location access denied. Please allow location access and try again.';
} else if (err.code === 3) {
errorEl.textContent = 'Location request timed out. Please try again.';
} else {
errorEl.textContent = 'Could not determine your location. Please try again.';
}
errorEl.classList.remove('hidden');
},
{ enableHighAccuracy: true, timeout: 15000 }
);
}
function onSearchInput(input) {
clearTimeout(_searchTimeout);
var resultsEl = document.getElementById('nearby-results');
if (input.value.trim().length < 2) {
resultsEl.classList.add('hidden');
resultsEl.innerHTML = '';
return;
}
_searchTimeout = setTimeout(function () {
searchNearby(input.value.trim());
}, 350);
}
async function searchNearby(query) {
var resultsEl = document.getElementById('nearby-results');
var errorEl = document.getElementById('nearby-error');
var spinnerEl = document.getElementById('nearby-spinner');
errorEl.classList.add('hidden');
spinnerEl.classList.remove('hidden');
try {
var url = '/api/v1/nearby-cafes?lat=' + _userLat + '&lng=' + _userLng + '&q=' + encodeURIComponent(query);
var resp = await fetch(url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Server returned ' + resp.status);
var cafes = await resp.json();
_nearbyCafes = cafes;
if (cafes.length === 0) {
resultsEl.innerHTML = '<div class="px-3 py-2 text-sm text-stone-500">No results found.</div>';
resultsEl.classList.remove('hidden');
return;
}
var html = '';
for (var i = 0; i < cafes.length; i++) {
var dist = cafes[i].distance_meters;
var distLabel = dist < 1000
? dist + ' m'
: (dist / 1000).toFixed(1) + ' km';
var location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
html += '<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(' + i + ')">'
+ '<span class="font-medium text-amber-900">' + escapeHtml(cafes[i].name) + '</span>'
+ '<span class="ml-2 text-xs text-stone-500">' + escapeHtml(location) + ' &middot; ' + distLabel + '</span>'
+ '</button>';
}
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
} catch (e) {
errorEl.textContent = 'Search failed. Please try again.';
errorEl.classList.remove('hidden');
} finally {
spinnerEl.classList.add('hidden');
}
}
function selectPlace(index) {
var cafe = _nearbyCafes[index];
if (!cafe) return;
var form = document.getElementById('nearby-search').closest('form');
form.querySelector('[name="name"]').value = cafe.name;
form.querySelector('[name="city"]').value = cafe.city || '';
form.querySelector('[name="country"]').value = cafe.country || '';
form.querySelector('[name="latitude"]').value = cafe.latitude;
form.querySelector('[name="longitude"]').value = cafe.longitude;
form.querySelector('[name="website"]').value = cafe.website || '';
document.getElementById('nearby-results').classList.add('hidden');
document.getElementById('nearby-search').value = '';
}
function escapeHtml(text) {
var el = document.createElement('span');
el.textContent = text;
return el.innerHTML;
}
</script>
{% endif %}
{% endblock %}
{% block content %}
<section data-signals:_show-form="false"> <section data-signals:_show-form="false">
<header class="flex flex-wrap items-start justify-between gap-4"> <header class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -38,6 +171,40 @@
data-ref="_form" data-ref="_form"
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())" data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
> >
<div class="relative border-b border-amber-200 pb-4">
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
id="locate-btn"
onclick="locateUser(this)"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z" clip-rule="evenodd" />
</svg>
Find nearby
</button>
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
<input
type="text"
id="nearby-search"
oninput="onSearchInput(this)"
class="input-field w-full text-sm pr-8"
placeholder="Search for a cafe&hellip;"
autocomplete="off"
/>
<svg id="nearby-spinner" class="hidden absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</div>
<p id="nearby-error" class="hidden mt-2 text-sm text-red-600"></p>
<div
id="nearby-results"
class="hidden absolute left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-md border border-amber-300 bg-white shadow-lg divide-y divide-amber-100"
></div>
</div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span> <span class="text-stone-700">Name *</span>