17 Commits

Author SHA1 Message Date
Ultradesu 43441ee430 Reworked calendar form
Build and Publish / Build and Publish Docker Image (push) Successful in 2m17s
2026-05-18 17:07:43 +03:00
Ultradesu 90fd4f86f8 Added landing visit log line
Build and Publish / Build and Publish Docker Image (push) Successful in 1m20s
2026-05-18 14:53:08 +03:00
Ultradesu 77f6b5c5e2 Added SEO keywords. Added medua upload indicator. Added Khabarovsk default
Build and Publish / Build and Publish Docker Image (push) Successful in 1m17s
2026-05-18 14:47:04 +03:00
ab 3a084a9d79 Fixed menu
Build and Publish / Build and Publish Docker Image (push) Successful in 1m17s
2026-05-15 15:55:37 +03:00
ab 85512ab48b Reworked telegram notifications
Build and Publish / Build and Publish Docker Image (push) Successful in 1m50s
2026-05-15 15:28:19 +03:00
ab 3bf62c80d5 fixed docker build static
Build and Publish / Build and Publish Docker Image (push) Successful in 3m51s
2026-05-14 16:34:42 +03:00
ab 4cc07632f0 fixed docker build static
Build and Publish / Build and Publish Docker Image (push) Successful in 1m47s
2026-05-14 16:29:30 +03:00
ab 7b0017d1f4 Fixed docker build
Build and Publish / Build and Publish Docker Image (push) Successful in 1m53s
2026-05-14 16:21:54 +03:00
ab 1d2722b715 SEO fixes and style fixes.
Build and Publish / Build and Publish Docker Image (push) Successful in 1m55s
2026-05-14 16:12:33 +03:00
Ultradesu bfd0aec56f Adjusted hero landing
Build and Publish / Build and Publish Docker Image (push) Successful in 2m6s
2026-05-13 15:00:38 +01:00
Ultradesu 68d578b29d Adjusted hero landing
Build and Publish / Build and Publish Docker Image (push) Successful in 1m53s
2026-05-13 14:56:15 +01:00
Ultradesu 21331b75a8 Fixed media upload. added comment section
Build and Publish / Build and Publish Docker Image (push) Successful in 1m12s
2026-05-12 14:47:44 +01:00
Ultradesu 6395e36c62 Fixed media upload. added comment section 2026-05-12 14:47:24 +01:00
Ultradesu 87fb8c744d Added timezone support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m9s
2026-05-11 13:46:40 +01:00
Ultradesu 357a2ed423 Added timezone support
Build and Publish / Build and Publish Docker Image (push) Failing after 56s
2026-05-11 13:43:31 +01:00
ab 434ed7a376 Update .github/workflows/build-and-publish.yml 2026-05-11 12:31:28 +00:00
Ultradesu b685075129 Added comment editing
Build and Publish / Build and Publish Docker Image (push) Successful in 1m20s
2026-05-11 13:30:34 +01:00
31 changed files with 1609 additions and 369 deletions
+3 -3
View File
@@ -2,9 +2,9 @@ name: Build and Publish
on:
push:
branches:
- master
- main
#branches:
# - master
# - main
tags:
- 'v*.*.*'
+1
View File
@@ -2,3 +2,4 @@
/data
db.sqlite3
/uploads
.DS_Store
Generated
+102 -1
View File
@@ -29,6 +29,15 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -1574,6 +1583,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
@@ -1649,6 +1667,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -2093,6 +2120,23 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -2376,6 +2420,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -2724,6 +2777,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -3012,6 +3074,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -3113,6 +3205,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -3255,9 +3353,10 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.2"
version = "0.1.9"
dependencies = [
"chrono",
"chrono-tz",
"cot",
"futures",
"multer",
@@ -3267,6 +3366,8 @@ dependencies = [
"serde_html_form",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
+4 -1
View File
@@ -1,11 +1,12 @@
[package]
name = "web-petting"
version = "0.1.2"
version = "0.1.10"
edition = "2024"
[dependencies]
cot = { version = "0.6.0", features = ["sqlite"] }
chrono = "0.4"
chrono-tz = "0.10"
serde = { version = "1", features = ["derive"] }
serde_html_form = "0.4"
password-auth = "1"
@@ -15,3 +16,5 @@ multer = "3"
futures = "0.3"
tokio = { version = "1", features = ["fs"] }
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+1
View File
@@ -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"]
+236 -44
View File
@@ -109,7 +109,7 @@ fn rand_client_color() -> &'static str {
CLIENT_COLORS[(h.finish() as usize) % CLIENT_COLORS.len()]
}
fn now() -> chrono::NaiveDateTime {
fn now_utc() -> chrono::NaiveDateTime {
chrono::Utc::now().naive_utc()
}
@@ -182,7 +182,9 @@ struct DashboardTemplate<'a> {
lang: Lang,
admin_name: &'a str,
today_visits: Vec<TodayVisit>,
recent_feedbacks: Vec<RecentFeedback>,
feedbacks: Vec<RecentFeedback>,
feedback_page: usize,
feedback_total_pages: usize,
}
#[derive(Debug, Template)]
@@ -260,6 +262,7 @@ struct ScheduleTemplate<'a> {
t: &'a Translations,
lang: Lang,
admin_name: &'a str,
timezone: String,
}
#[derive(Debug, Template)]
@@ -271,6 +274,7 @@ struct ScheduleNewTemplate<'a> {
clients: Vec<Client>,
users: Vec<User>,
current_user_id: i64,
timezone: String,
}
#[derive(Debug, Template)]
@@ -398,9 +402,11 @@ 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(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
user.save(&db).await?;
@@ -468,7 +474,8 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
Err(resp) => return Ok(resp),
};
let user_id = get_admin_id(&session).await.unwrap_or(0);
let today = chrono::Utc::now().date_naive();
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let all_visits = Visit::objects().all(&db).await?;
let clients = Client::objects().all(&db).await?;
@@ -492,13 +499,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
.collect();
today_visits.sort_by(|a, b| a.visit.time_start.cmp(&b.visit.time_start));
let week_ago = today - chrono::Duration::days(7);
let mut recent_feedbacks: Vec<RecentFeedback> = all_visits
let mut all_feedbacks: Vec<RecentFeedback> = all_visits
.iter()
.filter(|v| {
v.user_id.primary_key().unwrap() == user_id
&& v.client_feedback.is_some()
&& v.updated_at.date() >= week_ago
v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()
})
.map(|v| {
let cid: i64 = v.client_id.primary_key().unwrap();
@@ -515,14 +519,34 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
}
})
.collect();
recent_feedbacks.sort_by(|a, b| b.visit_date.cmp(&a.visit_date));
all_feedbacks.sort_by(|a, b| b.visit_date.cmp(&a.visit_date));
const PER_PAGE: usize = 10;
let feedback_page: usize = request
.uri()
.query()
.and_then(|q| {
q.split('&')
.find_map(|p| p.strip_prefix("page="))
.and_then(|v| v.parse().ok())
})
.unwrap_or(1)
.max(1);
let feedback_total_pages = (all_feedbacks.len() + PER_PAGE - 1).max(1) / PER_PAGE.max(1);
let feedbacks: Vec<RecentFeedback> = all_feedbacks
.into_iter()
.skip((feedback_page - 1) * PER_PAGE)
.take(PER_PAGE)
.collect();
let body = DashboardTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
today_visits,
recent_feedbacks,
feedbacks,
feedback_page,
feedback_total_pages,
}
.render()?;
html_response(body, lang)
@@ -664,7 +688,7 @@ async fn client_edit_submit(
if let Some(color) = form.color.filter(|s| !s.trim().is_empty()) {
client.color = Some(color);
}
client.updated_at = now();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
@@ -739,9 +763,11 @@ 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(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
user.save(&db).await?;
None
@@ -767,9 +793,11 @@ 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,
seo_keywords: String,
}
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
@@ -781,16 +809,18 @@ 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),
("seo_keywords", form.seo_keywords),
] {
let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?;
match existing {
Some(mut s) => {
s.value = value;
s.updated_at = now();
s.updated_at = now_utc();
s.save(&db).await?;
}
None => {
@@ -798,7 +828,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
id: Auto::auto(),
key: key.to_string(),
value,
updated_at: now(),
updated_at: now_utc(),
};
s.save(&db).await?;
}
@@ -835,7 +865,7 @@ async fn lead_set_status(
if let Some(mut lead) = query!(Lead, $id == lead_id).get(&db).await? {
lead.status = form.status;
lead.updated_at = now();
lead.updated_at = now_utc();
lead.save(&db).await?;
}
@@ -864,14 +894,14 @@ async fn lead_convert(
media_token: rand_token(),
color: Some(rand_client_color().to_string()),
status: "active".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
client.save(&db).await?;
lead.status = "converted".to_string();
lead.client_id = Some(ForeignKey::from(&client));
lead.updated_at = now();
lead.updated_at = now_utc();
lead.save(&db).await?;
}
@@ -890,7 +920,7 @@ async fn client_archive(
}
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
client.status = "archived".to_string();
client.updated_at = now();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
@@ -908,7 +938,7 @@ async fn client_activate(
}
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
client.status = "active".to_string();
client.updated_at = now();
client.updated_at = now_utc();
client.save(&db).await?;
}
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
@@ -926,7 +956,7 @@ async fn user_archive(
}
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
user.status = "archived".to_string();
user.updated_at = now();
user.updated_at = now_utc();
user.save(&db).await?;
}
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
@@ -944,7 +974,32 @@ async fn user_activate(
}
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
user.status = "active".to_string();
user.updated_at = now();
user.updated_at = now_utc();
user.save(&db).await?;
}
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()
@@ -972,8 +1027,8 @@ async fn add_lead(request: Request, session: Session, db: Database) -> cot::Resu
comment: form.comment.filter(|s| !s.trim().is_empty()),
status: "new".to_string(),
client_id: None,
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
lead.save(&db).await?;
@@ -1014,8 +1069,8 @@ async fn add_client(request: Request, session: Session, db: Database) -> cot::Re
media_token: rand_token(),
color: Some(rand_client_color().to_string()),
status: "active".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
client.save(&db).await?;
@@ -1026,16 +1081,18 @@ async fn add_client(request: Request, session: Session, db: Database) -> cot::Re
// Schedule Handlers
// ---------------------------------------------------------------------------
async fn schedule_page(request: Request, session: Session) -> cot::Result<Response> {
async fn schedule_page(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let admin_name = match require_auth(&session, lang).await {
Ok(name) => name,
Err(resp) => return Ok(resp),
};
let tz = crate::tz::load_tz(&db).await;
let body = ScheduleTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
timezone: tz.to_string(),
}
.render()?;
html_response(body, lang)
@@ -1054,6 +1111,7 @@ async fn schedule_new_page(
let current_user_id = get_admin_id(&session).await.unwrap_or(0);
let clients = query!(Client, $status == "active").all(&db).await?;
let users = query!(User, $status == "active").all(&db).await?;
let tz = crate::tz::load_tz(&db).await;
let body = ScheduleNewTemplate {
t: lang.t(),
lang,
@@ -1061,6 +1119,7 @@ async fn schedule_new_page(
clients,
users,
current_user_id,
timezone: tz.to_string(),
}
.render()?;
html_response(body, lang)
@@ -1089,10 +1148,12 @@ async fn schedule_events(
}
}
let tz = crate::tz::load_tz(&db).await;
let tz_today = crate::tz::today_in_tz(tz);
let start_date = chrono::NaiveDate::parse_from_str(start_str, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Utc::now().date_naive() - chrono::Duration::days(60));
.unwrap_or_else(|_| tz_today - chrono::Duration::days(60));
let end_date = chrono::NaiveDate::parse_from_str(end_str, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Utc::now().date_naive() + chrono::Duration::days(60));
.unwrap_or_else(|_| tz_today + chrono::Duration::days(60));
let visits = Visit::objects().all(&db).await?;
let clients = Client::objects().all(&db).await?;
@@ -1116,7 +1177,7 @@ async fn schedule_events(
.unwrap_or("?");
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"),
_ => (client_color.to_string(), "#fff"),
};
@@ -1203,8 +1264,8 @@ async fn schedule_create(
public_notes: None,
client_feedback: None,
status: "scheduled".to_string(),
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
visit.save(&db).await?;
}
@@ -1286,7 +1347,7 @@ async fn schedule_edit_submit(
visit.status = form.status;
visit.notes = form.notes.filter(|s| !s.trim().is_empty());
visit.public_notes = form.public_notes.filter(|s| !s.trim().is_empty());
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
@@ -1318,7 +1379,7 @@ async fn visit_set_done(
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "completed".to_string();
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
@@ -1336,7 +1397,7 @@ async fn visit_set_cancel(
}
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
visit.status = "cancelled".to_string();
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
@@ -1556,12 +1617,17 @@ async fn media_upload_submit(
file_type: ftype,
caption: caption_opt.clone(),
status: "active".to_string(),
created_at: now(),
created_at: now_utc(),
};
media.save(&db).await?;
}
Redirect::new(format!("/admin/?lang={}", lang.code())).into_response()
Redirect::new(format!(
"/admin/schedule/{}/edit?lang={}",
visit_id,
lang.code()
))
.into_response()
}
async fn media_delete(
@@ -1574,11 +1640,21 @@ async fn media_delete(
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let referer = request
.headers()
.get("referer")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
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.save(&db).await?;
let _ = tokio::fs::remove_file(&file_path).await;
}
Redirect::new(format!("/admin/media?lang={}", lang.code())).into_response()
let redirect_url = referer
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
.unwrap_or_else(|| format!("/admin/media?lang={}", lang.code()));
Redirect::new(redirect_url).into_response()
}
/// Serve uploaded files by media ID.
@@ -1759,7 +1835,7 @@ async fn testimonial_add(
image_path,
status: "active".to_string(),
sort_order: max_order + 1,
created_at: now(),
created_at: now_utc(),
};
testimonial.save(&db).await?;
}
@@ -1805,6 +1881,112 @@ async fn testimonial_delete(
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
async fn testimonial_edit(
request: Request,
session: Session,
db: Database,
Path(id): Path<i64>,
) -> cot::Result<Response> {
let lang = detect_lang(&request);
if let Err(resp) = require_auth(&session, lang).await {
return Ok(resp);
}
let boundary = extract_boundary(&request)
.ok_or_else(|| cot::Error::internal("missing multipart boundary"))?;
let bytes = request.into_body().into_bytes().await?;
let stream =
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
let mut multipart = multer::Multipart::new(stream, boundary);
let mut text = String::new();
let mut author_note = String::new();
let mut new_image_path: Option<String> = None;
let mut remove_image = false;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
{
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"text" => {
text = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"author_note" => {
author_note = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
"remove_image" => {
let val = field
.text()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
remove_image = val == "1";
}
"image" => {
let original_name = field.file_name().unwrap_or("").to_string();
if original_name.is_empty() {
continue;
}
let ext = original_name
.rsplit('.')
.next()
.unwrap_or("bin")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" | "png" | "webp" | "heic" | "heif" => {}
_ => continue,
}
let data = field
.bytes()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if data.is_empty() {
continue;
}
let upload_dir = "uploads/testimonials";
tokio::fs::create_dir_all(upload_dir)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let file_id = uuid::Uuid::new_v4();
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
tokio::fs::write(&path, &data)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
new_image_path = Some(path);
}
_ => {}
}
}
if let Some(mut t) = query!(Testimonial, $id == id).get(&db).await? {
if !text.trim().is_empty() {
t.text = text.trim().to_string();
}
t.author_note = if author_note.trim().is_empty() {
None
} else {
Some(author_note.trim().to_string())
};
if let Some(path) = new_image_path {
t.image_path = Some(path);
} else if remove_image {
t.image_path = None;
}
t.save(&db).await?;
}
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
}
/// Serve testimonial images (public, no auth needed for landing page).
async fn serve_testimonial_image(
_request: Request,
@@ -1929,6 +2111,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",
@@ -1962,6 +2149,11 @@ pub fn admin_router() -> Router {
testimonial_delete,
"admin-testimonial-delete",
),
Route::with_handler_and_name(
"/testimonials/{id}/edit",
testimonial_edit,
"admin-testimonial-edit",
),
Route::with_handler_and_name(
"/testimonials/{id}/image",
serve_testimonial_image,
+33 -9
View File
@@ -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,
@@ -130,6 +132,9 @@ pub struct Translations {
pub settings_telegram_chat_id: &'static str,
pub settings_contact_info: &'static str,
pub settings_pricing_info: &'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_pricing_title: &'static str,
@@ -196,6 +201,9 @@ pub struct Translations {
pub testimonials_add_button: &'static str,
pub testimonials_status_active: &'static str,
pub testimonials_status_hidden: &'static str,
pub testimonials_edit: &'static str,
pub testimonials_save: &'static str,
pub testimonials_remove_image: &'static str,
// Client edit
pub clients_edit_title: &'static str,
@@ -278,7 +286,7 @@ static RU: Translations = Translations {
nav_visits: "Визиты",
nav_users: "Админы",
nav_settings: "Настройки",
nav_title: "Пет-ситтинг",
nav_title: "МурНяня.РФ",
leads_title: "Заявки",
leads_empty: "Заявок пока нет.",
@@ -324,6 +332,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: "Параметр",
@@ -335,6 +345,9 @@ static RU: Translations = Translations {
settings_telegram_chat_id: "Chat ID для уведомлений",
settings_contact_info: "Контактная информация (отображается на лендинге)",
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
settings_site_domain: "Домен сайта (например https://example.com)",
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
@@ -355,7 +368,7 @@ static RU: Translations = Translations {
media_delete_confirm: "Удалить этот файл?",
media_all_clients: "Все клиенты",
portal_title: "Мои визиты",
portal_title: "Визиты",
portal_upcoming: "Предстоящие визиты",
portal_past: "Прошлые визиты",
portal_no_upcoming: "Нет предстоящих визитов.",
@@ -387,7 +400,7 @@ static RU: Translations = Translations {
schedule_new_title: "Запланировать визиты",
schedule_client: "Клиент",
schedule_admin: "Исполнитель",
schedule_default_time: "Время по умолчанию",
schedule_default_time: "Время",
schedule_time_start: "С",
schedule_time_end: "До",
schedule_pick_dates: "Добавить дату",
@@ -414,7 +427,7 @@ static RU: Translations = Translations {
schedule_delete_confirm: "Точно удалить этот визит?",
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома. Город Хабаровск",
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
landing_hero_cta: "Оставить заявку",
@@ -445,7 +458,7 @@ static RU: Translations = Translations {
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
landing_testimonials_title: "Отзывы",
landing_form_consent: "Я даю согласие на обработку персональных данных",
landing_footer_text: "Пет-ситтинг — забота о вашем питомце",
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
landing_footer_copyright: "Все права защищены",
nav_testimonials: "Отзывы",
@@ -458,6 +471,9 @@ static RU: Translations = Translations {
testimonials_add_button: "Добавить",
testimonials_status_active: "Отображается",
testimonials_status_hidden: "Скрыт",
testimonials_edit: "Редактировать",
testimonials_save: "Сохранить",
testimonials_remove_image: "Удалить фото",
no_value: "",
action_convert: "Конвертировать",
@@ -473,7 +489,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.",
@@ -519,6 +535,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",
@@ -530,6 +548,9 @@ static EN: Translations = Translations {
settings_telegram_chat_id: "Notification Chat ID",
settings_contact_info: "Contact info (shown on landing page)",
settings_pricing_info: "Pricing block (shown on landing page)",
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_pricing_title: "Pricing",
@@ -550,7 +571,7 @@ static EN: Translations = Translations {
media_delete_confirm: "Delete this file?",
media_all_clients: "All clients",
portal_title: "My Visits",
portal_title: "Visits",
portal_upcoming: "Upcoming visits",
portal_past: "Past visits",
portal_no_upcoming: "No upcoming visits.",
@@ -582,7 +603,7 @@ static EN: Translations = Translations {
schedule_new_title: "Plan Visits",
schedule_client: "Client",
schedule_admin: "Assigned to",
schedule_default_time: "Default Time",
schedule_default_time: "Time",
schedule_time_start: "From",
schedule_time_end: "To",
schedule_pick_dates: "Add date",
@@ -640,7 +661,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",
@@ -653,6 +674,9 @@ static EN: Translations = Translations {
testimonials_add_button: "Add",
testimonials_status_active: "Visible",
testimonials_status_hidden: "Hidden",
testimonials_edit: "Edit",
testimonials_save: "Save",
testimonials_remove_image: "Remove photo",
no_value: "",
action_convert: "Convert",
+8
View File
@@ -4,6 +4,9 @@ mod migrations;
pub mod models;
mod public;
mod telegram;
mod tz;
use tracing_subscriber;
use cot::cli::CliMetadata;
use cot::config::{
@@ -96,5 +99,10 @@ impl Project for PettingProject {
#[cot::main]
fn main() -> impl Project {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.try_init();
PettingProject
}
+2
View File
@@ -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,
];
+35
View File
@@ -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(),
];
}
+2
View File
@@ -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,
+155 -5
View File
@@ -6,6 +6,7 @@ use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router};
use serde::Deserialize;
use tracing::info;
use cot::db::query;
@@ -60,7 +61,7 @@ fn html_response(body: String, lang: Lang) -> cot::Result<Response> {
.into_response()
}
fn now() -> chrono::NaiveDateTime {
fn now_utc() -> chrono::NaiveDateTime {
chrono::Utc::now().naive_utc()
}
@@ -71,7 +72,10 @@ struct LandingTemplate<'a> {
lang: Lang,
contact_info: String,
pricing_info: String,
seo_keywords: String,
testimonials: Vec<Testimonial>,
site_domain: String,
review_count: usize,
}
#[derive(Debug, Template)]
@@ -83,6 +87,33 @@ struct ThankYouTemplate<'a> {
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
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 contact_info = query!(Setting, $key == key)
.get(&db)
@@ -95,15 +126,31 @@ 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 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?;
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,
seo_keywords,
testimonials,
site_domain,
review_count,
}
.render()?;
html_response(body, lang)
@@ -131,8 +178,8 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
comment: form.comment.filter(|s| !s.trim().is_empty()),
status: "new".to_string(),
client_id: None,
created_at: now(),
updated_at: now(),
created_at: now_utc(),
updated_at: now_utc(),
};
lead.save(&db).await?;
@@ -188,7 +235,8 @@ async fn client_portal(
};
let client_id = client.id.unwrap();
let today = chrono::Utc::now().date_naive();
let tz = crate::tz::load_tz(&db).await;
let today = crate::tz::today_in_tz(tz);
let mut visits = Visit::objects().all(&db).await?;
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
@@ -277,7 +325,7 @@ async fn submit_feedback(
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
if visit.client_id.primary_key().unwrap() == client_id {
visit.client_feedback = Some(form.feedback);
visit.updated_at = now();
visit.updated_at = now_utc();
visit.save(&db).await?;
}
}
@@ -366,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 {
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
View File
@@ -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> {
+30
View File
@@ -0,0 +1,30 @@
use chrono_tz::Tz;
use cot::db::{Database, query};
use crate::models::Setting;
const DEFAULT_TZ: &str = "UTC";
/// Load timezone from the database settings.
pub async fn load_tz(db: &Database) -> Tz {
let key = "timezone".to_string();
let tz_str = query!(Setting, $key == key)
.get(db)
.await
.ok()
.flatten()
.map(|s| s.value)
.unwrap_or_else(|| DEFAULT_TZ.to_string());
tz_str.parse::<Tz>().unwrap_or(Tz::UTC)
}
/// Current date+time in the configured timezone, returned as NaiveDateTime.
#[allow(dead_code)]
pub fn now_in_tz(tz: Tz) -> chrono::NaiveDateTime {
chrono::Utc::now().with_timezone(&tz).naive_local()
}
/// Today's date in the configured timezone.
pub fn today_in_tz(tz: Tz) -> chrono::NaiveDate {
chrono::Utc::now().with_timezone(&tz).date_naive()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

+24 -6
View File
@@ -47,19 +47,37 @@
{% endfor %}
{% endif %}
<!-- Recent feedbacks -->
<!-- Feedbacks -->
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
{% if recent_feedbacks.is_empty() %}
{% if feedbacks.is_empty() %}
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
{% else %}
{% for fb in &recent_feedbacks %}
<div class="item-card" style="border-left:3px solid #7c6cff;">
{% for fb in &feedbacks %}
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card" style="border-left:3px solid #7c6cff;display:block;text-decoration:none;color:inherit;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" style="color:#999;font-size:0.8rem;text-decoration:none;">{{ fb.visit_date }}</a>
<span style="color:#999;font-size:0.8rem;">{{ fb.visit_date }}</span>
</div>
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
</div>
</a>
{% endfor %}
{% if feedback_total_pages > 1 %}
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1rem;">
{% if feedback_page > 1 %}
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page - 1 }}" class="button is-small is-light">&laquo;</a>
{% endif %}
{% for p in 1..=feedback_total_pages %}
{% if p == feedback_page %}
<span class="button is-small is-primary">{{ p }}</span>
{% else %}
<a href="/admin/?lang={{ lang.code() }}&page={{ p }}" class="button is-small is-light">{{ p }}</a>
{% endif %}
{% endfor %}
{% if feedback_page < feedback_total_pages %}
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page + 1 }}" class="button is-small is-light">&raquo;</a>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endblock %}
+21 -11
View File
@@ -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>
-6
View File
@@ -33,12 +33,6 @@
</div>
{% if lead.status == "new" || lead.status == "in_progress" %}
<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">
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
</form>
+8 -1
View File
@@ -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>
+77 -3
View File
@@ -14,12 +14,13 @@
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</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">
<label class="label">{{ t.media_choose_files }}</label>
<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>
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
</div>
<div class="field">
@@ -29,7 +30,80 @@
</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>
<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>
{% endblock %}
+12 -14
View File
@@ -32,6 +32,10 @@
.fc .fc-toolbar-title { font-size: 1.1rem !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.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.day-weekend { background: #faf5f0; }
.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 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 .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; }
</style>
@@ -60,12 +62,8 @@
<div id="vmTime"></div>
<div id="vmNotes" style="margin-top:0.3rem;"></div>
</div>
<div id="vmStatus" style="margin-bottom:0.75rem;"></div>
<div class="actions" id="vmActions"></div>
<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 id="vmStatus" style="margin-bottom:1rem;"></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>
</div>
@@ -81,6 +79,7 @@ document.addEventListener('DOMContentLoaded', function() {
const calendar = new FullCalendar.Calendar(calEl, {
locale: lang,
timeZone: '{{ timezone }}',
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
@@ -88,6 +87,11 @@ document.addEventListener('DOMContentLoaded', function() {
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
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) {
info.jsEvent.preventDefault();
const ev = info.event;
@@ -101,12 +105,6 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('vmNotes').textContent = p.notes || '';
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
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('visitModal').classList.add('is-open');
},
+228 -50
View File
@@ -70,14 +70,17 @@
<!-- Status -->
<div class="field">
<label class="label">{{ t.schedule_status }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="status">
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
</select>
</div>
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
<div class="status-picker">
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
</button>
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
<span class="status-btn-icon"></span>{{ t.visit_status_completed }}
</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>
@@ -97,56 +100,125 @@
</div>
</div>
{% if let Some(fb) = visit.client_feedback.as_deref() %}
<div style="margin-bottom:1rem;">
<label class="label">{{ t.schedule_client_feedback }}</label>
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
</div>
{% endif %}
<hr style="margin:1rem 0;">
<!-- Media -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
<button type="button" class="button is-info is-small is-outlined" onclick="document.getElementById('uploadModal').classList.add('is-open')">+ {{ t.media_upload }}</button>
</div>
{% if media.is_empty() %}
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
{% else %}
<div class="visit-media-grid">
{% for m in &media %}
<div class="visit-media-item">
{% if m.file_type == "photo" %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
<div class="video-thumb-sm">🎬</div>
</a>
{% endif %}
{% if let Some(cap) = m.caption.as_deref() %}
<div class="media-cap">{{ cap }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<hr style="margin:1rem 0;">
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
</form>
{% if let Some(fb) = visit.client_feedback.as_deref() %}
<div style="margin-top:1rem;">
<label class="label">{{ t.schedule_client_feedback }}</label>
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
</div>
{% endif %}
<hr style="margin:1rem 0;">
<!-- Media -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
<a href="/admin/media/{{ visit.id }}/upload?lang={{ lang.code() }}" class="button is-info is-small is-outlined">📷 {{ t.media_upload }}</a>
</div>
{% if media.is_empty() %}
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
{% else %}
<div class="visit-media-grid">
{% for m in &media %}
<div class="visit-media-item">
{% if m.file_type == "photo" %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
<div class="video-thumb-sm">🎬</div>
</a>
{% endif %}
{% if let Some(cap) = m.caption.as_deref() %}
<div class="media-cap">{{ cap }}</div>
{% endif %}
<form method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="text-align:center;">
<button class="button is-danger is-outlined btn-sm" style="font-size:0.7rem;padding:0.15rem 0.4rem;">{{ t.media_delete }}</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<hr style="margin:1rem 0;">
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
</form>
</div>
<!-- Upload Modal -->
<div class="upload-modal-bg" id="uploadModal">
<div class="upload-modal">
<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>
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;"></button>
</div>
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
<div class="field">
<label class="label">{{ t.media_choose_files }}</label>
<div class="control">
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
</div>
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
</div>
<div class="field">
<label class="label">{{ t.media_caption }}</label>
<div class="control">
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
</div>
</div>
<!-- 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>
</div>
</div>
<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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
@@ -182,8 +254,114 @@
overflow: hidden;
text-overflow: ellipsis;
}
.visit-media-item form {
padding: 0.2rem;
.upload-modal-bg {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 100;
align-items: center;
justify-content: center;
}
.upload-modal-bg.is-open {
display: flex;
}
.upload-modal {
background: #fff;
border-radius: 12px;
padding: 1.5rem;
width: 90%;
max-width: 420px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
}
</style>
<script>
// Status picker
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>
(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 %}
+347 -164
View File
@@ -9,238 +9,421 @@
</div>
<div class="form-card">
<form method="post" action="/admin/schedule/create" id="visitForm">
<!-- Client -->
<div class="field">
<label class="label">{{ t.schedule_client }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="client_id" required>
<option value=""></option>
{% for c in &clients %}
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
{% endfor %}
</select>
</div>
<form method="post" action="/admin/schedule/create" id="visitForm">
<!-- Client -->
<div class="field">
<label class="label">{{ t.schedule_client }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="client_id" required>
<option value=""></option>
{% for c in &clients %}
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Admin -->
<div class="field">
<label class="label">{{ t.schedule_admin }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="user_id">
{% for u in &users %}
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
</option>
{% endfor %}
</select>
</div>
<!-- Admin -->
<div class="field">
<label class="label">{{ t.schedule_admin }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="user_id">
{% for u in &users %}
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Default time -->
<div class="field">
<label class="label">{{ t.schedule_default_time }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<div class="control">
<input class="input" type="time" id="defaultStart" value="18:00">
</div>
</div>
<div class="column">
<div class="control">
<input class="input" type="time" id="defaultEnd" value="19:00">
</div>
</div>
<!-- Default time -->
<div class="field">
<label class="label">{{ t.schedule_default_time }}</label>
<div class="time-row">
<div class="time-block">
<span class="time-lbl">{{ t.schedule_time_start }}</span>
<input class="input" type="time" id="defaultStart" value="18:00">
</div>
<div class="time-sep"></div>
<div class="time-block">
<span class="time-lbl">{{ t.schedule_time_end }}</span>
<input class="input" type="time" id="defaultEnd" value="19:00">
</div>
</div>
</div>
<!-- Add individual date -->
<div class="field">
<label class="label">{{ t.schedule_pick_dates }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<div class="control">
<input class="input" type="date" id="pickDate">
</div>
</div>
<div class="column is-narrow">
<button type="button" class="button is-info" id="addDateBtn">+</button>
</div>
</div>
<!-- Calendar -->
<div class="field">
<div class="cal-toolbar">
<button type="button" id="rangeModeBtn" class="button is-small is-outlined is-info">⇔ Выбрать диапазон</button>
<button type="button" id="resetBtn" class="button is-small is-outlined is-danger" style="display:none;">✕ Сбросить</button>
</div>
<!-- Date range fill -->
<div class="field">
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<input class="input" type="date" id="rangeFrom">
</div>
<div class="column">
<input class="input" type="date" id="rangeTo">
</div>
<div class="column is-narrow">
<button type="button" class="button is-info is-outlined" id="fillRangeBtn">{{ t.schedule_fill_range }}</button>
</div>
<div class="sched-cal">
<div class="cal-nav">
<button type="button" id="calPrev"></button>
<span id="calTitle"></span>
<button type="button" id="calNext"></button>
</div>
<div class="cal-grid" id="calGrid"></div>
</div>
</div>
<!-- Selected days list -->
<div class="field">
<label class="label">{{ t.schedule_selected_days }}</label>
<div id="daysList">
<p class="has-text-grey is-size-7" id="noDaysMsg">{{ t.schedule_no_days }}</p>
</div>
<!-- Selected days -->
<div class="field" id="selectedSection" style="display:none;">
<label class="label">{{ t.schedule_selected_days }} <span id="selectedCount" class="tag is-info is-light" style="margin-left:0.4rem;"></span></label>
<div id="daysList"></div>
</div>
<!-- Notes -->
<div class="field">
<label class="label">{{ t.schedule_notes }}</label>
<div class="control">
<textarea class="textarea" name="notes" rows="2"></textarea>
</div>
</div>
<!-- Notes -->
<div class="field">
<label class="label">{{ t.schedule_notes }}</label>
<div class="control">
<textarea class="textarea" name="notes" rows="2"></textarea>
</div>
</div>
<input type="hidden" name="days_json" id="daysJson" value="[]">
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
<!-- Hidden days data -->
<input type="hidden" name="days_json" id="daysJson" value="[]">
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
</form>
</form>
</div>
<style>
.day-row {
display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0;
border-bottom: 1px solid #f0f0f0; flex-wrap: wrap;
}
.day-row .day-date { font-weight: 600; min-width: 6rem; font-size: 0.9rem; }
.day-row input[type="time"] { width: 7rem; padding: 0.2rem 0.4rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85rem; }
.day-row .remove-btn { color: #e55; cursor: pointer; font-size: 0.8rem; margin-left: auto; background: none; border: none; }
/* Time row */
.time-row { display:flex; align-items:center; gap:0.5rem; }
.time-block { display:flex; flex-direction:column; flex:1; }
.time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
.time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
/* Calendar toolbar */
.cal-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem; }
/* Calendar container */
.sched-cal { background:#fafafa; border:1px solid #eee; border-radius:10px; overflow:hidden; }
.cal-nav { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 0.75rem; background:#fff; border-bottom:1px solid #eee; }
.cal-nav button { background:none; border:none; font-size:1.1rem; cursor:pointer; color:#6c63ff; padding:0.2rem 0.5rem; border-radius:4px; }
.cal-nav button:hover { background:#f0eeff; }
.cal-nav span { font-weight:700; font-size:1rem; color:#333; }
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
.cal-wday { text-align:center; font-size:0.72rem; font-weight:700; color:#aaa; padding:0.4rem 0; background:#fafafa; }
.cal-wday.is-weekend { color:#f0a0a0; }
.cal-day { text-align:center; padding:0.55rem 0.2rem; font-size:0.9rem; cursor:pointer; color:#333; border-radius:0; transition:background 0.1s; position:relative; user-select:none; -webkit-user-select:none; }
.cal-day:hover { background:#f0eeff; }
.cal-day.is-empty { cursor:default; }
.cal-day.is-empty:hover { background:none; }
.cal-day.is-today { font-weight:700; color:#6c63ff; }
.cal-day.is-selected { background:#6c63ff !important; color:#fff !important; border-radius:0; }
.cal-day.is-range-start { background:#a89cff !important; color:#fff !important; }
.cal-day.is-past { color:#ccc; }
/* Selected days list */
.day-row { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 0; border-bottom:1px solid #f5f5f5; }
.day-row:last-child { border-bottom:none; }
.day-date { font-weight:600; font-size:0.85rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.day-times { display:flex; align-items:center; gap:0.25rem; flex-shrink:0; }
.day-times .time-sep { color:#bbb; font-size:0.8rem; }
.day-rm { background:none; border:none; color:#ccc; cursor:pointer; font-size:1rem; padding:0.15rem 0.25rem; flex-shrink:0; line-height:1; }
.day-rm:hover { color:#e55; }
/* Time badge */
.time-badge-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: #ede9ff;
color: #5b52d6;
border: 1.5px solid #c4beff;
border-radius: 20px;
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
min-width: 3.6rem;
white-space: nowrap;
}
.time-badge-wrap:focus-within {
border-color: #6c63ff;
background: #f0eeff;
}
.time-badge-label { pointer-events: none; z-index: 1; }
.time-badge-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: none;
background: transparent;
padding: 0;
margin: 0;
font-size: 16px; /* prevent iOS zoom */
}
</style>
<script>
const days = new Map(); // date string -> {start, end}
const removeLabel = '{{ t.schedule_remove_day }}';
const weekdays = '{{ lang.code() }}' === 'ru'
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб']
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
(function() {
const IS_RU = '{{ lang.code() }}' === 'ru';
const TZ = '{{ timezone }}';
const WDAYS = IS_RU ? ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] : ['Mo','Tu','We','Th','Fr','Sa','Su'];
const MONTHS = IS_RU
? ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
: ['January','February','March','April','May','June','July','August','September','October','November','December'];
const days = new Map(); // dateStr -> {start, end}
let viewYear, viewMonth;
let rangeMode = false;
let rangeStart = null; // dateStr of first tap in range mode
const now = new Date();
const todayStr_ = tzDateStr(now);
viewYear = parseInt(todayStr_.slice(0, 4));
viewMonth = parseInt(todayStr_.slice(5, 7)) - 1; // 0-based
function isoDate(y, m, d) {
return y + '-' + String(m+1).padStart(2,'0') + '-' + String(d).padStart(2,'0');
}
function getDefaults() {
return {
start: document.getElementById('defaultStart').value || '18:00',
end: document.getElementById('defaultEnd').value || '19:00'
end: document.getElementById('defaultEnd').value || '19:00'
};
}
function addDay(dateStr) {
if (!dateStr || days.has(dateStr)) return;
function addDay(ds) {
if (!ds || days.has(ds)) return;
const def = getDefaults();
days.set(dateStr, { start: def.start, end: def.end });
renderDays();
days.set(ds, { start: def.start, end: def.end });
}
function removeDay(dateStr) {
days.delete(dateStr);
renderDays();
function toggleDay(ds) {
if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
}
function renderDays() {
const list = document.getElementById('daysList');
const msg = document.getElementById('noDaysMsg');
const btn = document.getElementById('submitBtn');
// Получить текущую дату в нужном TZ как строку YYYY-MM-DD
function tzDateStr(d) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(d);
const p = {};
parts.forEach(function(x) { p[x.type] = x.value; });
return p.year + '-' + p.month + '-' + p.day;
}
// Remove old day rows
list.querySelectorAll('.day-row').forEach(el => el.remove());
function fillRange(from, to) {
if (from > to) { let t = from; from = to; to = t; }
// Используем полдень чтобы избежать проблем с переходом суток при смене DST
let cur = new Date(from + 'T12:00:00');
const end = new Date(to + 'T12:00:00');
while (cur <= end) {
addDay(tzDateStr(cur));
cur.setDate(cur.getDate() + 1);
}
}
// ── Render calendar ──────────────────────────────────────
function renderCal() {
document.getElementById('calTitle').textContent = MONTHS[viewMonth] + ' ' + viewYear;
const grid = document.getElementById('calGrid');
grid.innerHTML = '';
// Weekday headers
WDAYS.forEach(function(wd, i) {
const cell = document.createElement('div');
cell.className = 'cal-wday' + (i >= 5 ? ' is-weekend' : '');
cell.textContent = wd;
grid.appendChild(cell);
});
// First day of month (Mon=0 for our grid)
const first = new Date(viewYear, viewMonth, 1);
let startDow = first.getDay(); // 0=Sun
startDow = (startDow === 0) ? 6 : startDow - 1; // shift to Mon=0
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const todayStr = todayStr_;
// Empty cells before first day
for (let i = 0; i < startDow; i++) {
const empty = document.createElement('div');
empty.className = 'cal-day is-empty';
grid.appendChild(empty);
}
for (let d = 1; d <= daysInMonth; d++) {
const ds = isoDate(viewYear, viewMonth, d);
const cell = document.createElement('div');
let cls = 'cal-day';
if (ds === todayStr) cls += ' is-today';
if (days.has(ds)) cls += ' is-selected';
if (ds === rangeStart) cls += ' is-range-start';
cell.className = cls;
cell.textContent = d;
cell.dataset.date = ds;
cell.addEventListener('click', onDayClick);
grid.appendChild(cell);
}
}
function onDayClick(e) {
const ds = e.currentTarget.dataset.date;
if (!ds) return;
if (rangeMode) {
if (days.has(ds)) {
days.delete(ds);
if (rangeStart === ds) rangeStart = null;
} else if (!rangeStart) {
rangeStart = ds;
} else {
fillRange(rangeStart, ds);
rangeStart = null;
}
} else {
toggleDay(ds);
}
renderCal();
renderList();
}
// ── Render selected days list ────────────────────────────
function renderList() {
const list = document.getElementById('daysList');
const section = document.getElementById('selectedSection');
const count = document.getElementById('selectedCount');
const btn = document.getElementById('submitBtn');
list.innerHTML = '';
const resetBtn = document.getElementById('resetBtn');
if (days.size === 0) {
msg.style.display = '';
section.style.display = 'none';
btn.disabled = true;
document.getElementById('daysJson').value = '[]';
resetBtn.style.display = 'none';
return;
}
msg.style.display = 'none';
resetBtn.style.display = '';
section.style.display = '';
btn.disabled = false;
count.textContent = days.size;
// Sort by date
const sorted = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
const sorted = [...days.entries()].sort((a,b) => a[0].localeCompare(b[0]));
sorted.forEach(([dateStr, times]) => {
const d = new Date(dateStr + 'T00:00:00');
const wd = weekdays[d.getDay()];
const label = dateStr.split('-').reverse().join('.') + ' ' + wd;
sorted.forEach(function([ds, times]) {
const d = new Date(ds + 'T00:00:00');
const dow = IS_RU
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][d.getDay()]
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
const parts = ds.split('-');
const label = parts[2] + '.' + parts[1] + ' ' + dow; // DD.MM Вт
const row = document.createElement('div');
row.className = 'day-row';
row.innerHTML = `
<span class="day-date">${label}</span>
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start">
<span></span>
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end">
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button>
`;
row.innerHTML =
'<span class="day-date">' + label + '</span>' +
'<div class="day-times">' +
'<div class="time-badge-wrap">' +
'<span class="time-badge-label">' + times.start + '</span>' +
'<input type="time" class="time-badge-input" value="' + times.start + '" data-date="' + ds + '" data-field="start">' +
'</div>' +
'<span class="time-sep"></span>' +
'<div class="time-badge-wrap">' +
'<span class="time-badge-label">' + times.end + '</span>' +
'<input type="time" class="time-badge-input" value="' + times.end + '" data-date="' + ds + '" data-field="end">' +
'</div>' +
'</div>' +
'<button type="button" class="day-rm" data-date="' + ds + '" title="Убрать"></button>';
list.appendChild(row);
});
// Update hidden JSON
updateJson();
// Bind events
list.querySelectorAll('input[type="time"]').forEach(inp => {
list.querySelectorAll('.time-badge-input').forEach(function(inp) {
inp.addEventListener('change', function() {
const dt = this.dataset.date;
const field = this.dataset.field;
if (days.has(dt)) {
days.get(dt)[field] = this.value;
const d = days.get(this.dataset.date);
if (d) {
d[this.dataset.field] = this.value;
this.previousElementSibling.textContent = this.value;
updateJson();
}
});
});
list.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', function() {
removeDay(this.dataset.date);
list.querySelectorAll('.day-rm').forEach(function(b) {
b.addEventListener('click', function() {
days.delete(this.dataset.date);
renderCal();
renderList();
});
});
updateJson();
}
function updateJson() {
const arr = [...days.entries()].map(([date, t]) => ({
date: date,
time_start: t.start,
time_end: t.end
}));
const arr = [...days.entries()].map(function([date, t]) {
return { date: date, time_start: t.start, time_end: t.end };
});
document.getElementById('daysJson').value = JSON.stringify(arr);
}
document.getElementById('addDateBtn').addEventListener('click', function() {
const v = document.getElementById('pickDate').value;
addDay(v);
document.getElementById('pickDate').value = '';
// ── Navigation ───────────────────────────────────────────
document.getElementById('calPrev').addEventListener('click', function() {
viewMonth--;
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
renderCal();
});
document.getElementById('calNext').addEventListener('click', function() {
viewMonth++;
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
renderCal();
});
document.getElementById('pickDate').addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); }
});
document.getElementById('fillRangeBtn').addEventListener('click', function() {
const from = document.getElementById('rangeFrom').value;
const to = document.getElementById('rangeTo').value;
if (!from || !to || from > to) return;
let cur = new Date(from + 'T00:00:00');
const end = new Date(to + 'T00:00:00');
while (cur <= end) {
const ds = cur.toISOString().slice(0, 10);
addDay(ds);
cur.setDate(cur.getDate() + 1);
// ── Range mode toggle ────────────────────────────────────
document.getElementById('rangeModeBtn').addEventListener('click', function() {
rangeMode = !rangeMode;
rangeStart = null;
if (rangeMode) {
this.classList.remove('is-outlined', 'is-info');
this.classList.add('is-warning');
this.textContent = '✕ Выбрать отдельные дни';
} else {
this.classList.remove('is-warning');
this.classList.add('is-outlined', 'is-info');
this.textContent = '⇔ Выбрать диапазон';
}
document.getElementById('rangeFrom').value = '';
document.getElementById('rangeTo').value = '';
renderCal();
});
// Set default pick date to today
document.getElementById('pickDate').valueAsDate = new Date();
document.getElementById('resetBtn').addEventListener('click', function() {
days.clear();
rangeStart = null;
renderCal();
renderList();
});
// ── Default time change → update existing days ───────────
// (only updates days that still have the old default)
// kept simple: doesn't retroactively update already-added days
renderCal();
renderList();
})();
</script>
{% endblock %}
+69 -6
View File
@@ -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,7 +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>
</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">
<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 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>
</form>
</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 %}
+8 -1
View File
@@ -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>
+78 -22
View File
@@ -39,36 +39,92 @@
<p style="color:#888;">{{ t.testimonials_empty }}</p>
{% else %}
{% for item in &testimonials %}
<div class="item-card">
<div class="item-card-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
{% if item.image_path.is_some() %}
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
{% endif %}
<div>
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
{% if let Some(note) = item.author_note.as_deref() %}
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
<div class="item-card" id="card-{{ item.id.unwrap() }}">
<!-- View mode -->
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
<div class="item-card-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
{% if item.image_path.is_some() %}
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
{% endif %}
<div>
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
{% if let Some(note) = item.author_note.as_deref() %}
<div style="font-size:0.8rem;color:#888;margin-top:0.2rem;">{{ note }}</div>
{% endif %}
</div>
</div>
<span class="badge {% if item.status == "active" %}badge-active{% else %}badge-archived{% endif %}">
{% if item.status == "active" %}{{ t.testimonials_status_active }}{% else %}{{ t.testimonials_status_hidden }}{% endif %}
</span>
</div>
<div class="item-card-actions">
<button type="button" class="button btn-sm is-info is-light" onclick="toggleEdit({{ item.id.unwrap() }})">{{ t.testimonials_edit }}</button>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/toggle">
{% if item.status == "active" %}
<button type="submit" class="button btn-sm is-warning is-light">{{ t.action_archive }}</button>
{% else %}
<button type="submit" class="button btn-sm is-success is-light">{{ t.action_activate }}</button>
{% endif %}
</form>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/delete" onsubmit="return confirm('Delete?')">
<button type="submit" class="button btn-sm is-danger is-light">{{ t.media_delete }}</button>
</form>
</div>
<span class="badge {% if item.status == "active" %}badge-active{% else %}badge-archived{% endif %}">
{% if item.status == "active" %}{{ t.testimonials_status_active }}{% else %}{{ t.testimonials_status_hidden }}{% endif %}
</span>
</div>
<div class="item-card-actions">
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/toggle">
{% if item.status == "active" %}
<button type="submit" class="button btn-sm is-warning is-light">{{ t.action_archive }}</button>
{% else %}
<button type="submit" class="button btn-sm is-success is-light">{{ t.action_activate }}</button>
<!-- Edit mode (hidden by default) -->
<div class="tm-edit" id="edit-{{ item.id.unwrap() }}" style="display:none;">
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/edit" enctype="multipart/form-data">
<div class="field">
<label class="label">{{ t.testimonials_text }}</label>
<div class="control">
<textarea class="input" name="text" rows="3" style="min-height:70px;resize:vertical;">{{ item.text }}</textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.testimonials_author_note }}</label>
<div class="control">
<input class="input" type="text" name="author_note" value="{{ item.author_note.as_deref().unwrap_or("") }}">
</div>
</div>
{% if item.image_path.is_some() %}
<div class="field">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
</label>
</div>
</div>
{% endif %}
</form>
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/delete" onsubmit="return confirm('Delete?')">
<button type="submit" class="button btn-sm is-danger is-light">{{ t.media_delete }}</button>
<div class="field">
<label class="label">{{ t.testimonials_image }}</label>
<div class="control">
<input class="input" type="file" name="image" accept="image/*">
</div>
</div>
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="button btn-sm is-primary">{{ t.testimonials_save }}</button>
<button type="button" class="button btn-sm is-light" onclick="toggleEdit({{ item.id.unwrap() }})"></button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
<script>
function toggleEdit(id) {
var view = document.getElementById('view-' + id);
var edit = document.getElementById('edit-' + id);
if (edit.style.display === 'none') {
view.style.display = 'none';
edit.style.display = 'block';
} else {
view.style.display = 'block';
edit.style.display = 'none';
}
}
</script>
{% endblock %}
+23
View File
@@ -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">
+3
View File
@@ -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 {
+69 -8
View File
@@ -5,11 +5,30 @@
<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 }}">
{% 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 -->
<script type="application/ld+json">
@@ -19,7 +38,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 +123,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 +164,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 +194,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 +225,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 +259,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;
@@ -255,10 +305,14 @@
border-top: 1px solid rgba(180,170,220,0.15);
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 ── */
@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 +340,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 -->
@@ -419,6 +477,9 @@
<!-- 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 style="margin-top:0.4rem;">&copy; 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
</footer>
+2
View File
@@ -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;