Replace duplicated inline SVGs across 16 templates with reusable Askama macros from a new icons.html partial, reducing repetition and making icon updates a single-point change.
374 lines
17 KiB
HTML
374 lines
17 KiB
HTML
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Check In{% endblock %}
|
|
|
|
{% block head %}
|
|
<script>
|
|
function locateUser() {
|
|
const root = document.getElementById('checkin-root');
|
|
const emit = (name, detail) =>
|
|
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
|
|
|
if (!navigator.geolocation) {
|
|
emit('location-error', { message: 'Geolocation not supported by your browser.' });
|
|
return;
|
|
}
|
|
|
|
emit('location-start');
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
emit('location-found', { lat: pos.coords.latitude, lng: pos.coords.longitude });
|
|
},
|
|
(err) => {
|
|
if (err.code === 1) {
|
|
emit('location-error', { message: 'Location access denied. Search by name instead.' });
|
|
} else {
|
|
emit('location-error', { message: 'Could not determine location. Search by name instead.' });
|
|
}
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 15000 },
|
|
);
|
|
}
|
|
</script>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<section
|
|
id="checkin-root"
|
|
data-signals:_step="1"
|
|
data-signals:_cafe-id="''"
|
|
data-signals:_cafe-name="''"
|
|
data-signals:_cafe-city="''"
|
|
data-signals:_cafe-country="''"
|
|
data-signals:_cafe-lat="0"
|
|
data-signals:_cafe-lng="0"
|
|
data-signals:_cafe-website="''"
|
|
data-signals:_cafe-search="''"
|
|
data-signals:_roast-id="''"
|
|
data-signals:_roast-name="''"
|
|
data-signals:_roaster-name="''"
|
|
data-signals:_rating="0"
|
|
data-signals:_error="''"
|
|
data-signals:_submitting="false"
|
|
data-signals:_locating="false"
|
|
data-signals:_location-found="false"
|
|
data-signals:_user-lat="0"
|
|
data-signals:_user-lng="0"
|
|
data-signals:_search-by-city="false"
|
|
data-signals:_city-name="''"
|
|
data-signals:_reviewing-cafe="false"
|
|
data-signals:_scan-waiting="false"
|
|
data-signals:_scan-success="''"
|
|
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
|
|
data-on:location-error="$_locating = false; $_error = evt.detail.message"
|
|
data-on:location-start="$_locating = true"
|
|
>
|
|
<header class="flex flex-col gap-2">
|
|
<h1 class="text-3xl font-semibold">Check In</h1>
|
|
<p class="max-w-2xl text-sm text-stone-600">
|
|
Record a cup of coffee at a cafe.
|
|
</p>
|
|
</header>
|
|
|
|
<!-- Selected cafe summary (shown after selection) -->
|
|
<div
|
|
class="mt-4 rounded-lg border border-amber-300 bg-amber-100/80 px-4 py-3 shadow-sm flex items-center justify-between"
|
|
data-show="$_cafeName && !$_reviewingCafe"
|
|
style="display: none"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
{% call icons::location("h-4 w-4 text-amber-600 shrink-0") %}
|
|
<span class="font-medium text-amber-800" data-text="$_cafeName"></span>
|
|
<span class="text-xs text-stone-500" data-show="$_cafeCity" data-text="$_cafeCity" style="display: none"></span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-on:click="$_cafeId = ''; $_cafeName = ''; $_step = 1"
|
|
class="text-xs text-stone-500 hover:text-stone-700"
|
|
>Change</button>
|
|
</div>
|
|
|
|
<!-- Selected roast summary (shown after selection) -->
|
|
<div
|
|
class="mt-2 rounded-lg border border-amber-300 bg-amber-100/80 px-4 py-3 shadow-sm flex items-center justify-between"
|
|
data-show="$_roastName && $_step > 2"
|
|
style="display: none"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
{% call icons::bag("h-4 w-4 text-amber-600 shrink-0") %}
|
|
<span class="font-medium text-amber-800" data-text="$_roastName"></span>
|
|
<span class="text-xs text-stone-500" data-show="$_roasterName" data-text="$_roasterName" style="display: none"></span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-on:click="$_roastId = ''; $_roastName = ''; $_roasterName = ''; $_scanSuccess = ''; $_step = 2"
|
|
class="text-xs text-stone-500 hover:text-stone-700"
|
|
>Change</button>
|
|
</div>
|
|
|
|
<!-- Step indicators -->
|
|
<div class="mt-4 flex gap-2 text-sm">
|
|
<span
|
|
class="rounded-full px-3 py-1 font-medium"
|
|
data-class:bg-amber-600="$_step === 1"
|
|
data-class:text-white="$_step === 1"
|
|
data-class:bg-amber-200="$_step > 1"
|
|
data-class:text-amber-800="$_step > 1"
|
|
>1. Cafe</span>
|
|
<span
|
|
class="rounded-full px-3 py-1 font-medium"
|
|
data-class:bg-amber-600="$_step === 2"
|
|
data-class:text-white="$_step === 2"
|
|
data-class:bg-amber-200="$_step > 2"
|
|
data-class:text-amber-800="$_step > 2"
|
|
data-class:bg-stone-200="$_step < 2"
|
|
data-class:text-stone-500="$_step < 2"
|
|
>2. Coffee</span>
|
|
<span
|
|
class="rounded-full px-3 py-1 font-medium"
|
|
data-class:bg-amber-600="$_step === 3"
|
|
data-class:text-white="$_step === 3"
|
|
data-class:bg-stone-200="$_step < 3"
|
|
data-class:text-stone-500="$_step < 3"
|
|
>3. Rate</span>
|
|
</div>
|
|
|
|
<!-- Error display -->
|
|
<p
|
|
class="mt-3 text-sm text-red-600"
|
|
data-show="$_error"
|
|
style="display: none"
|
|
data-text="$_error"
|
|
></p>
|
|
|
|
<!-- Step 1: Cafe selection -->
|
|
<div class="mt-4" data-show="$_step === 1" style="display: none">
|
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
|
<div class="flex flex-wrap items-center gap-3 mb-4" data-show="!$_reviewingCafe">
|
|
<button
|
|
type="button"
|
|
data-on:click="locateUser()"
|
|
data-show="!$_searchByCity"
|
|
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-4 py-2.5 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
|
data-attr:disabled="$_locating || $_locationFound"
|
|
>
|
|
{% call icons::location("h-4 w-4") %}
|
|
<span data-text="$_locating ? 'Locating\u2026' : $_locationFound ? 'Location found' : 'Use My Location'">Use My Location</span>
|
|
</button>
|
|
<div data-show="!$_searchByCity && $_locating" style="display: none">
|
|
{% call icons::spinner("h-5 w-5") %}
|
|
</div>
|
|
<input
|
|
type="text"
|
|
data-bind:_city-name
|
|
data-show="$_searchByCity"
|
|
style="display: none"
|
|
class="input-field flex-1 min-w-[200px] text-sm"
|
|
placeholder="City name…"
|
|
data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
|
|
/>
|
|
<label class="flex items-center gap-1.5 text-xs text-stone-500 cursor-pointer select-none">
|
|
<input type="checkbox" data-bind:_search-by-city class="accent-amber-600" />
|
|
Search by city
|
|
</label>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
data-bind:_cafe-search
|
|
data-show="!$_reviewingCafe"
|
|
class="input-field w-full text-sm"
|
|
placeholder="Search for a cafe…"
|
|
data-on:input__debounce.350ms="if ($_searchByCity) { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_locationFound && @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }"
|
|
/>
|
|
|
|
<!-- Nearby results (replaced by server fragment) -->
|
|
<div data-show="!$_reviewingCafe" id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div>
|
|
|
|
<!-- Review form for new cafe from Foursquare -->
|
|
<div
|
|
class="mt-3 border-t border-amber-200 pt-4"
|
|
data-show="$_reviewingCafe"
|
|
style="display: none"
|
|
>
|
|
<h3 class="text-base font-semibold text-amber-700 mb-1">Confirm cafe details</h3>
|
|
<p class="text-sm text-stone-600 mb-3">Review and edit the details before saving.</p>
|
|
<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" data-bind:_cafe-name required class="input-field" />
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">City</span>
|
|
<input type="text" data-bind:_cafe-city class="input-field" />
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Country</span>
|
|
<input type="text" data-bind:_cafe-country class="input-field" />
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Website</span>
|
|
<input type="url" data-bind:_cafe-website class="input-field" placeholder="https://…" />
|
|
</label>
|
|
</div>
|
|
<div class="mt-4 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="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
|
data-on:click="$_reviewingCafe = false; $_step = 2"
|
|
>
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Saved cafes -->
|
|
{% if !cafe_options.is_empty() %}
|
|
<div class="mt-3 border-t border-amber-200 pt-3" data-show="!$_reviewingCafe">
|
|
<p class="mb-2 text-xs text-stone-500">Or choose a saved cafe:</p>
|
|
<select
|
|
class="input-field w-full text-sm"
|
|
data-on:change="if (el.value) { const opt = el.options[el.selectedIndex]; $_cafeId = el.value; $_cafeName = opt.dataset.name || ''; $_cafeCity = opt.dataset.city || ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2 }"
|
|
>
|
|
<option value="">Select a cafe…</option>
|
|
{% for cafe in cafe_options %}
|
|
<option value="{{ cafe.id }}" data-name="{{ cafe.name }}" data-city="{{ cafe.city }}">{{ cafe.label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Roast identification -->
|
|
<div class="mt-4" data-show="$_step === 2" style="display: none">
|
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
|
<h3 class="text-base font-semibold text-amber-700 mb-3">What are you drinking?</h3>
|
|
|
|
<div data-show="!$_scanWaiting && !$_scanSuccess" style="display: none" class="mb-4">
|
|
<p class="text-sm text-stone-600 mb-3">Scan a bag to identify the coffee, or select from your existing roasts below.</p>
|
|
|
|
<input type="file" id="checkin-photo" accept="image/*" capture="environment" class="hidden"
|
|
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('checkin-image').value=r.result;document.getElementById('checkin-scan-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
|
|
|
|
<div id="scan-submit-result"></div>
|
|
|
|
<form id="checkin-scan-form"
|
|
data-on:submit="$_scanWaiting = true; $_error = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
|
data-on:datastar-fetch="if (!$_scanWaiting) return; if (evt.detail.type === 'finished') { $_scanWaiting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_scanWaiting = false; $_error = 'Scan failed. Please try again.' }"
|
|
>
|
|
<input type="hidden" name="image" id="checkin-image" />
|
|
<input type="hidden" name="prompt" id="checkin-prompt-hidden" />
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onclick="document.getElementById('checkin-photo').click()"
|
|
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-4 py-2.5 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
|
>
|
|
{% call icons::camera("h-5 w-5") %}
|
|
Scan Bag
|
|
</button>
|
|
<span class="text-xs text-stone-400">or</span>
|
|
<div class="flex flex-1 min-w-[200px] gap-2">
|
|
<input
|
|
type="text"
|
|
id="checkin-prompt-text"
|
|
class="input-field w-full text-sm"
|
|
placeholder="Describe the coffee…"
|
|
onkeydown="if(event.key==='Enter'){event.preventDefault();document.getElementById('checkin-prompt-hidden').value=this.value;document.getElementById('checkin-scan-form').requestSubmit()}"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onclick="document.getElementById('checkin-prompt-hidden').value=document.getElementById('checkin-prompt-text').value;document.getElementById('checkin-scan-form').requestSubmit()"
|
|
class="rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
|
>
|
|
Go
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div
|
|
class="flex items-center gap-3 text-sm text-amber-700 mb-4"
|
|
data-show="$_scanWaiting"
|
|
style="display: none"
|
|
>
|
|
{% call icons::spinner("h-5 w-5") %}
|
|
Scanning & saving…
|
|
</div>
|
|
|
|
<div
|
|
class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800"
|
|
data-show="$_scanSuccess"
|
|
style="display: none"
|
|
>
|
|
Scanned: <span class="font-medium" data-text="$_scanSuccess"></span>
|
|
</div>
|
|
|
|
<div class="border-t border-amber-200 pt-3">
|
|
<p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p>
|
|
<select
|
|
class="input-field w-full text-sm"
|
|
data-on:change="$_roastId = el.value; $_roastName = el.options[el.selectedIndex].dataset.name || ''; $_roasterName = el.options[el.selectedIndex].dataset.roaster || ''; el.value && ($_step = 3)"
|
|
>
|
|
<option value="">Select a roast…</option>
|
|
{% for roast in roast_options %}
|
|
<option value="{{ roast.id }}" data-name="{{ roast.name }}" data-roaster="{{ roast.roaster_name }}">{{ roast.label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Rating + submit -->
|
|
<div class="mt-4" data-show="$_step === 3" style="display: none">
|
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
|
<h3 class="text-base font-semibold text-amber-700 mb-3">How was it? (optional)</h3>
|
|
<div class="flex gap-1 mb-6">
|
|
{% for i in 1..=5 %}
|
|
<button
|
|
type="button"
|
|
class="transition hover:text-amber-400"
|
|
data-on:click="$_rating = {{ i }}"
|
|
data-class:text-amber-500="$_rating >= {{ i }}"
|
|
data-class:text-stone-300="$_rating < {{ i }}"
|
|
aria-label="Rate {{ i }} stars"
|
|
>
|
|
{% call icons::star("h-8 w-8") %}
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<form
|
|
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
|
|
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'finished') { window.location.href = '/' } else if (evt.detail.type === 'error') { $_submitting = false; $_error = 'Check-in failed. Please try again.' }"
|
|
>
|
|
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
|
<input type="hidden" name="cafe_name" data-attr:value="$_cafeName" />
|
|
<input type="hidden" name="cafe_city" data-attr:value="$_cafeCity" />
|
|
<input type="hidden" name="cafe_country" data-attr:value="$_cafeCountry" />
|
|
<input type="hidden" name="cafe_lat" data-attr:value="$_cafeLat" />
|
|
<input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" />
|
|
<input type="hidden" name="cafe_website" data-attr:value="$_cafeWebsite" />
|
|
<input type="hidden" name="roast_id" data-attr:value="$_roastId" />
|
|
<input type="hidden" name="rating" data-attr:value="$_rating" />
|
|
<button
|
|
type="submit"
|
|
class="w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50"
|
|
data-attr:disabled="$_submitting"
|
|
>
|
|
Check In
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{% endblock %}
|