Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a084a9d79 | |||
| 85512ab48b | |||
| 3bf62c80d5 | |||
| 4cc07632f0 | |||
| 7b0017d1f4 | |||
| 1d2722b715 | |||
| bfd0aec56f | |||
| 68d578b29d |
Generated
+1
-1
@@ -3255,7 +3255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-petting"
|
||||
version = "0.1.4"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.5"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -10,5 +10,6 @@ FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /data
|
||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||
COPY static /app/static
|
||||
EXPOSE 3000
|
||||
CMD ["web-petting"]
|
||||
|
||||
+36
-2
@@ -401,6 +401,8 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot::
|
||||
login: form.login,
|
||||
password_hash: password_auth::generate_hash(&form.password),
|
||||
display_name: display,
|
||||
telegram_chat_id: None,
|
||||
telegram_notifications: Some(false),
|
||||
status: "active".to_string(),
|
||||
created_at: now_utc(),
|
||||
updated_at: now_utc(),
|
||||
@@ -760,6 +762,8 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
|
||||
login: form.login,
|
||||
password_hash: password_auth::generate_hash(&form.password),
|
||||
display_name: display,
|
||||
telegram_chat_id: None,
|
||||
telegram_notifications: Some(false),
|
||||
status: "active".to_string(),
|
||||
created_at: now_utc(),
|
||||
updated_at: now_utc(),
|
||||
@@ -788,10 +792,10 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
|
||||
#[derive(Deserialize)]
|
||||
struct SettingsForm {
|
||||
telegram_bot_token: String,
|
||||
telegram_chat_id: String,
|
||||
contact_info: String,
|
||||
pricing_info: String,
|
||||
timezone: String,
|
||||
site_domain: String,
|
||||
}
|
||||
|
||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
@@ -803,10 +807,10 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
|
||||
for (key, value) in [
|
||||
("telegram_bot_token", form.telegram_bot_token),
|
||||
("telegram_chat_id", form.telegram_chat_id),
|
||||
("contact_info", form.contact_info),
|
||||
("pricing_info", form.pricing_info),
|
||||
("timezone", form.timezone),
|
||||
("site_domain", form.site_domain),
|
||||
] {
|
||||
let k = key.to_string();
|
||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||
@@ -973,6 +977,31 @@ async fn user_activate(
|
||||
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserTelegramForm {
|
||||
telegram_chat_id: Option<String>,
|
||||
telegram_notifications: Option<String>,
|
||||
}
|
||||
|
||||
async fn user_update_telegram(
|
||||
request: Request,
|
||||
session: Session,
|
||||
db: Database,
|
||||
Path(user_id): Path<i64>,
|
||||
) -> cot::Result<Response> {
|
||||
let (lang, form): (_, UserTelegramForm) = parse_form_from_request(request).await?;
|
||||
if let Err(resp) = require_auth(&session, lang).await {
|
||||
return Ok(resp);
|
||||
}
|
||||
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
|
||||
user.telegram_chat_id = form.telegram_chat_id.filter(|s| !s.trim().is_empty());
|
||||
user.telegram_notifications = Some(form.telegram_notifications.as_deref() == Some("true"));
|
||||
user.updated_at = now_utc();
|
||||
user.save(&db).await?;
|
||||
}
|
||||
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddLeadForm {
|
||||
name: String,
|
||||
@@ -2075,6 +2104,11 @@ pub fn admin_router() -> Router {
|
||||
user_activate,
|
||||
"admin-user-activate",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{user_id}/telegram",
|
||||
user_update_telegram,
|
||||
"admin-user-telegram",
|
||||
),
|
||||
Route::with_handler_and_name("/media", media_page, "admin-media"),
|
||||
Route::with_handler_and_name(
|
||||
"/media/{visit_id}/upload",
|
||||
|
||||
+13
-4
@@ -118,6 +118,8 @@ pub struct Translations {
|
||||
pub users_add_button: &'static str,
|
||||
pub users_error_passwords_mismatch: &'static str,
|
||||
pub users_error_login_taken: &'static str,
|
||||
pub users_telegram_chat_id: &'static str,
|
||||
pub users_telegram_enabled: &'static str,
|
||||
|
||||
// Settings
|
||||
pub settings_title: &'static str,
|
||||
@@ -131,6 +133,7 @@ pub struct Translations {
|
||||
pub settings_contact_info: &'static str,
|
||||
pub settings_pricing_info: &'static str,
|
||||
pub settings_timezone: &'static str,
|
||||
pub settings_site_domain: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
pub landing_pricing_title: &'static str,
|
||||
|
||||
@@ -282,7 +285,7 @@ static RU: Translations = Translations {
|
||||
nav_visits: "Визиты",
|
||||
nav_users: "Админы",
|
||||
nav_settings: "Настройки",
|
||||
nav_title: "Пет-ситтинг",
|
||||
nav_title: "МурНяня.РФ",
|
||||
|
||||
leads_title: "Заявки",
|
||||
leads_empty: "Заявок пока нет.",
|
||||
@@ -328,6 +331,8 @@ static RU: Translations = Translations {
|
||||
users_add_button: "Добавить",
|
||||
users_error_passwords_mismatch: "Пароли не совпадают.",
|
||||
users_error_login_taken: "Этот логин уже занят.",
|
||||
users_telegram_chat_id: "Telegram Chat ID",
|
||||
users_telegram_enabled: "Уведомления",
|
||||
|
||||
settings_title: "Настройки",
|
||||
settings_key: "Параметр",
|
||||
@@ -340,6 +345,7 @@ static RU: Translations = Translations {
|
||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
landing_pricing_title: "Стоимость",
|
||||
|
||||
@@ -450,7 +456,7 @@ static RU: Translations = Translations {
|
||||
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
|
||||
landing_testimonials_title: "Отзывы",
|
||||
landing_form_consent: "Я даю согласие на обработку персональных данных",
|
||||
landing_footer_text: "Пет-ситтинг — забота о вашем питомце",
|
||||
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||
landing_footer_copyright: "Все права защищены",
|
||||
|
||||
nav_testimonials: "Отзывы",
|
||||
@@ -481,7 +487,7 @@ static EN: Translations = Translations {
|
||||
nav_visits: "Visits",
|
||||
nav_users: "Admins",
|
||||
nav_settings: "Settings",
|
||||
nav_title: "Pet Sitting",
|
||||
nav_title: "МурНяня.РФ",
|
||||
|
||||
leads_title: "Leads",
|
||||
leads_empty: "No leads yet.",
|
||||
@@ -527,6 +533,8 @@ static EN: Translations = Translations {
|
||||
users_add_button: "Add",
|
||||
users_error_passwords_mismatch: "Passwords do not match.",
|
||||
users_error_login_taken: "This login is already taken.",
|
||||
users_telegram_chat_id: "Telegram Chat ID",
|
||||
users_telegram_enabled: "Notifications",
|
||||
|
||||
settings_title: "Settings",
|
||||
settings_key: "Parameter",
|
||||
@@ -539,6 +547,7 @@ static EN: Translations = Translations {
|
||||
settings_contact_info: "Contact info (shown on landing page)",
|
||||
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||
settings_site_domain: "Site domain (e.g. https://example.com)",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
@@ -649,7 +658,7 @@ static EN: Translations = Translations {
|
||||
landing_guarantee: "Integrity, honesty, and strict fulfillment of your requirements guaranteed.",
|
||||
landing_testimonials_title: "Testimonials",
|
||||
landing_form_consent: "I consent to the processing of my personal data",
|
||||
landing_footer_text: "Pet Sitting — caring for your pet",
|
||||
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||
landing_footer_copyright: "All rights reserved",
|
||||
|
||||
nav_testimonials: "Testimonials",
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod m_0002_visit_schedule;
|
||||
pub mod m_0003_visit_feedback;
|
||||
pub mod m_0004_visit_public_notes;
|
||||
pub mod m_0005_testimonials;
|
||||
pub mod m_0006_user_telegram;
|
||||
/// The list of migrations for current app.
|
||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
@@ -14,4 +15,5 @@ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0003_visit_feedback::Migration,
|
||||
&m_0004_visit_public_notes::Migration,
|
||||
&m_0005_testimonials::Migration,
|
||||
&m_0006_user_telegram::Migration,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Migration: add telegram_chat_id and telegram_notifications to User
|
||||
|
||||
#[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_0006_user_telegram";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] =
|
||||
&[::cot::db::migrations::MigrationDependency::migration(
|
||||
"web-petting",
|
||||
"m_0005_testimonials",
|
||||
)];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_chat_id"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("telegram_notifications"),
|
||||
<Option<bool> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<bool> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -179,6 +179,8 @@ pub struct User {
|
||||
pub login: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub telegram_chat_id: Option<String>,
|
||||
pub telegram_notifications: Option<bool>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
|
||||
+113
@@ -72,6 +72,8 @@ struct LandingTemplate<'a> {
|
||||
contact_info: String,
|
||||
pricing_info: String,
|
||||
testimonials: Vec<Testimonial>,
|
||||
site_domain: String,
|
||||
review_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@@ -95,15 +97,24 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_default();
|
||||
let domain_key = "site_domain".to_string();
|
||||
let site_domain = query!(Setting, $key == domain_key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_else(|| "https://example.net".to_string());
|
||||
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 review_count = testimonials.len();
|
||||
let body = LandingTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
contact_info,
|
||||
pricing_info,
|
||||
testimonials,
|
||||
site_domain,
|
||||
review_count,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -367,9 +378,111 @@ async fn serve_testimonial_image(
|
||||
}
|
||||
}
|
||||
|
||||
async fn favicon(_request: Request) -> cot::Result<Response> {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<ellipse cx="32" cy="42" rx="14" ry="16" fill="#7c6cff"/>
|
||||
<ellipse cx="14" cy="20" rx="7" ry="9" fill="#7c6cff" transform="rotate(-10 14 20)"/>
|
||||
<ellipse cx="50" cy="20" rx="7" ry="9" fill="#7c6cff" transform="rotate(10 50 20)"/>
|
||||
<ellipse cx="23" cy="8" rx="5.5" ry="7" fill="#7c6cff" transform="rotate(-5 23 8)"/>
|
||||
<ellipse cx="41" cy="8" rx="5.5" ry="7" fill="#7c6cff" transform="rotate(5 41 8)"/>
|
||||
</svg>"##;
|
||||
let mut resp = Response::new(cot::Body::fixed(svg.as_bytes().to_vec()));
|
||||
resp.headers_mut()
|
||||
.insert("content-type", "image/svg+xml".parse().unwrap());
|
||||
resp.headers_mut()
|
||||
.insert("cache-control", "public, max-age=604800".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn serve_static(_request: Request, Path(filename): Path<String>) -> cot::Result<Response> {
|
||||
// Only allow simple filenames (no path traversal)
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Html::new("404").into_response();
|
||||
}
|
||||
let ext = filename.rsplit('.').next().unwrap_or("");
|
||||
// Try relative path first, then /app/static/ (Docker)
|
||||
let path = format!("static/{filename}");
|
||||
let data = match tokio::fs::read(&path).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => match tokio::fs::read(format!("/app/static/{filename}")).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Html::new("404").into_response(),
|
||||
},
|
||||
};
|
||||
let content_type = match ext {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"gif" => "image/gif",
|
||||
_ => "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=604800".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn robots_txt(_request: Request, db: Database) -> cot::Result<Response> {
|
||||
let domain_key = "site_domain".to_string();
|
||||
let site_domain = query!(Setting, $key == domain_key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_else(|| "https://example.net".to_string());
|
||||
let body = format!(
|
||||
"User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /client/\nSitemap: {}/sitemap.xml\n",
|
||||
site_domain
|
||||
);
|
||||
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
||||
resp.headers_mut()
|
||||
.insert("content-type", "text/plain; charset=utf-8".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn sitemap_xml(_request: Request, db: Database) -> cot::Result<Response> {
|
||||
let domain_key = "site_domain".to_string();
|
||||
let site_domain = query!(Setting, $key == domain_key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_else(|| "https://example.net".to_string());
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>{domain}/?lang=ru</loc>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="{domain}/?lang=ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="{domain}/?lang=en"/>
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="{domain}/"/>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{domain}/?lang=en</loc>
|
||||
<xhtml:link rel="alternate" hreflang="ru" href="{domain}/?lang=ru"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="{domain}/?lang=en"/>
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="{domain}/"/>
|
||||
</url>
|
||||
</urlset>
|
||||
"#,
|
||||
domain = site_domain
|
||||
);
|
||||
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
||||
resp.headers_mut()
|
||||
.insert("content-type", "application/xml; charset=utf-8".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub fn public_router() -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name("/", landing_page, "landing"),
|
||||
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
||||
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
||||
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
||||
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
||||
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
||||
Route::with_handler_and_name(
|
||||
"/testimonial-image/{id}",
|
||||
|
||||
+28
-14
@@ -1,8 +1,8 @@
|
||||
use cot::db::{Database, query};
|
||||
|
||||
use crate::models::Setting;
|
||||
use crate::models::{Setting, User};
|
||||
|
||||
/// Send a Telegram message using bot settings from DB.
|
||||
/// Send a Telegram notification to all admins with notifications enabled.
|
||||
/// Silently ignores errors (missing config, network issues) — notifications are best-effort.
|
||||
pub async fn notify_new_lead(
|
||||
db: &Database,
|
||||
@@ -14,10 +14,6 @@ pub async fn notify_new_lead(
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return,
|
||||
};
|
||||
let chat_id = match get_setting(db, "telegram_chat_id").await {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
|
||||
if let Some(phone) = phone.filter(|s| !s.is_empty()) {
|
||||
@@ -27,15 +23,33 @@ pub async fn notify_new_lead(
|
||||
text.push_str(&format!("\nКомментарий: {comment}"));
|
||||
}
|
||||
|
||||
let active = "active".to_string();
|
||||
let users = match query!(User, $status == active).all(db).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
|
||||
let _ = reqwest::Client::new()
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
for user in &users {
|
||||
if user.telegram_notifications != Some(true) {
|
||||
continue;
|
||||
}
|
||||
let chat_id = match &user.telegram_chat_id {
|
||||
Some(id) if !id.is_empty() => id,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_setting(db: &Database, key_name: &str) -> Option<String> {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
+21
-11
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
:root { --accent: #6c63ff; color-scheme: light; }
|
||||
@@ -25,15 +26,24 @@
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
|
||||
background: #fff; border-top: 1px solid #e8e8e8;
|
||||
display: flex; height: 3.5rem;
|
||||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.bottom-tabs::-webkit-scrollbar { display: none; }
|
||||
.bottom-tabs a {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
flex: 0 0 auto; min-width: 3.2rem; padding: 0 0.45rem;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; text-decoration: none; color: #999;
|
||||
font-size: 0.65rem; font-weight: 600; gap: 0.15rem;
|
||||
transition: color 0.15s;
|
||||
font-size: 0.6rem; font-weight: 600; gap: 0.1rem;
|
||||
transition: color 0.15s; white-space: nowrap;
|
||||
}
|
||||
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
|
||||
.bottom-tabs a .tab-label { display: block; }
|
||||
.bottom-tabs a.is-active { color: var(--accent); }
|
||||
@media (max-width: 400px) {
|
||||
.bottom-tabs a .tab-label { display: none; }
|
||||
.bottom-tabs a { min-width: 2.8rem; padding: 0 0.3rem; }
|
||||
}
|
||||
|
||||
/* ── Desktop: hide bottom tabs, show top nav ── */
|
||||
.desktop-nav { display: none; }
|
||||
@@ -134,28 +144,28 @@
|
||||
<!-- Bottom tabs (mobile) -->
|
||||
<nav class="bottom-tabs">
|
||||
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">🏠</span>{{ t.dashboard_title }}
|
||||
<span class="tab-icon">🏠</span><span class="tab-label">{{ t.dashboard_title }}</span>
|
||||
</a>
|
||||
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">📋</span>{{ t.nav_leads }}
|
||||
<span class="tab-icon">📋</span><span class="tab-label">{{ t.nav_leads }}</span>
|
||||
</a>
|
||||
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">👥</span>{{ t.nav_clients }}
|
||||
<span class="tab-icon">👥</span><span class="tab-label">{{ t.nav_clients }}</span>
|
||||
</a>
|
||||
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">📅</span>{{ t.nav_schedule }}
|
||||
<span class="tab-icon">📅</span><span class="tab-label">{{ t.nav_schedule }}</span>
|
||||
</a>
|
||||
<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><span class="tab-label">{{ t.nav_media }}</span>
|
||||
</a>
|
||||
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">💬</span>{{ t.nav_testimonials }}
|
||||
<span class="tab-icon">💬</span><span class="tab-label">{{ t.nav_testimonials }}</span>
|
||||
</a>
|
||||
<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><span class="tab-label">{{ t.nav_users }}</span>
|
||||
</a>
|
||||
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
|
||||
<span class="tab-icon">⚙️</span>{{ t.nav_settings }}
|
||||
<span class="tab-icon">⚙️</span><span class="tab-label">{{ t.nav_settings }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
:root { color-scheme: light; }
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
input, textarea, select, .input, .textarea, .select select {
|
||||
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
||||
}
|
||||
.label, label { color: #363636 !important; }
|
||||
.notification { color: #333 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,12 +20,6 @@
|
||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_telegram_chat_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="telegram_chat_id" value="{% for s in &settings %}{% if s.key == "telegram_chat_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_contact_info }}</label>
|
||||
<div class="control">
|
||||
@@ -38,6 +32,12 @@
|
||||
<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>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_site_domain }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_timezone }}</label>
|
||||
<div class="control">
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
:root { color-scheme: light; }
|
||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||
.login-box { width: 100%; max-width: 400px; padding: 0 1rem; }
|
||||
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
input, textarea, select, .input, .textarea, .select select {
|
||||
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
||||
}
|
||||
.label, label { color: #363636 !important; }
|
||||
.notification { color: #333 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -17,6 +17,29 @@
|
||||
<div class="item-card-meta">
|
||||
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
||||
</div>
|
||||
{% if user.status == "active" %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/telegram" style="margin-top:0.5rem; padding-top:0.5rem; border-top:1px solid #eee;">
|
||||
<div class="columns is-mobile is-vcentered" style="margin-bottom:0;">
|
||||
<div class="column">
|
||||
<div class="field" style="margin-bottom:0;">
|
||||
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" name="telegram_chat_id" placeholder="123456789" value="{{ user.telegram_chat_id.as_deref().unwrap_or_default() }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="checkbox is-small">
|
||||
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
|
||||
{{ t.users_telegram_enabled }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button type="submit" class="button is-small is-info is-outlined">💾</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="item-card-actions">
|
||||
{% if user.status == "active" %}
|
||||
<form method="post" action="/admin/users/{{ user.id }}/archive">
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
@@ -54,6 +56,7 @@
|
||||
.feedback-form textarea {
|
||||
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
|
||||
font-size: 0.85rem; font-family: inherit; resize: vertical; min-height: 50px;
|
||||
background-color: #fff; color: #333;
|
||||
}
|
||||
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
|
||||
.feedback-form button {
|
||||
|
||||
+58
-8
@@ -5,11 +5,26 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{ t.landing_meta_description }}">
|
||||
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
|
||||
<!-- Canonical & Hreflang -->
|
||||
<link rel="canonical" href="{{ site_domain }}/?lang={{ lang.code() }}">
|
||||
<link rel="alternate" hreflang="ru" href="{{ site_domain }}/?lang=ru">
|
||||
<link rel="alternate" hreflang="en" href="{{ site_domain }}/?lang=en">
|
||||
<link rel="alternate" hreflang="x-default" href="{{ site_domain }}/">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ site_domain }}/?lang={{ lang.code() }}">
|
||||
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
|
||||
<meta property="og:site_name" content="{{ t.nav_title }}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||
<meta name="twitter:description" content="{{ t.landing_meta_description }}">
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
@@ -19,7 +34,15 @@
|
||||
"name": "{{ t.nav_title }}",
|
||||
"description": "{{ t.landing_meta_description }}",
|
||||
"serviceType": "Pet Sitting",
|
||||
"@id": "#business"
|
||||
"url": "{{ site_domain }}/",
|
||||
"@id": "#business"{% if review_count > 0 %},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "5",
|
||||
"bestRating": "5",
|
||||
"ratingCount": "{{ review_count }}",
|
||||
"reviewCount": "{{ review_count }}"
|
||||
}{% endif %}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -96,6 +119,25 @@
|
||||
box-shadow: 0 4px 20px rgba(124,108,255,0.35);
|
||||
}
|
||||
.hero-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); }
|
||||
.hero { position: relative; overflow: clip; }
|
||||
.hero-photo {
|
||||
position: absolute; z-index: 0;
|
||||
width: 420px; height: 420px;
|
||||
border-radius: 50%;
|
||||
border: 20px solid #ffe0ec;
|
||||
object-fit: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hero-photo-tr { right: -100px; top: -100px; }
|
||||
.hero-photo-bl { left: -100px; bottom: -150px; }
|
||||
.hero-content { position: relative; z-index: 1; }
|
||||
.section-alt { position: relative; z-index: 1; }
|
||||
@media (max-width: 600px) {
|
||||
.hero-photo { width: 200px; height: 200px; border-width: 12px; }
|
||||
.hero-photo-tr { right: -50px; top: -50px; }
|
||||
.hero-photo-bl { left: -50px; bottom: -70px; }
|
||||
.hero-cta { display: block; width: fit-content; margin-left: auto; margin-right: 0; }
|
||||
}
|
||||
.hero-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
|
||||
.hero-desc {
|
||||
font-size: clamp(0.9rem, 2vw, 1.05rem);
|
||||
@@ -118,6 +160,7 @@
|
||||
border-radius: 18px; padding: 1.75rem;
|
||||
border: 1px solid rgba(180,170,220,0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||
}
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-3px);
|
||||
@@ -147,6 +190,7 @@
|
||||
border-radius: 18px; padding: 2rem 2.5rem;
|
||||
border: 1px solid rgba(180,170,220,0.2);
|
||||
text-align: center;
|
||||
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||
}
|
||||
.pricing-text {
|
||||
font-size: clamp(1.1rem, 2.5vw, 1.3rem);
|
||||
@@ -177,12 +221,13 @@
|
||||
border-radius: 18px; padding: 2rem 1.75rem;
|
||||
border: 1px solid rgba(180,170,220,0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
|
||||
}
|
||||
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; }
|
||||
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; text-align: center; }
|
||||
.service-card h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.5rem; color: #2d2b55; }
|
||||
.service-card p { color: #5a5680; font-size: 0.95rem; line-height: 1.6; }
|
||||
|
||||
@@ -210,6 +255,7 @@
|
||||
border-radius: 22px; padding: 2.5rem 2rem;
|
||||
box-shadow: 0 8px 40px rgba(124,108,255,0.12);
|
||||
border: 1px solid rgba(180,170,220,0.25);
|
||||
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||
}
|
||||
.form-wrapper h2 {
|
||||
text-align: center; font-size: 1.6rem; font-weight: 800;
|
||||
@@ -258,7 +304,7 @@
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 600px) {
|
||||
.hero { padding: 6.5rem 1rem 3rem; }
|
||||
.hero { padding: 6.5rem 1rem 10rem; }
|
||||
.section { padding: 3rem 1rem; }
|
||||
.form-section { padding: 3rem 1rem; }
|
||||
.form-wrapper { padding: 1.75rem 1.25rem; }
|
||||
@@ -286,11 +332,15 @@
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
||||
<h1>{{ t.landing_hero_title }}</h1>
|
||||
<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>
|
||||
<img class="hero-photo hero-photo-tr" src="/static/cat_up_right.png" alt="">
|
||||
<img class="hero-photo hero-photo-bl" src="/static/cat_bottom_left.png" alt="">
|
||||
<div class="hero-content">
|
||||
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
||||
<h1>{{ t.landing_hero_title }}</h1>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services -->
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
|
||||
Reference in New Issue
Block a user