Files
web-petting/src/public.rs
T
Ultradesu d1ef66acc1
Build and Publish / Build and Publish Docker Image (push) Successful in 6m27s
Added reviews. Added pricing.
2026-05-11 11:34:11 +01:00

391 lines
11 KiB
Rust

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<Response> {
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<Testimonial>,
}
#[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<Response> {
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<String>,
comment: Option<String>,
}
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
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<Media>,
}
#[derive(Debug, Template)]
#[template(path = "client_portal.html")]
struct ClientPortalTemplate<'a> {
t: &'a Translations,
lang: Lang,
client: Client,
upcoming: Vec<PortalVisit>,
past: Vec<PortalVisit>,
feedback_sent: bool,
}
async fn client_portal(
request: Request,
db: Database,
Path(token): Path<String>,
) -> cot::Result<Response> {
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<Media> = 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<Response> {
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<Response> {
// 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<i64>,
) -> cot::Result<Response> {
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",
),
])
}