diff --git a/src/admin.rs b/src/admin.rs index 440efbf..7a763f6 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1,15 +1,15 @@ +use cot::Template; use cot::db::{Auto, Database, ForeignKey, Model, query}; use cot::html::Html; -use cot::request::extractors::Path; use cot::request::Request; +use cot::request::extractors::Path; use cot::response::{IntoResponse, Redirect, Response}; use cot::router::{Route, Router}; use cot::session::Session; -use cot::Template; use serde::Deserialize; use crate::i18n::{Lang, Translations}; -use crate::models::{Client, Lead, Media, Setting, User, Visit}; +use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit}; use crate::telegram; const SESSION_USER_ID: &str = "user_id"; @@ -81,16 +81,18 @@ fn has_query_flag(request: &Request, flag: &str) -> bool { request .uri() .query() - .map(|q| q.split('&').any(|p| p == format!("{}=1", flag) || p == flag)) + .map(|q| { + q.split('&') + .any(|p| p == format!("{}=1", flag) || p == flag) + }) .unwrap_or(false) } /// Soft pastel palette for client calendar colors. const CLIENT_COLORS: &[&str] = &[ - "#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", - "#8e6bbf", "#5cb8a5", "#c77c4f", "#a3729a", "#6b9e5e", - "#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e", - "#9476b8", "#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e", + "#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f", + "#a3729a", "#6b9e5e", "#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e", "#9476b8", + "#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e", ]; fn rand_client_color() -> &'static str { @@ -136,11 +138,7 @@ async fn get_admin_name(session: &Session) -> Option { /// Get admin user ID from session. async fn get_admin_id(session: &Session) -> Option { - session - .get::(SESSION_USER_ID) - .await - .ok() - .flatten() + session.get::(SESSION_USER_ID).await.ok().flatten() } /// Redirect to login if not authenticated. @@ -333,11 +331,7 @@ async fn has_any_admin(db: &Database) -> cot::Result { Ok(count > 0) } -async fn login_page( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn login_page(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); if get_admin_name(&session).await.is_some() { return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response(); @@ -380,11 +374,7 @@ struct SetupForm { password_confirm: String, } -async fn setup_submit( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn setup_submit(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, SetupForm) = parse_form_from_request(request).await?; // Block if admins already exist @@ -431,11 +421,7 @@ struct LoginForm { password: String, } -async fn login_submit( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?; let login = form.login.clone(); @@ -450,9 +436,7 @@ async fn login_submit( .as_deref() .unwrap_or(&user.login) .to_string(); - session - .insert(SESSION_USER_ID, user.id.unwrap()) - .await?; + session.insert(SESSION_USER_ID, user.id.unwrap()).await?; session.insert(SESSION_USER_NAME, display).await?; return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response(); } @@ -477,11 +461,7 @@ async fn logout(request: Request, session: Session) -> cot::Result { // GET Handlers (protected) // --------------------------------------------------------------------------- -async fn admin_index( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn admin_index(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -503,7 +483,9 @@ async fn admin_index( client_name: client.map(|c| c.name.clone()).unwrap_or_default(), client_phone: client.and_then(|c| c.phone.clone()).unwrap_or_default(), client_address: client.and_then(|c| c.address.clone()).unwrap_or_default(), - client_color: client.and_then(|c| c.color.clone()).unwrap_or_else(|| "#7c6ed4".to_string()), + client_color: client + .and_then(|c| c.color.clone()) + .unwrap_or_else(|| "#7c6ed4".to_string()), visit: v.clone(), } }) @@ -520,8 +502,11 @@ async fn admin_index( }) .map(|v| { let cid: i64 = v.client_id.primary_key().unwrap(); - let client_name = clients.iter().find(|c| c.id.unwrap() == cid) - .map(|c| c.name.clone()).unwrap_or_default(); + let client_name = clients + .iter() + .find(|c| c.id.unwrap() == cid) + .map(|c| c.name.clone()) + .unwrap_or_default(); RecentFeedback { visit_id: v.id.unwrap(), client_name, @@ -543,11 +528,7 @@ async fn admin_index( html_response(body, lang) } -async fn leads_page( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn leads_page(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -570,11 +551,7 @@ async fn leads_page( html_response(body, lang) } -async fn clients_page( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn clients_page(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -597,10 +574,7 @@ async fn clients_page( html_response(body, lang) } -async fn client_new_page( - request: Request, - session: Session, -) -> cot::Result { +async fn client_new_page(request: Request, session: Session) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -642,7 +616,9 @@ async fn client_edit_page( }; let client = match query!(Client, $id == client_id).get(&db).await? { Some(c) => c, - None => return Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response(), + None => { + return Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response(); + } }; let t = lang.t(); let action_url = format!("/admin/clients/{}/save", client.id); @@ -694,11 +670,7 @@ async fn client_edit_submit( Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response() } -async fn users_page( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn users_page(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -716,11 +688,7 @@ async fn users_page( html_response(body, lang) } -async fn settings_page( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn settings_page(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -750,11 +718,7 @@ struct AddUserForm { password_confirm: String, } -async fn add_user( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn add_user(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, AddUserForm) = parse_form_from_request(request).await?; let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -805,13 +769,10 @@ struct SettingsForm { telegram_bot_token: String, telegram_chat_id: String, contact_info: String, + pricing_info: String, } -async fn save_settings( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, SettingsForm) = parse_form_from_request(request).await?; let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -822,6 +783,7 @@ async fn save_settings( ("telegram_bot_token", form.telegram_bot_token), ("telegram_chat_id", form.telegram_chat_id), ("contact_info", form.contact_info), + ("pricing_info", form.pricing_info), ] { let k = key.to_string(); let existing = query!(Setting, $key == k).get(&db).await?; @@ -996,11 +958,7 @@ struct AddLeadForm { comment: Option, } -async fn add_lead( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn add_lead(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, AddLeadForm) = parse_form_from_request(request).await?; if let Err(resp) = require_auth(&session, lang).await { return Ok(resp); @@ -1040,11 +998,7 @@ struct AddClientForm { color: Option, } -async fn add_client( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn add_client(request: Request, session: Session, db: Database) -> cot::Result { let (lang, form): (_, AddClientForm) = parse_form_from_request(request).await?; if let Err(resp) = require_auth(&session, lang).await { return Ok(resp); @@ -1072,10 +1026,7 @@ async fn add_client( // Schedule Handlers // --------------------------------------------------------------------------- -async fn schedule_page( - request: Request, - session: Session, -) -> cot::Result { +async fn schedule_page(request: Request, session: Session) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -1159,9 +1110,7 @@ async fn schedule_events( let client_name = client.map(|c| c.name.as_str()).unwrap_or("?"); let client_phone = client.and_then(|c| c.phone.as_deref()).unwrap_or(""); let client_address = client.and_then(|c| c.address.as_deref()).unwrap_or(""); - let client_color = client - .and_then(|c| c.color.as_deref()) - .unwrap_or("#7c6ed4"); + let client_color = client.and_then(|c| c.color.as_deref()).unwrap_or("#7c6ed4"); let admin_name = user .map(|u| u.display_name.as_deref().unwrap_or(&u.login)) .unwrap_or("?"); @@ -1276,7 +1225,9 @@ async fn schedule_edit_page( }; let visit = match query!(Visit, $id == visit_id).get(&db).await? { Some(v) => v, - None => return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(), + None => { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } }; let clients = query!(Client, $status == "active").all(&db).await?; let users = query!(User, $status == "active").all(&db).await?; @@ -1395,11 +1346,7 @@ async fn visit_set_cancel( // Media Handlers // --------------------------------------------------------------------------- -async fn media_page( - request: Request, - session: Session, - db: Database, -) -> cot::Result { +async fn media_page(request: Request, session: Session, db: Database) -> cot::Result { let lang = detect_lang(&request); let admin_name = match require_auth(&session, lang).await { Ok(name) => name, @@ -1432,10 +1379,14 @@ async fn media_page( .map(|m| { let cid: i64 = m.client_id.primary_key().unwrap(); let client = clients_all.iter().find(|c| c.id.unwrap() == cid); - let visit_date = m.visit_id.as_ref().and_then(|fk| { - let vid: i64 = fk.primary_key().unwrap(); - visits_all.iter().find(|v| v.id.unwrap() == vid) - }).map(|v| v.visit_date.to_string()); + let visit_date = m + .visit_id + .as_ref() + .and_then(|fk| { + let vid: i64 = fk.primary_key().unwrap(); + visits_all.iter().find(|v| v.id.unwrap() == vid) + }) + .map(|v| v.visit_date.to_string()); MediaItem { client_name: client.map(|c| c.name.clone()).unwrap_or_default(), visit_date, @@ -1444,7 +1395,10 @@ async fn media_page( }) .collect(); - let active_clients = clients_all.into_iter().filter(|c| c.status == "active").collect(); + let active_clients = clients_all + .into_iter() + .filter(|c| c.status == "active") + .collect(); let body = MediaTemplate { t: lang.t(), @@ -1476,7 +1430,10 @@ async fn media_upload_page( let cid: i64 = visit.client_id.primary_key().unwrap(); let client = query!(Client, $id == cid).get(&db).await?; let client_name = client.map(|c| c.name).unwrap_or_default(); - let visit_label = format!("{} {} — {}", visit.visit_date, visit.time_start, visit.time_end); + let visit_label = format!( + "{} {} — {}", + visit.visit_date, visit.time_start, visit.time_end + ); let body = MediaUploadTemplate { t: lang.t(), @@ -1492,11 +1449,11 @@ async fn media_upload_page( fn extract_boundary(request: &Request) -> Option { let ct = request.headers().get("content-type")?.to_str().ok()?; - ct.split(';') - .find_map(|part| { - let part = part.trim(); - part.strip_prefix("boundary=").map(|b| b.trim_matches('"').to_string()) - }) + ct.split(';').find_map(|part| { + let part = part.trim(); + part.strip_prefix("boundary=") + .map(|b| b.trim_matches('"').to_string()) + }) } async fn media_upload_submit( @@ -1521,20 +1478,30 @@ async fn media_upload_submit( let client_id: i64 = visit.client_id.primary_key().unwrap(); let bytes = request.into_body().into_bytes().await?; - let stream = futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); + let stream = + futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); let mut multipart = multer::Multipart::new(stream, boundary); let upload_dir = format!("uploads/{}/{}", client_id, visit_id); - tokio::fs::create_dir_all(&upload_dir).await.map_err(|e| cot::Error::internal(e.to_string()))?; + tokio::fs::create_dir_all(&upload_dir) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let mut caption = String::new(); let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type) - while let Some(field) = multipart.next_field().await.map_err(|e| cot::Error::internal(e.to_string()))? { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + { let field_name = field.name().unwrap_or("").to_string(); if field_name == "caption" { - caption = field.text().await.map_err(|e| cot::Error::internal(e.to_string()))?; + caption = field + .text() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; continue; } @@ -1559,17 +1526,26 @@ async fn media_upload_submit( let file_id = uuid::Uuid::new_v4(); let file_path = format!("{}/{}.{}", upload_dir, file_id, ext); - let data = field.bytes().await.map_err(|e| cot::Error::internal(e.to_string()))?; + let data = field + .bytes() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; if data.is_empty() { continue; } - tokio::fs::write(&file_path, &data).await.map_err(|e| cot::Error::internal(e.to_string()))?; + tokio::fs::write(&file_path, &data) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; saved_files.push((file_path, file_type.to_string())); } } - let caption_opt = if caption.trim().is_empty() { None } else { Some(caption) }; + let caption_opt = if caption.trim().is_empty() { + None + } else { + Some(caption) + }; for (path, ftype) in saved_files { let mut media = Media { @@ -1638,7 +1614,224 @@ async fn serve_upload( }; 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("content-type", content_type.parse().unwrap()); + Ok(resp) + } + Err(_) => Html::new("404").into_response(), + } +} + +// --------------------------------------------------------------------------- +// Testimonials +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "admin/testimonials.html")] +struct TestimonialsTemplate<'a> { + t: &'a Translations, + lang: Lang, + admin_name: String, + testimonials: Vec, +} + +async fn testimonials_page( + request: Request, + session: Session, + db: Database, +) -> cot::Result { + let lang = detect_lang(&request); + let admin_name = match require_auth(&session, lang).await { + Ok(name) => name, + Err(resp) => return Ok(resp), + }; + + let mut testimonials = Testimonial::objects().all(&db).await?; + testimonials.sort_by(|a, b| { + a.sort_order + .cmp(&b.sort_order) + .then(b.id.unwrap().cmp(&a.id.unwrap())) + }); + + let body = TestimonialsTemplate { + t: lang.t(), + lang, + admin_name, + testimonials, + } + .render()?; + html_response(body, lang) +} + +async fn testimonial_add( + request: Request, + session: Session, + db: Database, +) -> cot::Result { + let lang = detect_lang(&request); + if let Err(resp) = require_auth(&session, lang).await { + return Ok(resp); + } + + let boundary = extract_boundary(&request) + .ok_or_else(|| cot::Error::internal("missing multipart boundary"))?; + + let bytes = request.into_body().into_bytes().await?; + let stream = + futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); + let mut multipart = multer::Multipart::new(stream, boundary); + + let mut text = String::new(); + let mut author_note = String::new(); + let mut image_path: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + { + let field_name = field.name().unwrap_or("").to_string(); + match field_name.as_str() { + "text" => { + text = field + .text() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + "author_note" => { + author_note = field + .text() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + "image" => { + let original_name = field.file_name().unwrap_or("").to_string(); + if original_name.is_empty() { + continue; + } + let ext = original_name + .rsplit('.') + .next() + .unwrap_or("bin") + .to_lowercase(); + match ext.as_str() { + "jpg" | "jpeg" | "png" | "webp" | "heic" | "heif" => {} + _ => continue, + } + let data = field + .bytes() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if data.is_empty() { + continue; + } + let upload_dir = "uploads/testimonials"; + tokio::fs::create_dir_all(upload_dir) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let file_id = uuid::Uuid::new_v4(); + let path = format!("{}/{}.{}", upload_dir, file_id, ext); + tokio::fs::write(&path, &data) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + image_path = Some(path); + } + _ => {} + } + } + + if !text.trim().is_empty() { + let max_order: i32 = Testimonial::objects() + .all(&db) + .await? + .iter() + .map(|t| t.sort_order) + .max() + .unwrap_or(0); + let mut testimonial = Testimonial { + id: Auto::auto(), + text: text.trim().to_string(), + author_note: if author_note.trim().is_empty() { + None + } else { + Some(author_note.trim().to_string()) + }, + image_path, + status: "active".to_string(), + sort_order: max_order + 1, + created_at: now(), + }; + testimonial.save(&db).await?; + } + + Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response() +} + +async fn testimonial_toggle( + request: Request, + session: Session, + db: Database, + Path(id): Path, +) -> cot::Result { + let lang = detect_lang(&request); + if let Err(resp) = require_auth(&session, lang).await { + return Ok(resp); + } + if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? { + t.status = if t.status == "active" { + "hidden".to_string() + } else { + "active".to_string() + }; + t.save(&db).await?; + } + Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response() +} + +async fn testimonial_delete( + request: Request, + session: Session, + db: Database, + Path(id): Path, +) -> cot::Result { + let lang = detect_lang(&request); + if let Err(resp) = require_auth(&session, lang).await { + return Ok(resp); + } + if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? { + t.status = "deleted".to_string(); + t.save(&db).await?; + } + Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response() +} + +/// Serve testimonial images (public, no auth needed for landing page). +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()); Ok(resp) } Err(_) => Html::new("404").into_response(), @@ -1675,19 +1868,55 @@ pub fn admin_router() -> Router { Route::with_handler_and_name("/clients", clients_page, "admin-clients"), Route::with_handler_and_name("/clients/new", client_new_page, "admin-client-new"), Route::with_handler_and_name("/clients/add", add_client, "admin-client-add"), - Route::with_handler_and_name("/clients/{client_id}/edit", client_edit_page, "admin-client-edit"), - Route::with_handler_and_name("/clients/{client_id}/save", client_edit_submit, "admin-client-edit-submit"), - Route::with_handler_and_name("/clients/{client_id}/archive", client_archive, "admin-client-archive"), - Route::with_handler_and_name("/clients/{client_id}/activate", client_activate, "admin-client-activate"), + Route::with_handler_and_name( + "/clients/{client_id}/edit", + client_edit_page, + "admin-client-edit", + ), + Route::with_handler_and_name( + "/clients/{client_id}/save", + client_edit_submit, + "admin-client-edit-submit", + ), + Route::with_handler_and_name( + "/clients/{client_id}/archive", + client_archive, + "admin-client-archive", + ), + Route::with_handler_and_name( + "/clients/{client_id}/activate", + client_activate, + "admin-client-activate", + ), Route::with_handler_and_name("/schedule", schedule_page, "admin-schedule"), Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"), Route::with_handler_and_name("/schedule/events", schedule_events, "admin-schedule-events"), Route::with_handler_and_name("/schedule/create", schedule_create, "admin-schedule-create"), - Route::with_handler_and_name("/schedule/{visit_id}/edit", schedule_edit_page, "admin-visit-edit"), - Route::with_handler_and_name("/schedule/{visit_id}/save", schedule_edit_submit, "admin-visit-save"), - Route::with_handler_and_name("/schedule/{visit_id}/delete", visit_delete, "admin-visit-delete"), - Route::with_handler_and_name("/schedule/{visit_id}/done", visit_set_done, "admin-visit-done"), - Route::with_handler_and_name("/schedule/{visit_id}/cancel", visit_set_cancel, "admin-visit-cancel"), + Route::with_handler_and_name( + "/schedule/{visit_id}/edit", + schedule_edit_page, + "admin-visit-edit", + ), + Route::with_handler_and_name( + "/schedule/{visit_id}/save", + schedule_edit_submit, + "admin-visit-save", + ), + Route::with_handler_and_name( + "/schedule/{visit_id}/delete", + visit_delete, + "admin-visit-delete", + ), + Route::with_handler_and_name( + "/schedule/{visit_id}/done", + visit_set_done, + "admin-visit-done", + ), + Route::with_handler_and_name( + "/schedule/{visit_id}/cancel", + visit_set_cancel, + "admin-visit-cancel", + ), Route::with_handler_and_name("/users", users_page, "admin-users"), Route::with_handler_and_name("/users/add", add_user, "admin-user-add"), Route::with_handler_and_name( @@ -1701,10 +1930,43 @@ pub fn admin_router() -> Router { "admin-user-activate", ), Route::with_handler_and_name("/media", media_page, "admin-media"), - Route::with_handler_and_name("/media/{visit_id}/upload", media_upload_page, "admin-media-upload"), - Route::with_handler_and_name("/media/{visit_id}/upload/submit", media_upload_submit, "admin-media-upload-submit"), - Route::with_handler_and_name("/media/{media_id}/delete", media_delete, "admin-media-delete"), + Route::with_handler_and_name( + "/media/{visit_id}/upload", + media_upload_page, + "admin-media-upload", + ), + Route::with_handler_and_name( + "/media/{visit_id}/upload/submit", + media_upload_submit, + "admin-media-upload-submit", + ), + Route::with_handler_and_name( + "/media/{media_id}/delete", + media_delete, + "admin-media-delete", + ), Route::with_handler_and_name("/uploads/{media_id}", serve_upload, "admin-uploads"), + Route::with_handler_and_name("/testimonials", testimonials_page, "admin-testimonials"), + Route::with_handler_and_name( + "/testimonials/add", + testimonial_add, + "admin-testimonial-add", + ), + Route::with_handler_and_name( + "/testimonials/{id}/toggle", + testimonial_toggle, + "admin-testimonial-toggle", + ), + Route::with_handler_and_name( + "/testimonials/{id}/delete", + testimonial_delete, + "admin-testimonial-delete", + ), + Route::with_handler_and_name( + "/testimonials/{id}/image", + serve_testimonial_image, + "admin-testimonial-image", + ), Route::with_handler_and_name("/settings", settings_page, "admin-settings-get"), Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"), ]) diff --git a/src/i18n.rs b/src/i18n.rs index 5ee9159..22581c0 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -129,7 +129,9 @@ pub struct Translations { pub settings_telegram_bot_token: &'static str, pub settings_telegram_chat_id: &'static str, pub settings_contact_info: &'static str, + pub settings_pricing_info: &'static str, pub landing_contact_label: &'static str, + pub landing_pricing_title: &'static str, // Dashboard pub dashboard_title: &'static str, @@ -151,14 +153,15 @@ pub struct Translations { pub landing_meta_description: &'static str, pub landing_hero_title: &'static str, pub landing_hero_subtitle: &'static str, + pub landing_hero_description: &'static str, pub landing_hero_cta: &'static str, pub landing_services_title: &'static str, pub landing_service_cats_title: &'static str, pub landing_service_cats_text: &'static str, - pub landing_service_dogs_title: &'static str, - pub landing_service_dogs_text: &'static str, - pub landing_service_home_title: &'static str, - pub landing_service_home_text: &'static str, + pub landing_service_exotic_title: &'static str, + pub landing_service_exotic_text: &'static str, + pub landing_service_bonus_title: &'static str, + pub landing_service_bonus_text: &'static str, pub landing_how_title: &'static str, pub landing_how_step1_title: &'static str, pub landing_how_step1_text: &'static str, @@ -176,8 +179,22 @@ pub struct Translations { pub landing_thank_you_title: &'static str, pub landing_thank_you_text: &'static str, pub landing_thank_you_back: &'static str, + pub landing_guarantee: &'static str, + pub landing_testimonials_title: &'static str, pub landing_footer_text: &'static str, + // Testimonials admin + pub nav_testimonials: &'static str, + pub testimonials_title: &'static str, + pub testimonials_empty: &'static str, + pub testimonials_add_title: &'static str, + pub testimonials_text: &'static str, + pub testimonials_author_note: &'static str, + pub testimonials_image: &'static str, + pub testimonials_add_button: &'static str, + pub testimonials_status_active: &'static str, + pub testimonials_status_hidden: &'static str, + // Client edit pub clients_edit_title: &'static str, pub clients_save: &'static str, @@ -315,7 +332,9 @@ static RU: Translations = Translations { settings_telegram_bot_token: "Токен Telegram бота", settings_telegram_chat_id: "Chat ID для уведомлений", settings_contact_info: "Контактная информация (отображается на лендинге)", + settings_pricing_info: "Блок с ценами (отображается на лендинге)", landing_contact_label: "Или свяжитесь с нами напрямую", + landing_pricing_title: "Стоимость", dashboard_title: "Главная", dashboard_today_visits: "Визиты на сегодня", @@ -392,24 +411,25 @@ static RU: Translations = Translations { schedule_delete: "Удалить визит", schedule_delete_confirm: "Точно удалить этот визит?", - landing_meta_description: "Профессиональный пет-ситтинг: кормление кошек, выгул собак, уход за питомцами пока вы в отпуске. Оставьте заявку — позаботимся о вашем любимце!", + landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!", landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома", - landing_hero_subtitle: "Кормление кошек, выгул собак, ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке", + landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.", + landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.", landing_hero_cta: "Оставить заявку", landing_services_title: "Наши услуги", - landing_service_cats_title: "Кормление кошек", - landing_service_cats_text: "Приедем к вам домой, покормим кошку, поменяем воду и лоток, поиграем и проверим, что всё в порядке", - landing_service_dogs_title: "Выгул собак", - landing_service_dogs_text: "Погуляем с вашей собакой по привычному маршруту, покормим и проследим за самочувствием питомца", - landing_service_home_title: "Домашние визиты", - landing_service_home_text: "Регулярные визиты к вам домой: проверим питомца, польём цветы, заберём почту — всё будет как при вас", + landing_service_cats_title: "Кошки", + landing_service_cats_text: "Приедем к вам домой, покормим, помоем миски, поменяем воду, уберём лоток, поиграем, погладим, поговорим — всё, что пожелает ваш любимец. Уделим ей максимум внимания. Отправим вам фото/видео отчёт с рассказом, как всё прошло.", + landing_service_exotic_title: "Грызуны, рептилии", + landing_service_exotic_text: "Приедем к вам домой, покормим, уберём в клетке. Отправим вам фото/видео отчёт с рассказом, как всё прошло.", + landing_service_bonus_title: "Цветы, рыбки", + landing_service_bonus_text: "Бонусом польём цветы, покормим рыбок, заберём почту — будет всё, как при вас.", landing_how_title: "Как это работает", landing_how_step1_title: "Оставьте заявку", - landing_how_step1_text: "Заполните форму ниже — укажите имя и телефон. Мы свяжемся с вами в течение часа", + landing_how_step1_text: "Заполните форму ниже — укажите имя и телефон. Мы свяжемся с вами в ближайшее время", landing_how_step2_title: "Обсудим детали", - landing_how_step2_text: "Познакомимся с вашим питомцем, обсудим расписание визитов и особые пожелания", + landing_how_step2_text: "Встретимся, познакомимся с вашим питомцем, обсудим расписание визитов и особые пожелания. Предъявим документ, удостоверяющий личность.", landing_how_step3_title: "Заботимся о питомце", - landing_how_step3_text: "Пока вас нет — мы рядом. После каждого визита отправим фото и отчёт о самочувствии", + landing_how_step3_text: "Пока вас нет — мы рядом. После каждого визита отправим фото и отчёт о самочувствии вашего питомца.", landing_form_title: "Оставить заявку", landing_form_subtitle: "Расскажите о себе, и мы свяжемся с вами в ближайшее время", landing_form_name: "Ваше имя", @@ -420,8 +440,21 @@ static RU: Translations = Translations { landing_thank_you_title: "Спасибо за заявку!", landing_thank_you_text: "Мы получили вашу заявку и свяжемся с вами в ближайшее время.", landing_thank_you_back: "Вернуться на главную", + landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.", + landing_testimonials_title: "Отзывы", landing_footer_text: "Пет-ситтинг — забота о вашем питомце", + nav_testimonials: "Отзывы", + testimonials_title: "Отзывы", + testimonials_empty: "Отзывов пока нет.", + testimonials_add_title: "Добавить отзыв", + testimonials_text: "Текст отзыва", + testimonials_author_note: "Имя / тип питомца", + testimonials_image: "Фото", + testimonials_add_button: "Добавить", + testimonials_status_active: "Отображается", + testimonials_status_hidden: "Скрыт", + no_value: "—", action_convert: "Конвертировать", action_reject: "Отклонить", @@ -492,7 +525,9 @@ static EN: Translations = Translations { settings_telegram_bot_token: "Telegram Bot Token", settings_telegram_chat_id: "Notification Chat ID", settings_contact_info: "Contact info (shown on landing page)", + settings_pricing_info: "Pricing block (shown on landing page)", landing_contact_label: "Or contact us directly", + landing_pricing_title: "Pricing", dashboard_title: "Home", dashboard_today_visits: "Today's visits", @@ -569,24 +604,25 @@ static EN: Translations = Translations { schedule_delete: "Delete visit", schedule_delete_confirm: "Are you sure you want to delete this visit?", - landing_meta_description: "Professional pet sitting: cat feeding, dog walking, home visits while you're away. Leave a request — we'll take care of your pet!", + landing_meta_description: "Professional pet sitting: feeding and care for cats, rodents, reptiles at your home. Leave a request — we'll take care of your pet!", landing_hero_title: "We'll take care of your pet while you're away", - landing_hero_subtitle: "Cat feeding, dog walking, daily visits — your pet is in safe hands while you're on vacation or a business trip", + landing_hero_subtitle: "Feeding and care for cats, rodents, and reptiles at your home. Daily visits — your pet is in safe hands while you're on vacation or a business trip.", + landing_hero_description: "Why is it better to leave your cat at home while you're away rather than placing them in a pet hotel? As we know, cats are territorial animals. When a cat finds itself in an unfamiliar environment, it can experience stress. Moreover, in pet hotels animals are often kept in cages. But cats love freedom. Waiting for their owners at home is much easier and more comfortable for them.", landing_hero_cta: "Leave a Request", landing_services_title: "Our Services", - landing_service_cats_title: "Cat Feeding", - landing_service_cats_text: "We'll visit your home, feed the cat, change water and litter, play and make sure everything is fine", - landing_service_dogs_title: "Dog Walking", - landing_service_dogs_text: "We'll walk your dog on their usual route, feed them and keep an eye on their well-being", - landing_service_home_title: "Home Visits", - landing_service_home_text: "Regular home visits: check on your pet, water the plants, collect mail — everything as if you were home", + landing_service_cats_title: "Cats", + landing_service_cats_text: "We'll come to your home, feed your cat, wash the bowls, change the water, clean the litter box, play, pet, and talk to them — everything your beloved pet desires. We'll give them maximum attention. We'll send you a photo/video report about how it went.", + landing_service_exotic_title: "Rodents, Reptiles", + landing_service_exotic_text: "We'll come to your home, feed them, clean the cage. We'll send you a photo/video report about how it went.", + landing_service_bonus_title: "Plants, Fish", + landing_service_bonus_text: "As a bonus, we'll water the plants, feed the fish, collect the mail — everything will be just like when you're home.", landing_how_title: "How It Works", landing_how_step1_title: "Leave a Request", - landing_how_step1_text: "Fill out the form below — just your name and phone. We'll contact you within an hour", + landing_how_step1_text: "Fill out the form below — just your name and phone. We'll contact you shortly", landing_how_step2_title: "Discuss the Details", - landing_how_step2_text: "We'll meet your pet, discuss the visit schedule and any special requirements", + landing_how_step2_text: "We'll meet, get to know your pet, discuss the visit schedule and any special requirements. We'll present an ID document.", landing_how_step3_title: "We Take Care", - landing_how_step3_text: "While you're away — we're here. After each visit we'll send photos and a wellness report", + landing_how_step3_text: "While you're away — we're here. After each visit we'll send photos and a report on your pet's well-being.", landing_form_title: "Leave a Request", landing_form_subtitle: "Tell us about yourself and we'll get back to you shortly", landing_form_name: "Your Name", @@ -597,8 +633,21 @@ static EN: Translations = Translations { landing_thank_you_title: "Thank you!", landing_thank_you_text: "We've received your request and will contact you shortly.", landing_thank_you_back: "Back to Home", + landing_guarantee: "Integrity, honesty, and strict fulfillment of your requirements guaranteed.", + landing_testimonials_title: "Testimonials", landing_footer_text: "Pet Sitting — caring for your pet", + nav_testimonials: "Testimonials", + testimonials_title: "Testimonials", + testimonials_empty: "No testimonials yet.", + testimonials_add_title: "Add Testimonial", + testimonials_text: "Review text", + testimonials_author_note: "Name / pet type", + testimonials_image: "Photo", + testimonials_add_button: "Add", + testimonials_status_active: "Visible", + testimonials_status_hidden: "Hidden", + no_value: "—", action_convert: "Convert", action_reject: "Reject", diff --git a/src/migrations.rs b/src/migrations.rs index 3d79d09..2cc87bc 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -6,10 +6,12 @@ pub mod m_0001_initial; pub mod m_0002_visit_schedule; pub mod m_0003_visit_feedback; pub mod m_0004_visit_public_notes; +pub mod m_0005_testimonials; /// The list of migrations for current app. pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ &m_0001_initial::Migration, &m_0002_visit_schedule::Migration, &m_0003_visit_feedback::Migration, &m_0004_visit_public_notes::Migration, + &m_0005_testimonials::Migration, ]; diff --git a/src/migrations/m_0002_visit_schedule.rs b/src/migrations/m_0002_visit_schedule.rs index fc5b28d..7742732 100644 --- a/src/migrations/m_0002_visit_schedule.rs +++ b/src/migrations/m_0002_visit_schedule.rs @@ -7,9 +7,11 @@ pub(super) struct Migration; impl ::cot::db::migrations::Migration for Migration { const APP_NAME: &'static str = "web-petting"; const MIGRATION_NAME: &'static str = "m_0002_visit_schedule"; - const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ - ::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0001_initial"), - ]; + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = + &[::cot::db::migrations::MigrationDependency::migration( + "web-petting", + "m_0001_initial", + )]; const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ // Add color to client (nullable for existing rows) ::cot::db::migrations::Operation::add_field() diff --git a/src/migrations/m_0003_visit_feedback.rs b/src/migrations/m_0003_visit_feedback.rs index 7d9c444..fae36ef 100644 --- a/src/migrations/m_0003_visit_feedback.rs +++ b/src/migrations/m_0003_visit_feedback.rs @@ -5,19 +5,20 @@ pub(super) struct Migration; impl ::cot::db::migrations::Migration for Migration { const APP_NAME: &'static str = "web-petting"; const MIGRATION_NAME: &'static str = "m_0003_visit_feedback"; - const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ - ::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0002_visit_schedule"), - ]; - const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ - ::cot::db::migrations::Operation::add_field() + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = + &[::cot::db::migrations::MigrationDependency::migration( + "web-petting", + "m_0002_visit_schedule", + )]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = + &[::cot::db::migrations::Operation::add_field() .table_name(::cot::db::Identifier::new("web_petting__visit")) .field( ::cot::db::migrations::Field::new( ::cot::db::Identifier::new("client_feedback"), as ::cot::db::DatabaseField>::TYPE, ) - .set_null( as ::cot::db::DatabaseField>::NULLABLE) + .set_null( as ::cot::db::DatabaseField>::NULLABLE), ) - .build(), - ]; + .build()]; } diff --git a/src/migrations/m_0004_visit_public_notes.rs b/src/migrations/m_0004_visit_public_notes.rs index d83563b..f957632 100644 --- a/src/migrations/m_0004_visit_public_notes.rs +++ b/src/migrations/m_0004_visit_public_notes.rs @@ -5,19 +5,20 @@ pub(super) struct Migration; impl ::cot::db::migrations::Migration for Migration { const APP_NAME: &'static str = "web-petting"; const MIGRATION_NAME: &'static str = "m_0004_visit_public_notes"; - const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ - ::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0003_visit_feedback"), - ]; - const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ - ::cot::db::migrations::Operation::add_field() + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = + &[::cot::db::migrations::MigrationDependency::migration( + "web-petting", + "m_0003_visit_feedback", + )]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = + &[::cot::db::migrations::Operation::add_field() .table_name(::cot::db::Identifier::new("web_petting__visit")) .field( ::cot::db::migrations::Field::new( ::cot::db::Identifier::new("public_notes"), as ::cot::db::DatabaseField>::TYPE, ) - .set_null( as ::cot::db::DatabaseField>::NULLABLE) + .set_null( as ::cot::db::DatabaseField>::NULLABLE), ) - .build(), - ]; + .build()]; } diff --git a/src/migrations/m_0005_testimonials.rs b/src/migrations/m_0005_testimonials.rs new file mode 100644 index 0000000..424429d --- /dev/null +++ b/src/migrations/m_0005_testimonials.rs @@ -0,0 +1,56 @@ +//! Migration: create Testimonial table + +#[derive(Debug, Copy, Clone)] +pub(super) struct Migration; +impl ::cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = "web-petting"; + const MIGRATION_NAME: &'static str = "m_0005_testimonials"; + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = + &[::cot::db::migrations::MigrationDependency::migration( + "web-petting", + "m_0004_visit_public_notes", + )]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = + &[::cot::db::migrations::Operation::create_model() + .table_name(::cot::db::Identifier::new("web_petting__testimonial")) + .fields(&[ + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("id"), + as ::cot::db::DatabaseField>::TYPE, + ) + .auto() + .primary_key() + .set_null( as ::cot::db::DatabaseField>::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("text"), + ::TYPE, + ) + .set_null(::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("author_note"), + as ::cot::db::DatabaseField>::TYPE, + ) + .set_null( as ::cot::db::DatabaseField>::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("image_path"), + as ::cot::db::DatabaseField>::TYPE, + ) + .set_null( as ::cot::db::DatabaseField>::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("status"), + ::TYPE, + ) + .set_null(::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("sort_order"), + ::TYPE, + ) + .set_null(::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("created_at"), + ::TYPE, + ) + .set_null(::NULLABLE), + ]) + .build()]; +} diff --git a/src/models.rs b/src/models.rs index 426882a..a077fef 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,4 @@ -use cot::db::{model, Auto, ForeignKey}; +use cot::db::{Auto, ForeignKey, model}; /// Lead status: new request from the website /// new -> in_progress -> converted | rejected @@ -185,6 +185,23 @@ pub struct User { pub updated_at: chrono::NaiveDateTime, } +/// A testimonial/review displayed on the landing page. +#[derive(Debug, Clone)] +#[model] +pub struct Testimonial { + #[model(primary_key)] + pub id: Auto, + pub text: String, + /// Optional short note (e.g. client name, pet type). + pub author_note: Option, + /// Optional image path. + pub image_path: Option, + /// active | hidden + pub status: String, + pub sort_order: i32, + pub created_at: chrono::NaiveDateTime, +} + /// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.). #[derive(Debug, Clone)] #[model] diff --git a/src/public.rs b/src/public.rs index 49d845f..d35efcc 100644 --- a/src/public.rs +++ b/src/public.rs @@ -1,16 +1,16 @@ +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 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::models::{Client, Lead, Media, Setting, Testimonial, User, Visit}; use crate::telegram; fn detect_lang(request: &Request) -> Lang { @@ -70,6 +70,8 @@ struct LandingTemplate<'a> { t: &'a Translations, lang: Lang, contact_info: String, + pricing_info: String, + testimonials: Vec, } #[derive(Debug, Template)] @@ -87,7 +89,23 @@ async fn landing_page(request: Request, db: Database) -> cot::Result { .await? .map(|s| s.value) .unwrap_or_default(); - let body = LandingTemplate { t: lang.t(), lang, contact_info }.render()?; + 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) } @@ -173,10 +191,12 @@ async fn client_portal( 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.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)) }); - 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?; @@ -200,7 +220,11 @@ async fn client_portal( }) .cloned() .collect(); - PortalVisit { visit: v, admin_name, media } + PortalVisit { + visit: v, + admin_name, + media, + } }; let mut upcoming = Vec::new(); @@ -258,7 +282,12 @@ async fn submit_feedback( } } - Redirect::new(format!("/client/{}?lang={}&feedback=ok", token_clone, lang.code())).into_response() + 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). @@ -303,10 +332,49 @@ async fn portal_media( } } +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", diff --git a/src/telegram.rs b/src/telegram.rs index 1af95d1..c90fc33 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -4,7 +4,12 @@ use crate::models::Setting; /// Send a Telegram message using bot settings from DB. /// Silently ignores errors (missing config, network issues) — notifications are best-effort. -pub async fn notify_new_lead(db: &Database, name: &str, phone: Option<&str>, comment: Option<&str>) { +pub async fn notify_new_lead( + db: &Database, + name: &str, + phone: Option<&str>, + comment: Option<&str>, +) { let token = match get_setting(db, "telegram_bot_token").await { Some(t) if !t.is_empty() => t, _ => return, diff --git a/templates/admin/layout.html b/templates/admin/layout.html index 8608e57..9af5017 100644 --- a/templates/admin/layout.html +++ b/templates/admin/layout.html @@ -98,6 +98,7 @@ {{ t.nav_clients }} {{ t.nav_schedule }} {{ t.nav_media }} + {{ t.nav_testimonials }} {{ t.nav_users }} {{ t.nav_settings }} @@ -130,6 +131,9 @@ 📷{{ t.nav_media }} + + 💬{{ t.nav_testimonials }} + 🔑{{ t.nav_users }} diff --git a/templates/admin/settings.html b/templates/admin/settings.html index c675088..412e7e3 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -32,6 +32,12 @@ +
+ +
+ +
+
diff --git a/templates/admin/testimonials.html b/templates/admin/testimonials.html new file mode 100644 index 0000000..03b1216 --- /dev/null +++ b/templates/admin/testimonials.html @@ -0,0 +1,74 @@ +{% extends "admin/layout.html" %} +{% let active_page = "testimonials" %} + +{% block title %}{{ t.testimonials_title }}{% endblock %} + +{% block content %} +
+

