Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fd4f86f8 | |||
| 77f6b5c5e2 | |||
| 3a084a9d79 | |||
| 85512ab48b | |||
| 3bf62c80d5 | |||
| 4cc07632f0 | |||
| 7b0017d1f4 | |||
| 1d2722b715 |
Generated
+1
-1
@@ -3255,7 +3255,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -16,3 +16,4 @@ multer = "3"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
tokio = { version = "1", features = ["fs"] }
|
tokio = { version = "1", features = ["fs"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ FROM debian:bookworm-slim
|
|||||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||||
|
COPY static /app/static
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["web-petting"]
|
CMD ["web-petting"]
|
||||||
|
|||||||
+41
-3
@@ -401,6 +401,8 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot::
|
|||||||
login: form.login,
|
login: form.login,
|
||||||
password_hash: password_auth::generate_hash(&form.password),
|
password_hash: password_auth::generate_hash(&form.password),
|
||||||
display_name: display,
|
display_name: display,
|
||||||
|
telegram_chat_id: None,
|
||||||
|
telegram_notifications: Some(false),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now_utc(),
|
created_at: now_utc(),
|
||||||
updated_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,
|
login: form.login,
|
||||||
password_hash: password_auth::generate_hash(&form.password),
|
password_hash: password_auth::generate_hash(&form.password),
|
||||||
display_name: display,
|
display_name: display,
|
||||||
|
telegram_chat_id: None,
|
||||||
|
telegram_notifications: Some(false),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now_utc(),
|
created_at: now_utc(),
|
||||||
updated_at: now_utc(),
|
updated_at: now_utc(),
|
||||||
@@ -788,10 +792,11 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SettingsForm {
|
struct SettingsForm {
|
||||||
telegram_bot_token: String,
|
telegram_bot_token: String,
|
||||||
telegram_chat_id: String,
|
|
||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
|
site_domain: String,
|
||||||
|
seo_keywords: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
@@ -803,10 +808,11 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
|
|
||||||
for (key, value) in [
|
for (key, value) in [
|
||||||
("telegram_bot_token", form.telegram_bot_token),
|
("telegram_bot_token", form.telegram_bot_token),
|
||||||
("telegram_chat_id", form.telegram_chat_id),
|
|
||||||
("contact_info", form.contact_info),
|
("contact_info", form.contact_info),
|
||||||
("pricing_info", form.pricing_info),
|
("pricing_info", form.pricing_info),
|
||||||
("timezone", form.timezone),
|
("timezone", form.timezone),
|
||||||
|
("site_domain", form.site_domain),
|
||||||
|
("seo_keywords", form.seo_keywords),
|
||||||
] {
|
] {
|
||||||
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?;
|
||||||
@@ -973,6 +979,31 @@ async fn user_activate(
|
|||||||
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct AddLeadForm {
|
struct AddLeadForm {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -1143,7 +1174,7 @@ async fn schedule_events(
|
|||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
|
|
||||||
let (bg_color, text_color) = match v.status.as_str() {
|
let (bg_color, text_color) = match v.status.as_str() {
|
||||||
"cancelled" => ("#ccc".to_string(), "#666"),
|
"cancelled" => ("#ffb3b3".to_string(), "#a00"),
|
||||||
"completed" => (format!("{}88", client_color), "#fff"),
|
"completed" => (format!("{}88", client_color), "#fff"),
|
||||||
_ => (client_color.to_string(), "#fff"),
|
_ => (client_color.to_string(), "#fff"),
|
||||||
};
|
};
|
||||||
@@ -1612,8 +1643,10 @@ async fn media_delete(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
||||||
|
let file_path = m.file_path.clone();
|
||||||
m.status = "archived".to_string();
|
m.status = "archived".to_string();
|
||||||
m.save(&db).await?;
|
m.save(&db).await?;
|
||||||
|
let _ = tokio::fs::remove_file(&file_path).await;
|
||||||
}
|
}
|
||||||
let redirect_url = referer
|
let redirect_url = referer
|
||||||
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
||||||
@@ -2075,6 +2108,11 @@ pub fn admin_router() -> Router {
|
|||||||
user_activate,
|
user_activate,
|
||||||
"admin-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", media_page, "admin-media"),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/media/{visit_id}/upload",
|
"/media/{visit_id}/upload",
|
||||||
|
|||||||
+13
-1
@@ -118,6 +118,8 @@ pub struct Translations {
|
|||||||
pub users_add_button: &'static str,
|
pub users_add_button: &'static str,
|
||||||
pub users_error_passwords_mismatch: &'static str,
|
pub users_error_passwords_mismatch: &'static str,
|
||||||
pub users_error_login_taken: &'static str,
|
pub users_error_login_taken: &'static str,
|
||||||
|
pub users_telegram_chat_id: &'static str,
|
||||||
|
pub users_telegram_enabled: &'static str,
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
pub settings_title: &'static str,
|
pub settings_title: &'static str,
|
||||||
@@ -131,6 +133,8 @@ pub struct Translations {
|
|||||||
pub settings_contact_info: &'static str,
|
pub settings_contact_info: &'static str,
|
||||||
pub settings_pricing_info: &'static str,
|
pub settings_pricing_info: &'static str,
|
||||||
pub settings_timezone: &'static str,
|
pub settings_timezone: &'static str,
|
||||||
|
pub settings_site_domain: &'static str,
|
||||||
|
pub settings_seo_keywords: &'static str,
|
||||||
pub landing_contact_label: &'static str,
|
pub landing_contact_label: &'static str,
|
||||||
pub landing_pricing_title: &'static str,
|
pub landing_pricing_title: &'static str,
|
||||||
|
|
||||||
@@ -328,6 +332,8 @@ static RU: Translations = Translations {
|
|||||||
users_add_button: "Добавить",
|
users_add_button: "Добавить",
|
||||||
users_error_passwords_mismatch: "Пароли не совпадают.",
|
users_error_passwords_mismatch: "Пароли не совпадают.",
|
||||||
users_error_login_taken: "Этот логин уже занят.",
|
users_error_login_taken: "Этот логин уже занят.",
|
||||||
|
users_telegram_chat_id: "Telegram Chat ID",
|
||||||
|
users_telegram_enabled: "Уведомления",
|
||||||
|
|
||||||
settings_title: "Настройки",
|
settings_title: "Настройки",
|
||||||
settings_key: "Параметр",
|
settings_key: "Параметр",
|
||||||
@@ -340,6 +346,8 @@ static RU: Translations = Translations {
|
|||||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||||
|
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||||
|
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
|
||||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||||
landing_pricing_title: "Стоимость",
|
landing_pricing_title: "Стоимость",
|
||||||
|
|
||||||
@@ -419,7 +427,7 @@ static RU: Translations = Translations {
|
|||||||
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_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
||||||
landing_hero_cta: "Оставить заявку",
|
landing_hero_cta: "Оставить заявку",
|
||||||
@@ -527,6 +535,8 @@ static EN: Translations = Translations {
|
|||||||
users_add_button: "Add",
|
users_add_button: "Add",
|
||||||
users_error_passwords_mismatch: "Passwords do not match.",
|
users_error_passwords_mismatch: "Passwords do not match.",
|
||||||
users_error_login_taken: "This login is already taken.",
|
users_error_login_taken: "This login is already taken.",
|
||||||
|
users_telegram_chat_id: "Telegram Chat ID",
|
||||||
|
users_telegram_enabled: "Notifications",
|
||||||
|
|
||||||
settings_title: "Settings",
|
settings_title: "Settings",
|
||||||
settings_key: "Parameter",
|
settings_key: "Parameter",
|
||||||
@@ -539,6 +549,8 @@ static EN: Translations = Translations {
|
|||||||
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)",
|
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||||
|
settings_site_domain: "Site domain (e.g. https://example.com)",
|
||||||
|
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
|
||||||
landing_contact_label: "Or contact us directly",
|
landing_contact_label: "Or contact us directly",
|
||||||
landing_pricing_title: "Pricing",
|
landing_pricing_title: "Pricing",
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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;
|
pub mod m_0005_testimonials;
|
||||||
|
pub mod m_0006_user_telegram;
|
||||||
/// 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,
|
||||||
@@ -14,4 +15,5 @@ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
|||||||
&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,
|
&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 login: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
pub telegram_chat_id: Option<String>,
|
||||||
|
pub telegram_notifications: Option<bool>,
|
||||||
/// active | archived
|
/// active | archived
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
|||||||
+149
@@ -6,6 +6,7 @@ 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 serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use cot::db::query;
|
use cot::db::query;
|
||||||
|
|
||||||
@@ -71,7 +72,10 @@ struct LandingTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
|
seo_keywords: String,
|
||||||
testimonials: Vec<Testimonial>,
|
testimonials: Vec<Testimonial>,
|
||||||
|
site_domain: String,
|
||||||
|
review_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -83,6 +87,33 @@ struct ThankYouTemplate<'a> {
|
|||||||
|
|
||||||
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||||
let lang = detect_lang(&request);
|
let lang = detect_lang(&request);
|
||||||
|
|
||||||
|
let ua = request
|
||||||
|
.headers()
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("-");
|
||||||
|
let referer = request
|
||||||
|
.headers()
|
||||||
|
.get("referer")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("-");
|
||||||
|
let ip = request
|
||||||
|
.headers()
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.unwrap_or("-");
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "landing",
|
||||||
|
ip = ip,
|
||||||
|
lang = lang.code(),
|
||||||
|
referer = referer,
|
||||||
|
ua = ua,
|
||||||
|
"landing visit"
|
||||||
|
);
|
||||||
let key = "contact_info".to_string();
|
let key = "contact_info".to_string();
|
||||||
let contact_info = query!(Setting, $key == key)
|
let contact_info = query!(Setting, $key == key)
|
||||||
.get(&db)
|
.get(&db)
|
||||||
@@ -95,15 +126,31 @@ 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 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 seo_key = "seo_keywords".to_string();
|
||||||
|
let seo_keywords = query!(Setting, $key == seo_key)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_default();
|
||||||
let mut testimonials = Testimonial::objects().all(&db).await?;
|
let mut testimonials = Testimonial::objects().all(&db).await?;
|
||||||
testimonials.retain(|t| t.status == "active");
|
testimonials.retain(|t| t.status == "active");
|
||||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||||
|
let review_count = testimonials.len();
|
||||||
let body = LandingTemplate {
|
let body = LandingTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
contact_info,
|
contact_info,
|
||||||
pricing_info,
|
pricing_info,
|
||||||
|
seo_keywords,
|
||||||
testimonials,
|
testimonials,
|
||||||
|
site_domain,
|
||||||
|
review_count,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -367,9 +414,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 {
|
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("/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("/submit", submit_lead, "submit-lead"),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/testimonial-image/{id}",
|
"/testimonial-image/{id}",
|
||||||
|
|||||||
+28
-14
@@ -1,8 +1,8 @@
|
|||||||
use cot::db::{Database, query};
|
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.
|
/// Silently ignores errors (missing config, network issues) — notifications are best-effort.
|
||||||
pub async fn notify_new_lead(
|
pub async fn notify_new_lead(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -14,10 +14,6 @@ pub async fn notify_new_lead(
|
|||||||
Some(t) if !t.is_empty() => t,
|
Some(t) if !t.is_empty() => t,
|
||||||
_ => return,
|
_ => 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}");
|
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
|
||||||
if let Some(phone) = phone.filter(|s| !s.is_empty()) {
|
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}"));
|
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 url = format!("https://api.telegram.org/bot{token}/sendMessage");
|
||||||
let _ = reqwest::Client::new()
|
|
||||||
.post(&url)
|
for user in &users {
|
||||||
.json(&serde_json::json!({
|
if user.telegram_notifications != Some(true) {
|
||||||
"chat_id": chat_id,
|
continue;
|
||||||
"text": text,
|
}
|
||||||
}))
|
let chat_id = match &user.telegram_chat_id {
|
||||||
.send()
|
Some(id) if !id.is_empty() => id,
|
||||||
.await;
|
_ => 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> {
|
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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
<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">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<style>
|
||||||
:root { --accent: #6c63ff; color-scheme: light; }
|
:root { --accent: #6c63ff; color-scheme: light; }
|
||||||
@@ -25,15 +26,24 @@
|
|||||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
|
||||||
background: #fff; border-top: 1px solid #e8e8e8;
|
background: #fff; border-top: 1px solid #e8e8e8;
|
||||||
display: flex; height: 3.5rem;
|
display: flex; height: 3.5rem;
|
||||||
|
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.bottom-tabs::-webkit-scrollbar { display: none; }
|
||||||
.bottom-tabs a {
|
.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;
|
justify-content: center; text-decoration: none; color: #999;
|
||||||
font-size: 0.65rem; font-weight: 600; gap: 0.15rem;
|
font-size: 0.6rem; font-weight: 600; gap: 0.1rem;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
|
.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); }
|
.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: hide bottom tabs, show top nav ── */
|
||||||
.desktop-nav { display: none; }
|
.desktop-nav { display: none; }
|
||||||
@@ -134,28 +144,28 @@
|
|||||||
<!-- Bottom tabs (mobile) -->
|
<!-- Bottom tabs (mobile) -->
|
||||||
<nav class="bottom-tabs">
|
<nav class="bottom-tabs">
|
||||||
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<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><span class="tab-label">{{ t.nav_media }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>
|
<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>
|
||||||
<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><span class="tab-label">{{ t.nav_users }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
|
<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>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if lead.status == "new" || lead.status == "in_progress" %}
|
{% if lead.status == "new" || lead.status == "in_progress" %}
|
||||||
<div class="item-card-actions">
|
<div class="item-card-actions">
|
||||||
{% if lead.status == "new" %}
|
|
||||||
<form method="post" action="/admin/leads/{{ lead.id }}/status">
|
|
||||||
<input type="hidden" name="status" value="in_progress">
|
|
||||||
<button type="submit" class="button is-small is-info is-outlined btn-sm">{{ t.action_in_progress }}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
|
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
|
||||||
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,11 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
<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">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<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-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); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -14,12 +14,13 @@
|
|||||||
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
<form id="uploadForm" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_choose_files }}</label>
|
<label class="label">{{ t.media_choose_files }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -29,7 +30,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
<!-- Progress -->
|
||||||
|
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||||
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||||
|
<span id="uploadPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||||
|
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var form = document.getElementById('uploadForm');
|
||||||
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var progress = document.getElementById('uploadProgress');
|
||||||
|
var bar = document.getElementById('uploadBar');
|
||||||
|
var percent = document.getElementById('uploadPercent');
|
||||||
|
var statusText = document.getElementById('uploadStatusText');
|
||||||
|
var submitBtn = document.getElementById('uploadSubmit');
|
||||||
|
|
||||||
|
filesInput.addEventListener('change', function() {
|
||||||
|
var n = this.files.length;
|
||||||
|
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!filesInput.files.length) return;
|
||||||
|
|
||||||
|
var data = new FormData(form);
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
progress.style.display = 'block';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Загрузка...';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
percent.textContent = '0%';
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', function(ev) {
|
||||||
|
if (!ev.lengthComputable) return;
|
||||||
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
percent.textContent = pct + '%';
|
||||||
|
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', function() {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
percent.textContent = '100%';
|
||||||
|
statusText.textContent = 'Готово!';
|
||||||
|
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', function() {
|
||||||
|
statusText.textContent = 'Ошибка соединения';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', form.action);
|
||||||
|
xhr.send(data);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
||||||
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
||||||
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
||||||
|
.fc-event.ev-completed .fc-event-title,
|
||||||
|
.fc-event.ev-completed .fc-list-event-title { text-decoration: line-through; opacity: 0.75; }
|
||||||
|
.fc-event.ev-cancelled .fc-event-title,
|
||||||
|
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; }
|
||||||
.fc .fc-day-today { background: #eef2ff !important; }
|
.fc .fc-day-today { background: #eef2ff !important; }
|
||||||
.fc .fc-day.day-weekend { background: #faf5f0; }
|
.fc .fc-day.day-weekend { background: #faf5f0; }
|
||||||
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
||||||
@@ -45,8 +49,6 @@
|
|||||||
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
||||||
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
||||||
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
||||||
.visit-modal .actions { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
|
||||||
.visit-modal .actions form { margin:0; }
|
|
||||||
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -60,12 +62,8 @@
|
|||||||
<div id="vmTime"></div>
|
<div id="vmTime"></div>
|
||||||
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="vmStatus" style="margin-bottom:0.75rem;"></div>
|
<div id="vmStatus" style="margin-bottom:1rem;"></div>
|
||||||
<div class="actions" id="vmActions"></div>
|
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth" style="font-size:1rem;font-weight:700;padding:0.65rem;">📋 {{ t.schedule_edit_title }}</a>
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
|
||||||
<a id="vmEditLink" href="#" class="button is-info is-small" style="flex:1;">{{ t.schedule_edit_title }}</a>
|
|
||||||
<button class="button is-light is-small" style="flex:1;" onclick="closeModal()">OK</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,6 +87,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||||
},
|
},
|
||||||
events: '/admin/schedule/events',
|
events: '/admin/schedule/events',
|
||||||
|
eventDidMount: function(info) {
|
||||||
|
var status = info.event.extendedProps.status;
|
||||||
|
if (status === 'completed') info.el.classList.add('ev-completed');
|
||||||
|
if (status === 'cancelled') info.el.classList.add('ev-cancelled');
|
||||||
|
},
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
info.jsEvent.preventDefault();
|
info.jsEvent.preventDefault();
|
||||||
const ev = info.event;
|
const ev = info.event;
|
||||||
@@ -102,12 +105,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('vmNotes').textContent = p.notes || '';
|
document.getElementById('vmNotes').textContent = p.notes || '';
|
||||||
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
||||||
document.getElementById('vmStatus').innerHTML = badge;
|
document.getElementById('vmStatus').innerHTML = badge;
|
||||||
let actions = '';
|
|
||||||
if (p.status === 'scheduled') {
|
|
||||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/done"><button class="button is-small is-success is-outlined">{{ t.schedule_mark_done }}</button></form>';
|
|
||||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/cancel"><button class="button is-small is-danger is-outlined">{{ t.schedule_cancel }}</button></form>';
|
|
||||||
}
|
|
||||||
document.getElementById('vmActions').innerHTML = actions;
|
|
||||||
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
||||||
document.getElementById('visitModal').classList.add('is-open');
|
document.getElementById('visitModal').classList.add('is-open');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,14 +70,17 @@
|
|||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_status }}</label>
|
<label class="label">{{ t.schedule_status }}</label>
|
||||||
<div class="control">
|
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
|
||||||
<div class="select is-fullwidth">
|
<div class="status-picker">
|
||||||
<select name="status">
|
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
|
||||||
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
|
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
|
||||||
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
|
</button>
|
||||||
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
|
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
|
||||||
</select>
|
<span class="status-btn-icon">✅</span>{{ t.visit_status_completed }}
|
||||||
</div>
|
</button>
|
||||||
|
<button type="button" class="status-btn status-btn-cancelled {% if visit.status == "cancelled" %}is-active{% endif %}" data-value="cancelled">
|
||||||
|
<span class="status-btn-icon">✕</span>{{ t.visit_status_cancelled }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,14 +153,15 @@
|
|||||||
<div class="upload-modal">
|
<div class="upload-modal">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
||||||
<button type="button" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')">✕</button>
|
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_choose_files }}</label>
|
<label class="label">{{ t.media_choose_files }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_caption }}</label>
|
<label class="label">{{ t.media_caption }}</label>
|
||||||
@@ -165,12 +169,56 @@
|
|||||||
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||||
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||||
|
<span id="uploadPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||||
|
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.status-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.status-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.6rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #777;
|
||||||
|
transition: all 0.15s;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
||||||
|
.status-btn:hover { filter: brightness(0.95); }
|
||||||
|
|
||||||
|
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
|
||||||
|
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
|
||||||
|
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
|
||||||
|
|
||||||
|
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
|
||||||
|
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
|
||||||
|
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
|
||||||
|
|
||||||
.visit-media-grid {
|
.visit-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
@@ -229,8 +277,91 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('uploadModal').addEventListener('click', function(e) {
|
// Status picker
|
||||||
if (e.target === this) this.classList.remove('is-open');
|
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
document.getElementById('statusInput').value = btn.dataset.value;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var modal = document.getElementById('uploadModal');
|
||||||
|
var form = document.getElementById('uploadForm');
|
||||||
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var progress = document.getElementById('uploadProgress');
|
||||||
|
var bar = document.getElementById('uploadBar');
|
||||||
|
var percent = document.getElementById('uploadPercent');
|
||||||
|
var statusText = document.getElementById('uploadStatusText');
|
||||||
|
var submitBtn = document.getElementById('uploadSubmit');
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeModal();
|
||||||
|
});
|
||||||
|
document.getElementById('uploadModalClose').addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
if (submitBtn.disabled) return; // prevent close during upload
|
||||||
|
modal.classList.remove('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show selected file count
|
||||||
|
filesInput.addEventListener('change', function() {
|
||||||
|
var n = this.files.length;
|
||||||
|
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit via XHR for progress tracking
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!filesInput.files.length) return;
|
||||||
|
|
||||||
|
var data = new FormData(form);
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// Show progress bar, disable submit
|
||||||
|
progress.style.display = 'block';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Загрузка...';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
percent.textContent = '0%';
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', function(ev) {
|
||||||
|
if (!ev.lengthComputable) return;
|
||||||
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
percent.textContent = pct + '%';
|
||||||
|
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', function() {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
percent.textContent = '100%';
|
||||||
|
statusText.textContent = 'Готово!';
|
||||||
|
// Reload page to show uploaded media
|
||||||
|
setTimeout(function() { window.location.reload(); }, 300);
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', function() {
|
||||||
|
statusText.textContent = 'Ошибка соединения';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', form.action);
|
||||||
|
xhr.send(data);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}">
|
<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>
|
</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">
|
<div class="field">
|
||||||
<label class="label">{{ t.settings_contact_info }}</label>
|
<label class="label">{{ t.settings_contact_info }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@@ -38,13 +32,76 @@
|
|||||||
<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>
|
<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>
|
</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">
|
<div class="field">
|
||||||
<label class="label">{{ t.settings_timezone }}</label>
|
<label class="label">{{ t.settings_timezone }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_seo_keywords }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3"
|
||||||
|
style="resize:vertical;"
|
||||||
|
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||||
|
</div>
|
||||||
|
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
||||||
|
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var COLORS = [
|
||||||
|
'rgba(124,108,255,0.18)',
|
||||||
|
'rgba(255,82,135,0.15)',
|
||||||
|
'rgba(255,140,38,0.18)',
|
||||||
|
'rgba(0,180,150,0.15)',
|
||||||
|
'rgba(77,166,255,0.18)',
|
||||||
|
'rgba(255,179,64,0.18)',
|
||||||
|
'rgba(176,108,255,0.16)',
|
||||||
|
'rgba(34,180,130,0.16)',
|
||||||
|
];
|
||||||
|
|
||||||
|
var ta = document.getElementById('seoKeywordsInput');
|
||||||
|
var preview = document.getElementById('seoPreview');
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var text = ta.value.trim();
|
||||||
|
if (!text) { preview.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
var parts = text.split(',');
|
||||||
|
var html = '';
|
||||||
|
parts.forEach(function(part, i) {
|
||||||
|
var word = part.trim();
|
||||||
|
if (word) {
|
||||||
|
var color = COLORS[i % COLORS.length];
|
||||||
|
html += '<span style="background:' + color + ';border-radius:4px;padding:2px 6px;margin:2px;">' + esc(word) + '</span>';
|
||||||
|
}
|
||||||
|
if (i < parts.length - 1) {
|
||||||
|
html += '<span style="color:#ccc;font-size:0.8em;margin:0 1px">,</span>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
preview.innerHTML = html;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
ta.addEventListener('input', render);
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,11 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
<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">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<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-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); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -17,6 +17,29 @@
|
|||||||
<div class="item-card-meta">
|
<div class="item-card-meta">
|
||||||
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
|
||||||
</div>
|
</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">
|
<div class="item-card-actions">
|
||||||
{% if user.status == "active" %}
|
{% if user.status == "active" %}
|
||||||
<form method="post" action="/admin/users/{{ user.id }}/archive">
|
<form method="post" action="/admin/users/{{ user.id }}/archive">
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
.feedback-form textarea {
|
.feedback-form textarea {
|
||||||
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
|
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;
|
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 textarea:focus { outline: none; border-color: #7c6cff; }
|
||||||
.feedback-form button {
|
.feedback-form button {
|
||||||
|
|||||||
+53
-59
@@ -5,11 +5,30 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="{{ t.landing_meta_description }}">
|
<meta name="description" content="{{ t.landing_meta_description }}">
|
||||||
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
|
<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 -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||||
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
||||||
<meta property="og:type" content="website">
|
<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 }}">
|
||||||
|
|
||||||
|
{% if !seo_keywords.is_empty() %}
|
||||||
|
<meta name="keywords" content="{{ seo_keywords }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- Structured Data -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
@@ -19,7 +38,15 @@
|
|||||||
"name": "{{ t.nav_title }}",
|
"name": "{{ t.nav_title }}",
|
||||||
"description": "{{ t.landing_meta_description }}",
|
"description": "{{ t.landing_meta_description }}",
|
||||||
"serviceType": "Pet Sitting",
|
"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>
|
</script>
|
||||||
|
|
||||||
@@ -96,50 +123,24 @@
|
|||||||
box-shadow: 0 4px 20px rgba(124,108,255,0.35);
|
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-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); }
|
||||||
.hero { position: relative; }
|
.hero { position: relative; overflow: clip; }
|
||||||
.hero-blob {
|
.hero-photo {
|
||||||
position: absolute;
|
position: absolute; z-index: 0;
|
||||||
|
width: 420px; height: 420px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
z-index: 0;
|
border: 20px solid #ffe0ec;
|
||||||
background-size: cover;
|
object-fit: cover;
|
||||||
background-position: center;
|
box-sizing: border-box;
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
.hero-blob-br {
|
|
||||||
width: 360px; height: 400px;
|
|
||||||
right: -100px; bottom: -110px;
|
|
||||||
background-color: #ffe0ec;
|
|
||||||
background-image: radial-gradient(ellipse at 40% 40%, #ffd6e8 0%, #f8c4d8 60%, #eab0d0 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-blob-tl {
|
|
||||||
width: 240px; height: 270px;
|
|
||||||
left: -65px; top: -15px;
|
|
||||||
background-color: #d8ecff;
|
|
||||||
background-image: radial-gradient(ellipse at 60% 60%, #d0e8ff 0%, #b8d8f8 60%, #a4c8f0 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-blob-bl {
|
|
||||||
width: 290px; height: 330px;
|
|
||||||
left: -80px; bottom: -90px;
|
|
||||||
background-color: #d4f0d8;
|
|
||||||
background-image: radial-gradient(ellipse at 55% 45%, #ddf5e0 0%, #c4e8ca 60%, #b0dbb8 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-blob svg {
|
|
||||||
position: absolute; opacity: 0.3;
|
|
||||||
}
|
|
||||||
.hero-blob img {
|
|
||||||
width: 100%; height: 100%; object-fit: contain;
|
|
||||||
}
|
}
|
||||||
|
.hero-photo-tr { right: -100px; top: -100px; }
|
||||||
|
.hero-photo-bl { left: -100px; bottom: -150px; }
|
||||||
.hero-content { position: relative; z-index: 1; }
|
.hero-content { position: relative; z-index: 1; }
|
||||||
|
.section-alt { position: relative; z-index: 1; }
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.hero-blob-br { width: 200px; height: 240px; right: -55px; bottom: -65px; }
|
.hero-photo { width: 200px; height: 200px; border-width: 12px; }
|
||||||
.hero-blob-bl { width: 180px; height: 210px; left: -50px; bottom: -55px; }
|
.hero-photo-tr { right: -50px; top: -50px; }
|
||||||
.hero-blob-tl { width: 140px; height: 160px; left: -40px; top: -10px; }
|
.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-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
@@ -230,7 +231,7 @@
|
|||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
|
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 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; }
|
.service-card p { color: #5a5680; font-size: 0.95rem; line-height: 1.6; }
|
||||||
|
|
||||||
@@ -304,10 +305,14 @@
|
|||||||
border-top: 1px solid rgba(180,170,220,0.15);
|
border-top: 1px solid rgba(180,170,220,0.15);
|
||||||
background: rgba(255,255,255,0.3);
|
background: rgba(255,255,255,0.3);
|
||||||
}
|
}
|
||||||
|
.seo-keywords {
|
||||||
|
font-size: 0.72rem; color: #aaa; line-height: 2;
|
||||||
|
max-width: 800px; margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.hero { padding: 6.5rem 1rem 3rem; }
|
.hero { padding: 6.5rem 1rem 10rem; }
|
||||||
.section { padding: 3rem 1rem; }
|
.section { padding: 3rem 1rem; }
|
||||||
.form-section { padding: 3rem 1rem; }
|
.form-section { padding: 3rem 1rem; }
|
||||||
.form-wrapper { padding: 1.75rem 1.25rem; }
|
.form-wrapper { padding: 1.75rem 1.25rem; }
|
||||||
@@ -335,22 +340,8 @@
|
|||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-blob hero-blob-tl">
|
<img class="hero-photo hero-photo-tr" src="/static/cat_up_right.png" alt="">
|
||||||
<svg viewBox="0 0 60 70" width="50" height="58" style="left:15%;top:18%;transform:rotate(-15deg);"><ellipse cx="30" cy="46" rx="16" ry="18" fill="#8cb8e8" opacity="0.55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="#8cb8e8" opacity="0.55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="#8cb8e8" opacity="0.55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="#8cb8e8" opacity="0.55" transform="rotate(-5 24 8)"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="#8cb8e8" opacity="0.55" transform="rotate(5 38 8)"/></svg>
|
<img class="hero-photo hero-photo-bl" src="/static/cat_bottom_left.png" alt="">
|
||||||
<svg viewBox="0 0 60 55" width="32" height="30" style="right:20%;bottom:22%;transform:rotate(12deg);"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="#7baad4" opacity="0.5"/></svg>
|
|
||||||
<svg viewBox="0 0 80 40" width="44" height="22" style="left:50%;top:60%;transform:rotate(8deg);"><path d="M60 20 Q48 2 28 8 Q10 14 10 20 Q10 26 28 32 Q48 38 60 20Z" fill="#9ac4e8" opacity="0.3"/><path d="M60 20 L74 6 L74 34 Z" fill="#9ac4e8" opacity="0.3"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="hero-blob hero-blob-bl">
|
|
||||||
<svg viewBox="0 0 60 70" width="45" height="52" style="right:18%;top:15%;transform:rotate(20deg);"><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="#8cc49a" opacity="0.35"/><path d="M30 60 L30 20 M30 35 Q20 30 15 25 M30 45 Q40 40 45 35" fill="none" stroke="#7ab88a" stroke-width="2" stroke-linecap="round"/></svg>
|
|
||||||
<svg viewBox="0 0 80 40" width="40" height="20" style="left:30%;bottom:25%;transform:rotate(-12deg);"><path d="M60 20 Q48 2 28 8 Q10 14 10 20 Q10 26 28 32 Q48 38 60 20Z" fill="#90c8a0" opacity="0.3"/><path d="M60 20 L74 6 L74 34 Z" fill="#90c8a0" opacity="0.3"/></svg>
|
|
||||||
<svg viewBox="0 0 60 55" width="30" height="28" style="right:30%;bottom:40%;transform:rotate(15deg);"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="#7cb88c" opacity="0.5"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="hero-blob hero-blob-br">
|
|
||||||
<svg viewBox="0 0 80 80" width="60" height="60" style="left:12%;top:15%;transform:rotate(10deg);"><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="#e89cb8" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="#e89cb8"/><circle cx="52" cy="36" r="3.5" fill="#e89cb8"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="#e89cb8"/></svg>
|
|
||||||
<svg viewBox="0 0 60 70" width="40" height="47" style="right:15%;top:50%;transform:rotate(-20deg);"><ellipse cx="30" cy="46" rx="16" ry="18" fill="#e8a0b8" opacity="0.55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="#e8a0b8" opacity="0.55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="#e8a0b8" opacity="0.55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="#e8a0b8" opacity="0.55" transform="rotate(-5 24 8)"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="#e8a0b8" opacity="0.55" transform="rotate(5 38 8)"/></svg>
|
|
||||||
<svg viewBox="0 0 60 55" width="36" height="33" style="left:55%;bottom:18%;transform:rotate(-8deg);"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="#d48aaa" opacity="0.5"/></svg>
|
|
||||||
<svg viewBox="0 0 60 70" width="30" height="35" style="left:20%;bottom:30%;transform:rotate(25deg);"><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="#e8b0c4" opacity="0.35"/><path d="M30 60 L30 20 M30 35 Q20 30 15 25 M30 45 Q40 40 45 35" fill="none" stroke="#d49ab0" stroke-width="2" stroke-linecap="round"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<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>
|
||||||
@@ -486,6 +477,9 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
{% if !seo_keywords.is_empty() %}
|
||||||
|
<p class="seo-keywords" id="seoKeywords">{{ seo_keywords }}</p>
|
||||||
|
{% endif %}
|
||||||
<p>{{ t.landing_footer_text }}</p>
|
<p>{{ t.landing_footer_text }}</p>
|
||||||
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
|||||||
Reference in New Issue
Block a user