brewlog/templates/cafes.html
Jon Seager 5f3507fa92
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
2026-02-03 20:36:59 +00:00

345 lines
13 KiB
HTML

{% extends "base.html" %} {% block title %}Brewlog · Cafes{% endblock %}
{% block head %}
{% if is_authenticated %}
<script>
let _userLat = null;
let _userLng = null;
let _searchTimeout = null;
let _nearbyCafes = [];
function locateUser(btn) {
const errorEl = document.getElementById('nearby-error');
const 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(
(pos) => {
_userLat = pos.coords.latitude;
_userLng = pos.coords.longitude;
btn.classList.add('hidden');
searchWrap.classList.remove('hidden');
document.getElementById('nearby-search').focus();
},
(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);
const resultsEl = document.getElementById('nearby-results');
if (input.value.trim().length < 2) {
resultsEl.classList.add('hidden');
resultsEl.innerHTML = '';
return;
}
_searchTimeout = setTimeout(() => {
searchNearby(input.value.trim());
}, 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');
const spinnerEl = document.getElementById('nearby-spinner');
errorEl.classList.add('hidden');
spinnerEl.classList.remove('hidden');
try {
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}`);
const 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;
}
let html = '';
for (let i = 0; i < cafes.length; i++) {
const dist = cafes[i].distance_meters;
const distLabel = dist < 1000
? `${dist} m`
: `${(dist / 1000).toFixed(1)} km`;
const 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) {
const cafe = _nearbyCafes[index];
if (!cafe) return;
const 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) {
const 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">
<h1 class="text-3xl font-semibold">Cafes</h1>
<p class="max-w-2xl text-sm text-stone-600">Discover and track your favourite cafes.</p>
</div>
{% if is_authenticated %}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
data-class:hidden="$_showForm"
data-on:click="$_showForm = true"
aria-label="Add new cafe"
>
<span aria-hidden="true">+</span>
</button>
{% endif %}
</header>
{% if is_authenticated %}
<div
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
data-show="$_showForm"
style="display: none"
>
<div>
<h2 class="text-lg font-semibold text-amber-700">New Cafe</h2>
<p class="mt-1 text-sm text-stone-600">
Add a cafe you have visited or want to remember.
</p>
</div>
<form
method="post"
action="/api/v1/cafes"
class="mt-4 flex flex-col gap-4"
data-on:submit="@post('/api/v1/cafes?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#cafe-list', mode: 'replace'}})"
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>
<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]">
<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">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span>
<input
type="text"
name="name"
required
class="input-field"
placeholder="Blue Bottle Coffee"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">City *</span>
<input
type="text"
name="city"
required
class="input-field"
placeholder="San Francisco"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Country *</span>
<input
type="text"
name="country"
required
class="input-field"
placeholder="United States"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Website</span>
<input
type="url"
name="website"
class="input-field"
placeholder="https://bluebottlecoffee.com"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Latitude *</span>
<input
type="number"
name="latitude"
step="any"
required
class="input-field"
placeholder="37.7749"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Longitude *</span>
<input
type="number"
name="longitude"
step="any"
required
class="input-field"
placeholder="-122.4194"
/>
</label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Notes</span>
<textarea
name="notes"
rows="3"
class="input-field"
placeholder="Atmosphere, speciality drinks, opening hours..."
></textarea>
</label>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
data-on:click="($_showForm = false, $_form && $_form.reset())"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Save Cafe
</button>
</div>
</form>
</div>
{% endif %}
</section>
{% include "partials/cafe_list.html" %} {% endblock %}