use cot::Template; use cot::db::{Auto, Database, Model}; use cot::html::Html; use cot::request::Request; use cot::request::extractors::Path; use cot::response::{IntoResponse, Redirect, Response}; use cot::router::{Route, Router}; use serde::Deserialize; use cot::db::query; use crate::i18n::{Lang, Translations}; use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit}; use crate::telegram; fn detect_lang(request: &Request) -> Lang { if let Some(q) = request.uri().query() { for pair in q.split('&') { if let Some(code) = pair.strip_prefix("lang=") { if let Some(lang) = Lang::from_code(code) { return lang; } } } } if let Some(cookie) = request .headers() .get("cookie") .and_then(|v| v.to_str().ok()) { for part in cookie.split(';') { let part = part.trim(); if let Some(code) = part.strip_prefix("lang=") { if let Some(lang) = Lang::from_code(code.trim()) { return lang; } } } } request .headers() .get("accept-language") .and_then(|v| v.to_str().ok()) .map(Lang::from_accept_language) .unwrap_or(Lang::Ru) } fn lang_cookie(lang: Lang) -> String { format!( "lang={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code() ) } fn html_response(body: String, lang: Lang) -> cot::Result { Html::new(body) .with_header("set-cookie", lang_cookie(lang)) .into_response() } fn now() -> chrono::NaiveDateTime { chrono::Utc::now().naive_utc() } #[derive(Debug, Template)] #[template(path = "landing.html")] struct LandingTemplate<'a> { t: &'a Translations, lang: Lang, contact_info: String, pricing_info: String, testimonials: Vec, } #[derive(Debug, Template)] #[template(path = "thank_you.html")] struct ThankYouTemplate<'a> { t: &'a Translations, lang: Lang, } async fn landing_page(request: Request, db: Database) -> cot::Result { let lang = detect_lang(&request); let key = "contact_info".to_string(); let contact_info = query!(Setting, $key == key) .get(&db) .await? .map(|s| s.value) .unwrap_or_default(); let pricing_key = "pricing_info".to_string(); let pricing_info = query!(Setting, $key == pricing_key) .get(&db) .await? .map(|s| s.value) .unwrap_or_default(); let mut testimonials = Testimonial::objects().all(&db).await?; testimonials.retain(|t| t.status == "active"); testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order)); let body = LandingTemplate { t: lang.t(), lang, contact_info, pricing_info, testimonials, } .render()?; html_response(body, lang) } #[derive(Deserialize)] struct LeadForm { name: String, phone: Option, comment: Option, } async fn submit_lead(request: Request, db: Database) -> cot::Result { let lang = detect_lang(&request); let body = request.into_body(); let bytes = body.into_bytes().await?; let form: LeadForm = serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; let mut lead = Lead { id: Auto::auto(), name: form.name, phone: form.phone.filter(|s| !s.trim().is_empty()), email: None, comment: form.comment.filter(|s| !s.trim().is_empty()), status: "new".to_string(), client_id: None, created_at: now(), updated_at: now(), }; lead.save(&db).await?; telegram::notify_new_lead( &db, &lead.name, lead.phone.as_deref(), lead.comment.as_deref(), ) .await; let rendered = ThankYouTemplate { t: lang.t(), lang }.render()?; html_response(rendered, lang) } // --------------------------------------------------------------------------- // Client Portal // --------------------------------------------------------------------------- #[derive(Debug)] struct PortalVisit { visit: Visit, admin_name: String, media: Vec, } #[derive(Debug, Template)] #[template(path = "client_portal.html")] struct ClientPortalTemplate<'a> { t: &'a Translations, lang: Lang, client: Client, upcoming: Vec, past: Vec, feedback_sent: bool, } async fn client_portal( request: Request, db: Database, Path(token): Path, ) -> cot::Result { let lang = detect_lang(&request); let feedback_sent = request .uri() .query() .map(|q| q.split('&').any(|p| p == "feedback=ok")) .unwrap_or(false); let client = match query!(Client, $media_token == token).get(&db).await? { Some(c) => c, None => return Html::new("404").into_response(), }; let client_id = client.id.unwrap(); let today = chrono::Utc::now().date_naive(); let mut visits = Visit::objects().all(&db).await?; visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled"); visits.sort_by(|a, b| { a.visit_date .cmp(&b.visit_date) .then(a.time_start.cmp(&b.time_start)) }); let users = User::objects().all(&db).await?; let all_media = Media::objects().all(&db).await?; let build_portal_visit = |v: Visit| -> PortalVisit { let uid: i64 = v.user_id.primary_key().unwrap(); let admin_name = users .iter() .find(|u| u.id.unwrap() == uid) .map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string()) .unwrap_or_default(); let vid = v.id.unwrap(); let media: Vec = all_media .iter() .filter(|m| { m.status == "active" && m.visit_id .as_ref() .map(|fk| fk.primary_key().unwrap() == vid) .unwrap_or(false) }) .cloned() .collect(); PortalVisit { visit: v, admin_name, media, } }; let mut upcoming = Vec::new(); let mut past = Vec::new(); for v in visits { if v.visit_date >= today && v.status == "scheduled" { upcoming.push(build_portal_visit(v)); } else { past.push(build_portal_visit(v)); } } past.reverse(); // newest first let body = ClientPortalTemplate { t: lang.t(), lang, client, upcoming, past, feedback_sent, } .render()?; html_response(body, lang) } #[derive(Deserialize)] struct FeedbackForm { feedback: String, } async fn submit_feedback( request: Request, db: Database, Path((token, visit_id)): Path<(String, i64)>, ) -> cot::Result { let lang = detect_lang(&request); // Verify token matches visit's client let token_clone = token.clone(); let client = match query!(Client, $media_token == token).get(&db).await? { Some(c) => c, None => return Html::new("404").into_response(), }; let client_id = client.id.unwrap(); let bytes = request.into_body().into_bytes().await?; let form: FeedbackForm = serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { if visit.client_id.primary_key().unwrap() == client_id { visit.client_feedback = Some(form.feedback); visit.updated_at = now(); visit.save(&db).await?; } } Redirect::new(format!( "/client/{}?lang={}&feedback=ok", token_clone, lang.code() )) .into_response() } /// Serve media files for the client portal (no auth required, but only via token). async fn portal_media( _request: Request, db: Database, Path((token, media_id)): Path<(String, i64)>, ) -> cot::Result { // Verify token let client = match query!(Client, $media_token == token).get(&db).await? { Some(c) => c, None => return Html::new("404").into_response(), }; let client_id = client.id.unwrap(); let media = match query!(Media, $id == media_id).get(&db).await? { Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m, _ => return Html::new("404").into_response(), }; match tokio::fs::read(&media.file_path).await { Ok(data) => { let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "heic" | "heif" => "image/heic", "webp" => "image/webp", "mp4" => "video/mp4", "mov" => "video/quicktime", "avi" => "video/x-msvideo", "mkv" => "video/x-matroska", "webm" => "video/webm", _ => "application/octet-stream", }; let body = cot::Body::fixed(data); let mut resp = Response::new(body); resp.headers_mut() .insert("content-type", content_type.parse().unwrap()); Ok(resp) } Err(_) => Html::new("404").into_response(), } } async fn serve_testimonial_image( _request: Request, db: Database, Path(id): Path, ) -> cot::Result { let testimonial = match query!(Testimonial, $id == id).get(&db).await? { Some(t) => t, None => return Html::new("404").into_response(), }; let path = match &testimonial.image_path { Some(p) => p.clone(), None => return Html::new("404").into_response(), }; match tokio::fs::read(&path).await { Ok(data) => { let content_type = match path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "webp" => "image/webp", "heic" | "heif" => "image/heic", _ => "application/octet-stream", }; let body = cot::Body::fixed(data); let mut resp = Response::new(body); resp.headers_mut() .insert("content-type", content_type.parse().unwrap()); resp.headers_mut() .insert("cache-control", "public, max-age=86400".parse().unwrap()); Ok(resp) } Err(_) => Html::new("404").into_response(), } } pub fn public_router() -> Router { Router::with_urls([ Route::with_handler_and_name("/", landing_page, "landing"), Route::with_handler_and_name("/submit", submit_lead, "submit-lead"), Route::with_handler_and_name( "/testimonial-image/{id}", serve_testimonial_image, "testimonial-image", ), Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"), Route::with_handler_and_name( "/client/{token}/{visit_id}/feedback", submit_feedback, "client-feedback", ), Route::with_handler_and_name( "/client/{token}/media/{media_id}", portal_media, "client-media", ), ]) }