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:
parent
bc30555447
commit
e4a304b406
8 changed files with 299 additions and 86 deletions
|
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")],
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
placeholder="Search for a cafe…"
|
||||||
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,44 +190,51 @@
|
||||||
{% 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">
|
|
||||||
<button
|
<input type="file" id="checkin-photo" accept="image/*" capture="environment" class="hidden"
|
||||||
type="button"
|
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=''}" />
|
||||||
onclick="triggerPhotoExtract('checkin', '/api/v1/extract-bag-scan', onScanExtracted)"
|
|
||||||
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"
|
<div id="scan-submit-result"></div>
|
||||||
>
|
|
||||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<form id="checkin-scan-form"
|
||||||
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
data-on:submit="$_scanWaiting = true; $_error = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||||
</svg>
|
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.' }"
|
||||||
Scan Bag
|
>
|
||||||
</button>
|
<input type="hidden" name="image" id="checkin-image" />
|
||||||
<span class="text-xs text-stone-400">or</span>
|
<input type="hidden" name="prompt" id="checkin-prompt-hidden" />
|
||||||
<div class="flex flex-1 min-w-[200px] gap-2">
|
|
||||||
<input
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
type="text"
|
|
||||||
id="checkin-extract-text"
|
|
||||||
class="input-field w-full text-sm"
|
|
||||||
placeholder="Describe the coffee…"
|
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('checkin','/api/v1/extract-bag-scan',onScanExtracted)}"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="extractFromText('checkin', '/api/v1/extract-bag-scan', onScanExtracted)"
|
onclick="document.getElementById('checkin-photo').click()"
|
||||||
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="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"
|
||||||
>
|
>
|
||||||
Go
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Scan Bag
|
||||||
</button>
|
</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-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
<div id="checkin-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700 mt-2">
|
|
||||||
<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…
|
|
||||||
</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 & roast…
|
Scanning & saving…
|
||||||
</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>
|
||||||
<button
|
|
||||||
type="button"
|
<form
|
||||||
onclick="submitCheckIn()"
|
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
|
||||||
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-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.' }"
|
||||||
data-attr:disabled="$_submitting"
|
|
||||||
>
|
>
|
||||||
Check In
|
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
23
templates/partials/nearby_cafes.html
Normal file
23
templates/partials/nearby_cafes.html
Normal 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 }} · {{ cafe.distance }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Loading…
Reference in a new issue