Migrate web app from Python/FastAPI to Node.js/TypeScript

Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack:
- Framework: Hono + @hono/node-server
- Templates: Nunjucks (.njk) replacing Jinja2 (.html)
- ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed)
- Sessions: hono-sessions with CookieStore
- CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept)
- Dev: tsx watch; Prod: tsc + node dist/index.js

Original Python app preserved in web-python/ as backup.
Updated Dockerfile.web and docker-compose.yml for Node.js deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-17 19:33:32 +03:00
parent db0c1cbed3
commit 854c912a88
100 changed files with 5770 additions and 39 deletions

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
<div class="container">
<a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if user %}
<li class="nav-item">
<a href="/connections" class="nav-link">Подключения</a>
</li>
<li class="nav-item">
<a href="/catalog" class="nav-link">Каталог</a>
</li>
<li class="nav-item">
<a href="/sync" class="nav-link">Синхронизация</a>
</li>
<li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
</li>
<li class="nav-item">
<a href="/logout" class="nav-link text-muted">Выход</a>
</li>
{% else %}
<li class="nav-item">
<a href="/login" class="nav-link">Вход</a>
</li>
<li class="nav-item">
<a href="/register" class="nav-link">Регистрация</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="container py-4">
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<p class="mb-1">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success">
<p class="mb-0">{{ success }}</p>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item active">{{ store.name }}</li>
</ol>
</nav>
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">Группы товаров</h1>
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
{% if not groups %}
<div class="text-center py-5 text-muted">
<i class="bi bi-folder fs-1 mb-3 d-block"></i>
<p>Группы не найдены в этом магазине.</p>
<a href="/catalog/products?store_id={{ store.evotor_id }}" class="btn btn-outline-primary btn-sm">
Посмотреть все товары магазина
</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Кол-во товаров</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
{% set mode = filter_map.get(group.evotor_id) %}
<tr>
<td>{{ group.name }}</td>
<td class="text-muted">{{ product_counts.get(group.evotor_id, 0) }}</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
class="btn btn-outline-secondary btn-sm" title="Товары">
<i class="bi bi-arrow-right"></i>
</a>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
</form>
</li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Товары — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
{% if group %}
<li class="breadcrumb-item active">{{ group.name }}</li>
{% else %}
<li class="breadcrumb-item active">Все товары</li>
{% endif %}
</ol>
</nav>
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
<a href="/catalog/export?type=products&store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
{% if not products %}
<div class="text-center py-5 text-muted">
<i class="bi bi-box fs-1 mb-3 d-block"></i>
<p>Товары не найдены.</p>
</div>
{% else %}
<div class="table-responsive" style="overflow: visible;">
<table class="table table-striped table-hover align-middle small">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Артикул</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Ед. изм.</th>
<th>В продаже</th>
<th>Синхронизирован</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for product in products %}
{% set mode = filter_map.get(product.evotor_id) %}
<tr>
<td>{{ product.name }}</td>
<td class="text-muted font-monospace">{{ product.article_number or "—" }}</td>
<td>{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}</td>
<td>{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}</td>
<td class="text-muted">{{ product.measure_name or "—" }}</td>
<td>
{% if product.allow_to_sell is none %}
<span class="text-muted"></span>
{% elif product.allow_to_sell %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-danger"></i>
{% endif %}
</td>
<td>
{% if product.synced_at %}
<span title="{{ product.synced_at.strftime('%d.%m.%Y %H:%M') }}">
<i class="bi bi-check-circle-fill text-success"></i>
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
{% endif %}
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-strategy="fixed">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}Каталог — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">Каталог</h1>
<div class="d-flex gap-2">
{% if evotor %}
<form method="post" action="/catalog/refresh">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить
</button>
</form>
<a href="/catalog/export?type=stores" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
{% endif %}
</div>
</div>
{% if not evotor %}
<div class="alert alert-warning d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
</div>
{% elif not stores %}
<div class="text-center py-5 text-muted">
<i class="bi bi-shop fs-1 mb-3 d-block"></i>
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
</div>
{% else %}
{% if fetched_at %}
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}</p>
{% endif %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for store in stores %}
{% set mode = filter_map.get(store.evotor_id) %}
<tr>
<td>{{ store.name }}</td>
<td class="text-muted small">{{ store.address or "—" }}</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
class="btn btn-outline-secondary btn-sm" title="Группы">
<i class="bi bi-arrow-right"></i>
</a>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
</form>
</li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-5 text-center">
<div class="card-body p-5">
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Подключения — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h1 class="h4 mb-0">Подключения</h1>
<a href="/connections/add" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Добавить
</a>
</div>
{% if connections %}
<div class="row g-3">
{% for conn in connections %}
<div class="col-sm-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
<div>
<h5 class="mb-0">{{ conn.name }}</h5>
{% if conn.details %}
<small class="text-muted">{{ conn.details }}</small>
{% endif %}
</div>
<div class="ms-auto">
{% if conn.is_online %}
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
{% else %}
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ conn.configure_url }}" class="btn btn-outline-primary btn-sm flex-fill">Настроить</a>
<form method="post" action="/connections/delete?type={{ conn.type }}">
<button type="submit"
class="btn btn-outline-danger btn-sm"
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
Отключить
</button>
</form>
</div>
</div>
<div class="card-footer text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
{% else %}
Статус ещё не проверялся
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-plug fs-1 mb-3 d-block"></i>
<p class="mb-3">Нет подключённых сервисов</p>
<a href="/connections/add" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-items-center mb-4">
<a href="/connections" class="text-muted me-3"><i class="bi bi-arrow-left fs-5"></i></a>
<h1 class="h4 mb-0">Добавить подключение</h1>
</div>
{% if available %}
<div class="row g-3">
{% for svc in available %}
<div class="col-sm-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center mb-3">
<i class="bi {{ svc.icon }} fs-2 me-3 text-secondary"></i>
<h5 class="mb-0">{{ svc.name }}</h5>
</div>
<p class="text-muted small flex-grow-1">{{ svc.description }}</p>
<a href="{{ svc.connect_url }}" class="btn btn-primary btn-sm mt-auto">
Подключить <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-check-circle fs-1 mb-3 d-block text-success"></i>
<p class="mb-3">Все доступные сервисы подключены</p>
<a href="/connections" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-5 text-center">
<div class="card-body p-5">
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
<h1 class="h4 mb-3">Email подтвержден!</h1>
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
<a href="/login" class="btn btn-primary mt-2">Войти</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div class="alert alert-danger mt-4">
{% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
{% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
{% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %}
</div>
{% endif %}
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0">Подключение Эвотор</h1>
</div>
{% if connection %}
{# ── CONNECTED STATE ── #}
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.store_name %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Магазин</span>
<span>{{ connection.store_name }}</span>
</li>
{% endif %}
{% if connection.store_id %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">ID магазина</span>
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li>
</ul>
<div class="card-footer">
<p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
<form method="post" action="/evotor/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
<form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
{% else %}
{# ── NOT CONNECTED STATE ── #}
<div class="card-body">
<p class="text-muted mb-3">
Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
и скопировать токен доступа из его настроек.
</p>
<ol class="text-muted small mb-4">
{% if app_url %}
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
{% else %}
<li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
{% endif %}
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
</ol>
<form method="post" action="/evotor/token">
<div class="mb-3">
<label class="form-label small text-muted">Токен доступа</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Подключить</button>
</div>
</form>
</div>
{% endif %}
</div>
<div class="mt-3 text-center">
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-2">Забыли пароль?</h1>
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
<form method="post" action="/forgot-password">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" name="email" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Отправить ссылку для сброса</button>
</div>
</form>
<div class="mt-3 text-center small">
<a href="/login">Вернуться ко входу</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Вход — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-4">Вход</h1>
<form method="post" action="/login">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" name="email" class="form-control"
value="{{ form.email if form else '' }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Войти</button>
</div>
</form>
<div class="mt-3 text-center small">
<a href="/forgot-password">Забыли пароль?</a><br>
<a href="/register">Зарегистрироваться</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-5 text-center">
<div class="card-body p-5">
<h1 class="h4 mb-3">{{ title }}</h1>
<p class="text-muted">{{ message }}</p>
{% if link %}
<a href="{{ link }}" class="btn btn-primary mt-2">{{ link_text }}</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0"><i class="bi bi-key me-2"></i>Изменить пароль</h1>
</div>
<div class="card-body p-4">
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/profile/change-password">
<div class="mb-3">
<label for="current_password" class="form-label">Текущий пароль</label>
<input type="password" id="current_password" name="current_password" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Новый пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтвердить пароль</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Изменить пароль</button>
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4 border-danger">
<div class="card-header bg-danger text-white">
<h1 class="h5 mb-0"><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
</div>
<div class="card-body p-4">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
</div>
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/profile/delete">
<div class="mb-4">
<label for="password" class="form-label">Введите пароль для подтверждения</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
</button>
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0"><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
</div>
<div class="card-body p-4">
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/profile/edit">
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="first_name" class="form-label">Имя</label>
<input type="text" id="first_name" name="first_name" class="form-control"
value="{{ form.first_name if form else user.first_name }}" required>
</div>
<div class="col-sm-6">
<label for="last_name" class="form-label">Фамилия</label>
<input type="text" id="last_name" name="last_name" class="form-control"
value="{{ form.last_name if form else user.last_name }}" required>
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">Email</label>
<input type="email" class="form-control" value="{{ user.email }}" disabled>
</div>
<div class="mb-4">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" id="phone" name="phone" class="form-control"
value="{{ form.phone if form else user.phone }}" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0"><i class="bi bi-person-circle me-2"></i>Личный кабинет</h1>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted small">Имя</span>
<span>{{ user.first_name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted small">Фамилия</span>
<span>{{ user.last_name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted small">Email</span>
<span>{{ user.email }}</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span class="text-muted small">Телефон</span>
<span>{{ user.phone }}</span>
</li>
</ul>
<div class="card-body d-grid gap-2">
<a href="/profile/edit" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>Редактировать профиль
</a>
<a href="/profile/change-password" class="btn btn-secondary">
<i class="bi bi-key me-1"></i>Изменить пароль
</a>
<a href="/logout" class="btn btn-outline-secondary">
<i class="bi bi-box-arrow-right me-1"></i>Выход
</a>
<a href="/profile/delete" class="btn btn-outline-danger btn-sm mt-2">
<i class="bi bi-trash me-1"></i>Удалить аккаунт
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-4">Регистрация</h1>
<form method="post" action="/register">
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="first_name" class="form-label">Имя</label>
<input type="text" id="first_name" name="first_name" class="form-control"
value="{{ form.first_name if form else '' }}">
</div>
<div class="col-sm-6">
<label for="last_name" class="form-label">Фамилия</label>
<input type="text" id="last_name" name="last_name" class="form-control"
value="{{ form.last_name if form else '' }}">
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email <span class="text-danger">*</span></label>
<input type="email" id="email" name="email" class="form-control"
value="{{ form.email if form else '' }}" required>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон <span class="text-danger">*</span></label>
<input type="tel" id="phone" name="phone" class="form-control"
value="{{ form.phone if form else '' }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль <span class="text-danger">*</span></label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтверждение пароля <span class="text-danger">*</span></label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
</div>
</form>
<div class="mt-3 text-center small">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-4">Новый пароль</h1>
<form method="post" action="/reset-password?token={{ token }}">
<div class="mb-3">
<label for="password" class="form-label">Новый пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтверждение пароля</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Сменить пароль</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
{% block content %}
<h1 class="h4 mb-4">Синхронизация</h1>
{% if not evotor %}
<div class="alert alert-warning d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
</div>
{% endif %}
{% if not vk %}
<div class="alert alert-warning d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>ВКонтакте не подключён. <a href="/vk">Подключить ВКонтакте</a></span>
</div>
{% endif %}
<div class="row g-3">
{# ── Status card ── #}
<div class="col-12 col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Статус</h5>
<div class="mb-3">
{% if status == "active" %}
<span class="badge bg-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
{% elif status == "paused" %}
<span class="badge bg-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
{% elif status == "pending" %}
<span class="badge bg-warning text-dark fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
{% else %}
<span class="badge bg-light text-dark border fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
{% endif %}
</div>
{% if config.confirmed_at %}
<p class="text-muted small mb-3">
Запущена: {{ config.confirmed_at.strftime("%d.%m.%Y %H:%M") }}
</p>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
{# Toggle enable/disable #}
<form method="post" action="/sync/toggle">
{% if config.is_enabled %}
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pause-fill me-1"></i>Приостановить
</button>
{% else %}
<button type="submit" class="btn btn-outline-primary btn-sm" {% if not evotor or not vk %}disabled{% endif %}>
<i class="bi bi-play-fill me-1"></i>Включить
</button>
{% endif %}
</form>
{# Confirm button #}
{% if config.is_enabled and summary.total > 0 %}
<form method="post" action="/sync/confirm">
<button type="submit" class="btn btn-success btn-sm">
<i class="bi bi-check-lg me-1"></i>Подтвердить и запустить
</button>
</form>
{% endif %}
</div>
{% if config.is_enabled and summary.total == 0 %}
<p class="text-muted small mt-2 mb-0">
<i class="bi bi-info-circle me-1"></i>Настройте фильтры, чтобы подтвердить запуск.
</p>
{% endif %}
</div>
</div>
</div>
{# ── Filters card ── #}
<div class="col-12 col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Фильтры</h5>
{% if summary.total > 0 %}
<ul class="list-unstyled mb-3">
{% if summary.stores > 0 %}
<li class="text-muted small"><i class="bi bi-shop me-1"></i>Магазины: {{ summary.stores }} правил</li>
{% endif %}
{% if summary.groups > 0 %}
<li class="text-muted small"><i class="bi bi-folder me-1"></i>Группы: {{ summary.groups }} правил</li>
{% endif %}
{% if summary.products > 0 %}
<li class="text-muted small"><i class="bi bi-box me-1"></i>Товары: {{ summary.products }} правил</li>
{% endif %}
</ul>
{% else %}
<p class="text-muted small mb-3">Фильтры не настроены — будут синхронизированы все товары.</p>
{% endif %}
<a href="/catalog" class="btn btn-outline-primary btn-sm" {% if not evotor %}disabled{% endif %}>
<i class="bi bi-sliders me-1"></i>Настроить фильтры
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div class="alert alert-danger mt-4">
{% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен или у него нет прав администратора сообщества.
{% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
{% elif error == "no_client_id" %}
<i class="bi bi-exclamation-triangle me-2"></i>Автоматическое подключение не настроено. Введите токен вручную.
{% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %}
</div>
{% endif %}
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0">Подключение ВКонтакте</h1>
</div>
{% if connection %}
{# ── CONNECTED STATE ── #}
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.first_name %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Сообщество</span>
<span>{{ connection.first_name }}</span>
</li>
{% endif %}
{% if connection.vk_user_id %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">ID сообщества</span>
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li>
</ul>
<div class="card-footer">
<p class="text-muted small mb-2">Обновить токен пользователя:</p>
<form method="post" action="/vk/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен пользователя" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
<form method="post" action="/vk/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить ВКонтакте</button>
</form>
</div>
{% else %}
{# ── NOT CONNECTED STATE ── #}
<div class="card-body">
{% if vk_client_id %}
<p class="text-muted mb-4">
Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества.
</p>
<div class="d-grid mb-3">
<a href="/vk/connect" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
</a>
</div>
<hr class="my-4">
<p class="text-muted small mb-2">Или введите токен вручную:</p>
{% else %}
<p class="text-muted mb-3">
Для синхронизации товаров необходим <strong>токен пользователя</strong> ВКонтакте
с правами на управление товарами сообщества.
</p>
{% endif %}
<form method="post" action="/vk/token">
<div class="mb-3">
<label class="form-label small text-muted">Токен пользователя ВКонтакте</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен пользователя" required {% if vk_client_id %}{% else %}autofocus{% endif %}>
</div>
<div class="d-grid">
<button type="submit" class="btn {% if vk_client_id %}btn-outline-secondary{% else %}btn-primary{% endif %}">Подключить</button>
</div>
</form>
</div>
{% endif %}
</div>
<div class="mt-3 text-center">
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4 text-center">
<div class="card-body py-5">
<div id="state-loading">
<div class="spinner-border text-primary mb-3" role="status"></div>
<p class="text-muted mb-0">Подключение ВКонтакте…</p>
</div>
<div id="state-error" class="d-none">
<i class="bi bi-exclamation-triangle-fill text-danger fs-1 mb-3 d-block"></i>
<p class="text-muted mb-3" id="error-message">Не удалось получить токен от ВКонтакте.</p>
<a href="/vk" class="btn btn-outline-secondary">Попробовать снова</a>
</div>
</div>
</div>
</div>
</div>
<form id="token-form" method="post" action="/vk/token" class="d-none">
<input type="hidden" name="token" id="token-input">
</form>
<script>
(function () {
var hash = window.location.hash.slice(1);
var params = {};
hash.split("&").forEach(function (part) {
var kv = part.split("=");
params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || "");
});
if (params.access_token) {
document.getElementById("token-input").value = params.access_token;
document.getElementById("token-form").submit();
} else {
var msg = params.error_description || params.error || "Авторизация отклонена.";
document.getElementById("error-message").textContent = msg;
document.getElementById("state-loading").classList.add("d-none");
document.getElementById("state-error").classList.remove("d-none");
}
})();
</script>
{% endblock %}