feat(checkin): Datastar-native check-in and nearby cafe search

- Add POST /api/v1/check-in endpoint with CheckInSubmission
- nearby_cafes returns HTML fragment for Datastar, JSON for API
- Add NearbyCafeView and NearbyCafesFragment for server-rendered results
- Rewrite checkin.html: replace checkin.js with Datastar signals,
  @post for submission, @get for nearby search, inline geolocation JS
- Cafe filtering uses data-show with signal-based search
This commit is contained in:
Jon Seager 2026-02-04 14:36:55 +00:00
parent bc30555447
commit e4a304b406
No known key found for this signature in database
8 changed files with 299 additions and 86 deletions

View file

@ -15,9 +15,11 @@ use crate::application::server::AppState;
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
use crate::domain::ids::CafeId; use crate::domain::ids::CafeId;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::infrastructure::foursquare::{self, NearbyCafe}; use crate::infrastructure::foursquare;
use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate}; use crate::presentation::web::templates::{
use crate::presentation::web::views::{CafeView, ListNavigator, Paginated}; CafeDetailTemplate, CafeListTemplate, CafesTemplate, NearbyCafesFragment,
};
use crate::presentation::web::views::{CafeView, ListNavigator, NearbyCafeView, Paginated};
const CAFE_PAGE_PATH: &str = "/cafes"; const CAFE_PAGE_PATH: &str = "/cafes";
const CAFE_FRAGMENT_PATH: &str = "/cafes#cafe-list"; const CAFE_FRAGMENT_PATH: &str = "/cafes#cafe-list";
@ -194,12 +196,13 @@ pub struct NearbyQuery {
near: Option<String>, near: Option<String>,
} }
#[tracing::instrument(skip(state, _auth_user))] #[tracing::instrument(skip(state, _auth_user, headers))]
pub(crate) async fn nearby_cafes( pub(crate) async fn nearby_cafes(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<NearbyQuery>, Query(query): Query<NearbyQuery>,
) -> Result<Json<Vec<NearbyCafe>>, ApiError> { ) -> Result<Response, ApiError> {
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());
@ -238,5 +241,13 @@ pub(crate) async fn nearby_cafes(
) )
.await .await
.map_err(ApiError::from)?; .map_err(ApiError::from)?;
Ok(Json(cafes))
if is_datastar_request(&headers) {
let views: Vec<NearbyCafeView> = cafes.into_iter().map(NearbyCafeView::from).collect();
let template = NearbyCafesFragment { cafes: views };
crate::application::routes::support::render_fragment(template, "#nearby-results")
.map_err(ApiError::from)
} else {
Ok(Json(cafes).into_response())
}
} }

View file

@ -1,11 +1,19 @@
use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize;
use crate::application::errors::map_app_error; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::{load_cafe_options, load_roast_options}; use crate::application::routes::support::{
FlexiblePayload, PayloadSource, is_datastar_request, load_cafe_options, load_roast_options,
};
use crate::application::server::AppState; use crate::application::server::AppState;
use crate::domain::cafes::NewCafe;
use crate::domain::cups::NewCup;
use crate::domain::ids::{CafeId, RoastId};
use crate::presentation::web::templates::CheckInTemplate; use crate::presentation::web::templates::CheckInTemplate;
#[tracing::instrument(skip(state, cookies))] #[tracing::instrument(skip(state, cookies))]
@ -32,3 +40,96 @@ pub(crate) async fn checkin_page(
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)
} }
#[derive(Debug, Deserialize)]
pub(crate) struct CheckInSubmission {
#[serde(default)]
cafe_id: Option<String>,
#[serde(default)]
cafe_name: Option<String>,
#[serde(default)]
cafe_city: Option<String>,
#[serde(default)]
cafe_country: Option<String>,
#[serde(default)]
cafe_lat: f64,
#[serde(default)]
cafe_lng: f64,
#[serde(default)]
cafe_website: Option<String>,
roast_id: String,
#[serde(default)]
rating: i32,
}
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn submit_checkin(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
payload: FlexiblePayload<CheckInSubmission>,
) -> Result<Response, ApiError> {
let (submission, source) = payload.into_parts();
let roast_id: i64 = submission
.roast_id
.parse()
.map_err(|_| AppError::validation("invalid roast ID"))?;
// Use existing cafe or create a new one
let cafe_id = if let Some(id) = submission.cafe_id.as_deref().filter(|s| !s.is_empty()) {
let parsed: i64 = id
.parse()
.map_err(|_| AppError::validation("invalid cafe ID"))?;
CafeId::from(parsed)
} else {
let name = submission
.cafe_name
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| AppError::validation("cafe name is required"))?;
let new_cafe = NewCafe {
name: name.to_string(),
city: submission.cafe_city.unwrap_or_default(),
country: submission.cafe_country.unwrap_or_default(),
latitude: submission.cafe_lat,
longitude: submission.cafe_lng,
website: submission.cafe_website.filter(|s| !s.is_empty()),
}
.normalize();
let cafe = state
.cafe_repo
.insert(new_cafe)
.await
.map_err(AppError::from)?;
cafe.id
};
let rating = if (1..=5).contains(&submission.rating) {
Some(submission.rating)
} else {
None
};
let new_cup = NewCup {
roast_id: RoastId::from(roast_id),
cafe_id,
rating,
};
let cup = state
.cup_repo
.insert(new_cup)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
crate::application::routes::support::render_signals_json(&[]).map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to("/").into_response())
} else {
Ok((StatusCode::CREATED, Json(cup)).into_response())
}
}