{{ t.testimonials_title }}

+
+ + +
+

{{ t.testimonials_add_title }}

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ + +{% if testimonials.is_empty() %} +

{{ t.testimonials_empty }}

+{% else %} + {% for item in &testimonials %} +
+
+
+ {% if item.image_path.is_some() %} + + {% endif %} +
+
{{ item.text }}
+ {% if let Some(note) = item.author_note.as_deref() %} +
{{ note }}
+ {% endif %} +
+
+ + {% if item.status == "active" %}{{ t.testimonials_status_active }}{% else %}{{ t.testimonials_status_hidden }}{% endif %} + +
+
+
+ {% if item.status == "active" %} + + {% else %} + + {% endif %} +
+
+ +
+
+
+ {% endfor %} +{% endif %} +{% endblock %} diff --git a/templates/landing.html b/templates/landing.html index 38ff626..a55e097 100644 --- a/templates/landing.html +++ b/templates/landing.html @@ -97,6 +97,62 @@ } .hero-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); } .hero-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; } + .hero-desc { + font-size: clamp(0.9rem, 2vw, 1.05rem); + color: #7a7599; max-width: 640px; margin: 0 auto 2rem; + line-height: 1.7; + } + .guarantee { + text-align: center; padding: 2rem 1.5rem; + font-size: 1.1rem; font-weight: 600; color: #2d2b55; + font-style: italic; + } + + /* ── Testimonials ── */ + .testimonials-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + } + .testimonial-card { + background: rgba(255,255,255,0.55); backdrop-filter: blur(10px); + border-radius: 18px; padding: 1.75rem; + border: 1px solid rgba(180,170,220,0.2); + transition: transform 0.2s, box-shadow 0.2s; + } + .testimonial-card:hover { + transform: translateY(-3px); + box-shadow: 0 10px 30px rgba(124,108,255,0.1); + } + .testimonial-text { + font-size: 0.95rem; line-height: 1.7; color: #5a5680; + font-style: italic; margin-bottom: 1rem; + } + .testimonial-text::before { content: "\201C"; font-size: 1.5rem; color: #b06cff; margin-right: 0.2rem; } + .testimonial-text::after { content: "\201D"; font-size: 1.5rem; color: #b06cff; margin-left: 0.2rem; } + .testimonial-footer { + display: flex; align-items: center; gap: 0.75rem; + } + .testimonial-avatar { + width: 44px; height: 44px; border-radius: 50%; object-fit: cover; + border: 2px solid rgba(180,170,220,0.3); + } + .testimonial-note { + font-size: 0.85rem; color: #7a7599; font-weight: 600; + } + + /* ── Pricing ── */ + .pricing-block { + max-width: 600px; margin: 0 auto; + background: rgba(255,255,255,0.55); backdrop-filter: blur(10px); + border-radius: 18px; padding: 2rem 2.5rem; + border: 1px solid rgba(180,170,220,0.2); + text-align: center; + } + .pricing-text { + font-size: clamp(1.1rem, 2.5vw, 1.3rem); + color: #2d2b55; font-weight: 700; line-height: 1.7; + white-space: pre-line; + } /* ── Section common ── */ .section { padding: 5rem 1.5rem; background: transparent; } @@ -230,9 +286,10 @@
- 🐱🐶 + 🐱🐹🦎

{{ t.landing_hero_title }}

{{ t.landing_hero_subtitle }}

+

{{ t.landing_hero_description }}

{{ t.landing_hero_cta }}
@@ -247,19 +304,31 @@

{{ t.landing_service_cats_text }}

- 🐕 -

{{ t.landing_service_dogs_title }}

-

{{ t.landing_service_dogs_text }}

+ 🐹🦎 +

{{ t.landing_service_exotic_title }}

+

{{ t.landing_service_exotic_text }}

- 🏠 -

{{ t.landing_service_home_title }}

-

{{ t.landing_service_home_text }}

+ 🌿🐟 +

{{ t.landing_service_bonus_title }}

+

{{ t.landing_service_bonus_text }}

+ +{% if !pricing_info.is_empty() %} +
+
+

{{ t.landing_pricing_title }}

+
+

{{ pricing_info }}

+
+
+
+{% endif %} +
@@ -284,6 +353,37 @@
+ +{% if !testimonials.is_empty() %} +
+
+

{{ t.landing_testimonials_title }}

+
+ {% for item in &testimonials %} +
+
{{ item.text }}
+ {% if item.image_path.is_some() || item.author_note.is_some() %} + + {% endif %} +
+ {% endfor %} +
+
+
+{% endif %} + + +
+

{{ t.landing_guarantee }}

+
+
@@ -323,10 +423,8 @@ var icons = [ // Cat face '', - // Dog face - '', - // Bone - '', + // Leaf + '', // Bowl '', // Paw print