Added reviews. Added pricing.
Build and Publish / Build and Publish Docker Image (push) Successful in 6m27s

This commit is contained in:
Ultradesu
2026-05-11 11:34:11 +01:00
parent ff32e6bbaf
commit d1ef66acc1
14 changed files with 839 additions and 194 deletions
+390 -128
View File
@@ -1,15 +1,15 @@
use cot::Template;
use cot::db::{Auto, Database, ForeignKey, Model, query}; use cot::db::{Auto, Database, ForeignKey, Model, query};
use cot::html::Html; use cot::html::Html;
use cot::request::extractors::Path;
use cot::request::Request; use cot::request::Request;
use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response}; use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router}; use cot::router::{Route, Router};
use cot::session::Session; use cot::session::Session;
use cot::Template;
use serde::Deserialize; use serde::Deserialize;
use crate::i18n::{Lang, Translations}; 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; use crate::telegram;
const SESSION_USER_ID: &str = "user_id"; const SESSION_USER_ID: &str = "user_id";
@@ -81,16 +81,18 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
request request
.uri() .uri()
.query() .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) .unwrap_or(false)
} }
/// Soft pastel palette for client calendar colors. /// Soft pastel palette for client calendar colors.
const CLIENT_COLORS: &[&str] = &[ const CLIENT_COLORS: &[&str] = &[
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
"#8e6bbf", "#5cb8a5", "#c77c4f", "#a3729a", "#6b9e5e", "#a3729a", "#6b9e5e", "#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e", "#9476b8",
"#d48b6c", "#7a8fc4", "#c45d7c", "#5eab7d", "#b8864e", "#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e",
"#9476b8", "#6aafb5", "#d4785e", "#7f8e5b", "#b56c9e",
]; ];
fn rand_client_color() -> &'static str { fn rand_client_color() -> &'static str {
@@ -136,11 +138,7 @@ async fn get_admin_name(session: &Session) -> Option<String> {
/// Get admin user ID from session. /// Get admin user ID from session.
async fn get_admin_id(session: &Session) -> Option<i64> { async fn get_admin_id(session: &Session) -> Option<i64> {
session session.get::<i64>(SESSION_USER_ID).await.ok().flatten()
.get::<i64>(SESSION_USER_ID)
.await
.ok()
.flatten()
} }
/// Redirect to login if not authenticated. /// Redirect to login if not authenticated.
@@ -333,11 +331,7 @@ async fn has_any_admin(db: &Database) -> cot::Result<bool> {
Ok(count > 0) Ok(count > 0)
} }
async fn login_page( async fn login_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
if get_admin_name(&session).await.is_some() { if get_admin_name(&session).await.is_some() {
return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response(); return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response();
@@ -380,11 +374,7 @@ struct SetupForm {
password_confirm: String, password_confirm: String,
} }
async fn setup_submit( async fn setup_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, SetupForm) = parse_form_from_request(request).await?; let (lang, form): (_, SetupForm) = parse_form_from_request(request).await?;
// Block if admins already exist // Block if admins already exist
@@ -431,11 +421,7 @@ struct LoginForm {
password: String, password: String,
} }
async fn login_submit( async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?; let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?;
let login = form.login.clone(); let login = form.login.clone();
@@ -450,9 +436,7 @@ async fn login_submit(
.as_deref() .as_deref()
.unwrap_or(&user.login) .unwrap_or(&user.login)
.to_string(); .to_string();
session session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
.insert(SESSION_USER_ID, user.id.unwrap())
.await?;
session.insert(SESSION_USER_NAME, display).await?; session.insert(SESSION_USER_NAME, display).await?;
return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response(); return Redirect::new(format!("/admin/leads?lang={}", lang.code())).into_response();
} }
@@ -477,11 +461,7 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
// GET Handlers (protected) // GET Handlers (protected)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn admin_index( async fn admin_index(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -503,7 +483,9 @@ async fn admin_index(
client_name: client.map(|c| c.name.clone()).unwrap_or_default(), client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
client_phone: client.and_then(|c| c.phone.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_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(), visit: v.clone(),
} }
}) })
@@ -520,8 +502,11 @@ async fn admin_index(
}) })
.map(|v| { .map(|v| {
let cid: i64 = v.client_id.primary_key().unwrap(); let cid: i64 = v.client_id.primary_key().unwrap();
let client_name = clients.iter().find(|c| c.id.unwrap() == cid) let client_name = clients
.map(|c| c.name.clone()).unwrap_or_default(); .iter()
.find(|c| c.id.unwrap() == cid)
.map(|c| c.name.clone())
.unwrap_or_default();
RecentFeedback { RecentFeedback {
visit_id: v.id.unwrap(), visit_id: v.id.unwrap(),
client_name, client_name,
@@ -543,11 +528,7 @@ async fn admin_index(
html_response(body, lang) html_response(body, lang)
} }
async fn leads_page( async fn leads_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -570,11 +551,7 @@ async fn leads_page(
html_response(body, lang) html_response(body, lang)
} }
async fn clients_page( async fn clients_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -597,10 +574,7 @@ async fn clients_page(
html_response(body, lang) html_response(body, lang)
} }
async fn client_new_page( async fn client_new_page(request: Request, session: Session) -> cot::Result<Response> {
request: Request,
session: Session,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -642,7 +616,9 @@ async fn client_edit_page(
}; };
let client = match query!(Client, $id == client_id).get(&db).await? { let client = match query!(Client, $id == client_id).get(&db).await? {
Some(c) => c, 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 t = lang.t();
let action_url = format!("/admin/clients/{}/save", client.id); 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() Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
} }
async fn users_page( async fn users_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -716,11 +688,7 @@ async fn users_page(
html_response(body, lang) html_response(body, lang)
} }
async fn settings_page( async fn settings_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -750,11 +718,7 @@ struct AddUserForm {
password_confirm: String, password_confirm: String,
} }
async fn add_user( async fn add_user(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, AddUserForm) = parse_form_from_request(request).await?; let (lang, form): (_, AddUserForm) = parse_form_from_request(request).await?;
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -805,13 +769,10 @@ struct SettingsForm {
telegram_bot_token: String, telegram_bot_token: String,
telegram_chat_id: String, telegram_chat_id: String,
contact_info: String, contact_info: String,
pricing_info: String,
} }
async fn save_settings( async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, SettingsForm) = parse_form_from_request(request).await?; let (lang, form): (_, SettingsForm) = parse_form_from_request(request).await?;
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -822,6 +783,7 @@ async fn save_settings(
("telegram_bot_token", form.telegram_bot_token), ("telegram_bot_token", form.telegram_bot_token),
("telegram_chat_id", form.telegram_chat_id), ("telegram_chat_id", form.telegram_chat_id),
("contact_info", form.contact_info), ("contact_info", form.contact_info),
("pricing_info", form.pricing_info),
] { ] {
let k = key.to_string(); let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?; let existing = query!(Setting, $key == k).get(&db).await?;
@@ -996,11 +958,7 @@ struct AddLeadForm {
comment: Option<String>, comment: Option<String>,
} }
async fn add_lead( async fn add_lead(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, AddLeadForm) = parse_form_from_request(request).await?; let (lang, form): (_, AddLeadForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await { if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp); return Ok(resp);
@@ -1040,11 +998,7 @@ struct AddClientForm {
color: Option<String>, color: Option<String>,
} }
async fn add_client( async fn add_client(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let (lang, form): (_, AddClientForm) = parse_form_from_request(request).await?; let (lang, form): (_, AddClientForm) = parse_form_from_request(request).await?;
if let Err(resp) = require_auth(&session, lang).await { if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp); return Ok(resp);
@@ -1072,10 +1026,7 @@ async fn add_client(
// Schedule Handlers // Schedule Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn schedule_page( async fn schedule_page(request: Request, session: Session) -> cot::Result<Response> {
request: Request,
session: Session,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, 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_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_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_address = client.and_then(|c| c.address.as_deref()).unwrap_or("");
let client_color = client let client_color = client.and_then(|c| c.color.as_deref()).unwrap_or("#7c6ed4");
.and_then(|c| c.color.as_deref())
.unwrap_or("#7c6ed4");
let admin_name = user let admin_name = user
.map(|u| u.display_name.as_deref().unwrap_or(&u.login)) .map(|u| u.display_name.as_deref().unwrap_or(&u.login))
.unwrap_or("?"); .unwrap_or("?");
@@ -1276,7 +1225,9 @@ async fn schedule_edit_page(
}; };
let visit = match query!(Visit, $id == visit_id).get(&db).await? { let visit = match query!(Visit, $id == visit_id).get(&db).await? {
Some(v) => v, 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 clients = query!(Client, $status == "active").all(&db).await?;
let users = query!(User, $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 // Media Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn media_page( async fn media_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await { let admin_name = match require_auth(&session, lang).await {
Ok(name) => name, Ok(name) => name,
@@ -1432,10 +1379,14 @@ async fn media_page(
.map(|m| { .map(|m| {
let cid: i64 = m.client_id.primary_key().unwrap(); let cid: i64 = m.client_id.primary_key().unwrap();
let client = clients_all.iter().find(|c| c.id.unwrap() == cid); let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
let visit_date = m.visit_id.as_ref().and_then(|fk| { let visit_date = m
let vid: i64 = fk.primary_key().unwrap(); .visit_id
visits_all.iter().find(|v| v.id.unwrap() == vid) .as_ref()
}).map(|v| v.visit_date.to_string()); .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 { MediaItem {
client_name: client.map(|c| c.name.clone()).unwrap_or_default(), client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
visit_date, visit_date,
@@ -1444,7 +1395,10 @@ async fn media_page(
}) })
.collect(); .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 { let body = MediaTemplate {
t: lang.t(), t: lang.t(),
@@ -1476,7 +1430,10 @@ async fn media_upload_page(
let cid: i64 = visit.client_id.primary_key().unwrap(); let cid: i64 = visit.client_id.primary_key().unwrap();
let client = query!(Client, $id == cid).get(&db).await?; let client = query!(Client, $id == cid).get(&db).await?;
let client_name = client.map(|c| c.name).unwrap_or_default(); 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 { let body = MediaUploadTemplate {
t: lang.t(), t: lang.t(),
@@ -1492,11 +1449,11 @@ async fn media_upload_page(
fn extract_boundary(request: &Request) -> Option<String> { fn extract_boundary(request: &Request) -> Option<String> {
let ct = request.headers().get("content-type")?.to_str().ok()?; let ct = request.headers().get("content-type")?.to_str().ok()?;
ct.split(';') ct.split(';').find_map(|part| {
.find_map(|part| { let part = part.trim();
let part = part.trim(); part.strip_prefix("boundary=")
part.strip_prefix("boundary=").map(|b| b.trim_matches('"').to_string()) .map(|b| b.trim_matches('"').to_string())
}) })
} }
async fn media_upload_submit( 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 client_id: i64 = visit.client_id.primary_key().unwrap();
let bytes = request.into_body().into_bytes().await?; 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 mut multipart = multer::Multipart::new(stream, boundary);
let upload_dir = format!("uploads/{}/{}", client_id, visit_id); 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 caption = String::new();
let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type) 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(); let field_name = field.name().unwrap_or("").to_string();
if field_name == "caption" { 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; continue;
} }
@@ -1559,17 +1526,26 @@ async fn media_upload_submit(
let file_id = uuid::Uuid::new_v4(); let file_id = uuid::Uuid::new_v4();
let file_path = format!("{}/{}.{}", upload_dir, file_id, ext); 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() { if data.is_empty() {
continue; 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())); 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 { for (path, ftype) in saved_files {
let mut media = Media { let mut media = Media {
@@ -1638,7 +1614,224 @@ async fn serve_upload(
}; };
let body = cot::Body::fixed(data); let body = cot::Body::fixed(data);
let mut resp = Response::new(body); 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<Testimonial>,
}
async fn testimonials_page(
request: Request,
session: Session,
db: Database,
) -> cot::Result<Response> {
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<Response> {
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<String> = 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<i64>,
) -> cot::Result<Response> {
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<i64>,
) -> cot::Result<Response> {
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<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());
Ok(resp) Ok(resp)
} }
Err(_) => Html::new("404").into_response(), 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", clients_page, "admin-clients"),
Route::with_handler_and_name("/clients/new", client_new_page, "admin-client-new"), 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/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(
Route::with_handler_and_name("/clients/{client_id}/save", client_edit_submit, "admin-client-edit-submit"), "/clients/{client_id}/edit",
Route::with_handler_and_name("/clients/{client_id}/archive", client_archive, "admin-client-archive"), client_edit_page,
Route::with_handler_and_name("/clients/{client_id}/activate", client_activate, "admin-client-activate"), "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", schedule_page, "admin-schedule"),
Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"), 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/events", schedule_events, "admin-schedule-events"),
Route::with_handler_and_name("/schedule/create", schedule_create, "admin-schedule-create"), 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(
Route::with_handler_and_name("/schedule/{visit_id}/save", schedule_edit_submit, "admin-visit-save"), "/schedule/{visit_id}/edit",
Route::with_handler_and_name("/schedule/{visit_id}/delete", visit_delete, "admin-visit-delete"), schedule_edit_page,
Route::with_handler_and_name("/schedule/{visit_id}/done", visit_set_done, "admin-visit-done"), "admin-visit-edit",
Route::with_handler_and_name("/schedule/{visit_id}/cancel", visit_set_cancel, "admin-visit-cancel"), ),
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", users_page, "admin-users"),
Route::with_handler_and_name("/users/add", add_user, "admin-user-add"), Route::with_handler_and_name("/users/add", add_user, "admin-user-add"),
Route::with_handler_and_name( Route::with_handler_and_name(
@@ -1701,10 +1930,43 @@ pub fn admin_router() -> Router {
"admin-user-activate", "admin-user-activate",
), ),
Route::with_handler_and_name("/media", media_page, "admin-media"), 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(
Route::with_handler_and_name("/media/{visit_id}/upload/submit", media_upload_submit, "admin-media-upload-submit"), "/media/{visit_id}/upload",
Route::with_handler_and_name("/media/{media_id}/delete", media_delete, "admin-media-delete"), 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("/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", settings_page, "admin-settings-get"),
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"), Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
]) ])
+75 -26
View File
@@ -129,7 +129,9 @@ pub struct Translations {
pub settings_telegram_bot_token: &'static str, pub settings_telegram_bot_token: &'static str,
pub settings_telegram_chat_id: &'static str, pub settings_telegram_chat_id: &'static str,
pub settings_contact_info: &'static str, pub settings_contact_info: &'static str,
pub settings_pricing_info: &'static str,
pub landing_contact_label: &'static str, pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str,
// Dashboard // Dashboard
pub dashboard_title: &'static str, pub dashboard_title: &'static str,
@@ -151,14 +153,15 @@ pub struct Translations {
pub landing_meta_description: &'static str, pub landing_meta_description: &'static str,
pub landing_hero_title: &'static str, pub landing_hero_title: &'static str,
pub landing_hero_subtitle: &'static str, pub landing_hero_subtitle: &'static str,
pub landing_hero_description: &'static str,
pub landing_hero_cta: &'static str, pub landing_hero_cta: &'static str,
pub landing_services_title: &'static str, pub landing_services_title: &'static str,
pub landing_service_cats_title: &'static str, pub landing_service_cats_title: &'static str,
pub landing_service_cats_text: &'static str, pub landing_service_cats_text: &'static str,
pub landing_service_dogs_title: &'static str, pub landing_service_exotic_title: &'static str,
pub landing_service_dogs_text: &'static str, pub landing_service_exotic_text: &'static str,
pub landing_service_home_title: &'static str, pub landing_service_bonus_title: &'static str,
pub landing_service_home_text: &'static str, pub landing_service_bonus_text: &'static str,
pub landing_how_title: &'static str, pub landing_how_title: &'static str,
pub landing_how_step1_title: &'static str, pub landing_how_step1_title: &'static str,
pub landing_how_step1_text: &'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_title: &'static str,
pub landing_thank_you_text: &'static str, pub landing_thank_you_text: &'static str,
pub landing_thank_you_back: &'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, 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 // Client edit
pub clients_edit_title: &'static str, pub clients_edit_title: &'static str,
pub clients_save: &'static str, pub clients_save: &'static str,
@@ -315,7 +332,9 @@ static RU: Translations = Translations {
settings_telegram_bot_token: "Токен Telegram бота", settings_telegram_bot_token: "Токен Telegram бота",
settings_telegram_chat_id: "Chat ID для уведомлений", settings_telegram_chat_id: "Chat ID для уведомлений",
settings_contact_info: "Контактная информация (отображается на лендинге)", settings_contact_info: "Контактная информация (отображается на лендинге)",
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
landing_contact_label: "Или свяжитесь с нами напрямую", landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
dashboard_title: "Главная", dashboard_title: "Главная",
dashboard_today_visits: "Визиты на сегодня", dashboard_today_visits: "Визиты на сегодня",
@@ -392,24 +411,25 @@ static RU: Translations = Translations {
schedule_delete: "Удалить визит", schedule_delete: "Удалить визит",
schedule_delete_confirm: "Точно удалить этот визит?", schedule_delete_confirm: "Точно удалить этот визит?",
landing_meta_description: "Профессиональный пет-ситтинг: кормление кошек, выгул собак, уход за питомцами пока вы в отпуске. Оставьте заявку — позаботимся о вашем любимце!", landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома", landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
landing_hero_subtitle: "Кормление кошек, выгул собак, ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке", landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
landing_hero_cta: "Оставить заявку", landing_hero_cta: "Оставить заявку",
landing_services_title: "Наши услуги", landing_services_title: "Наши услуги",
landing_service_cats_title: "Кормление кошек", landing_service_cats_title: "Кошки",
landing_service_cats_text: "Приедем к вам домой, покормим кошку, поменяем воду и лоток, поиграем и проверим, что всё в порядке", landing_service_cats_text: "Приедем к вам домой, покормим, помоем миски, поменяем воду, уберём лоток, поиграем, погладим, поговорим — всё, что пожелает ваш любимец. Уделим ей максимум внимания. Отправим вам фото/видео отчёт с рассказом, как всё прошло.",
landing_service_dogs_title: "Выгул собак", landing_service_exotic_title: "Грызуны, рептилии",
landing_service_dogs_text: "Погуляем с вашей собакой по привычному маршруту, покормим и проследим за самочувствием питомца", landing_service_exotic_text: "Приедем к вам домой, покормим, уберём в клетке. Отправим вам фото/видео отчёт с рассказом, как всё прошло.",
landing_service_home_title: "Домашние визиты", landing_service_bonus_title: "Цветы, рыбки",
landing_service_home_text: "Регулярные визиты к вам домой: проверим питомца, польём цветы, заберём почту — всё будет как при вас", landing_service_bonus_text: "Бонусом польём цветы, покормим рыбок, заберём почту — будет всё, как при вас.",
landing_how_title: "Как это работает", landing_how_title: "Как это работает",
landing_how_step1_title: "Оставьте заявку", landing_how_step1_title: "Оставьте заявку",
landing_how_step1_text: "Заполните форму ниже — укажите имя и телефон. Мы свяжемся с вами в течение часа", landing_how_step1_text: "Заполните форму ниже — укажите имя и телефон. Мы свяжемся с вами в ближайшее время",
landing_how_step2_title: "Обсудим детали", landing_how_step2_title: "Обсудим детали",
landing_how_step2_text: "Познакомимся с вашим питомцем, обсудим расписание визитов и особые пожелания", landing_how_step2_text: "Встретимся, познакомимся с вашим питомцем, обсудим расписание визитов и особые пожелания. Предъявим документ, удостоверяющий личность.",
landing_how_step3_title: "Заботимся о питомце", landing_how_step3_title: "Заботимся о питомце",
landing_how_step3_text: "Пока вас нет — мы рядом. После каждого визита отправим фото и отчёт о самочувствии", landing_how_step3_text: "Пока вас нет — мы рядом. После каждого визита отправим фото и отчёт о самочувствии вашего питомца.",
landing_form_title: "Оставить заявку", landing_form_title: "Оставить заявку",
landing_form_subtitle: "Расскажите о себе, и мы свяжемся с вами в ближайшее время", landing_form_subtitle: "Расскажите о себе, и мы свяжемся с вами в ближайшее время",
landing_form_name: "Ваше имя", landing_form_name: "Ваше имя",
@@ -420,8 +440,21 @@ static RU: Translations = Translations {
landing_thank_you_title: "Спасибо за заявку!", landing_thank_you_title: "Спасибо за заявку!",
landing_thank_you_text: "Мы получили вашу заявку и свяжемся с вами в ближайшее время.", landing_thank_you_text: "Мы получили вашу заявку и свяжемся с вами в ближайшее время.",
landing_thank_you_back: "Вернуться на главную", landing_thank_you_back: "Вернуться на главную",
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
landing_testimonials_title: "Отзывы",
landing_footer_text: "Пет-ситтинг — забота о вашем питомце", 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: "", no_value: "",
action_convert: "Конвертировать", action_convert: "Конвертировать",
action_reject: "Отклонить", action_reject: "Отклонить",
@@ -492,7 +525,9 @@ static EN: Translations = Translations {
settings_telegram_bot_token: "Telegram Bot Token", settings_telegram_bot_token: "Telegram Bot Token",
settings_telegram_chat_id: "Notification Chat ID", settings_telegram_chat_id: "Notification Chat ID",
settings_contact_info: "Contact info (shown on landing page)", 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_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing",
dashboard_title: "Home", dashboard_title: "Home",
dashboard_today_visits: "Today's visits", dashboard_today_visits: "Today's visits",
@@ -569,24 +604,25 @@ static EN: Translations = Translations {
schedule_delete: "Delete visit", schedule_delete: "Delete visit",
schedule_delete_confirm: "Are you sure you want to delete this 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_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_hero_cta: "Leave a Request",
landing_services_title: "Our Services", landing_services_title: "Our Services",
landing_service_cats_title: "Cat Feeding", landing_service_cats_title: "Cats",
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_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_dogs_title: "Dog Walking", landing_service_exotic_title: "Rodents, Reptiles",
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_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_home_title: "Home Visits", landing_service_bonus_title: "Plants, Fish",
landing_service_home_text: "Regular home visits: check on your pet, water the plants, collect mail — everything as if you were home", 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_title: "How It Works",
landing_how_step1_title: "Leave a Request", 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_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_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_title: "Leave a Request",
landing_form_subtitle: "Tell us about yourself and we'll get back to you shortly", landing_form_subtitle: "Tell us about yourself and we'll get back to you shortly",
landing_form_name: "Your Name", landing_form_name: "Your Name",
@@ -597,8 +633,21 @@ static EN: Translations = Translations {
landing_thank_you_title: "Thank you!", landing_thank_you_title: "Thank you!",
landing_thank_you_text: "We've received your request and will contact you shortly.", landing_thank_you_text: "We've received your request and will contact you shortly.",
landing_thank_you_back: "Back to Home", 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", 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: "", no_value: "",
action_convert: "Convert", action_convert: "Convert",
action_reject: "Reject", action_reject: "Reject",
+2
View File
@@ -6,10 +6,12 @@ pub mod m_0001_initial;
pub mod m_0002_visit_schedule; pub mod m_0002_visit_schedule;
pub mod m_0003_visit_feedback; pub mod m_0003_visit_feedback;
pub mod m_0004_visit_public_notes; pub mod m_0004_visit_public_notes;
pub mod m_0005_testimonials;
/// The list of migrations for current app. /// The list of migrations for current app.
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration, &m_0001_initial::Migration,
&m_0002_visit_schedule::Migration, &m_0002_visit_schedule::Migration,
&m_0003_visit_feedback::Migration, &m_0003_visit_feedback::Migration,
&m_0004_visit_public_notes::Migration, &m_0004_visit_public_notes::Migration,
&m_0005_testimonials::Migration,
]; ];
+5 -3
View File
@@ -7,9 +7,11 @@ pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration { impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting"; const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0002_visit_schedule"; const MIGRATION_NAME: &'static str = "m_0002_visit_schedule";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0001_initial"), &[::cot::db::migrations::MigrationDependency::migration(
]; "web-petting",
"m_0001_initial",
)];
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
// Add color to client (nullable for existing rows) // Add color to client (nullable for existing rows)
::cot::db::migrations::Operation::add_field() ::cot::db::migrations::Operation::add_field()
+9 -8
View File
@@ -5,19 +5,20 @@ pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration { impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting"; const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0003_visit_feedback"; const MIGRATION_NAME: &'static str = "m_0003_visit_feedback";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0002_visit_schedule"), &[::cot::db::migrations::MigrationDependency::migration(
]; "web-petting",
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ "m_0002_visit_schedule",
::cot::db::migrations::Operation::add_field() )];
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
&[::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit")) .table_name(::cot::db::Identifier::new("web_petting__visit"))
.field( .field(
::cot::db::migrations::Field::new( ::cot::db::migrations::Field::new(
::cot::db::Identifier::new("client_feedback"), ::cot::db::Identifier::new("client_feedback"),
<Option<String> as ::cot::db::DatabaseField>::TYPE, <Option<String> as ::cot::db::DatabaseField>::TYPE,
) )
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE) .set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
) )
.build(), .build()];
];
} }
+9 -8
View File
@@ -5,19 +5,20 @@ pub(super) struct Migration;
impl ::cot::db::migrations::Migration for Migration { impl ::cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "web-petting"; const APP_NAME: &'static str = "web-petting";
const MIGRATION_NAME: &'static str = "m_0004_visit_public_notes"; const MIGRATION_NAME: &'static str = "m_0004_visit_public_notes";
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0003_visit_feedback"), &[::cot::db::migrations::MigrationDependency::migration(
]; "web-petting",
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ "m_0003_visit_feedback",
::cot::db::migrations::Operation::add_field() )];
const OPERATIONS: &'static [::cot::db::migrations::Operation] =
&[::cot::db::migrations::Operation::add_field()
.table_name(::cot::db::Identifier::new("web_petting__visit")) .table_name(::cot::db::Identifier::new("web_petting__visit"))
.field( .field(
::cot::db::migrations::Field::new( ::cot::db::migrations::Field::new(
::cot::db::Identifier::new("public_notes"), ::cot::db::Identifier::new("public_notes"),
<Option<String> as ::cot::db::DatabaseField>::TYPE, <Option<String> as ::cot::db::DatabaseField>::TYPE,
) )
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE) .set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
) )
.build(), .build()];
];
} }
+56
View File
@@ -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"),
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
)
.auto()
.primary_key()
.set_null(<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("text"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("author_note"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("image_path"),
<Option<String> as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("status"),
<String as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("sort_order"),
<i32 as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<i32 as ::cot::db::DatabaseField>::NULLABLE),
::cot::db::migrations::Field::new(
::cot::db::Identifier::new("created_at"),
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
)
.set_null(<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE),
])
.build()];
}
+18 -1
View File
@@ -1,4 +1,4 @@
use cot::db::{model, Auto, ForeignKey}; use cot::db::{Auto, ForeignKey, model};
/// Lead status: new request from the website /// Lead status: new request from the website
/// new -> in_progress -> converted | rejected /// new -> in_progress -> converted | rejected
@@ -185,6 +185,23 @@ pub struct User {
pub updated_at: chrono::NaiveDateTime, 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<i64>,
pub text: String,
/// Optional short note (e.g. client name, pet type).
pub author_note: Option<String>,
/// Optional image path.
pub image_path: Option<String>,
/// 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.). /// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[model] #[model]
+76 -8
View File
@@ -1,16 +1,16 @@
use cot::Template;
use cot::db::{Auto, Database, Model}; use cot::db::{Auto, Database, Model};
use cot::html::Html; use cot::html::Html;
use cot::request::Request; use cot::request::Request;
use cot::request::extractors::Path; use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response}; use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router}; use cot::router::{Route, Router};
use cot::Template;
use serde::Deserialize; use serde::Deserialize;
use cot::db::query; use cot::db::query;
use crate::i18n::{Lang, Translations}; 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; use crate::telegram;
fn detect_lang(request: &Request) -> Lang { fn detect_lang(request: &Request) -> Lang {
@@ -70,6 +70,8 @@ struct LandingTemplate<'a> {
t: &'a Translations, t: &'a Translations,
lang: Lang, lang: Lang,
contact_info: String, contact_info: String,
pricing_info: String,
testimonials: Vec<Testimonial>,
} }
#[derive(Debug, Template)] #[derive(Debug, Template)]
@@ -87,7 +89,23 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
.await? .await?
.map(|s| s.value) .map(|s| s.value)
.unwrap_or_default(); .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) html_response(body, lang)
} }
@@ -173,10 +191,12 @@ async fn client_portal(
let today = chrono::Utc::now().date_naive(); let today = chrono::Utc::now().date_naive();
let mut visits = Visit::objects().all(&db).await?; let mut visits = Visit::objects().all(&db).await?;
visits.retain(|v| { visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
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 users = User::objects().all(&db).await?;
let all_media = Media::objects().all(&db).await?; let all_media = Media::objects().all(&db).await?;
@@ -200,7 +220,11 @@ async fn client_portal(
}) })
.cloned() .cloned()
.collect(); .collect();
PortalVisit { visit: v, admin_name, media } PortalVisit {
visit: v,
admin_name,
media,
}
}; };
let mut upcoming = Vec::new(); 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). /// 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<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 { pub fn public_router() -> Router {
Router::with_urls([ Router::with_urls([
Route::with_handler_and_name("/", landing_page, "landing"), Route::with_handler_and_name("/", landing_page, "landing"),
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"), 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}", client_portal, "client-portal"),
Route::with_handler_and_name( Route::with_handler_and_name(
"/client/{token}/{visit_id}/feedback", "/client/{token}/{visit_id}/feedback",
+6 -1
View File
@@ -4,7 +4,12 @@ use crate::models::Setting;
/// Send a Telegram message using bot settings from DB. /// Send a Telegram message using bot settings from DB.
/// Silently ignores errors (missing config, network issues) — notifications are best-effort. /// 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 { let token = match get_setting(db, "telegram_bot_token").await {
Some(t) if !t.is_empty() => t, Some(t) if !t.is_empty() => t,
_ => return, _ => return,
+4
View File
@@ -98,6 +98,7 @@
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>{{ t.nav_clients }}</a> <a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>{{ t.nav_clients }}</a>
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>{{ t.nav_schedule }}</a> <a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>{{ t.nav_schedule }}</a>
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a> <a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a>
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>{{ t.nav_testimonials }}</a>
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>{{ t.nav_users }}</a> <a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>{{ t.nav_users }}</a>
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>{{ t.nav_settings }}</a> <a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>{{ t.nav_settings }}</a>
</nav> </nav>
@@ -130,6 +131,9 @@
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}> <a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>
<span class="tab-icon">📷</span>{{ t.nav_media }} <span class="tab-icon">📷</span>{{ t.nav_media }}
</a> </a>
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>
<span class="tab-icon">💬</span>{{ t.nav_testimonials }}
</a>
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}> <a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>
<span class="tab-icon">🔑</span>{{ t.nav_users }} <span class="tab-icon">🔑</span>{{ t.nav_users }}
</a> </a>
+6
View File
@@ -32,6 +32,12 @@
<input class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}"> <input class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
</div> </div>
<div class="field">
<label class="label">{{ t.settings_pricing_info }}</label>
<div class="control">
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
</div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button> <button type="submit" class="button is-primary">{{ t.settings_save }}</button>
</form> </form>
</div> </div>
+74
View File
@@ -0,0 +1,74 @@
{% extends "admin/layout.html" %}
{% let active_page = "testimonials" %}
{% block title %}{{ t.testimonials_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.testimonials_title }}</h1>
</div>
<!-- Add form -->
<div class="form-card" style="margin-bottom:1.5rem;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:0.75rem;">{{ t.testimonials_add_title }}</h2>
<form method="post" action="/admin/testimonials/add" enctype="multipart/form-data">
<div class="field">
<label class="label">{{ t.testimonials_text }} *</label>
<div class="control">
<textarea class="input" name="text" rows="3" required style="min-height:80px;resize:vertical;"></textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.testimonials_author_note }}</label>
<div class="control">
<input class="input" type="text" name="author_note">
</div>
</div>
<div class="field">
<label class="label">{{ t.testimonials_image }}</label>
<div class="control">
<input class="input" type="file" name="image" accept="image/*">
</div>
</div>
<button type="submit" class="button is-primary">{{ t.testimonials_add_button }}</button>
</form>
</div>
<!-- List -->
{% if testimonials.is_empty() %}
<p style="color:#888;">{{ t.testimonials_empty }}</p>
{% else %}
{% for item in &testimonials %}
<div class="item-card">
<div class="item-card-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
{% if item.image_path.is_some() %}
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
{% endif %}
<div>
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
{% if let Some(note) = item.author_note.as_deref() %}
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
{% endif %}
</div>
</div>
<span class="badge {% if item.status == "active" %}badge-active{% else %}badge-archived{% endif %}">
{% if item.status == "active" %}{{ t.testimonials_status_active }}{% else %}{{ t.testimonials_status_hidden }}{% endif %}
</span>
</div>
<div class="item-card-actions">
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/toggle">
{% if item.status == "active" %}
<button type="submit" class="button btn-sm is-warning is-light">{{ t.action_archive }}</button>
{% else %}
<button type="submit" class="button btn-sm is-success is-light">{{ t.action_activate }}</button>
{% endif %}
</form>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/delete" onsubmit="return confirm('Delete?')">
<button type="submit" class="button btn-sm is-danger is-light">{{ t.media_delete }}</button>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
+109 -11
View File
@@ -97,6 +97,62 @@
} }
.hero-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); } .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-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 common ── */
.section { padding: 5rem 1.5rem; background: transparent; } .section { padding: 5rem 1.5rem; background: transparent; }
@@ -230,9 +286,10 @@
<!-- Hero --> <!-- Hero -->
<section class="hero"> <section class="hero">
<span class="hero-emoji" role="img" aria-label="pets">🐱🐶</span> <span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
<h1>{{ t.landing_hero_title }}</h1> <h1>{{ t.landing_hero_title }}</h1>
<p>{{ t.landing_hero_subtitle }}</p> <p>{{ t.landing_hero_subtitle }}</p>
<p class="hero-desc">{{ t.landing_hero_description }}</p>
<a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a> <a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a>
</section> </section>
@@ -247,19 +304,31 @@
<p>{{ t.landing_service_cats_text }}</p> <p>{{ t.landing_service_cats_text }}</p>
</article> </article>
<article class="service-card"> <article class="service-card">
<span class="service-icon" role="img" aria-label="dog">🐕</span> <span class="service-icon" role="img" aria-label="rodents and reptiles">🐹🦎</span>
<h3>{{ t.landing_service_dogs_title }}</h3> <h3>{{ t.landing_service_exotic_title }}</h3>
<p>{{ t.landing_service_dogs_text }}</p> <p>{{ t.landing_service_exotic_text }}</p>
</article> </article>
<article class="service-card"> <article class="service-card">
<span class="service-icon" role="img" aria-label="home">🏠</span> <span class="service-icon" role="img" aria-label="plants and fish">🌿🐟</span>
<h3>{{ t.landing_service_home_title }}</h3> <h3>{{ t.landing_service_bonus_title }}</h3>
<p>{{ t.landing_service_home_text }}</p> <p>{{ t.landing_service_bonus_text }}</p>
</article> </article>
</div> </div>
</div> </div>
</section> </section>
<!-- Pricing -->
{% if !pricing_info.is_empty() %}
<section class="section" id="pricing">
<div class="section-inner">
<h2 class="section-title">{{ t.landing_pricing_title }}</h2>
<div class="pricing-block">
<p class="pricing-text">{{ pricing_info }}</p>
</div>
</div>
</section>
{% endif %}
<!-- How it works --> <!-- How it works -->
<section class="section" id="how"> <section class="section" id="how">
<div class="section-inner"> <div class="section-inner">
@@ -284,6 +353,37 @@
</div> </div>
</section> </section>
<!-- Testimonials -->
{% if !testimonials.is_empty() %}
<section class="section section-alt" id="testimonials">
<div class="section-inner">
<h2 class="section-title">{{ t.landing_testimonials_title }}</h2>
<div class="testimonials-grid">
{% for item in &testimonials %}
<article class="testimonial-card">
<div class="testimonial-text">{{ item.text }}</div>
{% if item.image_path.is_some() || item.author_note.is_some() %}
<div class="testimonial-footer">
{% if item.image_path.is_some() %}
<img class="testimonial-avatar" src="/testimonial-image/{{ item.id.unwrap() }}" alt="">
{% endif %}
{% if let Some(note) = item.author_note.as_deref() %}
<span class="testimonial-note">{{ note }}</span>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<!-- Guarantee -->
<div class="guarantee" style="position:relative;z-index:1;">
<p>{{ t.landing_guarantee }}</p>
</div>
<!-- Lead Form --> <!-- Lead Form -->
<section class="form-section" id="form"> <section class="form-section" id="form">
<div class="form-wrapper"> <div class="form-wrapper">
@@ -323,10 +423,8 @@
var icons = [ var icons = [
// Cat face // Cat face
'<svg viewBox="0 0 80 80" width="W" height="W"><path d="M20 15 L10 2 L18 18 M60 15 L70 2 L62 18 M40 70 C18 70 8 52 8 38 C8 20 22 8 40 8 C58 8 72 20 72 38 C72 52 62 70 40 70Z" fill="none" stroke="C" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="C"/><circle cx="52" cy="36" r="3.5" fill="C"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="C"/><path d="M36 48 Q32 54 28 52 M44 48 Q48 54 52 52" fill="none" stroke="C" stroke-width="2" stroke-linecap="round"/></svg>', '<svg viewBox="0 0 80 80" width="W" height="W"><path d="M20 15 L10 2 L18 18 M60 15 L70 2 L62 18 M40 70 C18 70 8 52 8 38 C8 20 22 8 40 8 C58 8 72 20 72 38 C72 52 62 70 40 70Z" fill="none" stroke="C" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="C"/><circle cx="52" cy="36" r="3.5" fill="C"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="C"/><path d="M36 48 Q32 54 28 52 M44 48 Q48 54 52 52" fill="none" stroke="C" stroke-width="2" stroke-linecap="round"/></svg>',
// Dog face // Leaf
'<svg viewBox="0 0 80 80" width="W" height="W"><path d="M15 28 Q6 18 12 8 Q18 14 22 22 M65 28 Q74 18 68 8 Q62 14 58 22" fill="none" stroke="C" stroke-width="3" stroke-linecap="round"/><ellipse cx="40" cy="44" rx="28" ry="26" fill="none" stroke="C" stroke-width="3"/><circle cx="30" cy="38" r="3" fill="C"/><circle cx="50" cy="38" r="3" fill="C"/><ellipse cx="40" cy="50" rx="5" ry="3.5" fill="C"/><path d="M40 53.5 L40 58 M36 58 Q40 62 44 58" fill="none" stroke="C" stroke-width="2" stroke-linecap="round"/></svg>', '<svg viewBox="0 0 60 70" width="W2" height="W"><path d="M30 65 C30 65 10 45 10 25 C10 10 25 2 30 2 C35 2 50 10 50 25 C50 45 30 65 30 65Z" fill="C" opacity="0.3"/><path d="M30 60 L30 20 M30 35 Q20 30 15 25 M30 45 Q40 40 45 35" fill="none" stroke="C" stroke-width="2" stroke-linecap="round"/></svg>',
// Bone
'<svg viewBox="0 0 100 44" width="W" height="H"><path d="M28 14 C28 6, 18 0, 12 6 C6 0, -2 8, 4 16 C0 20, 0 24, 4 28 C-2 36, 6 44, 12 38 C18 44, 28 38, 28 30 L72 30 C72 38, 82 44, 88 38 C94 44, 102 36, 96 28 C100 24, 100 20, 96 16 C102 8, 94 0, 88 6 C82 0, 72 6, 72 14 Z" fill="C" opacity="0.45"/></svg>',
// Bowl // Bowl
'<svg viewBox="0 0 80 50" width="W" height="H2"><path d="M8 18 Q8 46 40 46 Q72 46 72 18 Z" fill="C" opacity="0.2"/><ellipse cx="40" cy="18" rx="34" ry="10" fill="none" stroke="C" stroke-width="3"/><path d="M8 18 Q8 46 40 46 Q72 46 72 18" fill="none" stroke="C" stroke-width="3" stroke-linejoin="round"/></svg>', '<svg viewBox="0 0 80 50" width="W" height="H2"><path d="M8 18 Q8 46 40 46 Q72 46 72 18 Z" fill="C" opacity="0.2"/><ellipse cx="40" cy="18" rx="34" ry="10" fill="none" stroke="C" stroke-width="3"/><path d="M8 18 Q8 46 40 46 Q72 46 72 18" fill="none" stroke="C" stroke-width="3" stroke-linejoin="round"/></svg>',
// Paw print // Paw print