View file

@ -82,6 +82,7 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/extract-roast", post(roasts::extract_roast_info)) .route("/extract-roast", post(roasts::extract_roast_info))
.route("/extract-bag-scan", post(scan::extract_bag_scan)) .route("/extract-bag-scan", post(scan::extract_bag_scan))
.route("/scan", post(scan::submit_scan)) .route("/scan", post(scan::submit_scan))
.route("/check-in", post(checkin::submit_checkin))
.route("/cups", get(cups::list_cups).post(cups::create_cup)) .route("/cups", get(cups::list_cups).post(cups::create_cup))
.route( .route(
"/cups/:id", "/cups/:id",
@ -116,8 +117,6 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/check-in", get(checkin::checkin_page)) .route("/check-in", get(checkin::checkin_page))
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))
.route("/extract.js", get(extract_js))
.route("/checkin.js", get(checkin_js))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.nest("/api/v1", api_routes) .nest("/api/v1", api_routes)
.layer(ServiceBuilder::new().layer(CookieManagerLayer::new())) .layer(ServiceBuilder::new().layer(CookieManagerLayer::new()))
@ -135,20 +134,6 @@ async fn styles() -> impl IntoResponse {
) )
} }
async fn extract_js() -> impl IntoResponse {
(
[("content-type", "application/javascript; charset=utf-8")],
include_str!("../../../templates/extract.js"),
)
}
async fn checkin_js() -> impl IntoResponse {
(
[("content-type", "application/javascript; charset=utf-8")],
include_str!("../../../templates/checkin.js"),
)
}
async fn favicon() -> impl IntoResponse { async fn favicon() -> impl IntoResponse {
( (
[("content-type", "image/x-icon")], [("content-type", "image/x-icon")],

View file

@ -2,7 +2,7 @@ use askama::Template;
use super::views::{ use super::views::{
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView, BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView,
GearOptionView, GearView, ListNavigator, Paginated, RoastOptionView, RoastView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, RoastOptionView, RoastView,
RoasterOptionView, RoasterView, StatsView, TimelineEventView, TimelineMonthView, RoasterOptionView, RoasterView, StatsView, TimelineEventView, TimelineMonthView,
}; };
use crate::domain::bags::BagSortKey; use crate::domain::bags::BagSortKey;
@ -230,6 +230,12 @@ pub struct CheckInTemplate {
pub cafe_options: Vec<CafeOptionView>, pub cafe_options: Vec<CafeOptionView>,
} }
#[derive(Template)]
#[template(path = "partials/nearby_cafes.html")]
pub struct NearbyCafesFragment {
pub cafes: Vec<NearbyCafeView>,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> { pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
template.render() template.render()
} }

View file

@ -1,4 +1,5 @@
use crate::domain::cafes::Cafe; use crate::domain::cafes::Cafe;
use crate::infrastructure::foursquare::NearbyCafe;
pub struct CafeView { pub struct CafeView {
pub id: String, pub id: String,
@ -70,3 +71,41 @@ impl From<Cafe> for CafeOptionView {
} }
} }
} }
pub struct NearbyCafeView {
pub name: String,
pub city: String,
pub country: String,
pub latitude: f64,
pub longitude: f64,
pub website: String,
pub distance: String,
pub location: String,
}
impl From<NearbyCafe> for NearbyCafeView {
fn from(cafe: NearbyCafe) -> Self {
let distance = if cafe.distance_meters < 1000 {
format!("{} m", cafe.distance_meters)
} else {
format!("{:.1} km", f64::from(cafe.distance_meters) / 1000.0)
};
let location = [&cafe.city, &cafe.country]
.iter()
.filter(|s| !s.is_empty())
.copied()
.cloned()
.collect::<Vec<_>>()
.join(", ");
Self {
name: cafe.name,
city: cafe.city,
country: cafe.country,
latitude: cafe.latitude,
longitude: cafe.longitude,
website: cafe.website.unwrap_or_default(),
distance,
location,
}
}
}

