Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf62c80d5 | |||
| 4cc07632f0 | |||
| 7b0017d1f4 | |||
| 1d2722b715 | |||
| bfd0aec56f | |||
| 68d578b29d | |||
| 21331b75a8 | |||
| 6395e36c62 |
@@ -2,3 +2,4 @@
|
|||||||
/data
|
/data
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
/uploads
|
/uploads
|
||||||
|
.DS_Store
|
||||||
|
|||||||
Generated
+1
-1
@@ -3255,7 +3255,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.4"
|
version = "0.1.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.4"
|
version = "0.1.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ FROM debian:bookworm-slim
|
|||||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||||
|
COPY static /app/static
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["web-petting"]
|
CMD ["web-petting"]
|
||||||
|
|||||||
+44
-10
@@ -182,7 +182,9 @@ struct DashboardTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
today_visits: Vec<TodayVisit>,
|
today_visits: Vec<TodayVisit>,
|
||||||
recent_feedbacks: Vec<RecentFeedback>,
|
feedbacks: Vec<RecentFeedback>,
|
||||||
|
feedback_page: usize,
|
||||||
|
feedback_total_pages: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -494,13 +496,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
.collect();
|
.collect();
|
||||||
today_visits.sort_by(|a, b| a.visit.time_start.cmp(&b.visit.time_start));
|
today_visits.sort_by(|a, b| a.visit.time_start.cmp(&b.visit.time_start));
|
||||||
|
|
||||||
let week_ago = today - chrono::Duration::days(7);
|
let mut all_feedbacks: Vec<RecentFeedback> = all_visits
|
||||||
let mut recent_feedbacks: Vec<RecentFeedback> = all_visits
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|v| {
|
.filter(|v| {
|
||||||
v.user_id.primary_key().unwrap() == user_id
|
v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()
|
||||||
&& v.client_feedback.is_some()
|
|
||||||
&& v.updated_at.date() >= week_ago
|
|
||||||
})
|
})
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
let cid: i64 = v.client_id.primary_key().unwrap();
|
let cid: i64 = v.client_id.primary_key().unwrap();
|
||||||
@@ -517,14 +516,34 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
recent_feedbacks.sort_by(|a, b| b.visit_date.cmp(&a.visit_date));
|
all_feedbacks.sort_by(|a, b| b.visit_date.cmp(&a.visit_date));
|
||||||
|
|
||||||
|
const PER_PAGE: usize = 10;
|
||||||
|
let feedback_page: usize = request
|
||||||
|
.uri()
|
||||||
|
.query()
|
||||||
|
.and_then(|q| {
|
||||||
|
q.split('&')
|
||||||
|
.find_map(|p| p.strip_prefix("page="))
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
let feedback_total_pages = (all_feedbacks.len() + PER_PAGE - 1).max(1) / PER_PAGE.max(1);
|
||||||
|
let feedbacks: Vec<RecentFeedback> = all_feedbacks
|
||||||
|
.into_iter()
|
||||||
|
.skip((feedback_page - 1) * PER_PAGE)
|
||||||
|
.take(PER_PAGE)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let body = DashboardTemplate {
|
let body = DashboardTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
today_visits,
|
today_visits,
|
||||||
recent_feedbacks,
|
feedbacks,
|
||||||
|
feedback_page,
|
||||||
|
feedback_total_pages,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -773,6 +792,7 @@ struct SettingsForm {
|
|||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
|
site_domain: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
@@ -788,6 +808,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
("contact_info", form.contact_info),
|
("contact_info", form.contact_info),
|
||||||
("pricing_info", form.pricing_info),
|
("pricing_info", form.pricing_info),
|
||||||
("timezone", form.timezone),
|
("timezone", form.timezone),
|
||||||
|
("site_domain", form.site_domain),
|
||||||
] {
|
] {
|
||||||
let k = key.to_string();
|
let k = key.to_string();
|
||||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||||
@@ -1569,7 +1590,12 @@ async fn media_upload_submit(
|
|||||||
media.save(&db).await?;
|
media.save(&db).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Redirect::new(format!("/admin/?lang={}", lang.code())).into_response()
|
Redirect::new(format!(
|
||||||
|
"/admin/schedule/{}/edit?lang={}",
|
||||||
|
visit_id,
|
||||||
|
lang.code()
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn media_delete(
|
async fn media_delete(
|
||||||
@@ -1582,11 +1608,19 @@ async fn media_delete(
|
|||||||
if let Err(resp) = require_auth(&session, lang).await {
|
if let Err(resp) = require_auth(&session, lang).await {
|
||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
|
let referer = request
|
||||||
|
.headers()
|
||||||
|
.get("referer")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
||||||
m.status = "archived".to_string();
|
m.status = "archived".to_string();
|
||||||
m.save(&db).await?;
|
m.save(&db).await?;
|
||||||
}
|
}
|
||||||
Redirect::new(format!("/admin/media?lang={}", lang.code())).into_response()
|
let redirect_url = referer
|
||||||
|
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
||||||
|
.unwrap_or_else(|| format!("/admin/media?lang={}", lang.code()));
|
||||||
|
Redirect::new(redirect_url).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve uploaded files by media ID.
|
/// Serve uploaded files by media ID.
|
||||||
|
|||||||
+9
-6
@@ -131,6 +131,7 @@ pub struct Translations {
|
|||||||
pub settings_contact_info: &'static str,
|
pub settings_contact_info: &'static str,
|
||||||
pub settings_pricing_info: &'static str,
|
pub settings_pricing_info: &'static str,
|
||||||
pub settings_timezone: &'static str,
|
pub settings_timezone: &'static str,
|
||||||
|
pub settings_site_domain: &'static str,
|
||||||
pub landing_contact_label: &'static str,
|
pub landing_contact_label: &'static str,
|
||||||
pub landing_pricing_title: &'static str,
|
pub landing_pricing_title: &'static str,
|
||||||
|
|
||||||
@@ -282,7 +283,7 @@ static RU: Translations = Translations {
|
|||||||
nav_visits: "Визиты",
|
nav_visits: "Визиты",
|
||||||
nav_users: "Админы",
|
nav_users: "Админы",
|
||||||
nav_settings: "Настройки",
|
nav_settings: "Настройки",
|
||||||
nav_title: "Пет-ситтинг",
|
nav_title: "МурНяня.РФ",
|
||||||
|
|
||||||
leads_title: "Заявки",
|
leads_title: "Заявки",
|
||||||
leads_empty: "Заявок пока нет.",
|
leads_empty: "Заявок пока нет.",
|
||||||
@@ -340,6 +341,7 @@ static RU: Translations = Translations {
|
|||||||
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
settings_contact_info: "Контактная информация (отображается на лендинге)",
|
||||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||||
|
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||||
landing_pricing_title: "Стоимость",
|
landing_pricing_title: "Стоимость",
|
||||||
|
|
||||||
@@ -360,7 +362,7 @@ static RU: Translations = Translations {
|
|||||||
media_delete_confirm: "Удалить этот файл?",
|
media_delete_confirm: "Удалить этот файл?",
|
||||||
media_all_clients: "Все клиенты",
|
media_all_clients: "Все клиенты",
|
||||||
|
|
||||||
portal_title: "Мои визиты",
|
portal_title: "Визиты",
|
||||||
portal_upcoming: "Предстоящие визиты",
|
portal_upcoming: "Предстоящие визиты",
|
||||||
portal_past: "Прошлые визиты",
|
portal_past: "Прошлые визиты",
|
||||||
portal_no_upcoming: "Нет предстоящих визитов.",
|
portal_no_upcoming: "Нет предстоящих визитов.",
|
||||||
@@ -450,7 +452,7 @@ static RU: Translations = Translations {
|
|||||||
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
|
landing_guarantee: "Порядочность, честность и строгое выполнение ваших требований гарантировано.",
|
||||||
landing_testimonials_title: "Отзывы",
|
landing_testimonials_title: "Отзывы",
|
||||||
landing_form_consent: "Я даю согласие на обработку персональных данных",
|
landing_form_consent: "Я даю согласие на обработку персональных данных",
|
||||||
landing_footer_text: "Пет-ситтинг — забота о вашем питомце",
|
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||||
landing_footer_copyright: "Все права защищены",
|
landing_footer_copyright: "Все права защищены",
|
||||||
|
|
||||||
nav_testimonials: "Отзывы",
|
nav_testimonials: "Отзывы",
|
||||||
@@ -481,7 +483,7 @@ static EN: Translations = Translations {
|
|||||||
nav_visits: "Visits",
|
nav_visits: "Visits",
|
||||||
nav_users: "Admins",
|
nav_users: "Admins",
|
||||||
nav_settings: "Settings",
|
nav_settings: "Settings",
|
||||||
nav_title: "Pet Sitting",
|
nav_title: "МурНяня.РФ",
|
||||||
|
|
||||||
leads_title: "Leads",
|
leads_title: "Leads",
|
||||||
leads_empty: "No leads yet.",
|
leads_empty: "No leads yet.",
|
||||||
@@ -539,6 +541,7 @@ static EN: Translations = Translations {
|
|||||||
settings_contact_info: "Contact info (shown on landing page)",
|
settings_contact_info: "Contact info (shown on landing page)",
|
||||||
settings_pricing_info: "Pricing block (shown on landing page)",
|
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||||
|
settings_site_domain: "Site domain (e.g. https://example.com)",
|
||||||
landing_contact_label: "Or contact us directly",
|
landing_contact_label: "Or contact us directly",
|
||||||
landing_pricing_title: "Pricing",
|
landing_pricing_title: "Pricing",
|
||||||
|
|
||||||
@@ -559,7 +562,7 @@ static EN: Translations = Translations {
|
|||||||
media_delete_confirm: "Delete this file?",
|
media_delete_confirm: "Delete this file?",
|
||||||
media_all_clients: "All clients",
|
media_all_clients: "All clients",
|
||||||
|
|
||||||
portal_title: "My Visits",
|
portal_title: "Visits",
|
||||||
portal_upcoming: "Upcoming visits",
|
portal_upcoming: "Upcoming visits",
|
||||||
portal_past: "Past visits",
|
portal_past: "Past visits",
|
||||||
portal_no_upcoming: "No upcoming visits.",
|
portal_no_upcoming: "No upcoming visits.",
|
||||||
@@ -649,7 +652,7 @@ static EN: Translations = Translations {
|
|||||||
landing_guarantee: "Integrity, honesty, and strict fulfillment of your requirements guaranteed.",
|
landing_guarantee: "Integrity, honesty, and strict fulfillment of your requirements guaranteed.",
|
||||||
landing_testimonials_title: "Testimonials",
|
landing_testimonials_title: "Testimonials",
|
||||||
landing_form_consent: "I consent to the processing of my personal data",
|
landing_form_consent: "I consent to the processing of my personal data",
|
||||||
landing_footer_text: "Pet Sitting — caring for your pet",
|
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
|
||||||
landing_footer_copyright: "All rights reserved",
|
landing_footer_copyright: "All rights reserved",
|
||||||
|
|
||||||
nav_testimonials: "Testimonials",
|
nav_testimonials: "Testimonials",
|
||||||
|
|||||||
+113
@@ -72,6 +72,8 @@ struct LandingTemplate<'a> {
|
|||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
testimonials: Vec<Testimonial>,
|
testimonials: Vec<Testimonial>,
|
||||||
|
site_domain: String,
|
||||||
|
review_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -95,15 +97,24 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
.await?
|
.await?
|
||||||
.map(|s| s.value)
|
.map(|s| s.value)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let domain_key = "site_domain".to_string();
|
||||||
|
let site_domain = query!(Setting, $key == domain_key)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_else(|| "https://example.net".to_string());
|
||||||
let mut testimonials = Testimonial::objects().all(&db).await?;
|
let mut testimonials = Testimonial::objects().all(&db).await?;
|
||||||
testimonials.retain(|t| t.status == "active");
|
testimonials.retain(|t| t.status == "active");
|
||||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||||
|
let review_count = testimonials.len();
|
||||||
let body = LandingTemplate {
|
let body = LandingTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
contact_info,
|
contact_info,
|
||||||
pricing_info,
|
pricing_info,
|
||||||
testimonials,
|
testimonials,
|
||||||
|
site_domain,
|
||||||
|
review_count,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -367,9 +378,111 @@ async fn serve_testimonial_image(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn favicon(_request: Request) -> cot::Result<Response> {
|
||||||
|
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<ellipse cx="32" cy="42" rx="14" ry="16" fill="#7c6cff"/>
|
||||||
|
<ellipse cx="14" cy="20" rx="7" ry="9" fill="#7c6cff" transform="rotate(-10 14 20)"/>
|
||||||
|
<ellipse cx="50" cy="20" rx="7" ry="9" fill="#7c6cff" transform="rotate(10 50 20)"/>
|
||||||
|
<ellipse cx="23" cy="8" rx="5.5" ry="7" fill="#7c6cff" transform="rotate(-5 23 8)"/>
|
||||||
|
<ellipse cx="41" cy="8" rx="5.5" ry="7" fill="#7c6cff" transform="rotate(5 41 8)"/>
|
||||||
|
</svg>"##;
|
||||||
|
let mut resp = Response::new(cot::Body::fixed(svg.as_bytes().to_vec()));
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert("content-type", "image/svg+xml".parse().unwrap());
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert("cache-control", "public, max-age=604800".parse().unwrap());
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_static(_request: Request, Path(filename): Path<String>) -> cot::Result<Response> {
|
||||||
|
// Only allow simple filenames (no path traversal)
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return Html::new("404").into_response();
|
||||||
|
}
|
||||||
|
let ext = filename.rsplit('.').next().unwrap_or("");
|
||||||
|
// Try relative path first, then /app/static/ (Docker)
|
||||||
|
let path = format!("static/{filename}");
|
||||||
|
let data = match tokio::fs::read(&path).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => match tokio::fs::read(format!("/app/static/{filename}")).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return Html::new("404").into_response(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let content_type = match ext {
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
let body = cot::Body::fixed(data);
|
||||||
|
let mut resp = Response::new(body);
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert("content-type", content_type.parse().unwrap());
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert("cache-control", "public, max-age=604800".parse().unwrap());
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn robots_txt(_request: Request, db: Database) -> cot::Result<Response> {
|
||||||
|
let domain_key = "site_domain".to_string();
|
||||||
|
let site_domain = query!(Setting, $key == domain_key)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_else(|| "https://example.net".to_string());
|
||||||
|
let body = format!(
|
||||||
|
"User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /client/\nSitemap: {}/sitemap.xml\n",
|
||||||
|
site_domain
|
||||||
|
);
|
||||||
|
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert("content-type", "text/plain; charset=utf-8".parse().unwrap());
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sitemap_xml(_request: Request, db: Database) -> cot::Result<Response> {
|
||||||
|
let domain_key = "site_domain".to_string();
|
||||||
|
let site_domain = query!(Setting, $key == domain_key)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_else(|| "https://example.net".to_string());
|
||||||
|
let body = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
<url>
|
||||||
|
<loc>{domain}/?lang=ru</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="ru" href="{domain}/?lang=ru"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="{domain}/?lang=en"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="{domain}/"/>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>{domain}/?lang=en</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="ru" href="{domain}/?lang=ru"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="{domain}/?lang=en"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="{domain}/"/>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
"#,
|
||||||
|
domain = site_domain
|
||||||
|
);
|
||||||
|
let mut resp = Response::new(cot::Body::fixed(body.into_bytes()));
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert("content-type", "application/xml; charset=utf-8".parse().unwrap());
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn public_router() -> Router {
|
pub fn public_router() -> Router {
|
||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
Route::with_handler_and_name("/", landing_page, "landing"),
|
Route::with_handler_and_name("/", landing_page, "landing"),
|
||||||
|
Route::with_handler_and_name("/favicon.svg", favicon, "favicon"),
|
||||||
|
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
|
||||||
|
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
|
||||||
|
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
|
||||||
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/testimonial-image/{id}",
|
"/testimonial-image/{id}",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use chrono::TimeZone;
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use cot::db::{Database, Model, query};
|
use cot::db::{Database, query};
|
||||||
|
|
||||||
use crate::models::Setting;
|
use crate::models::Setting;
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
@@ -47,19 +47,37 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recent feedbacks -->
|
<!-- Feedbacks -->
|
||||||
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
|
<h2 style="font-size:1.15rem;font-weight:700;margin:1.5rem 0 0.75rem;">{{ t.dashboard_recent_feedbacks }}</h2>
|
||||||
{% if recent_feedbacks.is_empty() %}
|
{% if feedbacks.is_empty() %}
|
||||||
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
|
<p class="has-text-grey">{{ t.dashboard_no_feedbacks }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for fb in &recent_feedbacks %}
|
{% for fb in &feedbacks %}
|
||||||
<div class="item-card" style="border-left:3px solid #7c6cff;">
|
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" class="item-card" style="border-left:3px solid #7c6cff;display:block;text-decoration:none;color:inherit;">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
|
||||||
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
|
<strong style="font-size:0.9rem;">{{ fb.client_name }}</strong>
|
||||||
<a href="/admin/schedule/{{ fb.visit_id }}/edit?lang={{ lang.code() }}" style="color:#999;font-size:0.8rem;text-decoration:none;">{{ fb.visit_date }}</a>
|
<span style="color:#999;font-size:0.8rem;">{{ fb.visit_date }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
<div style="font-size:0.85rem;color:#4a4570;">{{ fb.feedback }}</div>
|
||||||
</div>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if feedback_total_pages > 1 %}
|
||||||
|
<div style="display:flex;justify-content:center;gap:0.5rem;margin-top:1rem;">
|
||||||
|
{% if feedback_page > 1 %}
|
||||||
|
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page - 1 }}" class="button is-small is-light">«</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in 1..=feedback_total_pages %}
|
||||||
|
{% if p == feedback_page %}
|
||||||
|
<span class="button is-small is-primary">{{ p }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="/admin/?lang={{ lang.code() }}&page={{ p }}" class="button is-small is-light">{{ p }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if feedback_page < feedback_total_pages %}
|
||||||
|
<a href="/admin/?lang={{ lang.code() }}&page={{ feedback_page + 1 }}" class="button is-small is-light">»</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
<title>{{ t.nav_title }} — {% block title %}{% endblock %}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<style>
|
||||||
:root { --accent: #6c63ff; color-scheme: light; }
|
:root { --accent: #6c63ff; color-scheme: light; }
|
||||||
|
|||||||
@@ -4,11 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<style>
|
||||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
:root { color-scheme: light; }
|
||||||
|
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||||
.login-box { width: 100%; max-width: 380px; padding: 0 1rem; }
|
.login-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); }
|
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||||
|
input, textarea, select, .input, .textarea, .select select {
|
||||||
|
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
||||||
|
}
|
||||||
|
.label, label { color: #363636 !important; }
|
||||||
|
.notification { color: #333 !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -97,11 +97,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</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() %}
|
{% if let Some(fb) = visit.client_feedback.as_deref() %}
|
||||||
<div style="margin-top:1rem;">
|
<div style="margin-bottom:1rem;">
|
||||||
<label class="label">{{ t.schedule_client_feedback }}</label>
|
<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 style="background:#f0f0ff;border-radius:8px;padding:0.6rem 0.85rem;font-size:0.9rem;color:#4a4570;">{{ fb }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +109,7 @@
|
|||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
<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>
|
<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>
|
<button type="button" class="button is-info is-small is-outlined" onclick="document.getElementById('uploadModal').classList.add('is-open')">+ {{ t.media_upload }}</button>
|
||||||
</div>
|
</div>
|
||||||
{% if media.is_empty() %}
|
{% if media.is_empty() %}
|
||||||
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
|
<p class="has-text-grey is-size-7" style="margin-bottom:1rem;">{{ t.media_empty }}</p>
|
||||||
@@ -132,20 +129,47 @@
|
|||||||
{% if let Some(cap) = m.caption.as_deref() %}
|
{% if let Some(cap) = m.caption.as_deref() %}
|
||||||
<div class="media-cap">{{ cap }}</div>
|
<div class="media-cap">{{ cap }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<hr style="margin:1rem 0;">
|
||||||
|
|
||||||
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
<hr style="margin:1rem 0;">
|
||||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
||||||
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
<button type="submit" class="button is-danger is-outlined is-fullwidth is-small">{{ t.schedule_delete }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div class="upload-modal-bg" id="uploadModal">
|
||||||
|
<div class="upload-modal">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
||||||
|
<button type="button" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.media_choose_files }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.media_caption }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.visit-media-grid {
|
.visit-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -182,8 +206,31 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.visit-media-item form {
|
.upload-modal-bg {
|
||||||
padding: 0.2rem;
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.upload-modal-bg.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.upload-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('uploadModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_site_domain }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.settings_timezone }}</label>
|
<label class="label">{{ t.settings_timezone }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
|||||||
@@ -4,11 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.setup_title }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
<style>
|
<style>
|
||||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
:root { color-scheme: light; }
|
||||||
|
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||||
.login-box { width: 100%; max-width: 400px; padding: 0 1rem; }
|
.login-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); }
|
.login-card { background: #fff; border-radius: 12px; padding: 2rem 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||||
|
input, textarea, select, .input, .textarea, .select select {
|
||||||
|
background-color: #fff !important; color: #333 !important; border-color: #dbdbdb !important;
|
||||||
|
}
|
||||||
|
.label, label { color: #363636 !important; }
|
||||||
|
.notification { color: #333 !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
.feedback-form textarea {
|
.feedback-form textarea {
|
||||||
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
|
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;
|
font-size: 0.85rem; font-family: inherit; resize: vertical; min-height: 50px;
|
||||||
|
background-color: #fff; color: #333;
|
||||||
}
|
}
|
||||||
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
|
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
|
||||||
.feedback-form button {
|
.feedback-form button {
|
||||||
|
|||||||
+52
-2
@@ -5,11 +5,26 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="{{ t.landing_meta_description }}">
|
<meta name="description" content="{{ t.landing_meta_description }}">
|
||||||
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.landing_hero_title }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
|
||||||
|
<!-- Canonical & Hreflang -->
|
||||||
|
<link rel="canonical" href="{{ site_domain }}/?lang={{ lang.code() }}">
|
||||||
|
<link rel="alternate" hreflang="ru" href="{{ site_domain }}/?lang=ru">
|
||||||
|
<link rel="alternate" hreflang="en" href="{{ site_domain }}/?lang=en">
|
||||||
|
<link rel="alternate" hreflang="x-default" href="{{ site_domain }}/">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
<meta property="og:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||||
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
<meta property="og:description" content="{{ t.landing_meta_description }}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="{{ site_domain }}/?lang={{ lang.code() }}">
|
||||||
|
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
|
||||||
|
<meta property="og:site_name" content="{{ t.nav_title }}">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||||
|
<meta name="twitter:description" content="{{ t.landing_meta_description }}">
|
||||||
|
|
||||||
<!-- Structured Data -->
|
<!-- Structured Data -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
@@ -19,7 +34,15 @@
|
|||||||
"name": "{{ t.nav_title }}",
|
"name": "{{ t.nav_title }}",
|
||||||
"description": "{{ t.landing_meta_description }}",
|
"description": "{{ t.landing_meta_description }}",
|
||||||
"serviceType": "Pet Sitting",
|
"serviceType": "Pet Sitting",
|
||||||
"@id": "#business"
|
"url": "{{ site_domain }}/",
|
||||||
|
"@id": "#business"{% if review_count > 0 %},
|
||||||
|
"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": "5",
|
||||||
|
"bestRating": "5",
|
||||||
|
"ratingCount": "{{ review_count }}",
|
||||||
|
"reviewCount": "{{ review_count }}"
|
||||||
|
}{% endif %}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -96,6 +119,25 @@
|
|||||||
box-shadow: 0 4px 20px rgba(124,108,255,0.35);
|
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-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(124,108,255,0.45); }
|
||||||
|
.hero { position: relative; overflow: clip; }
|
||||||
|
.hero-photo {
|
||||||
|
position: absolute; z-index: 0;
|
||||||
|
width: 420px; height: 420px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 20px solid #ffe0ec;
|
||||||
|
object-fit: cover;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.hero-photo-tr { right: -100px; top: -100px; }
|
||||||
|
.hero-photo-bl { left: -100px; bottom: -150px; }
|
||||||
|
.hero-content { position: relative; z-index: 1; }
|
||||||
|
.section-alt { position: relative; z-index: 1; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hero-photo { width: 200px; height: 200px; border-width: 12px; }
|
||||||
|
.hero-photo-tr { right: -50px; top: -50px; }
|
||||||
|
.hero-photo-bl { left: -50px; bottom: -70px; }
|
||||||
|
.hero-cta { display: block; width: fit-content; margin-left: auto; margin-right: 0; }
|
||||||
|
}
|
||||||
.hero-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
|
.hero-emoji { font-size: 4rem; margin-bottom: 1rem; display: block; }
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-size: clamp(0.9rem, 2vw, 1.05rem);
|
font-size: clamp(0.9rem, 2vw, 1.05rem);
|
||||||
@@ -118,6 +160,7 @@
|
|||||||
border-radius: 18px; padding: 1.75rem;
|
border-radius: 18px; padding: 1.75rem;
|
||||||
border: 1px solid rgba(180,170,220,0.2);
|
border: 1px solid rgba(180,170,220,0.2);
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.testimonial-card:hover {
|
.testimonial-card:hover {
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
@@ -147,6 +190,7 @@
|
|||||||
border-radius: 18px; padding: 2rem 2.5rem;
|
border-radius: 18px; padding: 2rem 2.5rem;
|
||||||
border: 1px solid rgba(180,170,220,0.2);
|
border: 1px solid rgba(180,170,220,0.2);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.pricing-text {
|
.pricing-text {
|
||||||
font-size: clamp(1.1rem, 2.5vw, 1.3rem);
|
font-size: clamp(1.1rem, 2.5vw, 1.3rem);
|
||||||
@@ -177,12 +221,13 @@
|
|||||||
border-radius: 18px; padding: 2rem 1.75rem;
|
border-radius: 18px; padding: 2rem 1.75rem;
|
||||||
border: 1px solid rgba(180,170,220,0.2);
|
border: 1px solid rgba(180,170,220,0.2);
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.service-card:hover {
|
.service-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
|
box-shadow: 0 12px 36px rgba(124,108,255,0.12);
|
||||||
}
|
}
|
||||||
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; }
|
.service-icon { font-size: 2.5rem; margin-bottom: 0.75rem; display: block; text-align: center; }
|
||||||
.service-card h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.5rem; color: #2d2b55; }
|
.service-card 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; }
|
.service-card p { color: #5a5680; font-size: 0.95rem; line-height: 1.6; }
|
||||||
|
|
||||||
@@ -210,6 +255,7 @@
|
|||||||
border-radius: 22px; padding: 2.5rem 2rem;
|
border-radius: 22px; padding: 2.5rem 2rem;
|
||||||
box-shadow: 0 8px 40px rgba(124,108,255,0.12);
|
box-shadow: 0 8px 40px rgba(124,108,255,0.12);
|
||||||
border: 1px solid rgba(180,170,220,0.25);
|
border: 1px solid rgba(180,170,220,0.25);
|
||||||
|
-webkit-transform: translateZ(0); transform: translateZ(0);
|
||||||
}
|
}
|
||||||
.form-wrapper h2 {
|
.form-wrapper h2 {
|
||||||
text-align: center; font-size: 1.6rem; font-weight: 800;
|
text-align: center; font-size: 1.6rem; font-weight: 800;
|
||||||
@@ -286,11 +332,15 @@
|
|||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
|
<img class="hero-photo hero-photo-tr" src="/static/cat_up_right.png" alt="">
|
||||||
|
<img class="hero-photo hero-photo-bl" src="/static/cat_bottom_left.png" alt="">
|
||||||
|
<div class="hero-content">
|
||||||
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
<span class="hero-emoji" role="img" aria-label="pets">🐱🐹🦎</span>
|
||||||
<h1>{{ t.landing_hero_title }}</h1>
|
<h1>{{ t.landing_hero_title }}</h1>
|
||||||
<p>{{ t.landing_hero_subtitle }}</p>
|
<p>{{ t.landing_hero_subtitle }}</p>
|
||||||
<p class="hero-desc">{{ t.landing_hero_description }}</p>
|
<p class="hero-desc">{{ t.landing_hero_description }}</p>
|
||||||
<a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a>
|
<a href="#form" class="hero-cta">{{ t.landing_hero_cta }}</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Services -->
|
<!-- Services -->
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.landing_thank_you_title }}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
|||||||
Reference in New Issue
Block a user