feat(home): replace scan page with home dashboard and check-in flow
- Add home page at / with scan, last brew, open bags, activity, stats - Add check-in page at /check-in with cafe search + roast scan + rating - Use Datastar signals for check-in UI state (steps, rating, selection) - Bridge async JS (geolocation, fetch) to Datastar via custom events - Replace nav camera icon with house icon (always visible) - Redirect /scan to / for backward compatibility - Delete scan.html, add home.html, checkin.html, checkin.js
This commit is contained in:
parent
c046be4e33
commit
a3aba1b02a
12 changed files with 1223 additions and 251 deletions
34
src/application/routes/checkin.rs
Normal file
34
src/application/routes/checkin.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
|
||||||
|
use crate::application::errors::map_app_error;
|
||||||
|
use crate::application::routes::render_html;
|
||||||
|
use crate::application::routes::support::{load_cafe_options, load_roast_options};
|
||||||
|
use crate::application::server::AppState;
|
||||||
|
use crate::presentation::web::templates::CheckInTemplate;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
|
pub(crate) async fn checkin_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: tower_cookies::Cookies,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||||
|
if !is_authenticated {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let roast_options = load_roast_options(&state).await.map_err(map_app_error)?;
|
||||||
|
let cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?;
|
||||||
|
|
||||||
|
let template = CheckInTemplate {
|
||||||
|
nav_active: "home",
|
||||||
|
is_authenticated: true,
|
||||||
|
has_ai_extract: state.has_ai_extract(),
|
||||||
|
has_foursquare: state.has_foursquare(),
|
||||||
|
roast_options,
|
||||||
|
cafe_options,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
192
src/application/routes/home.rs
Normal file
192
src/application/routes/home.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
use crate::application::errors::{AppError, map_app_error};
|
||||||
|
use crate::application::routes::render_html;
|
||||||
|
use crate::application::server::AppState;
|
||||||
|
use crate::domain::bags::{BagFilter, BagSortKey};
|
||||||
|
use crate::domain::brews::{BrewFilter, BrewSortKey};
|
||||||
|
use crate::domain::cafes::CafeSortKey;
|
||||||
|
use crate::domain::cups::CupFilter;
|
||||||
|
use crate::domain::gear::GearFilter;
|
||||||
|
use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey};
|
||||||
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
|
use crate::domain::roasts::RoastSortKey;
|
||||||
|
use crate::domain::timeline::TimelineSortKey;
|
||||||
|
use crate::presentation::web::templates::HomeTemplate;
|
||||||
|
use crate::presentation::web::views::{BagView, BrewView, StatsView, TimelineEventView};
|
||||||
|
|
||||||
|
#[allow(clippy::similar_names)]
|
||||||
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
|
pub(crate) async fn home_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: tower_cookies::Cookies,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||||
|
|
||||||
|
let (content, stats) =
|
||||||
|
tokio::try_join!(load_home_content(&state), load_stats(&state),).map_err(map_app_error)?;
|
||||||
|
|
||||||
|
let template = HomeTemplate {
|
||||||
|
nav_active: "home",
|
||||||
|
is_authenticated,
|
||||||
|
has_ai_extract: state.has_ai_extract(),
|
||||||
|
has_foursquare: state.has_foursquare(),
|
||||||
|
last_brew: content.last_brew,
|
||||||
|
open_bags: content.open_bags,
|
||||||
|
recent_events: content.recent_events,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeContent {
|
||||||
|
last_brew: Option<BrewView>,
|
||||||
|
open_bags: Vec<BagView>,
|
||||||
|
recent_events: Vec<TimelineEventView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `ListRequest` that fetches page 1 with 1 item, using a sort key's
|
||||||
|
/// defaults. Used to obtain `Page.total` for entity counts.
|
||||||
|
fn count_request<K: SortKey>() -> ListRequest<K> {
|
||||||
|
let key = K::default();
|
||||||
|
ListRequest::new(1, PageSize::limited(1), key, key.default_direction())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
|
||||||
|
let last_brew_req = ListRequest::new(
|
||||||
|
1,
|
||||||
|
PageSize::limited(1),
|
||||||
|
BrewSortKey::CreatedAt,
|
||||||
|
SortDirection::Desc,
|
||||||
|
);
|
||||||
|
let open_bags_req = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
|
||||||
|
let recent_events_req = ListRequest::new(
|
||||||
|
1,
|
||||||
|
PageSize::limited(5),
|
||||||
|
TimelineSortKey::default(),
|
||||||
|
TimelineSortKey::default().default_direction(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (last_brew_page, open_bags_page, recent_events_page) = tokio::try_join!(
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.brew_repo
|
||||||
|
.list(BrewFilter::all(), &last_brew_req, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.bag_repo
|
||||||
|
.list(BagFilter::open(), &open_bags_req, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.timeline_repo
|
||||||
|
.list(&recent_events_req)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let last_brew = last_brew_page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(BrewView::from_domain);
|
||||||
|
|
||||||
|
let open_bags = open_bags_page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(BagView::from_domain)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let recent_events = recent_events_page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(TimelineEventView::from_domain)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(HomeContent {
|
||||||
|
last_brew,
|
||||||
|
open_bags,
|
||||||
|
recent_events,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_stats(state: &AppState) -> Result<StatsView, AppError> {
|
||||||
|
let req_roasters: ListRequest<RoasterSortKey> = count_request();
|
||||||
|
let req_roasts: ListRequest<RoastSortKey> = count_request();
|
||||||
|
let req_bags: ListRequest<BagSortKey> = count_request();
|
||||||
|
let req_brews: ListRequest<BrewSortKey> = count_request();
|
||||||
|
let req_gear: ListRequest<crate::domain::gear::GearSortKey> = count_request();
|
||||||
|
let req_cafes: ListRequest<CafeSortKey> = count_request();
|
||||||
|
let req_cups: ListRequest<crate::domain::cups::CupSortKey> = count_request();
|
||||||
|
|
||||||
|
let (roasters, roasts, bags, brews, gear, cafes, cups) = tokio::try_join!(
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.roaster_repo
|
||||||
|
.list(&req_roasters, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.roast_repo
|
||||||
|
.list(&req_roasts, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.bag_repo
|
||||||
|
.list(BagFilter::all(), &req_bags, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.brew_repo
|
||||||
|
.list(BrewFilter::all(), &req_brews, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.gear_repo
|
||||||
|
.list(GearFilter::all(), &req_gear, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.cafe_repo
|
||||||
|
.list(&req_cafes, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.cup_repo
|
||||||
|
.list(CupFilter::all(), &req_cups, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(StatsView {
|
||||||
|
brews: brews.total,
|
||||||
|
roasts: roasts.total,
|
||||||
|
roasters: roasters.total,
|
||||||
|
cups: cups.total,
|
||||||
|
cafes: cafes.total,
|
||||||
|
bags: bags.total,
|
||||||
|
gear: gear.total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,10 @@ pub mod auth;
|
||||||
pub mod bags;
|
pub mod bags;
|
||||||
pub mod brews;
|
pub mod brews;
|
||||||
pub mod cafes;
|
pub mod cafes;
|
||||||
|
pub mod checkin;
|
||||||
pub mod cups;
|
pub mod cups;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
|
pub mod home;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
|
|
@ -94,7 +96,7 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
.route("/tokens/:id/revoke", post(tokens::revoke_token));
|
.route("/tokens/:id/revoke", post(tokens::revoke_token));
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/", get(root_redirect))
|
.route("/", get(home::home_page))
|
||||||
.route("/login", get(auth::login_page).post(auth::login_submit))
|
.route("/login", get(auth::login_page).post(auth::login_submit))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/roasters", get(roasters::roasters_page))
|
.route("/roasters", get(roasters::roasters_page))
|
||||||
|
|
@ -110,18 +112,20 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
.route("/cafes", get(cafes::cafes_page))
|
.route("/cafes", get(cafes::cafes_page))
|
||||||
.route("/cafes/:slug", get(cafes::cafe_page))
|
.route("/cafes/:slug", get(cafes::cafe_page))
|
||||||
.route("/cups", get(cups::cups_page))
|
.route("/cups", get(cups::cups_page))
|
||||||
.route("/scan", get(scan::scan_page))
|
.route("/scan", get(scan_redirect))
|
||||||
|
.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("/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()))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root_redirect() -> Redirect {
|
async fn scan_redirect() -> Redirect {
|
||||||
Redirect::temporary("/timeline")
|
Redirect::permanent("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn styles() -> impl IntoResponse {
|
async fn styles() -> impl IntoResponse {
|
||||||
|
|
@ -138,6 +142,13 @@ async fn extract_js() -> impl IntoResponse {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")],
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,17 @@
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Redirect, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
use crate::application::routes::render_html;
|
|
||||||
use crate::application::routes::roasts::TastingNotesInput;
|
use crate::application::routes::roasts::TastingNotesInput;
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
use crate::domain::errors::RepositoryError;
|
use crate::domain::errors::RepositoryError;
|
||||||
use crate::domain::roasters::NewRoaster;
|
use crate::domain::roasters::NewRoaster;
|
||||||
use crate::domain::roasts::NewRoast;
|
use crate::domain::roasts::NewRoast;
|
||||||
use crate::infrastructure::ai::{self, ExtractedBagScan, ExtractionInput};
|
use crate::infrastructure::ai::{self, ExtractedBagScan, ExtractionInput};
|
||||||
use crate::presentation::web::templates::ScanTemplate;
|
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, cookies))]
|
|
||||||
pub(crate) async fn scan_page(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
cookies: tower_cookies::Cookies,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
|
||||||
if !is_authenticated || !state.has_ai_extract() {
|
|
||||||
return Ok(Redirect::to("/timeline").into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
let template = ScanTemplate {
|
|
||||||
nav_active: "scan",
|
|
||||||
is_authenticated: true,
|
|
||||||
has_ai_extract: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
pub(crate) async fn extract_bag_scan(
|
pub(crate) async fn extract_bag_scan(
|
||||||
|
|
@ -69,6 +48,7 @@ pub(crate) struct BagScanSubmission {
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ScanResult {
|
struct ScanResult {
|
||||||
redirect: String,
|
redirect: String,
|
||||||
|
roast_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
|
@ -131,5 +111,6 @@ pub(crate) async fn submit_scan(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
||||||
Ok((StatusCode::CREATED, Json(ScanResult { redirect })).into_response())
|
let roast_id = roast.id.into_inner();
|
||||||
|
Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@ impl AppState {
|
||||||
pub fn has_ai_extract(&self) -> bool {
|
pub fn has_ai_extract(&self) -> bool {
|
||||||
self.openrouter_api_key.is_some()
|
self.openrouter_api_key.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_foursquare(&self) -> bool {
|
||||||
|
self.foursquare_api_key.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,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, Paginated, RoastOptionView, RoastView,
|
||||||
RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView,
|
RoasterOptionView, RoasterView, StatsView, TimelineEventView, TimelineMonthView,
|
||||||
};
|
};
|
||||||
use crate::domain::bags::BagSortKey;
|
use crate::domain::bags::BagSortKey;
|
||||||
use crate::domain::brews::BrewSortKey;
|
use crate::domain::brews::BrewSortKey;
|
||||||
|
|
@ -207,11 +207,27 @@ pub struct CupListTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "scan.html")]
|
#[template(path = "home.html")]
|
||||||
pub struct ScanTemplate {
|
pub struct HomeTemplate {
|
||||||
pub nav_active: &'static str,
|
pub nav_active: &'static str,
|
||||||
pub is_authenticated: bool,
|
pub is_authenticated: bool,
|
||||||
pub has_ai_extract: bool,
|
pub has_ai_extract: bool,
|
||||||
|
pub has_foursquare: bool,
|
||||||
|
pub last_brew: Option<BrewView>,
|
||||||
|
pub open_bags: Vec<BagView>,
|
||||||
|
pub recent_events: Vec<TimelineEventView>,
|
||||||
|
pub stats: StatsView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "checkin.html")]
|
||||||
|
pub struct CheckInTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub is_authenticated: bool,
|
||||||
|
pub has_ai_extract: bool,
|
||||||
|
pub has_foursquare: bool,
|
||||||
|
pub roast_options: Vec<RoastOptionView>,
|
||||||
|
pub cafe_options: Vec<CafeOptionView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,16 @@ pub use timeline::{
|
||||||
TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView,
|
TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct StatsView {
|
||||||
|
pub brews: u64,
|
||||||
|
pub roasts: u64,
|
||||||
|
pub roasters: u64,
|
||||||
|
pub cups: u64,
|
||||||
|
pub cafes: u64,
|
||||||
|
pub bags: u64,
|
||||||
|
pub gear: u64,
|
||||||
|
}
|
||||||
|
|
||||||
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
|
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
|
||||||
|
|
||||||
pub struct Paginated<T> {
|
pub struct Paginated<T> {
|
||||||
|
|
|
||||||
273
templates/checkin.html
Normal file
273
templates/checkin.html
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="/checkin.js"></script>
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<script src="/extract.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% 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:_roast-id="''"
|
||||||
|
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:_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)"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 text-amber-600" 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>
|
||||||
|
<span class="font-medium text-amber-800" data-text="$_cafeName"></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>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
{% if has_foursquare %}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="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"
|
||||||
|
data-attr:disabled="$_locating || $_locationFound"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span data-text="$_locating ? 'Locating\u2026' : $_locationFound ? 'Location found' : 'Use My Location'">Use My Location</span>
|
||||||
|
</button>
|
||||||
|
<div data-show="$_locating" style="display: none">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="cafe-search"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Search for a cafe…"
|
||||||
|
data-on:input="filterExistingCafes(el.value)"
|
||||||
|
data-on:input__debounce.350ms="$_locationFound && searchNearbyCafes(el.value, $_userLat, $_userLng)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Foursquare results -->
|
||||||
|
<div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div>
|
||||||
|
|
||||||
|
<!-- Existing cafes -->
|
||||||
|
{% 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">
|
||||||
|
<h3 class="px-3 py-2 text-xs font-semibold text-stone-500 uppercase tracking-wide">Saved Cafes</h3>
|
||||||
|
{% for cafe in cafe_options %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-cafe-name="{{ cafe.label }}"
|
||||||
|
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: '' })"
|
||||||
|
>
|
||||||
|
{{ cafe.label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
<p data-no-match class="px-3 py-2 text-sm text-stone-500" style="display: none">No matching cafes.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Roast identification -->
|
||||||
|
<div 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>
|
||||||
|
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<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>
|
||||||
|
<div id="checkin-extract-controls" class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span class="text-xs text-stone-400">or</span>
|
||||||
|
<div class="flex flex-1 min-w-[200px] gap-2">
|
||||||
|
<input
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
onclick="extractFromText('checkin', '/api/v1/extract-bag-scan', onScanExtracted)"
|
||||||
|
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 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
|
||||||
|
class="flex items-center gap-3 text-sm text-amber-700 mb-4"
|
||||||
|
data-show="$_scanWaiting"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Saving roaster & roast…
|
||||||
|
</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>
|
||||||
|
{% else %}
|
||||||
|
<div>
|
||||||
|
{% endif %}
|
||||||
|
<select
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
data-on:change="$_roastId = el.value; el.value && ($_step = 3)"
|
||||||
|
>
|
||||||
|
<option value="">Select a roast…</option>
|
||||||
|
{% for roast in roast_options %}
|
||||||
|
<option value="{{ roast.id }}">{{ roast.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Rating + submit -->
|
||||||
|
<div 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"
|
||||||
|
>
|
||||||
|
<svg class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
data-attr:disabled="$_submitting"
|
||||||
|
>
|
||||||
|
Check In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
257
templates/checkin.js
Normal file
257
templates/checkin.js
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
// Check-in page — minimal JS for browser APIs and async operations.
|
||||||
|
// All state management and UI logic lives in Datastar signals (checkin.html).
|
||||||
|
// JS dispatches custom events to bridge async results back to Datastar.
|
||||||
|
|
||||||
|
const _root = () => document.getElementById('checkin-root');
|
||||||
|
|
||||||
|
const emit = (name, detail = {}) =>
|
||||||
|
_root().dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
||||||
|
|
||||||
|
// --- State mirror ---
|
||||||
|
// Tracks signal values via event listeners so submitCheckIn() can read them
|
||||||
|
// without accessing Datastar internals.
|
||||||
|
|
||||||
|
let _cafeId = '';
|
||||||
|
let _cafeName = '';
|
||||||
|
let _cafeCity = '';
|
||||||
|
let _cafeCountry = '';
|
||||||
|
let _cafeLat = 0;
|
||||||
|
let _cafeLng = 0;
|
||||||
|
let _cafeWebsite = '';
|
||||||
|
let _roastId = '';
|
||||||
|
let _rating = 0;
|
||||||
|
let _submitting = false;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const root = _root();
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
root.addEventListener('cafe-selected', (e) => {
|
||||||
|
_cafeId = String(e.detail.id);
|
||||||
|
_cafeName = e.detail.name;
|
||||||
|
_cafeCity = e.detail.city || '';
|
||||||
|
_cafeCountry = e.detail.country || '';
|
||||||
|
_cafeLat = e.detail.lat || 0;
|
||||||
|
_cafeLng = e.detail.lng || 0;
|
||||||
|
_cafeWebsite = e.detail.website || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener('scan-complete', (e) => {
|
||||||
|
_roastId = e.detail.roastId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track roast selection from the dropdown
|
||||||
|
root.addEventListener('change', (e) => {
|
||||||
|
if (e.target.tagName === 'SELECT' && e.target.value) {
|
||||||
|
_roastId = e.target.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track rating clicks via aria-label convention
|
||||||
|
root.addEventListener('click', (e) => {
|
||||||
|
const star = e.target.closest('[aria-label^="Rate"]');
|
||||||
|
if (!star) return;
|
||||||
|
const match = star.getAttribute('aria-label')?.match(/Rate (\d)/);
|
||||||
|
if (match) _rating = parseInt(match[1], 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Geolocation (browser API) ---
|
||||||
|
|
||||||
|
const locateUser = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
emit('location-error', { message: 'Geolocation is 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) => emit('location-error', {
|
||||||
|
message: err.code === 1
|
||||||
|
? 'Location access denied. You can search by name instead.'
|
||||||
|
: 'Could not determine your location. You can search by name instead.',
|
||||||
|
}),
|
||||||
|
{ enableHighAccuracy: true, timeout: 15000 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Nearby cafe search (Foursquare API) ---
|
||||||
|
|
||||||
|
let _nearbyCafes = [];
|
||||||
|
|
||||||
|
const searchNearbyCafes = async (query, lat, lng) => {
|
||||||
|
if (!lat || !query || query.length < 2) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/v1/nearby-cafes?lat=${lat}&lng=${lng}&q=${encodeURIComponent(query)}`,
|
||||||
|
{ credentials: 'same-origin' },
|
||||||
|
);
|
||||||
|
if (!resp.ok) throw new Error(`${resp.status}`);
|
||||||
|
|
||||||
|
const cafes = await resp.json();
|
||||||
|
_nearbyCafes = cafes;
|
||||||
|
renderNearbyCafes(cafes);
|
||||||
|
} catch {
|
||||||
|
emit('checkin-error', { message: 'Nearby search failed. Please try again.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNearbyCafes = (cafes) => {
|
||||||
|
const el = document.getElementById('nearby-results');
|
||||||
|
|
||||||
|
if (!cafes.length) {
|
||||||
|
el.innerHTML = '<p class="px-3 py-2 text-sm text-stone-500">No nearby cafes found.</p>';
|
||||||
|
} else {
|
||||||
|
let html = '<h3 class="px-3 py-2 text-xs font-semibold text-stone-500 uppercase tracking-wide">Nearby</h3>';
|
||||||
|
cafes.forEach((cafe, i) => {
|
||||||
|
const dist = cafe.distance_meters < 1000
|
||||||
|
? `${cafe.distance_meters} m`
|
||||||
|
: `${(cafe.distance_meters / 1000).toFixed(1)} km`;
|
||||||
|
const loc = [cafe.city, cafe.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="selectNearby(${i})">`;
|
||||||
|
html += `<span class="font-medium text-amber-900">${esc(cafe.name)}</span>`;
|
||||||
|
html += `<span class="ml-2 text-xs text-stone-500">${esc(loc)} · ${dist}</span>`;
|
||||||
|
html += `</button>`;
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectNearby = (i) => {
|
||||||
|
const c = _nearbyCafes[i];
|
||||||
|
if (!c) return;
|
||||||
|
emit('cafe-selected', {
|
||||||
|
id: '',
|
||||||
|
name: c.name,
|
||||||
|
city: c.city || '',
|
||||||
|
country: c.country || '',
|
||||||
|
lat: c.latitude || 0,
|
||||||
|
lng: c.longitude || 0,
|
||||||
|
website: c.website || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Client-side cafe filtering ---
|
||||||
|
|
||||||
|
const filterExistingCafes = (query) => {
|
||||||
|
const container = document.getElementById('existing-cafes');
|
||||||
|
if (!container) return;
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
let visible = 0;
|
||||||
|
container.querySelectorAll('[data-cafe-name]').forEach((btn) => {
|
||||||
|
const show = !query || btn.dataset.cafeName.toLowerCase().includes(lower);
|
||||||
|
btn.style.display = show ? '' : 'none';
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
const noMatch = container.querySelector('[data-no-match]');
|
||||||
|
if (noMatch) noMatch.style.display = query && !visible ? '' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Scan callback (extract.js integration) ---
|
||||||
|
|
||||||
|
const onScanExtracted = async (data) => {
|
||||||
|
emit('scan-start');
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
roaster_name: data.roaster?.name || '',
|
||||||
|
roaster_country: data.roaster?.country || '',
|
||||||
|
roaster_city: data.roaster?.city || '',
|
||||||
|
roaster_homepage: data.roaster?.homepage || '',
|
||||||
|
roast_name: data.roast?.name || '',
|
||||||
|
origin: data.roast?.origin || '',
|
||||||
|
region: data.roast?.region || '',
|
||||||
|
producer: data.roast?.producer || '',
|
||||||
|
process: data.roast?.process || '',
|
||||||
|
tasting_notes: (data.roast?.tasting_notes || []).join(', '),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/v1/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Server returned ${resp.status}`);
|
||||||
|
}
|
||||||
|
const result = await resp.json();
|
||||||
|
emit('scan-complete', { roastId: String(result.roast_id), name: body.roast_name });
|
||||||
|
} catch (e) {
|
||||||
|
emit('scan-error', { message: `Scan failed: ${e.message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Submit check-in ---
|
||||||
|
|
||||||
|
const submitCheckIn = async () => {
|
||||||
|
if (_submitting) return;
|
||||||
|
if (!_cafeName) { emit('checkin-error', { message: 'Please select a cafe.' }); return; }
|
||||||
|
if (!_roastId) { emit('checkin-error', { message: 'Please select or scan a coffee.' }); return; }
|
||||||
|
|
||||||
|
_submitting = true;
|
||||||
|
emit('submit-start');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let cafeId = _cafeId;
|
||||||
|
|
||||||
|
// Create cafe from Foursquare if no existing ID
|
||||||
|
if (!cafeId) {
|
||||||
|
const cafeResp = await fetch('/api/v1/cafes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: _cafeName,
|
||||||
|
city: _cafeCity || null,
|
||||||
|
country: _cafeCountry || null,
|
||||||
|
latitude: _cafeLat || null,
|
||||||
|
longitude: _cafeLng || null,
|
||||||
|
website: _cafeWebsite || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!cafeResp.ok) {
|
||||||
|
const err = await cafeResp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to create cafe (${cafeResp.status})`);
|
||||||
|
}
|
||||||
|
const newCafe = await cafeResp.json();
|
||||||
|
cafeId = String(newCafe.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cupBody = {
|
||||||
|
roast_id: parseInt(_roastId, 10),
|
||||||
|
cafe_id: parseInt(cafeId, 10),
|
||||||
|
};
|
||||||
|
if (_rating) cupBody.rating = _rating;
|
||||||
|
|
||||||
|
const cupResp = await fetch('/api/v1/cups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(cupBody),
|
||||||
|
});
|
||||||
|
if (!cupResp.ok) {
|
||||||
|
const err = await cupResp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to create cup (${cupResp.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (e) {
|
||||||
|
_submitting = false;
|
||||||
|
emit('checkin-error', { message: `Check-in failed: ${e.message}` });
|
||||||
|
emit('submit-error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Utility ---
|
||||||
|
|
||||||
|
const esc = (t) => {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = t;
|
||||||
|
return el.innerHTML;
|
||||||
|
};
|
||||||
409
templates/home.html
Normal file
409
templates/home.html
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
{% extends "base.html" %} {% block title %}Brewlog{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% if is_authenticated && has_ai_extract %}
|
||||||
|
<script src="/extract.js"></script>
|
||||||
|
<script>
|
||||||
|
let _submitting = false;
|
||||||
|
|
||||||
|
const fillScanForms = (data) => {
|
||||||
|
const form = document.getElementById('scan-form');
|
||||||
|
if (!form) return;
|
||||||
|
if (data.roaster) {
|
||||||
|
if (data.roaster.name) form.querySelector('[name="roaster_name"]').value = data.roaster.name;
|
||||||
|
if (data.roaster.country) form.querySelector('[name="roaster_country"]').value = data.roaster.country;
|
||||||
|
if (data.roaster.city) form.querySelector('[name="roaster_city"]').value = data.roaster.city;
|
||||||
|
if (data.roaster.homepage) form.querySelector('[name="roaster_homepage"]').value = data.roaster.homepage;
|
||||||
|
}
|
||||||
|
if (data.roast) {
|
||||||
|
if (data.roast.name) form.querySelector('[name="roast_name"]').value = data.roast.name;
|
||||||
|
if (data.roast.origin) form.querySelector('[name="origin"]').value = data.roast.origin;
|
||||||
|
if (data.roast.region) form.querySelector('[name="region"]').value = data.roast.region;
|
||||||
|
if (data.roast.producer) form.querySelector('[name="producer"]').value = data.roast.producer;
|
||||||
|
if (data.roast.process) form.querySelector('[name="process"]').value = data.roast.process;
|
||||||
|
if (data.roast.tasting_notes && data.roast.tasting_notes.length > 0) {
|
||||||
|
form.querySelector('[name="tasting_notes"]').value = data.roast.tasting_notes.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('scan-input-section').style.display = 'none';
|
||||||
|
document.getElementById('scan-form-section').style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetScan = () => {
|
||||||
|
document.getElementById('scan-section').style.display = 'none';
|
||||||
|
document.getElementById('scan-input-section').style.display = 'block';
|
||||||
|
document.getElementById('scan-form-section').style.display = 'none';
|
||||||
|
document.getElementById('scan-form').reset();
|
||||||
|
document.getElementById('scan-submit-error').classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleScan = () => {
|
||||||
|
const el = document.getElementById('scan-section');
|
||||||
|
if (el.style.display === 'none') {
|
||||||
|
el.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
resetScan();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitScan = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (_submitting) return;
|
||||||
|
_submitting = true;
|
||||||
|
const errorEl = document.getElementById('scan-submit-error');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const form = document.getElementById('scan-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const body = {};
|
||||||
|
formData.forEach((value, key) => { body[key] = value; });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/v1/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errData = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.message || `Server returned ${resp.status}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
window.location.href = data.redirect || '/roasts';
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = `Save failed: ${e.message}`;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
_submitting = false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBag = async (bagId, cardEl) => {
|
||||||
|
if (!confirm('Close this bag? This will mark it as finished.')) return;
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const resp = await fetch(`/api/v1/bags/${bagId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||||
|
cardEl.remove();
|
||||||
|
const grid = document.getElementById('open-bags-grid');
|
||||||
|
if (grid && grid.children.length === 0) {
|
||||||
|
document.getElementById('open-bags-section')?.remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Failed to close bag: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
{% if is_authenticated %}
|
||||||
|
<section class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="toggleScan()"
|
||||||
|
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-6 py-5 text-lg font-semibold text-amber-800 shadow-sm transition hover:bg-amber-100 hover:border-amber-500"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" 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>
|
||||||
|
{% endif %}
|
||||||
|
<a
|
||||||
|
href="/check-in"
|
||||||
|
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-6 py-5 text-lg font-semibold text-amber-800 shadow-sm transition hover:bg-amber-100 hover:border-amber-500"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M15.993 1.385a1.87 1.87 0 0 1 2.623 2.622l-4.03 5.27a12.75 12.75 0 0 1-4.223 3.358 7.724 7.724 0 0 1-2.71.832 2.14 2.14 0 0 1-.588-.085 1.07 1.07 0 0 1-.725-.723 2.14 2.14 0 0 1-.083-.588 7.724 7.724 0 0 1 .832-2.71 12.75 12.75 0 0 1 3.358-4.223l5.27-4.03.176.177-.176-.177Z" />
|
||||||
|
<path d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v3.75a2.25 2.25 0 0 1-2.25 2.25h-.878a15.262 15.262 0 0 0 .878 3h.75a3.75 3.75 0 0 0 3.75-3.75V6a3.75 3.75 0 0 0-3.75-3.75h-7.5A3.75 3.75 0 0 0 5.25 6v.75a15.262 15.262 0 0 0 .75.128Z" />
|
||||||
|
</svg>
|
||||||
|
Check In
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Inline Scan Section (hidden by default) -->
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<section id="scan-section" style="display: none">
|
||||||
|
<!-- Input: photo or text -->
|
||||||
|
<div id="scan-input-section" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<div id="scan-extract-controls" class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="triggerPhotoExtract('scan', '/api/v1/extract-bag-scan', fillScanForms)"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-3 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">
|
||||||
|
<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>
|
||||||
|
Take Photo
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-stone-400">or</span>
|
||||||
|
<div class="flex flex-1 min-w-[200px] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="scan-extract-text"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Describe the coffee bag…"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('scan','/api/v1/extract-bag-scan',fillScanForms)}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="extractFromText('scan', '/api/v1/extract-bag-scan', fillScanForms)"
|
||||||
|
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 id="scan-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700">
|
||||||
|
<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="scan-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form: pre-filled roaster + roast -->
|
||||||
|
<div id="scan-form-section" style="display: none">
|
||||||
|
<form id="scan-form" class="mt-4 flex flex-col gap-4" onsubmit="return submitScan(event)">
|
||||||
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
|
||||||
|
<div class="mt-4 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="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Country *</span>
|
||||||
|
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">City</span>
|
||||||
|
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Homepage</span>
|
||||||
|
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">Roast</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
|
||||||
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Roast Name *</span>
|
||||||
|
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Origin *</span>
|
||||||
|
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Region *</span>
|
||||||
|
<input type="text" name="region" required class="input-field" placeholder="Guji" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Producer *</span>
|
||||||
|
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Process *</span>
|
||||||
|
<input type="text" name="process" required class="input-field" placeholder="Washed" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
|
||||||
|
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="scan-submit-error" class="hidden text-sm text-red-600"></p>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="resetScan()"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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 Roaster & Roast
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last Brew -->
|
||||||
|
{% if let Some(brew) = last_brew %}
|
||||||
|
<section class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">Last Brew</h2>
|
||||||
|
<span class="text-xs text-stone-500">{{ brew.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex flex-wrap items-baseline gap-2">
|
||||||
|
<a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="font-semibold text-amber-800 hover:text-amber-600">{{ brew.roast_name }}</a>
|
||||||
|
<span class="text-sm text-stone-500">{{ brew.roaster_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-stone-600">
|
||||||
|
<span>{{ brew.coffee_weight }}</span>
|
||||||
|
<span>{{ brew.water_volume }} @ {{ brew.water_temp }}</span>
|
||||||
|
<span>{{ brew.ratio }}</span>
|
||||||
|
<span>{{ brew.grinder_name }} @ {{ brew.grind_setting }}</span>
|
||||||
|
<span>{{ brew.brewer_name }}</span>
|
||||||
|
{% if let Some(fp) = brew.filter_paper_name %}
|
||||||
|
<span>{{ fp }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if is_authenticated %}
|
||||||
|
<div class="mt-3 pt-3 border-t border-amber-200">
|
||||||
|
<form
|
||||||
|
class="inline"
|
||||||
|
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
|
||||||
|
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
|
||||||
|
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
||||||
|
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
|
||||||
|
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
||||||
|
{% if let Some(fp_id) = brew.filter_paper_id %}
|
||||||
|
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
|
||||||
|
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Brew Again
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Open Bags -->
|
||||||
|
{% if !open_bags.is_empty() %}
|
||||||
|
<section id="open-bags-section">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">Open Bags</h2>
|
||||||
|
<a href="/bags" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all bags →</a>
|
||||||
|
</div>
|
||||||
|
<div id="open-bags-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for bag in open_bags %}
|
||||||
|
<div id="bag-card-{{ bag.id }}" class="rounded-lg border border-amber-200 bg-amber-50 p-4 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<a href="/roasters/{{ bag.roaster_slug }}/roasts/{{ bag.roast_slug }}" class="font-semibold text-amber-800 hover:text-amber-600">{{ bag.roast_name }}</a>
|
||||||
|
<p class="text-sm text-stone-500">{{ bag.roaster_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-stone-600">
|
||||||
|
<span class="font-medium">{{ bag.remaining }}g</span>
|
||||||
|
<span class="text-stone-400">of {{ bag.amount }}g remaining</span>
|
||||||
|
</p>
|
||||||
|
{% if is_authenticated %}
|
||||||
|
<div class="mt-3 pt-3 border-t border-amber-100 flex gap-3">
|
||||||
|
<a href="/brews?bag_id={{ bag.id }}" class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-11.25a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Brew
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeBag('{{ bag.id }}', document.getElementById('bag-card-{{ bag.id }}'))"
|
||||||
|
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-stone-700"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
{% if !recent_events.is_empty() %}
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700">Recent Activity</h2>
|
||||||
|
<a href="/timeline" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View full timeline →</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for event in recent_events %}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span>
|
||||||
|
<a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a>
|
||||||
|
</div>
|
||||||
|
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.date_label }}</time>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700 mb-4">Your Coffee Journey</h2>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
|
||||||
|
<a href="/brews" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.brews }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Brews</div>
|
||||||
|
</a>
|
||||||
|
<a href="/roasts" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasts }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Roasts</div>
|
||||||
|
</a>
|
||||||
|
<a href="/roasters" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasters }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Roasters</div>
|
||||||
|
</a>
|
||||||
|
<a href="/bags" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.bags }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Bags</div>
|
||||||
|
</a>
|
||||||
|
<a href="/cups" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cups }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Cups</div>
|
||||||
|
</a>
|
||||||
|
<a href="/cafes" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cafes }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Cafes</div>
|
||||||
|
</a>
|
||||||
|
<a href="/gear" class="group">
|
||||||
|
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.gear }}</div>
|
||||||
|
<div class="text-xs text-stone-500">Gear</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<nav class="relative rounded-lg border border-amber-300 bg-amber-100/80 p-3 text-sm text-stone-600" data-signals:_nav-open="false">
|
<nav class="relative rounded-lg border border-amber-300 bg-amber-100/80 p-3 text-sm text-stone-600" data-signals:_nav-open="false">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/timeline">B{rew}log</a></div>
|
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/">B{rew}log</a></div>
|
||||||
<!-- Desktop links -->
|
<!-- Desktop links -->
|
||||||
<div class="hidden md:flex items-center gap-3">
|
<div class="hidden md:flex items-center gap-3">
|
||||||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
|
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
|
||||||
|
|
@ -11,13 +11,11 @@
|
||||||
<a class="border-b-2 pb-1 transition {% if nav_active == "cups" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/cups">Cups</a>
|
<a class="border-b-2 pb-1 transition {% if nav_active == "cups" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/cups">Cups</a>
|
||||||
<a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</a>
|
<a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</a>
|
||||||
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
|
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
|
||||||
{% if is_authenticated && has_ai_extract %}
|
<a class="border-b-2 pb-1 transition {% if nav_active == "home" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/" aria-label="Home">
|
||||||
<a class="border-b-2 pb-1 transition {% if nav_active == "scan" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/scan" aria-label="Scan bag">
|
|
||||||
<svg class="inline h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="inline h-4 w-4" 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" />
|
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<form method="post" action="/logout" class="inline">
|
<form method="post" action="/logout" class="inline">
|
||||||
<button type="submit" class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400">Logout</button>
|
<button type="submit" class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400">Logout</button>
|
||||||
|
|
@ -46,14 +44,12 @@
|
||||||
<a class="py-1 transition {% if nav_active == "cups" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/cups">Cups</a>
|
<a class="py-1 transition {% if nav_active == "cups" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/cups">Cups</a>
|
||||||
<a class="py-1 transition {% if nav_active == "gear" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/gear">Gear</a>
|
<a class="py-1 transition {% if nav_active == "gear" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/gear">Gear</a>
|
||||||
<a class="py-1 transition {% if nav_active == "timeline" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/timeline">Timeline</a>
|
<a class="py-1 transition {% if nav_active == "timeline" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/timeline">Timeline</a>
|
||||||
{% if is_authenticated && has_ai_extract %}
|
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "home" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/">
|
||||||
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "scan" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/scan">
|
|
||||||
<svg class="inline h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="inline h-4 w-4" 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" />
|
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
Scan
|
Home
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<form method="post" action="/logout" class="inline">
|
<form method="post" action="/logout" class="inline">
|
||||||
<button type="submit" class="py-1 transition text-stone-500 hover:text-amber-600 text-left">Logout</button>
|
<button type="submit" class="py-1 transition text-stone-500 hover:text-amber-600 text-left">Logout</button>
|
||||||
|
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
{% extends "base.html" %} {% block title %}Brewlog · Scan Bag{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<script src="/extract.js"></script>
|
|
||||||
<script>
|
|
||||||
var _submitting = false;
|
|
||||||
|
|
||||||
function fillScanForms(data) {
|
|
||||||
var form = document.getElementById('scan-form');
|
|
||||||
if (!form) return;
|
|
||||||
if (data.roaster) {
|
|
||||||
if (data.roaster.name) form.querySelector('[name="roaster_name"]').value = data.roaster.name;
|
|
||||||
if (data.roaster.country) form.querySelector('[name="roaster_country"]').value = data.roaster.country;
|
|
||||||
if (data.roaster.city) form.querySelector('[name="roaster_city"]').value = data.roaster.city;
|
|
||||||
if (data.roaster.homepage) form.querySelector('[name="roaster_homepage"]').value = data.roaster.homepage;
|
|
||||||
}
|
|
||||||
if (data.roast) {
|
|
||||||
if (data.roast.name) form.querySelector('[name="roast_name"]').value = data.roast.name;
|
|
||||||
if (data.roast.origin) form.querySelector('[name="origin"]').value = data.roast.origin;
|
|
||||||
if (data.roast.region) form.querySelector('[name="region"]').value = data.roast.region;
|
|
||||||
if (data.roast.producer) form.querySelector('[name="producer"]').value = data.roast.producer;
|
|
||||||
if (data.roast.process) form.querySelector('[name="process"]').value = data.roast.process;
|
|
||||||
if (data.roast.tasting_notes && data.roast.tasting_notes.length > 0) {
|
|
||||||
form.querySelector('[name="tasting_notes"]').value = data.roast.tasting_notes.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.getElementById('scan-input-section').style.display = 'none';
|
|
||||||
document.getElementById('scan-form-section').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetScan() {
|
|
||||||
document.getElementById('scan-input-section').style.display = 'block';
|
|
||||||
document.getElementById('scan-form-section').style.display = 'none';
|
|
||||||
document.getElementById('scan-form').reset();
|
|
||||||
document.getElementById('scan-submit-error').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitScan(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (_submitting) return;
|
|
||||||
_submitting = true;
|
|
||||||
var errorEl = document.getElementById('scan-submit-error');
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
var form = document.getElementById('scan-form');
|
|
||||||
var formData = new FormData(form);
|
|
||||||
var body = {};
|
|
||||||
formData.forEach(function (value, key) { body[key] = value; });
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/v1/scan', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
var errData = await resp.json().catch(function () { return {}; });
|
|
||||||
throw new Error(errData.message || 'Server returned ' + resp.status);
|
|
||||||
}
|
|
||||||
var data = await resp.json();
|
|
||||||
window.location.href = data.redirect || '/roasts';
|
|
||||||
} catch (e) {
|
|
||||||
errorEl.textContent = 'Save failed: ' + e.message;
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
} finally {
|
|
||||||
_submitting = false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<section>
|
|
||||||
<header class="flex flex-col gap-2">
|
|
||||||
<h1 class="text-3xl font-semibold">Scan Bag</h1>
|
|
||||||
<p class="max-w-2xl text-sm text-stone-600">
|
|
||||||
Take a photo of a coffee bag or describe it, and Brewlog will extract the roaster and roast details for you.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Input section: photo or text -->
|
|
||||||
<div id="scan-input-section" class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
|
||||||
<div id="scan-extract-controls" class="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="triggerPhotoExtract('scan', '/api/v1/extract-bag-scan', fillScanForms)"
|
|
||||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-3 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">
|
|
||||||
<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>
|
|
||||||
Take Photo
|
|
||||||
</button>
|
|
||||||
<span class="text-xs text-stone-400">or</span>
|
|
||||||
<div class="flex flex-1 min-w-[200px] gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="scan-extract-text"
|
|
||||||
class="input-field w-full text-sm"
|
|
||||||
placeholder="Describe the coffee bag…"
|
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('scan','/api/v1/extract-bag-scan',fillScanForms)}"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="extractFromText('scan', '/api/v1/extract-bag-scan', fillScanForms)"
|
|
||||||
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 id="scan-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700">
|
|
||||||
<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="scan-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form section: pre-filled roaster + roast forms -->
|
|
||||||
<div id="scan-form-section" style="display: none">
|
|
||||||
<form
|
|
||||||
id="scan-form"
|
|
||||||
class="mt-6 flex flex-col gap-6"
|
|
||||||
onsubmit="return submitScan(event)"
|
|
||||||
>
|
|
||||||
<!-- Roaster section -->
|
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2>
|
|
||||||
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 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="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Country *</span>
|
|
||||||
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">City</span>
|
|
||||||
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Homepage</span>
|
|
||||||
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Roast section -->
|
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-amber-700">Roast</h2>
|
|
||||||
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Roast Name *</span>
|
|
||||||
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Origin *</span>
|
|
||||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Region *</span>
|
|
||||||
<input type="text" name="region" required class="input-field" placeholder="Guji" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Producer *</span>
|
|
||||||
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Process *</span>
|
|
||||||
<input type="text" name="process" required class="input-field" placeholder="Washed" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
|
|
||||||
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine"></textarea>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="scan-submit-error" class="hidden text-sm text-red-600"></p>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="resetScan()"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Start Over
|
|
||||||
</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 Roaster & Roast
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
Loading…
Reference in a new issue