View file

@ -9,7 +9,7 @@ mod timeline;
pub use bags::{BagOptionView, BagView}; pub use bags::{BagOptionView, BagView};
pub use brews::{BrewDefaultsView, BrewView}; pub use brews::{BrewDefaultsView, BrewView};
pub use cafes::{CafeOptionView, CafeView}; pub use cafes::{CafeOptionView, CafeView, NearbyCafeView};
pub use cups::CupView; pub use cups::CupView;
pub use gear::{GearOptionView, GearView}; pub use gear::{GearOptionView, GearView};
pub use roasters::{RoasterOptionView, RoasterView}; pub use roasters::{RoasterOptionView, RoasterView};

View file

@ -1,9 +1,35 @@
{% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %} {% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %}
{% block head %} {% block head %}
<script src="/checkin.js"></script> {% if has_foursquare %}
{% if has_ai_extract %} <script>
<script src="/extract.js"></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>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -18,6 +44,7 @@
data-signals:_cafe-lat="0" data-signals:_cafe-lat="0"
data-signals:_cafe-lng="0" data-signals:_cafe-lng="0"
data-signals:_cafe-website="''" data-signals:_cafe-website="''"
data-signals:_cafe-search="''"
data-signals:_roast-id="''" data-signals:_roast-id="''"
data-signals:_rating="0" data-signals:_rating="0"
data-signals:_error="''" data-signals:_error="''"
@ -28,16 +55,11 @@
data-signals:_user-lng="0" data-signals:_user-lng="0"
data-signals:_scan-waiting="false" data-signals:_scan-waiting="false"
data-signals:_scan-success="''" data-signals:_scan-success="''"
data-on:cafe-selected="$_cafeId = String(evt.detail.id); $_cafeName = evt.detail.name; $_cafeCity = evt.detail.city || ''; $_cafeCountry = evt.detail.country || ''; $_cafeLat = evt.detail.lat || 0; $_cafeLng = evt.detail.lng || 0; $_cafeWebsite = evt.detail.website || ''; $_step = 2" {% if has_foursquare %}
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; searchNearbyCafes('coffee', evt.detail.lat, evt.detail.lng)" 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-error="$_locating = false; $_error = evt.detail.message"
data-on:scan-complete="$_scanWaiting = false; $_roastId = evt.detail.roastId; $_scanSuccess = evt.detail.name; $_step = 3"
data-on:scan-error="$_scanWaiting = false; $_error = evt.detail.message"
data-on:checkin-error="$_error = evt.detail.message"
data-on:location-start="$_locating = true" data-on:location-start="$_locating = true"
data-on:scan-start="$_scanWaiting = true" {% endif %}
data-on:submit-start="$_submitting = true"
data-on:submit-error="$_submitting = false"
> >
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Check In</h1> <h1 class="text-3xl font-semibold">Check In</h1>
@ -107,7 +129,7 @@
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<button <button
type="button" type="button"
onclick="locateUser()" data-on:click="locateUser()"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-2.5 text-sm font-medium text-amber-800 transition hover:bg-amber-100" class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-2.5 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
data-attr:disabled="$_locating || $_locationFound" data-attr:disabled="$_locating || $_locationFound"
> >
@ -127,31 +149,34 @@
<input <input
type="text" type="text"
id="cafe-search" data-bind:_cafe-search
class="input-field w-full text-sm" class="input-field w-full text-sm"
placeholder="Search for a cafe&hellip;" placeholder="Search for a cafe&hellip;"
data-on:input="filterExistingCafes(el.value)" {% if has_foursquare %}
data-on:input__debounce.350ms="$_locationFound && searchNearbyCafes(el.value, $_userLat, $_userLng)" data-on:input__debounce.350ms="$_locationFound && @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
{% endif %}
/> />
<!-- Foursquare results --> {% if has_foursquare %}
<!-- Foursquare results (replaced by server fragment) -->
<div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div> <div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div>
{% endif %}
<!-- Existing cafes --> <!-- Existing cafes -->
{% if !cafe_options.is_empty() %} {% if !cafe_options.is_empty() %}
<div id="existing-cafes" class="mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"> <div class="mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white">
<h3 class="px-3 py-2 text-xs font-semibold text-stone-500 uppercase tracking-wide">Saved Cafes</h3> <h3 class="px-3 py-2 text-xs font-semibold text-stone-500 uppercase tracking-wide">Saved Cafes</h3>
{% for cafe in cafe_options %} {% for cafe in cafe_options %}
<button <button
type="button" type="button"
data-cafe-name="{{ cafe.label }}" data-cafe-name="{{ cafe.label }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition"
onclick="emit('cafe-selected', { id: '{{ cafe.id }}', name: this.dataset.cafeName, city: '', country: '', lat: 0, lng: 0, website: '' })" data-show="!$_cafeSearch || el.dataset.cafeName.toLowerCase().includes($_cafeSearch.toLowerCase())"
data-on:click="$_cafeId = '{{ cafe.id }}'; $_cafeName = el.dataset.cafeName; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2"
> >
{{ cafe.label }} {{ cafe.label }}
</button> </button>
{% endfor %} {% endfor %}
<p data-no-match class="px-3 py-2 text-sm text-stone-500" style="display: none">No matching cafes.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -165,10 +190,23 @@
{% if has_ai_extract %} {% if has_ai_extract %}
<div data-show="!$_scanWaiting && !$_scanSuccess" style="display: none" class="mb-4"> <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> <p class="text-sm text-stone-600 mb-3">Scan a bag to identify the coffee, or select from your existing roasts below.</p>
<div id="checkin-extract-controls" class="flex flex-wrap items-center gap-3">
<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 (evt.detail.type === 'finished') { $_scanWaiting = false; $_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 <button
type="button" type="button"
onclick="triggerPhotoExtract('checkin', '/api/v1/extract-bag-scan', onScanExtracted)" onclick="document.getElementById('checkin-photo').click()"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-2.5 text-sm font-medium text-amber-800 transition hover:bg-amber-100" class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-2.5 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
> >
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@ -180,29 +218,23 @@
<div class="flex flex-1 min-w-[200px] gap-2"> <div class="flex flex-1 min-w-[200px] gap-2">
<input <input
type="text" type="text"
id="checkin-extract-text" id="checkin-prompt-text"
class="input-field w-full text-sm" class="input-field w-full text-sm"
placeholder="Describe the coffee&hellip;" placeholder="Describe the coffee&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('checkin','/api/v1/extract-bag-scan',onScanExtracted)}" onkeydown="if(event.key==='Enter'){event.preventDefault();document.getElementById('checkin-prompt-hidden').value=this.value;document.getElementById('checkin-scan-form').requestSubmit()}"
/> />
<button <button
type="button" type="button"
onclick="extractFromText('checkin', '/api/v1/extract-bag-scan', onScanExtracted)" 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-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100" class="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"
> >
Go Go
</button> </button>
</div> </div>
</div> </div>
<div id="checkin-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700 mt-2"> </form>
<svg class="h-5 w-5 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>
Waiting for response&hellip;
</div>
<p id="checkin-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
</div> </div>
<div <div
class="flex items-center gap-3 text-sm text-amber-700 mb-4" class="flex items-center gap-3 text-sm text-amber-700 mb-4"
data-show="$_scanWaiting" data-show="$_scanWaiting"
@ -212,8 +244,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
Saving roaster &amp; roast&hellip; Scanning &amp; saving&hellip;
</div> </div>
<div <div
class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800" class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800"
data-show="$_scanSuccess" data-show="$_scanSuccess"
@ -221,6 +254,7 @@
> >
Scanned: <span class="font-medium" data-text="$_scanSuccess"></span> Scanned: <span class="font-medium" data-text="$_scanSuccess"></span>
</div> </div>
<div class="border-t border-amber-200 pt-3"> <div class="border-t border-amber-200 pt-3">
<p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p> <p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p>
{% else %} {% else %}
@ -259,14 +293,28 @@
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
<form
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
data-on:datastar-fetch="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 <button
type="button" type="submit"
onclick="submitCheckIn()"
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" 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" data-attr:disabled="$_submitting"
> >
Check In Check In
</button> </button>
</form>
</div> </div>
</div> </div>
</section> </section>

View file

@ -0,0 +1,23 @@
<div id="nearby-results" class="mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white">
{% if cafes.is_empty() %}
<p class="px-3 py-2 text-sm text-stone-500">No nearby cafes found.</p>
{% else %}
<h3 class="px-3 py-2 text-xs font-semibold text-stone-500 uppercase tracking-wide">Nearby</h3>
{% for cafe in cafes %}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition"
data-cafe-name="{{ cafe.name }}"
data-cafe-city="{{ cafe.city }}"
data-cafe-country="{{ cafe.country }}"
data-cafe-lat="{{ cafe.latitude }}"
data-cafe-lng="{{ cafe.longitude }}"
data-cafe-website="{{ cafe.website }}"
data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_step = 2"
>
<span class="font-medium text-amber-900">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ cafe.location }} &middot; {{ cafe.distance }}</span>
</button>
{% endfor %}
{% endif %}
</div>