ogrechkoandClaude Sonnet 5 f53c8b1ed7 Fix missing H1 on list-style pages via SectionHeading template
SectionHeading always rendered its title as <h2>, so every page whose
only heading came from a shared section component (services hub,
promotions, cases, faq, contact, reviews, blog list) had no <h1> at
all. Add a level prop (default h2) and pass headingLevel="h1" from
each page where that section is the page's own top-level heading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
2026-08-26 00:00:24 +03:00

TopSysOps - сайт + админ-панель

Продакшн-версия сайта top-sysops.ru: публичная часть с серверным рендерингом на Next.js (Главная, Акции, Услуги, Кейсы, FAQ, Блог с рич-текст редактором, Отзывы, Контакты), личный кабинет для клиентов (переписка с командой, ИИ-чат, профиль) и админ-панель (дашборд посещаемости, модерация отзывов, редактор прайс-листа, редактор акций, редактор кейсов и FAQ, редактор блога, переписка с клиентами, ИИ-ассистент для сотрудников, виджет чата поддержки на сайте). SEO: sitemap.xml, robots.txt и разметка schema.org генерируются самим Next.js.

Из чего состоит

├── docker-compose.yml     # четыре сервиса: db, backend, nextjs, web
├── .env.example           # шаблон настроек, скопировать в .env
├── backend/                     # Node.js/Express API + PostgreSQL
├── public-site/                 # Next.js (SSR) - публичный сайт и блог
└── admin-app/                   # React (Vite) - админка и личный кабинет + Caddy
  • db - PostgreSQL. Хранит услуги/цены, акции, отзывы, статьи блога, статистику визитов, клиентов и учётные записи администраторов.
  • backend - REST API. При первом запуске сам накатывает миграции и создаёт администратора и стартовый прайс-лист/акции (те же позиции, что были на старом сайте).
  • nextjs - публичный сайт и блог, отдаются с сервера (SSR) - это и есть ответ на "нужен SEO": краулер получает готовый HTML с разметкой schema.org, а не пустую страницу, которую дорисовывает JavaScript. Не имеет отдельного порта наружу - доступен только через Caddy.
  • web - Caddy: собирает и раздаёт статику admin-app (админка и личный кабинет - им SEO не нужен, поэтому остались обычным SPA), проксирует /api/* на backend, всё остальное - на nextjs. Caddy же сам получает и продлевает HTTPS-сертификат Let's Encrypt - вручную ничего настраивать не нужно, только домен в .env.

Почему не переписали админку и кабинет на Next.js тоже: SEO им не нужно вообще (они закрыты в robots.txt и требуют авторизации), а полный переезд уже готовой и работающей админки ради нулевой выгоды - это только риск что-то сломать. SSR применён именно там, где от него есть польза - на страницах, которые должны индексироваться.

Требования

  • Сервер на Ubuntu 24.04 (2 ГБ RAM хватит с запасом).
  • Домен, у которого A-запись указывает на IP сервера - нужен для автоматического HTTPS. Если домена пока нет, можно временно развернуть по IP без HTTPS (см. .env.example).
  • Открытые порты 80 и 443.

1. Установка Docker на Ubuntu 24

sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# чтобы не писать sudo перед каждой командой docker (перелогиньтесь после этого)
sudo usermod -aG docker $USER

Проверка: docker compose version.

2. Загрузка проекта на сервер

Скопируйте всю папку проекта на сервер, например через scp или git:

scp -r topsysops-app your-user@your-server-ip:~/topsysops-app
# или, если проект в git-репозитории:
git clone <ваш-репозиторий> topsysops-app

3. Настройка .env

cd topsysops-app
cp .env.example .env
nano .env

Обязательно замените:

  • POSTGRES_PASSWORD - пароль базы данных;
  • JWT_SECRET - длинная случайная строка (openssl rand -hex 32);
  • ADMIN_USERNAME / ADMIN_PASSWORD - логин и пароль первого администратора;
  • DOMAIN - ваш домен (например top-sysops.ru) или :80, если тестируете по IP без HTTPS.

Файл .env содержит секреты - он уже добавлен в .gitignore, не публикуйте его.

4. Запуск

docker compose up -d --build

Первый запуск соберёт образы (нужен интернет на сервере), поднимет базу, применит миграции, создаст администратора и стартовый прайс-лист/акции, затем запустит сайт. Проверить статус и логи:

docker compose ps
docker compose logs -f backend

Дождитесь строки [start] backend listening on port 4000, затем откройте сайт в браузере по вашему домену (или http://IP-сервера, если DOMAIN=:80).

Если используете реальный домен, Caddy автоматически выпустит сертификат при первом обращении по HTTPS - на это может уйти несколько секунд.

Если TLS у вас на Nginx Proxy Manager

Именно этот сценарий - в .env.example он уже настроен по умолчанию: DOMAIN=http://top-sysops.ru, HTTP_PORT=7778, порт 443 у контейнера закомментирован в docker-compose.yml. Со стороны NPM:

  1. Hosts -> Proxy Hosts -> Add Proxy Host.
  2. Domain Names: ваш домен (top-sysops.ru).
  3. Scheme: http, Forward Hostname/IP: IP или hostname сервера, где запущен docker compose (127.0.0.1, если NPM на той же машине; иначе реальный IP), Forward Port: 7778 (или то, что указали в HTTP_PORT).
  4. Вкладка SSL: выберите/запросите сертификат Let's Encrypt, включите Force SSL - весь HTTPS полностью на стороне NPM, контейнеру об этом вообще думать не нужно.
  5. Save. Проверьте, что docker compose ps показывает web со проброшенным портом 7778->80 - именно на него должен смотреть Forward Port в NPM.

Если после этого видите "too many redirects" - смотрите раздел "Устранение неполадок" ниже, обычно это означает, что DOMAIN в .env задан без схемы http://.

5. Первый вход в админку

Откройте https://ваш-домен/admin, войдите под ADMIN_USERNAME / ADMIN_PASSWORD из .env, затем сразу зайдите в Настройки -> Сменить пароль и задайте собственный пароль - значение из .env дальше не используется системой, но лучше не оставлять его действующим.

Из админки доступно:

  • Дашборд - визиты (всего/сегодня/график за 7 дней), количество отзывов;
  • Отзывы - одобрить / отклонить / удалить;
  • Клиенты - список зарегистрированных клиентов и переписка с ними (ответ на сообщения из личного кабинета, метка «ждёт ответа» для непрочитанных);
  • Прайс-лист - добавление и редактирование категорий и позиций услуг, скрытие позиции с сайта без удаления (иконка глаза);
  • Акции - то же самое для акций;
  • Кейсы - карточки портфолио (название, тег, статус с цветным индикатором, описание), с возможностью скрыть без удаления;
  • FAQ - вопросы и ответы для сайта;
  • Блог - статьи (заголовок, слаг, краткое описание, текст в WYSIWYG-редакторе с форматированием - заголовки, списки, ссылки, цитаты - публикация) - попадают на по-настоящему серверно отрендеренную страницу /blog/<slug> и в sitemap.xml сразу после сохранения;
  • Контакты - адрес, телефон, email, режим работы, ссылка на карту;
  • ИИ-ассистент - внутренний чат для сотрудников на базе OpenRouter (см. ниже);
  • Настройки - смена пароля и управление администраторами (добавить нового сотрудника с логином/паролем, удалить лишнего - кроме себя и последнего оставшегося администратора).

Изменения в прайсе, акциях, кейсах и FAQ появляются на сайте в течение минуты: Next.js кэширует данные с публичного сайта на 60 секунд (revalidate: 60), это плата за серверный рендеринг - страницы отдаются мгновенно, а не после похода в базу на каждый заход. В самой админке изменения видны сразу же, без задержки.

Обновление прайс-листа на уже развёрнутом сайте

Стартовый прайс-лист (в seed.js) применяется только к пустой базе - то есть только при самом первом запуске. Для уже развёрнутого сайта обновление новой линейки услуг оформлено как обычные миграции (backend/migrations/006_update_services_2026_07.sql и 007_landing_tier.sql) - они применяются автоматически при следующем запуске бэкенда, ничего вручную запускать не нужно:

docker compose up -d --build

Система миграций (backend/src/migrate.js) гарантирует, что каждый файл выполнится ровно один раз - если уже разворачивали сайт раньше и обновляете код сейчас, эти миграции просто окажутся среди тех, что применятся при следующем перезапуске backend, наравне с остальными.

Что делают эти миграции: убирают старую категорию "Разработка сайтов" (от 5 000 ₽) и добавляют:

  • Разработка под ключ - Лендинг (от 20 000 ₽, простая посадочная без бота/админки), MVP «Быстрый старт» (от 120 000 ₽, с формой-ботом и простой админкой), MVP Pro (от 450 000 ₽, с личным кабинетом и ИИ-ассистентом - как этот сайт), продакшн-разработка (от 1 200 000 ₽, индивидуально), Discovery-воркшоп (от 30 000 ₽), сопровождение проекта (от 40 000 ₽/мес);
  • White-label и партнёрство - white-label «Кабинет + ИИ-ассистент» (от 500 000 ₽), IT-мониторинг сайта (от 10 000 ₽/мес), реферальная и партнёрская программы.

Цифры сверены с рынком Санкт-Петербурга (лендинги студийного уровня - 19 000-65 000 ₽, MVP с личным кабинетом у студий - от 260 000 ₽ и выше).

Дальше все эти позиции редактируются как обычно - через админку («Прайс-лист»).

Если после docker compose up -d --build старая категория всё равно видна - проверьте docker compose logs backend | grep migrate: там должны быть строки applying 006_update_services_2026_07.sql и applying 007_landing_tier.sql. Если их нет - значит контейнер backend не пересобрался/не перезапустился с новым кодом (проверьте docker compose ps, при необходимости docker compose up -d --build backend отдельно).

SEO

  • robots.txt и sitemap.xml - генерируются самим Next.js (public-site/app/robots.js и app/sitemap.js), закрывают /admin и /account от индексации, доступны по адресам https://ваш-домен/robots.txt и /sitemap.xml. Sitemap включает главную, /blog и все опубликованные статьи - обновляется автоматически.
  • schema.org (JSON-LD) - рендерится прямо на сервере (это заслуга SSR: разметка оказывается в HTML, который получает краулер, а не дорисовывается JS уже в браузере). На сайте автоматически публикуется LocalBusiness (адрес, телефон, email из раздела «Контакты», рейтинг из одобренных отзывов), FAQPage (из раздела FAQ) и каталог услуг (hasOfferCatalog, из прайс-листа) - помогает Google/Яндексу показывать расширенные сниппеты (рейтинг со звёздами, раскрывающиеся вопросы в выдаче). На страницах статей блога - разметка Article.
  • Блог с рич-текст редактором - основной канал органического трафика для B2B IT-услуг. Пишите статьи через админку («Блог», обычный WYSIWYG-редактор: заголовки, списки, ссылки, цитаты) - каждая становится отдельной, по-настоящему серверно отрендеренной страницей на /blog/<slug> и сразу появляется в sitemap.xml.
  • Серверный рендеринг (SSR) - публичный сайт и блог теперь на Next.js: краулер и превью в соцсетях получают готовый HTML сразу, без ожидания JavaScript. Админка и личный кабинет остались SPA на Vite - им это не нужно, они и так закрыты в robots.txt и требуют входа.
  • Что дальше сделать руками (не автоматизируется из кода): зарегистрировать сайт в Яндекс.Вебмастере и Google Search Console (там же отправить sitemap.xml вручную первый раз), завести профиль в Яндекс.Картах/Google Business с тем же адресом и телефоном, что в разделе «Контакты» (важно для локального SEO по запросам вида «IT-аутсорсинг Санкт-Петербург»).

Чат поддержки на сайте (TopTicket / Chatwoot)

Виджет чата для посетителей сайта настраивается в .env и встраивается на этапе сборки Next.js-приложения (значения переменных NEXT_PUBLIC_* попадают в клиентский бандл, поэтому смена провайдера требует пересборки контейнера nextjs, а не web).

По умолчанию уже включён TopTicket (ваша собственная разработка, help.top-sysops.ru) с реальными данными. Выберите вариант в .env:

# TopTicket (по умолчанию) - embed-сниппет вашей разработки:
# <script src="https://help.top-sysops.ru/widget.js" data-key="..." async>
CHAT_PROVIDER=topticket
TOPTICKET_WIDGET_URL=https://help.top-sysops.ru/widget.js
TOPTICKET_WIDGET_KEY=EBpDS4JqT7a-_MzZ

# либо Chatwoot
CHAT_PROVIDER=chatwoot
CHATWOOT_BASE_URL=https://chat.lamlaba.ru
CHATWOOT_WEBSITE_TOKEN=ваш-website-token   # Inboxes -> ваш инбокс -> Configuration

# либо совсем без чата
CHAT_PROVIDER=none

После изменения .env:

docker compose up -d --build nextjs

Виджет подключается только на публичных страницах сайта, в админке не показывается.

ИИ-ассистент в админке (OpenRouter)

Это отдельный, второй чат - не для посетителей сайта, а внутренний инструмент для сотрудников: помогает набросать ответ на отзыв, посчитать что-то по прайсу и т.д. Доступен только тем, кто вошёл в админку.

Работает через OpenRouter - агрегатор моделей разных провайдеров под одним API. Ключ используется только на бэкенде и никогда не попадает в браузер:

OPENROUTER_API_KEY=sk-or-...        # https://openrouter.ai/keys
OPENROUTER_MODEL=openai/gpt-4o-mini # любая модель, доступная в вашем аккаунте

После заполнения:

docker compose up -d backend

Если OPENROUTER_API_KEY не задан, раздел «ИИ-ассистент» в админке просто покажет предупреждение и не будет пытаться обращаться к API - остальной сайт при этом продолжает работать как обычно.

Контакты

Адрес, телефон, email, режим работы и ссылка на карту редактируются в админке (раздел «Контакты», /admin/contact) — как и прайс-лист, акции, кейсы и FAQ, без пересборки. Пустое поле просто не показывается на сайте (например, если не хотите публиковать телефон).

Личный кабинет клиентов

Отдельная система входа для посетителей сайта - не путать с админкой. Регистрация и вход доступны по адресам /account/register и /account, на главной странице есть заметная плашка-приглашение с этими же ссылками.

После входа клиенту доступны:

  • Обзор - приветствие и текущие акции;
  • Написать нам - личная переписка с командой TopSysOps. Сообщения клиента видны в админке (раздел «Клиенты»), сотрудник отвечает прямо оттуда, ответ сразу появляется в кабинете клиента;
  • ИИ-ассистент - второй, отдельный чат, отвечает на вопросы об услугах и ценах через тот же OpenRouter, что и ассистент в админке (см. выше), но с собственным системным промптом и более строгим лимитом запросов;
  • Профиль - изменение имени/компании/телефона, смена пароля.

Регистрация клиентов и вход в админку - две независимые системы: у них разные таблицы в базе (customers и admin_users) и разные сессионные cookie (ts_customer_session и ts_admin_session), так что сотрудник может быть одновременно залогинен и в кабинет, и в админку в одном браузере без конфликтов.

Дополнительная защита от спама/перебора: на регистрацию, вход, отправку сообщений и обращения к ИИ-ассистенту в кабинете клиента тоже действует express-rate-limit, как и в остальном API.

Резервное копирование базы данных

docker compose exec db pg_dump -U topsysops topsysops > backup-$(date +%F).sql

Восстановление из бэкапа:

cat backup-2026-07-25.sql | docker compose exec -T db psql -U topsysops topsysops

Рекомендуется добавить это в cron, например раз в сутки.

Обновление после изменений в коде

git pull            # если используете git
docker compose up -d --build

Данные в PostgreSQL хранятся в именованном томе db_data и не теряются при пересборке контейнеров.

Устранение неполадок

  • docker compose logs -f backend - если сайт не открывается, чаще всего проблема видна тут (например, не заполнен JWT_SECRET).
  • docker compose logs -f nextjs - если не открывается главная страница или блог (например, не может достучаться до бэкенда - тогда секции будут просто пустыми, а в логе будет [serverApi] failed to fetch ...).
  • docker compose logs -f web - если не выдаётся HTTPS-сертификат, здесь будут сообщения Caddy (обычно это неверный DNS: домен ещё не указывает на сервер).
  • Если меняли DOMAIN в .env, перезапустите web: docker compose up -d web.
  • "Too many redirects" / бесконечный редирект - значит перед контейнером стоит внешний реверс-прокси со своим TLS-сертификатом (например, Nginx Proxy Manager - см. раздел выше), а Caddy внутри контейнера всё равно пытается сам сделать редирект http->https. Исправляется так: в .env поставьте DOMAIN=http://ваш-домен (со схемой http://) и перезапустите web (docker compose up -d web) - это отключит автоматический HTTPS/редирект в Caddy, он будет просто отдавать http, а шифрование останется на внешнем nginx. Также проверьте, что внешний nginx передаёт заголовки Host, X-Forwarded-Proto и X-Real-IP/X-Forwarded-For.
  • Полный сброс базы (например, во время тестов): docker compose down -v удалит и данные - используйте только осознанно.

Безопасность на будущее

  • Не открывайте порт 5432 (PostgreSQL) наружу - в текущей конфигурации он и так не проброшен на хост, доступен только другим контейнерам.
  • Ограничьте доступ по SSH и настройте ufw (ufw allow 80,443,OpenSSH, затем ufw enable). Если используете сценарий с внешним nginx и внутренним портом вроде 7778 (HTTP_PORT=7778), этот порт наружу открывать не нужно - трафик между nginx и контейнером идёт локально, через него наружу открыты только 80/443 самого nginx.
  • express-rate-limit уже ограничивает количество попыток входа в админку и количество отзывов с одного IP - это защита от перебора паролей и спама, но не полноценный WAF; при росте нагрузки рассмотрите Cloudflare или аналог.
S
Description
No description provided
Readme
167 KiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 85.6%
PLpgSQL 13.3%
Dockerfile 0.6%
CSS 0.3%
HTML 0.2%