Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21331b75a8 | |||
| 6395e36c62 | |||
| 87fb8c744d | |||
| 357a2ed423 | |||
| 434ed7a376 | |||
| b685075129 |
@@ -2,9 +2,9 @@ name: Build and Publish
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
#branches:
|
||||||
- master
|
# - master
|
||||||
- main
|
# - main
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
/data
|
/data
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
/uploads
|
/uploads
|
||||||
|
.DS_Store
|
||||||
|
|||||||
Generated
+2
-1
@@ -3255,9 +3255,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
"cot",
|
"cot",
|
||||||
"futures",
|
"futures",
|
||||||
"multer",
|
"multer",
|
||||||
|
|||||||
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cot = { version = "0.6.0", features = ["sqlite"] }
|
cot = { version = "0.6.0", features = ["sqlite"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
chrono-tz = "0.10"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_html_form = "0.4"
|
serde_html_form = "0.4"
|
||||||
password-auth = "1"
|
password-auth = "1"
|
||||||
|
|||||||
+192
-41
@@ -109,7 +109,7 @@ fn rand_client_color() -> &'static str {
|
|||||||
CLIENT_COLORS[(h.finish() as usize) % CLIENT_COLORS.len()]
|
CLIENT_COLORS[(h.finish() as usize) % CLIENT_COLORS.len()]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> chrono::NaiveDateTime {
|
fn now_utc() -> chrono::NaiveDateTime {
|
||||||
chrono::Utc::now().naive_utc()
|
chrono::Utc::now().naive_utc()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,9 @@ struct DashboardTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
today_visits: Vec<TodayVisit>,
|
today_visits: Vec<TodayVisit>,
|
||||||
recent_feedbacks: Vec<RecentFeedback>,
|
feedbacks: Vec<RecentFeedback>,
|
||||||
|
feedback_page: usize,
|
||||||
|
feedback_total_pages: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -260,6 +262,7 @@ struct ScheduleTemplate<'a> {
|
|||||||
t: &'a Translations,
|
t: &'a Translations,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
|
timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -399,8 +402,8 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot::
|
|||||||
password_hash: password_auth::generate_hash(&form.password),
|
password_hash: password_auth::generate_hash(&form.password),
|
||||||
display_name: display,
|
display_name: display,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
user.save(&db).await?;
|
user.save(&db).await?;
|
||||||
|
|
||||||
@@ -468,7 +471,8 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
let user_id = get_admin_id(&session).await.unwrap_or(0);
|
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 all_visits = Visit::objects().all(&db).await?;
|
||||||
let clients = Client::objects().all(&db).await?;
|
let clients = Client::objects().all(&db).await?;
|
||||||
@@ -492,13 +496,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
.collect();
|
.collect();
|
||||||
today_visits.sort_by(|a, b| a.visit.time_start.cmp(&b.visit.time_start));
|
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 all_feedbacks: Vec<RecentFeedback> = all_visits
|
||||||
let mut recent_feedbacks: Vec<RecentFeedback> = all_visits
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|v| {
|
.filter(|v| {
|
||||||
v.user_id.primary_key().unwrap() == user_id
|
v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()
|
||||||
&& v.client_feedback.is_some()
|
|
||||||
&& v.updated_at.date() >= week_ago
|
|
||||||
})
|
})
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
let cid: i64 = v.client_id.primary_key().unwrap();
|
let cid: i64 = v.client_id.primary_key().unwrap();
|
||||||
@@ -515,14 +516,34 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
let body = DashboardTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
today_visits,
|
today_visits,
|
||||||
recent_feedbacks,
|
feedbacks,
|
||||||
|
feedback_page,
|
||||||
|
feedback_total_pages,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -664,7 +685,7 @@ async fn client_edit_submit(
|
|||||||
if let Some(color) = form.color.filter(|s| !s.trim().is_empty()) {
|
if let Some(color) = form.color.filter(|s| !s.trim().is_empty()) {
|
||||||
client.color = Some(color);
|
client.color = Some(color);
|
||||||
}
|
}
|
||||||
client.updated_at = now();
|
client.updated_at = now_utc();
|
||||||
client.save(&db).await?;
|
client.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||||
@@ -740,8 +761,8 @@ async fn add_user(request: Request, session: Session, db: Database) -> cot::Resu
|
|||||||
password_hash: password_auth::generate_hash(&form.password),
|
password_hash: password_auth::generate_hash(&form.password),
|
||||||
display_name: display,
|
display_name: display,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
user.save(&db).await?;
|
user.save(&db).await?;
|
||||||
None
|
None
|
||||||
@@ -770,6 +791,7 @@ struct SettingsForm {
|
|||||||
telegram_chat_id: String,
|
telegram_chat_id: String,
|
||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
|
timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
@@ -784,13 +806,14 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
("telegram_chat_id", form.telegram_chat_id),
|
("telegram_chat_id", form.telegram_chat_id),
|
||||||
("contact_info", form.contact_info),
|
("contact_info", form.contact_info),
|
||||||
("pricing_info", form.pricing_info),
|
("pricing_info", form.pricing_info),
|
||||||
|
("timezone", form.timezone),
|
||||||
] {
|
] {
|
||||||
let k = key.to_string();
|
let k = key.to_string();
|
||||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||||
match existing {
|
match existing {
|
||||||
Some(mut s) => {
|
Some(mut s) => {
|
||||||
s.value = value;
|
s.value = value;
|
||||||
s.updated_at = now();
|
s.updated_at = now_utc();
|
||||||
s.save(&db).await?;
|
s.save(&db).await?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -798,7 +821,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
id: Auto::auto(),
|
id: Auto::auto(),
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
value,
|
value,
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
s.save(&db).await?;
|
s.save(&db).await?;
|
||||||
}
|
}
|
||||||
@@ -835,7 +858,7 @@ async fn lead_set_status(
|
|||||||
|
|
||||||
if let Some(mut lead) = query!(Lead, $id == lead_id).get(&db).await? {
|
if let Some(mut lead) = query!(Lead, $id == lead_id).get(&db).await? {
|
||||||
lead.status = form.status;
|
lead.status = form.status;
|
||||||
lead.updated_at = now();
|
lead.updated_at = now_utc();
|
||||||
lead.save(&db).await?;
|
lead.save(&db).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,14 +887,14 @@ async fn lead_convert(
|
|||||||
media_token: rand_token(),
|
media_token: rand_token(),
|
||||||
color: Some(rand_client_color().to_string()),
|
color: Some(rand_client_color().to_string()),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
client.save(&db).await?;
|
client.save(&db).await?;
|
||||||
|
|
||||||
lead.status = "converted".to_string();
|
lead.status = "converted".to_string();
|
||||||
lead.client_id = Some(ForeignKey::from(&client));
|
lead.client_id = Some(ForeignKey::from(&client));
|
||||||
lead.updated_at = now();
|
lead.updated_at = now_utc();
|
||||||
lead.save(&db).await?;
|
lead.save(&db).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,7 +913,7 @@ async fn client_archive(
|
|||||||
}
|
}
|
||||||
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
|
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
|
||||||
client.status = "archived".to_string();
|
client.status = "archived".to_string();
|
||||||
client.updated_at = now();
|
client.updated_at = now_utc();
|
||||||
client.save(&db).await?;
|
client.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||||
@@ -908,7 +931,7 @@ async fn client_activate(
|
|||||||
}
|
}
|
||||||
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
|
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
|
||||||
client.status = "active".to_string();
|
client.status = "active".to_string();
|
||||||
client.updated_at = now();
|
client.updated_at = now_utc();
|
||||||
client.save(&db).await?;
|
client.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||||
@@ -926,7 +949,7 @@ async fn user_archive(
|
|||||||
}
|
}
|
||||||
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
|
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
|
||||||
user.status = "archived".to_string();
|
user.status = "archived".to_string();
|
||||||
user.updated_at = now();
|
user.updated_at = now_utc();
|
||||||
user.save(&db).await?;
|
user.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
||||||
@@ -944,7 +967,7 @@ async fn user_activate(
|
|||||||
}
|
}
|
||||||
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
|
if let Some(mut user) = query!(User, $id == user_id).get(&db).await? {
|
||||||
user.status = "active".to_string();
|
user.status = "active".to_string();
|
||||||
user.updated_at = now();
|
user.updated_at = now_utc();
|
||||||
user.save(&db).await?;
|
user.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/users?lang={}", lang.code())).into_response()
|
||||||
@@ -972,8 +995,8 @@ async fn add_lead(request: Request, session: Session, db: Database) -> cot::Resu
|
|||||||
comment: form.comment.filter(|s| !s.trim().is_empty()),
|
comment: form.comment.filter(|s| !s.trim().is_empty()),
|
||||||
status: "new".to_string(),
|
status: "new".to_string(),
|
||||||
client_id: None,
|
client_id: None,
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
lead.save(&db).await?;
|
lead.save(&db).await?;
|
||||||
|
|
||||||
@@ -1014,8 +1037,8 @@ async fn add_client(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
media_token: rand_token(),
|
media_token: rand_token(),
|
||||||
color: Some(rand_client_color().to_string()),
|
color: Some(rand_client_color().to_string()),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
client.save(&db).await?;
|
client.save(&db).await?;
|
||||||
|
|
||||||
@@ -1026,16 +1049,18 @@ async fn add_client(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
// Schedule Handlers
|
// 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 lang = detect_lang(&request);
|
||||||
let admin_name = match require_auth(&session, lang).await {
|
let admin_name = match require_auth(&session, lang).await {
|
||||||
Ok(name) => name,
|
Ok(name) => name,
|
||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
|
let tz = crate::tz::load_tz(&db).await;
|
||||||
let body = ScheduleTemplate {
|
let body = ScheduleTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
|
timezone: tz.to_string(),
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -1089,10 +1114,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")
|
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")
|
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 visits = Visit::objects().all(&db).await?;
|
||||||
let clients = Client::objects().all(&db).await?;
|
let clients = Client::objects().all(&db).await?;
|
||||||
@@ -1203,8 +1230,8 @@ async fn schedule_create(
|
|||||||
public_notes: None,
|
public_notes: None,
|
||||||
client_feedback: None,
|
client_feedback: None,
|
||||||
status: "scheduled".to_string(),
|
status: "scheduled".to_string(),
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
visit.save(&db).await?;
|
visit.save(&db).await?;
|
||||||
}
|
}
|
||||||
@@ -1286,7 +1313,7 @@ async fn schedule_edit_submit(
|
|||||||
visit.status = form.status;
|
visit.status = form.status;
|
||||||
visit.notes = form.notes.filter(|s| !s.trim().is_empty());
|
visit.notes = form.notes.filter(|s| !s.trim().is_empty());
|
||||||
visit.public_notes = form.public_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?;
|
visit.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
||||||
@@ -1318,7 +1345,7 @@ async fn visit_set_done(
|
|||||||
}
|
}
|
||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
visit.status = "completed".to_string();
|
visit.status = "completed".to_string();
|
||||||
visit.updated_at = now();
|
visit.updated_at = now_utc();
|
||||||
visit.save(&db).await?;
|
visit.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
||||||
@@ -1336,7 +1363,7 @@ async fn visit_set_cancel(
|
|||||||
}
|
}
|
||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
visit.status = "cancelled".to_string();
|
visit.status = "cancelled".to_string();
|
||||||
visit.updated_at = now();
|
visit.updated_at = now_utc();
|
||||||
visit.save(&db).await?;
|
visit.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
||||||
@@ -1556,12 +1583,17 @@ async fn media_upload_submit(
|
|||||||
file_type: ftype,
|
file_type: ftype,
|
||||||
caption: caption_opt.clone(),
|
caption: caption_opt.clone(),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
};
|
};
|
||||||
media.save(&db).await?;
|
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(
|
async fn media_delete(
|
||||||
@@ -1574,11 +1606,19 @@ async fn media_delete(
|
|||||||
if let Err(resp) = require_auth(&session, lang).await {
|
if let Err(resp) = require_auth(&session, lang).await {
|
||||||
return Ok(resp);
|
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? {
|
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
||||||
m.status = "archived".to_string();
|
m.status = "archived".to_string();
|
||||||
m.save(&db).await?;
|
m.save(&db).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.
|
/// Serve uploaded files by media ID.
|
||||||
@@ -1759,7 +1799,7 @@ async fn testimonial_add(
|
|||||||
image_path,
|
image_path,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
sort_order: max_order + 1,
|
sort_order: max_order + 1,
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
};
|
};
|
||||||
testimonial.save(&db).await?;
|
testimonial.save(&db).await?;
|
||||||
}
|
}
|
||||||
@@ -1805,6 +1845,112 @@ async fn testimonial_delete(
|
|||||||
Redirect::new(format!("/admin/testimonials?lang={}", lang.code())).into_response()
|
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).
|
/// Serve testimonial images (public, no auth needed for landing page).
|
||||||
async fn serve_testimonial_image(
|
async fn serve_testimonial_image(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@@ -1962,6 +2108,11 @@ pub fn admin_router() -> Router {
|
|||||||
testimonial_delete,
|
testimonial_delete,
|
||||||
"admin-testimonial-delete",
|
"admin-testimonial-delete",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/testimonials/{id}/edit",
|
||||||
|
testimonial_edit,
|
||||||
|
"admin-testimonial-edit",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/testimonials/{id}/image",
|
"/testimonials/{id}/image",
|
||||||
serve_testimonial_image,
|
serve_testimonial_image,
|
||||||
|
|||||||
+14
-2
@@ -130,6 +130,7 @@ pub struct Translations {
|
|||||||
pub settings_telegram_chat_id: &'static str,
|
pub settings_telegram_chat_id: &'static str,
|
||||||
pub settings_contact_info: &'static str,
|
pub settings_contact_info: &'static str,
|
||||||
pub settings_pricing_info: &'static str,
|
pub settings_pricing_info: &'static str,
|
||||||
|
pub settings_timezone: &'static str,
|
||||||
pub landing_contact_label: &'static str,
|
pub landing_contact_label: &'static str,
|
||||||
pub landing_pricing_title: &'static str,
|
pub landing_pricing_title: &'static str,
|
||||||
|
|
||||||
@@ -196,6 +197,9 @@ pub struct Translations {
|
|||||||
pub testimonials_add_button: &'static str,
|
pub testimonials_add_button: &'static str,
|
||||||
pub testimonials_status_active: &'static str,
|
pub testimonials_status_active: &'static str,
|
||||||
pub testimonials_status_hidden: &'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
|
// Client edit
|
||||||
pub clients_edit_title: &'static str,
|
pub clients_edit_title: &'static str,
|
||||||
@@ -335,6 +339,7 @@ static RU: Translations = Translations {
|
|||||||
settings_telegram_chat_id: "Chat ID для уведомлений",
|
settings_telegram_chat_id: "Chat ID для уведомлений",
|
||||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||||
|
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||||
landing_pricing_title: "Стоимость",
|
landing_pricing_title: "Стоимость",
|
||||||
|
|
||||||
@@ -355,7 +360,7 @@ static RU: Translations = Translations {
|
|||||||
media_delete_confirm: "Удалить этот файл?",
|
media_delete_confirm: "Удалить этот файл?",
|
||||||
media_all_clients: "Все клиенты",
|
media_all_clients: "Все клиенты",
|
||||||
|
|
||||||
portal_title: "Мои визиты",
|
portal_title: "Визиты",
|
||||||
portal_upcoming: "Предстоящие визиты",
|
portal_upcoming: "Предстоящие визиты",
|
||||||
portal_past: "Прошлые визиты",
|
portal_past: "Прошлые визиты",
|
||||||
portal_no_upcoming: "Нет предстоящих визитов.",
|
portal_no_upcoming: "Нет предстоящих визитов.",
|
||||||
@@ -458,6 +463,9 @@ static RU: Translations = Translations {
|
|||||||
testimonials_add_button: "Добавить",
|
testimonials_add_button: "Добавить",
|
||||||
testimonials_status_active: "Отображается",
|
testimonials_status_active: "Отображается",
|
||||||
testimonials_status_hidden: "Скрыт",
|
testimonials_status_hidden: "Скрыт",
|
||||||
|
testimonials_edit: "Редактировать",
|
||||||
|
testimonials_save: "Сохранить",
|
||||||
|
testimonials_remove_image: "Удалить фото",
|
||||||
|
|
||||||
no_value: "—",
|
no_value: "—",
|
||||||
action_convert: "Конвертировать",
|
action_convert: "Конвертировать",
|
||||||
@@ -530,6 +538,7 @@ static EN: Translations = Translations {
|
|||||||
settings_telegram_chat_id: "Notification Chat ID",
|
settings_telegram_chat_id: "Notification Chat ID",
|
||||||
settings_contact_info: "Contact info (shown on landing page)",
|
settings_contact_info: "Contact info (shown on landing page)",
|
||||||
settings_pricing_info: "Pricing block (shown on landing page)",
|
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||||
|
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||||
landing_contact_label: "Or contact us directly",
|
landing_contact_label: "Or contact us directly",
|
||||||
landing_pricing_title: "Pricing",
|
landing_pricing_title: "Pricing",
|
||||||
|
|
||||||
@@ -550,7 +559,7 @@ static EN: Translations = Translations {
|
|||||||
media_delete_confirm: "Delete this file?",
|
media_delete_confirm: "Delete this file?",
|
||||||
media_all_clients: "All clients",
|
media_all_clients: "All clients",
|
||||||
|
|
||||||
portal_title: "My Visits",
|
portal_title: "Visits",
|
||||||
portal_upcoming: "Upcoming visits",
|
portal_upcoming: "Upcoming visits",
|
||||||
portal_past: "Past visits",
|
portal_past: "Past visits",
|
||||||
portal_no_upcoming: "No upcoming visits.",
|
portal_no_upcoming: "No upcoming visits.",
|
||||||
@@ -653,6 +662,9 @@ static EN: Translations = Translations {
|
|||||||
testimonials_add_button: "Add",
|
testimonials_add_button: "Add",
|
||||||
testimonials_status_active: "Visible",
|
testimonials_status_active: "Visible",
|
||||||
testimonials_status_hidden: "Hidden",
|
testimonials_status_hidden: "Hidden",
|
||||||
|
testimonials_edit: "Edit",
|
||||||
|
testimonials_save: "Save",
|
||||||
|
testimonials_remove_image: "Remove photo",
|
||||||
|
|
||||||
no_value: "—",
|
no_value: "—",
|
||||||
action_convert: "Convert",
|
action_convert: "Convert",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod migrations;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
mod public;
|
mod public;
|
||||||
mod telegram;
|
mod telegram;
|
||||||
|
mod tz;
|
||||||
|
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::config::{
|
use cot::config::{
|
||||||
|
|||||||
+6
-5
@@ -60,7 +60,7 @@ fn html_response(body: String, lang: Lang) -> cot::Result<Response> {
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> chrono::NaiveDateTime {
|
fn now_utc() -> chrono::NaiveDateTime {
|
||||||
chrono::Utc::now().naive_utc()
|
chrono::Utc::now().naive_utc()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,8 +131,8 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
comment: form.comment.filter(|s| !s.trim().is_empty()),
|
comment: form.comment.filter(|s| !s.trim().is_empty()),
|
||||||
status: "new".to_string(),
|
status: "new".to_string(),
|
||||||
client_id: None,
|
client_id: None,
|
||||||
created_at: now(),
|
created_at: now_utc(),
|
||||||
updated_at: now(),
|
updated_at: now_utc(),
|
||||||
};
|
};
|
||||||
lead.save(&db).await?;
|
lead.save(&db).await?;
|
||||||
|
|
||||||
@@ -188,7 +188,8 @@ async fn client_portal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let client_id = client.id.unwrap();
|
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?;
|
let mut visits = Visit::objects().all(&db).await?;
|
||||||
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
|
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
|
||||||
@@ -277,7 +278,7 @@ async fn submit_feedback(
|
|||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
if visit.client_id.primary_key().unwrap() == client_id {
|
if visit.client_id.primary_key().unwrap() == client_id {
|
||||||
visit.client_feedback = Some(form.feedback);
|
visit.client_feedback = Some(form.feedback);
|
||||||
visit.updated_at = now();
|
visit.updated_at = now_utc();
|
||||||
visit.save(&db).await?;
|
visit.save(&db).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -47,19 +47,37 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recent feedbacks -->
|
<!-- Feedbacks -->
|
||||||
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
|
<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>
|
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for fb in &recent_feedbacks %}
|
{% for fb in &feedbacks %}
|
||||||
<div class="item-card" style="border-left:3px solid #7c6cff;">
|
<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;">
|
<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>
|
<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>
|
||||||
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
||||||
</div>
|
</a>
|
||||||
{% endfor %}
|
{% 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">«</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">»</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
const calendar = new FullCalendar.Calendar(calEl, {
|
const calendar = new FullCalendar.Calendar(calEl, {
|
||||||
locale: lang,
|
locale: lang,
|
||||||
|
timeZone: '{{ timezone }}',
|
||||||
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
|
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: 'prev,next today',
|
left: 'prev,next today',
|
||||||
|
|||||||
@@ -97,55 +97,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||||
</form>
|
</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;">
|
<hr style="margin:1rem 0;">
|
||||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
<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>
|
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" 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>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.visit-media-grid {
|
.visit-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -182,8 +206,31 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.visit-media-item form {
|
.upload-modal-bg {
|
||||||
padding: 0.2rem;
|
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>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('uploadModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_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>
|
||||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,36 +39,92 @@
|
|||||||
<p style="color:#888;">{{ t.testimonials_empty }}</p>
|
<p style="color:#888;">{{ t.testimonials_empty }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for item in &testimonials %}
|
{% for item in &testimonials %}
|
||||||
<div class="item-card">
|
<div class="item-card" id="card-{{ item.id.unwrap() }}">
|
||||||
<div class="item-card-header">
|
<!-- View mode -->
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
||||||
{% if item.image_path.is_some() %}
|
<div class="item-card-header">
|
||||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
{% endif %}
|
{% if item.image_path.is_some() %}
|
||||||
<div>
|
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
||||||
<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 %}
|
{% 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>
|
</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>
|
</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>
|
||||||
<div class="item-card-actions">
|
|
||||||
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/toggle">
|
<!-- Edit mode (hidden by default) -->
|
||||||
{% if item.status == "active" %}
|
<div class="tm-edit" id="edit-{{ item.id.unwrap() }}" style="display:none;">
|
||||||
<button type="submit" class="button btn-sm is-warning is-light">{{ t.action_archive }}</button>
|
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/edit" enctype="multipart/form-data">
|
||||||
{% else %}
|
<div class="field">
|
||||||
<button type="submit" class="button btn-sm is-success is-light">{{ t.action_activate }}</button>
|
<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 %}
|
{% endif %}
|
||||||
</form>
|
<div class="field">
|
||||||
<form method="post" action="/admin/testimonials/{{ item.id.unwrap() }}/delete" onsubmit="return confirm('Delete?')">
|
<label class="label">{{ t.testimonials_image }}</label>
|
||||||
<button type="submit" class="button btn-sm is-danger is-light">{{ t.media_delete }}</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user