391 lines
11 KiB
Rust
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",
|
|
),
|
|
])
|
|
}
|