18 Commits

Author SHA1 Message Date
Ultradesu 91ca486e64 Added user/visits 'deleted' status, hide it in UI
Build and Publish / Build and Publish Docker Image (push) Successful in 1m22s
2026-06-04 13:41:18 +03:00
Ultradesu 2389bca42b Fixed image transcoding. Paying attention to EXIF orientation data
Build and Publish / Build and Publish Docker Image (push) Successful in 1m24s
2026-06-04 13:08:34 +03:00
Ultradesu 520960d009 Added image compression
Build and Publish / Build and Publish Docker Image (push) Successful in 1m59s
2026-06-02 19:30:05 +03:00
Ultradesu 0cda791d44 Fixed OIDC small bug 2026-05-20 14:43:24 +03:00
ab a65488c304 Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s
2026-05-19 00:57:05 +03:00
ab 4d9d0a894c Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 2m53s
2026-05-19 00:32:36 +03:00
ab fd1e78ba8c Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s
2026-05-19 00:16:22 +03:00
ab 99e2cbc1f0 Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m51s
2026-05-18 23:50:34 +03:00
ab 71f444b9aa Added claudflare Turnstile captcha support 2026-05-18 23:09:07 +03:00
ab a8de7cfa33 Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s
2026-05-18 22:30:36 +03:00
ab f7dcefeea6 Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 7m6s
2026-05-18 22:12:54 +03:00
ab 757ebea2ba Added claudflare Turnstile captcha support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m56s
2026-05-18 21:48:30 +03:00
ab 4d41513994 Added claudflare Turnstile captcha support 2026-05-18 21:48:02 +03:00
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
23 changed files with 2047 additions and 303 deletions
Generated
+233 -1
View File
@@ -29,6 +29,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -323,12 +332,24 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -621,6 +642,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@@ -884,12 +914,31 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1425,6 +1474,32 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -1574,6 +1649,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 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]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -1619,6 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [ dependencies = [
"adler2", "adler2",
"simd-adler32",
] ]
[[package]] [[package]]
@@ -1632,6 +1717,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@@ -1649,6 +1744,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@@ -1888,6 +1992,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1940,6 +2057,18 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -2093,6 +2222,23 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -2376,6 +2522,15 @@ dependencies = [
"digest", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -2402,6 +2557,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.2"
@@ -2724,6 +2885,15 @@ dependencies = [
"syn", "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]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -3012,6 +3182,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "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]] [[package]]
@@ -3090,6 +3290,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3113,6 +3319,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -3255,12 +3467,14 @@ dependencies = [
[[package]] [[package]]
name = "web-petting" name = "web-petting"
version = "0.1.7" version = "0.1.14"
dependencies = [ dependencies = [
"base64",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"cot", "cot",
"futures", "futures",
"image",
"multer", "multer",
"password-auth", "password-auth",
"reqwest", "reqwest",
@@ -3268,6 +3482,9 @@ dependencies = [
"serde_html_form", "serde_html_form",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid", "uuid",
] ]
@@ -3808,3 +4025,18 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+6 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "web-petting" name = "web-petting"
version = "0.1.7" version = "0.1.15"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -14,5 +14,10 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
serde_json = "1" serde_json = "1"
multer = "3" multer = "3"
futures = "0.3" futures = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
tokio = { version = "1", features = ["fs"] } tokio = { version = "1", features = ["fs"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
urlencoding = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+672 -30
View File
File diff suppressed because it is too large Load Diff
+80 -3
View File
@@ -103,8 +103,11 @@ pub struct Translations {
pub clients_media_link: &'static str, pub clients_media_link: &'static str,
pub clients_add_title: &'static str, pub clients_add_title: &'static str,
pub clients_add_button: &'static str, pub clients_add_button: &'static str,
pub clients_delete: &'static str,
pub clients_delete_confirm: &'static str,
pub client_status_active: &'static str, pub client_status_active: &'static str,
pub client_status_archived: &'static str, pub client_status_archived: &'static str,
pub client_status_deleted: &'static str,
// Users // Users
pub users_title: &'static str, pub users_title: &'static str,
@@ -118,6 +121,8 @@ pub struct Translations {
pub users_add_button: &'static str, pub users_add_button: &'static str,
pub users_error_passwords_mismatch: &'static str, pub users_error_passwords_mismatch: &'static str,
pub users_error_login_taken: &'static str, pub users_error_login_taken: &'static str,
pub users_telegram_chat_id: &'static str,
pub users_telegram_enabled: &'static str,
// Settings // Settings
pub settings_title: &'static str, pub settings_title: &'static str,
@@ -132,6 +137,20 @@ pub struct Translations {
pub settings_pricing_info: &'static str, pub settings_pricing_info: &'static str,
pub settings_timezone: &'static str, pub settings_timezone: &'static str,
pub settings_site_domain: &'static str, pub settings_site_domain: &'static str,
pub settings_seo_keywords: &'static str,
pub settings_turnstile_site_key: &'static str,
pub settings_turnstile_secret_key: &'static str,
pub settings_oidc_issuer_url: &'static str,
pub settings_oidc_client_id: &'static str,
pub settings_oidc_client_secret: &'static str,
pub settings_oidc_allowed_groups: &'static str,
pub settings_auth_password_enabled: &'static str,
pub settings_auth_sso_enabled: &'static str,
pub settings_section_advanced: &'static str,
pub settings_section_notifications: &'static str,
pub settings_section_captcha: &'static str,
pub settings_section_oidc: &'static str,
pub settings_section_general: &'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,
@@ -146,6 +165,11 @@ pub struct Translations {
pub login_title: &'static str, pub login_title: &'static str,
pub login_button: &'static str, pub login_button: &'static str,
pub login_error: &'static str, pub login_error: &'static str,
pub login_sso_button: &'static str,
pub login_sso_error: &'static str,
pub login_sso_error_group: &'static str,
pub login_sso_error_provider: &'static str,
pub login_sso_error_user_disabled: &'static str,
pub logout: &'static str, pub logout: &'static str,
pub setup_title: &'static str, pub setup_title: &'static str,
pub setup_description: &'static str, pub setup_description: &'static str,
@@ -235,6 +259,7 @@ pub struct Translations {
pub visit_status_scheduled: &'static str, pub visit_status_scheduled: &'static str,
pub visit_status_completed: &'static str, pub visit_status_completed: &'static str,
pub visit_status_cancelled: &'static str, pub visit_status_cancelled: &'static str,
pub visit_status_deleted: &'static str,
pub schedule_mark_done: &'static str, pub schedule_mark_done: &'static str,
pub schedule_cancel: &'static str, pub schedule_cancel: &'static str,
pub schedule_edit_title: &'static str, pub schedule_edit_title: &'static str,
@@ -315,8 +340,11 @@ static RU: Translations = Translations {
clients_media_link: "Медиа", clients_media_link: "Медиа",
clients_add_title: "Добавить клиента", clients_add_title: "Добавить клиента",
clients_add_button: "Добавить", clients_add_button: "Добавить",
clients_delete: "Удалить клиента",
clients_delete_confirm: "Точно удалить этого клиента?",
client_status_active: "Активный", client_status_active: "Активный",
client_status_archived: "Архив", client_status_archived: "Архив",
client_status_deleted: "Удалён",
users_title: "Администраторы", users_title: "Администраторы",
users_login: "Логин", users_login: "Логин",
@@ -329,6 +357,8 @@ static RU: Translations = Translations {
users_add_button: "Добавить", users_add_button: "Добавить",
users_error_passwords_mismatch: "Пароли не совпадают.", users_error_passwords_mismatch: "Пароли не совпадают.",
users_error_login_taken: "Этот логин уже занят.", users_error_login_taken: "Этот логин уже занят.",
users_telegram_chat_id: "Telegram Chat ID",
users_telegram_enabled: "Уведомления",
settings_title: "Настройки", settings_title: "Настройки",
settings_key: "Параметр", settings_key: "Параметр",
@@ -342,6 +372,20 @@ static RU: Translations = Translations {
settings_pricing_info: "Блок с ценами (отображается на лендинге)", settings_pricing_info: "Блок с ценами (отображается на лендинге)",
settings_timezone: "Часовой пояс (например Asia/Vladivostok)", settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
settings_site_domain: "Домен сайта (например https://example.com)", settings_site_domain: "Домен сайта (например https://example.com)",
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
settings_auth_password_enabled: "Вход по логину и паролю",
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
settings_section_advanced: "Расширенные настройки",
settings_section_notifications: "Уведомления",
settings_section_captcha: "Защита от ботов",
settings_section_oidc: "Единый вход (SSO / OIDC)",
settings_section_general: "Сайт",
landing_contact_label: "Или свяжитесь с нами напрямую", landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость", landing_pricing_title: "Стоимость",
@@ -376,6 +420,11 @@ static RU: Translations = Translations {
login_title: "Вход в систему", login_title: "Вход в систему",
login_button: "Войти", login_button: "Войти",
login_error: "Неверный логин или пароль.", login_error: "Неверный логин или пароль.",
login_sso_button: "Войти через SSO",
login_sso_error: "Ошибка SSO-авторизации.",
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
logout: "Выйти", logout: "Выйти",
setup_title: "Создание администратора", setup_title: "Создание администратора",
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.", setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
@@ -394,7 +443,7 @@ static RU: Translations = Translations {
schedule_new_title: "Запланировать визиты", schedule_new_title: "Запланировать визиты",
schedule_client: "Клиент", schedule_client: "Клиент",
schedule_admin: "Исполнитель", schedule_admin: "Исполнитель",
schedule_default_time: "Время по умолчанию", schedule_default_time: "Время",
schedule_time_start: "С", schedule_time_start: "С",
schedule_time_end: "До", schedule_time_end: "До",
schedule_pick_dates: "Добавить дату", schedule_pick_dates: "Добавить дату",
@@ -411,6 +460,7 @@ static RU: Translations = Translations {
visit_status_scheduled: "Запланирован", visit_status_scheduled: "Запланирован",
visit_status_completed: "Выполнен", visit_status_completed: "Выполнен",
visit_status_cancelled: "Отменён", visit_status_cancelled: "Отменён",
visit_status_deleted: "Удалён",
schedule_mark_done: "Выполнен", schedule_mark_done: "Выполнен",
schedule_cancel: "Отменить", schedule_cancel: "Отменить",
schedule_edit_title: "Редактировать визит", schedule_edit_title: "Редактировать визит",
@@ -421,7 +471,7 @@ static RU: Translations = Translations {
schedule_delete_confirm: "Точно удалить этот визит?", schedule_delete_confirm: "Точно удалить этот визит?",
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!", landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома", landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома. Город Хабаровск",
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.", landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.", landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
landing_hero_cta: "Оставить заявку", landing_hero_cta: "Оставить заявку",
@@ -515,8 +565,11 @@ static EN: Translations = Translations {
clients_media_link: "Media", clients_media_link: "Media",
clients_add_title: "Add Client", clients_add_title: "Add Client",
clients_add_button: "Add", clients_add_button: "Add",
clients_delete: "Delete client",
clients_delete_confirm: "Are you sure you want to delete this client?",
client_status_active: "Active", client_status_active: "Active",
client_status_archived: "Archived", client_status_archived: "Archived",
client_status_deleted: "Deleted",
users_title: "Administrators", users_title: "Administrators",
users_login: "Login", users_login: "Login",
@@ -529,6 +582,8 @@ static EN: Translations = Translations {
users_add_button: "Add", users_add_button: "Add",
users_error_passwords_mismatch: "Passwords do not match.", users_error_passwords_mismatch: "Passwords do not match.",
users_error_login_taken: "This login is already taken.", users_error_login_taken: "This login is already taken.",
users_telegram_chat_id: "Telegram Chat ID",
users_telegram_enabled: "Notifications",
settings_title: "Settings", settings_title: "Settings",
settings_key: "Parameter", settings_key: "Parameter",
@@ -542,6 +597,20 @@ static EN: Translations = Translations {
settings_pricing_info: "Pricing block (shown on landing page)", settings_pricing_info: "Pricing block (shown on landing page)",
settings_timezone: "Timezone (e.g. Asia/Vladivostok)", settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
settings_site_domain: "Site domain (e.g. https://example.com)", settings_site_domain: "Site domain (e.g. https://example.com)",
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
settings_oidc_issuer_url: "OIDC — Issuer URL",
settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
settings_auth_password_enabled: "Password login",
settings_auth_sso_enabled: "SSO login (OIDC)",
settings_section_advanced: "Advanced settings",
settings_section_notifications: "Notifications",
settings_section_captcha: "Bot protection",
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
settings_section_general: "Site",
landing_contact_label: "Or contact us directly", landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing", landing_pricing_title: "Pricing",
@@ -576,6 +645,11 @@ static EN: Translations = Translations {
login_title: "Sign In", login_title: "Sign In",
login_button: "Sign In", login_button: "Sign In",
login_error: "Invalid login or password.", login_error: "Invalid login or password.",
login_sso_button: "Sign in with SSO",
login_sso_error: "SSO authentication failed.",
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
login_sso_error_provider: "Could not reach the authentication provider.",
login_sso_error_user_disabled: "Your account is disabled.",
logout: "Sign Out", logout: "Sign Out",
setup_title: "Create Administrator", setup_title: "Create Administrator",
setup_description: "There are no administrators yet. Create the first one to get started.", setup_description: "There are no administrators yet. Create the first one to get started.",
@@ -594,7 +668,7 @@ static EN: Translations = Translations {
schedule_new_title: "Plan Visits", schedule_new_title: "Plan Visits",
schedule_client: "Client", schedule_client: "Client",
schedule_admin: "Assigned to", schedule_admin: "Assigned to",
schedule_default_time: "Default Time", schedule_default_time: "Time",
schedule_time_start: "From", schedule_time_start: "From",
schedule_time_end: "To", schedule_time_end: "To",
schedule_pick_dates: "Add date", schedule_pick_dates: "Add date",
@@ -611,6 +685,7 @@ static EN: Translations = Translations {
visit_status_scheduled: "Scheduled", visit_status_scheduled: "Scheduled",
visit_status_completed: "Completed", visit_status_completed: "Completed",
visit_status_cancelled: "Cancelled", visit_status_cancelled: "Cancelled",
visit_status_deleted: "Deleted",
schedule_mark_done: "Done", schedule_mark_done: "Done",
schedule_cancel: "Cancel", schedule_cancel: "Cancel",
schedule_edit_title: "Edit Visit", schedule_edit_title: "Edit Visit",
@@ -697,6 +772,7 @@ impl Translations {
"scheduled" => self.visit_status_scheduled, "scheduled" => self.visit_status_scheduled,
"completed" => self.visit_status_completed, "completed" => self.visit_status_completed,
"cancelled" => self.visit_status_cancelled, "cancelled" => self.visit_status_cancelled,
"deleted" => self.visit_status_deleted,
_ => "?", _ => "?",
} }
} }
@@ -705,6 +781,7 @@ impl Translations {
match status { match status {
"active" => self.client_status_active, "active" => self.client_status_active,
"archived" => self.client_status_archived, "archived" => self.client_status_archived,
"deleted" => self.client_status_deleted,
_ => "?", _ => "?",
} }
} }
+31 -4
View File
@@ -4,12 +4,15 @@ mod migrations;
pub mod models; pub mod models;
mod public; mod public;
mod telegram; mod telegram;
mod turnstile;
mod tz; mod tz;
use tracing_subscriber;
use cot::cli::CliMetadata; use cot::cli::CliMetadata;
use cot::config::{ use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
SessionStoreTypeConfig, SessionStoreConfig, SessionStoreTypeConfig,
}; };
use cot::db::migrations::SyncDynMigration; use cot::db::migrations::SyncDynMigration;
use cot::middleware::SessionMiddleware; use cot::middleware::SessionMiddleware;
@@ -48,14 +51,32 @@ impl App for PublicApp {
struct PettingProject; struct PettingProject;
fn parse_bool_env(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn debug_enabled(config_name: &str) -> bool {
parse_bool_env("WEB_PETTING_DEBUG").unwrap_or_else(|| {
matches!(
config_name,
"dev" | "development" | "debug" | "local" | "test"
)
})
}
impl Project for PettingProject { impl Project for PettingProject {
fn cli_metadata(&self) -> CliMetadata { fn cli_metadata(&self) -> CliMetadata {
cot::cli::metadata!() cot::cli::metadata!()
} }
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> { fn config(&self, config_name: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder() Ok(ProjectConfig::builder()
.debug(true) .debug(debug_enabled(config_name))
.database( .database(
DatabaseConfig::builder() DatabaseConfig::builder()
.url("sqlite://db.sqlite3?mode=rwc") .url("sqlite://db.sqlite3?mode=rwc")
@@ -66,6 +87,7 @@ impl Project for PettingProject {
.session( .session(
SessionMiddlewareConfig::builder() SessionMiddlewareConfig::builder()
.secure(false) .secure(false)
.same_site(SameSite::Lax)
.store( .store(
SessionStoreConfig::builder() SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database) .store_type(SessionStoreTypeConfig::Database)
@@ -97,5 +119,10 @@ impl Project for PettingProject {
#[cot::main] #[cot::main]
fn main() -> impl Project { 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 PettingProject
} }
+2
View File
@@ -7,6 +7,7 @@ pub mod m_0002_visit_schedule;
pub mod m_0003_visit_feedback; pub mod m_0003_visit_feedback;
pub mod m_0004_visit_public_notes; pub mod m_0004_visit_public_notes;
pub mod m_0005_testimonials; pub mod m_0005_testimonials;
pub mod m_0006_user_telegram;
/// The list of migrations for current app. /// The list of migrations for current app.
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration, &m_0001_initial::Migration,
@@ -14,4 +15,5 @@ pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
&m_0003_visit_feedback::Migration, &m_0003_visit_feedback::Migration,
&m_0004_visit_public_notes::Migration, &m_0004_visit_public_notes::Migration,
&m_0005_testimonials::Migration, &m_0005_testimonials::Migration,
&m_0006_user_telegram::Migration,
]; ];
+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(),
];
}
+5 -1
View File
@@ -43,6 +43,7 @@ pub enum VisitStatus {
Scheduled, Scheduled,
Completed, Completed,
Cancelled, Cancelled,
Deleted,
} }
impl VisitStatus { impl VisitStatus {
@@ -51,6 +52,7 @@ impl VisitStatus {
Self::Scheduled => "scheduled", Self::Scheduled => "scheduled",
Self::Completed => "completed", Self::Completed => "completed",
Self::Cancelled => "cancelled", Self::Cancelled => "cancelled",
Self::Deleted => "deleted",
} }
} }
} }
@@ -146,7 +148,7 @@ pub struct Visit {
pub public_notes: Option<String>, pub public_notes: Option<String>,
/// Feedback text from client via portal. /// Feedback text from client via portal.
pub client_feedback: Option<String>, pub client_feedback: Option<String>,
/// scheduled | completed | cancelled /// scheduled | completed | cancelled | deleted
pub status: String, pub status: String,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime,
@@ -179,6 +181,8 @@ pub struct User {
pub login: String, pub login: String,
pub password_hash: String, pub password_hash: String,
pub display_name: Option<String>, pub display_name: Option<String>,
pub telegram_chat_id: Option<String>,
pub telegram_notifications: Option<bool>,
/// active | archived /// active | archived
pub status: String, pub status: String,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
+85 -4
View File
@@ -6,6 +6,7 @@ use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response}; use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router}; use cot::router::{Route, Router};
use serde::Deserialize; use serde::Deserialize;
use tracing::info;
use cot::db::query; use cot::db::query;
@@ -71,9 +72,11 @@ struct LandingTemplate<'a> {
lang: Lang, lang: Lang,
contact_info: String, contact_info: String,
pricing_info: String, pricing_info: String,
seo_keywords: String,
testimonials: Vec<Testimonial>, testimonials: Vec<Testimonial>,
site_domain: String, site_domain: String,
review_count: usize, review_count: usize,
turnstile_site_key: String,
} }
#[derive(Debug, Template)] #[derive(Debug, Template)]
@@ -85,6 +88,33 @@ struct ThankYouTemplate<'a> {
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> { async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request); let lang = detect_lang(&request);
let ua = request
.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let referer = request
.headers()
.get("referer")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let ip = request
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim())
.unwrap_or("-");
info!(
target: "landing",
ip = ip,
lang = lang.code(),
referer = referer,
ua = ua,
"landing visit"
);
let key = "contact_info".to_string(); let key = "contact_info".to_string();
let contact_info = query!(Setting, $key == key) let contact_info = query!(Setting, $key == key)
.get(&db) .get(&db)
@@ -103,6 +133,13 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
.await? .await?
.map(|s| s.value) .map(|s| s.value)
.unwrap_or_else(|| "https://example.net".to_string()); .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 turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let mut testimonials = Testimonial::objects().all(&db).await?; let mut testimonials = Testimonial::objects().all(&db).await?;
testimonials.retain(|t| t.status == "active"); testimonials.retain(|t| t.status == "active");
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order)); testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
@@ -112,9 +149,11 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
lang, lang,
contact_info, contact_info,
pricing_info, pricing_info,
seo_keywords,
testimonials, testimonials,
site_domain, site_domain,
review_count, review_count,
turnstile_site_key,
} }
.render()?; .render()?;
html_response(body, lang) html_response(body, lang)
@@ -125,6 +164,8 @@ struct LeadForm {
name: String, name: String,
phone: Option<String>, phone: Option<String>,
comment: Option<String>, comment: Option<String>,
#[serde(default, rename = "cf-turnstile-response")]
cf_turnstile_response: Option<String>,
} }
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> { async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
@@ -134,6 +175,10 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
let form: LeadForm = let form: LeadForm =
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
return Redirect::new(format!("/?lang={}", lang.code())).into_response();
}
let mut lead = Lead { let mut lead = Lead {
id: Auto::auto(), id: Auto::auto(),
name: form.name, name: form.name,
@@ -179,6 +224,7 @@ struct ClientPortalTemplate<'a> {
upcoming: Vec<PortalVisit>, upcoming: Vec<PortalVisit>,
past: Vec<PortalVisit>, past: Vec<PortalVisit>,
feedback_sent: bool, feedback_sent: bool,
turnstile_site_key: String,
} }
async fn client_portal( async fn client_portal(
@@ -194,7 +240,8 @@ async fn client_portal(
.unwrap_or(false); .unwrap_or(false);
let client = match query!(Client, $media_token == token).get(&db).await? { let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c, Some(c) if c.status != "deleted" => c,
Some(_) => return Html::new("404").into_response(),
None => return Html::new("404").into_response(), None => return Html::new("404").into_response(),
}; };
@@ -203,7 +250,11 @@ async fn client_portal(
let today = crate::tz::today_in_tz(tz); 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"
&& v.status != "deleted"
});
visits.sort_by(|a, b| { visits.sort_by(|a, b| {
a.visit_date a.visit_date
.cmp(&b.visit_date) .cmp(&b.visit_date)
@@ -250,6 +301,7 @@ async fn client_portal(
} }
past.reverse(); // newest first past.reverse(); // newest first
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let body = ClientPortalTemplate { let body = ClientPortalTemplate {
t: lang.t(), t: lang.t(),
lang, lang,
@@ -257,6 +309,7 @@ async fn client_portal(
upcoming, upcoming,
past, past,
feedback_sent, feedback_sent,
turnstile_site_key,
} }
.render()?; .render()?;
html_response(body, lang) html_response(body, lang)
@@ -265,6 +318,8 @@ async fn client_portal(
#[derive(Deserialize)] #[derive(Deserialize)]
struct FeedbackForm { struct FeedbackForm {
feedback: String, feedback: String,
#[serde(default, rename = "cf-turnstile-response")]
cf_turnstile_response: Option<String>,
} }
async fn submit_feedback( async fn submit_feedback(
@@ -277,7 +332,8 @@ async fn submit_feedback(
// Verify token matches visit's client // Verify token matches visit's client
let token_clone = token.clone(); let token_clone = token.clone();
let client = match query!(Client, $media_token == token).get(&db).await? { let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c, Some(c) if c.status != "deleted" => c,
Some(_) => return Html::new("404").into_response(),
None => return Html::new("404").into_response(), None => return Html::new("404").into_response(),
}; };
let client_id = client.id.unwrap(); let client_id = client.id.unwrap();
@@ -286,7 +342,24 @@ async fn submit_feedback(
let form: FeedbackForm = let form: FeedbackForm =
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?; serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
return Redirect::new(format!(
"/client/{}?lang={}",
token_clone,
lang.code()
))
.into_response();
}
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.status == "deleted" {
return Redirect::new(format!(
"/client/{}?lang={}",
token_clone,
lang.code()
))
.into_response();
}
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_utc(); visit.updated_at = now_utc();
@@ -310,7 +383,8 @@ async fn portal_media(
) -> cot::Result<Response> { ) -> cot::Result<Response> {
// Verify token // Verify token
let client = match query!(Client, $media_token == token).get(&db).await? { let client = match query!(Client, $media_token == token).get(&db).await? {
Some(c) => c, Some(c) if c.status != "deleted" => c,
Some(_) => return Html::new("404").into_response(),
None => return Html::new("404").into_response(), None => return Html::new("404").into_response(),
}; };
let client_id = client.id.unwrap(); let client_id = client.id.unwrap();
@@ -319,6 +393,13 @@ async fn portal_media(
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m, Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
_ => return Html::new("404").into_response(), _ => return Html::new("404").into_response(),
}; };
if let Some(fk) = &media.visit_id {
let visit_id: i64 = fk.primary_key().unwrap();
match query!(Visit, $id == visit_id).get(&db).await? {
Some(v) if v.status != "deleted" => {}
_ => return Html::new("404").into_response(),
}
}
match tokio::fs::read(&media.file_path).await { match tokio::fs::read(&media.file_path).await {
Ok(data) => { Ok(data) => {
+21 -7
View File
@@ -1,8 +1,8 @@
use cot::db::{Database, query}; use cot::db::{Database, query};
use crate::models::Setting; use crate::models::{Setting, User};
/// Send a Telegram message using bot settings from DB. /// Send a Telegram notification to all admins with notifications enabled.
/// Silently ignores errors (missing config, network issues) — notifications are best-effort. /// Silently ignores errors (missing config, network issues) — notifications are best-effort.
pub async fn notify_new_lead( pub async fn notify_new_lead(
db: &Database, db: &Database,
@@ -14,10 +14,6 @@ pub async fn notify_new_lead(
Some(t) if !t.is_empty() => t, Some(t) if !t.is_empty() => t,
_ => return, _ => return,
}; };
let chat_id = match get_setting(db, "telegram_chat_id").await {
Some(c) if !c.is_empty() => c,
_ => return,
};
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}"); let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
if let Some(phone) = phone.filter(|s| !s.is_empty()) { if let Some(phone) = phone.filter(|s| !s.is_empty()) {
@@ -27,8 +23,25 @@ pub async fn notify_new_lead(
text.push_str(&format!("\nКомментарий: {comment}")); text.push_str(&format!("\nКомментарий: {comment}"));
} }
let active = "active".to_string();
let users = match query!(User, $status == active).all(db).await {
Ok(u) => u,
Err(_) => return,
};
let client = reqwest::Client::new();
let url = format!("https://api.telegram.org/bot{token}/sendMessage"); let url = format!("https://api.telegram.org/bot{token}/sendMessage");
let _ = reqwest::Client::new()
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) .post(&url)
.json(&serde_json::json!({ .json(&serde_json::json!({
"chat_id": chat_id, "chat_id": chat_id,
@@ -36,6 +49,7 @@ pub async fn notify_new_lead(
})) }))
.send() .send()
.await; .await;
}
} }
async fn get_setting(db: &Database, key_name: &str) -> Option<String> { async fn get_setting(db: &Database, key_name: &str) -> Option<String> {
+48
View File
@@ -0,0 +1,48 @@
use cot::db::{Database, query};
use crate::models::Setting;
/// Read `turnstile_site_key` from Settings. Returns empty string if not configured.
pub async fn get_site_key(db: &Database) -> cot::Result<String> {
let key = "turnstile_site_key".to_string();
Ok(query!(Setting, $key == key)
.get(db)
.await?
.map(|s| s.value)
.unwrap_or_default())
}
/// Verify a Turnstile token against Cloudflare.
/// Returns `true` if verification succeeds, or if no secret key is configured (passthrough).
pub async fn verify(db: &Database, token: Option<&str>) -> cot::Result<bool> {
let secret_key_name = "turnstile_secret_key".to_string();
let secret_key = query!(Setting, $key == secret_key_name)
.get(db)
.await?
.map(|s| s.value)
.filter(|s| !s.is_empty());
let Some(secret) = secret_key else {
return Ok(true);
};
let token = token.unwrap_or("");
let client = reqwest::Client::new();
let resp = client
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
.json(&serde_json::json!({
"secret": secret,
"response": token
}))
.send()
.await;
Ok(match resp {
Ok(r) => r
.json::<serde_json::Value>()
.await
.map(|v| v["success"].as_bool() == Some(true))
.unwrap_or(false),
Err(_) => false,
})
}
+6 -1
View File
@@ -76,11 +76,16 @@
<form method="post" action="/admin/clients/{{ client_id }}/archive"> <form method="post" action="/admin/clients/{{ client_id }}/archive">
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button> <button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
</form> </form>
{% else %} {% else if client_status == "archived" %}
<form method="post" action="/admin/clients/{{ client_id }}/activate"> <form method="post" action="/admin/clients/{{ client_id }}/activate">
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button> <button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
</form> </form>
{% endif %} {% endif %}
{% if client_status != "deleted" %}
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
</form>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
+20 -11
View File
@@ -26,15 +26,24 @@
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30; position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
background: #fff; border-top: 1px solid #e8e8e8; background: #fff; border-top: 1px solid #e8e8e8;
display: flex; height: 3.5rem; display: flex; height: 3.5rem;
overflow-x: auto; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
} }
.bottom-tabs::-webkit-scrollbar { display: none; }
.bottom-tabs a { .bottom-tabs a {
flex: 1; display: flex; flex-direction: column; align-items: center; flex: 0 0 auto; min-width: 3.2rem; padding: 0 0.45rem;
display: flex; flex-direction: column; align-items: center;
justify-content: center; text-decoration: none; color: #999; justify-content: center; text-decoration: none; color: #999;
font-size: 0.65rem; font-weight: 600; gap: 0.15rem; font-size: 0.6rem; font-weight: 600; gap: 0.1rem;
transition: color 0.15s; transition: color 0.15s; white-space: nowrap;
} }
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; } .bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
.bottom-tabs a .tab-label { display: block; }
.bottom-tabs a.is-active { color: var(--accent); } .bottom-tabs a.is-active { color: var(--accent); }
@media (max-width: 400px) {
.bottom-tabs a .tab-label { display: none; }
.bottom-tabs a { min-width: 2.8rem; padding: 0 0.3rem; }
}
/* ── Desktop: hide bottom tabs, show top nav ── */ /* ── Desktop: hide bottom tabs, show top nav ── */
.desktop-nav { display: none; } .desktop-nav { display: none; }
@@ -135,28 +144,28 @@
<!-- Bottom tabs (mobile) --> <!-- Bottom tabs (mobile) -->
<nav class="bottom-tabs"> <nav class="bottom-tabs">
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}> <a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>
<span class="tab-icon">🏠</span>{{ t.dashboard_title }} <span class="tab-icon">🏠</span><span class="tab-label">{{ t.dashboard_title }}</span>
</a> </a>
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}> <a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>
<span class="tab-icon">📋</span>{{ t.nav_leads }} <span class="tab-icon">📋</span><span class="tab-label">{{ t.nav_leads }}</span>
</a> </a>
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}> <a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>
<span class="tab-icon">👥</span>{{ t.nav_clients }} <span class="tab-icon">👥</span><span class="tab-label">{{ t.nav_clients }}</span>
</a> </a>
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}> <a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>
<span class="tab-icon">📅</span>{{ t.nav_schedule }} <span class="tab-icon">📅</span><span class="tab-label">{{ t.nav_schedule }}</span>
</a> </a>
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}> <a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>
<span class="tab-icon">📷</span>{{ t.nav_media }} <span class="tab-icon">📷</span><span class="tab-label">{{ t.nav_media }}</span>
</a> </a>
<a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}> <a href="/admin/testimonials?lang={{ lang.code() }}" {% if active_page == "testimonials" %}class="is-active"{% endif %}>
<span class="tab-icon">💬</span>{{ t.nav_testimonials }} <span class="tab-icon">💬</span><span class="tab-label">{{ t.nav_testimonials }}</span>
</a> </a>
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}> <a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>
<span class="tab-icon">🔑</span>{{ t.nav_users }} <span class="tab-icon">🔑</span><span class="tab-label">{{ t.nav_users }}</span>
</a> </a>
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}> <a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
<span class="tab-icon">⚙️</span>{{ t.nav_settings }} <span class="tab-icon">⚙️</span><span class="tab-label">{{ t.nav_settings }}</span>
</a> </a>
</nav> </nav>
-6
View File
@@ -33,12 +33,6 @@
</div> </div>
{% if lead.status == "new" || lead.status == "in_progress" %} {% if lead.status == "new" || lead.status == "in_progress" %}
<div class="item-card-actions"> <div class="item-card-actions">
{% if lead.status == "new" %}
<form method="post" action="/admin/leads/{{ lead.id }}/status">
<input type="hidden" name="status" value="in_progress">
<button type="submit" class="button is-small is-info is-outlined btn-sm">{{ t.action_in_progress }}</button>
</form>
{% endif %}
<form method="post" action="/admin/leads/{{ lead.id }}/convert"> <form method="post" action="/admin/leads/{{ lead.id }}/convert">
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button> <button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
</form> </form>
+12
View File
@@ -6,6 +6,9 @@
<title>{{ t.nav_title }} — {{ t.login_title }}</title> <title>{{ t.nav_title }} — {{ t.login_title }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style> <style>
:root { color-scheme: light; } :root { color-scheme: light; }
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; } body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
@@ -32,6 +35,11 @@
{% if let Some(err) = error.as_ref() %} {% if let Some(err) = error.as_ref() %}
<div class="notification is-danger is-light">{{ err }}</div> <div class="notification is-danger is-light">{{ err }}</div>
{% endif %} {% endif %}
{% if auth_sso_enabled %}
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
{% endif %}
{% if auth_password_enabled %}
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
<form method="post" action="/admin/login/submit"> <form method="post" action="/admin/login/submit">
<div class="field"> <div class="field">
<label class="label">{{ t.users_login }}</label> <label class="label">{{ t.users_login }}</label>
@@ -41,8 +49,12 @@
<label class="label">{{ t.users_password }}</label> <label class="label">{{ t.users_password }}</label>
<div class="control"><input class="input" type="password" name="password" required></div> <div class="control"><input class="input" type="password" name="password" required></div>
</div> </div>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
{% endif %}
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button> <button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
</form> </form>
{% endif %}
</div> </div>
</div> </div>
</body> </body>
+77 -3
View File
@@ -14,12 +14,13 @@
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div> <div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
</div> </div>
<form method="post" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data"> <form id="uploadForm" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
<div class="field"> <div class="field">
<label class="label">{{ t.media_choose_files }}</label> <label class="label">{{ t.media_choose_files }}</label>
<div class="control"> <div class="control">
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required> <input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
</div> </div>
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
</div> </div>
<div class="field"> <div class="field">
@@ -29,7 +30,80 @@
</div> </div>
</div> </div>
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button> <!-- Progress -->
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
<span id="uploadStatusText">{{ t.media_upload }}...</span>
<span id="uploadPercent">0%</span>
</div>
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
</div>
</div>
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
</form> </form>
<script>
(function() {
var form = document.getElementById('uploadForm');
var filesInput = document.getElementById('uploadFiles');
var fileCount = document.getElementById('fileCount');
var progress = document.getElementById('uploadProgress');
var bar = document.getElementById('uploadBar');
var percent = document.getElementById('uploadPercent');
var statusText = document.getElementById('uploadStatusText');
var submitBtn = document.getElementById('uploadSubmit');
filesInput.addEventListener('change', function() {
var n = this.files.length;
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
});
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!filesInput.files.length) return;
var data = new FormData(form);
var xhr = new XMLHttpRequest();
progress.style.display = 'block';
submitBtn.disabled = true;
submitBtn.textContent = 'Загрузка...';
bar.style.width = '0%';
percent.textContent = '0%';
xhr.upload.addEventListener('progress', function(ev) {
if (!ev.lengthComputable) return;
var pct = Math.round(ev.loaded / ev.total * 100);
bar.style.width = pct + '%';
percent.textContent = pct + '%';
if (pct === 100) statusText.textContent = 'Обработка...';
});
xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 400) {
bar.style.width = '100%';
percent.textContent = '100%';
statusText.textContent = 'Готово!';
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
} else {
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}';
}
});
xhr.addEventListener('error', function() {
statusText.textContent = 'Ошибка соединения';
submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}';
});
xhr.open('POST', form.action);
xhr.send(data);
});
})();
</script>
</div> </div>
{% endblock %} {% endblock %}
+11 -14
View File
@@ -32,6 +32,10 @@
.fc .fc-toolbar-title { font-size: 1.1rem !important; } .fc .fc-toolbar-title { font-size: 1.1rem !important; }
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; } .fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; } .fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
.fc-event.ev-completed .fc-event-title,
.fc-event.ev-completed .fc-list-event-title { text-decoration: line-through; opacity: 0.75; }
.fc-event.ev-cancelled .fc-event-title,
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; }
.fc .fc-day-today { background: #eef2ff !important; } .fc .fc-day-today { background: #eef2ff !important; }
.fc .fc-day.day-weekend { background: #faf5f0; } .fc .fc-day.day-weekend { background: #faf5f0; }
.fc .fc-day-today.day-weekend { background: #eef2ff !important; } .fc .fc-day-today.day-weekend { background: #eef2ff !important; }
@@ -45,8 +49,6 @@
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); } .visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; } .visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; } .visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
.visit-modal .actions { display:flex; gap:0.5rem; flex-wrap:wrap; }
.visit-modal .actions form { margin:0; }
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; } .color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
</style> </style>
@@ -60,12 +62,8 @@
<div id="vmTime"></div> <div id="vmTime"></div>
<div id="vmNotes" style="margin-top:0.3rem;"></div> <div id="vmNotes" style="margin-top:0.3rem;"></div>
</div> </div>
<div id="vmStatus" style="margin-bottom:0.75rem;"></div> <div id="vmStatus" style="margin-bottom:1rem;"></div>
<div class="actions" id="vmActions"></div> <a id="vmEditLink" href="#" class="button is-primary is-fullwidth" style="font-size:1rem;font-weight:700;padding:0.65rem;">📋 {{ t.schedule_edit_title }}</a>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
<a id="vmEditLink" href="#" class="button is-info is-small" style="flex:1;">{{ t.schedule_edit_title }}</a>
<button class="button is-light is-small" style="flex:1;" onclick="closeModal()">OK</button>
</div>
</div> </div>
</div> </div>
@@ -89,6 +87,11 @@ document.addEventListener('DOMContentLoaded', function() {
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek' right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
}, },
events: '/admin/schedule/events', events: '/admin/schedule/events',
eventDidMount: function(info) {
var status = info.event.extendedProps.status;
if (status === 'completed') info.el.classList.add('ev-completed');
if (status === 'cancelled') info.el.classList.add('ev-cancelled');
},
eventClick: function(info) { eventClick: function(info) {
info.jsEvent.preventDefault(); info.jsEvent.preventDefault();
const ev = info.event; const ev = info.event;
@@ -102,12 +105,6 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('vmNotes').textContent = p.notes || ''; document.getElementById('vmNotes').textContent = p.notes || '';
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>'; const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
document.getElementById('vmStatus').innerHTML = badge; document.getElementById('vmStatus').innerHTML = badge;
let actions = '';
if (p.status === 'scheduled') {
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/done"><button class="button is-small is-success is-outlined">{{ t.schedule_mark_done }}</button></form>';
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/cancel"><button class="button is-small is-danger is-outlined">{{ t.schedule_cancel }}</button></form>';
}
document.getElementById('vmActions').innerHTML = actions;
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang; document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
document.getElementById('visitModal').classList.add('is-open'); document.getElementById('visitModal').classList.add('is-open');
}, },
+160 -23
View File
@@ -14,15 +14,7 @@
<div class="field"> <div class="field">
<label class="label">{{ t.schedule_client }}</label> <label class="label">{{ t.schedule_client }}</label>
<div class="control"> <div class="control">
<div class="select is-fullwidth"> <input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
<select name="client_id" required>
{% for c in &clients %}
<option value="{{ c.id }}" {% if c.id.unwrap() == visit.client_id.primary_key().unwrap() %}selected{% endif %}>
{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}
</option>
{% endfor %}
</select>
</div>
</div> </div>
</div> </div>
@@ -70,14 +62,17 @@
<!-- Status --> <!-- Status -->
<div class="field"> <div class="field">
<label class="label">{{ t.schedule_status }}</label> <label class="label">{{ t.schedule_status }}</label>
<div class="control"> <input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
<div class="select is-fullwidth"> <div class="status-picker">
<select name="status"> <button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option> <span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option> </button>
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option> <button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
</select> <span class="status-btn-icon"></span>{{ t.visit_status_completed }}
</div> </button>
<button type="button" class="status-btn status-btn-cancelled {% if visit.status == "cancelled" %}is-active{% endif %}" data-value="cancelled">
<span class="status-btn-icon"></span>{{ t.visit_status_cancelled }}
</button>
</div> </div>
</div> </div>
@@ -129,6 +124,9 @@
{% if let Some(cap) = m.caption.as_deref() %} {% if let Some(cap) = m.caption.as_deref() %}
<div class="media-cap">{{ cap }}</div> <div class="media-cap">{{ cap }}</div>
{% endif %} {% endif %}
<div class="visit-media-delete">
<button type="submit" form="visit-media-delete-{{ m.id.unwrap() }}" class="button is-small is-danger is-outlined">{{ t.media_delete }}</button>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -138,6 +136,9 @@
<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>
{% for m in &media %}
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
{% endfor %}
<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 }}');">
@@ -150,14 +151,15 @@
<div class="upload-modal"> <div class="upload-modal">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3> <h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
<button type="button" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')"></button> <button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;"></button>
</div> </div>
<form method="post" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data"> <form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
<div class="field"> <div class="field">
<label class="label">{{ t.media_choose_files }}</label> <label class="label">{{ t.media_choose_files }}</label>
<div class="control"> <div class="control">
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required> <input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
</div> </div>
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ t.media_caption }}</label> <label class="label">{{ t.media_caption }}</label>
@@ -165,12 +167,56 @@
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}"> <input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
</div> </div>
</div> </div>
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
<!-- Progress -->
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
<span id="uploadStatusText">{{ t.media_upload }}...</span>
<span id="uploadPercent">0%</span>
</div>
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
</div>
</div>
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
</form> </form>
</div> </div>
</div> </div>
<style> <style>
.status-picker {
display: flex;
gap: 0.5rem;
}
.status-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.6rem 0.4rem;
border-radius: 10px;
border: 2px solid transparent;
background: #f5f5f5;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
color: #777;
transition: all 0.15s;
line-height: 1.2;
}
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
.status-btn:hover { filter: brightness(0.95); }
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
.visit-media-grid { .visit-media-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
@@ -206,6 +252,14 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.visit-media-delete {
padding: 0.25rem 0.4rem 0.4rem;
}
.visit-media-delete .button {
width: 100%;
font-size: 0.68rem;
min-height: 1.65rem;
}
.upload-modal-bg { .upload-modal-bg {
display: none; display: none;
position: fixed; position: fixed;
@@ -229,8 +283,91 @@
</style> </style>
<script> <script>
document.getElementById('uploadModal').addEventListener('click', function(e) { // Status picker
if (e.target === this) this.classList.remove('is-open'); document.querySelectorAll('.status-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
btn.classList.add('is-active');
document.getElementById('statusInput').value = btn.dataset.value;
});
}); });
</script> </script>
<script>
(function() {
var modal = document.getElementById('uploadModal');
var form = document.getElementById('uploadForm');
var filesInput = document.getElementById('uploadFiles');
var fileCount = document.getElementById('fileCount');
var progress = document.getElementById('uploadProgress');
var bar = document.getElementById('uploadBar');
var percent = document.getElementById('uploadPercent');
var statusText = document.getElementById('uploadStatusText');
var submitBtn = document.getElementById('uploadSubmit');
// Close modal on backdrop click
modal.addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.getElementById('uploadModalClose').addEventListener('click', closeModal);
function closeModal() {
if (submitBtn.disabled) return; // prevent close during upload
modal.classList.remove('is-open');
}
// Show selected file count
filesInput.addEventListener('change', function() {
var n = this.files.length;
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
});
// Submit via XHR for progress tracking
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!filesInput.files.length) return;
var data = new FormData(form);
var xhr = new XMLHttpRequest();
// Show progress bar, disable submit
progress.style.display = 'block';
submitBtn.disabled = true;
submitBtn.textContent = 'Загрузка...';
bar.style.width = '0%';
percent.textContent = '0%';
xhr.upload.addEventListener('progress', function(ev) {
if (!ev.lengthComputable) return;
var pct = Math.round(ev.loaded / ev.total * 100);
bar.style.width = pct + '%';
percent.textContent = pct + '%';
if (pct === 100) statusText.textContent = 'Обработка...';
});
xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 400) {
bar.style.width = '100%';
percent.textContent = '100%';
statusText.textContent = 'Готово!';
// Reload page to show uploaded media
setTimeout(function() { window.location.reload(); }, 300);
} else {
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}';
}
});
xhr.addEventListener('error', function() {
statusText.textContent = 'Ошибка соединения';
submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}';
});
xhr.open('POST', form.action);
xhr.send(data);
});
})();
</script>
{% endblock %} {% endblock %}
+303 -120
View File
@@ -9,7 +9,8 @@
</div> </div>
<div class="form-card"> <div class="form-card">
<form method="post" action="/admin/schedule/create" id="visitForm"> <form method="post" action="/admin/schedule/create" id="visitForm">
<!-- Client --> <!-- Client -->
<div class="field"> <div class="field">
<label class="label">{{ t.schedule_client }}</label> <label class="label">{{ t.schedule_client }}</label>
@@ -44,57 +45,40 @@
<!-- Default time --> <!-- Default time -->
<div class="field"> <div class="field">
<label class="label">{{ t.schedule_default_time }}</label> <label class="label">{{ t.schedule_default_time }}</label>
<div class="columns is-mobile" style="margin-bottom:0;"> <div class="time-row">
<div class="column"> <div class="time-block">
<div class="control"> <span class="time-lbl">{{ t.schedule_time_start }}</span>
<input class="input" type="time" id="defaultStart" value="18:00"> <input class="input" type="time" id="defaultStart" value="18:00">
</div> </div>
</div> <div class="time-sep"></div>
<div class="column"> <div class="time-block">
<div class="control"> <span class="time-lbl">{{ t.schedule_time_end }}</span>
<input class="input" type="time" id="defaultEnd" value="19:00"> <input class="input" type="time" id="defaultEnd" value="19:00">
</div> </div>
</div> </div>
</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> </div>
<!-- Add individual date --> <div class="sched-cal">
<div class="field"> <div class="cal-nav">
<label class="label">{{ t.schedule_pick_dates }}</label> <button type="button" id="calPrev"></button>
<div class="columns is-mobile" style="margin-bottom:0;"> <span id="calTitle"></span>
<div class="column"> <button type="button" id="calNext"></button>
<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>
<div class="cal-grid" id="calGrid"></div>
</div> </div>
</div> </div>
<!-- Date range fill --> <!-- Selected days -->
<div class="field"> <div class="field" id="selectedSection" style="display:none;">
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label> <label class="label">{{ t.schedule_selected_days }} <span id="selectedCount" class="tag is-info is-light" style="margin-left:0.4rem;"></span></label>
<div class="columns is-mobile" style="margin-bottom:0;"> <div id="daysList"></div>
<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>
</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>
</div> </div>
<!-- Notes --> <!-- Notes -->
@@ -105,29 +89,114 @@
</div> </div>
</div> </div>
<!-- Hidden days data -->
<input type="hidden" name="days_json" id="daysJson" value="[]"> <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> <button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
</form>
</form>
</div> </div>
<style> <style>
.day-row { /* Time row */
display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0; .time-row { display:flex; align-items:center; gap:0.5rem; }
border-bottom: 1px solid #f0f0f0; flex-wrap: wrap; .time-block { display:flex; flex-direction:column; flex:1; }
} .time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
.day-row .day-date { font-weight: 600; min-width: 6rem; font-size: 0.9rem; } .time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
.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; } /* 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> </style>
<script> <script>
const days = new Map(); // date string -> {start, end} (function() {
const removeLabel = '{{ t.schedule_remove_day }}';
const weekdays = '{{ lang.code() }}' === 'ru' const IS_RU = '{{ lang.code() }}' === 'ru';
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'] const TZ = '{{ timezone }}';
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; 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() { function getDefaults() {
return { return {
@@ -136,111 +205,225 @@ function getDefaults() {
}; };
} }
function addDay(dateStr) { function addDay(ds) {
if (!dateStr || days.has(dateStr)) return; if (!ds || days.has(ds)) return;
const def = getDefaults(); const def = getDefaults();
days.set(dateStr, { start: def.start, end: def.end }); days.set(ds, { start: def.start, end: def.end });
renderDays();
} }
function removeDay(dateStr) { function toggleDay(ds) {
days.delete(dateStr); if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
renderDays();
} }
function renderDays() { // Получить текущую дату в нужном 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;
}
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 list = document.getElementById('daysList');
const msg = document.getElementById('noDaysMsg'); const section = document.getElementById('selectedSection');
const count = document.getElementById('selectedCount');
const btn = document.getElementById('submitBtn'); const btn = document.getElementById('submitBtn');
// Remove old day rows list.innerHTML = '';
list.querySelectorAll('.day-row').forEach(el => el.remove());
const resetBtn = document.getElementById('resetBtn');
if (days.size === 0) { if (days.size === 0) {
msg.style.display = ''; section.style.display = 'none';
btn.disabled = true; btn.disabled = true;
document.getElementById('daysJson').value = '[]'; document.getElementById('daysJson').value = '[]';
resetBtn.style.display = 'none';
return; return;
} }
msg.style.display = 'none'; resetBtn.style.display = '';
section.style.display = '';
btn.disabled = false; 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]) => { sorted.forEach(function([ds, times]) {
const d = new Date(dateStr + 'T00:00:00'); const d = new Date(ds + 'T00:00:00');
const wd = weekdays[d.getDay()]; const dow = IS_RU
const label = dateStr.split('-').reverse().join('.') + ' ' + wd; ? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][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'); const row = document.createElement('div');
row.className = 'day-row'; row.className = 'day-row';
row.innerHTML = ` row.innerHTML =
<span class="day-date">${label}</span> '<span class="day-date">' + label + '</span>' +
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start"> '<div class="day-times">' +
<span>—</span> '<div class="time-badge-wrap">' +
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end"> '<span class="time-badge-label">' + times.start + '</span>' +
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button> '<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); list.appendChild(row);
}); });
// Update hidden JSON list.querySelectorAll('.time-badge-input').forEach(function(inp) {
updateJson();
// Bind events
list.querySelectorAll('input[type="time"]').forEach(inp => {
inp.addEventListener('change', function() { inp.addEventListener('change', function() {
const dt = this.dataset.date; const d = days.get(this.dataset.date);
const field = this.dataset.field; if (d) {
if (days.has(dt)) { d[this.dataset.field] = this.value;
days.get(dt)[field] = this.value; this.previousElementSibling.textContent = this.value;
updateJson(); updateJson();
} }
}); });
}); });
list.querySelectorAll('.remove-btn').forEach(btn => { list.querySelectorAll('.day-rm').forEach(function(b) {
btn.addEventListener('click', function() { b.addEventListener('click', function() {
removeDay(this.dataset.date); days.delete(this.dataset.date);
renderCal();
renderList();
}); });
}); });
updateJson();
} }
function updateJson() { function updateJson() {
const arr = [...days.entries()].map(([date, t]) => ({ const arr = [...days.entries()].map(function([date, t]) {
date: date, return { date: date, time_start: t.start, time_end: t.end };
time_start: t.start, });
time_end: t.end
}));
document.getElementById('daysJson').value = JSON.stringify(arr); document.getElementById('daysJson').value = JSON.stringify(arr);
} }
document.getElementById('addDateBtn').addEventListener('click', function() { // ── Navigation ───────────────────────────────────────────
const v = document.getElementById('pickDate').value; document.getElementById('calPrev').addEventListener('click', function() {
addDay(v); viewMonth--;
document.getElementById('pickDate').value = ''; 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) { // ── Range mode toggle ────────────────────────────────────
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); } document.getElementById('rangeModeBtn').addEventListener('click', function() {
}); rangeMode = !rangeMode;
rangeStart = null;
document.getElementById('fillRangeBtn').addEventListener('click', function() { if (rangeMode) {
const from = document.getElementById('rangeFrom').value; this.classList.remove('is-outlined', 'is-info');
const to = document.getElementById('rangeTo').value; this.classList.add('is-warning');
if (!from || !to || from > to) return; this.textContent = '✕ Выбрать отдельные дни';
let cur = new Date(from + 'T00:00:00'); } else {
const end = new Date(to + 'T00:00:00'); this.classList.remove('is-warning');
while (cur <= end) { this.classList.add('is-outlined', 'is-info');
const ds = cur.toISOString().slice(0, 10); this.textContent = '⇔ Выбрать диапазон';
addDay(ds);
cur.setDate(cur.getDate() + 1);
} }
document.getElementById('rangeFrom').value = ''; renderCal();
document.getElementById('rangeTo').value = '';
}); });
// Set default pick date to today document.getElementById('resetBtn').addEventListener('click', function() {
document.getElementById('pickDate').valueAsDate = new Date(); 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> </script>
{% endblock %} {% endblock %}
+129 -13
View File
@@ -14,18 +14,8 @@
<div class="form-card"> <div class="form-card">
<form method="post" action="/admin/settings/save"> <form method="post" action="/admin/settings/save">
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label> <h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
<div class="control">
<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"> <div class="field">
<label class="label">{{ t.settings_contact_info }}</label> <label class="label">{{ t.settings_contact_info }}</label>
<div class="control"> <div class="control">
@@ -38,6 +28,23 @@
<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_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>
</div>
<details style="margin-top:1.5rem;">
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
{{ t.settings_section_advanced }}
</summary>
<div style="margin-top:1rem;">
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
<div class="field"> <div class="field">
<label class="label">{{ t.settings_site_domain }}</label> <label class="label">{{ t.settings_site_domain }}</label>
<div class="control"> <div class="control">
@@ -50,7 +57,116 @@
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}"> <input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
</div> </div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
<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>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
<div class="field">
<label class="label">{{ t.settings_turnstile_site_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
<div class="field">
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
<div class="control">
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_id }}</label>
<div class="control">
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_secret }}</label>
<div class="control">
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
<div class="control">
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
{{ t.settings_auth_password_enabled }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
{{ t.settings_auth_sso_enabled }}
</label>
</div>
</div>
</details>
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
</form> </form>
</div> </div>
<script>
(function() {
var COLORS = [
'rgba(124,108,255,0.18)',
'rgba(255,82,135,0.15)',
'rgba(255,140,38,0.18)',
'rgba(0,180,150,0.15)',
'rgba(77,166,255,0.18)',
'rgba(255,179,64,0.18)',
'rgba(176,108,255,0.16)',
'rgba(34,180,130,0.16)',
];
var ta = document.getElementById('seoKeywordsInput');
var preview = document.getElementById('seoPreview');
function esc(s) {
return s.replace(/&/g,'&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 %} {% endblock %}
+23
View File
@@ -17,6 +17,29 @@
<div class="item-card-meta"> <div class="item-card-meta">
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span> <span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
</div> </div>
{% if user.status == "active" %}
<form method="post" action="/admin/users/{{ user.id }}/telegram" style="margin-top:0.5rem; padding-top:0.5rem; border-top:1px solid #eee;">
<div class="columns is-mobile is-vcentered" style="margin-bottom:0;">
<div class="column">
<div class="field" style="margin-bottom:0;">
<label class="label is-small">{{ t.users_telegram_chat_id }}</label>
<div class="control">
<input class="input is-small" type="text" name="telegram_chat_id" placeholder="123456789" value="{{ user.telegram_chat_id.as_deref().unwrap_or_default() }}">
</div>
</div>
</div>
<div class="column is-narrow">
<label class="checkbox is-small">
<input type="checkbox" name="telegram_notifications" value="true" {% if user.telegram_notifications == Some(true) %}checked{% endif %}>
{{ t.users_telegram_enabled }}
</label>
</div>
<div class="column is-narrow">
<button type="submit" class="button is-small is-info is-outlined">💾</button>
</div>
</div>
</form>
{% endif %}
<div class="item-card-actions"> <div class="item-card-actions">
{% if user.status == "active" %} {% if user.status == "active" %}
<form method="post" action="/admin/users/{{ user.id }}/archive"> <form method="post" action="/admin/users/{{ user.id }}/archive">
+9
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.portal_title }} — {{ client.name }}</title> <title>{{ t.portal_title }} — {{ client.name }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style> <style>
:root { color-scheme: light; } :root { color-scheme: light; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -166,6 +169,9 @@
</div> </div>
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback"> <form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" required>{{ fb }}</textarea> <textarea name="feedback" required>{{ fb }}</textarea>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
{% endif %}
<div style="display:flex;gap:0.4rem;"> <div style="display:flex;gap:0.4rem;">
<button type="submit">{{ t.portal_feedback_submit }}</button> <button type="submit">{{ t.portal_feedback_submit }}</button>
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})"></button> <button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})"></button>
@@ -174,6 +180,9 @@
{% else %} {% else %}
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback"> <form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea> <textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
{% endif %}
<button type="submit">{{ t.portal_feedback_submit }}</button> <button type="submit">{{ t.portal_feedback_submit }}</button>
</form> </form>
{% endif %} {% endif %}
+19 -1
View File
@@ -21,6 +21,10 @@
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}"> <meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
<meta property="og:site_name" content="{{ t.nav_title }}"> <meta property="og:site_name" content="{{ t.nav_title }}">
{% if !seo_keywords.is_empty() %}
<meta name="keywords" content="{{ seo_keywords }}">
{% endif %}
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}"> <meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
@@ -46,6 +50,10 @@
} }
</script> </script>
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style> <style>
/* ── Reset & Base ── */ /* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -301,10 +309,14 @@
border-top: 1px solid rgba(180,170,220,0.15); border-top: 1px solid rgba(180,170,220,0.15);
background: rgba(255,255,255,0.3); background: rgba(255,255,255,0.3);
} }
.seo-keywords {
font-size: 0.72rem; color: #aaa; line-height: 2;
max-width: 800px; margin: 0 auto 1rem;
}
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 600px) { @media (max-width: 600px) {
.hero { padding: 6.5rem 1rem 3rem; } .hero { padding: 6.5rem 1rem 10rem; }
.section { padding: 3rem 1rem; } .section { padding: 3rem 1rem; }
.form-section { padding: 3rem 1rem; } .form-section { padding: 3rem 1rem; }
.form-wrapper { padding: 1.75rem 1.25rem; } .form-wrapper { padding: 1.75rem 1.25rem; }
@@ -456,6 +468,9 @@
<input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;"> <input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;">
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label> <label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
</div> </div>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-bottom:1.25rem;"></div>
{% endif %}
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button> <button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
</form> </form>
{% if !contact_info.is_empty() %} {% if !contact_info.is_empty() %}
@@ -469,6 +484,9 @@
<!-- Footer --> <!-- Footer -->
<footer class="site-footer"> <footer class="site-footer">
{% if !seo_keywords.is_empty() %}
<p class="seo-keywords" id="seoKeywords">{{ seo_keywords }}</p>
{% endif %}
<p>{{ t.landing_footer_text }}</p> <p>{{ t.landing_footer_text }}</p>
<p style="margin-top:0.4rem;">&copy; 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p> <p style="margin-top:0.4rem;">&copy; 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
</footer> </footer>