This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
|
||||
<button class="lightbox-close" onclick="closeLightbox(event)">×</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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user