diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index c3aaced..cfe620d 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -15,9 +15,11 @@ use crate::application::server::AppState; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::ids::CafeId; use crate::domain::listing::{ListRequest, SortDirection}; -use crate::infrastructure::foursquare::{self, NearbyCafe}; -use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate}; -use crate::presentation::web::views::{CafeView, ListNavigator, Paginated}; +use crate::infrastructure::foursquare; +use crate::presentation::web::templates::{ + CafeDetailTemplate, CafeListTemplate, CafesTemplate, NearbyCafesFragment, +}; +use crate::presentation::web::views::{CafeView, ListNavigator, NearbyCafeView, Paginated}; const CAFE_PAGE_PATH: &str = "/cafes"; const CAFE_FRAGMENT_PATH: &str = "/cafes#cafe-list"; @@ -194,12 +196,13 @@ pub struct NearbyQuery { near: Option, } -#[tracing::instrument(skip(state, _auth_user))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn nearby_cafes( State(state): State, _auth_user: AuthenticatedUser, + headers: HeaderMap, Query(query): Query, -) -> Result>, ApiError> { +) -> Result { let q = query.q.trim(); if q.is_empty() || q.len() < 2 { return Err(AppError::validation("q must be at least 2 characters").into()); @@ -238,5 +241,13 @@ pub(crate) async fn nearby_cafes( ) .await .map_err(ApiError::from)?; - Ok(Json(cafes)) + + if is_datastar_request(&headers) { + let views: Vec = 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()) + } } diff --git a/src/application/routes/checkin.rs b/src/application/routes/checkin.rs index 5bb9457..e4e2bdb 100644 --- a/src/application/routes/checkin.rs +++ b/src/application/routes/checkin.rs @@ -1,11 +1,19 @@ +use axum::Json; use axum::extract::State; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; 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::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::domain::cafes::NewCafe; +use crate::domain::cups::NewCup; +use crate::domain::ids::{CafeId, RoastId}; use crate::presentation::web::templates::CheckInTemplate; #[tracing::instrument(skip(state, cookies))] @@ -32,3 +40,96 @@ pub(crate) async fn checkin_page( render_html(template).map(IntoResponse::into_response) } + +#[derive(Debug, Deserialize)] +pub(crate) struct CheckInSubmission { + #[serde(default)] + cafe_id: Option, + #[serde(default)] + cafe_name: Option, + #[serde(default)] + cafe_city: Option, + #[serde(default)] + cafe_country: Option, + #[serde(default)] + cafe_lat: f64, + #[serde(default)] + cafe_lng: f64, + #[serde(default)] + cafe_website: Option, + roast_id: String, + #[serde(default)] + rating: i32, +} + +#[tracing::instrument(skip(state, _auth_user, headers, payload))] +pub(crate) async fn submit_checkin( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + payload: FlexiblePayload, +) -> Result { + 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()) + } +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 2a6cd7d..5ec646b 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -82,6 +82,7 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/extract-roast", post(roasts::extract_roast_info)) .route("/extract-bag-scan", post(scan::extract_bag_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/:id", @@ -116,8 +117,6 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/check-in", get(checkin::checkin_page)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) - .route("/extract.js", get(extract_js)) - .route("/checkin.js", get(checkin_js)) .route("/favicon.ico", get(favicon)) .nest("/api/v1", api_routes) .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 { ( [("content-type", "image/x-icon")], diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 8af3164..a117fb7 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -2,7 +2,7 @@ use askama::Template; use super::views::{ 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, }; use crate::domain::bags::BagSortKey; @@ -230,6 +230,12 @@ pub struct CheckInTemplate { pub cafe_options: Vec, } +#[derive(Template)] +#[template(path = "partials/nearby_cafes.html")] +pub struct NearbyCafesFragment { + pub cafes: Vec, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/src/presentation/web/views/cafes.rs b/src/presentation/web/views/cafes.rs index db8c09a..f390632 100644 --- a/src/presentation/web/views/cafes.rs +++ b/src/presentation/web/views/cafes.rs @@ -1,4 +1,5 @@ use crate::domain::cafes::Cafe; +use crate::infrastructure::foursquare::NearbyCafe; pub struct CafeView { pub id: String, @@ -70,3 +71,41 @@ impl From 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 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::>() + .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, + } + } +} diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index da91c3e..5b99925 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -9,7 +9,7 @@ mod timeline; pub use bags::{BagOptionView, BagView}; pub use brews::{BrewDefaultsView, BrewView}; -pub use cafes::{CafeOptionView, CafeView}; +pub use cafes::{CafeOptionView, CafeView, NearbyCafeView}; pub use cups::CupView; pub use gear::{GearOptionView, GearView}; pub use roasters::{RoasterOptionView, RoasterView}; diff --git a/templates/checkin.html b/templates/checkin.html index 2e201d6..940c42c 100644 --- a/templates/checkin.html +++ b/templates/checkin.html @@ -1,9 +1,35 @@ {% extends "base.html" %} {% block title %}Brewlog ยท Check In{% endblock %} {% block head %} - -{% if has_ai_extract %} - +{% if has_foursquare %} + {% endif %} {% endblock %} @@ -18,6 +44,7 @@ data-signals:_cafe-lat="0" data-signals:_cafe-lng="0" data-signals:_cafe-website="''" + data-signals:_cafe-search="''" data-signals:_roast-id="''" data-signals:_rating="0" data-signals:_error="''" @@ -28,16 +55,11 @@ data-signals:_user-lng="0" data-signals:_scan-waiting="false" 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" - data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; searchNearbyCafes('coffee', evt.detail.lat, evt.detail.lng)" + {% if has_foursquare %} + 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: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:scan-start="$_scanWaiting = true" - data-on:submit-start="$_submitting = true" - data-on:submit-error="$_submitting = false" + {% endif %} >

Check In

@@ -107,7 +129,7 @@
{% endfor %} -
{% endif %} @@ -165,44 +190,51 @@ {% if has_ai_extract %} diff --git a/templates/partials/nearby_cafes.html b/templates/partials/nearby_cafes.html new file mode 100644 index 0000000..7420d84 --- /dev/null +++ b/templates/partials/nearby_cafes.html @@ -0,0 +1,23 @@ +
+ {% if cafes.is_empty() %} +

No nearby cafes found.

+ {% else %} +

Nearby

+ {% for cafe in cafes %} + + {% endfor %} + {% endif %} +