This commit is contained in:
+1711
File diff suppressed because it is too large
Load Diff
+637
@@ -0,0 +1,637 @@
|
||||
/// Supported languages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Lang {
|
||||
Ru,
|
||||
En,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Lang::Ru => "ru",
|
||||
Lang::En => "en",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
match code {
|
||||
"ru" => Some(Lang::Ru),
|
||||
"en" => Some(Lang::En),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Accept-Language header and pick best match.
|
||||
pub fn from_accept_language(header: &str) -> Self {
|
||||
for part in header.split(',') {
|
||||
let tag = part.split(';').next().unwrap_or("").trim().to_lowercase();
|
||||
if tag.starts_with("ru") {
|
||||
return Lang::Ru;
|
||||
}
|
||||
if tag.starts_with("en") {
|
||||
return Lang::En;
|
||||
}
|
||||
}
|
||||
Lang::En
|
||||
}
|
||||
|
||||
pub fn t(self) -> &'static Translations {
|
||||
match self {
|
||||
Lang::Ru => &RU,
|
||||
Lang::En => &EN,
|
||||
}
|
||||
}
|
||||
|
||||
/// The other language (for the switcher).
|
||||
pub fn other(self) -> Self {
|
||||
match self {
|
||||
Lang::Ru => Lang::En,
|
||||
Lang::En => Lang::Ru,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Lang::Ru => "Русский",
|
||||
Lang::En => "English",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct Translations {
|
||||
// Nav
|
||||
pub nav_leads: &'static str,
|
||||
pub nav_clients: &'static str,
|
||||
pub nav_visits: &'static str,
|
||||
pub nav_users: &'static str,
|
||||
pub nav_settings: &'static str,
|
||||
pub nav_title: &'static str,
|
||||
|
||||
// Leads
|
||||
pub leads_title: &'static str,
|
||||
pub leads_empty: &'static str,
|
||||
pub leads_name: &'static str,
|
||||
pub leads_phone: &'static str,
|
||||
pub leads_email: &'static str,
|
||||
pub leads_comment: &'static str,
|
||||
pub leads_status: &'static str,
|
||||
pub leads_created: &'static str,
|
||||
pub leads_actions: &'static str,
|
||||
|
||||
// Lead statuses
|
||||
pub status_new: &'static str,
|
||||
pub status_in_progress: &'static str,
|
||||
pub status_converted: &'static str,
|
||||
pub status_rejected: &'static str,
|
||||
|
||||
// Leads (add)
|
||||
pub leads_add_title: &'static str,
|
||||
pub leads_add_button: &'static str,
|
||||
|
||||
// Clients
|
||||
pub clients_title: &'static str,
|
||||
pub clients_empty: &'static str,
|
||||
pub clients_name: &'static str,
|
||||
pub clients_phone: &'static str,
|
||||
pub clients_email: &'static str,
|
||||
pub clients_address: &'static str,
|
||||
pub clients_notes: &'static str,
|
||||
pub clients_status: &'static str,
|
||||
pub clients_created: &'static str,
|
||||
pub clients_media_link: &'static str,
|
||||
pub clients_add_title: &'static str,
|
||||
pub clients_add_button: &'static str,
|
||||
pub client_status_active: &'static str,
|
||||
pub client_status_archived: &'static str,
|
||||
|
||||
// Users
|
||||
pub users_title: &'static str,
|
||||
pub users_login: &'static str,
|
||||
pub users_display_name: &'static str,
|
||||
pub users_status: &'static str,
|
||||
pub users_created: &'static str,
|
||||
pub users_password: &'static str,
|
||||
pub users_password_confirm: &'static str,
|
||||
pub users_add_title: &'static str,
|
||||
pub users_add_button: &'static str,
|
||||
pub users_error_passwords_mismatch: &'static str,
|
||||
pub users_error_login_taken: &'static str,
|
||||
|
||||
// Settings
|
||||
pub settings_title: &'static str,
|
||||
pub settings_key: &'static str,
|
||||
pub settings_value: &'static str,
|
||||
pub settings_save: &'static str,
|
||||
pub settings_saved: &'static str,
|
||||
pub settings_empty: &'static str,
|
||||
pub settings_telegram_bot_token: &'static str,
|
||||
pub settings_telegram_chat_id: &'static str,
|
||||
pub settings_contact_info: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
|
||||
// Dashboard
|
||||
pub dashboard_title: &'static str,
|
||||
pub dashboard_today_visits: &'static str,
|
||||
pub dashboard_no_visits: &'static str,
|
||||
pub dashboard_recent_feedbacks: &'static str,
|
||||
pub dashboard_no_feedbacks: &'static str,
|
||||
|
||||
// Login / Setup
|
||||
pub login_title: &'static str,
|
||||
pub login_button: &'static str,
|
||||
pub login_error: &'static str,
|
||||
pub logout: &'static str,
|
||||
pub setup_title: &'static str,
|
||||
pub setup_description: &'static str,
|
||||
pub setup_button: &'static str,
|
||||
|
||||
// Landing page
|
||||
pub landing_meta_description: &'static str,
|
||||
pub landing_hero_title: &'static str,
|
||||
pub landing_hero_subtitle: &'static str,
|
||||
pub landing_hero_cta: &'static str,
|
||||
pub landing_services_title: &'static str,
|
||||
pub landing_service_cats_title: &'static str,
|
||||
pub landing_service_cats_text: &'static str,
|
||||
pub landing_service_dogs_title: &'static str,
|
||||
pub landing_service_dogs_text: &'static str,
|
||||
pub landing_service_home_title: &'static str,
|
||||
pub landing_service_home_text: &'static str,
|
||||
pub landing_how_title: &'static str,
|
||||
pub landing_how_step1_title: &'static str,
|
||||
pub landing_how_step1_text: &'static str,
|
||||
pub landing_how_step2_title: &'static str,
|
||||
pub landing_how_step2_text: &'static str,
|
||||
pub landing_how_step3_title: &'static str,
|
||||
pub landing_how_step3_text: &'static str,
|
||||
pub landing_form_title: &'static str,
|
||||
pub landing_form_subtitle: &'static str,
|
||||
pub landing_form_name: &'static str,
|
||||
pub landing_form_phone: &'static str,
|
||||
pub landing_form_comment: &'static str,
|
||||
pub landing_form_comment_placeholder: &'static str,
|
||||
pub landing_form_submit: &'static str,
|
||||
pub landing_thank_you_title: &'static str,
|
||||
pub landing_thank_you_text: &'static str,
|
||||
pub landing_thank_you_back: &'static str,
|
||||
pub landing_footer_text: &'static str,
|
||||
|
||||
// Client edit
|
||||
pub clients_edit_title: &'static str,
|
||||
pub clients_save: &'static str,
|
||||
pub clients_color: &'static str,
|
||||
|
||||
// Filters
|
||||
pub filter_show_all: &'static str,
|
||||
pub filter_show_active: &'static str,
|
||||
|
||||
// Schedule
|
||||
pub nav_schedule: &'static str,
|
||||
pub schedule_title: &'static str,
|
||||
pub schedule_new: &'static str,
|
||||
pub schedule_new_title: &'static str,
|
||||
pub schedule_client: &'static str,
|
||||
pub schedule_admin: &'static str,
|
||||
pub schedule_default_time: &'static str,
|
||||
pub schedule_time_start: &'static str,
|
||||
pub schedule_time_end: &'static str,
|
||||
pub schedule_pick_dates: &'static str,
|
||||
pub schedule_range_from: &'static str,
|
||||
pub schedule_range_to: &'static str,
|
||||
pub schedule_fill_range: &'static str,
|
||||
pub schedule_selected_days: &'static str,
|
||||
pub schedule_no_days: &'static str,
|
||||
pub schedule_notes: &'static str,
|
||||
pub schedule_public_notes: &'static str,
|
||||
pub schedule_client_feedback: &'static str,
|
||||
pub schedule_create: &'static str,
|
||||
pub schedule_remove_day: &'static str,
|
||||
pub visit_status_scheduled: &'static str,
|
||||
pub visit_status_completed: &'static str,
|
||||
pub visit_status_cancelled: &'static str,
|
||||
pub schedule_mark_done: &'static str,
|
||||
pub schedule_cancel: &'static str,
|
||||
pub schedule_edit_title: &'static str,
|
||||
pub schedule_date: &'static str,
|
||||
pub schedule_status: &'static str,
|
||||
pub schedule_save: &'static str,
|
||||
pub schedule_delete: &'static str,
|
||||
pub schedule_delete_confirm: &'static str,
|
||||
|
||||
// Media
|
||||
pub nav_media: &'static str,
|
||||
pub media_title: &'static str,
|
||||
pub media_upload: &'static str,
|
||||
pub media_upload_title: &'static str,
|
||||
pub media_caption: &'static str,
|
||||
pub media_choose_files: &'static str,
|
||||
pub media_empty: &'static str,
|
||||
pub media_delete: &'static str,
|
||||
pub media_delete_confirm: &'static str,
|
||||
pub media_all_clients: &'static str,
|
||||
|
||||
// Client portal
|
||||
pub portal_title: &'static str,
|
||||
pub portal_upcoming: &'static str,
|
||||
pub portal_past: &'static str,
|
||||
pub portal_no_upcoming: &'static str,
|
||||
pub portal_no_past: &'static str,
|
||||
pub portal_photos: &'static str,
|
||||
pub portal_feedback_placeholder: &'static str,
|
||||
pub portal_feedback_submit: &'static str,
|
||||
pub portal_feedback_thanks: &'static str,
|
||||
pub portal_link: &'static str,
|
||||
|
||||
// Common
|
||||
pub no_value: &'static str,
|
||||
pub action_convert: &'static str,
|
||||
pub action_reject: &'static str,
|
||||
pub action_in_progress: &'static str,
|
||||
pub action_archive: &'static str,
|
||||
pub action_activate: &'static str,
|
||||
}
|
||||
|
||||
static RU: Translations = Translations {
|
||||
nav_leads: "Заявки",
|
||||
nav_clients: "Клиенты",
|
||||
nav_visits: "Визиты",
|
||||
nav_users: "Админы",
|
||||
nav_settings: "Настройки",
|
||||
nav_title: "Пет-ситтинг",
|
||||
|
||||
leads_title: "Заявки",
|
||||
leads_empty: "Заявок пока нет.",
|
||||
leads_name: "Имя",
|
||||
leads_phone: "Телефон",
|
||||
leads_email: "Email",
|
||||
leads_comment: "Комментарий",
|
||||
leads_status: "Статус",
|
||||
leads_created: "Создана",
|
||||
leads_actions: "Действия",
|
||||
|
||||
status_new: "Новая",
|
||||
status_in_progress: "В работе",
|
||||
status_converted: "Конвертирована",
|
||||
status_rejected: "Отклонена",
|
||||
|
||||
leads_add_title: "Добавить заявку",
|
||||
leads_add_button: "Добавить",
|
||||
|
||||
clients_title: "Клиенты",
|
||||
clients_empty: "Клиентов пока нет.",
|
||||
clients_name: "Имя",
|
||||
clients_phone: "Телефон",
|
||||
clients_email: "Email",
|
||||
clients_address: "Адрес",
|
||||
clients_notes: "Заметки",
|
||||
clients_status: "Статус",
|
||||
clients_created: "Добавлен",
|
||||
clients_media_link: "Медиа",
|
||||
clients_add_title: "Добавить клиента",
|
||||
clients_add_button: "Добавить",
|
||||
client_status_active: "Активный",
|
||||
client_status_archived: "Архив",
|
||||
|
||||
users_title: "Администраторы",
|
||||
users_login: "Логин",
|
||||
users_display_name: "Отображаемое имя",
|
||||
users_status: "Статус",
|
||||
users_created: "Создан",
|
||||
users_password: "Пароль",
|
||||
users_password_confirm: "Подтверждение пароля",
|
||||
users_add_title: "Добавить администратора",
|
||||
users_add_button: "Добавить",
|
||||
users_error_passwords_mismatch: "Пароли не совпадают.",
|
||||
users_error_login_taken: "Этот логин уже занят.",
|
||||
|
||||
settings_title: "Настройки",
|
||||
settings_key: "Параметр",
|
||||
settings_value: "Значение",
|
||||
settings_save: "Сохранить",
|
||||
settings_saved: "Сохранено!",
|
||||
settings_empty: "Настройки не заданы.",
|
||||
settings_telegram_bot_token: "Токен Telegram бота",
|
||||
settings_telegram_chat_id: "Chat ID для уведомлений",
|
||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
|
||||
dashboard_title: "Главная",
|
||||
dashboard_today_visits: "Визиты на сегодня",
|
||||
dashboard_no_visits: "На сегодня визитов нет.",
|
||||
dashboard_recent_feedbacks: "Недавние отзывы клиентов",
|
||||
dashboard_no_feedbacks: "Новых отзывов нет.",
|
||||
|
||||
nav_media: "Медиа",
|
||||
media_title: "Медиа",
|
||||
media_upload: "Загрузить",
|
||||
media_upload_title: "Загрузить медиа",
|
||||
media_caption: "Подпись",
|
||||
media_choose_files: "Выберите файлы",
|
||||
media_empty: "Медиа нет.",
|
||||
media_delete: "Удалить",
|
||||
media_delete_confirm: "Удалить этот файл?",
|
||||
media_all_clients: "Все клиенты",
|
||||
|
||||
portal_title: "Мои визиты",
|
||||
portal_upcoming: "Предстоящие визиты",
|
||||
portal_past: "Прошлые визиты",
|
||||
portal_no_upcoming: "Нет предстоящих визитов.",
|
||||
portal_no_past: "Прошлых визитов пока нет.",
|
||||
portal_photos: "Фото и видео",
|
||||
portal_feedback_placeholder: "Оставьте отзыв о визите...",
|
||||
portal_feedback_submit: "Отправить",
|
||||
portal_feedback_thanks: "Спасибо за отзыв!",
|
||||
portal_link: "Ссылка клиента",
|
||||
|
||||
login_title: "Вход в систему",
|
||||
login_button: "Войти",
|
||||
login_error: "Неверный логин или пароль.",
|
||||
logout: "Выйти",
|
||||
setup_title: "Создание администратора",
|
||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||
setup_button: "Создать и войти",
|
||||
|
||||
clients_edit_title: "Редактировать клиента",
|
||||
clients_save: "Сохранить",
|
||||
clients_color: "Цвет в календаре",
|
||||
|
||||
filter_show_all: "Показать все",
|
||||
filter_show_active: "Только активные",
|
||||
|
||||
nav_schedule: "Расписание",
|
||||
schedule_title: "Расписание",
|
||||
schedule_new: "Новые визиты",
|
||||
schedule_new_title: "Запланировать визиты",
|
||||
schedule_client: "Клиент",
|
||||
schedule_admin: "Исполнитель",
|
||||
schedule_default_time: "Время по умолчанию",
|
||||
schedule_time_start: "С",
|
||||
schedule_time_end: "До",
|
||||
schedule_pick_dates: "Добавить дату",
|
||||
schedule_range_from: "С",
|
||||
schedule_range_to: "По",
|
||||
schedule_fill_range: "Заполнить диапазон",
|
||||
schedule_selected_days: "Выбранные дни",
|
||||
schedule_no_days: "Дни не выбраны",
|
||||
schedule_notes: "Приватные заметки",
|
||||
schedule_public_notes: "Комментарий для клиента",
|
||||
schedule_client_feedback: "Отзыв клиента",
|
||||
schedule_create: "Создать визиты",
|
||||
schedule_remove_day: "Убрать",
|
||||
visit_status_scheduled: "Запланирован",
|
||||
visit_status_completed: "Выполнен",
|
||||
visit_status_cancelled: "Отменён",
|
||||
schedule_mark_done: "Выполнен",
|
||||
schedule_cancel: "Отменить",
|
||||
schedule_edit_title: "Редактировать визит",
|
||||
schedule_date: "Дата",
|
||||
schedule_status: "Статус",
|
||||
schedule_save: "Сохранить",
|
||||
schedule_delete: "Удалить визит",
|
||||
schedule_delete_confirm: "Точно удалить этот визит?",
|
||||
|
||||
landing_meta_description: "Профессиональный пет-ситтинг: кормление кошек, выгул собак, уход за питомцами пока вы в отпуске. Оставьте заявку — позаботимся о вашем любимце!",
|
||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
|
||||
landing_hero_subtitle: "Кормление кошек, выгул собак, ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке",
|
||||
landing_hero_cta: "Оставить заявку",
|
||||
landing_services_title: "Наши услуги",
|
||||
landing_service_cats_title: "Кормление кошек",
|
||||
landing_service_cats_text: "Приедем к вам домой, покормим кошку, поменяем воду и лоток, поиграем и проверим, что всё в порядке",
|
||||
landing_service_dogs_title: "Выгул собак",
|
||||
landing_service_dogs_text: "Погуляем с вашей собакой по привычному маршруту, покормим и проследим за самочувствием питомца",
|
||||
landing_service_home_title: "Домашние визиты",
|
||||
landing_service_home_text: "Регулярные визиты к вам домой: проверим питомца, польём цветы, заберём почту — всё будет как при вас",
|
||||
landing_how_title: "Как это работает",
|
||||
landing_how_step1_title: "Оставьте заявку",
|
||||
landing_how_step1_text: "Заполните форму ниже — укажите имя и телефон. Мы свяжемся с вами в течение часа",
|
||||
landing_how_step2_title: "Обсудим детали",
|
||||
landing_how_step2_text: "Познакомимся с вашим питомцем, обсудим расписание визитов и особые пожелания",
|
||||
landing_how_step3_title: "Заботимся о питомце",
|
||||
landing_how_step3_text: "Пока вас нет — мы рядом. После каждого визита отправим фото и отчёт о самочувствии",
|
||||
landing_form_title: "Оставить заявку",
|
||||
landing_form_subtitle: "Расскажите о себе, и мы свяжемся с вами в ближайшее время",
|
||||
landing_form_name: "Ваше имя",
|
||||
landing_form_phone: "Телефон",
|
||||
landing_form_comment: "Комментарий",
|
||||
landing_form_comment_placeholder: "Расскажите о питомце и когда нужна помощь...",
|
||||
landing_form_submit: "Отправить заявку",
|
||||
landing_thank_you_title: "Спасибо за заявку!",
|
||||
landing_thank_you_text: "Мы получили вашу заявку и свяжемся с вами в ближайшее время.",
|
||||
landing_thank_you_back: "Вернуться на главную",
|
||||
landing_footer_text: "Пет-ситтинг — забота о вашем питомце",
|
||||
|
||||
no_value: "—",
|
||||
action_convert: "Конвертировать",
|
||||
action_reject: "Отклонить",
|
||||
action_in_progress: "В работу",
|
||||
action_archive: "В архив",
|
||||
action_activate: "Активировать",
|
||||
};
|
||||
|
||||
static EN: Translations = Translations {
|
||||
nav_leads: "Leads",
|
||||
nav_clients: "Clients",
|
||||
nav_visits: "Visits",
|
||||
nav_users: "Admins",
|
||||
nav_settings: "Settings",
|
||||
nav_title: "Pet Sitting",
|
||||
|
||||
leads_title: "Leads",
|
||||
leads_empty: "No leads yet.",
|
||||
leads_name: "Name",
|
||||
leads_phone: "Phone",
|
||||
leads_email: "Email",
|
||||
leads_comment: "Comment",
|
||||
leads_status: "Status",
|
||||
leads_created: "Created",
|
||||
leads_actions: "Actions",
|
||||
|
||||
status_new: "New",
|
||||
status_in_progress: "In Progress",
|
||||
status_converted: "Converted",
|
||||
status_rejected: "Rejected",
|
||||
|
||||
leads_add_title: "Add Lead",
|
||||
leads_add_button: "Add",
|
||||
|
||||
clients_title: "Clients",
|
||||
clients_empty: "No clients yet.",
|
||||
clients_name: "Name",
|
||||
clients_phone: "Phone",
|
||||
clients_email: "Email",
|
||||
clients_address: "Address",
|
||||
clients_notes: "Notes",
|
||||
clients_status: "Status",
|
||||
clients_created: "Created",
|
||||
clients_media_link: "Media",
|
||||
clients_add_title: "Add Client",
|
||||
clients_add_button: "Add",
|
||||
client_status_active: "Active",
|
||||
client_status_archived: "Archived",
|
||||
|
||||
users_title: "Administrators",
|
||||
users_login: "Login",
|
||||
users_display_name: "Display Name",
|
||||
users_status: "Status",
|
||||
users_created: "Created",
|
||||
users_password: "Password",
|
||||
users_password_confirm: "Confirm Password",
|
||||
users_add_title: "Add Administrator",
|
||||
users_add_button: "Add",
|
||||
users_error_passwords_mismatch: "Passwords do not match.",
|
||||
users_error_login_taken: "This login is already taken.",
|
||||
|
||||
settings_title: "Settings",
|
||||
settings_key: "Parameter",
|
||||
settings_value: "Value",
|
||||
settings_save: "Save",
|
||||
settings_saved: "Saved!",
|
||||
settings_empty: "No settings configured.",
|
||||
settings_telegram_bot_token: "Telegram Bot Token",
|
||||
settings_telegram_chat_id: "Notification Chat ID",
|
||||
settings_contact_info: "Contact info (shown on landing page)",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
|
||||
dashboard_title: "Home",
|
||||
dashboard_today_visits: "Today's visits",
|
||||
dashboard_no_visits: "No visits for today.",
|
||||
dashboard_recent_feedbacks: "Recent client feedback",
|
||||
dashboard_no_feedbacks: "No recent feedback.",
|
||||
|
||||
nav_media: "Media",
|
||||
media_title: "Media",
|
||||
media_upload: "Upload",
|
||||
media_upload_title: "Upload Media",
|
||||
media_caption: "Caption",
|
||||
media_choose_files: "Choose files",
|
||||
media_empty: "No media yet.",
|
||||
media_delete: "Delete",
|
||||
media_delete_confirm: "Delete this file?",
|
||||
media_all_clients: "All clients",
|
||||
|
||||
portal_title: "My Visits",
|
||||
portal_upcoming: "Upcoming visits",
|
||||
portal_past: "Past visits",
|
||||
portal_no_upcoming: "No upcoming visits.",
|
||||
portal_no_past: "No past visits yet.",
|
||||
portal_photos: "Photos & Videos",
|
||||
portal_feedback_placeholder: "Leave feedback about this visit...",
|
||||
portal_feedback_submit: "Submit",
|
||||
portal_feedback_thanks: "Thank you for your feedback!",
|
||||
portal_link: "Client link",
|
||||
|
||||
login_title: "Sign In",
|
||||
login_button: "Sign In",
|
||||
login_error: "Invalid login or password.",
|
||||
logout: "Sign Out",
|
||||
setup_title: "Create Administrator",
|
||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||
setup_button: "Create & Sign In",
|
||||
|
||||
clients_edit_title: "Edit Client",
|
||||
clients_save: "Save",
|
||||
clients_color: "Calendar color",
|
||||
|
||||
filter_show_all: "Show all",
|
||||
filter_show_active: "Active only",
|
||||
|
||||
nav_schedule: "Schedule",
|
||||
schedule_title: "Schedule",
|
||||
schedule_new: "New Visits",
|
||||
schedule_new_title: "Plan Visits",
|
||||
schedule_client: "Client",
|
||||
schedule_admin: "Assigned to",
|
||||
schedule_default_time: "Default Time",
|
||||
schedule_time_start: "From",
|
||||
schedule_time_end: "To",
|
||||
schedule_pick_dates: "Add date",
|
||||
schedule_range_from: "From",
|
||||
schedule_range_to: "To",
|
||||
schedule_fill_range: "Fill range",
|
||||
schedule_selected_days: "Selected days",
|
||||
schedule_no_days: "No days selected",
|
||||
schedule_notes: "Private notes",
|
||||
schedule_public_notes: "Note for client",
|
||||
schedule_client_feedback: "Client feedback",
|
||||
schedule_create: "Create visits",
|
||||
schedule_remove_day: "Remove",
|
||||
visit_status_scheduled: "Scheduled",
|
||||
visit_status_completed: "Completed",
|
||||
visit_status_cancelled: "Cancelled",
|
||||
schedule_mark_done: "Done",
|
||||
schedule_cancel: "Cancel",
|
||||
schedule_edit_title: "Edit Visit",
|
||||
schedule_date: "Date",
|
||||
schedule_status: "Status",
|
||||
schedule_save: "Save",
|
||||
schedule_delete: "Delete visit",
|
||||
schedule_delete_confirm: "Are you sure you want to delete this visit?",
|
||||
|
||||
landing_meta_description: "Professional pet sitting: cat feeding, dog walking, home visits while you're away. Leave a request — we'll take care of your pet!",
|
||||
landing_hero_title: "We'll take care of your pet while you're away",
|
||||
landing_hero_subtitle: "Cat feeding, dog walking, daily visits — your pet is in safe hands while you're on vacation or a business trip",
|
||||
landing_hero_cta: "Leave a Request",
|
||||
landing_services_title: "Our Services",
|
||||
landing_service_cats_title: "Cat Feeding",
|
||||
landing_service_cats_text: "We'll visit your home, feed the cat, change water and litter, play and make sure everything is fine",
|
||||
landing_service_dogs_title: "Dog Walking",
|
||||
landing_service_dogs_text: "We'll walk your dog on their usual route, feed them and keep an eye on their well-being",
|
||||
landing_service_home_title: "Home Visits",
|
||||
landing_service_home_text: "Regular home visits: check on your pet, water the plants, collect mail — everything as if you were home",
|
||||
landing_how_title: "How It Works",
|
||||
landing_how_step1_title: "Leave a Request",
|
||||
landing_how_step1_text: "Fill out the form below — just your name and phone. We'll contact you within an hour",
|
||||
landing_how_step2_title: "Discuss the Details",
|
||||
landing_how_step2_text: "We'll meet your pet, discuss the visit schedule and any special requirements",
|
||||
landing_how_step3_title: "We Take Care",
|
||||
landing_how_step3_text: "While you're away — we're here. After each visit we'll send photos and a wellness report",
|
||||
landing_form_title: "Leave a Request",
|
||||
landing_form_subtitle: "Tell us about yourself and we'll get back to you shortly",
|
||||
landing_form_name: "Your Name",
|
||||
landing_form_phone: "Phone",
|
||||
landing_form_comment: "Comment",
|
||||
landing_form_comment_placeholder: "Tell us about your pet and when you need help...",
|
||||
landing_form_submit: "Submit Request",
|
||||
landing_thank_you_title: "Thank you!",
|
||||
landing_thank_you_text: "We've received your request and will contact you shortly.",
|
||||
landing_thank_you_back: "Back to Home",
|
||||
landing_footer_text: "Pet Sitting — caring for your pet",
|
||||
|
||||
no_value: "—",
|
||||
action_convert: "Convert",
|
||||
action_reject: "Reject",
|
||||
action_in_progress: "In Progress",
|
||||
action_archive: "Archive",
|
||||
action_activate: "Activate",
|
||||
};
|
||||
|
||||
impl Translations {
|
||||
pub fn lead_status(&self, status: &str) -> &'static str {
|
||||
match status {
|
||||
"new" => self.status_new,
|
||||
"in_progress" => self.status_in_progress,
|
||||
"converted" => self.status_converted,
|
||||
"rejected" => self.status_rejected,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit_status(&self, status: &str) -> &'static str {
|
||||
match status {
|
||||
"scheduled" => self.visit_status_scheduled,
|
||||
"completed" => self.visit_status_completed,
|
||||
"cancelled" => self.visit_status_cancelled,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client_status(&self, status: &str) -> &'static str {
|
||||
match status {
|
||||
"active" => self.client_status_active,
|
||||
"archived" => self.client_status_archived,
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
mod admin;
|
||||
mod i18n;
|
||||
mod migrations;
|
||||
pub mod models;
|
||||
mod public;
|
||||
mod telegram;
|
||||
|
||||
use cot::cli::CliMetadata;
|
||||
use cot::config::{
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
||||
SessionStoreTypeConfig,
|
||||
};
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler};
|
||||
use cot::router::Router;
|
||||
use cot::session::db::SessionApp;
|
||||
use cot::{App, AppBuilder, Project};
|
||||
|
||||
struct PettingApp;
|
||||
|
||||
impl App for PettingApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"web-petting"
|
||||
}
|
||||
|
||||
fn migrations(&self) -> Vec<Box<SyncDynMigration>> {
|
||||
cot::db::migrations::wrap_migrations(migrations::MIGRATIONS)
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
admin::admin_router()
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicApp;
|
||||
|
||||
impl App for PublicApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"public"
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
public::public_router()
|
||||
}
|
||||
}
|
||||
|
||||
struct PettingProject;
|
||||
|
||||
impl Project for PettingProject {
|
||||
fn cli_metadata(&self) -> CliMetadata {
|
||||
cot::cli::metadata!()
|
||||
}
|
||||
|
||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
Ok(ProjectConfig::builder()
|
||||
.debug(true)
|
||||
.database(
|
||||
DatabaseConfig::builder()
|
||||
.url("sqlite://db.sqlite3?mode=rwc")
|
||||
.build(),
|
||||
)
|
||||
.middlewares(
|
||||
MiddlewareConfig::builder()
|
||||
.session(
|
||||
SessionMiddlewareConfig::builder()
|
||||
.secure(false)
|
||||
.store(
|
||||
SessionStoreConfig::builder()
|
||||
.store_type(SessionStoreTypeConfig::Database)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
|
||||
apps.register(SessionApp::new());
|
||||
apps.register_with_views(PublicApp, "");
|
||||
apps.register_with_views(PettingApp, "/admin");
|
||||
}
|
||||
|
||||
fn middlewares(
|
||||
&self,
|
||||
handler: cot::project::RootHandlerBuilder,
|
||||
context: &MiddlewareContext,
|
||||
) -> RootHandler {
|
||||
handler
|
||||
.middleware(SessionMiddleware::from_context(context))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
#[cot::main]
|
||||
fn main() -> impl Project {
|
||||
PettingProject
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! List of migrations for the current app.
|
||||
//!
|
||||
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
|
||||
|
||||
pub mod m_0001_initial;
|
||||
pub mod m_0002_visit_schedule;
|
||||
pub mod m_0003_visit_feedback;
|
||||
pub mod m_0004_visit_public_notes;
|
||||
/// The list of migrations for current app.
|
||||
pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
&m_0002_visit_schedule::Migration,
|
||||
&m_0003_visit_feedback::Migration,
|
||||
&m_0004_visit_public_notes::Migration,
|
||||
];
|
||||
@@ -0,0 +1,487 @@
|
||||
//! Generated by cot CLI 0.6.0 on 2026-04-29 10:36:47+00:00
|
||||
|
||||
#[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_0001_initial";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__user"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("login"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("password_hash"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("display_name"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__setting"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("key"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("value"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("address"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("media_token"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("scheduled_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("duration_minutes"),
|
||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__media"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<cot::db::ForeignKey<
|
||||
crate::models::Client,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_id"),
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Visit>,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Visit as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Visit as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Visit>,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_path"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("file_type"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("caption"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::create_model()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__lead"))
|
||||
.fields(
|
||||
&[
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("id"),
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.auto()
|
||||
.primary_key()
|
||||
.set_null(
|
||||
<cot::db::Auto<i64> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("name"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("phone"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("email"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("comment"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<Option<String> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("status"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_id"),
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Client>,
|
||||
> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::Client as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::Client as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(
|
||||
<Option<
|
||||
cot::db::ForeignKey<crate::models::Client>,
|
||||
> as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("created_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("updated_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::NULLABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Client {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
/// Unique token for the public media page (client views photos/videos here).
|
||||
#[model(unique)]
|
||||
pub media_token: String,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Lead {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
/// new | in_progress | converted | rejected
|
||||
pub status: String,
|
||||
pub client_id: Option<cot::db::ForeignKey<crate::models::Client>>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Media {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub client_id: cot::db::ForeignKey<crate::models::Client>,
|
||||
pub visit_id: Option<cot::db::ForeignKey<crate::models::Visit>>,
|
||||
pub file_path: String,
|
||||
/// photo | video
|
||||
pub file_type: String,
|
||||
pub caption: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Setting {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _User {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub login: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
#[derive(::core::fmt::Debug)]
|
||||
#[::cot::db::model(model_type = "migration")]
|
||||
struct _Visit {
|
||||
#[model(primary_key)]
|
||||
pub id: cot::db::Auto<i64>,
|
||||
pub client_id: cot::db::ForeignKey<crate::models::Client>,
|
||||
pub scheduled_at: chrono::NaiveDateTime,
|
||||
pub duration_minutes: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
/// scheduled | completed | cancelled
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Migration: update Visit model for scheduling + add Client.color
|
||||
//! Visit: Remove scheduled_at, duration_minutes; Add user_id, visit_date, time_start, time_end
|
||||
//! Client: Add color
|
||||
|
||||
#[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_0002_visit_schedule";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[
|
||||
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0001_initial"),
|
||||
];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
// Add color to client (nullable for existing rows)
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__client"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("color"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
// Remove old visit fields
|
||||
::cot::db::migrations::Operation::remove_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("scheduled_at"),
|
||||
<chrono::NaiveDateTime as ::cot::db::DatabaseField>::TYPE,
|
||||
))
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::remove_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("duration_minutes"),
|
||||
<Option<i32> as ::cot::db::DatabaseField>::TYPE,
|
||||
).set_null(<Option<i32> as ::cot::db::DatabaseField>::NULLABLE))
|
||||
.build(),
|
||||
// Add new fields
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("user_id"),
|
||||
<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.foreign_key(
|
||||
<crate::models::User as ::cot::db::Model>::TABLE_NAME,
|
||||
<crate::models::User as ::cot::db::Model>::PRIMARY_KEY_NAME,
|
||||
::cot::db::ForeignKeyOnDeletePolicy::Restrict,
|
||||
::cot::db::ForeignKeyOnUpdatePolicy::Restrict,
|
||||
)
|
||||
.set_null(<cot::db::ForeignKey<crate::models::User> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("visit_date"),
|
||||
<chrono::NaiveDate as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<chrono::NaiveDate as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_start"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("time_end"),
|
||||
<String as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Migration: add client_feedback to Visit
|
||||
|
||||
#[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_0003_visit_feedback";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[
|
||||
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0002_visit_schedule"),
|
||||
];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("client_feedback"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Migration: add public_notes to Visit
|
||||
|
||||
#[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_0004_visit_public_notes";
|
||||
const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[
|
||||
::cot::db::migrations::MigrationDependency::migration("web-petting", "m_0003_visit_feedback"),
|
||||
];
|
||||
const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[
|
||||
::cot::db::migrations::Operation::add_field()
|
||||
.table_name(::cot::db::Identifier::new("web_petting__visit"))
|
||||
.field(
|
||||
::cot::db::migrations::Field::new(
|
||||
::cot::db::Identifier::new("public_notes"),
|
||||
<Option<String> as ::cot::db::DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as ::cot::db::DatabaseField>::NULLABLE)
|
||||
)
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
use cot::db::{model, Auto, ForeignKey};
|
||||
|
||||
/// Lead status: new request from the website
|
||||
/// new -> in_progress -> converted | rejected
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LeadStatus {
|
||||
New,
|
||||
InProgress,
|
||||
Converted,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl LeadStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::New => "new",
|
||||
Self::InProgress => "in_progress",
|
||||
Self::Converted => "converted",
|
||||
Self::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client status
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientStatus {
|
||||
Active,
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl ClientStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Archived => "archived",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit status
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VisitStatus {
|
||||
Scheduled,
|
||||
Completed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl VisitStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Scheduled => "scheduled",
|
||||
Self::Completed => "completed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Media file type
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MediaType {
|
||||
Photo,
|
||||
Video,
|
||||
}
|
||||
|
||||
impl MediaType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Photo => "photo",
|
||||
Self::Video => "video",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User (admin) status
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserStatus {
|
||||
Active,
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl UserStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Archived => "archived",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A lead submitted via the public website form.
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct Lead {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
/// new | in_progress | converted | rejected
|
||||
pub status: String,
|
||||
pub client_id: Option<ForeignKey<Client>>,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// A confirmed client created from a lead (or manually).
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct Client {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub name: String,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
/// Unique token for the public media page (client views photos/videos here).
|
||||
#[model(unique)]
|
||||
pub media_token: String,
|
||||
/// Hex color for calendar display, e.g. "#7c6ed4"
|
||||
pub color: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// A scheduled pet-sitting visit.
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct Visit {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub client_id: ForeignKey<Client>,
|
||||
pub user_id: ForeignKey<User>,
|
||||
pub visit_date: chrono::NaiveDate,
|
||||
pub time_start: String,
|
||||
pub time_end: String,
|
||||
pub notes: Option<String>,
|
||||
/// Public notes visible to client on their portal.
|
||||
pub public_notes: Option<String>,
|
||||
/// Feedback text from client via portal.
|
||||
pub client_feedback: Option<String>,
|
||||
/// scheduled | completed | cancelled
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// A photo or video uploaded for a client (optionally tied to a visit).
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct Media {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub client_id: ForeignKey<Client>,
|
||||
pub visit_id: Option<ForeignKey<Visit>>,
|
||||
pub file_path: String,
|
||||
/// photo | video
|
||||
pub file_type: String,
|
||||
pub caption: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// An admin user who can log in to manage the system.
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct User {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub login: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
/// active | archived
|
||||
pub status: String,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// Global key-value settings (telegram_bot_token, telegram_chat_id, etc.).
|
||||
#[derive(Debug, Clone)]
|
||||
#[model]
|
||||
pub struct Setting {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
#[model(unique)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
use cot::db::{Auto, Database, Model};
|
||||
use cot::html::Html;
|
||||
use cot::request::Request;
|
||||
use cot::request::extractors::Path;
|
||||
use cot::response::{IntoResponse, Redirect, Response};
|
||||
use cot::router::{Route, Router};
|
||||
use cot::Template;
|
||||
use serde::Deserialize;
|
||||
|
||||
use cot::db::query;
|
||||
|
||||
use crate::i18n::{Lang, Translations};
|
||||
use crate::models::{Client, Lead, Media, Setting, User, Visit};
|
||||
use crate::telegram;
|
||||
|
||||
fn detect_lang(request: &Request) -> Lang {
|
||||
if let Some(q) = request.uri().query() {
|
||||
for pair in q.split('&') {
|
||||
if let Some(code) = pair.strip_prefix("lang=") {
|
||||
if let Some(lang) = Lang::from_code(code) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cookie) = request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
for part in cookie.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(code) = part.strip_prefix("lang=") {
|
||||
if let Some(lang) = Lang::from_code(code.trim()) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request
|
||||
.headers()
|
||||
.get("accept-language")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(Lang::from_accept_language)
|
||||
.unwrap_or(Lang::Ru)
|
||||
}
|
||||
|
||||
fn lang_cookie(lang: Lang) -> String {
|
||||
format!(
|
||||
"lang={}; Path=/; SameSite=Lax; Max-Age=31536000",
|
||||
lang.code()
|
||||
)
|
||||
}
|
||||
|
||||
fn html_response(body: String, lang: Lang) -> cot::Result<Response> {
|
||||
Html::new(body)
|
||||
.with_header("set-cookie", lang_cookie(lang))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn now() -> chrono::NaiveDateTime {
|
||||
chrono::Utc::now().naive_utc()
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "landing.html")]
|
||||
struct LandingTemplate<'a> {
|
||||
t: &'a Translations,
|
||||
lang: Lang,
|
||||
contact_info: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "thank_you.html")]
|
||||
struct ThankYouTemplate<'a> {
|
||||
t: &'a Translations,
|
||||
lang: Lang,
|
||||
}
|
||||
|
||||
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let key = "contact_info".to_string();
|
||||
let contact_info = query!(Setting, $key == key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_default();
|
||||
let body = LandingTemplate { t: lang.t(), lang, contact_info }.render()?;
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LeadForm {
|
||||
name: String,
|
||||
phone: Option<String>,
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let body = request.into_body();
|
||||
let bytes = body.into_bytes().await?;
|
||||
let form: LeadForm =
|
||||
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let mut lead = Lead {
|
||||
id: Auto::auto(),
|
||||
name: form.name,
|
||||
phone: form.phone.filter(|s| !s.trim().is_empty()),
|
||||
email: None,
|
||||
comment: form.comment.filter(|s| !s.trim().is_empty()),
|
||||
status: "new".to_string(),
|
||||
client_id: None,
|
||||
created_at: now(),
|
||||
updated_at: now(),
|
||||
};
|
||||
lead.save(&db).await?;
|
||||
|
||||
telegram::notify_new_lead(
|
||||
&db,
|
||||
&lead.name,
|
||||
lead.phone.as_deref(),
|
||||
lead.comment.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let rendered = ThankYouTemplate { t: lang.t(), lang }.render()?;
|
||||
html_response(rendered, lang)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client Portal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PortalVisit {
|
||||
visit: Visit,
|
||||
admin_name: String,
|
||||
media: Vec<Media>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "client_portal.html")]
|
||||
struct ClientPortalTemplate<'a> {
|
||||
t: &'a Translations,
|
||||
lang: Lang,
|
||||
client: Client,
|
||||
upcoming: Vec<PortalVisit>,
|
||||
past: Vec<PortalVisit>,
|
||||
feedback_sent: bool,
|
||||
}
|
||||
|
||||
async fn client_portal(
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path(token): Path<String>,
|
||||
) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let feedback_sent = request
|
||||
.uri()
|
||||
.query()
|
||||
.map(|q| q.split('&').any(|p| p == "feedback=ok"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
|
||||
let client_id = client.id.unwrap();
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
|
||||
let mut visits = Visit::objects().all(&db).await?;
|
||||
visits.retain(|v| {
|
||||
v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled"
|
||||
});
|
||||
visits.sort_by(|a, b| a.visit_date.cmp(&b.visit_date).then(a.time_start.cmp(&b.time_start)));
|
||||
|
||||
let users = User::objects().all(&db).await?;
|
||||
let all_media = Media::objects().all(&db).await?;
|
||||
|
||||
let build_portal_visit = |v: Visit| -> PortalVisit {
|
||||
let uid: i64 = v.user_id.primary_key().unwrap();
|
||||
let admin_name = users
|
||||
.iter()
|
||||
.find(|u| u.id.unwrap() == uid)
|
||||
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
|
||||
.unwrap_or_default();
|
||||
let vid = v.id.unwrap();
|
||||
let media: Vec<Media> = all_media
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.status == "active"
|
||||
&& m.visit_id
|
||||
.as_ref()
|
||||
.map(|fk| fk.primary_key().unwrap() == vid)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
PortalVisit { visit: v, admin_name, media }
|
||||
};
|
||||
|
||||
let mut upcoming = Vec::new();
|
||||
let mut past = Vec::new();
|
||||
for v in visits {
|
||||
if v.visit_date >= today && v.status == "scheduled" {
|
||||
upcoming.push(build_portal_visit(v));
|
||||
} else {
|
||||
past.push(build_portal_visit(v));
|
||||
}
|
||||
}
|
||||
past.reverse(); // newest first
|
||||
|
||||
let body = ClientPortalTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
client,
|
||||
upcoming,
|
||||
past,
|
||||
feedback_sent,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FeedbackForm {
|
||||
feedback: String,
|
||||
}
|
||||
|
||||
async fn submit_feedback(
|
||||
request: Request,
|
||||
db: Database,
|
||||
Path((token, visit_id)): Path<(String, i64)>,
|
||||
) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
|
||||
// Verify token matches visit's client
|
||||
let token_clone = token.clone();
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let client_id = client.id.unwrap();
|
||||
|
||||
let bytes = request.into_body().into_bytes().await?;
|
||||
let form: FeedbackForm =
|
||||
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||
if visit.client_id.primary_key().unwrap() == client_id {
|
||||
visit.client_feedback = Some(form.feedback);
|
||||
visit.updated_at = now();
|
||||
visit.save(&db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Redirect::new(format!("/client/{}?lang={}&feedback=ok", token_clone, lang.code())).into_response()
|
||||
}
|
||||
|
||||
/// Serve media files for the client portal (no auth required, but only via token).
|
||||
async fn portal_media(
|
||||
_request: Request,
|
||||
db: Database,
|
||||
Path((token, media_id)): Path<(String, i64)>,
|
||||
) -> cot::Result<Response> {
|
||||
// Verify token
|
||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||
Some(c) => c,
|
||||
None => return Html::new("404").into_response(),
|
||||
};
|
||||
let client_id = client.id.unwrap();
|
||||
|
||||
let media = match query!(Media, $id == media_id).get(&db).await? {
|
||||
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
|
||||
_ => return Html::new("404").into_response(),
|
||||
};
|
||||
|
||||
match tokio::fs::read(&media.file_path).await {
|
||||
Ok(data) => {
|
||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"heic" | "heif" => "image/heic",
|
||||
"webp" => "image/webp",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let body = cot::Body::fixed(data);
|
||||
let mut resp = Response::new(body);
|
||||
resp.headers_mut()
|
||||
.insert("content-type", content_type.parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
Err(_) => Html::new("404").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_router() -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name("/", landing_page, "landing"),
|
||||
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
||||
Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/{visit_id}/feedback",
|
||||
submit_feedback,
|
||||
"client-feedback",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/client/{token}/media/{media_id}",
|
||||
portal_media,
|
||||
"client-media",
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use cot::db::{Database, query};
|
||||
|
||||
use crate::models::Setting;
|
||||
|
||||
/// Send a Telegram message using bot settings from DB.
|
||||
/// Silently ignores errors (missing config, network issues) — notifications are best-effort.
|
||||
pub async fn notify_new_lead(db: &Database, name: &str, phone: Option<&str>, comment: Option<&str>) {
|
||||
let token = match get_setting(db, "telegram_bot_token").await {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return,
|
||||
};
|
||||
let chat_id = match get_setting(db, "telegram_chat_id").await {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let mut text = format!("📋 Новая заявка!\n\nИмя: {name}");
|
||||
if let Some(phone) = phone.filter(|s| !s.is_empty()) {
|
||||
text.push_str(&format!("\nТелефон: {phone}"));
|
||||
}
|
||||
if let Some(comment) = comment.filter(|s| !s.is_empty()) {
|
||||
text.push_str(&format!("\nКомментарий: {comment}"));
|
||||
}
|
||||
|
||||
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
|
||||
let _ = reqwest::Client::new()
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn get_setting(db: &Database, key_name: &str) -> Option<String> {
|
||||
let k = key_name.to_string();
|
||||
query!(Setting, $key == k)
|
||||
.get(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.value)
|
||||
}
|
||||
Reference in New Issue
Block a user