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:
parent
4cd1203864
commit
a938e9bcd3
1 changed files with 168 additions and 1 deletions
|
|
@ -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) + ' · ' + 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">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
@ -38,6 +171,40 @@
|
|||
data-ref="_form"
|
||||
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…"
|
||||
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">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Name *</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue