init
Build and Publish / Build and Publish Docker Image (push) Successful in 1m12s

This commit is contained in:
Ultradesu
2026-04-29 17:49:07 +03:00
commit ff32e6bbaf
36 changed files with 9595 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
target
db.sqlite3
uploads
.git
+54
View File
@@ -0,0 +1,54 @@
name: Build and Publish
on:
push:
branches:
- master
- main
tags:
- 'v*.*.*'
env:
IMAGE_NAME: ultradesu/web-petting
jobs:
build_docker:
name: Build and Publish Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
echo "cargo_version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "docker_tags=${IMAGE_NAME}:${TAG_NAME},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/heads/* ]]; then
BRANCH=${GITHUB_REF#refs/heads/}
echo "docker_tags=${IMAGE_NAME}:${BRANCH},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
else
echo "docker_tags=${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
fi
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.docker_tags }}
cache-from: type=registry,ref=${{ IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ IMAGE_NAME }}:buildcache,mode=max
+4
View File
@@ -0,0 +1,4 @@
/target
/data
db.sqlite3
/uploads
+54
View File
@@ -0,0 +1,54 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Pet sitting web service for managing clients and bookings. The owner uses the site to:
- Receive and manage client requests (leads) from the website
- Schedule calls and visits with clients
- Upload photos/videos of pets for remote viewing by clients (public media page via unique token)
- Get Telegram notifications about new requests
## Tech Stack
- **Language:** Rust (edition 2024)
- **Web framework:** [Cot](https://github.com/cot-rs/cot) — Rust web framework (Django-like), local path `../cot/cot`
- **Database:** SQLite (via Cot ORM), file `db.sqlite3`
- **Notifications:** Telegram Bot API
## Build & Run
```sh
cargo build # build
cargo run # run dev server at http://127.0.0.1:8000
cargo test # run all tests
cargo test <name> # run a single test by name
cargo clippy # lint
cargo fmt --check # check formatting
cot migration make # generate migrations from model changes (requires cot-cli)
```
## Architecture
Monolithic Cot web app with a single SQLite database.
- `src/main.rs` — project/app setup, router, config
- `src/models.rs` — all database models (Lead, Client, Visit, Media, User, Setting)
- `src/migrations.rs` — migration registry (auto-generated by `cot migration make`)
- `src/migrations/` — migration files (auto-generated)
## Database Design Principles
- **Soft-delete everywhere:** records are never physically deleted, only status changes (e.g. `active` -> `archived`, `new` -> `rejected`). This ensures data can always be recovered.
- **Status fields** are stored as `String` with enum-like values defined in `models.rs`.
- **Foreign keys** use `cot::db::ForeignKey<T>` with `Restrict` on delete/update.
## Data Model
- **Lead** (`new`/`in_progress`/`converted`/`rejected`) — public form submission; links to Client when converted
- **Client** (`active`/`archived`) — confirmed client with `media_token` for public media page
- **Visit** (`scheduled`/`completed`/`cancelled`) — pet sitting session, belongs to Client
- **Media** (`active`/`archived`) — photo/video, belongs to Client, optionally to Visit
- **User** (`active`/`archived`) — admin accounts (supports multiple admins)
- **Setting** — global key-value config (telegram_bot_token, telegram_chat_id, etc.)
Generated
+3809
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "web-petting"
version = "0.1.0"
edition = "2024"
[dependencies]
cot = { version = "0.6.0", features = ["sqlite"] }
chrono = "0.4"
serde = { version = "1", features = ["derive"] }
serde_html_form = "0.4"
password-auth = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde_json = "1"
multer = "3"
futures = "0.3"
tokio = { version = "1", features = ["fs"] }
uuid = { version = "1", features = ["v4"] }
+14
View File
@@ -0,0 +1,14 @@
FROM rust:1-slim AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
COPY templates ./templates
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /data
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
EXPOSE 3000
CMD ["web-petting"]
+1711
View File
File diff suppressed because it is too large Load Diff
+637
View File
@@ -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
View File
@@ -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
}
+15
View File
@@ -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,
];
+487
View File
@@ -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,
}
+88
View File
@@ -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(),
];
}
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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",
),
])
}
+44
View File
@@ -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)
}
+86
View File
@@ -0,0 +1,86 @@
{% extends "admin/layout.html" %}
{% let active_page = "clients" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ title }}</h1>
</div>
<div class="form-card">
<form method="post" action="{{ action_url }}">
<div class="field">
<label class="label">{{ t.clients_name }}</label>
<div class="control">
<input class="input" type="text" name="name" value="{{ client_name }}" required>
</div>
</div>
<div class="field">
<label class="label">{{ t.clients_phone }}</label>
<div class="control">
<input class="input" type="tel" name="phone" value="{{ client_phone }}">
</div>
</div>
<div class="field">
<label class="label">{{ t.clients_email }}</label>
<div class="control">
<input class="input" type="email" name="email" value="{{ client_email }}">
</div>
</div>
<div class="field">
<label class="label">{{ t.clients_address }}</label>
<div class="control">
<input class="input" type="text" name="address" value="{{ client_address }}">
</div>
</div>
<div class="field">
<label class="label">{{ t.clients_notes }}</label>
<div class="control">
<textarea class="textarea" name="notes" rows="3">{{ client_notes }}</textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.clients_color }}</label>
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
<input type="color" name="color" value="{{ client_color }}" style="width:3rem;height:2.2rem;padding:0;border:1px solid #ddd;border-radius:6px;cursor:pointer;">
<span class="has-text-grey is-size-7">{{ client_color }}</span>
</div>
</div>
<div class="field">
<button type="submit" class="button is-primary is-fullwidth">{{ submit_label }}</button>
</div>
</form>
{% if is_edit %}
<hr>
<div class="field">
<label class="label">{{ t.portal_link }}</label>
<div class="control" style="display:flex;align-items:center;gap:0.5rem;">
<input class="input is-small" type="text" readonly id="portalUrl" value="" style="flex:1;">
<button type="button" class="button is-small is-info is-outlined" onclick="navigator.clipboard.writeText(document.getElementById('portalUrl').value)">📋</button>
</div>
<div style="margin-top:0.75rem;text-align:center;">
<canvas id="qrCanvas" style="max-width:180px;"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
<script>
(function() {
var url = window.location.origin + '/client/{{ client_token }}';
document.getElementById('portalUrl').value = url;
new QRious({ element: document.getElementById('qrCanvas'), value: url, size: 180, level: 'M' });
})();
</script>
<hr>
{% if client_status == "active" %}
<form method="post" action="/admin/clients/{{ client_id }}/archive">
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
</form>
{% else %}
<form method="post" action="/admin/clients/{{ client_id }}/activate">
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
</form>
{% endif %}
{% endif %}
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "admin/layout.html" %}
{% let active_page = "clients" %}
{% block title %}{{ t.clients_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.clients_title }}</h1>
<div>
{% if show_all %}
<a href="/admin/clients?lang={{ lang.code() }}" class="button is-small is-light">{{ t.filter_show_active }}</a>
{% else %}
<a href="/admin/clients?lang={{ lang.code() }}&all=1" class="button is-small is-light">{{ t.filter_show_all }}</a>
{% endif %}
<a href="/admin/clients/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.clients_add_button }}</a>
</div>
</div>
{% if clients.is_empty() %}
<p class="has-text-grey">{{ t.clients_empty }}</p>
{% else %}
{% for client in &clients %}
<div class="item-card">
<div class="item-card-header">
<a href="/admin/clients/{{ client.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{{ client.color.as_deref().unwrap_or("#7c6ed4") }};margin-right:6px;vertical-align:middle;"></span>{{ client.name }}
</a>
<span class="badge badge-{{ client.status }}">{{ t.client_status(&client.status) }}</span>
</div>
<div class="item-card-meta">
{% if let Some(phone) = client.phone.as_deref() %}
<span><a href="tel:{{ phone }}" style="color:inherit;text-decoration:none;">📞 {{ phone }}</a></span>
{% endif %}
{% if let Some(email) = client.email.as_deref() %}
<span>✉️ {{ email }}</span>
{% endif %}
{% if let Some(addr) = client.address.as_deref() %}
<span>📍 {{ addr }}</span>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "admin/layout.html" %}
{% let active_page = "dashboard" %}
{% block title %}{{ t.dashboard_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.dashboard_today_visits }}</h1>
<a href="/admin/schedule/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.schedule_new }}</a>
</div>
{% if today_visits.is_empty() %}
<p class="has-text-grey">{{ t.dashboard_no_visits }}</p>
{% else %}
{% for tv in &today_visits %}
<div class="item-card">
<div class="item-card-header">
<a href="/admin/schedule/{{ tv.visit.id }}/edit?lang={{ lang.code() }}" class="name" style="text-decoration:none;color:inherit;">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{{ tv.client_color }};margin-right:6px;vertical-align:middle;"></span>{{ tv.client_name }}
</a>
<span class="badge badge-visit-{{ tv.visit.status }}">{{ t.visit_status(&tv.visit.status) }}</span>
</div>
<div class="item-card-meta">
<span>🕐 {{ tv.visit.time_start }} — {{ tv.visit.time_end }}</span>
{% if !tv.client_phone.is_empty() %}
<span><a href="tel:{{ tv.client_phone }}" style="color:inherit;text-decoration:none;">📞 {{ tv.client_phone }}</a></span>
{% endif %}
{% if !tv.client_address.is_empty() %}
<span>📍 {{ tv.client_address }}</span>
{% endif %}
</div>
{% if let Some(notes) = tv.visit.notes.as_deref() %}
<div style="color:#888;font-size:0.82rem;margin-top:0.3rem;">{{ notes }}</div>
{% endif %}
<div class="item-card-actions">
{% if tv.visit.status == "scheduled" %}
<form method="post" action="/admin/schedule/{{ tv.visit.id }}/done">
<button class="button is-small is-success is-outlined btn-sm">{{ t.schedule_mark_done }}</button>
</form>
<form method="post" action="/admin/schedule/{{ tv.visit.id }}/cancel">
<button class="button is-small is-danger is-outlined btn-sm">{{ t.schedule_cancel }}</button>
</form>
{% endif %}
<a href="/admin/media/{{ tv.visit.id }}/upload?lang={{ lang.code() }}" class="button is-small is-info is-outlined btn-sm">📷 {{ t.media_upload }}</a>
</div>
</div>
{% endfor %}
{% endif %}
<!-- Recent feedbacks -->
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
{% if recent_feedbacks.is_empty() %}
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
{% else %}
{% for fb in &recent_feedbacks %}
<div class="item-card" style="border-left:3px solid #7c6cff;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" style="color:#999;font-size:0.8rem;text-decoration:none;">{{ fb.visit_date }}</a>
</div>
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
+143
View File
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
<style>
:root { --accent: #6c63ff; }
body { padding-bottom: 4rem; min-height: 100vh; background: #f5f5f5; }
/* ── Top bar ── */
.top-header {
background: #fff; border-bottom: 1px solid #e8e8e8;
padding: 0.5rem 1rem; display: flex; align-items: center;
justify-content: space-between; position: sticky; top: 0; z-index: 30;
}
.top-header .brand { font-weight: 700; font-size: 1.1rem; color: #333; text-decoration: none; }
.top-header-right { display: flex; align-items: center; gap: 0.75rem; font-size: 0.85rem; }
.top-header-right a { color: #888; text-decoration: none; }
.top-header-right .admin-name { color: #aaa; }
/* ── Bottom tabs (mobile nav) ── */
.bottom-tabs {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
background: #fff; border-top: 1px solid #e8e8e8;
display: flex; height: 3.5rem;
}
.bottom-tabs a {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-decoration: none; color: #999;
font-size: 0.65rem; font-weight: 600; gap: 0.15rem;
transition: color 0.15s;
}
.bottom-tabs a .tab-icon { font-size: 1.25rem; line-height: 1; }
.bottom-tabs a.is-active { color: var(--accent); }
/* ── Desktop: hide bottom tabs, show top nav ── */
.desktop-nav { display: none; }
@media (min-width: 769px) {
body { padding-bottom: 0; }
.bottom-tabs { display: none; }
.desktop-nav { display: flex; gap: 0.25rem; }
.desktop-nav a {
padding: 0.3rem 0.75rem; border-radius: 6px; font-size: 0.9rem;
color: #555; text-decoration: none; transition: background 0.15s;
}
.desktop-nav a:hover { background: #f0f0f0; }
.desktop-nav a.is-active { background: var(--accent); color: #fff; }
}
/* ── Content ── */
.main-content { padding: 1rem; max-width: 900px; margin: 0 auto; }
/* ── Status badges ── */
.badge { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 99px; font-size: 0.75rem; font-weight: 600; }
.badge-new { background: #dbeafe; color: #1e40af; }
.badge-in_progress { background: #fef3c7; color: #92400e; }
.badge-converted { background: #d1fae5; color: #065f46; }
.badge-rejected { background: #fee2e2; color: #991b1b; }
.badge-active { background: #d1fae5; color: #065f46; }
.badge-archived { background: #e5e7eb; color: #374151; }
.badge-visit-scheduled { background: #dbeafe; color: #1e40af; }
.badge-visit-completed { background: #d1fae5; color: #065f46; }
.badge-visit-cancelled { background: #e5e7eb; color: #374151; }
/* ── Item cards ── */
.item-card {
background: #fff; border-radius: 10px; padding: 0.85rem 1rem;
margin-bottom: 0.6rem; border: 1px solid #eee;
}
.item-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
.item-card-header .name { font-weight: 700; font-size: 1rem; }
.item-card-meta { color: #888; font-size: 0.82rem; line-height: 1.5; }
.item-card-meta span { margin-right: 1rem; }
.item-card-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
.item-card-actions form { margin: 0; }
/* ── Small buttons ── */
.btn-sm { font-size: 0.78rem !important; padding: 0.25rem 0.6rem !important; height: auto !important; }
/* ── Page header ── */
.page-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.page-head h1 { font-size: 1.3rem; font-weight: 700; margin: 0; }
/* ── Forms ── */
.form-card { background: #fff; border-radius: 10px; padding: 1.25rem; border: 1px solid #eee; }
</style>
</head>
<body>
<!-- Top header -->
<div class="top-header">
<a class="brand" href="/admin/?lang={{ lang.code() }}">🐾 {{ t.nav_title }}</a>
<nav class="desktop-nav">
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>{{ t.dashboard_title }}</a>
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>{{ t.nav_leads }}</a>
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>{{ t.nav_clients }}</a>
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>{{ t.nav_schedule }}</a>
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>{{ t.nav_media }}</a>
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>{{ t.nav_users }}</a>
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>{{ t.nav_settings }}</a>
</nav>
<div class="top-header-right">
<span class="admin-name">{{ admin_name }}</span>
<a href="/admin/logout">{{ t.logout }}</a>
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
</div>
</div>
<!-- Main -->
<div class="main-content">
{% block content %}{% endblock %}
</div>
<!-- Bottom tabs (mobile) -->
<nav class="bottom-tabs">
<a href="/admin/?lang={{ lang.code() }}" {% if active_page == "dashboard" %}class="is-active"{% endif %}>
<span class="tab-icon">🏠</span>{{ t.dashboard_title }}
</a>
<a href="/admin/leads?lang={{ lang.code() }}" {% if active_page == "leads" %}class="is-active"{% endif %}>
<span class="tab-icon">📋</span>{{ t.nav_leads }}
</a>
<a href="/admin/clients?lang={{ lang.code() }}" {% if active_page == "clients" %}class="is-active"{% endif %}>
<span class="tab-icon">👥</span>{{ t.nav_clients }}
</a>
<a href="/admin/schedule?lang={{ lang.code() }}" {% if active_page == "schedule" %}class="is-active"{% endif %}>
<span class="tab-icon">📅</span>{{ t.nav_schedule }}
</a>
<a href="/admin/media?lang={{ lang.code() }}" {% if active_page == "media" %}class="is-active"{% endif %}>
<span class="tab-icon">📷</span>{{ t.nav_media }}
</a>
<a href="/admin/users?lang={{ lang.code() }}" {% if active_page == "users" %}class="is-active"{% endif %}>
<span class="tab-icon">🔑</span>{{ t.nav_users }}
</a>
<a href="/admin/settings?lang={{ lang.code() }}" {% if active_page == "settings" %}class="is-active"{% endif %}>
<span class="tab-icon">⚙️</span>{{ t.nav_settings }}
</a>
</nav>
{% include "partials/lightbox.html" %}
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
{% extends "admin/layout.html" %}
{% let active_page = "leads" %}
{% block title %}{{ t.leads_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.leads_title }}</h1>
{% if show_all %}
<a href="/admin/leads?lang={{ lang.code() }}" class="button is-small is-light">{{ t.filter_show_active }}</a>
{% else %}
<a href="/admin/leads?lang={{ lang.code() }}&all=1" class="button is-small is-light">{{ t.filter_show_all }}</a>
{% endif %}
</div>
{% if leads.is_empty() %}
<p class="has-text-grey">{{ t.leads_empty }}</p>
{% else %}
{% for lead in &leads %}
<div class="item-card">
<div class="item-card-header">
<span class="name">{{ lead.name }}</span>
<span class="badge badge-{{ lead.status }}">{{ t.lead_status(&lead.status) }}</span>
</div>
<div class="item-card-meta">
{% if let Some(phone) = lead.phone.as_deref() %}
<span><a href="tel:{{ phone }}" style="color:inherit;text-decoration:none;">📞 {{ phone }}</a></span>
{% endif %}
{% if let Some(comment) = lead.comment.as_deref() %}
<span>💬 {{ comment }}</span>
{% endif %}
<span>🕐 {{ lead.created_at.format("%d.%m.%Y %H:%M") }}</span>
</div>
{% if lead.status == "new" || lead.status == "in_progress" %}
<div class="item-card-actions">
{% if lead.status == "new" %}
<form method="post" action="/admin/leads/{{ lead.id }}/status">
<input type="hidden" name="status" value="in_progress">
<button type="submit" class="button is-small is-info is-outlined btn-sm">{{ t.action_in_progress }}</button>
</form>
{% endif %}
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
</form>
<form method="post" action="/admin/leads/{{ lead.id }}/status">
<input type="hidden" name="status" value="rejected">
<button type="submit" class="button is-small is-danger is-outlined btn-sm">{{ t.action_reject }}</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
<style>
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
</style>
</head>
<body>
<div class="login-box">
<div class="has-text-right mb-3">
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
</div>
<div class="login-card">
<div class="has-text-centered mb-4">
<p class="is-size-3">🐾</p>
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
<p class="has-text-grey">{{ t.login_title }}</p>
</div>
{% if let Some(err) = error.as_ref() %}
<div class="notification is-danger is-light">{{ err }}</div>
{% endif %}
<form method="post" action="/admin/login/submit">
<div class="field">
<label class="label">{{ t.users_login }}</label>
<div class="control"><input class="input" type="text" name="login" required autofocus></div>
</div>
<div class="field">
<label class="label">{{ t.users_password }}</label>
<div class="control"><input class="input" type="password" name="password" required></div>
</div>
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
</form>
</div>
</div>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
{% extends "admin/layout.html" %}
{% let active_page = "media" %}
{% block title %}{{ t.media_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.media_title }}</h1>
</div>
<!-- Client filter -->
<div style="margin-bottom:1rem;">
<div class="select is-small">
<select onchange="window.location.href='/admin/media?lang={{ lang.code() }}' + (this.value ? '&client_id=' + this.value : '')">
<option value="">{{ t.media_all_clients }}</option>
{% for c in &clients %}
<option value="{{ c.id }}" {% if c.id.unwrap() == filter_client_id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% if items.is_empty() %}
<p class="has-text-grey">{{ t.media_empty }}</p>
{% else %}
<div class="media-grid">
{% for item in &items %}
<div class="media-card">
{% if item.media.file_type == "photo" %}
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="photo">
<img src="/admin/uploads/{{ item.media.id }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
<div class="video-thumb">🎬</div>
</a>
{% endif %}
<div class="media-info">
<div class="media-meta">
<strong>{{ item.client_name }}</strong>
{% if let Some(d) = item.visit_date.as_deref() %}
<span style="color:#999;">{{ d }}</span>
{% endif %}
</div>
{% if let Some(cap) = item.media.caption.as_deref() %}
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
{% endif %}
<form method="post" action="/admin/media/{{ item.media.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.media-card {
background: #fff;
border-radius: 10px;
border: 1px solid #eee;
overflow: hidden;
}
.media-card img {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
}
.media-card .video-thumb {
width: 100%;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: #f0f0f0;
}
.media-info {
padding: 0.6rem 0.75rem;
}
.media-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
@media (max-width: 480px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.media-card img, .media-card .video-thumb {
height: 120px;
}
}
</style>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "admin/layout.html" %}
{% let active_page = "media" %}
{% block title %}{{ t.media_upload_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.media_upload_title }}</h1>
</div>
<div class="form-card">
<div style="margin-bottom:1rem;font-size:0.9rem;color:#666;">
<div><strong>{{ t.schedule_client }}:</strong> {{ client_name }}</div>
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
</div>
<form method="post" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
<div class="field">
<label class="label">{{ t.media_choose_files }}</label>
<div class="control">
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
</div>
</div>
<div class="field">
<label class="label">{{ t.media_caption }}</label>
<div class="control">
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
</div>
</div>
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
</form>
</div>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
{% extends "admin/layout.html" %}
{% let active_page = "schedule" %}
{% block title %}{{ t.schedule_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.schedule_title }}</h1>
<a href="/admin/schedule/new?lang={{ lang.code() }}" class="button is-primary is-small">+ {{ t.schedule_new }}</a>
</div>
<div class="form-card" id="calendar-wrap" style="padding:0.5rem;">
<div id="calendar"></div>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.17/index.global.min.css">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.17/index.global.min.js"></script>
{% if lang == Lang::Ru %}
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.17/locales/ru.global.min.js"></script>
{% endif %}
<style>
#calendar-wrap { overflow: hidden; }
.fc { font-size: 0.85rem; }
.fc .fc-toolbar { flex-wrap: wrap; gap: 0.3rem; }
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
.fc .fc-day-today { background: #eef2ff !important; }
.fc .fc-day.day-weekend { background: #faf5f0; }
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
@media (max-width: 768px) {
.fc .fc-toolbar { font-size: 0.75rem; }
.fc .fc-toolbar-title { font-size: 0.95rem !important; }
.fc .fc-button { padding: 0.2rem 0.35rem !important; font-size: 0.72rem !important; }
}
.visit-modal-bg { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.35); z-index:100; align-items:center; justify-content:center; }
.visit-modal-bg.is-open { display:flex; }
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
.visit-modal .actions { display:flex; gap:0.5rem; flex-wrap:wrap; }
.visit-modal .actions form { margin:0; }
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
</style>
<div class="visit-modal-bg" id="visitModal">
<div class="visit-modal">
<h3><span class="color-dot" id="vmDot"></span><span id="vmTitle"></span></h3>
<div class="meta">
<div id="vmClient"></div>
<div id="vmAddress"></div>
<div id="vmAdmin"></div>
<div id="vmTime"></div>
<div id="vmNotes" style="margin-top:0.3rem;"></div>
</div>
<div id="vmStatus" style="margin-bottom:0.75rem;"></div>
<div class="actions" id="vmActions"></div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
<a id="vmEditLink" href="#" class="button is-info is-small" style="flex:1;">{{ t.schedule_edit_title }}</a>
<button class="button is-light is-small" style="flex:1;" onclick="closeModal()">OK</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const lang = '{{ lang.code() }}';
const calEl = document.getElementById('calendar');
const statusLabels = {
scheduled: '{{ t.visit_status_scheduled }}',
completed: '{{ t.visit_status_completed }}',
cancelled: '{{ t.visit_status_cancelled }}'
};
const calendar = new FullCalendar.Calendar(calEl, {
locale: lang,
initialView: window.innerWidth < 768 ? 'listWeek' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
events: '/admin/schedule/events',
eventClick: function(info) {
info.jsEvent.preventDefault();
const ev = info.event;
const p = ev.extendedProps;
document.getElementById('vmDot').style.background = p.client_color || '#7c6ed4';
document.getElementById('vmTitle').textContent = p.client_name;
document.getElementById('vmClient').innerHTML = p.client_phone ? ('<a href="tel:' + p.client_phone + '" style="color:inherit;text-decoration:none;">📞 ' + p.client_phone + '</a>') : '';
document.getElementById('vmAddress').textContent = p.client_address ? ('📍 ' + p.client_address) : '';
document.getElementById('vmAdmin').textContent = '👤 ' + p.admin_name;
document.getElementById('vmTime').textContent = '🕐 ' + p.time_start + ' — ' + p.time_end;
document.getElementById('vmNotes').textContent = p.notes || '';
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
document.getElementById('vmStatus').innerHTML = badge;
let actions = '';
if (p.status === 'scheduled') {
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/done"><button class="button is-small is-success is-outlined">{{ t.schedule_mark_done }}</button></form>';
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/cancel"><button class="button is-small is-danger is-outlined">{{ t.schedule_cancel }}</button></form>';
}
document.getElementById('vmActions').innerHTML = actions;
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
document.getElementById('visitModal').classList.add('is-open');
},
dayCellClassNames: function(arg) {
var dow = arg.date.getDay();
if (dow === 0 || dow === 6) return ['day-weekend'];
return [];
},
height: 'auto',
navLinks: true,
nowIndicator: true,
});
calendar.render();
document.getElementById('visitModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
});
function closeModal() {
document.getElementById('visitModal').classList.remove('is-open');
}
</script>
{% endblock %}
+189
View File
@@ -0,0 +1,189 @@
{% extends "admin/layout.html" %}
{% let active_page = "schedule" %}
{% block title %}{{ t.schedule_edit_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.schedule_edit_title }}</h1>
</div>
<div class="form-card">
<form method="post" action="/admin/schedule/{{ visit.id }}/save">
<!-- Client -->
<div class="field">
<label class="label">{{ t.schedule_client }}</label>
<div class="control">
<div class="select is-fullwidth">
<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>
<!-- Admin -->
<div class="field">
<label class="label">{{ t.schedule_admin }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="user_id">
{% for u in &users %}
<option value="{{ u.id }}" {% if u.id.unwrap() == visit.user_id.primary_key().unwrap() %}selected{% endif %}>
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Date -->
<div class="field">
<label class="label">{{ t.schedule_date }}</label>
<div class="control">
<input class="input" type="date" name="visit_date" value="{{ visit.visit_date }}" required>
</div>
</div>
<!-- Time -->
<div class="field">
<label class="label">{{ t.schedule_default_time }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<div class="control">
<input class="input" type="time" name="time_start" value="{{ visit.time_start }}" required>
</div>
</div>
<div class="column">
<div class="control">
<input class="input" type="time" name="time_end" value="{{ visit.time_end }}" required>
</div>
</div>
</div>
</div>
<!-- Status -->
<div class="field">
<label class="label">{{ t.schedule_status }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="status">
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
</select>
</div>
</div>
</div>
<!-- Private Notes -->
<div class="field">
<label class="label">{{ t.schedule_notes }}</label>
<div class="control">
<textarea class="textarea" name="notes" rows="2">{{ visit.notes.as_deref().unwrap_or("") }}</textarea>
</div>
</div>
<!-- Public Notes (visible to client) -->
<div class="field">
<label class="label">{{ t.schedule_public_notes }}</label>
<div class="control">
<textarea class="textarea" name="public_notes" rows="2">{{ visit.public_notes.as_deref().unwrap_or("") }}</textarea>
</div>
</div>
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
</form>
{% if let Some(fb) = visit.client_feedback.as_deref() %}
<div style="margin-top:1rem;">
<label class="label">{{ t.schedule_client_feedback }}</label>
<div style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
</div>
{% endif %}
<hr style="margin:1rem 0;">
<!-- Media -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<label class="label" style="margin:0;">{{ t.nav_media }}</label>
<a href="/admin/media/{{ visit.id }}/upload?lang={{ lang.code() }}" class="button is-info is-small is-outlined">📷 {{ t.media_upload }}</a>
</div>
{% if media.is_empty() %}
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
{% else %}
<div class="visit-media-grid">
{% for m in &media %}
<div class="visit-media-item">
{% if m.file_type == "photo" %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
<div class="video-thumb-sm">🎬</div>
</a>
{% endif %}
{% if let Some(cap) = m.caption.as_deref() %}
<div class="media-cap">{{ cap }}</div>
{% endif %}
<form method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="text-align:center;">
<button class="button is-danger is-outlined btn-sm" style="font-size:0.7rem;padding:0.15rem 0.4rem;">{{ t.media_delete }}</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<hr style="margin:1rem 0;">
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
</form>
</div>
<style>
.visit-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.visit-media-item {
background: #fafafa;
border-radius: 8px;
border: 1px solid #eee;
overflow: hidden;
}
.visit-media-item img {
width: 100%;
height: 80px;
object-fit: cover;
display: block;
}
.visit-media-item .video-thumb-sm {
width: 100%;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
background: #f0f0f0;
}
.visit-media-item .media-cap {
font-size: 0.7rem;
color: #888;
padding: 0.2rem 0.4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.visit-media-item form {
padding: 0.2rem;
}
</style>
{% endblock %}
+246
View File
@@ -0,0 +1,246 @@
{% extends "admin/layout.html" %}
{% let active_page = "schedule" %}
{% block title %}{{ t.schedule_new_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.schedule_new_title }}</h1>
</div>
<div class="form-card">
<form method="post" action="/admin/schedule/create" id="visitForm">
<!-- Client -->
<div class="field">
<label class="label">{{ t.schedule_client }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="client_id" required>
<option value=""></option>
{% for c in &clients %}
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Admin -->
<div class="field">
<label class="label">{{ t.schedule_admin }}</label>
<div class="control">
<div class="select is-fullwidth">
<select name="user_id">
{% for u in &users %}
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Default time -->
<div class="field">
<label class="label">{{ t.schedule_default_time }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<div class="control">
<input class="input" type="time" id="defaultStart" value="18:00">
</div>
</div>
<div class="column">
<div class="control">
<input class="input" type="time" id="defaultEnd" value="19:00">
</div>
</div>
</div>
</div>
<!-- Add individual date -->
<div class="field">
<label class="label">{{ t.schedule_pick_dates }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<div class="control">
<input class="input" type="date" id="pickDate">
</div>
</div>
<div class="column is-narrow">
<button type="button" class="button is-info" id="addDateBtn">+</button>
</div>
</div>
</div>
<!-- Date range fill -->
<div class="field">
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label>
<div class="columns is-mobile" style="margin-bottom:0;">
<div class="column">
<input class="input" type="date" id="rangeFrom">
</div>
<div class="column">
<input class="input" type="date" id="rangeTo">
</div>
<div class="column is-narrow">
<button type="button" class="button is-info is-outlined" id="fillRangeBtn">{{ t.schedule_fill_range }}</button>
</div>
</div>
</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>
<!-- Notes -->
<div class="field">
<label class="label">{{ t.schedule_notes }}</label>
<div class="control">
<textarea class="textarea" name="notes" rows="2"></textarea>
</div>
</div>
<!-- Hidden days data -->
<input type="hidden" name="days_json" id="daysJson" value="[]">
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
</form>
</div>
<style>
.day-row {
display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0;
border-bottom: 1px solid #f0f0f0; flex-wrap: wrap;
}
.day-row .day-date { font-weight: 600; min-width: 6rem; font-size: 0.9rem; }
.day-row input[type="time"] { width: 7rem; padding: 0.2rem 0.4rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85rem; }
.day-row .remove-btn { color: #e55; cursor: pointer; font-size: 0.8rem; margin-left: auto; background: none; border: none; }
</style>
<script>
const days = new Map(); // date string -> {start, end}
const removeLabel = '{{ t.schedule_remove_day }}';
const weekdays = '{{ lang.code() }}' === 'ru'
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб']
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
function getDefaults() {
return {
start: document.getElementById('defaultStart').value || '18:00',
end: document.getElementById('defaultEnd').value || '19:00'
};
}
function addDay(dateStr) {
if (!dateStr || days.has(dateStr)) return;
const def = getDefaults();
days.set(dateStr, { start: def.start, end: def.end });
renderDays();
}
function removeDay(dateStr) {
days.delete(dateStr);
renderDays();
}
function renderDays() {
const list = document.getElementById('daysList');
const msg = document.getElementById('noDaysMsg');
const btn = document.getElementById('submitBtn');
// Remove old day rows
list.querySelectorAll('.day-row').forEach(el => el.remove());
if (days.size === 0) {
msg.style.display = '';
btn.disabled = true;
document.getElementById('daysJson').value = '[]';
return;
}
msg.style.display = 'none';
btn.disabled = false;
// Sort by date
const sorted = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
sorted.forEach(([dateStr, times]) => {
const d = new Date(dateStr + 'T00:00:00');
const wd = weekdays[d.getDay()];
const label = dateStr.split('-').reverse().join('.') + ' ' + wd;
const row = document.createElement('div');
row.className = 'day-row';
row.innerHTML = `
<span class="day-date">${label}</span>
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start">
<span>—</span>
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end">
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button>
`;
list.appendChild(row);
});
// Update hidden JSON
updateJson();
// Bind events
list.querySelectorAll('input[type="time"]').forEach(inp => {
inp.addEventListener('change', function() {
const dt = this.dataset.date;
const field = this.dataset.field;
if (days.has(dt)) {
days.get(dt)[field] = this.value;
updateJson();
}
});
});
list.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', function() {
removeDay(this.dataset.date);
});
});
}
function updateJson() {
const arr = [...days.entries()].map(([date, t]) => ({
date: date,
time_start: t.start,
time_end: t.end
}));
document.getElementById('daysJson').value = JSON.stringify(arr);
}
document.getElementById('addDateBtn').addEventListener('click', function() {
const v = document.getElementById('pickDate').value;
addDay(v);
document.getElementById('pickDate').value = '';
});
document.getElementById('pickDate').addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); }
});
document.getElementById('fillRangeBtn').addEventListener('click', function() {
const from = document.getElementById('rangeFrom').value;
const to = document.getElementById('rangeTo').value;
if (!from || !to || from > to) return;
let cur = new Date(from + 'T00:00:00');
const end = new Date(to + 'T00:00:00');
while (cur <= end) {
const ds = cur.toISOString().slice(0, 10);
addDay(ds);
cur.setDate(cur.getDate() + 1);
}
document.getElementById('rangeFrom').value = '';
document.getElementById('rangeTo').value = '';
});
// Set default pick date to today
document.getElementById('pickDate').valueAsDate = new Date();
</script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "admin/layout.html" %}
{% let active_page = "settings" %}
{% block title %}{{ t.settings_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.settings_title }}</h1>
</div>
{% if saved %}
<div class="notification is-success is-light">{{ t.settings_saved }}</div>
{% endif %}
<div class="form-card">
<form method="post" action="/admin/settings/save">
<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>
<div class="field">
<label class="label">{{ t.settings_telegram_chat_id }}</label>
<div class="control">
<input class="input" type="text" name="telegram_chat_id" value="{% for s in &settings %}{% if s.key == "telegram_chat_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_contact_info }}</label>
<div class="control">
<input class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
</form>
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
<style>
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { width: 100%; max-width: 400px; padding: 0 1rem; }
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
</style>
</head>
<body>
<div class="login-box">
<div class="has-text-right mb-3">
<a href="?lang={{ lang.other().code() }}" class="has-text-grey is-size-7">{{ lang.other().label() }}</a>
</div>
<div class="login-card">
<div class="has-text-centered mb-4">
<p class="is-size-3">🐾</p>
<h1 class="is-size-4 has-text-weight-bold">{{ t.nav_title }}</h1>
<p class="has-text-grey">{{ t.setup_title }}</p>
</div>
<p class="has-text-grey has-text-centered is-size-7 mb-4">{{ t.setup_description }}</p>
{% if let Some(err) = error.as_ref() %}
<div class="notification is-danger is-light">{{ err }}</div>
{% endif %}
<form method="post" action="/admin/setup/submit">
<div class="field">
<label class="label">{{ t.users_login }}</label>
<div class="control"><input class="input" type="text" name="login" required autofocus></div>
</div>
<div class="field">
<label class="label">{{ t.users_display_name }}</label>
<div class="control"><input class="input" type="text" name="display_name"></div>
</div>
<div class="field">
<label class="label">{{ t.users_password }}</label>
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
</div>
<div class="field">
<label class="label">{{ t.users_password_confirm }}</label>
<div class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
</div>
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.setup_button }}</button>
</form>
</div>
</div>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
{% extends "admin/layout.html" %}
{% let active_page = "users" %}
{% block title %}{{ t.users_title }}{% endblock %}
{% block content %}
<div class="page-head">
<h1>{{ t.users_title }}</h1>
</div>
{% for user in &users %}
<div class="item-card">
<div class="item-card-header">
<span class="name">{{ user.login }}{% if let Some(dn) = user.display_name.as_deref() %} <span style="font-weight:400;color:#888;">— {{ dn }}</span>{% endif %}</span>
<span class="badge badge-{{ user.status }}">{{ t.client_status(&user.status) }}</span>
</div>
<div class="item-card-meta">
<span>🕐 {{ user.created_at.format("%d.%m.%Y %H:%M") }}</span>
</div>
<div class="item-card-actions">
{% if user.status == "active" %}
<form method="post" action="/admin/users/{{ user.id }}/archive">
<button type="submit" class="button is-small is-warning is-outlined btn-sm">{{ t.action_archive }}</button>
</form>
{% else %}
<form method="post" action="/admin/users/{{ user.id }}/activate">
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_activate }}</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
<div class="form-card" style="margin-top:1.5rem;">
<h2 class="is-size-5 has-text-weight-bold mb-3">{{ t.users_add_title }}</h2>
{% if let Some(err) = error.as_ref() %}
<div class="notification is-danger is-light">{{ err }}</div>
{% endif %}
<form method="post" action="/admin/users/add">
<div class="columns is-mobile">
<div class="column">
<div class="field">
<label class="label is-small">{{ t.users_login }}</label>
<div class="control"><input class="input" type="text" name="login" required></div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label is-small">{{ t.users_display_name }}</label>
<div class="control"><input class="input" type="text" name="display_name"></div>
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="field">
<label class="label is-small">{{ t.users_password }}</label>
<div class="control"><input class="input" type="password" name="password" required minlength="4"></div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label is-small">{{ t.users_password_confirm }}</label>
<div class="control"><input class="input" type="password" name="password_confirm" required minlength="4"></div>
</div>
</div>
</div>
<button type="submit" class="button is-primary">{{ t.users_add_button }}</button>
</form>
</div>
{% endblock %}
+209
View File
@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.portal_title }} — {{ client.name }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
padding: 0 0 2rem;
}
.portal-header {
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
}
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
.portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; }
.container { max-width: 700px; margin: 0 auto; padding: 0 1rem; }
.section-title {
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
}
.visit-card {
background: #fff; border-radius: 12px; padding: 1rem;
margin-bottom: 0.75rem; border: 1px solid #eee;
}
.visit-card-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.3rem;
}
.visit-card-head .date { font-weight: 700; font-size: 0.95rem; }
.visit-card-head .time { color: #7a7599; font-size: 0.85rem; }
.visit-card .admin { color: #999; font-size: 0.82rem; }
.badge-sm {
display: inline-block; padding: 0.1rem 0.5rem; border-radius: 99px;
font-size: 0.7rem; font-weight: 600;
}
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-scheduled { background: #dbeafe; color: #1e40af; }
.media-row {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;
}
.media-row a { display: block; cursor: pointer; }
.media-row img {
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
}
.media-row .vid-thumb {
width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0;
display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
}
.feedback-form { margin-top: 0.6rem; }
.feedback-form textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
font-size: 0.85rem; font-family: inherit; resize: vertical; min-height: 50px;
}
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
.feedback-form button {
margin-top: 0.3rem; padding: 0.35rem 1rem; border: none; border-radius: 8px;
background: #7c6cff; color: #fff; font-size: 0.82rem; font-weight: 600;
cursor: pointer;
}
.feedback-text {
margin-top: 0.4rem; padding: 0.5rem 0.75rem; background: #f0f0ff;
border-radius: 8px; font-size: 0.85rem; color: #4a4570;
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.fb-edit-btn {
background: none; border: none; cursor: pointer; font-size: 0.85rem;
opacity: 0.35; padding: 0; line-height: 1; flex-shrink: 0;
}
.fb-edit-btn:hover { opacity: 0.7; }
.fb-cancel-btn {
padding: 0.35rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
background: #fff; font-size: 0.82rem; cursor: pointer; color: #999;
}
.success-msg {
background: #d1fae5; color: #065f46; padding: 0.6rem 1rem;
border-radius: 8px; margin: 1rem 0; font-size: 0.9rem; text-align: center;
}
.empty-msg { color: #999; font-size: 0.9rem; padding: 0.5rem 0; }
.lang-switch { text-align: right; padding: 0.5rem 1rem 0; }
.lang-switch a { color: #7c6cff; font-size: 0.85rem; text-decoration: none; }
/* Compact upcoming schedule */
.upcoming-list {
display: flex; flex-direction: column; gap: 0.35rem;
}
.upcoming-row {
display: flex; align-items: center; gap: 0.75rem;
background: #fff; border-radius: 8px; padding: 0.5rem 0.75rem;
border: 1px solid #eee; font-size: 0.85rem;
}
.upcoming-row .up-date {
font-weight: 700; min-width: 5.5rem;
}
.upcoming-row .up-time { color: #7a7599; }
</style>
</head>
<body>
<div class="lang-switch">
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
</div>
<div class="portal-header">
<h1>{{ t.portal_title }}</h1>
<div class="sub">{{ client.name }}</div>
</div>
<div class="container">
{% if feedback_sent %}
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
{% endif %}
<!-- Past visits with media first -->
<h2 class="section-title">{{ t.portal_past }}</h2>
{% if past.is_empty() %}
<p class="empty-msg">{{ t.portal_no_past }}</p>
{% else %}
{% for pv in &past %}
<div class="visit-card">
<div class="visit-card-head">
<span class="date">{{ pv.visit.visit_date }}</span>
<span class="badge-sm badge-{{ pv.visit.status }}">
{{ t.visit_status(&pv.visit.status) }}
</span>
</div>
<div class="visit-card-head" style="margin-bottom:0;">
<span class="time">{{ pv.visit.time_start }} — {{ pv.visit.time_end }}</span>
<span class="admin">{{ pv.admin_name }}</span>
</div>
{% if let Some(pn) = pv.visit.public_notes.as_deref() %}
<div style="margin-top:0.4rem;padding:0.5rem 0.75rem;background:#f5f0ff;border-radius:8px;font-size:0.85rem;color:#4a4570;">{{ pn }}</div>
{% endif %}
{% if !pv.media.is_empty() %}
<div class="media-row">
{% for m in &pv.media %}
{% if m.file_type == "photo" %}
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="photo">
<img src="/client/{{ client.media_token }}/media/{{ m.id }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
<div class="vid-thumb">🎬</div>
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if pv.visit.status == "completed" %}
{% if let Some(fb) = pv.visit.client_feedback.as_deref() %}
<div class="feedback-view" id="fb-view-{{ pv.visit.id }}">
<div class="feedback-text">
<span>{{ fb }}</span>
<button class="fb-edit-btn" onclick="showFbEdit({{ pv.visit.id }})" title="Edit">✏️</button>
</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">
<textarea name="feedback" required>{{ fb }}</textarea>
<div style="display:flex;gap:0.4rem;">
<button type="submit">{{ t.portal_feedback_submit }}</button>
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})"></button>
</div>
</form>
{% else %}
<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>
<button type="submit">{{ t.portal_feedback_submit }}</button>
</form>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% endif %}
<!-- Compact upcoming schedule -->
{% if !upcoming.is_empty() %}
<h2 class="section-title">{{ t.portal_upcoming }}</h2>
<div class="upcoming-list">
{% for pv in &upcoming %}
<div class="upcoming-row">
<span class="up-date">{{ pv.visit.visit_date }}</span>
<span class="up-time">{{ pv.visit.time_start }} — {{ pv.visit.time_end }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<script>
function showFbEdit(id) {
document.getElementById('fb-view-' + id).style.display = 'none';
document.getElementById('fb-form-' + id).style.display = '';
}
function hideFbEdit(id) {
document.getElementById('fb-form-' + id).style.display = 'none';
document.getElementById('fb-view-' + id).style.display = '';
}
</script>
{% include "partials/lightbox.html" %}
</body>
</html>
+402
View File
@@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ t.landing_meta_description }}">
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
<!-- Open Graph -->
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
<meta property="og:description" content="{{ t.landing_meta_description }}">
<meta property="og:type" content="website">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "{{ t.nav_title }}",
"description": "{{ t.landing_meta_description }}",
"serviceType": "Pet Sitting",
"@id": "#business"
}
</script>
<style>
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
color: #2d2b55; line-height: 1.6; overflow-x: hidden;
background: linear-gradient(160deg, #e8e4ff 0%, #fce4ec 25%, #fff3e0 50%, #e0f7fa 75%, #ede7f6 100%);
background-attachment: fixed;
}
a { color: inherit; }
/* ── Floating SVG background ── */
.bg-deco {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 0; overflow: hidden;
}
.bg-deco svg {
position: absolute; opacity: 0.22;
will-change: transform;
}
/* ── Header ── */
.site-header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(255,255,255,0.6); backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(180,170,220,0.2);
}
.header-inner {
max-width: 1100px; margin: 0 auto; padding: 0.8rem 1.5rem;
display: flex; justify-content: space-between; align-items: center;
}
.logo { font-size: 1.25rem; font-weight: 700; text-decoration: none; color: #2d2b55; }
.header-secret { flex: 1; align-self: stretch; margin: 0 0.75rem; }
.header-right { display: flex; align-items: center; gap: 1.25rem; }
.header-right a { text-decoration: none; font-size: 0.9rem; color: #666; transition: color 0.2s; }
.header-right a:hover { color: #2d2b55; }
.header-cta {
display: inline-block; padding: 0.45rem 1.1rem; border-radius: 8px;
background: linear-gradient(135deg, #7c6cff, #b06cff); color: #fff !important;
font-weight: 600; font-size: 0.9rem; text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.header-cta:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(124,108,255,0.35); }
/* ── Content z-index above bg ── */
.site-header, .hero, .section, .form-section, .site-footer {
position: relative; z-index: 1;
}
/* ── Hero ── */
.hero {
padding: 8rem 1.5rem 4.5rem;
text-align: center;
background: radial-gradient(ellipse at 50% 0%, rgba(255,255,255,0.6) 0%, transparent 70%);
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.2rem); font-weight: 800;
line-height: 1.2; max-width: 750px; margin: 0 auto 1.25rem;
color: #2d2b55;
}
.hero p {
font-size: clamp(1.05rem, 2.5vw, 1.25rem);
color: #5a5680; max-width: 640px; margin: 0 auto 2rem;
}
.hero-cta {
display: inline-block; padding: 0.9rem 2.5rem; border-radius: 14px;
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; font-size: 1.1rem; font-weight: 700;
text-decoration: none; transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(124,108,255,0.35);
}
.hero-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); }
.hero-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
/* ── Section common ── */
.section { padding: 5rem 1.5rem; background: transparent; }
.section-inner { max-width: 1100px; margin: 0 auto; }
.section-title {
text-align: center; font-size: clamp(1.6rem, 3.5vw, 2.2rem);
font-weight: 800; margin-bottom: 3rem; color: #2d2b55;
}
.section-alt {
background: rgba(255,255,255,0.3); backdrop-filter: blur(4px);
border-top: 1px solid rgba(180,170,220,0.15);
border-bottom: 1px solid rgba(180,170,220,0.15);
}
/* ── Services grid ── */
.services-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
}
.service-card {
background: rgba(255,255,255,0.55); backdrop-filter: blur(10px);
border-radius: 18px; padding: 2rem 1.75rem;
border: 1px solid rgba(180,170,220,0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.service-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
}
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; }
.service-card h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.5rem; color: #2d2b55; }
.service-card p { color: #5a5680; font-size: 0.95rem; line-height: 1.6; }
/* ── Steps ── */
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 2rem; }
.step { text-align: center; padding: 1.5rem; }
.step-num {
width: 3.5rem; height: 3.5rem; border-radius: 50%;
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; font-size: 1.4rem; font-weight: 800;
display: inline-flex; align-items: center; justify-content: center;
margin-bottom: 1rem; box-shadow: 0 4px 14px rgba(124,108,255,0.3);
}
.step h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.4rem; color: #2d2b55; }
.step p { color: #5a5680; font-size: 0.95rem; }
/* ── Form section ── */
.form-section {
padding: 5rem 1.5rem;
background: radial-gradient(ellipse at 50% 100%, rgba(255,255,255,0.5) 0%, transparent 70%);
}
.form-wrapper {
max-width: 540px; margin: 0 auto;
background: rgba(255,255,255,0.6); backdrop-filter: blur(14px);
border-radius: 22px; padding: 2.5rem 2rem;
box-shadow: 0 8px 40px rgba(124,108,255,0.12);
border: 1px solid rgba(180,170,220,0.25);
}
.form-wrapper h2 {
text-align: center; font-size: 1.6rem; font-weight: 800;
margin-bottom: 0.4rem; color: #2d2b55;
}
.form-wrapper .form-sub {
text-align: center; color: #7a7599; margin-bottom: 1.75rem; font-size: 0.95rem;
}
.form-field { margin-bottom: 1.25rem; }
.form-field label {
display: block; font-size: 0.85rem; font-weight: 600;
color: #4a4570; margin-bottom: 0.35rem;
}
.form-field input,
.form-field textarea {
width: 100%; padding: 0.75rem 1rem;
border: 1.5px solid rgba(180,170,220,0.35);
border-radius: 10px; font-size: 1rem; font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
background: rgba(255,255,255,0.65);
}
.form-field input:focus,
.form-field textarea:focus {
outline: none; border-color: #7c6cff;
box-shadow: 0 0 0 3px rgba(124,108,255,0.15);
background: rgba(255,255,255,0.85);
}
.form-field textarea { resize: vertical; min-height: 90px; }
.form-submit {
width: 100%; padding: 0.85rem; border: none; border-radius: 12px;
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; font-size: 1.1rem; font-weight: 700;
cursor: pointer; transition: transform 0.2s, box-shadow 0.2s;
margin-top: 0.5rem;
box-shadow: 0 4px 16px rgba(124,108,255,0.3);
}
.form-submit:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(124,108,255,0.4); }
/* ── Footer ── */
.site-footer {
text-align: center; padding: 2rem 1.5rem;
color: #8a85a8; font-size: 0.85rem;
border-top: 1px solid rgba(180,170,220,0.15);
background: rgba(255,255,255,0.3);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.hero { padding: 6.5rem 1rem 3rem; }
.section { padding: 3rem 1rem; }
.form-section { padding: 3rem 1rem; }
.form-wrapper { padding: 1.75rem 1.25rem; }
.header-cta { display: none; }
.bg-deco svg { opacity: 0.15; }
}
</style>
</head>
<body>
<!-- Floating decorative SVG background -->
<div class="bg-deco" id="bgDeco" aria-hidden="true"></div>
<!-- Header -->
<header class="site-header">
<div class="header-inner">
<a href="/?lang={{ lang.code() }}" class="logo">🐾 {{ t.nav_title }}</a>
<a href="/admin" class="header-secret" aria-hidden="true"></a>
<div class="header-right">
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
<a href="#form" class="header-cta">{{ t.landing_hero_cta }}</a>
</div>
</div>
</header>
<!-- Hero -->
<section class="hero">
<span class="hero-emoji" role="img" aria-label="pets">🐱🐶</span>
<h1>{{ t.landing_hero_title }}</h1>
<p>{{ t.landing_hero_subtitle }}</p>
<a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a>
</section>
<!-- Services -->
<section class="section section-alt" id="services">
<div class="section-inner">
<h2 class="section-title">{{ t.landing_services_title }}</h2>
<div class="services-grid">
<article class="service-card">
<span class="service-icon" role="img" aria-label="cat">🐱</span>
<h3>{{ t.landing_service_cats_title }}</h3>
<p>{{ t.landing_service_cats_text }}</p>
</article>
<article class="service-card">
<span class="service-icon" role="img" aria-label="dog">🐕</span>
<h3>{{ t.landing_service_dogs_title }}</h3>
<p>{{ t.landing_service_dogs_text }}</p>
</article>
<article class="service-card">
<span class="service-icon" role="img" aria-label="home">🏠</span>
<h3>{{ t.landing_service_home_title }}</h3>
<p>{{ t.landing_service_home_text }}</p>
</article>
</div>
</div>
</section>
<!-- How it works -->
<section class="section" id="how">
<div class="section-inner">
<h2 class="section-title">{{ t.landing_how_title }}</h2>
<div class="steps">
<div class="step">
<span class="step-num">1</span>
<h3>{{ t.landing_how_step1_title }}</h3>
<p>{{ t.landing_how_step1_text }}</p>
</div>
<div class="step">
<span class="step-num">2</span>
<h3>{{ t.landing_how_step2_title }}</h3>
<p>{{ t.landing_how_step2_text }}</p>
</div>
<div class="step">
<span class="step-num">3</span>
<h3>{{ t.landing_how_step3_title }}</h3>
<p>{{ t.landing_how_step3_text }}</p>
</div>
</div>
</div>
</section>
<!-- Lead Form -->
<section class="form-section" id="form">
<div class="form-wrapper">
<h2>{{ t.landing_form_title }}</h2>
<p class="form-sub">{{ t.landing_form_subtitle }}</p>
<form method="post" action="/submit">
<div class="form-field">
<label for="name">{{ t.landing_form_name }}</label>
<input type="text" id="name" name="name" required autocomplete="name">
</div>
<div class="form-field">
<label for="phone">{{ t.landing_form_phone }}</label>
<input type="tel" id="phone" name="phone" autocomplete="tel">
</div>
<div class="form-field">
<label for="comment">{{ t.landing_form_comment }}</label>
<textarea id="comment" name="comment" placeholder="{{ t.landing_form_comment_placeholder }}"></textarea>
</div>
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
</form>
{% if !contact_info.is_empty() %}
<div style="text-align:center;margin-top:1.5rem;padding-top:1.25rem;border-top:1px solid rgba(180,170,220,0.25);">
<p style="color:#7a7599;font-size:0.9rem;margin-bottom:0.4rem;">{{ t.landing_contact_label }}</p>
<p style="font-size:1.1rem;font-weight:600;color:#2d2b55;">{{ contact_info }}</p>
</div>
{% endif %}
</div>
</section>
<!-- Footer -->
<footer class="site-footer">
<p>{{ t.landing_footer_text }}</p>
</footer>
<script>
(function() {
var icons = [
// Cat face
'<svg viewBox="0 0 80 80" width="W" height="W"><path d="M20 15 L10 2 L18 18 M60 15 L70 2 L62 18 M40 70 C18 70 8 52 8 38 C8 20 22 8 40 8 C58 8 72 20 72 38 C72 52 62 70 40 70Z" fill="none" stroke="C" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="C"/><circle cx="52" cy="36" r="3.5" fill="C"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="C"/><path d="M36 48 Q32 54 28 52 M44 48 Q48 54 52 52" fill="none" stroke="C" stroke-width="2" stroke-linecap="round"/></svg>',
// Dog face
'<svg viewBox="0 0 80 80" width="W" height="W"><path d="M15 28 Q6 18 12 8 Q18 14 22 22 M65 28 Q74 18 68 8 Q62 14 58 22" fill="none" stroke="C" stroke-width="3" stroke-linecap="round"/><ellipse cx="40" cy="44" rx="28" ry="26" fill="none" stroke="C" stroke-width="3"/><circle cx="30" cy="38" r="3" fill="C"/><circle cx="50" cy="38" r="3" fill="C"/><ellipse cx="40" cy="50" rx="5" ry="3.5" fill="C"/><path d="M40 53.5 L40 58 M36 58 Q40 62 44 58" fill="none" stroke="C" stroke-width="2" stroke-linecap="round"/></svg>',
// Bone
'<svg viewBox="0 0 100 44" width="W" height="H"><path d="M28 14 C28 6, 18 0, 12 6 C6 0, -2 8, 4 16 C0 20, 0 24, 4 28 C-2 36, 6 44, 12 38 C18 44, 28 38, 28 30 L72 30 C72 38, 82 44, 88 38 C94 44, 102 36, 96 28 C100 24, 100 20, 96 16 C102 8, 94 0, 88 6 C82 0, 72 6, 72 14 Z" fill="C" opacity="0.45"/></svg>',
// Bowl
'<svg viewBox="0 0 80 50" width="W" height="H2"><path d="M8 18 Q8 46 40 46 Q72 46 72 18 Z" fill="C" opacity="0.2"/><ellipse cx="40" cy="18" rx="34" ry="10" fill="none" stroke="C" stroke-width="3"/><path d="M8 18 Q8 46 40 46 Q72 46 72 18" fill="none" stroke="C" stroke-width="3" stroke-linejoin="round"/></svg>',
// Paw print
'<svg viewBox="0 0 60 70" width="W2" height="W"><ellipse cx="30" cy="46" rx="16" ry="18" fill="C" opacity="0.55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="C" opacity="0.55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="C" opacity="0.55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="C" opacity="0.55" transform="rotate(-5 24 8)"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="C" opacity="0.55" transform="rotate(5 38 8)"/></svg>',
// Fish
'<svg viewBox="0 0 80 40" width="W" height="H"><path d="M60 20 Q48 2 28 8 Q10 14 10 20 Q10 26 28 32 Q48 38 60 20Z" fill="C" opacity="0.25"/><path d="M60 20 Q48 2 28 8 Q10 14 10 20 Q10 26 28 32 Q48 38 60 20Z" fill="none" stroke="C" stroke-width="2.5"/><path d="M60 20 L74 6 L74 34 Z" fill="C" opacity="0.25" stroke="C" stroke-width="2.5" stroke-linejoin="round"/><circle cx="24" cy="18" r="2.5" fill="C"/></svg>',
// Heart
'<svg viewBox="0 0 60 55" width="W2" height="W2"><path d="M30 50 C10 35 0 22 0 14 C0 5 7 0 15 0 C21 0 26 3 30 9 C34 3 39 0 45 0 C53 0 60 5 60 14 C60 22 50 35 30 50Z" fill="C" opacity="0.5"/></svg>',
// Mouse toy
'<svg viewBox="0 0 70 50" width="W" height="H2"><ellipse cx="32" cy="28" rx="22" ry="16" fill="C" opacity="0.2"/><ellipse cx="32" cy="28" rx="22" ry="16" fill="none" stroke="C" stroke-width="2.5"/><path d="M12 22 Q2 10 10 6 M12 34 Q2 40 6 46" fill="none" stroke="C" stroke-width="2.5" stroke-linecap="round"/><circle cx="22" cy="24" r="2" fill="C"/><path d="M54 28 Q68 18 64 32 Q60 40 56 30" fill="none" stroke="C" stroke-width="2.5" stroke-linecap="round"/></svg>'
];
var colors = ['#7c6cff','#ff5287','#ff8c26','#00c9a7','#b06cff','#ff6eb4','#22c993','#4da6ff','#ffb340','#ff6b6b'];
var container = document.getElementById('bgDeco');
if (!container) return;
var count = Math.min(30, Math.floor(window.innerWidth / 50));
var items = [];
var seed = 42;
function rng() { seed = (seed * 16807 + 0) % 2147483647; return seed / 2147483647; }
for (var i = 0; i < count; i++) {
var iconIdx = Math.floor(rng() * icons.length);
var color = colors[Math.floor(rng() * colors.length)];
var size = 44 + Math.floor(rng() * 56);
var x = rng() * 100;
var y = rng() * 100;
var rotation = Math.floor(rng() * 360);
var speed = 0.15 + rng() * 0.35;
var rotSpeed = (rng() - 0.5) * 0.04;
var svg = icons[iconIdx]
.replace(/W2/g, String(Math.round(size * 0.75)))
.replace(/H2/g, String(Math.round(size * 0.6)))
.replace(/W/g, String(size))
.replace(/H/g, String(Math.round(size * 0.5)))
.replace(/C/g, color);
var wrapper = document.createElement('div');
wrapper.innerHTML = svg;
var el = wrapper.firstChild;
el.style.left = x + '%';
el.style.top = y + '%';
el.style.transform = 'rotate(' + rotation + 'deg)';
container.appendChild(el);
items.push({ el: el, baseY: y, rotation: rotation, speed: speed, rotSpeed: rotSpeed });
}
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(function() {
var scrollY = window.pageYOffset || document.documentElement.scrollTop;
for (var i = 0; i < items.length; i++) {
var it = items[i];
var offsetY = scrollY * it.speed;
var rot = it.rotation + scrollY * it.rotSpeed;
it.el.style.transform = 'translateY(' + (-offsetY) + 'px) rotate(' + rot + 'deg)';
}
ticking = false;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
})();
</script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox(event)">&times;</button>
<img id="lightboxImg" src="" alt="">
<video id="lightboxVideo" controls style="display:none;"></video>
</div>
<style>
.lightbox-overlay {
display:none; position:fixed; inset:0; z-index:200;
background:rgba(0,0,0,0.85); align-items:center; justify-content:center;
}
.lightbox-overlay.is-open { display:flex; }
.lightbox-overlay img, .lightbox-overlay video {
max-width:92vw; max-height:88vh; border-radius:8px; object-fit:contain;
}
.lightbox-close {
position:absolute; top:0.75rem; right:1rem; background:none; border:none;
color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201;
}
</style>
<script>
function openLightbox(url, isVideo) {
var lb = document.getElementById('lightbox');
var img = document.getElementById('lightboxImg');
var vid = document.getElementById('lightboxVideo');
if (isVideo) {
img.style.display = 'none';
vid.style.display = '';
vid.src = url;
} else {
vid.style.display = 'none';
vid.pause && vid.pause(); vid.src = '';
img.style.display = '';
img.src = url;
}
lb.classList.add('is-open');
}
function closeLightbox(e) {
if (e && e.target !== document.getElementById('lightbox') && e.target.className !== 'lightbox-close') return;
var lb = document.getElementById('lightbox');
lb.classList.remove('is-open');
var vid = document.getElementById('lightboxVideo');
vid.pause && vid.pause(); vid.src = '';
}
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(null); });
document.addEventListener('click', function(e) {
var a = e.target.closest('[data-lightbox]');
if (a) { e.preventDefault(); openLightbox(a.href, a.dataset.lightbox === 'video'); }
});
</script>
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
color: #1a1a2e; background: linear-gradient(170deg, #f0eeff 0%, #fff 60%);
min-height: 100vh; display: flex; align-items: center; justify-content: center;
}
.card {
text-align: center; max-width: 480px; padding: 3rem 2rem;
background: #fff; border-radius: 20px;
box-shadow: 0 8px 40px rgba(108,99,255,0.1);
border: 1px solid #e8e6ff; margin: 1rem;
}
.icon { font-size: 4rem; margin-bottom: 1rem; display: block; }
h1 { font-size: 1.8rem; font-weight: 800; margin-bottom: 0.75rem; }
p { color: #555; font-size: 1.05rem; margin-bottom: 2rem; line-height: 1.6; }
.back-link {
display: inline-block; padding: 0.75rem 2rem; border-radius: 12px;
background: #6c63ff; color: #fff; font-weight: 700; font-size: 1rem;
text-decoration: none; transition: background 0.2s, transform 0.2s;
}
.back-link:hover { background: #5a52d5; transform: translateY(-1px); }
</style>
</head>
<body>
<div class="card">
<span class="icon" role="img" aria-label="check"></span>
<h1>{{ t.landing_thank_you_title }}</h1>
<p>{{ t.landing_thank_you_text }}</p>
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
</div>
</body>
</html>