This commit is contained in:
+322
@@ -0,0 +1,322 @@
|
||||
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 cot::Template;
|
||||
use serde::Deserialize;
|
||||
|
||||
use cot::db::query;
|
||||
|
||||
use crate::i18n::{Lang, Translations};
|
||||
use crate::models::{Client, Lead, Media, Setting, 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,
|
||||
}
|
||||
|
||||
#[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 body = LandingTemplate { t: lang.t(), lang, contact_info }.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(),
|
||||
}
|
||||
}
|
||||
|
||||
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("/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",
|
||||
),
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user