Initial commit: TopSysOps site + admin panel

Multi-page public site (Next.js), Node/Express backend with Postgres,
and Vite admin/account app, restructured from a single-page layout
into dedicated service, promotions, cases, about, FAQ, and blog pages
for SEO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-25 23:12:16 +03:00
commit 130c5382cd
108 files changed
+7146

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
# Скопируйте этот файл в .env и заполните реальными значениями:
# cp .env.example .env
# --- PostgreSQL ---
POSTGRES_DB=topsysops
POSTGRES_USER=topsysops
POSTGRES_PASSWORD=change-me-to-a-long-random-password
# --- Бэкенд ---
# Длинная случайная строка для подписи сессионных JWT-токенов.
# Сгенерировать: openssl rand -hex 32
JWT_SECRET=change-me-to-a-long-random-string
# Логин и пароль первого администратора. Создаются один раз при первом
# запуске (если таблица admin_users пуста) - после этого меняются только
# из самой админ-панели (Настройки -> Сменить пароль).
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password
# Оставьте true в проде (сайт работает по HTTPS через Caddy).
# Поставьте false, только если временно открываете сайт по http:// без домена
# (например, тестируете по IP-адресу сервера) - иначе браузер не примет cookie.
COOKIE_SECURE=true
# --- Домен / Caddy ---
# Вариант А - Caddy сам получает и продлевает сертификат Let's Encrypt (нет
# внешнего реверс-прокси перед контейнером):
# DOMAIN=top-sysops.ru
# Вариант Б - перед контейнером уже стоит свой реверс-прокси со своим
# сертификатом (Nginx Proxy Manager, обычный nginx и т.п.), который проксирует
# сюда по обычному http. В этом случае ОБЯЗАТЕЛЬНО добавьте схему http:// -
# иначе Caddy сам попытается сделать редирект на https и получится
# бесконечный редирект ("too many redirects"):
# DOMAIN=http://top-sysops.ru
# Вариант В - тестируете по IP сервера, без домена и без HTTPS вообще:
# DOMAIN=:80
DOMAIN=http://top-sysops.ru
# Порт, на котором Caddy отдаёт сайт наружу (обычный http, без TLS - шифрование
# на внешнем реверс-прокси). Настройте этот же порт как target в Nginx Proxy
# Manager (Proxy Host -> Forward Hostname/IP: адрес сервера, Forward Port: это
# значение).
HTTP_PORT=7778
# Порт для HTTPS - нужен только в варианте А (Caddy сам управляет сертификатом).
# В варианте Б (ваш случай, внешний реверс-прокси) он не используется вообще -
# в docker-compose.yml проброс "443:443" уже закомментирован по умолчанию.
HTTPS_PORT=443
# --- Чат поддержки на публичном сайте ---
# Выберите один провайдер: topticket, chatwoot или none (чат выключен).
# Смена значения требует пересборки: docker compose up -d --build nextjs
CHAT_PROVIDER=topticket
# Если CHAT_PROVIDER=topticket - embed-сниппет вашей собственной разработки
# TopTicket: <script src="..." data-key="..." async>.
# URL и ключ ниже уже вписаны под help.top-sysops.ru.
TOPTICKET_WIDGET_URL=https://help.top-sysops.ru/widget.js
TOPTICKET_WIDGET_KEY=EBpDS4JqT7a-_MzZ
# Если CHAT_PROVIDER=chatwoot - адрес инсталляции и Website Token
# (Chatwoot -> Inboxes -> ваш website-инбокс -> Settings -> вкладка Configuration).
CHATWOOT_BASE_URL=https://chat.lamlaba.ru
CHATWOOT_WEBSITE_TOKEN=
# --- ИИ-ассистент в админ-панели (OpenRouter) ---
# Внутренний инструмент для сотрудников (раздел "ИИ-ассистент" в админке).
# Ключ и модель просто оставьте пустыми/по умолчанию, если ассистент не нужен -
# раздел покажет предупреждение и не будет пытаться обращаться к API.
# Получить ключ: https://openrouter.ai/keys
OPENROUTER_API_KEY=
# Любая модель, доступная в вашем аккаунте OpenRouter, например:
# openai/gpt-4o-mini, anthropic/claude-3.5-haiku, google/gemini-2.0-flash-001
OPENROUTER_MODEL=openai/gpt-4o-mini
# Используется как базовый URL в sitemap.xml и в заголовке HTTP-Referer
# запросов к OpenRouter. Укажите реальный домен сайта.
PUBLIC_SITE_URL=https://top-sysops.ru
+4
View File
@@ -0,0 +1,4 @@
.env
node_modules/
dist/
*.log
+390
View File
@@ -0,0 +1,390 @@
# 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
```bash
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`:
```bash
scp -r topsysops-app your-user@your-server-ip:~/topsysops-app
# или, если проект в git-репозитории:
git clone <ваш-репозиторий> topsysops-app
```
## 3. Настройка .env
```bash
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. Запуск
```bash
docker compose up -d --build
```
Первый запуск соберёт образы (нужен интернет на сервере), поднимет базу,
применит миграции, создаст администратора и стартовый прайс-лист/акции, затем
запустит сайт. Проверить статус и логи:
```bash
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`) - они применяются **автоматически** при следующем
запуске бэкенда, ничего вручную запускать не нужно:
```bash
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`:
```bash
# 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`:
```bash
docker compose up -d --build nextjs
```
Виджет подключается только на публичных страницах сайта, в админке не показывается.
## ИИ-ассистент в админке (OpenRouter)
Это отдельный, второй чат - не для посетителей сайта, а внутренний инструмент
для сотрудников: помогает набросать ответ на отзыв, посчитать что-то по
прайсу и т.д. Доступен только тем, кто вошёл в админку.
Работает через [OpenRouter](https://openrouter.ai/) - агрегатор моделей разных
провайдеров под одним API. Ключ используется только на бэкенде и никогда не
попадает в браузер:
```bash
OPENROUTER_API_KEY=sk-or-... # https://openrouter.ai/keys
OPENROUTER_MODEL=openai/gpt-4o-mini # любая модель, доступная в вашем аккаунте
```
После заполнения:
```bash
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.
## Резервное копирование базы данных
```bash
docker compose exec db pg_dump -U topsysops topsysops > backup-$(date +%F).sql
```
Восстановление из бэкапа:
```bash
cat backup-2026-07-25.sql | docker compose exec -T db psql -U topsysops topsysops
```
Рекомендуется добавить это в cron, например раз в сутки.
## Обновление после изменений в коде
```bash
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-&gt;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 или аналог.
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.env
+28
View File
@@ -0,0 +1,28 @@
{$DOMAIN} {
encode gzip
handle /api/* {
reverse_proxy backend:4000
}
# /assets* - собранные JS/CSS файлы admin-app (Vite кладёт их сюда
# независимо от того, с /admin или /account их запросили).
@app path /admin* /account* /assets*
handle @app {
root * /srv
try_files {path} /index.html
file_server
}
# Всё остальное (главная страница, /blog и т.д.) обслуживает Next.js -
# он же сам отдаёт /sitemap.xml и /robots.txt.
handle {
reverse_proxy nextjs:3000
}
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
}
}
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<title>TopSysOps — панель управления</title>
<meta name="robots" content="noindex, nofollow" />
<meta
name="description"
content="Админ-панель и личный кабинет TopSysOps."
/>
</head>
<body class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "topsysops-admin-app",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.383.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-quill": "^2.0.0",
"react-router-dom": "^6.24.1",
"recharts": "^2.12.7"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"vite": "^5.3.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+57
View File
@@ -0,0 +1,57 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './admin/Login.jsx';
import AdminLayout from './admin/AdminLayout.jsx';
import Dashboard from './admin/Dashboard.jsx';
import ReviewsManager from './admin/ReviewsManager.jsx';
import ServicesEditor from './admin/ServicesEditor.jsx';
import PromotionsEditor from './admin/PromotionsEditor.jsx';
import CasesEditor from './admin/CasesEditor.jsx';
import FaqEditor from './admin/FaqEditor.jsx';
import ContactEditor from './admin/ContactEditor.jsx';
import BlogEditor from './admin/BlogEditor.jsx';
import AiAssistant from './admin/AiAssistant.jsx';
import CustomersManager from './admin/CustomersManager.jsx';
import Settings from './admin/Settings.jsx';
import AccountLogin from './account/Login.jsx';
import AccountRegister from './account/Register.jsx';
import AccountLayout from './account/AccountLayout.jsx';
import AccountOverview from './account/Overview.jsx';
import AccountMessages from './account/Messages.jsx';
import AccountAiChat from './account/AiChat.jsx';
import AccountProfile from './account/Profile.jsx';
// Это приложение обслуживает только /admin* и /account* - Caddy направляет сюда
// именно эти пути, а всё остальное (публичный сайт и блог) идёт в Next.js.
export default function App() {
return (
<Routes>
{/* staff admin - login lives at the bare /admin path */}
<Route path="/admin" element={<Login />} />
<Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<Dashboard />} />
<Route path="/admin/reviews" element={<ReviewsManager />} />
<Route path="/admin/customers" element={<CustomersManager />} />
<Route path="/admin/services" element={<ServicesEditor />} />
<Route path="/admin/promotions" element={<PromotionsEditor />} />
<Route path="/admin/cases" element={<CasesEditor />} />
<Route path="/admin/faqs" element={<FaqEditor />} />
<Route path="/admin/blog" element={<BlogEditor />} />
<Route path="/admin/contact" element={<ContactEditor />} />
<Route path="/admin/ai" element={<AiAssistant />} />
<Route path="/admin/settings" element={<Settings />} />
</Route>
{/* customer cabinet - login/register live at bare /account paths */}
<Route path="/account" element={<AccountLogin />} />
<Route path="/account/register" element={<AccountRegister />} />
<Route element={<AccountLayout />}>
<Route path="/account/dashboard" element={<AccountOverview />} />
<Route path="/account/messages" element={<AccountMessages />} />
<Route path="/account/ai" element={<AccountAiChat />} />
<Route path="/account/profile" element={<AccountProfile />} />
</Route>
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate, Link } from 'react-router-dom';
import { LayoutDashboard, MessageCircle, Bot, UserCog, LogOut, Loader2, Terminal } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const NAV = [
{ to: '/account/dashboard', label: 'Обзор', icon: LayoutDashboard },
{ to: '/account/messages', label: 'Написать нам', icon: MessageCircle },
{ to: '/account/ai', label: 'ИИ-ассистент', icon: Bot },
{ to: '/account/profile', label: 'Профиль', icon: UserCog },
];
export default function AccountLayout() {
const navigate = useNavigate();
const [status, setStatus] = useState('checking'); // checking | authed | anon
const [customer, setCustomer] = useState(null);
useEffect(() => {
let cancelled = false;
api
.customerMe()
.then((data) => {
if (cancelled) return;
setCustomer(data);
setStatus('authed');
})
.catch(() => !cancelled && setStatus('anon'));
return () => {
cancelled = true;
};
}, []);
async function handleLogout() {
try {
await api.customerLogout();
} finally {
navigate('/account');
}
}
if (status === 'checking') {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
);
}
if (status === 'anon') {
return <Navigate to="/account" replace />;
}
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950">
<aside className="md:w-56 border-b md:border-b-0 md:border-r p-4 flex md:flex-col gap-2 overflow-x-auto md:overflow-x-visible border-slate-200 dark:border-slate-900">
<Link to="/" className="hidden md:flex items-center gap-2 mb-6 px-2">
<Terminal className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-bold font-display">TopSysOps</span>
</Link>
{customer && <p className="hidden md:block text-xs text-slate-400 dark:text-slate-500 px-2 mb-2 truncate">{customer.name}</p>}
{NAV.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors flex-shrink-0 whitespace-nowrap',
isActive
? 'bg-emerald-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900'
)
}
>
<Icon className="h-4 w-4" /> {item.label}
</NavLink>
);
})}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium flex-shrink-0 whitespace-nowrap md:mt-auto text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</aside>
<main className="flex-1 p-5 sm:p-8 max-w-3xl">
<Outlet context={{ customer }} />
</main>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react';
import { Send, Bot, Loader2, Trash2 } from 'lucide-react';
import { api } from '../lib/api.js';
const STORAGE_KEY = 'ts-account-ai-chat';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function AiChat() {
const [status, setStatus] = useState(null);
const [messages, setMessages] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const bottomRef = useRef(null);
useEffect(() => {
api.accountAiStatus().then(setStatus).catch(() => setStatus({ configured: false }));
}, []);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch (e) {
// not critical if history can't be persisted
}
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend(e) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const next = [...messages, { role: 'user', content: text }];
setMessages(next);
setInput('');
setSending(true);
setError('');
try {
const { reply } = await api.accountAiChat(next);
setMessages([...next, { role: 'assistant', content: reply || '(пустой ответ)' }]);
} catch (err) {
setError('Не получилось получить ответ, попробуйте ещё раз чуть позже.');
} finally {
setSending(false);
}
}
function clearHistory() {
if (!window.confirm('Очистить историю чата?')) return;
setMessages([]);
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="font-bold font-display text-lg flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-500" /> ИИ-ассистент
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">Отвечает на вопросы об услугах и ценах, доступно 24/7.</p>
</div>
<button onClick={clearHistory} className="p-2 rounded-md text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-900" title="Очистить историю">
<Trash2 className="h-4 w-4" />
</button>
</div>
{status && !status.configured && (
<div className="rounded-lg border border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 p-4 text-sm mb-4">
Ассистент временно недоступен. Напишите нам в разделе «Написать нам» - ответим лично.
</div>
)}
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{messages.length === 0 && (
<p className="text-sm text-slate-400">Спросите что-нибудь про наши услуги, цены или сроки.</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.role === 'user' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.content}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Ассистент печатает...
</div>
)}
<div ref={bottomRef} />
</div>
{error && <p className="text-xs text-rose-500 mb-2">{error}</p>}
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
disabled={status && !status.configured}
className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700 disabled:opacity-50"
/>
<button
type="submit"
disabled={sending || (status && !status.configured)}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { LogIn } from 'lucide-react';
import { api } from '../lib/api.js';
export default function Login() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setBusy(true);
setError('');
try {
await api.customerLogin(form.email, form.password);
navigate('/account/dashboard');
} catch (err) {
setError(err.message === 'invalid credentials' ? 'Неверный email или пароль' : 'Не удалось войти, попробуйте ещё раз');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-5 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<LogIn className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Личный кабинет</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">
Войдите, чтобы написать нам или задать вопрос ИИ-ассистенту.
</p>
<label className="text-xs block mb-1">Email</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
autoComplete="email"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-3 border-slate-300 dark:border-slate-700"
/>
<label className="text-xs block mb-1">Пароль</label>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="current-password"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-4 border-slate-300 dark:border-slate-700"
/>
{error && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60 mb-3"
>
{busy ? 'Входим...' : 'Войти'}
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
Ещё нет аккаунта?{' '}
<Link to="/account/register" className="text-emerald-500 hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function Messages() {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const bottomRef = useRef(null);
async function load() {
setLoading(true);
setMessages(await api.accountGetMessages());
setLoading(false);
}
useEffect(() => {
load();
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend(e) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
setSending(true);
try {
const msg = await api.accountSendMessage(text);
setMessages((m) => [...m, msg]);
setInput('');
} catch (err) {
// the user can just retry
} finally {
setSending(false);
}
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<h1 className="font-bold mb-1 font-display text-lg">Написать нам</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Сообщения видит команда TopSysOps и ответит вам здесь же.</p>
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{loading && <p className="text-sm text-slate-400">Загружаем переписку...</p>}
{!loading && messages.length === 0 && (
<p className="text-sm text-slate-400">Пока нет сообщений - напишите нам, если возникли вопросы.</p>
)}
{messages.map((m) => (
<div
key={m.id}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.sender === 'customer' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.body}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700"
/>
<button
type="submit"
disabled={sending}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { useOutletContext, Link } from 'react-router-dom';
import { Bot, MessageCircle, Tag } from 'lucide-react';
import { api } from '../lib/api.js';
export default function Overview() {
const { customer } = useOutletContext();
const [promotions, setPromotions] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.getPromotions()
.then(setPromotions)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Здравствуйте, {customer?.name}!</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">Рады видеть вас в личном кабинете TopSysOps.</p>
<div className="grid sm:grid-cols-2 gap-4 mb-8">
<Link
to="/account/messages"
className="rounded-lg border p-4 hover:border-emerald-500 transition-colors bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<MessageCircle className="h-5 w-5 text-emerald-500 mb-2" />
<div className="text-sm font-medium">Написать нам</div>
<div className="text-xs text-slate-400 dark:text-slate-500">Личная переписка с командой TopSysOps</div>
</Link>
<Link
to="/account/ai"
className="rounded-lg border p-4 hover:border-emerald-500 transition-colors bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<Bot className="h-5 w-5 text-emerald-500 mb-2" />
<div className="text-sm font-medium">ИИ-ассистент</div>
<div className="text-xs text-slate-400 dark:text-slate-500">Быстрые ответы на вопросы, доступно 24/7</div>
</Link>
</div>
<h2 className="text-sm font-medium mb-3 flex items-center gap-2">
<Tag className="h-4 w-4 text-emerald-500" /> Акции специально для вас
</h2>
{loading && <p className="text-sm text-slate-400">Загружаем...</p>}
<div className="grid sm:grid-cols-2 gap-4">
{promotions.slice(0, 4).map((p) => (
<div key={p.id} className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="text-sm font-medium mb-1">{p.title}</div>
<div className="text-emerald-500 font-mono text-sm">{p.price_text}</div>
</div>
))}
{!loading && promotions.length === 0 && <p className="text-sm text-slate-400">Сейчас нет активных акций.</p>}
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
export default function Profile() {
const { customer } = useOutletContext();
const [form, setForm] = useState({ name: '', company: '', phone: '' });
const [savedMsg, setSavedMsg] = useState('');
const [busy, setBusy] = useState(false);
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [pwMsg, setPwMsg] = useState(null);
useEffect(() => {
if (customer) setForm({ name: customer.name || '', company: customer.company || '', phone: customer.phone || '' });
}, [customer]);
async function saveProfile(e) {
e.preventDefault();
setBusy(true);
setSavedMsg('');
try {
await api.customerUpdateMe(form);
setSavedMsg('Сохранено');
} catch (err) {
setSavedMsg('Не удалось сохранить');
} finally {
setBusy(false);
}
}
async function changePassword(e) {
e.preventDefault();
setPwMsg(null);
if (pwForm.newPassword !== pwForm.confirm) {
setPwMsg({ type: 'error', text: 'Пароли не совпадают' });
return;
}
if (pwForm.newPassword.length < 8) {
setPwMsg({ type: 'error', text: 'Новый пароль должен быть не короче 8 символов' });
return;
}
try {
await api.customerChangePassword(pwForm.currentPassword, pwForm.newPassword);
setPwMsg({ type: 'ok', text: 'Пароль изменён' });
setPwForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) {
setPwMsg({
type: 'error',
text: err.message === 'current password is incorrect' ? 'Текущий пароль указан неверно' : 'Не удалось изменить пароль',
});
}
}
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Профиль</h1>
<form onSubmit={saveProfile} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-6">
<h2 className="text-sm font-medium mb-1">Данные аккаунта</h2>
<div>
<label className="text-xs block mb-1">Email</label>
<input value={customer?.email || ''} disabled className={`${fieldCls} opacity-60`} />
</div>
<div>
<label className="text-xs block mb-1">Имя</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Компания</label>
<input value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
{savedMsg && <p className="text-xs text-emerald-500">{savedMsg}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
<form onSubmit={changePassword} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-1">Сменить пароль</h2>
<div>
<label className="text-xs block mb-1">Текущий пароль</label>
<input
type="password"
required
value={pwForm.currentPassword}
onChange={(e) => setPwForm({ ...pwForm, currentPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Новый пароль</label>
<input
type="password"
required
value={pwForm.newPassword}
onChange={(e) => setPwForm({ ...pwForm, newPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Повторите новый пароль</label>
<input
type="password"
required
value={pwForm.confirm}
onChange={(e) => setPwForm({ ...pwForm, confirm: e.target.value })}
className={fieldCls}
/>
</div>
{pwMsg && <p className={pwMsg.type === 'ok' ? 'text-xs text-emerald-500' : 'text-xs text-rose-500'}>{pwMsg.text}</p>}
<button type="submit" className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600">
Сохранить пароль
</button>
</form>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserPlus } from 'lucide-react';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
export default function Register() {
const navigate = useNavigate();
const [form, setForm] = useState({ name: '', email: '', password: '', company: '', phone: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (form.password.length < 8) {
setError('Пароль должен быть не короче 8 символов');
return;
}
setBusy(true);
try {
await api.customerRegister(form);
navigate('/account/dashboard');
} catch (err) {
setError(
err.message === 'this email is already registered'
? 'Этот email уже зарегистрирован'
: 'Не удалось зарегистрироваться, попробуйте ещё раз'
);
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-5 py-10 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<UserPlus className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Регистрация</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">Доступ к ИИ-ассистенту и личной переписке с нами.</p>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs block mb-1">Имя*</label>
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Email*</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
autoComplete="email"
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Пароль* (от 8 символов)</label>
<input
type="password"
required
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="new-password"
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Компания</label>
<input value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
</div>
{error && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60 mb-3"
>
{busy ? 'Регистрируем...' : 'Зарегистрироваться'}
</button>
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
Уже есть аккаунт?{' '}
<Link to="/account" className="text-emerald-500 hover:underline">
Войти
</Link>
</p>
</form>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { LayoutDashboard, MessageSquare, Tag, Terminal, Settings as SettingsIcon, LogOut, Loader2, Briefcase, HelpCircle, Bot, Users, MapPin, Newspaper } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const NAV = [
{ to: '/admin/dashboard', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/admin/reviews', label: 'Отзывы', icon: MessageSquare },
{ to: '/admin/customers', label: 'Клиенты', icon: Users },
{ to: '/admin/services', label: 'Прайс-лист', icon: Terminal },
{ to: '/admin/promotions', label: 'Акции', icon: Tag },
{ to: '/admin/cases', label: 'Кейсы', icon: Briefcase },
{ to: '/admin/faqs', label: 'FAQ', icon: HelpCircle },
{ to: '/admin/blog', label: 'Блог', icon: Newspaper },
{ to: '/admin/contact', label: 'Контакты', icon: MapPin },
{ to: '/admin/ai', label: 'ИИ-ассистент', icon: Bot },
{ to: '/admin/settings', label: 'Настройки', icon: SettingsIcon },
];
export default function AdminLayout() {
const navigate = useNavigate();
const [status, setStatus] = useState('checking'); // checking | authed | anon
useEffect(() => {
let cancelled = false;
api
.me()
.then(() => !cancelled && setStatus('authed'))
.catch(() => !cancelled && setStatus('anon'));
return () => {
cancelled = true;
};
}, []);
async function handleLogout() {
try {
await api.logout();
} finally {
navigate('/admin');
}
}
if (status === 'checking') {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
</div>
);
}
if (status === 'anon') {
return <Navigate to="/admin" replace />;
}
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950">
<aside className="md:w-56 border-b md:border-b-0 md:border-r p-4 flex md:flex-col gap-2 overflow-x-auto md:overflow-x-visible border-slate-200 dark:border-slate-900">
<a href="/" className="hidden md:flex items-center gap-2 mb-6 px-2">
<Terminal className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-bold font-display">TopSysOps</span>
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">admin</span>
</a>
{NAV.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors flex-shrink-0 whitespace-nowrap',
isActive
? 'bg-emerald-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900'
)
}
>
<Icon className="h-4 w-4" /> {item.label}
</NavLink>
);
})}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium flex-shrink-0 whitespace-nowrap md:mt-auto text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-900"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</aside>
<main className="flex-1 p-5 sm:p-8 max-w-5xl">
<Outlet />
</main>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from 'react';
import { Send, Bot, Loader2, Trash2 } from 'lucide-react';
import { api } from '../lib/api.js';
const STORAGE_KEY = 'ts-ai-chat-history';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function AiAssistant() {
const [status, setStatus] = useState(null); // { configured, model }
const [messages, setMessages] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
});
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const bottomRef = useRef(null);
useEffect(() => {
api.aiStatus().then(setStatus).catch(() => setStatus({ configured: false }));
}, []);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch (e) {
// ignore - not critical if history can't be persisted
}
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend(e) {
e.preventDefault();
const text = input.trim();
if (!text || sending) return;
const next = [...messages, { role: 'user', content: text }];
setMessages(next);
setInput('');
setSending(true);
setError('');
try {
const { reply } = await api.aiChat(next);
setMessages([...next, { role: 'assistant', content: reply || '(пустой ответ)' }]);
} catch (err) {
setError('Не удалось получить ответ от ассистента. Проверьте OPENROUTER_API_KEY в .env бэкенда.');
} finally {
setSending(false);
}
}
function clearHistory() {
if (!window.confirm('Очистить историю чата?')) return;
setMessages([]);
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)] md:h-[calc(100vh-2rem)] max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="font-bold font-display text-lg flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-500" /> ИИ-ассистент
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Внутренний инструмент для сотрудников - через OpenRouter, модель {status?.model || '...'}.
</p>
</div>
<button onClick={clearHistory} className="p-2 rounded-md text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-900" title="Очистить историю">
<Trash2 className="h-4 w-4" />
</button>
</div>
{status && !status.configured && (
<div className="rounded-lg border border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 p-4 text-sm mb-4">
Ассистент не настроен: задайте <code>OPENROUTER_API_KEY</code> в <code>.env</code> и перезапустите
backend (<code>docker compose up -d backend</code>).
</div>
)}
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{messages.length === 0 && (
<p className="text-sm text-slate-400">
Спросите что-нибудь: «помоги ответить на негативный отзыв», «сколько стоит настройка сервера» и т.д.
</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.role === 'user'
? 'ml-auto bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.content}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Ассистент печатает...
</div>
)}
<div ref={bottomRef} />
</div>
{error && <p className="text-xs text-rose-500 mb-2">{error}</p>}
<form onSubmit={handleSend} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
disabled={status && !status.configured}
className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700 disabled:opacity-50"
/>
<button
type="submit"
disabled={sending || (status && !status.configured)}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700';
const QUILL_MODULES = {
toolbar: [
[{ header: [2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['blockquote', 'link'],
['clean'],
],
};
const TRANSLIT = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y',
к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f',
х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
};
function slugify(text) {
return String(text)
.toLowerCase()
.split('')
.map((ch) => (TRANSLIT[ch] !== undefined ? TRANSLIT[ch] : ch))
.join('')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
function PostForm({ initial, onSave, onCancel }) {
const [form, setForm] = useState(
initial || { slug: '', title: '', excerpt: '', content: '', published: false }
);
const [slugTouched, setSlugTouched] = useState(Boolean(initial));
function handleTitleChange(title) {
setForm((f) => ({ ...f, title, slug: slugTouched ? f.slug : slugify(title) }));
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Заголовок статьи" value={form.title} onChange={(e) => handleTitleChange(e.target.value)} />
<input
className={inputCls}
placeholder="url-slug (латиницей)"
value={form.slug}
onChange={(e) => {
setSlugTouched(true);
setForm({ ...form, slug: e.target.value });
}}
/>
<textarea className={inputCls} rows={2} placeholder="Краткое описание (для списка статей)" value={form.excerpt} onChange={(e) => setForm({ ...form, excerpt: e.target.value })} />
<div className="bg-white rounded-md overflow-hidden">
<ReactQuill
theme="snow"
modules={QUILL_MODULES}
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
placeholder="Текст статьи..."
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.published} onChange={(e) => setForm({ ...form, published: e.target.checked })} />
Опубликовать на сайте
</label>
<div className="flex gap-2">
<button
onClick={() => form.title.trim() && form.slug.trim() && onSave(form)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600"
>
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={onCancel} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
function PostRow({ post, onEdit, onDelete, onTogglePublish }) {
return (
<div className={cx('rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !post.published && 'opacity-60')}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className={cx('text-xs px-2 py-0.5 rounded-full', post.published ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300')}>
{post.published ? 'опубликовано' : 'черновик'}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">/blog/{post.slug}</span>
</div>
<div className="text-sm font-medium">{post.title}</div>
{post.excerpt && <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">{post.excerpt}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button title={post.published ? 'Снять с публикации' : 'Опубликовать'} onClick={() => onTogglePublish(post)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
{post.published ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => onEdit(post)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить статью «${post.title}»?`) && onDelete(post.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export default function BlogEditor() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | post object
const [saving, setSaving] = useState(false);
async function load() {
setLoading(true);
setPosts(await api.adminGetBlogPosts());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function handleSave(form) {
setSaving(true);
try {
if (form.id) {
await api.adminUpdateBlogPost(form.id, form);
} else {
await api.adminCreateBlogPost(form);
}
setEditing(null);
load();
} catch (err) {
window.alert(err.message === 'a post with this slug already exists' ? 'Статья с таким slug уже существует' : 'Не удалось сохранить статью');
} finally {
setSaving(false);
}
}
async function togglePublish(post) {
await api.adminUpdateBlogPost(post.id, { published: !post.published });
load();
}
async function remove(id) {
await api.adminDeleteBlogPost(id);
load();
}
if (loading) return <p className="text-sm text-slate-400">Загружаем статьи...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Блог</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Опубликованные статьи попадают на сайт (/blog) и в sitemap.xml сразу после сохранения.
</p>
{editing === 'new' && <PostForm onSave={handleSave} onCancel={() => setEditing(null)} />}
{editing && editing !== 'new' && <PostForm initial={editing} onSave={handleSave} onCancel={() => setEditing(null)} />}
{!editing && (
<>
<div className="space-y-3 mb-5">
{posts.length === 0 && <p className="text-sm text-slate-400">Пока нет статей.</p>}
{posts.map((p) => (
<PostRow key={p.id} post={p} onEdit={setEditing} onDelete={remove} onTogglePublish={togglePublish} />
))}
</div>
<button
onClick={() => setEditing('new')}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500"
>
<Plus className="h-4 w-4" /> Новая статья
</button>
</>
)}
{saving && <p className="text-xs text-slate-400 mt-2">Сохраняем...</p>}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye } from 'lucide-react';
import { api } from '../lib/api.js';
import StatusDot from '../components/StatusDot.jsx';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700';
const TONES = [
{ value: 'ok', label: 'завершён (зелёный)' },
{ value: 'warn', label: 'в работе (жёлтый)' },
{ value: 'danger', label: 'проблема (красный)' },
{ value: 'neutral', label: 'нейтральный (серый)' },
];
function CaseCard({ item, onSave, onDelete }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(item);
useEffect(() => setForm(item), [item]);
if (editing) {
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название проекта" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Тег, напр. Кибербезопасность" value={form.tag || ''} onChange={(e) => setForm({ ...form, tag: e.target.value })} />
<input className={inputCls} placeholder="Текст статуса, напр. завершён" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} />
</div>
<select className={inputCls} value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })}>
{TONES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<textarea className={inputCls} rows={2} placeholder="Описание проекта" value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(item); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !item.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 flex-wrap">
{item.tag && <span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">{item.tag}</span>}
<StatusDot tone={item.tone} label={item.status} mono />
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title={item.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...item, active: !item.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{item.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить кейс «${item.title}»?`) && onDelete(item.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<h3 className="font-bold mb-1 font-display text-[15px]">{item.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
</div>
);
}
function NewCaseForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ title: '', tag: '', status: 'в работе', tone: 'warn', description: '' });
async function submit() {
if (!form.title.trim()) return;
await onCreate(form);
setForm({ title: '', tag: '', status: 'в работе', tone: 'warn', description: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новый кейс
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название проекта" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Тег, напр. Кибербезопасность" value={form.tag} onChange={(e) => setForm({ ...form, tag: e.target.value })} />
<input className={inputCls} placeholder="Текст статуса, напр. завершён" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} />
</div>
<select className={inputCls} value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })}>
{TONES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<textarea className={inputCls} rows={2} placeholder="Описание проекта" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function CasesEditor() {
const [cases, setCases] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setCases(await api.adminGetCases());
setLoading(false);
}
useEffect(() => {
load();
}, []);
if (loading) return <p className="text-sm text-slate-400">Загружаем кейсы...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Кейсы и портфолио</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Замените демонстрационные кейсы на реальные проекты вашей команды.
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
{cases.map((item) => (
<CaseCard
key={item.id}
item={item}
onSave={async (data) => { await api.adminUpdateCase(item.id, data); load(); }}
onDelete={async (id) => { await api.adminDeleteCase(id); load(); }}
/>
))}
</div>
<NewCaseForm onCreate={async (data) => { await api.adminCreateCase(data); load(); }} />
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { MapPin } from 'lucide-react';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
export default function ContactEditor() {
const [form, setForm] = useState({ address: '', phone: '', email: '', hours: '', map_embed: '' });
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [savedMsg, setSavedMsg] = useState('');
useEffect(() => {
api
.adminGetContact()
.then(setForm)
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e) {
e.preventDefault();
setBusy(true);
setSavedMsg('');
try {
const updated = await api.adminUpdateContact(form);
setForm(updated);
setSavedMsg('Сохранено - изменения уже видны на сайте');
} catch (err) {
setSavedMsg('Не удалось сохранить, попробуйте ещё раз');
} finally {
setBusy(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем контакты...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg flex items-center gap-2">
<MapPin className="h-5 w-5 text-emerald-500" /> Контакты
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Эти данные показываются в разделе «Контакты» на сайте. Оставьте поле пустым, чтобы строка не отображалась.
</p>
<form onSubmit={handleSubmit} className="max-w-lg rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div>
<label className="text-xs block mb-1">Адрес</label>
<input
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Телефон</label>
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Email</label>
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Режим работы</label>
<input value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} className={fieldCls} />
</div>
<div>
<label className="text-xs block mb-1">Ссылка на карту (iframe src из Яндекс.Карт или Google Maps)</label>
<textarea
rows={3}
value={form.map_embed}
onChange={(e) => setForm({ ...form, map_embed: e.target.value })}
className={fieldCls}
/>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Оставьте пустым, чтобы блок с картой не показывался вовсе.
</p>
</div>
{savedMsg && <p className="text-xs text-emerald-500">{savedMsg}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { Send, Users } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function CustomersManager() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null);
const [thread, setThread] = useState([]);
const [threadLoading, setThreadLoading] = useState(false);
const [reply, setReply] = useState('');
const [sending, setSending] = useState(false);
async function loadCustomers() {
setLoading(true);
setCustomers(await api.adminGetCustomers());
setLoading(false);
}
useEffect(() => {
loadCustomers();
}, []);
async function openCustomer(id) {
setSelectedId(id);
setThreadLoading(true);
setThread(await api.adminGetCustomerMessages(id));
setThreadLoading(false);
}
async function sendReply(e) {
e.preventDefault();
const text = reply.trim();
if (!text || sending || !selectedId) return;
setSending(true);
try {
const msg = await api.adminSendCustomerMessage(selectedId, text);
setThread((t) => [...t, msg]);
setReply('');
loadCustomers();
} finally {
setSending(false);
}
}
const selected = customers.find((c) => c.id === selectedId);
if (loading) return <p className="text-sm text-slate-400">Загружаем клиентов...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-emerald-500" /> Клиенты
</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Переписка из личных кабинетов зарегистрированных клиентов.
</p>
<div className="grid md:grid-cols-[260px_1fr] gap-5">
<div className="space-y-2">
{customers.length === 0 && <p className="text-sm text-slate-400">Пока нет зарегистрированных клиентов.</p>}
{customers.map((c) => (
<button
key={c.id}
onClick={() => openCustomer(c.id)}
className={cx(
'w-full text-left rounded-lg border p-3 transition-colors bg-white dark:bg-slate-900',
selectedId === c.id
? 'border-emerald-500'
: 'border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700'
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{c.name}</span>
{c.last_message_sender === 'customer' && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-amber-500 text-white flex-shrink-0">ждёт ответа</span>
)}
</div>
<div className="text-xs text-slate-400 dark:text-slate-500 truncate">{c.email}</div>
{c.last_message_body && (
<div className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">{c.last_message_body}</div>
)}
</button>
))}
</div>
<div>
{!selectedId && <p className="text-sm text-slate-400">Выберите клиента слева, чтобы увидеть переписку.</p>}
{selectedId && (
<div className="flex flex-col h-[70vh]">
<h2 className="text-sm font-medium mb-3">
{selected?.name} <span className="text-slate-400 font-normal">· {selected?.email}</span>
</h2>
<div className="flex-1 overflow-y-auto rounded-lg border p-4 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 mb-3">
{threadLoading && <p className="text-sm text-slate-400">Загружаем переписку...</p>}
{!threadLoading && thread.length === 0 && <p className="text-sm text-slate-400">Сообщений пока нет.</p>}
{thread.map((m) => (
<div
key={m.id}
className={cx(
'max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
m.sender === 'staff' ? 'ml-auto bg-emerald-500 text-white' : 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100'
)}
>
{m.body}
</div>
))}
</div>
<form onSubmit={sendReply} className="flex gap-2">
<input
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder="Ответить клиенту..."
className="flex-1 rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700"
/>
<button
type="submit"
disabled={sending}
className="px-4 py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" />
</button>
</form>
</div>
)}
</div>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { TrendingUp, Users, MessageSquare, Check } from 'lucide-react';
import { useTheme } from '../context/ThemeContext.jsx';
import { api } from '../lib/api.js';
import StatusDot from '../components/StatusDot.jsx';
function formatDay(iso) {
const d = new Date(iso + 'T00:00:00');
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`;
}
export default function Dashboard() {
const { dark } = useTheme();
const [stats, setStats] = useState({ byDay: [], total: 0 });
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
const results = await Promise.allSettled([api.adminGetStats(), api.adminGetReviews()]);
if (cancelled) return;
if (results[0].status === 'fulfilled') setStats(results[0].value);
if (results[1].status === 'fulfilled') setReviews(results[1].value);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
const today = new Date().toISOString().slice(0, 10);
const todayVisits = stats.byDay.find((d) => d.date === today)?.count || 0;
const pending = reviews.filter((r) => r.status === 'pending').length;
const approved = reviews.filter((r) => r.status === 'approved').length;
const chartData = stats.byDay.slice(-7).map((d) => ({ date: formatDay(d.date), visits: d.count }));
const cards = [
{ label: 'Визитов всего', value: stats.total, icon: TrendingUp, tone: 'ok' },
{ label: 'Визитов сегодня', value: todayVisits, icon: Users, tone: 'ok' },
{ label: 'Отзывов на модерации', value: pending, icon: MessageSquare, tone: pending > 0 ? 'warn' : 'neutral' },
{ label: 'Опубликовано отзывов', value: approved, icon: Check, tone: 'ok' },
];
const axisColor = dark ? '#64748b' : '#94a3b8';
if (loading) return <p className="text-sm text-slate-400">Загружаем статистику...</p>;
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Дашборд</h1>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{cards.map((c) => {
const Icon = c.icon;
return (
<div key={c.label} className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center justify-between mb-2">
<Icon className="h-4 w-4 text-emerald-500" />
<StatusDot tone={c.tone} />
</div>
<div className="text-2xl font-bold font-mono">{c.value}</div>
<div className="text-xs mt-1 text-slate-400 dark:text-slate-500">{c.label}</div>
</div>
);
})}
</div>
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-4">Посещаемость за 7 дней</h2>
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={dark ? '#1e293b' : '#e2e8f0'} vertical={false} />
<XAxis dataKey="date" stroke={axisColor} fontSize={12} tickLine={false} axisLine={false} />
<YAxis stroke={axisColor} fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} width={28} />
<Tooltip
contentStyle={{
background: dark ? '#0f172a' : '#ffffff',
border: `1px solid ${dark ? '#1e293b' : '#e2e8f0'}`,
borderRadius: 8,
fontSize: 12,
}}
/>
<Bar dataKey="visits" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<p className="text-xs mt-3 text-slate-400 dark:text-slate-600">
Считаются реальные заходы на сайт (таблица <code>visits</code> в базе данных).
</p>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700';
function FaqCard({ item, onSave, onDelete }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(item);
useEffect(() => setForm(item), [item]);
if (editing) {
return (
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Вопрос" value={form.question} onChange={(e) => setForm({ ...form, question: e.target.value })} />
<textarea className={inputCls} rows={3} placeholder="Ответ" value={form.answer} onChange={(e) => setForm({ ...form, answer: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(item); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !item.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium mb-1">{item.question}</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.answer}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title={item.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...item, active: !item.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{item.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm('Удалить этот вопрос?') && onDelete(item.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
function NewFaqForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ question: '', answer: '' });
async function submit() {
if (!form.question.trim() || !form.answer.trim()) return;
await onCreate(form);
setForm({ question: '', answer: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новый вопрос
</button>
);
}
return (
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Вопрос" value={form.question} onChange={(e) => setForm({ ...form, question: e.target.value })} />
<textarea className={inputCls} rows={3} placeholder="Ответ" value={form.answer} onChange={(e) => setForm({ ...form, answer: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Добавить</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function FaqEditor() {
const [faqs, setFaqs] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setFaqs(await api.adminGetFaqs());
setLoading(false);
}
useEffect(() => {
load();
}, []);
if (loading) return <p className="text-sm text-slate-400">Загружаем вопросы...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">FAQ</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Вопросы показываются на сайте в том порядке, в котором были созданы.
</p>
<div className="space-y-3 mb-5">
{faqs.map((item) => (
<FaqCard
key={item.id}
item={item}
onSave={async (data) => { await api.adminUpdateFaq(item.id, data); load(); }}
onDelete={async (id) => { await api.adminDeleteFaq(id); load(); }}
/>
))}
</div>
<NewFaqForm onCreate={async (data) => { await api.adminCreateFaq(data); load(); }} />
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Terminal } from 'lucide-react';
import { api } from '../lib/api.js';
export default function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setBusy(true);
setError('');
try {
await api.login(username, password);
navigate('/admin/dashboard');
} catch (err) {
setError(err.message === 'invalid credentials' ? 'Неверный логин или пароль' : 'Не удалось войти, попробуйте ещё раз');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-5 bg-slate-50 dark:bg-slate-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border p-6 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-2 mb-1">
<Terminal className="h-5 w-5 text-emerald-500" />
<h1 className="font-bold font-display text-base">Админ-панель</h1>
</div>
<p className="text-xs mb-5 text-slate-400 dark:text-slate-500">Доступ только для сотрудников TopSysOps.</p>
<label className="text-xs block mb-1">Логин</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-3 border-slate-300 dark:border-slate-700"
/>
<label className="text-xs block mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent mb-4 border-slate-300 dark:border-slate-700"
/>
{error && <p className="text-xs text-rose-500 mb-4">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Входим...' : 'Войти'}
</button>
</form>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye, ChevronUp, ChevronDown } from 'lucide-react';
import { api } from '../lib/api.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700';
function PromoCard({ promo, onSave, onDelete, onMoveUp, onMoveDown, isFirst, isLast }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(promo);
useEffect(() => setForm(promo), [promo]);
if (editing) {
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название акции" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Цена по акции, напр. 35 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Старая цена (необязательно)" value={form.old_price_text || ''} onChange={(e) => setForm({ ...form, old_price_text: e.target.value })} />
</div>
<textarea className={inputCls} rows={2} placeholder="Условия акции" value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(promo); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800', !promo.active && 'opacity-50')}>
<div className="flex items-start justify-between gap-3 mb-2">
<h3 className="font-bold font-display text-[15px]">{promo.title}</h3>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить раньше"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить позже"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
title={promo.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...promo, active: !promo.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{promo.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить акцию «${promo.title}»?`) && onDelete(promo.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex items-baseline gap-2 mb-2">
<span className="text-lg font-bold text-emerald-500 font-mono">{promo.price_text}</span>
{promo.old_price_text && <span className="text-sm line-through text-slate-400 dark:text-slate-600 font-mono">{promo.old_price_text}</span>}
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{promo.description}</p>
</div>
);
}
function NewPromoForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ title: '', price_text: '', old_price_text: '', description: '' });
async function submit() {
if (!form.title.trim() || !form.price_text.trim()) return;
await onCreate({ ...form, old_price_text: form.old_price_text || null });
setForm({ title: '', price_text: '', old_price_text: '', description: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новая акция
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Название акции" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<div className="grid sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Цена по акции, напр. 35 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Старая цена (необязательно)" value={form.old_price_text} onChange={(e) => setForm({ ...form, old_price_text: e.target.value })} />
</div>
<textarea className={inputCls} rows={2} placeholder="Условия акции" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function PromotionsEditor() {
const [promotions, setPromotions] = useState([]);
const [loading, setLoading] = useState(true);
const [reordering, setReordering] = useState(false);
async function load() {
setLoading(true);
setPromotions(await api.adminGetPromotions());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function move(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= promotions.length || reordering) return;
const a = promotions[index];
const b = promotions[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdatePromotion(a.id, { sort_order: b.sort_order }),
api.adminUpdatePromotion(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем акции...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Акции</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Когда акция заканчивается, просто скройте её кнопкой с глазом - не обязательно удалять.
Стрелками можно менять порядок, в котором акции идут на сайте.
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
{promotions.map((p, i) => (
<PromoCard
key={p.id}
promo={p}
isFirst={i === 0}
isLast={i === promotions.length - 1}
onMoveUp={() => move(i, -1)}
onMoveDown={() => move(i, 1)}
onSave={async (data) => { await api.adminUpdatePromotion(p.id, data); load(); }}
onDelete={async (id) => { await api.adminDeletePromotion(id); load(); }}
/>
))}
</div>
<NewPromoForm onCreate={async (data) => { await api.adminCreatePromotion(data); load(); }} />
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { Check, X, Star, Trash2 } from 'lucide-react';
import { api } from '../lib/api.js';
import StatusDot from '../components/StatusDot.jsx';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
function ReviewRow({ r, onApprove, onReject, onDelete }) {
return (
<div className="rounded-lg border p-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="text-sm font-medium">
{r.name}
{r.role ? ` · ${r.role}` : ''}
</div>
<div className="flex items-center gap-1 mt-1">
{[1, 2, 3, 4, 5].map((n) => (
<Star key={n} className={cx('h-3.5 w-3.5', n <= r.rating ? 'fill-amber-400 text-amber-400' : 'text-slate-300')} />
))}
</div>
</div>
<StatusDot tone={r.status === 'pending' ? 'warn' : r.status === 'approved' ? 'ok' : 'danger'} label={r.status} mono />
</div>
<p className="text-sm mb-3">{r.text}</p>
<div className="flex gap-2">
{r.status !== 'approved' && (
<button
onClick={() => onApprove(r.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600"
>
<Check className="h-3.5 w-3.5" /> Одобрить
</button>
)}
{r.status !== 'rejected' && (
<button
onClick={() => onReject(r.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
<X className="h-3.5 w-3.5" /> Отклонить
</button>
)}
<button
onClick={() => onDelete(r.id)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 ml-auto"
>
<Trash2 className="h-3.5 w-3.5" /> Удалить
</button>
</div>
</div>
);
}
export default function ReviewsManager() {
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setReviews(await api.adminGetReviews());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function setStatus(id, status) {
setReviews((rs) => rs.map((r) => (r.id === id ? { ...r, status } : r)));
try {
await api.adminSetReviewStatus(id, status);
} catch (e) {
load();
}
}
async function remove(id) {
if (!window.confirm('Удалить отзыв без возможности восстановления?')) return;
setReviews((rs) => rs.filter((r) => r.id !== id));
try {
await api.adminDeleteReview(id);
} catch (e) {
load();
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем отзывы...</p>;
const pending = reviews.filter((r) => r.status === 'pending');
const rest = reviews.filter((r) => r.status !== 'pending');
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Модерация отзывов</h1>
<h2 className="text-sm font-medium text-amber-500 mb-3">На модерации ({pending.length})</h2>
<div className="space-y-3 mb-8">
{pending.length === 0 && (
<p className="text-sm text-slate-400">Новых отзывов нет - здесь появятся отзывы с формы на сайте.</p>
)}
{pending.map((r) => (
<ReviewRow key={r.id} r={r} onApprove={(id) => setStatus(id, 'approved')} onReject={(id) => setStatus(id, 'rejected')} onDelete={remove} />
))}
</div>
<h2 className="text-sm font-medium mb-3 text-slate-400">История ({rest.length})</h2>
<div className="space-y-3">
{rest.map((r) => (
<ReviewRow key={r.id} r={r} onApprove={(id) => setStatus(id, 'approved')} onReject={(id) => setStatus(id, 'rejected')} onDelete={remove} />
))}
</div>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Save, X, EyeOff, Eye, ChevronUp, ChevronDown } from 'lucide-react';
import { api } from '../lib/api.js';
import { ICONS } from '../lib/icons.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const inputCls = 'w-full rounded-md border px-2.5 py-1.5 text-sm bg-transparent border-slate-300 dark:border-slate-700';
function ItemRow({ item, onSave, onDelete, onMoveUp, onMoveDown, isFirst, isLast }) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(item);
useEffect(() => setForm(item), [item]);
if (editing) {
return (
<div className="grid sm:grid-cols-2 gap-2 p-3 rounded-md bg-slate-50 dark:bg-slate-950/50">
<input className={inputCls} placeholder="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input className={inputCls} placeholder="Цена, напр. от 20 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Период, напр. /мес" value={form.period || ''} onChange={(e) => setForm({ ...form, period: e.target.value })} />
<input className={inputCls} placeholder="Примечание" value={form.note || ''} onChange={(e) => setForm({ ...form, note: e.target.value })} />
<div className="sm:col-span-2 flex gap-2">
<button onClick={() => { onSave(form); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">
<Save className="h-3.5 w-3.5" /> Сохранить
</button>
<button onClick={() => { setForm(item); setEditing(false); }} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">
<X className="h-3.5 w-3.5" /> Отмена
</button>
</div>
</div>
);
}
return (
<div className={cx('flex items-start justify-between gap-3 p-3 rounded-md', !item.active && 'opacity-50')}>
<div className="min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-sm font-medium">{item.name}</span>
<span className="text-sm text-emerald-500 font-mono whitespace-nowrap">
{item.price_text}
{item.period}
</span>
</div>
{item.note && <p className="text-xs text-slate-400 dark:text-slate-500">{item.note}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить раньше"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить позже"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
title={item.active ? 'Скрыть с сайта' : 'Показать на сайте'}
onClick={() => onSave({ ...item, active: !item.active })}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800"
>
{item.active ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => onDelete(item.id)} className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
}
function NewItemForm({ categoryId, onCreate }) {
const [form, setForm] = useState({ name: '', price_text: '', period: '', note: '' });
const [open, setOpen] = useState(false);
async function submit() {
if (!form.name.trim() || !form.price_text.trim()) return;
await onCreate({ category_id: categoryId, ...form });
setForm({ name: '', price_text: '', period: '', note: '' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-1 text-xs text-emerald-500 font-medium px-3 py-2">
<Plus className="h-3.5 w-3.5" /> Добавить позицию
</button>
);
}
return (
<div className="grid sm:grid-cols-2 gap-2 p-3 rounded-md bg-slate-50 dark:bg-slate-950/50">
<input className={inputCls} placeholder="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input className={inputCls} placeholder="Цена, напр. от 20 000 ₽" value={form.price_text} onChange={(e) => setForm({ ...form, price_text: e.target.value })} />
<input className={inputCls} placeholder="Период, напр. /мес" value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} />
<input className={inputCls} placeholder="Примечание" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
<div className="sm:col-span-2 flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Добавить</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
function CategoryCard({
category,
onUpdateCategory,
onDeleteCategory,
onCreateItem,
onUpdateItem,
onDeleteItem,
onMoveItem,
onMoveCategoryUp,
onMoveCategoryDown,
isFirst,
isLast,
}) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ title: category.title, description: category.description, icon: category.icon });
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
{editing ? (
<div className="space-y-2 mb-4">
<input className={inputCls} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="Название категории" />
<input className={inputCls} value={form.description || ''} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Описание" />
<select className={inputCls} value={form.icon} onChange={(e) => setForm({ ...form, icon: e.target.value })}>
{Object.keys(ICONS).map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<div className="flex gap-2">
<button onClick={() => { onUpdateCategory(category.id, form); setEditing(false); }} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Сохранить</button>
<button onClick={() => setEditing(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
) : (
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-bold font-display text-[15px]">{category.title}</h3>
<p className="text-xs text-slate-400 dark:text-slate-500">{category.description}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<button
title="Переместить категорию раньше"
onClick={onMoveCategoryUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
title="Переместить категорию позже"
onClick={onMoveCategoryDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronDown className="h-4 w-4" />
</button>
<button onClick={() => setEditing(true)} className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800">
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => window.confirm(`Удалить категорию «${category.title}» вместе со всеми её позициями?`) && onDeleteCategory(category.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)}
<div className="space-y-1 divide-y divide-slate-100 dark:divide-slate-800">
{category.items.map((item, i) => (
<ItemRow
key={item.id}
item={item}
isFirst={i === 0}
isLast={i === category.items.length - 1}
onMoveUp={() => onMoveItem(category, i, -1)}
onMoveDown={() => onMoveItem(category, i, 1)}
onSave={(data) => onUpdateItem(item.id, data)}
onDelete={onDeleteItem}
/>
))}
</div>
<div className="mt-2">
<NewItemForm categoryId={category.id} onCreate={onCreateItem} />
</div>
</div>
);
}
function NewCategoryForm({ onCreate }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ slug: '', title: '', description: '', icon: 'Server' });
async function submit() {
if (!form.slug.trim() || !form.title.trim()) return;
await onCreate(form);
setForm({ slug: '', title: '', description: '', icon: 'Server' });
setOpen(false);
}
if (!open) {
return (
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md border border-dashed border-slate-300 dark:border-slate-700 text-sm text-slate-500 hover:border-emerald-500 hover:text-emerald-500">
<Plus className="h-4 w-4" /> Новая категория услуг
</button>
);
}
return (
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800 space-y-2">
<input className={inputCls} placeholder="Слаг (латиницей, напр. hosting)" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} />
<input className={inputCls} placeholder="Название категории" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<input className={inputCls} placeholder="Описание" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<select className={inputCls} value={form.icon} onChange={(e) => setForm({ ...form, icon: e.target.value })}>
{Object.keys(ICONS).map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<div className="flex gap-2">
<button onClick={submit} className="px-3 py-1.5 rounded-md bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600">Создать</button>
<button onClick={() => setOpen(false)} className="px-3 py-1.5 rounded-md text-xs font-medium border border-slate-300 dark:border-slate-700">Отмена</button>
</div>
</div>
);
}
export default function ServicesEditor() {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [reordering, setReordering] = useState(false);
async function load() {
setLoading(true);
setCategories(await api.adminGetServices());
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function moveCategory(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= categories.length || reordering) return;
const a = categories[index];
const b = categories[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdateCategory(a.id, { sort_order: b.sort_order }),
api.adminUpdateCategory(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
async function moveItem(category, index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= category.items.length || reordering) return;
const a = category.items[index];
const b = category.items[targetIndex];
setReordering(true);
try {
await Promise.all([
api.adminUpdateItem(a.id, { sort_order: b.sort_order }),
api.adminUpdateItem(b.id, { sort_order: a.sort_order }),
]);
await load();
} finally {
setReordering(false);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем прайс-лист...</p>;
return (
<div>
<h1 className="font-bold mb-1 font-display text-lg">Прайс-лист</h1>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">
Изменения появляются на сайте сразу после сохранения, без пересборки и перезапуска.
Стрелками можно менять порядок категорий и позиций внутри них.
</p>
<div className="space-y-5 mb-5">
{categories.map((cat, i) => (
<CategoryCard
key={cat.id}
category={cat}
isFirst={i === 0}
isLast={i === categories.length - 1}
onMoveCategoryUp={() => moveCategory(i, -1)}
onMoveCategoryDown={() => moveCategory(i, 1)}
onMoveItem={moveItem}
onUpdateCategory={async (id, data) => { await api.adminUpdateCategory(id, data); load(); }}
onDeleteCategory={async (id) => { await api.adminDeleteCategory(id); load(); }}
onCreateItem={async (data) => { await api.adminCreateItem(data); load(); }}
onUpdateItem={async (id, data) => { await api.adminUpdateItem(id, data); load(); }}
onDeleteItem={async (id) => { await api.adminDeleteItem(id); load(); }}
/>
))}
</div>
<NewCategoryForm onCreate={async (data) => { await api.adminCreateCategory(data); load(); }} />
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react';
import { Trash2, UserPlus } from 'lucide-react';
import { api } from '../lib/api.js';
const fieldCls = 'w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700';
function PasswordForm() {
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [message, setMessage] = useState(null); // { type: 'ok' | 'error', text }
const [busy, setBusy] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setMessage(null);
if (form.newPassword !== form.confirm) {
setMessage({ type: 'error', text: 'Новые пароли не совпадают' });
return;
}
if (form.newPassword.length < 8) {
setMessage({ type: 'error', text: 'Новый пароль должен быть не короче 8 символов' });
return;
}
setBusy(true);
try {
await api.changePassword(form.currentPassword, form.newPassword);
setMessage({ type: 'ok', text: 'Пароль изменён' });
setForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (err) {
setMessage({
type: 'error',
text: err.message === 'current password is incorrect' ? 'Текущий пароль указан неверно' : 'Не удалось изменить пароль',
});
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-sm rounded-lg border p-5 space-y-3 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium mb-1">Сменить пароль</h2>
<div>
<label className="text-xs block mb-1">Текущий пароль</label>
<input
type="password"
required
value={form.currentPassword}
onChange={(e) => setForm({ ...form, currentPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Новый пароль</label>
<input
type="password"
required
value={form.newPassword}
onChange={(e) => setForm({ ...form, newPassword: e.target.value })}
className={fieldCls}
/>
</div>
<div>
<label className="text-xs block mb-1">Повторите новый пароль</label>
<input
type="password"
required
value={form.confirm}
onChange={(e) => setForm({ ...form, confirm: e.target.value })}
className={fieldCls}
/>
</div>
{message && (
<p className={message.type === 'ok' ? 'text-xs text-emerald-500' : 'text-xs text-rose-500'}>{message.text}</p>
)}
<button
type="submit"
disabled={busy}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{busy ? 'Сохраняем...' : 'Сохранить'}
</button>
</form>
);
}
function AdminsSection() {
const [admins, setAdmins] = useState([]);
const [me, setMe] = useState(null);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function load() {
setLoading(true);
const [list, meData] = await Promise.all([api.adminGetAdmins(), api.me()]);
setAdmins(list);
setMe(meData);
setLoading(false);
}
useEffect(() => {
load();
}, []);
async function handleCreate(e) {
e.preventDefault();
setError('');
if (!form.username.trim()) {
setError('Укажите логин');
return;
}
if (form.password.length < 8) {
setError('Пароль должен быть не короче 8 символов');
return;
}
setBusy(true);
try {
await api.adminCreateAdminUser(form.username.trim(), form.password);
setForm({ username: '', password: '' });
load();
} catch (err) {
setError(err.message === 'this username is already taken' ? 'Такой логин уже занят' : 'Не удалось создать администратора');
} finally {
setBusy(false);
}
}
async function handleDelete(id) {
if (!window.confirm('Удалить этого администратора без возможности восстановления?')) return;
try {
await api.adminDeleteAdminUser(id);
load();
} catch (err) {
window.alert(
err.message === 'you cannot delete your own account while logged in'
? 'Нельзя удалить самого себя, пока вы вошли под этим аккаунтом'
: err.message === 'cannot delete the last remaining admin'
? 'Нельзя удалить последнего администратора - должен остаться хотя бы один'
: 'Не удалось удалить'
);
}
}
if (loading) return <p className="text-sm text-slate-400">Загружаем список администраторов...</p>;
return (
<div className="max-w-sm rounded-lg border p-5 space-y-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="text-sm font-medium">Администраторы</h2>
<div className="space-y-2">
{admins.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2 text-sm">
<span>
{a.username}
{me?.id === a.id && <span className="text-slate-400 dark:text-slate-500"> (вы)</span>}
</span>
{me?.id !== a.id && (
<button
onClick={() => handleDelete(a.id)}
className="p-1.5 rounded-md text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30"
title="Удалить администратора"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
<form onSubmit={handleCreate} className="space-y-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<label className="text-xs block mb-1">Новый администратор</label>
<input
placeholder="Логин"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={fieldCls}
/>
<input
type="password"
placeholder="Пароль (от 8 символов)"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={fieldCls}
/>
{error && <p className="text-xs text-rose-500">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full py-2 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 disabled:opacity-60 inline-flex items-center justify-center gap-1.5"
>
<UserPlus className="h-3.5 w-3.5" /> {busy ? 'Создаём...' : 'Добавить'}
</button>
</form>
</div>
);
}
export default function Settings() {
return (
<div>
<h1 className="font-bold mb-5 font-display text-lg">Настройки</h1>
<div className="grid sm:grid-cols-2 gap-5 items-start">
<PasswordForm />
<AdminsSection />
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const COLORS = {
ok: 'bg-emerald-500',
warn: 'bg-amber-500',
danger: 'bg-rose-500',
neutral: 'bg-slate-400',
};
export default function StatusDot({ tone = 'neutral', pulse, label, mono }) {
const dot = COLORS[tone] || COLORS.neutral;
return (
<span className="inline-flex items-center gap-2">
<span className="relative flex h-2 w-2">
{pulse && <span className={cx('animate-ping absolute inline-flex h-full w-full rounded-full opacity-60', dot)} />}
<span className={cx('relative inline-flex rounded-full h-2 w-2', dot)} />
</span>
{label && <span className={cx('text-xs', mono && 'font-mono')}>{label}</span>}
</span>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ dark: false, toggle: () => {} });
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }) {
const [dark, setDark] = useState(() => {
try {
const saved = localStorage.getItem('ts-theme');
if (saved) return saved === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
} catch (e) {
return false;
}
});
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
try {
localStorage.setItem('ts-theme', dark ? 'dark' : 'light');
} catch (e) {
// localStorage unavailable (private mode etc.) - theme just won't persist
}
}, [dark]);
return (
<ThemeContext.Provider value={{ dark, toggle: () => setDark((d) => !d) }}>
{children}
</ThemeContext.Provider>
);
}
+20
View File
@@ -0,0 +1,20 @@
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700;900&family=Manrope:wght@400;500;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
overflow-x: hidden;
}
body {
font-family: 'Manrope', sans-serif;
overflow-x: hidden;
max-width: 100vw;
}
.ql-editor {
min-height: 220px;
font-size: 0.9rem;
}
+124
View File
@@ -0,0 +1,124 @@
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(BASE + path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
let message = 'Request failed';
try {
const data = await res.json();
message = data.error || message;
} catch (e) {
// response had no JSON body
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
export const api = {
// public
getServices: () => request('/services'),
getPromotions: () => request('/promotions'),
getReviews: () => request('/reviews'),
getCases: () => request('/cases'),
getFaqs: () => request('/faqs'),
getBlogPosts: () => request('/blog'),
getBlogPost: (slug) => request(`/blog/${slug}`),
getContact: () => request('/contact'),
submitReview: (data) => request('/reviews', { method: 'POST', body: JSON.stringify(data) }),
trackVisit: () => request('/stats/visit', { method: 'POST' }).catch(() => {}),
// auth
login: (username, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => request('/auth/logout', { method: 'POST' }),
me: () => request('/auth/me'),
changePassword: (currentPassword, newPassword) =>
request('/auth/change-password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) }),
// admin: reviews
adminGetReviews: () => request('/admin/reviews'),
adminSetReviewStatus: (id, status) =>
request(`/admin/reviews/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) }),
adminDeleteReview: (id) => request(`/admin/reviews/${id}`, { method: 'DELETE' }),
// admin: services / price list
adminGetServices: () => request('/admin/services'),
adminCreateCategory: (data) => request('/admin/services/categories', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateCategory: (id, data) =>
request(`/admin/services/categories/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteCategory: (id) => request(`/admin/services/categories/${id}`, { method: 'DELETE' }),
adminCreateItem: (data) => request('/admin/services/items', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateItem: (id, data) => request(`/admin/services/items/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteItem: (id) => request(`/admin/services/items/${id}`, { method: 'DELETE' }),
// admin: promotions
adminGetPromotions: () => request('/admin/promotions'),
adminCreatePromotion: (data) => request('/admin/promotions', { method: 'POST', body: JSON.stringify(data) }),
adminUpdatePromotion: (id, data) => request(`/admin/promotions/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeletePromotion: (id) => request(`/admin/promotions/${id}`, { method: 'DELETE' }),
// admin: stats
adminGetStats: () => request('/admin/stats'),
// admin: cases
adminGetCases: () => request('/admin/cases'),
adminCreateCase: (data) => request('/admin/cases', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateCase: (id, data) => request(`/admin/cases/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteCase: (id) => request(`/admin/cases/${id}`, { method: 'DELETE' }),
// admin: faqs
adminGetFaqs: () => request('/admin/faqs'),
adminCreateFaq: (data) => request('/admin/faqs', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateFaq: (id, data) => request(`/admin/faqs/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteFaq: (id) => request(`/admin/faqs/${id}`, { method: 'DELETE' }),
// admin: contact info
adminGetContact: () => request('/admin/contact'),
adminUpdateContact: (data) => request('/admin/contact', { method: 'PUT', body: JSON.stringify(data) }),
// admin: manage admin users
adminGetAdmins: () => request('/admin/admins'),
adminCreateAdminUser: (username, password) =>
request('/admin/admins', { method: 'POST', body: JSON.stringify({ username, password }) }),
adminDeleteAdminUser: (id) => request(`/admin/admins/${id}`, { method: 'DELETE' }),
// admin: ai assistant
aiStatus: () => request('/admin/ai/status'),
aiChat: (messages) => request('/admin/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
// admin: blog
adminGetBlogPosts: () => request('/admin/blog'),
adminCreateBlogPost: (data) => request('/admin/blog', { method: 'POST', body: JSON.stringify(data) }),
adminUpdateBlogPost: (id, data) => request(`/admin/blog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
adminDeleteBlogPost: (id) => request(`/admin/blog/${id}`, { method: 'DELETE' }),
// admin: customers
adminGetCustomers: () => request('/admin/customers'),
adminGetCustomerMessages: (id) => request(`/admin/customers/${id}/messages`),
adminSendCustomerMessage: (id, body) =>
request(`/admin/customers/${id}/messages`, { method: 'POST', body: JSON.stringify({ body }) }),
// customer auth
customerRegister: (data) => request('/customer/register', { method: 'POST', body: JSON.stringify(data) }),
customerLogin: (email, password) =>
request('/customer/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
customerLogout: () => request('/customer/logout', { method: 'POST' }),
customerMe: () => request('/customer/me'),
customerUpdateMe: (data) => request('/customer/me', { method: 'PUT', body: JSON.stringify(data) }),
customerChangePassword: (currentPassword, newPassword) =>
request('/customer/change-password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) }),
// account (protected customer area)
accountGetMessages: () => request('/account/messages'),
accountSendMessage: (body) => request('/account/messages', { method: 'POST', body: JSON.stringify({ body }) }),
accountAiStatus: () => request('/account/ai/status'),
accountAiChat: (messages) => request('/account/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
};
+7
View File
@@ -0,0 +1,7 @@
import { Bot, Headphones, ShieldCheck, Database, Network, Cloud, Globe, Server, Rocket, Handshake } from 'lucide-react';
export const ICONS = { Bot, Headphones, ShieldCheck, Database, Network, Cloud, Globe, Server, Rocket, Handshake };
export function getIcon(name) {
return ICONS[name] || Server;
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import { ThemeProvider } from './context/ThemeContext.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
display: ['Unbounded', 'sans-serif'],
body: ['Manrope', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
};
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
});
+3
View File
@@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY . .
EXPOSE 4000
CMD ["node", "src/index.js"]
+55
View File
@@ -0,0 +1,55 @@
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS service_categories (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
icon TEXT DEFAULT 'Server',
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS service_items (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES service_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
price_text TEXT NOT NULL,
period TEXT DEFAULT '',
note TEXT DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS promotions (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
price_text TEXT NOT NULL,
old_price_text TEXT,
description TEXT DEFAULT '',
active BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
role TEXT,
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS visits (
date DATE PRIMARY KEY,
count INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_service_items_category ON service_items(category_id);
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
tag TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'в работе',
tone TEXT NOT NULL DEFAULT 'warn' CHECK (tone IN ('ok', 'warn', 'danger', 'neutral')),
description TEXT DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS faqs (
id SERIAL PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true
);
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
company TEXT,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS support_messages (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
sender TEXT NOT NULL CHECK (sender IN ('customer', 'staff')),
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_support_messages_customer ON support_messages(customer_id);
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS contact_info (
id INT PRIMARY KEY DEFAULT 1,
address TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
hours TEXT NOT NULL DEFAULT '',
map_embed TEXT NOT NULL DEFAULT '',
CHECK (id = 1)
);
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS blog_posts (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT DEFAULT '',
content TEXT NOT NULL DEFAULT '',
published BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_published ON blog_posts(published, published_at);
@@ -0,0 +1,55 @@
-- Обновление прайс-листа: убирает старую заглушку "Разработка сайтов" (от 5 000 ₽)
-- и добавляет новую линейку "Разработка под ключ" + "White-label и партнёрство".
-- Это настоящая миграция (не разовый скрипт) - применяется САМА при следующем
-- запуске бэкенда (docker compose up -d --build), вручную ничего запускать не нужно.
-- Безопасна к повторному запуску (WHERE NOT EXISTS), но система миграций и так
-- гарантирует, что этот файл выполнится только один раз.
BEGIN;
-- 1) Убираем старую заглушку "Разработка сайтов" (каскадно удалит её позицию "Лендинг")
DELETE FROM service_categories WHERE slug = 'web';
-- 2) Новая категория: Разработка под ключ
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'development', 'Разработка под ключ',
'От быстрого MVP до продакшн-разработки и сопровождения', 'Rocket',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'development');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'development'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('MVP «Быстрый старт»', 'от 120 000 ₽', '', 'Посадочная страница, форма/бот для заявок, простая админка - 2-3 недели', 0),
('MVP Pro', 'от 450 000 ₽', '', 'Личный кабинет с авторизацией, оплата, ИИ-ассистент - 4-6 недель', 1),
('Продакшн-разработка', 'от 1 200 000 ₽', '', 'Архитектура, тесты, DevOps, масштабирование - от 2 месяцев, точная смета после дискавери', 2),
('Discovery-воркшоп', 'от 30 000 ₽', '', 'Фиксация ТЗ и оценки перед стартом проекта - 1-2 дня', 3),
('Сопровождение проекта', 'от 40 000 ₽', '/мес', 'Ежемесячные доработки уже готовых продуктов', 4)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'development') AND name = v.name
);
-- 3) Новая категория: White-label и партнёрство
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'partners', 'White-label и партнёрство',
'Готовые решения для других компаний и программы сотрудничества', 'Handshake',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'partners');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'partners'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('White-label «Кабинет + ИИ-ассистент»', 'от 500 000 ₽', '', 'Личный кабинет с авторизацией и ИИ-чатом под брендом вашей компании', 0),
('IT-мониторинг сайта', 'от 10 000 ₽', '/мес', 'Аптайм, скорость загрузки, уведомления об инцидентах', 1),
('Реферальная программа', '10%', 'от суммы контракта', 'Бонус вам за рекомендацию нас новому клиенту', 2),
('Партнёрская программа', 'по договорённости', '', 'Комиссия для хостинг-провайдеров и поставщиков оборудования за рекомендации', 3)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'partners') AND name = v.name
);
COMMIT;
+20
View File
@@ -0,0 +1,20 @@
-- Разводим простую посадочную страницу и полноценный MVP по разным ценам:
-- "Лендинг" - без бота-формы и админки, просто сайт;
-- "MVP «Быстрый старт»" (добавлен в 006) - с ботом/формой заявок и админкой.
-- Автоматически применяется при следующем запуске бэкенда, вручную запускать
-- ничего не нужно. Безопасна к повторному запуску (WHERE NOT EXISTS).
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT
(SELECT id FROM service_categories WHERE slug = 'development'),
'Лендинг',
'от 20 000 ₽',
'',
'Простая посадочная страница под продукт или услугу - без формы-бота и админки, 1 неделя',
-1
WHERE EXISTS (SELECT 1 FROM service_categories WHERE slug = 'development')
AND NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'development')
AND name = 'Лендинг'
);
@@ -0,0 +1,38 @@
-- Разводит пересекающиеся предложения по почтовым серверам (акция vs услуга) -
-- явно показывает разницу в составе, и добавляет ориентировочные показатели
-- (сроки/% улучшения/объём) в описания кейсов, с пометкой, что цифры оценочные
-- и уточняются по каждому проекту отдельно.
-- Автоматически применяется при следующем запуске бэкенда, вручную запускать
-- ничего не нужно. UPDATE-ы безопасны к повторному запуску.
BEGIN;
-- 1) Услуги "Почтовый сервер" - уточняем, что НЕ входит (в отличие от акции)
UPDATE service_items
SET note = 'Развёртывание с нуля: установка, домены и почтовые ящики. Без SPF/DKIM/DMARC, антиспама и веб-почты - это в акции «Почтовый сервер под ключ»'
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'network')
AND name = 'Почтовый сервер (ваше оборудование)';
UPDATE service_items
SET note = 'Аренда сервера + ежемесячное администрирование почты. Разовая настройка SPF/DKIM/DMARC и антиспама - отдельно или по акции «Почтовый сервер под ключ»'
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'network')
AND name = 'Почтовый сервер (наше оборудование)';
-- 2) Акция "Почтовый сервер под ключ" - явно перечисляем полный состав пакета
UPDATE promotions
SET description = 'Разовая настройка на вашем оборудовании: SPF/DKIM/DMARC, антиспам, веб-почта и 3 месяца сопровождения - всё включено в стоимость по акционной цене'
WHERE title = 'Почтовый сервер под ключ';
-- 3) Кейсы - добавляем ориентировочные цифры (сроки/объём/% улучшения)
UPDATE cases
SET description = 'Перенесли серверы 12 магазинов на новое оборудование без остановки продаж за ~3 недели, снизили время отклика сети на ~30%* (*оценка, уточняется с клиентом по итогам проекта).'
WHERE title = 'Миграция инфраструктуры розничной сети';
UPDATE cases
SET description = 'Внедрили Telegram-бота с RAG на базе базы знаний компании за ~4 недели - снизили нагрузку на операторов поддержки на ~40%* по числу типовых обращений (*оценка, уточняется с клиентом).'
WHERE title = 'ИИ-ассистент для отдела поддержки';
UPDATE cases
SET description = 'Проводим комплексный аудит защищённости и внедряем систему мониторинга угроз 24/7, срок проекта - ориентировочно 6-8 недель* (*уточняется по ходу работ).'
WHERE title = 'Аудит кибербезопасности финтех-проекта';
COMMIT;
@@ -0,0 +1,140 @@
-- Первые статьи блога под конкретные коммерческие направления (IT-аутсорсинг,
-- почтовые серверы, кибербезопасность, ИИ-автоматизация). Автоматически
-- применяется при следующем запуске бэкенда, вручную запускать не нужно.
-- Безопасна к повторному запуску (ON CONFLICT DO NOTHING по slug).
BEGIN;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'it-autsorsing-kak-vybrat-podryadchika',
'Как выбрать IT-аутсорсинг для бизнеса: 7 критериев',
'На что смотреть при выборе подрядчика на IT-аутсорсинг, чтобы не переплатить и не остаться без поддержки в критический момент.',
$html$
<p>Передача IT-инфраструктуры на аутсорсинг снимает с бизнеса часть операционной нагрузки, но выбор подрядчика легко превращается в лотерею. Вот на что стоит смотреть до подписания договора.</p>
<h2>1. Время реакции на инциденты закреплено в договоре</h2>
<p>«Быстро отреагируем» - не показатель. Спрашивайте конкретное время реакции по каждому уровню критичности инцидента и убедитесь, что оно зафиксировано в SLA, а не устно.</p>
<h2>2. Есть разные тарифы под разную критичность бизнеса</h2>
<p>Интернет-магазину с пиковыми нагрузками нужна поддержка 24/7, а небольшому офису может хватить графика 5/2. Хороший подрядчик предлагает несколько уровней тарифа, а не один пакет «для всех».</p>
<h2>3. NDA и защищённый доступ к инфраструктуре - по умолчанию</h2>
<p>Подрядчик получает доступ к серверам, базам данных и переписке. Подписание NDA и использование защищённых каналов доступа (а не паролей в мессенджере) должно быть стандартной практикой, а не опцией по запросу.</p>
<h2>4. Прозрачная отчётность о состоянии инфраструктуры</h2>
<p>Вы должны понимать, что происходит с вашими серверами, даже не разбираясь в администрировании глубоко: сколько было инцидентов, что чинили, какие риски видят специалисты.</p>
<h2>5. Возможность разовых работ без абонемента</h2>
<p>Не всегда нужен полный абонемент - иногда достаточно разовой настройки сервера или аудита. Гибкость в этом вопросе часто говорит о зрелости подрядчика больше, чем маркетинговые обещания.</p>
<h2>6. Выезд специалиста - если работаете не полностью удалённо</h2>
<p>Даже при удалённой поддержке иногда нужен физический доступ к оборудованию: замена комплектующих, работа с сетевым железом. Уточняйте регион выезда и сроки реакции по выездным заявкам отдельно от удалённых.</p>
<h2>7. Референсы и живые кейсы, а не только логотипы клиентов</h2>
<p>Логотип на сайте ничего не доказывает. Спрашивайте, какие задачи решали для похожего на ваш бизнеса и какие результаты получили - сроки, объём работ, что изменилось.</p>
<p>Мы в TopSysOps работаем именно по такой модели: несколько тарифов IT-аутсорсинга от базовой удалённой поддержки до 24/7, NDA перед стартом и подробный <a href="/uslugi/support">прайс-лист на техподдержку</a>. Если сомневаетесь, с чего начать - <a href="/kontakty">напишите нам</a>, обсудим вашу инфраструктуру бесплатно.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'nastroyka-pochtovogo-servera-spf-dkim-dmarc',
'Настройка корпоративной почты: SPF, DKIM, DMARC и защита от спама - чек-лист',
'Разбираем, зачем нужны SPF, DKIM и DMARC для корпоративной почты и как проверить, что ваш домен не помечают спамом.',
$html$
<p>Если письма от вашей компании попадают в спам или, того хуже, кто-то отправляет фишинг от имени вашего домена - вероятно, у почтового сервера не настроены базовые механизмы защиты. Разбираем, что это такое и зачем нужно.</p>
<h2>SPF - кто имеет право отправлять почту от вашего домена</h2>
<p>SPF (Sender Policy Framework) - это запись в DNS, которая перечисляет серверы, которым разрешено отправлять письма от имени вашего домена. Без неё любой сервер в мире технически может подделать письмо «от вас».</p>
<h2>DKIM - цифровая подпись письма</h2>
<p>DKIM добавляет к каждому письму криптографическую подпись, которую можно проверить на стороне получателя. Это подтверждает, что письмо не было изменено по дороге и действительно отправлено с вашего сервера.</p>
<h2>DMARC - что делать с письмами, которые не прошли проверку</h2>
<p>DMARC связывает SPF и DKIM и объясняет почтовым сервисам получателей, что делать с письмом, которое не прошло проверки: пропустить, отправить в спам или отклонить. Также DMARC присылает отчёты о том, кто пытается отправлять письма от вашего домена.</p>
<h2>Антиспам и веб-почта - вторая часть уравнения</h2>
<p>SPF/DKIM/DMARC защищают репутацию домена на исходящих письмах. Отдельно нужен антиспам-фильтр на входящих, чтобы сотрудники не разбирали десятки спам-писем вручную, и веб-почта для доступа к ящику из браузера без настройки почтового клиента на каждом устройстве.</p>
<h2>Как понять, что пора этим заняться</h2>
<ul>
<li>Письма клиентам иногда попадают в папку «Спам»</li>
<li>Были случаи фишинговых писем «от имени» вашей компании</li>
<li>Почтовый сервер настраивался разово несколько лет назад и с тех пор не пересматривался</li>
<li>Нет ни SPF, ни DKIM, ни DMARC записей в DNS вашего домена (это можно быстро проверить любым онлайн-чекером)</li>
</ul>
<p>У нас есть отдельная услуга по <a href="/uslugi/network">развёртыванию и администрированию почтовых серверов</a>, а на комплексную настройку с SPF/DKIM/DMARC, антиспамом, веб-почтой и тремя месяцами сопровождения действует акция «Почтовый сервер под ключ» - подробности на <a href="/aktsii">странице акций</a>.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'kiberbezopasnost-malogo-biznesa-s-chego-nachat',
'Кибербезопасность малого и среднего бизнеса: с чего начать',
'Небольшой компании не нужен SOC на 20 человек - но базовый набор мер по кибербезопасности нужен любому бизнесу с серверами и клиентскими данными.',
$html$
<p>Кибербезопасность часто воспринимается как что-то нужное только банкам и крупным корпорациям. На практике небольшая компания - удобная мишень именно потому, что базовые меры защиты у неё, как правило, не настроены.</p>
<h2>1. Аудит - прежде чем что-то внедрять</h2>
<p>Нельзя защитить то, о чём не знаешь. Первый шаг - аудит текущей инфраструктуры: какие сервисы смотрят наружу, какие пароли и доступы используются, где хранятся резервные копии и кто имеет к ним доступ.</p>
<h2>2. Мониторинг вместо разовой проверки</h2>
<p>Аудит - это снимок на конкретный момент. Угрозы появляются постоянно, поэтому кроме разового аудита нужен регулярный мониторинг: логи, алерты о подозрительной активности, обновления уязвимого ПО.</p>
<h2>3. IDS/IPS - обнаружение и предотвращение вторжений</h2>
<p>Системы обнаружения (IDS) и предотвращения (IPS) вторжений отслеживают трафик на предмет подозрительных паттернов - сканирование портов, попытки перебора паролей, известные сигнатуры атак - и либо оповещают, либо блокируют их автоматически.</p>
<h2>4. Резервное копирование, отделённое от основной инфраструктуры</h2>
<p>Один из самых частых сценариев атаки - шифровальщик, который уничтожает и рабочие данные, и бэкапы, если они лежат в одном месте. Резервные копии должны быть изолированы и регулярно проверяться на возможность восстановления.</p>
<h2>5. NDA и разграничение доступов для подрядчиков</h2>
<p>Если с инфраструктурой работает внешняя команда (в том числе на аутсорсе), убедитесь, что доступы выданы по принципу минимально необходимых прав, а не «админ всем».</p>
<h2>С чего начать конкретно</h2>
<p>Если у вас пока нет ни одного из этих пунктов - начните с аудита. Он покажет, какие меры критичны прямо сейчас, а что можно внедрить постепенно. Мы проводим <a href="/uslugi/security">аудит и мониторинг кибербезопасности</a>, включая внедрение IDS/IPS - можно начать с разового аудита без обязательств по абонементу.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'ii-assistent-dlya-biznesa-kak-vnedrit',
'ИИ-ассистент для бизнеса: как внедрить и не прогореть',
'ИИ-бот для поддержки клиентов или сотрудников - не «просто чат-бот». Разбираем, что действительно нужно для рабочего внедрения.',
$html$
<p>Спрос на «внедрить ИИ» часто формулируется расплывчато: «хотим бота, который отвечает на вопросы». В реальности между таким запросом и рабочим инструментом есть несколько важных шагов.</p>
<h2>RAG, а не просто обёртка над языковой моделью</h2>
<p>Просто подключить готовую модель недостаточно - она не знает деталей вашего бизнеса и может отвечать общими фразами или ошибаться в конкретике. RAG (Retrieval-Augmented Generation) добавляет ассистенту доступ к базе знаний вашей компании: документации, FAQ, истории обращений - и ответы становятся конкретными, а не общими.</p>
<h2>Интеграция с реальными каналами</h2>
<p>Ассистент, до которого нужно отдельно заходить на непонятную страницу, используется гораздо реже, чем бот в Telegram или на сайте, куда клиенты и так пишут. Интеграция с мессенджерами, CRM и внутренними сервисами - то, что определяет, будут ли им пользоваться.</p>
<h2>Где ИИ-ассистент окупается быстрее всего</h2>
<ul>
<li>Ответы на типовые вопросы поддержки - снижает нагрузку на операторов на рутинных обращениях</li>
<li>Первичная квалификация заявок до передачи специалисту</li>
<li>Внутренний ассистент для сотрудников по регламентам и базе знаний компании</li>
</ul>
<h2>Что нужно для старта</h2>
<p>Нужна база знаний (даже неструктурированная - её можно привести в порядок в процессе), понимание, в каком канале живут ваши клиенты или сотрудники, и готовность донастраивать ассистента после запуска: первая версия почти никогда не бывает финальной.</p>
<p>Мы разрабатываем ботов и ИИ-ассистентов с обучением на данных компании и дальнейшим помесячным сопровождением - подробнее на странице <a href="/uslugi/ai">«ИИ и автоматизация»</a>. Если пока не уверены, что именно автоматизировать в первую очередь - <a href="/kontakty">напишите нам</a>, поможем определить приоритеты.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
COMMIT;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "topsysops-backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
}
}
@@ -0,0 +1,55 @@
-- Обновление прайс-листа: убирает старую заглушку "Разработка сайтов" (от 5 000 ₽)
-- и добавляет новую линейку "Разработка под ключ" + "White-label и партнёрство".
-- Безопасно выполнять повторно - использует WHERE NOT EXISTS, дублей не создаст.
-- Запуск:
-- docker compose exec -T db psql -U topsysops topsysops < backend/scripts/2026-07-update-services.sql
BEGIN;
-- 1) Убираем старую заглушку "Разработка сайтов" (каскадно удалит её позицию "Лендинг")
DELETE FROM service_categories WHERE slug = 'web';
-- 2) Новая категория: Разработка под ключ
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'development', 'Разработка под ключ',
'От быстрого MVP до продакшн-разработки и сопровождения', 'Rocket',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'development');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'development'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('MVP «Быстрый старт»', 'от 120 000 ₽', '', 'Посадочная страница, форма/бот для заявок, простая админка - 2-3 недели', 0),
('MVP Pro', 'от 450 000 ₽', '', 'Личный кабинет с авторизацией, оплата, ИИ-ассистент - 4-6 недель', 1),
('Продакшн-разработка', 'от 1 200 000 ₽', '', 'Архитектура, тесты, DevOps, масштабирование - от 2 месяцев, точная смета после дискавери', 2),
('Discovery-воркшоп', 'от 30 000 ₽', '', 'Фиксация ТЗ и оценки перед стартом проекта - 1-2 дня', 3),
('Сопровождение проекта', 'от 40 000 ₽', '/мес', 'Ежемесячные доработки уже готовых продуктов', 4)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'development') AND name = v.name
);
-- 3) Новая категория: White-label и партнёрство
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'partners', 'White-label и партнёрство',
'Готовые решения для других компаний и программы сотрудничества', 'Handshake',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'partners');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'partners'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('White-label «Кабинет + ИИ-ассистент»', 'от 500 000 ₽', '', 'Личный кабинет с авторизацией и ИИ-чатом под брендом вашей компании', 0),
('IT-мониторинг сайта', 'от 10 000 ₽', '/мес', 'Аптайм, скорость загрузки, уведомления об инцидентах', 1),
('Реферальная программа', '10%', 'от суммы контракта', 'Бонус вам за рекомендацию нас новому клиенту', 2),
('Партнёрская программа', 'по договорённости', '', 'Комиссия для хостинг-провайдеров и поставщиков оборудования за рекомендации', 3)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'partners') AND name = v.name
);
COMMIT;
+38
View File
@@ -0,0 +1,38 @@
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const COOKIE_NAME = 'ts_admin_session';
if (!SECRET) {
console.warn('[auth] JWT_SECRET is not set - set a long random value in .env before going to production');
}
function issueToken(res, payload) {
const token = jwt.sign({ ...payload, type: 'admin' }, SECRET || 'insecure-dev-secret', { expiresIn: '12h' });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.COOKIE_SECURE !== 'false',
maxAge: 12 * 60 * 60 * 1000,
path: '/',
});
}
function clearToken(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
function requireAuth(req, res, next) {
const token = req.cookies ? req.cookies[COOKIE_NAME] : null;
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
const payload = jwt.verify(token, SECRET || 'insecure-dev-secret');
if (payload.type !== 'admin') return res.status(401).json({ error: 'unauthorized' });
req.admin = payload;
next();
} catch (e) {
return res.status(401).json({ error: 'unauthorized' });
}
}
module.exports = { issueToken, clearToken, requireAuth, COOKIE_NAME };
+34
View File
@@ -0,0 +1,34 @@
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const COOKIE_NAME = 'ts_customer_session';
function issueCustomerToken(res, payload) {
const token = jwt.sign({ ...payload, type: 'customer' }, SECRET || 'insecure-dev-secret', { expiresIn: '7d' });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.COOKIE_SECURE !== 'false',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/',
});
}
function clearCustomerToken(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
function requireCustomerAuth(req, res, next) {
const token = req.cookies ? req.cookies[COOKIE_NAME] : null;
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
const payload = jwt.verify(token, SECRET || 'insecure-dev-secret');
if (payload.type !== 'customer') return res.status(401).json({ error: 'unauthorized' });
req.customer = payload;
next();
} catch (e) {
return res.status(401).json({ error: 'unauthorized' });
}
}
module.exports = { issueCustomerToken, clearCustomerToken, requireCustomerAuth, COOKIE_NAME };
+11
View File
@@ -0,0 +1,11 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.on('error', (err) => {
console.error('[db] unexpected error on idle client', err);
});
module.exports = pool;
+106
View File
@@ -0,0 +1,106 @@
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const compression = require('compression');
const migrate = require('./migrate');
const seed = require('./seed');
const pool = require('./db');
const { requireAuth } = require('./auth');
const { requireCustomerAuth } = require('./customerAuth');
const authRouter = require('./routes/auth');
const publicRouter = require('./routes/public');
const adminRouter = require('./routes/admin');
const customerAuthRouter = require('./routes/customerAuth');
const accountRouter = require('./routes/account');
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(compression());
app.use(express.json({ limit: '200kb' }));
app.use(cookieParser());
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.get('/sitemap.xml', async (req, res) => {
const base = (process.env.PUBLIC_SITE_URL || 'https://top-sysops.ru').replace(/\/$/, '');
let posts = [];
try {
const { rows } = await pool.query(
'SELECT slug, updated_at FROM blog_posts WHERE published = true ORDER BY published_at DESC'
);
posts = rows;
} catch (err) {
console.error('[sitemap] failed to load blog posts', err);
}
const urls = [
{ loc: `${base}/`, changefreq: 'weekly', priority: '1.0' },
{ loc: `${base}/blog`, changefreq: 'weekly', priority: '0.7' },
...posts.map((p) => ({
loc: `${base}/blog/${p.slug}`,
changefreq: 'monthly',
priority: '0.6',
lastmod: new Date(p.updated_at).toISOString().slice(0, 10),
})),
];
const body = urls
.map(
(u) =>
` <url>\n <loc>${u.loc}</loc>\n${u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>\n` : ''} <changefreq>${u.changefreq}</changefreq>\n <priority>${u.priority}</priority>\n </url>`
)
.join('\n');
res.set('Content-Type', 'application/xml');
res.send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`);
});
app.use('/api/auth', authRouter);
app.use('/api', publicRouter);
app.use('/api/admin', requireAuth, adminRouter);
app.use('/api/customer', customerAuthRouter);
app.use('/api/account', requireCustomerAuth, accountRouter);
app.use((req, res) => {
res.status(404).json({ error: 'not found' });
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[error]', err);
res.status(500).json({ error: 'internal server error' });
});
const PORT = process.env.PORT || 4000;
async function waitForDatabaseAndMigrate() {
const maxAttempts = 30;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await migrate();
return;
} catch (err) {
console.log(`[start] database not ready yet (attempt ${attempt}/${maxAttempts}): ${err.message}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw new Error('database did not become ready in time');
}
async function start() {
await waitForDatabaseAndMigrate();
await seed();
app.listen(PORT, () => console.log(`[start] backend listening on port ${PORT}`));
}
start().catch((err) => {
console.error('[start] fatal error during startup', err);
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
const pool = require('./db');
async function migrate() {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT now()
)
`);
const dir = path.join(__dirname, '..', 'migrations');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
const { rows } = await pool.query('SELECT 1 FROM schema_migrations WHERE filename = $1', [file]);
if (rows.length) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
console.log(`[migrate] applying ${file}`);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}
module.exports = migrate;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const router = express.Router();
/* ---------- support messages (chat with us) ---------- */
router.get('/messages', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, sender, body, created_at FROM support_messages WHERE customer_id = $1 ORDER BY created_at ASC',
[req.customer.sub]
);
res.json(rows);
});
const messageLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many messages, try again later' },
});
router.post('/messages', messageLimiter, async (req, res) => {
const { body } = req.body || {};
if (!body || !String(body).trim()) return res.status(400).json({ error: 'body is required' });
const { rows } = await pool.query(
"INSERT INTO support_messages (customer_id, sender, body) VALUES ($1, 'customer', $2) RETURNING id, sender, body, created_at",
[req.customer.sub, String(body).slice(0, 4000)]
);
res.status(201).json(rows[0]);
});
/* ---------- ai assistant (OpenRouter) - customer-facing chat ---------- */
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-4o-mini';
router.get('/ai/status', (req, res) => {
res.json({ configured: Boolean(OPENROUTER_API_KEY) });
});
const aiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 15,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many messages, try again later' },
});
router.post('/ai/chat', aiLimiter, async (req, res) => {
if (!OPENROUTER_API_KEY) {
return res.status(503).json({ error: 'AI is not configured' });
}
const { messages } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: 'messages array is required' });
}
const trimmed = messages.slice(-20).map((m) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: String(m.content || '').slice(0, 2000),
}));
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': process.env.PUBLIC_SITE_URL || 'https://top-sysops.ru',
'X-Title': 'TopSysOps Client Assistant',
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'system',
content:
'Ты - ассистент компании TopSysOps (top-sysops.ru) для зарегистрированных клиентов. Отвечаешь на вопросы об услугах, тарифах и IT-инфраструктуре в целом, кратко и дружелюбно, на русском языке. Если вопрос требует точного расчёта стоимости или доступа к данным конкретного клиента - предложи написать в разделе "Написать нам" личного кабинета, там ответит сотрудник компании.',
},
...trimmed,
],
}),
});
if (!response.ok) {
const text = await response.text();
console.error('[account-ai] OpenRouter error', response.status, text);
return res.status(502).json({ error: 'AI provider request failed' });
}
const data = await response.json();
const reply = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : '';
res.json({ reply });
} catch (err) {
console.error('[account-ai] request failed', err);
res.status(502).json({ error: 'AI provider request failed' });
}
});
module.exports = router;
+465
View File
@@ -0,0 +1,465 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('../db');
const { sanitizeHtml } = require('../sanitizeHtml');
const router = express.Router();
/* ---------- reviews ---------- */
router.get('/reviews', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM reviews ORDER BY created_at DESC');
res.json(rows);
});
router.patch('/reviews/:id', async (req, res) => {
const { status } = req.body || {};
if (!['approved', 'rejected', 'pending'].includes(status)) {
return res.status(400).json({ error: 'status must be approved, rejected or pending' });
}
const { rows } = await pool.query('UPDATE reviews SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/reviews/:id', async (req, res) => {
await pool.query('DELETE FROM reviews WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- services / price list ---------- */
router.get('/services', async (req, res) => {
const { rows: categories } = await pool.query('SELECT * FROM service_categories ORDER BY sort_order, id');
const { rows: items } = await pool.query('SELECT * FROM service_items ORDER BY sort_order, id');
res.json(categories.map((cat) => ({ ...cat, items: items.filter((it) => it.category_id === cat.id) })));
});
router.post('/services/categories', async (req, res) => {
const { slug, title, description, icon } = req.body || {};
if (!slug || !title) return res.status(400).json({ error: 'slug and title are required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM service_categories');
try {
const { rows } = await pool.query(
'INSERT INTO service_categories (slug, title, description, icon, sort_order) VALUES ($1,$2,$3,$4,$5) RETURNING *',
[slug, title, description || '', icon || 'Server', maxRows[0].next]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'a category with this slug already exists' });
throw err;
}
});
router.put('/services/categories/:id', async (req, res) => {
const { title, description, icon, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE service_categories SET
title = COALESCE($1, title),
description = COALESCE($2, description),
icon = COALESCE($3, icon),
sort_order = COALESCE($4, sort_order)
WHERE id = $5 RETURNING *`,
[title, description, icon, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/services/categories/:id', async (req, res) => {
await pool.query('DELETE FROM service_categories WHERE id = $1', [req.params.id]);
res.status(204).end();
});
router.post('/services/items', async (req, res) => {
const { category_id, name, price_text, period, note } = req.body || {};
if (!category_id || !name || !price_text) {
return res.status(400).json({ error: 'category_id, name and price_text are required' });
}
const { rows: maxRows } = await pool.query(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM service_items WHERE category_id = $1',
[category_id]
);
const { rows } = await pool.query(
'INSERT INTO service_items (category_id, name, price_text, period, note, sort_order) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
[category_id, name, price_text, period || '', note || '', maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/services/items/:id', async (req, res) => {
const { name, price_text, period, note, active, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE service_items SET
name = COALESCE($1, name),
price_text = COALESCE($2, price_text),
period = COALESCE($3, period),
note = COALESCE($4, note),
active = COALESCE($5, active),
sort_order = COALESCE($6, sort_order)
WHERE id = $7 RETURNING *`,
[name, price_text, period, note, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/services/items/:id', async (req, res) => {
await pool.query('DELETE FROM service_items WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- promotions ---------- */
router.get('/promotions', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM promotions ORDER BY sort_order, id');
res.json(rows);
});
router.post('/promotions', async (req, res) => {
const { title, price_text, old_price_text, description } = req.body || {};
if (!title || !price_text) return res.status(400).json({ error: 'title and price_text are required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM promotions');
const { rows } = await pool.query(
'INSERT INTO promotions (title, price_text, old_price_text, description, sort_order) VALUES ($1,$2,$3,$4,$5) RETURNING *',
[title, price_text, old_price_text || null, description || '', maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/promotions/:id', async (req, res) => {
const { title, price_text, old_price_text, description, active, sort_order } = req.body || {};
const { rows: existingRows } = await pool.query('SELECT * FROM promotions WHERE id = $1', [req.params.id]);
if (!existingRows.length) return res.status(404).json({ error: 'not found' });
// old_price_text - особый случай: null должен явно ОЧИЩАТЬ поле (форма
// редактирования иногда так и делает), но если поле вообще не прислали
// (например, обновляем только sort_order при сортировке) - нужно сохранить
// прежнее значение, а не затереть его в null.
const nextOldPriceText = old_price_text === undefined ? existingRows[0].old_price_text : old_price_text;
const { rows } = await pool.query(
`UPDATE promotions SET
title = COALESCE($1, title),
price_text = COALESCE($2, price_text),
old_price_text = $3,
description = COALESCE($4, description),
active = COALESCE($5, active),
sort_order = COALESCE($6, sort_order)
WHERE id = $7 RETURNING *`,
[title, price_text, nextOldPriceText, description, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/promotions/:id', async (req, res) => {
await pool.query('DELETE FROM promotions WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- cases ---------- */
router.get('/cases', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM cases ORDER BY sort_order, id');
res.json(rows);
});
router.post('/cases', async (req, res) => {
const { title, tag, status, tone, description } = req.body || {};
if (!title) return res.status(400).json({ error: 'title is required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM cases');
const { rows } = await pool.query(
'INSERT INTO cases (title, tag, status, tone, description, sort_order) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
[title, tag || '', status || 'в работе', tone || 'warn', description || '', maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/cases/:id', async (req, res) => {
const { title, tag, status, tone, description, active, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE cases SET
title = COALESCE($1, title),
tag = COALESCE($2, tag),
status = COALESCE($3, status),
tone = COALESCE($4, tone),
description = COALESCE($5, description),
active = COALESCE($6, active),
sort_order = COALESCE($7, sort_order)
WHERE id = $8 RETURNING *`,
[title, tag, status, tone, description, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/cases/:id', async (req, res) => {
await pool.query('DELETE FROM cases WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- faqs ---------- */
router.get('/faqs', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM faqs ORDER BY sort_order, id');
res.json(rows);
});
router.post('/faqs', async (req, res) => {
const { question, answer } = req.body || {};
if (!question || !answer) return res.status(400).json({ error: 'question and answer are required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM faqs');
const { rows } = await pool.query(
'INSERT INTO faqs (question, answer, sort_order) VALUES ($1,$2,$3) RETURNING *',
[question, answer, maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/faqs/:id', async (req, res) => {
const { question, answer, active, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE faqs SET
question = COALESCE($1, question),
answer = COALESCE($2, answer),
active = COALESCE($3, active),
sort_order = COALESCE($4, sort_order)
WHERE id = $5 RETURNING *`,
[question, answer, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/faqs/:id', async (req, res) => {
await pool.query('DELETE FROM faqs WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- admin users management ---------- */
router.get('/admins', async (req, res) => {
const { rows } = await pool.query('SELECT id, username, created_at FROM admin_users ORDER BY created_at ASC');
res.json(rows);
});
router.post('/admins', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
if (String(password).length < 8) return res.status(400).json({ error: 'password must be at least 8 characters' });
const hash = await bcrypt.hash(password, 12);
try {
const { rows } = await pool.query(
'INSERT INTO admin_users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at',
[String(username).trim().slice(0, 60), hash]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'this username is already taken' });
throw err;
}
});
router.delete('/admins/:id', async (req, res) => {
const targetId = Number(req.params.id);
if (targetId === req.admin.sub) {
return res.status(400).json({ error: 'you cannot delete your own account while logged in' });
}
const { rows: countRows } = await pool.query('SELECT COUNT(*)::int AS c FROM admin_users');
if (countRows[0].c <= 1) {
return res.status(400).json({ error: 'cannot delete the last remaining admin' });
}
await pool.query('DELETE FROM admin_users WHERE id = $1', [targetId]);
res.status(204).end();
});
/* ---------- contact info ---------- */
router.get('/contact', async (req, res) => {
const { rows } = await pool.query('SELECT address, phone, email, hours, map_embed FROM contact_info WHERE id = 1');
res.json(rows[0] || { address: '', phone: '', email: '', hours: '', map_embed: '' });
});
router.put('/contact', async (req, res) => {
const { address, phone, email, hours, map_embed } = req.body || {};
const { rows } = await pool.query(
`INSERT INTO contact_info (id, address, phone, email, hours, map_embed)
VALUES (1, $1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
address = EXCLUDED.address,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
hours = EXCLUDED.hours,
map_embed = EXCLUDED.map_embed
RETURNING *`,
[address || '', phone || '', email || '', hours || '', map_embed || '']
);
res.json(rows[0]);
});
/* ---------- customers / support messages ---------- */
router.get('/customers', async (req, res) => {
const { rows } = await pool.query(`
SELECT c.id, c.name, c.email, c.company, c.created_at,
lm.body AS last_message_body,
lm.sender AS last_message_sender,
lm.created_at AS last_message_at
FROM customers c
LEFT JOIN LATERAL (
SELECT body, sender, created_at FROM support_messages
WHERE customer_id = c.id ORDER BY created_at DESC LIMIT 1
) lm ON true
ORDER BY COALESCE(lm.created_at, c.created_at) DESC
`);
res.json(rows);
});
router.get('/customers/:id/messages', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, sender, body, created_at FROM support_messages WHERE customer_id = $1 ORDER BY created_at ASC',
[req.params.id]
);
res.json(rows);
});
router.post('/customers/:id/messages', async (req, res) => {
const { body } = req.body || {};
if (!body || !String(body).trim()) return res.status(400).json({ error: 'body is required' });
const { rows } = await pool.query(
"INSERT INTO support_messages (customer_id, sender, body) VALUES ($1, 'staff', $2) RETURNING id, sender, body, created_at",
[req.params.id, String(body).slice(0, 4000)]
);
res.status(201).json(rows[0]);
});
/* ---------- blog ---------- */
router.get('/blog', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM blog_posts ORDER BY created_at DESC');
res.json(rows);
});
router.post('/blog', async (req, res) => {
const { slug, title, excerpt, content, published } = req.body || {};
if (!slug || !title) return res.status(400).json({ error: 'slug and title are required' });
try {
const { rows } = await pool.query(
`INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES ($1,$2,$3,$4,$5, CASE WHEN $5 THEN now() ELSE NULL END) RETURNING *`,
[slug, title, excerpt || '', sanitizeHtml(content || ''), Boolean(published)]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'a post with this slug already exists' });
throw err;
}
});
router.put('/blog/:id', async (req, res) => {
const { title, excerpt, content, published } = req.body || {};
const { rows: existingRows } = await pool.query('SELECT * FROM blog_posts WHERE id = $1', [req.params.id]);
if (!existingRows.length) return res.status(404).json({ error: 'not found' });
const existing = existingRows[0];
const cleanContent = content === undefined ? undefined : sanitizeHtml(content);
const nowPublishing = published === true && !existing.published;
const { rows } = await pool.query(
`UPDATE blog_posts SET
title = COALESCE($1, title),
excerpt = COALESCE($2, excerpt),
content = COALESCE($3, content),
published = COALESCE($4, published),
published_at = CASE WHEN $5 THEN now() ELSE published_at END,
updated_at = now()
WHERE id = $6 RETURNING *`,
[title, excerpt, cleanContent, published, nowPublishing, req.params.id]
);
res.json(rows[0]);
});
router.delete('/blog/:id', async (req, res) => {
await pool.query('DELETE FROM blog_posts WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- ai assistant (OpenRouter) - staff-only tool inside the admin panel ---------- */
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-4o-mini';
router.get('/ai/status', (req, res) => {
res.json({ configured: Boolean(OPENROUTER_API_KEY), model: OPENROUTER_MODEL });
});
router.post('/ai/chat', async (req, res) => {
if (!OPENROUTER_API_KEY) {
return res.status(503).json({ error: 'OPENROUTER_API_KEY is not configured on the server' });
}
const { messages } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: 'messages array is required' });
}
const trimmed = messages.slice(-20).map((m) => ({
role: ['user', 'assistant', 'system'].includes(m.role) ? m.role : 'user',
content: String(m.content || '').slice(0, 4000),
}));
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': process.env.PUBLIC_SITE_URL || 'https://top-sysops.ru',
'X-Title': 'TopSysOps Admin Assistant',
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'system',
content:
'Ты - внутренний ассистент админ-панели IT-компании TopSysOps. Помогаешь сотруднику: помогаешь формулировать ответы на отзывы клиентов, отвечаешь на вопросы по прайс-листу и организационным задачам. Отвечай кратко и по делу, на русском языке.',
},
...trimmed,
],
}),
});
if (!response.ok) {
const text = await response.text();
console.error('[ai] OpenRouter error', response.status, text);
return res.status(502).json({ error: 'AI provider request failed' });
}
const data = await response.json();
const reply = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : '';
res.json({ reply });
} catch (err) {
console.error('[ai] request failed', err);
res.status(502).json({ error: 'AI provider request failed' });
}
});
/* ---------- stats ---------- */
router.get('/stats', async (req, res) => {
const { rows } = await pool.query(
"SELECT to_char(date, 'YYYY-MM-DD') AS date, count FROM visits WHERE date >= CURRENT_DATE - INTERVAL '29 days' ORDER BY date"
);
const { rows: totalRows } = await pool.query('SELECT COALESCE(SUM(count), 0)::int AS total FROM visits');
res.json({ byDay: rows, total: totalRows[0].total });
});
module.exports = router;
+59
View File
@@ -0,0 +1,59 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { issueToken, clearToken, requireAuth } = require('../auth');
const router = express.Router();
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many attempts, try again later' },
});
router.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
const { rows } = await pool.query('SELECT * FROM admin_users WHERE username = $1', [username]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
issueToken(res, { sub: user.id, username: user.username });
res.json({ username: user.username });
});
router.post('/logout', (req, res) => {
clearToken(res);
res.json({ ok: true });
});
router.get('/me', requireAuth, (req, res) => {
res.json({ id: req.admin.sub, username: req.admin.username });
});
router.post('/change-password', requireAuth, async (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword || String(newPassword).length < 8) {
return res.status(400).json({ error: 'newPassword must be at least 8 characters' });
}
const { rows } = await pool.query('SELECT * FROM admin_users WHERE id = $1', [req.admin.sub]);
const user = rows[0];
if (!user) return res.status(404).json({ error: 'not found' });
const ok = await bcrypt.compare(currentPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'current password is incorrect' });
const hash = await bcrypt.hash(newPassword, 12);
await pool.query('UPDATE admin_users SET password_hash = $1 WHERE id = $2', [hash, user.id]);
res.json({ ok: true });
});
module.exports = router;
+100
View File
@@ -0,0 +1,100 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { issueCustomerToken, clearCustomerToken, requireCustomerAuth } = require('../customerAuth');
const router = express.Router();
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many attempts, try again later' },
});
router.post('/register', authLimiter, async (req, res) => {
const { name, email, password, company, phone } = req.body || {};
if (!name || !email || !password) {
return res.status(400).json({ error: 'name, email and password are required' });
}
if (String(password).length < 8) {
return res.status(400).json({ error: 'password must be at least 8 characters' });
}
const normalizedEmail = String(email).trim().toLowerCase();
const { rows: existing } = await pool.query('SELECT id FROM customers WHERE email = $1', [normalizedEmail]);
if (existing.length) return res.status(409).json({ error: 'this email is already registered' });
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
'INSERT INTO customers (name, email, password_hash, company, phone) VALUES ($1,$2,$3,$4,$5) RETURNING id, name, email, company, phone',
[
String(name).slice(0, 120),
normalizedEmail,
hash,
company ? String(company).slice(0, 160) : null,
phone ? String(phone).slice(0, 40) : null,
]
);
issueCustomerToken(res, { sub: rows[0].id, email: rows[0].email });
res.status(201).json(rows[0]);
});
router.post('/login', authLimiter, async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
const normalizedEmail = String(email).trim().toLowerCase();
const { rows } = await pool.query('SELECT * FROM customers WHERE email = $1', [normalizedEmail]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
issueCustomerToken(res, { sub: user.id, email: user.email });
res.json({ id: user.id, name: user.name, email: user.email, company: user.company, phone: user.phone });
});
router.post('/logout', (req, res) => {
clearCustomerToken(res);
res.json({ ok: true });
});
router.get('/me', requireCustomerAuth, async (req, res) => {
const { rows } = await pool.query('SELECT id, name, email, company, phone FROM customers WHERE id = $1', [req.customer.sub]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.put('/me', requireCustomerAuth, async (req, res) => {
const { name, company, phone } = req.body || {};
const { rows } = await pool.query(
'UPDATE customers SET name = COALESCE($1, name), company = $2, phone = $3 WHERE id = $4 RETURNING id, name, email, company, phone',
[name, company || null, phone || null, req.customer.sub]
);
res.json(rows[0]);
});
router.post('/change-password', requireCustomerAuth, async (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword || String(newPassword).length < 8) {
return res.status(400).json({ error: 'newPassword must be at least 8 characters' });
}
const { rows } = await pool.query('SELECT * FROM customers WHERE id = $1', [req.customer.sub]);
const user = rows[0];
if (!user) return res.status(404).json({ error: 'not found' });
const ok = await bcrypt.compare(currentPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'current password is incorrect' });
const hash = await bcrypt.hash(newPassword, 12);
await pool.query('UPDATE customers SET password_hash = $1 WHERE id = $2', [hash, user.id]);
res.json({ ok: true });
});
module.exports = router;
+109
View File
@@ -0,0 +1,109 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const router = express.Router();
router.get('/services', async (req, res) => {
const { rows: categories } = await pool.query(
'SELECT id, slug, title, description, icon FROM service_categories ORDER BY sort_order, id'
);
const { rows: items } = await pool.query(
'SELECT id, category_id, name, price_text, period, note FROM service_items WHERE active = true ORDER BY sort_order, id'
);
const grouped = categories
.map((cat) => ({ ...cat, items: items.filter((it) => it.category_id === cat.id) }))
.filter((cat) => cat.items.length > 0);
res.json(grouped);
});
router.get('/promotions', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, title, price_text, old_price_text, description FROM promotions WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
router.get('/reviews', async (req, res) => {
const { rows } = await pool.query(
"SELECT id, name, role, rating, text, created_at FROM reviews WHERE status = 'approved' ORDER BY created_at DESC"
);
res.json(rows);
});
router.get('/cases', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, title, tag, status, tone, description FROM cases WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
router.get('/faqs', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, question, answer FROM faqs WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
router.get('/blog', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, slug, title, excerpt, published_at FROM blog_posts WHERE published = true ORDER BY published_at DESC'
);
res.json(rows);
});
router.get('/blog/:slug', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, slug, title, excerpt, content, published_at FROM blog_posts WHERE slug = $1 AND published = true',
[req.params.slug]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.get('/contact', async (req, res) => {
const { rows } = await pool.query('SELECT address, phone, email, hours, map_embed FROM contact_info WHERE id = 1');
res.json(rows[0] || { address: '', phone: '', email: '', hours: '', map_embed: '' });
});
const reviewLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many reviews submitted, try again later' },
});
router.post('/reviews', reviewLimiter, async (req, res) => {
const { name, role, rating, text, website } = req.body || {};
// honeypot field - real users never fill it in, bots often do
if (website) return res.status(201).json({ ok: true });
if (!name || !text || !rating) {
return res.status(400).json({ error: 'name, rating and text are required' });
}
const ratingNum = Number(rating);
if (!Number.isInteger(ratingNum) || ratingNum < 1 || ratingNum > 5) {
return res.status(400).json({ error: 'rating must be an integer from 1 to 5' });
}
await pool.query(
"INSERT INTO reviews (name, role, rating, text, status) VALUES ($1, $2, $3, $4, 'pending')",
[String(name).slice(0, 120), role ? String(role).slice(0, 160) : null, ratingNum, String(text).slice(0, 2000)]
);
res.status(201).json({ ok: true });
});
router.post('/stats/visit', async (req, res) => {
await pool.query(
`INSERT INTO visits (date, count) VALUES (CURRENT_DATE, 1)
ON CONFLICT (date) DO UPDATE SET count = visits.count + 1`
);
res.status(204).end();
});
module.exports = router;
+17
View File
@@ -0,0 +1,17 @@
// Лёгкая санитизация HTML для контента блога, который создают только
// авторизованные сотрудники через рич-текст редактор в админке. Это не полноценная
// библиотека санитизации - если этот контент когда-нибудь станет доступен для
// публичного ввода (не только сотрудников), нужно будет подключить полноценный
// пакет (например sanitize-html или isomorphic-dompurify).
function sanitizeHtml(html) {
if (!html) return '';
let out = String(html);
out = out.replace(/<\/?(script|iframe|object|embed|link|meta|base|style)[^>]*>/gi, '');
out = out.replace(/\son\w+\s*=\s*"[^"]*"/gi, '');
out = out.replace(/\son\w+\s*=\s*'[^']*'/gi, '');
out = out.replace(/\son\w+\s*=\s*[^\s>]+/gi, '');
out = out.replace(/(href|src)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1="#"');
return out;
}
module.exports = { sanitizeHtml };
+167
View File
@@ -0,0 +1,167 @@
const bcrypt = require('bcryptjs');
const pool = require('./db');
async function ensureAdmin() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM admin_users');
if (rows[0].c > 0) return;
const username = process.env.ADMIN_USERNAME || 'admin';
const password = process.env.ADMIN_PASSWORD;
if (!password) {
console.warn('[seed] ADMIN_PASSWORD is not set in .env - no admin user was created. Set it and restart the backend.');
return;
}
const hash = await bcrypt.hash(password, 12);
await pool.query('INSERT INTO admin_users (username, password_hash) VALUES ($1, $2)', [username, hash]);
console.log(`[seed] created admin user "${username}" - please log in and change the password from the admin panel`);
}
async function ensureContent() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM service_categories');
if (rows[0].c > 0) return;
const categories = [
{ slug: 'ai', title: 'ИИ и автоматизация', description: 'Внедрение ИИ, боты и ассистенты, автоматизация процессов', icon: 'Bot', items: [
['Бот / ИИ-ассистент', 'от 20 000 ₽', '/проект', 'Автоматизация бизнес-процессов'],
['Интеграция с сервисами', 'от 25 000 ₽', '/проект', 'Подключение CRM, мессенджеров, API'],
] },
{ slug: 'support', title: 'IT-поддержка', description: 'Удалённое и выездное сопровождение инфраструктуры', icon: 'Headphones', items: [
['Базовый тариф', 'от 30 000 ₽', '/мес', 'Удалённая поддержка, график 5/2'],
['Премиум тариф', 'от 100 000 ₽', '/мес', 'Полное сопровождение, выезды, 24/7'],
] },
{ slug: 'security', title: 'Кибербезопасность', description: 'Аудит, мониторинг и защита от угроз', icon: 'ShieldCheck', items: [
['Аудит и мониторинг', 'от 50 000 ₽', '/мес', 'Комплексная защита данных и систем'],
['Внедрение IDS/IPS', 'от 30 000 ₽', '/проект', 'Обнаружение и предотвращение вторжений'],
] },
{ slug: 'db', title: 'Администрирование БД', description: 'Настройка, оптимизация, резервное копирование', icon: 'Database', items: [
['Настройка и оптимизация', 'от 3 000 ₽', '/час', 'MySQL, PostgreSQL и другие СУБД'],
['Резервное копирование', 'от 4 000 ₽', '/день', 'Бэкапы и восстановление данных'],
] },
{ slug: 'network', title: 'Сетевая инфраструктура', description: 'Проектирование и поддержка корпоративных сетей', icon: 'Network', items: [
['Настройка сервера', 'от 25 000 ₽', '', 'Подбор и настройка оборудования'],
['Почтовый сервер (ваше оборудование)', 'от 20 000 ₽', '', 'Развёртывание с нуля'],
['Почтовый сервер (наше оборудование)', 'от 30 000 ₽', '/мес', 'Аренда и обслуживание'],
['Проектирование сети', 'от 20 000 ₽', '/проект', 'Монтаж и настройка'],
['Управление оборудованием', 'от 6 000 ₽', '/мес', 'Роутеры и коммутаторы'],
] },
{ slug: 'cloud', title: 'Облачные решения', description: 'Миграция и оптимизация затрат на облако', icon: 'Cloud', items: [
['Миграция в облако', 'от 25 000 ₽', '/проект', 'Перенос и настройка сервисов'],
] },
{ slug: 'development', title: 'Разработка под ключ', description: 'От быстрого MVP до продакшн-разработки и сопровождения', icon: 'Rocket', items: [
['Лендинг', 'от 20 000 ₽', '', 'Простая посадочная страница под продукт или услугу - без формы-бота и админки, 1 неделя'],
['MVP «Быстрый старт»', 'от 120 000 ₽', '', 'Посадочная страница, форма/бот для заявок, простая админка - 2-3 недели'],
['MVP Pro', 'от 450 000 ₽', '', 'Личный кабинет с авторизацией, оплата, ИИ-ассистент - 4-6 недель'],
['Продакшн-разработка', 'от 1 200 000 ₽', '', 'Архитектура, тесты, DevOps, масштабирование - от 2 месяцев, точная смета после дискавери'],
['Discovery-воркшоп', 'от 30 000 ₽', '', 'Фиксация ТЗ и оценки перед стартом проекта - 1-2 дня'],
['Сопровождение проекта', 'от 40 000 ₽', '/мес', 'Ежемесячные доработки уже готовых продуктов'],
] },
{ slug: 'partners', title: 'White-label и партнёрство', description: 'Готовые решения для других компаний и программы сотрудничества', icon: 'Handshake', items: [
['White-label «Кабинет + ИИ-ассистент»', 'от 500 000 ₽', '', 'Личный кабинет с авторизацией и ИИ-чатом под брендом вашей компании'],
['IT-мониторинг сайта', 'от 10 000 ₽', '/мес', 'Аптайм, скорость загрузки, уведомления об инцидентах'],
['Реферальная программа', '10%', 'от суммы контракта', 'Бонус вам за рекомендацию нас новому клиенту'],
['Партнёрская программа', 'по договорённости', '', 'Комиссия для хостинг-провайдеров и поставщиков оборудования за рекомендации'],
] },
{ slug: 'extra', title: 'Дополнительные услуги', description: 'Разовые и почасовые работы вне основных тарифов', icon: 'Server', items: [
['Экстренный выезд специалиста', '10 000 ₽', '', 'В течение 2 часов, по Санкт-Петербургу'],
['Выезд специалиста', '5 000 ₽', '', 'В течение дня, по Санкт-Петербургу'],
['Почасовые работы', 'от 3 000 ₽', '/час', 'Иные работы по IT-инфраструктуре'],
['Удалённая работа', 'от 15 000 ₽', '/мес', 'Аренда мощностей для 1-5 сотрудников'],
] },
];
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
const { rows: catRows } = await pool.query(
'INSERT INTO service_categories (slug, title, description, icon, sort_order) VALUES ($1,$2,$3,$4,$5) RETURNING id',
[cat.slug, cat.title, cat.description, cat.icon, i]
);
const categoryId = catRows[0].id;
for (let j = 0; j < cat.items.length; j++) {
const [name, price, period, note] = cat.items[j];
await pool.query(
'INSERT INTO service_items (category_id, name, price_text, period, note, sort_order) VALUES ($1,$2,$3,$4,$5,$6)',
[categoryId, name, price, period, note, j]
);
}
}
const promotions = [
['Бонус для новых клиентов', '15 000 ₽', null, 'Бонус на любые услуги при заключении договора на IT-аутсорсинг, от 10 рабочих мест.'],
['Год без забот', 'от 20 000 ₽/мес', 'от 30 000 ₽', 'IT-аутсорсинг базовый 5/2 при оплате за 12 месяцев - скидка 30%.'],
['Премиум 24/7', 'от 80 000 ₽/мес', 'от 100 000 ₽', 'Полное сопровождение при договоре от 6 месяцев - скидка 20%.'],
['Почтовый сервер под ключ', '35 000 ₽', 'от 50 000 ₽', 'SPF/DKIM/DMARC, антиспам, веб-почта, 3 месяца сопровождения.'],
['Бот + ИИ-ассистент', '35 000 ₽', 'от 45 000 ₽', 'Telegram-бот с обучением на ваших данных (RAG).'],
];
for (let i = 0; i < promotions.length; i++) {
const [title, price, oldPrice, desc] = promotions[i];
await pool.query(
'INSERT INTO promotions (title, price_text, old_price_text, description, sort_order) VALUES ($1,$2,$3,$4,$5)',
[title, price, oldPrice, desc, i]
);
}
console.log('[seed] inserted starter services and promotions - edit or delete them from the admin panel any time');
}
async function ensureCasesAndFaqs() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM cases');
if (rows[0].c === 0) {
const cases = [
['Миграция инфраструктуры розничной сети', 'Сетевая инфраструктура', 'завершён', 'ok', 'Перенесли серверы 12 магазинов на новое оборудование без остановки продаж, снизили время отклика сети.'],
['ИИ-ассистент для отдела поддержки', 'ИИ и автоматизация', 'завершён', 'ok', 'Внедрили Telegram-бота с RAG на базе базы знаний компании - снизили нагрузку на операторов.'],
['Аудит кибербезопасности финтех-проекта', 'Кибербезопасность', 'в работе', 'warn', 'Проводим комплексный аудит защищённости и внедряем систему мониторинга угроз 24/7.'],
];
for (let i = 0; i < cases.length; i++) {
const [title, tag, status, tone, description] = cases[i];
await pool.query(
'INSERT INTO cases (title, tag, status, tone, description, sort_order) VALUES ($1,$2,$3,$4,$5,$6)',
[title, tag, status, tone, description, i]
);
}
}
const { rows: faqRows } = await pool.query('SELECT COUNT(*)::int AS c FROM faqs');
if (faqRows[0].c === 0) {
const faqs = [
['Как быстро вы реагируете на заявки?', 'Для клиентов на поддержке 24/7 среднее время реакции - около 15 минут. Для разовых заявок мы связываемся в течение рабочего дня.'],
['Работаете ли вы полностью удалённо?', 'Да, большинство работ выполняется удалённо. Выезд специалиста доступен по Санкт-Петербургу.'],
['Что входит в базовый тариф IT-аутсорсинга?', 'Удалённая поддержка инфраструктуры по графику 5/2: мониторинг, устранение инцидентов, консультации.'],
['Как происходит оплата услуг?', 'По разовым работам - по факту выполнения или по предоплате для крупных проектов. По абонентскому обслуживанию - ежемесячно.'],
['Можно ли заказать разовую услугу без абонемента?', 'Да, доступны разовые работы: настройка сервера, аудит безопасности, разработка бота и другие услуги из прайс-листа.'],
['Как обеспечивается конфиденциальность данных?', 'Мы подписываем NDA перед началом работ и используем защищённые каналы доступа к вашей инфраструктуре.'],
];
for (let i = 0; i < faqs.length; i++) {
const [question, answer] = faqs[i];
await pool.query('INSERT INTO faqs (question, answer, sort_order) VALUES ($1,$2,$3)', [question, answer, i]);
}
}
}
async function ensureContactInfo() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM contact_info');
if (rows[0].c > 0) return;
await pool.query(
'INSERT INTO contact_info (id, address, phone, email, hours, map_embed) VALUES (1, $1, $2, $3, $4, $5)',
[
'г. Санкт-Петербург, ул. Вавиловых, д. 10, к1',
'+7 (988) 845-47-65',
'info@top-sysops.ru',
'Пн-Пт: 8:00-18:00',
'https://yandex.ru/map-widget/v1/?um=constructor%3Aefc560cea4e680eb7a1e2826c633bc8b25312218ff4c0e69e571089cbb4372a5&source=constructor',
]
);
}
async function seed() {
await ensureAdmin();
await ensureContent();
await ensureCasesAndFaqs();
await ensureContactInfo();
}
module.exports = seed;
+79
View File
@@ -0,0 +1,79 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
PORT: 4000
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENROUTER_MODEL: ${OPENROUTER_MODEL:-openai/gpt-4o-mini}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-}
# Next.js: публичный сайт + блог, с серверным рендерингом (SEO).
nextjs:
build:
context: ./public-site
args:
NEXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-https://top-sysops.ru}
NEXT_PUBLIC_CHAT_PROVIDER: ${CHAT_PROVIDER:-none}
NEXT_PUBLIC_TOPTICKET_WIDGET_URL: ${TOPTICKET_WIDGET_URL:-}
NEXT_PUBLIC_TOPTICKET_WIDGET_KEY: ${TOPTICKET_WIDGET_KEY:-}
NEXT_PUBLIC_CHATWOOT_BASE_URL: ${CHATWOOT_BASE_URL:-}
NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN: ${CHATWOOT_WEBSITE_TOKEN:-}
restart: unless-stopped
depends_on:
- backend
environment:
# Внутренний адрес бэкенда по docker-сети - используется в Server Components
# (SSR) для прямых запросов, в обход Caddy.
BACKEND_INTERNAL_URL: http://backend:4000
NEXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-https://top-sysops.ru}
# Caddy: единственный сервис с портами наружу. Отдаёт статику admin-app
# (/admin, /account), проксирует /api на backend, всё остальное - на nextjs.
web:
build:
context: ./admin-app
restart: unless-stopped
depends_on:
- backend
- nextjs
environment:
DOMAIN: ${DOMAIN:-:80}
ports:
- "${HTTP_PORT:-80}:80"
# Порт 443 контейнеру не нужен, если TLS терминирует внешний реверс-прокси
# (например, Nginx Proxy Manager) - именно ваш случай. Строка закомментирована;
# раскомментируйте её, только если хотите, чтобы Caddy сам получал
# сертификат Let's Encrypt (тогда нужен реальный DOMAIN без схемы http://).
# - "${HTTPS_PORT:-443}:443"
volumes:
- caddy_data:/data
- caddy_config:/config
volumes:
db_data:
caddy_data:
caddy_config:
+3
View File
@@ -0,0 +1,3 @@
node_modules
.next
.env
+41
View File
@@ -0,0 +1,41 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
# NEXT_PUBLIC_* переменные встраиваются в клиентский бандл на этапе сборки.
ARG NEXT_PUBLIC_SITE_URL=https://top-sysops.ru
ARG NEXT_PUBLIC_CHAT_PROVIDER=none
ARG NEXT_PUBLIC_TOPTICKET_WIDGET_URL=
ARG NEXT_PUBLIC_TOPTICKET_WIDGET_KEY=
ARG NEXT_PUBLIC_CHATWOOT_BASE_URL=
ARG NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN=
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \
NEXT_PUBLIC_CHAT_PROVIDER=$NEXT_PUBLIC_CHAT_PROVIDER \
NEXT_PUBLIC_TOPTICKET_WIDGET_URL=$NEXT_PUBLIC_TOPTICKET_WIDGET_URL \
NEXT_PUBLIC_TOPTICKET_WIDGET_KEY=$NEXT_PUBLIC_TOPTICKET_WIDGET_KEY \
NEXT_PUBLIC_CHATWOOT_BASE_URL=$NEXT_PUBLIC_CHATWOOT_BASE_URL \
NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN=$NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN
# BACKEND_INTERNAL_URL нужен и на этапе сборки (генерация sitemap исполняется
# как серверный код) - но реальные данные для sitemap читаются уже в рантайме,
# на сборку это не влияет, если бэкенд на этот момент недоступен.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+22
View File
@@ -0,0 +1,22 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Promotions from '../../components/sections/Promotions.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Акции на IT-аутсорсинг, техподдержку и почтовые серверы',
description:
'Актуальные акции и специальные предложения TopSysOps: скидки на IT-аутсорсинг при годовом договоре, почтовый сервер под ключ, бот с ИИ-ассистентом.',
};
export default async function PromotionsPage() {
const promotions = await serverApi.getPromotions();
return (
<div>
<Header />
<Promotions promotions={promotions} showMailNote />
<Footer />
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import Header from '../../../components/Header.jsx';
import Footer from '../../../components/Footer.jsx';
import Reveal from '../../../components/Reveal.jsx';
import { serverApi } from '../../../lib/serverApi.js';
function formatDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
export async function generateMetadata({ params }) {
const post = await serverApi.getBlogPost(params.slug);
if (!post) return { title: 'Статья не найдена' };
return {
title: post.title,
description: post.excerpt || undefined,
};
}
export default async function BlogPostPage({ params }) {
const post = await serverApi.getBlogPost(params.slug);
if (!post) notFound();
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
...(post.excerpt ? { description: post.excerpt } : {}),
...(post.published_at ? { datePublished: post.published_at } : {}),
author: { '@type': 'Organization', name: 'TopSysOps' },
};
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
<Header />
<article className="max-w-2xl mx-auto px-5 sm:px-8 py-20 min-h-[60vh]">
<Link href="/blog" className="text-sm text-emerald-500 font-medium inline-flex items-center gap-1 mb-8">
<ArrowLeft className="h-3.5 w-3.5" /> Ко всем статьям
</Link>
<Reveal>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2 font-mono">{formatDate(post.published_at)}</p>
<h1 className="text-3xl font-bold mb-6 font-display">{post.title}</h1>
<div
className="prose prose-slate dark:prose-invert max-w-none prose-a:text-emerald-500"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: post.content || '' }}
/>
</Reveal>
</article>
<Footer />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import SectionHeading from '../../components/SectionHeading.jsx';
import Reveal from '../../components/Reveal.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Блог',
description: 'Заметки об IT-инфраструктуре, кибербезопасности и автоматизации от TopSysOps.',
};
function formatDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
export default async function BlogListPage() {
const posts = await serverApi.getBlogPosts();
return (
<div>
<Header />
<section className="max-w-3xl mx-auto px-5 sm:px-8 py-20 min-h-[60vh]">
<SectionHeading eyebrow="blog --articles" title="Блог" desc="Заметки об IT-инфраструктуре, кибербезопасности и автоматизации." />
{posts.length === 0 && <p className="text-sm text-slate-400">Пока нет опубликованных статей - загляните позже.</p>}
<div className="space-y-4">
{posts.map((p, i) => (
<Reveal key={p.id} delay={i * 60}>
<Link
href={`/blog/${p.slug}`}
className="block rounded-lg border p-5 transition-colors hover:border-emerald-500 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800"
>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1 font-mono">{formatDate(p.published_at)}</p>
<h2 className="font-bold mb-2 font-display text-lg">{p.title}</h2>
{p.excerpt && <p className="text-sm text-slate-600 dark:text-slate-400 mb-2">{p.excerpt}</p>}
<span className="text-sm text-emerald-500 font-medium inline-flex items-center gap-1">
Читать <ArrowRight className="h-3.5 w-3.5" />
</span>
</Link>
</Reveal>
))}
</div>
</section>
<Footer />
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Faq from '../../components/sections/Faq.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Частые вопросы об IT-аутсорсинге и услугах',
description: 'Ответы на частые вопросы об IT-аутсорсинге, техподдержке, оплате услуг и конфиденциальности данных TopSysOps.',
};
export default async function FaqPage() {
const faqs = await serverApi.getFaqs();
const faqSchema =
faqs.length > 0
? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((f) => ({
'@type': 'Question',
name: f.question,
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
}
: null;
return (
<div>
{faqSchema && (
// eslint-disable-next-line react/no-danger
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }} />
)}
<Header />
<Faq faqs={faqs} />
<Footer />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700;900&family=Manrope:wght@400;500;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
body {
font-family: 'Manrope', sans-serif;
overflow-x: hidden;
max-width: 100vw;
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a"/>
<path d="M11 9 L20 16 L11 23" fill="none" stroke="#10b981" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="21" y="20.4" width="6" height="3.2" rx="1" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

+22
View File
@@ -0,0 +1,22 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Cases from '../../components/sections/Cases.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Кейсы и портфолио — сетевая инфраструктура, ИИ, кибербезопасность',
description:
'Реализованные проекты TopSysOps: миграция сетевой инфраструктуры, внедрение ИИ-ассистента, аудит кибербезопасности финтех-проекта.',
};
export default async function CasesPage() {
const cases = await serverApi.getCases();
return (
<div>
<Header />
<Cases cases={cases} showDisclaimer />
<Footer />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Contact from '../../components/sections/Contact.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Контакты',
description: 'Контакты TopSysOps: адрес офиса в Санкт-Петербурге, телефон, email и режим работы. Работаем с клиентами по всей России.',
};
export default async function ContactPage() {
const contact = await serverApi.getContact();
return (
<div>
<Header />
<Contact contact={contact} />
<Footer />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import './globals.css';
import { ThemeProvider } from '../components/ThemeProvider.jsx';
import SupportChat from '../components/SupportChat.jsx';
import VisitTracker from '../components/VisitTracker.jsx';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru';
export const metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: 'TopSysOps — IT-аутсорсинг и автоматизация',
template: '%s | TopSysOps',
},
description:
'Профессиональный IT-аутсорсинг: техподдержка, кибербезопасность, администрирование БД, сетевая инфраструктура, ИИ и автоматизация.',
};
// Выставляет класс "dark" на <html> ДО гидратации React, чтобы не было
// мигания светлой темы при заходе пользователя с сохранённой тёмной темой.
const NO_FLASH_SCRIPT = `
(function () {
try {
var theme = localStorage.getItem('ts-theme');
var dark = theme ? theme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
`;
export default function RootLayout({ children }) {
return (
<html lang="ru">
<head>
{/* eslint-disable-next-line react/no-danger */}
<script dangerouslySetInnerHTML={{ __html: NO_FLASH_SCRIPT }} />
</head>
<body className="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
<ThemeProvider>{children}</ThemeProvider>
<SupportChat />
<VisitTracker />
</body>
</html>
);
}
+141
View File
@@ -0,0 +1,141 @@
import Link from 'next/link';
import { ArrowRight, Users, CalendarDays, MapPin, ShieldCheck, Clock, Bot } from 'lucide-react';
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Reveal from '../../components/Reveal.jsx';
import SectionHeading from '../../components/SectionHeading.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'О компании',
description:
'TopSysOps — IT-аутсорсинг и техподдержка с 2023 года. Команда 20+ специалистов, штаб-квартира в Санкт-Петербурге, работаем с клиентами по всей России.',
};
const FACTS = [
{ icon: CalendarDays, label: 'На рынке с', value: '2023 года' },
{ icon: Users, label: 'В команде', value: '20+ специалистов' },
{ icon: MapPin, label: 'Штаб-квартира', value: 'Санкт-Петербург' },
];
const DIFFERENTIATORS = [
{
icon: Clock,
title: 'Быстрый отклик',
text: 'На тарифе «Премиум» среднее время реакции на заявку — около 15 минут, поддержка доступна 24/7.',
},
{
icon: ShieldCheck,
title: 'Конфиденциальность',
text: 'Перед началом работ подписываем NDA и используем защищённые каналы доступа к инфраструктуре клиента.',
},
{
icon: Bot,
title: 'Личный кабинет с ИИ',
text: 'Клиенты получают личный кабинет с перепиской с командой и ИИ-ассистентом, отвечающим на вопросы 24/7.',
},
{
icon: MapPin,
title: 'Вся Россия удалённо',
text: 'Большинство работ выполняем удалённо для клиентов по всей стране, выезд специалиста — по Санкт-Петербургу.',
},
];
export default async function AboutPage() {
const contact = await serverApi.getContact();
const orgSchema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'TopSysOps',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru',
foundingDate: '2023',
numberOfEmployees: { '@type': 'QuantitativeValue', minValue: 20 },
...(contact?.address ? { address: { '@type': 'PostalAddress', streetAddress: contact.address, addressCountry: 'RU' } } : {}),
};
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
<Header />
<section className="max-w-4xl mx-auto px-5 sm:px-8 pt-16 pb-10">
<Reveal>
<p className="text-xs uppercase tracking-widest text-emerald-500 mb-4 font-mono">&gt; top-sysops.ru --about-company</p>
<h1 className="text-2xl sm:text-3xl font-bold mb-6 font-display">
TopSysOps — IT-аутсорсинг и техподдержка для бизнеса
</h1>
<p className="text-base text-slate-600 dark:text-slate-400 max-w-2xl">
Мы занимаемся IT-аутсорсингом, техподдержкой инфраструктуры, кибербезопасностью, администрированием
серверов и баз данных, а также автоматизацией процессов с помощью ИИ. Наша цель — чтобы за IT-инфраструктуру
бизнеса отвечала команда, а не один перегруженный сисадмин.
</p>
</Reveal>
</section>
<section className="max-w-4xl mx-auto px-5 sm:px-8 pb-16">
<div className="grid sm:grid-cols-3 gap-5">
{FACTS.map((f, i) => {
const Icon = f.icon;
return (
<Reveal key={f.label} delay={i * 60}>
<div className="h-full rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<Icon className="h-5 w-5 text-emerald-500 mb-3" />
<div className="text-xs text-slate-400 dark:text-slate-500 mb-1">{f.label}</div>
<div className="font-bold font-display">{f.value}</div>
</div>
</Reveal>
);
})}
</div>
</section>
<section className="max-w-4xl mx-auto px-5 sm:px-8 pb-20">
<SectionHeading eyebrow="why --us" title="Почему выбирают нас" />
<div className="grid sm:grid-cols-2 gap-5">
{DIFFERENTIATORS.map((d, i) => {
const Icon = d.icon;
return (
<Reveal key={d.title} delay={i * 60}>
<div className="h-full rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<Icon className="h-5 w-5 text-emerald-500 mb-3" />
<h3 className="font-bold mb-1 font-display text-[15px]">{d.title}</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">{d.text}</p>
</div>
</Reveal>
);
})}
</div>
</section>
<section className="max-w-4xl mx-auto px-5 sm:px-8 pb-20">
<Reveal>
<div className="rounded-lg border p-6 sm:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-6 justify-between bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900">
<div>
<h3 className="font-bold text-lg mb-2 font-display">Готовы обсудить вашу задачу?</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
Посмотрите услуги и цены или сразу оставьте заявку — свяжемся в течение рабочего дня.
</p>
</div>
<div className="flex gap-3 flex-shrink-0">
<Link
href="/uslugi"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md text-sm font-medium border border-slate-300 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-900 transition-colors whitespace-nowrap"
>
Услуги
</Link>
<Link
href="/kontakty"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors whitespace-nowrap"
>
Оставить заявку <ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</Reveal>
</section>
<Footer />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Reviews from '../../components/sections/Reviews.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Отзывы клиентов TopSysOps',
description: 'Отзывы клиентов об IT-аутсорсинге, техподдержке и других услугах TopSysOps. Оставьте свой отзыв.',
};
export default async function ReviewsPage() {
const reviews = await serverApi.getReviews();
return (
<div>
<Header />
<Reviews reviews={reviews} />
<Footer />
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import Header from '../components/Header.jsx';
import Footer from '../components/Footer.jsx';
import Hero from '../components/sections/Hero.jsx';
import AccountPromo from '../components/sections/AccountPromo.jsx';
import Promotions from '../components/sections/Promotions.jsx';
import Services from '../components/sections/Services.jsx';
import Cases from '../components/sections/Cases.jsx';
import Reviews from '../components/sections/Reviews.jsx';
import Contact from '../components/sections/Contact.jsx';
import Reveal from '../components/Reveal.jsx';
import SectionHeading from '../components/SectionHeading.jsx';
import { serverApi } from '../lib/serverApi.js';
export default async function HomePage() {
const [services, promotions, reviews, cases, contact] = await Promise.all([
serverApi.getServices(),
serverApi.getPromotions(),
serverApi.getReviews(),
serverApi.getCases(),
serverApi.getContact(),
]);
const business = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'TopSysOps',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru',
...(contact?.address ? { address: { '@type': 'PostalAddress', streetAddress: contact.address, addressCountry: 'RU' } } : {}),
...(contact?.phone ? { telephone: contact.phone } : {}),
...(contact?.email ? { email: contact.email } : {}),
...(reviews.length > 0
? {
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: (reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length).toFixed(1),
reviewCount: reviews.length,
},
}
: {}),
...(services.length > 0
? {
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'Услуги TopSysOps',
itemListElement: services.flatMap((cat) =>
cat.items.map((it) => ({
'@type': 'Offer',
itemOffered: { '@type': 'Service', name: it.name, description: it.note || cat.description },
}))
),
},
}
: {}),
};
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(business) }} />
<Header />
<Hero />
<AccountPromo />
<Services
categories={services}
compact
limit={6}
moreHref="/uslugi"
eyebrow="services --overview"
title="Ключевые услуги"
desc="IT-аутсорсинг, кибербезопасность, администрирование серверов и баз данных, сетевая инфраструктура, облака и автоматизация с ИИ."
/>
<Promotions promotions={promotions} limit={3} moreHref="/aktsii" />
<section className="max-w-6xl mx-auto px-5 sm:px-8 py-20">
<SectionHeading
eyebrow="about --company"
title="О компании"
desc="TopSysOps — IT-аутсорсинг и техподдержка с 2023 года. Команда 20+ специалистов, штаб-квартира в Санкт-Петербурге, работаем с клиентами по всей России."
/>
<Reveal>
<Link href="/o-kompanii" className="inline-flex items-center gap-2 text-sm text-emerald-500 font-medium hover:underline">
Узнать больше о компании <ArrowRight className="h-3.5 w-3.5" />
</Link>
</Reveal>
</section>
<Cases cases={cases} limit={3} moreHref="/keysy" />
<Reviews reviews={reviews} limit={2} moreHref="/otzyvy" showForm={false} />
<Contact contact={contact} compact />
<Footer />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru';
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/admin', '/account'],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
+30
View File
@@ -0,0 +1,30 @@
import { serverApi } from '../lib/serverApi.js';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru';
export default async function sitemap() {
const [posts, services] = await Promise.all([serverApi.getBlogPosts(), serverApi.getServices()]);
return [
{ url: `${SITE_URL}/`, changeFrequency: 'weekly', priority: 1.0 },
{ url: `${SITE_URL}/uslugi`, changeFrequency: 'weekly', priority: 0.9 },
...services.map((cat) => ({
url: `${SITE_URL}/uslugi/${cat.slug}`,
changeFrequency: 'monthly',
priority: 0.8,
})),
{ url: `${SITE_URL}/aktsii`, changeFrequency: 'weekly', priority: 0.7 },
{ url: `${SITE_URL}/keysy`, changeFrequency: 'monthly', priority: 0.6 },
{ url: `${SITE_URL}/o-kompanii`, changeFrequency: 'monthly', priority: 0.6 },
{ url: `${SITE_URL}/otzyvy`, changeFrequency: 'weekly', priority: 0.5 },
{ url: `${SITE_URL}/faq`, changeFrequency: 'monthly', priority: 0.5 },
{ url: `${SITE_URL}/kontakty`, changeFrequency: 'monthly', priority: 0.6 },
{ url: `${SITE_URL}/blog`, changeFrequency: 'weekly', priority: 0.7 },
...posts.map((p) => ({
url: `${SITE_URL}/blog/${p.slug}`,
lastModified: p.published_at ? new Date(p.published_at) : undefined,
changeFrequency: 'monthly',
priority: 0.6,
})),
];
}
+91
View File
@@ -0,0 +1,91 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, ArrowRight, CheckCircle2 } from 'lucide-react';
import Header from '../../../components/Header.jsx';
import Footer from '../../../components/Footer.jsx';
import Reveal from '../../../components/Reveal.jsx';
import { ServicePriceList } from '../../../components/sections/Services.jsx';
import { getIcon } from '../../../lib/icons.js';
import { getServiceLandingCopy } from '../../../lib/serviceLandingCopy.js';
import { serverApi } from '../../../lib/serverApi.js';
async function findCategory(slug) {
const services = await serverApi.getServices();
return services.find((c) => c.slug === slug) || null;
}
export async function generateMetadata({ params }) {
const category = await findCategory(params.slug);
if (!category) return { title: 'Услуга не найдена' };
const copy = getServiceLandingCopy(category);
return { title: copy.metaTitle, description: copy.metaDescription };
}
export default async function ServiceLandingPage({ params }) {
const category = await findCategory(params.slug);
if (!category) notFound();
const copy = getServiceLandingCopy(category);
const Icon = getIcon(category.icon);
const serviceSchema = {
'@context': 'https://schema.org',
'@type': 'Service',
name: copy.h1,
description: copy.metaDescription,
provider: { '@type': 'Organization', name: 'TopSysOps' },
areaServed: 'RU',
};
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(serviceSchema) }} />
<Header />
<section className="max-w-4xl mx-auto px-5 sm:px-8 pt-16 pb-4">
<Link href="/uslugi" className="text-sm text-emerald-500 font-medium inline-flex items-center gap-1 mb-8">
<ArrowLeft className="h-3.5 w-3.5" /> Все услуги
</Link>
<Reveal>
<Icon className="h-8 w-8 text-emerald-500 mb-4" />
<h1 className="text-2xl sm:text-3xl font-bold mb-4 font-display">{copy.h1}</h1>
<p className="text-base text-slate-600 dark:text-slate-400 max-w-2xl mb-6">{copy.intro}</p>
</Reveal>
{copy.bullets.length > 0 && (
<Reveal delay={80}>
<ul className="space-y-2 mb-10">
{copy.bullets.map((b) => (
<li key={b} className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5 flex-shrink-0" />
{b}
</li>
))}
</ul>
</Reveal>
)}
</section>
<section className="max-w-4xl mx-auto px-5 sm:px-8 pb-20">
<Reveal delay={120}>
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h2 className="font-bold mb-1 font-display text-[15px]">{category.title}: цены</h2>
<p className="text-xs mb-4 text-slate-500 dark:text-slate-400">{category.description}</p>
<ServicePriceList category={category} />
</div>
</Reveal>
<Reveal delay={160} className="mt-8">
<Link
href="/kontakty"
className="inline-flex items-center gap-2 px-5 py-3 rounded-md bg-emerald-500 text-white font-medium text-sm hover:bg-emerald-600 transition-colors"
>
Обсудить задачу <ArrowRight className="h-4 w-4" />
</Link>
</Reveal>
</section>
<Footer />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Services from '../../components/sections/Services.jsx';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
title: 'Услуги IT-аутсорсинга и техподдержки — прайс-лист',
description:
'IT-аутсорсинг, техподдержка, кибербезопасность, администрирование серверов и баз данных, сетевая инфраструктура, облачные решения, ИИ и автоматизация. Актуальные цены.',
};
export default async function ServicesPage() {
const services = await serverApi.getServices();
return (
<div>
<Header />
<Services categories={services} />
<Footer />
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from 'next/link';
const SECONDARY_LINKS = [
{ href: '/uslugi', label: 'Услуги' },
{ href: '/aktsii', label: 'Акции' },
{ href: '/keysy', label: 'Кейсы' },
{ href: '/o-kompanii', label: 'О компании' },
{ href: '/faq', label: 'FAQ' },
{ href: '/otzyvy', label: 'Отзывы' },
{ href: '/kontakty', label: 'Контакты' },
{ href: '/blog', label: 'Блог' },
];
export default function Footer() {
return (
<footer className="border-t py-8 border-slate-200 text-slate-500 dark:border-slate-900 dark:text-slate-500">
<div className="max-w-6xl mx-auto px-5 sm:px-8">
<nav className="flex flex-wrap gap-x-5 gap-y-2 text-xs mb-6">
{SECONDARY_LINKS.map((item) => (
<Link key={item.href} href={item.href} className="hover:text-emerald-500 transition-colors">
{item.label}
</Link>
))}
</nav>
<div className="text-xs flex flex-col sm:flex-row gap-2 sm:gap-0 justify-between">
<span>© {new Date().getFullYear()} TopSysOps. Все права защищены.</span>
<span className="font-mono">top-sysops.ru</span>
</div>
</div>
</footer>
);
}
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Sun, Moon, Menu, X, Terminal } from 'lucide-react';
import { useTheme } from './ThemeProvider.jsx';
import StatusDot from './StatusDot.jsx';
const NAV_ITEMS = [
{ href: '/', label: 'Главная' },
{ href: '/uslugi', label: 'Услуги' },
{ href: '/aktsii', label: 'Акции' },
{ href: '/keysy', label: 'Кейсы' },
{ href: '/o-kompanii', label: 'О компании' },
{ href: '/blog', label: 'Блог' },
{ href: '/kontakty', label: 'Контакты' },
];
export default function Header() {
const { dark, toggle } = useTheme();
const [open, setOpen] = useState(false);
return (
<header className="sticky top-0 z-40 backdrop-blur border-b bg-slate-50/85 border-slate-200 dark:bg-slate-950/85 dark:border-slate-800">
<div className="max-w-6xl mx-auto px-5 sm:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="flex items-center gap-2 font-display">
<Terminal className="h-5 w-5 text-emerald-500" />
<span className="text-base font-bold tracking-tight">TopSysOps</span>
</Link>
<nav className="hidden md:flex items-center gap-1">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="px-3 py-2 rounded-md text-sm font-medium transition-colors text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-900"
>
{item.label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<button
aria-label={dark ? 'Включить светлую тему' : 'Включить тёмную тему'}
onClick={toggle}
className="p-2 rounded-md transition-colors hover:bg-slate-100 text-slate-700 dark:hover:bg-slate-900 dark:text-amber-400"
>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
aria-label="Открыть меню"
onClick={() => setOpen(!open)}
className="md:hidden p-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-900"
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
{open && (
<nav className="md:hidden pb-4 flex flex-col gap-1">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className="text-left px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-900"
>
{item.label}
</Link>
))}
</nav>
)}
</div>
<div className="border-t px-5 sm:px-8 py-1.5 text-xs flex items-center gap-4 max-w-6xl mx-auto border-slate-200 text-slate-500 dark:border-slate-900 dark:text-slate-400">
<StatusDot tone="ok" pulse label="система активна" mono />
<span className="hidden sm:inline font-mono">24/7</span>
<span className="hidden sm:inline font-mono">отклик ~15 мин</span>
<a href="/admin" className="ml-auto underline decoration-dotted underline-offset-2 hover:text-emerald-500">
вход для сотрудников
</a>
</div>
</header>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useEffect, useRef, useState } from 'react';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function Reveal({ children, className, delay }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return undefined;
let reduced = false;
try {
reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (e) {
// matchMedia unavailable - fall back to animating
}
if (reduced) {
setVisible(true);
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
});
},
{ threshold: 0.15 }
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
className={cx(
className,
'transition-all duration-700 ease-out',
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
)}
>
{children}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import Reveal from './Reveal.jsx';
export default function SectionHeading({ eyebrow, title, desc }) {
return (
<Reveal className="mb-10 max-w-2xl">
<p className="text-xs uppercase tracking-widest text-emerald-500 mb-3 font-mono">&gt; {eyebrow}</p>
<h2 className="text-2xl sm:text-3xl font-bold mb-3 font-display">{title}</h2>
{desc && <p className="text-sm sm:text-base text-slate-600 dark:text-slate-400">{desc}</p>}
</Reveal>
);
}
+23
View File
@@ -0,0 +1,23 @@
function cx(...args) {
return args.filter(Boolean).join(' ');
}
const COLORS = {
ok: 'bg-emerald-500',
warn: 'bg-amber-500',
danger: 'bg-rose-500',
neutral: 'bg-slate-400',
};
export default function StatusDot({ tone = 'neutral', pulse, label, mono }) {
const dot = COLORS[tone] || COLORS.neutral;
return (
<span className="inline-flex items-center gap-2">
<span className="relative flex h-2 w-2">
{pulse && <span className={cx('animate-ping absolute inline-flex h-full w-full rounded-full opacity-60', dot)} />}
<span className={cx('relative inline-flex rounded-full h-2 w-2', dot)} />
</span>
{label && <span className={cx('text-xs', mono && 'font-mono')}>{label}</span>}
</span>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { useEffect } from 'react';
// Провайдер и адреса задаются на этапе сборки через переменные NEXT_PUBLIC_*
// (см. корневой .env и docker-compose.yml -> nextjs -> build.args). Смена
// провайдера требует пересборки: docker compose up -d --build nextjs.
const PROVIDER = process.env.NEXT_PUBLIC_CHAT_PROVIDER || 'none';
function loadTopTicket() {
// TopTicket - собственная разработка компании. Embed-сниппет вида:
// <script src="https://help.top-sysops.ru/widget.js" data-key="..." async></script>
const widgetUrl = process.env.NEXT_PUBLIC_TOPTICKET_WIDGET_URL;
const widgetKey = process.env.NEXT_PUBLIC_TOPTICKET_WIDGET_KEY;
if (!widgetUrl || !widgetKey) return undefined;
const script = document.createElement('script');
script.src = widgetUrl;
script.setAttribute('data-key', widgetKey);
script.async = true;
document.body.appendChild(script);
return () => script.remove();
}
function loadChatwoot() {
const baseUrl = process.env.NEXT_PUBLIC_CHATWOOT_BASE_URL;
const token = process.env.NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN;
if (!baseUrl || !token) return undefined;
const script = document.createElement('script');
script.src = `${baseUrl.replace(/\/$/, '')}/packs/js/sdk.js`;
script.defer = true;
script.async = true;
script.onload = () => {
if (window.chatwootSDK) {
window.chatwootSDK.run({ websiteToken: token, baseUrl });
}
};
document.body.appendChild(script);
return () => script.remove();
}
export default function SupportChat() {
useEffect(() => {
if (PROVIDER === 'topticket') return loadTopTicket();
if (PROVIDER === 'chatwoot') return loadChatwoot();
return undefined;
}, []);
return null;
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ dark: false, toggle: () => {} });
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }) {
// Инлайн-скрипт в layout.jsx уже выставляет класс "dark" на <html> ДО гидратации
// (чтобы не было мигания темы). Читаем этот класс напрямую при монтировании.
const [dark, setDark] = useState(() => {
if (typeof document === 'undefined') return false;
return document.documentElement.classList.contains('dark');
});
// Побочные эффекты (DOM-класс, localStorage) вынесены в отдельный useEffect,
// а не в апдейтер состояния - React может вызвать функцию апдейтера больше
// одного раза за один рендер, и тогда classList.toggle сработал бы дважды,
// фактически отменяя переключение (баг "не срабатывает" / "снова светлая").
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
try {
localStorage.setItem('ts-theme', dark ? 'dark' : 'light');
} catch (e) {
// localStorage unavailable - theme just won't persist
}
}, [dark]);
return (
<ThemeContext.Provider value={{ dark, toggle: () => setDark((d) => !d) }}>
{children}
</ThemeContext.Provider>
);
}
+11
View File
@@ -0,0 +1,11 @@
'use client';
import { useEffect } from 'react';
import { clientApi } from '../lib/clientApi.js';
export default function VisitTracker() {
useEffect(() => {
clientApi.trackVisit();
}, []);
return null;
}
@@ -0,0 +1,45 @@
import { Bot, MessageCircle, Sparkles } from 'lucide-react';
import Reveal from '../Reveal.jsx';
export default function AccountPromo() {
return (
<section className="max-w-6xl mx-auto px-5 sm:px-8 pb-16">
<Reveal>
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-900 p-6 sm:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-6 justify-between">
<div>
<p className="text-xs uppercase tracking-widest text-emerald-600 dark:text-emerald-400 mb-2 font-mono">
&gt; account --perks
</p>
<h3 className="font-bold text-lg mb-2 font-display">Личный кабинет для клиентов</h3>
<ul className="text-sm text-slate-600 dark:text-slate-400 space-y-1">
<li className="flex items-center gap-2">
<Bot className="h-4 w-4 text-emerald-500 flex-shrink-0" /> ИИ-ассистент отвечает на вопросы 24/7
</li>
<li className="flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-emerald-500 flex-shrink-0" /> Личная переписка с нашей командой
</li>
<li className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-emerald-500 flex-shrink-0" /> Акции и предложения специально для вас
</li>
</ul>
</div>
<div className="flex gap-3 flex-shrink-0">
{/* /account живёт в отдельном приложении (Vite), поэтому обычная ссылка, а не next/link */}
<a
href="/account/register"
className="px-4 py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors whitespace-nowrap"
>
Зарегистрироваться
</a>
<a
href="/account"
className="px-4 py-2.5 rounded-md text-sm font-medium border border-slate-300 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-900 transition-colors whitespace-nowrap"
>
Войти
</a>
</div>
</div>
</Reveal>
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Link from 'next/link';
import Reveal from '../Reveal.jsx';
import SectionHeading from '../SectionHeading.jsx';
import StatusDot from '../StatusDot.jsx';
export default function Cases({ cases, limit, moreHref, showDisclaimer = false }) {
if (cases.length === 0) return null;
const list = limit ? cases.slice(0, limit) : cases;
return (
<section id="cases" className="max-w-6xl mx-auto px-5 sm:px-8 py-20">
<SectionHeading eyebrow="cases --log" title="Кейсы и портфолио" />
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{list.map((c, i) => (
<Reveal key={c.id} delay={i * 60}>
<div className="h-full rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center justify-between mb-3">
{c.tag && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{c.tag}
</span>
)}
<StatusDot tone={c.tone} label={c.status} mono />
</div>
<h3 className="font-bold mb-2 font-display text-[15px]">{c.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{c.description}</p>
</div>
</Reveal>
))}
</div>
{moreHref && (
<Reveal className="mt-8">
<Link href={moreHref} className="text-sm text-emerald-500 font-medium hover:underline">
Все кейсы →
</Link>
</Reveal>
)}
{showDisclaimer && (
<Reveal className="mt-8">
<p className="text-xs text-slate-400 dark:text-slate-500">
* Показатели по срокам, объёмам и улучшениям — ориентировочные оценки по внутренним данным, точные цифры
уточняются и согласовываются с клиентом по каждому проекту.
</p>
</Reveal>
)}
</section>
);
}
@@ -0,0 +1,73 @@
import Link from 'next/link';
import { MapPin, Phone, Mail, Clock, ArrowRight } from 'lucide-react';
import Reveal from '../Reveal.jsx';
import SectionHeading from '../SectionHeading.jsx';
export default function Contact({ contact, compact = false }) {
const rows = contact
? [
{ icon: MapPin, label: 'Адрес', value: contact.address },
{ icon: Phone, label: 'Телефон', value: contact.phone },
{ icon: Mail, label: 'Email', value: contact.email },
{ icon: Clock, label: 'Режим работы', value: contact.hours },
].filter((r) => r.value)
: [];
if (compact) {
return (
<section id="contact" className="max-w-6xl mx-auto px-5 sm:px-8 py-16">
<Reveal>
<div className="rounded-lg border p-6 sm:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-6 justify-between bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div>
<h3 className="font-bold text-lg mb-2 font-display">Оставить заявку</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{contact?.phone && `${contact.phone} · `}
{contact?.email}
</p>
</div>
<Link
href="/kontakty"
className="inline-flex items-center gap-2 px-5 py-3 rounded-md bg-emerald-500 text-white font-medium text-sm hover:bg-emerald-600 transition-colors whitespace-nowrap"
>
Все контакты <ArrowRight className="h-4 w-4" />
</Link>
</div>
</Reveal>
</section>
);
}
return (
<section id="contact" className="max-w-6xl mx-auto px-5 sm:px-8 py-20">
<SectionHeading eyebrow="contact --reach-us" title="Контакты" />
<div className="grid md:grid-cols-2 gap-5">
<Reveal>
<div className="rounded-lg border p-5 h-full bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="space-y-5">
{rows.map((r) => {
const Icon = r.icon;
return (
<div key={r.label} className="flex items-start gap-3">
<Icon className="h-4 w-4 text-emerald-500 mt-0.5" />
<div>
<div className="text-xs text-slate-400 dark:text-slate-500">{r.label}</div>
<div className="text-sm">{r.value}</div>
</div>
</div>
);
})}
{rows.length === 0 && <p className="text-sm text-slate-400">Контакты пока не заполнены.</p>}
</div>
</div>
</Reveal>
{contact?.map_embed && (
<Reveal delay={100}>
<div className="rounded-lg border overflow-hidden h-64 md:h-full bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<iframe title="Карта офиса TopSysOps" className="w-full h-full" src={contact.map_embed} />
</div>
</Reveal>
)}
</div>
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { ChevronDown } from 'lucide-react';
import Reveal from '../Reveal.jsx';
import SectionHeading from '../SectionHeading.jsx';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
function FaqItem({ item }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-slate-200 dark:border-slate-800">
<button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between gap-4 py-4 text-left">
<span className="text-sm sm:text-base font-medium">{item.question}</span>
<ChevronDown className={cx('h-4 w-4 flex-shrink-0 transition-transform', open && 'rotate-180')} />
</button>
{open && <p className="pb-4 text-sm text-slate-600 dark:text-slate-400">{item.answer}</p>}
</div>
);
}
export default function Faq({ faqs, limit, moreHref }) {
if (faqs.length === 0) return null;
const list = limit ? faqs.slice(0, limit) : faqs;
return (
<section id="faq" className="max-w-3xl mx-auto px-5 sm:px-8 py-20">
<SectionHeading eyebrow="faq --common" title="Частые вопросы" />
<Reveal>
<div>
{list.map((item) => (
<FaqItem key={item.id} item={item} />
))}
</div>
</Reveal>
{moreHref && (
<Reveal className="mt-6">
<Link href={moreHref} className="text-sm text-emerald-500 font-medium hover:underline">
Все вопросы →
</Link>
</Reveal>
)}
</section>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
const LINES = ['$ status --infra', 'uptime: 99.98%', 'incidents_resolved: 1240+', 'avg_response_time: 15m'];
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function Hero() {
const [shown, setShown] = useState(0);
useEffect(() => {
if (shown >= LINES.length) return undefined;
const timer = setTimeout(() => setShown((s) => s + 1), 450);
return () => clearTimeout(timer);
}, [shown]);
return (
<section id="home" className="max-w-6xl mx-auto px-5 sm:px-8 pt-16 pb-20 grid md:grid-cols-2 gap-12 items-center">
<div>
<p className="text-xs uppercase tracking-widest text-emerald-500 mb-4 font-mono">
&gt; top-sysops.ru --about
</p>
<h1
className="text-[clamp(1.5rem,7.5vw,3rem)] md:text-3xl lg:text-4xl leading-tight font-bold mb-6 font-display text-slate-900 dark:text-slate-100 break-words"
style={{ hyphens: 'auto' }}
lang="ru"
>
IT-аутсорсинг и техподдержка<br />
инфраструктуры, за которую<br />
можно не переживать
</h1>
<p className="text-base sm:text-lg mb-8 max-w-md text-slate-600 dark:text-slate-400">
Администрирование серверов, кибербезопасность, сетевая инфраструктура и автоматизация бизнес-процессов
с помощью ИИ — с гарантией отклика от 15 минут.
</p>
<div className="flex flex-wrap gap-3">
<Link
href="/uslugi"
className="inline-flex items-center gap-2 px-5 py-3 rounded-md bg-emerald-500 text-white font-medium text-sm hover:bg-emerald-600 transition-colors"
>
Наши услуги <ArrowRight className="h-4 w-4" />
</Link>
<Link
href="/kontakty"
className="inline-flex items-center gap-2 px-5 py-3 rounded-md font-medium text-sm border transition-colors border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-900"
>
Оставить заявку
</Link>
</div>
</div>
<div className="rounded-lg border p-5 shadow-sm font-mono bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-1.5 mb-4">
<span className="h-2.5 w-2.5 rounded-full bg-rose-400" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
</div>
<div className="space-y-2 text-sm">
{LINES.slice(0, shown).map((line, i) => (
<div key={line} className={cx(i === 0 ? 'text-emerald-500' : 'text-slate-700 dark:text-slate-300')}>
{line}
</div>
))}
<span className="inline-flex items-center gap-1 text-slate-700 dark:text-slate-300">
{shown >= LINES.length && <span className="text-emerald-500">$</span>}
<span className="inline-block w-2 h-4 bg-emerald-500 animate-pulse align-middle" />
</span>
</div>
</div>
</section>
);
}
@@ -0,0 +1,68 @@
import Link from 'next/link';
import { ArrowUpRight } from 'lucide-react';
import Reveal from '../Reveal.jsx';
import SectionHeading from '../SectionHeading.jsx';
export default function Promotions({ promotions, limit, moreHref, showMailNote = false }) {
if (promotions.length === 0) return null;
const list = limit ? promotions.slice(0, limit) : promotions;
return (
<section id="promotions" className="py-20 bg-slate-100/60 dark:bg-slate-950/40">
<div className="max-w-6xl mx-auto px-5 sm:px-8">
<SectionHeading
eyebrow="promotions --active"
title="Акции и специальные предложения"
desc="Актуальные сроки и условия уточняйте у менеджера при обращении."
/>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{list.map((p, i) => (
<Reveal key={p.id} delay={i * 60}>
<div className="h-full rounded-lg border p-5 flex flex-col bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h3 className="font-bold mb-2 font-display text-[15px]">{p.title}</h3>
<div className="flex items-baseline gap-2 mb-3">
<span className="text-xl font-bold text-emerald-500 font-mono">{p.price_text}</span>
{p.old_price_text && (
<span className="text-sm line-through text-slate-400 dark:text-slate-600 font-mono">
{p.old_price_text}
</span>
)}
</div>
<p className="text-sm flex-1 text-slate-600 dark:text-slate-400">{p.description}</p>
<Link
href="/kontakty"
className="mt-4 text-sm text-emerald-500 font-medium inline-flex items-center gap-1 hover:gap-2 transition-all"
>
Забронировать <ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
</Reveal>
))}
</div>
{moreHref && (
<Reveal className="mt-8">
<Link href={moreHref} className="text-sm text-emerald-500 font-medium hover:underline">
Все акции →
</Link>
</Reveal>
)}
{showMailNote && (
<Reveal className="mt-8">
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<p className="text-sm font-medium mb-1">В чём разница между акцией и обычной услугой на почтовый сервер?</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Услуги «Почтовый сервер (ваше/наше оборудование)» из прайс-листа — это базовое развёртывание или
аренда+администрирование без пакета доп. настроек. Акция «Почтовый сервер под ключ» — это
отдельный, более полный пакет: настройка на вашем оборудовании + SPF/DKIM/DMARC, антиспам,
веб-почта и 3 месяца сопровождения включены в стоимость — по акционной цене вместо отдельной оплаты
каждого пункта.
</p>
</div>
</Reveal>
)}
</div>
</section>
);
}
@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { Star } from 'lucide-react';
import { clientApi } from '../../lib/clientApi.js';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
function StarRating({ value, onChange }) {
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} type="button" onClick={() => onChange(n)} aria-label={`${n} звёзд`}>
<Star className={cx('h-5 w-5', n <= value ? 'fill-amber-400 text-amber-400' : 'text-slate-400')} />
</button>
))}
</div>
);
}
export default function ReviewForm() {
const [form, setForm] = useState({ name: '', role: '', rating: 5, text: '', website: '' });
const [status, setStatus] = useState('idle'); // idle | sending | sent | error
async function handleSubmit(e) {
e.preventDefault();
if (!form.name.trim() || !form.text.trim()) return;
setStatus('sending');
try {
await clientApi.submitReview(form);
setForm({ name: '', role: '', rating: 5, text: '', website: '' });
setStatus('sent');
} catch (err) {
setStatus('error');
}
}
return (
<form onSubmit={handleSubmit} className="rounded-lg border p-5 space-y-4 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<h3 className="font-bold font-display text-[15px]">Оставить отзыв</h3>
{/* honeypot field - hidden from real users via CSS, bots often fill every input */}
<input
type="text"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
className="hidden"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<div>
<label className="text-xs block mb-1">Имя*</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700"
/>
</div>
<div>
<label className="text-xs block mb-1">Должность / компания</label>
<input
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700"
/>
</div>
<div>
<label className="text-xs block mb-1">Оценка*</label>
<StarRating value={form.rating} onChange={(n) => setForm({ ...form, rating: n })} />
</div>
<div>
<label className="text-xs block mb-1">Отзыв*</label>
<textarea
required
rows={3}
value={form.text}
onChange={(e) => setForm({ ...form, text: e.target.value })}
className="w-full rounded-md border px-3 py-2 text-sm bg-transparent border-slate-300 dark:border-slate-700"
/>
</div>
<button
type="submit"
disabled={status === 'sending'}
className="w-full py-2.5 rounded-md bg-emerald-500 text-white text-sm font-medium hover:bg-emerald-600 transition-colors disabled:opacity-60"
>
{status === 'sending' ? 'Отправляем...' : 'Отправить на модерацию'}
</button>
{status === 'sent' && <p className="text-xs text-emerald-500">Спасибо! Отзыв появится на сайте после одобрения.</p>}
{status === 'error' && <p className="text-xs text-rose-500">Не получилось отправить отзыв, попробуйте ещё раз чуть позже.</p>}
</form>
);
}
@@ -0,0 +1,57 @@
import Link from 'next/link';
import { Star } from 'lucide-react';
import Reveal from '../Reveal.jsx';
import SectionHeading from '../SectionHeading.jsx';
import ReviewForm from './ReviewForm.jsx';
function cx(...args) {
return args.filter(Boolean).join(' ');
}
export default function Reviews({ reviews, limit, moreHref, showForm = true }) {
const list = limit ? reviews.slice(0, limit) : reviews;
return (
<section id="reviews" className="py-20 bg-slate-100/60 dark:bg-slate-950/40">
<div className="max-w-6xl mx-auto px-5 sm:px-8">
<SectionHeading
eyebrow="reviews --approved"
title="Отзывы клиентов"
desc="Публикуются после проверки менеджером - это защищает страницу от спама и фейков."
/>
<div className={cx('grid gap-10', showForm && 'md:grid-cols-2')}>
<Reveal>
<div className="space-y-4">
{list.length === 0 && <p className="text-sm text-slate-400">Пока нет опубликованных отзывов. Будьте первым!</p>}
{list.map((r) => (
<div key={r.id} className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<div className="flex items-center gap-1 mb-2">
{[1, 2, 3, 4, 5].map((n) => (
<Star key={n} className={cx('h-4 w-4', n <= r.rating ? 'fill-amber-400 text-amber-400' : 'text-slate-300')} />
))}
</div>
<p className="text-sm mb-3">{r.text}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
{r.name}
{r.role ? ` · ${r.role}` : ''}
</p>
</div>
))}
</div>
{moreHref && (
<Link href={moreHref} className="mt-6 inline-block text-sm text-emerald-500 font-medium hover:underline">
Все отзывы →
</Link>
)}
</Reveal>
{showForm && (
<Reveal delay={100}>
<ReviewForm />
</Reveal>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,88 @@
import Link from 'next/link';
import Reveal from '../Reveal.jsx';
import SectionHeading from '../SectionHeading.jsx';
import { getIcon } from '../../lib/icons.js';
export function ServicePriceList({ category }) {
return (
<ul className="space-y-3">
{category.items.map((it) => (
<li key={it.id} className="pt-3 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm">{it.name}</span>
<span className="text-sm text-emerald-500 whitespace-nowrap font-mono">
{it.price_text}
{it.period}
</span>
</div>
{it.note && <p className="text-xs mt-1 text-slate-400 dark:text-slate-500">{it.note}</p>}
</li>
))}
</ul>
);
}
export default function Services({
categories,
compact = false,
limit,
moreHref,
eyebrow = 'services --price-list',
title = 'Услуги IT-аутсорсинга и техподдержки: прайс-лист',
desc = 'Гибкие тарифы для бизнеса любого масштаба. Возможна индивидуальная настройка под ваши задачи.',
}) {
const list = limit ? categories.slice(0, limit) : categories;
return (
<section id="services" className="max-w-6xl mx-auto px-5 sm:px-8 py-20">
<SectionHeading eyebrow={eyebrow} title={title} desc={desc} />
{list.length === 0 && <p className="text-sm text-slate-400">Прайс-лист пока пуст.</p>}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{list.map((cat, i) => {
const Icon = getIcon(cat.icon);
return (
<Reveal key={cat.id} delay={i * 60}>
<div className="h-full rounded-lg border p-5 transition-transform hover:-translate-y-1 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<Icon className="h-5 w-5 text-emerald-500 mb-3" />
<Link href={`/uslugi/${cat.slug}`} className="hover:text-emerald-500 transition-colors">
<h3 className="font-bold mb-1 font-display text-[15px]">{cat.title}</h3>
</Link>
<p className="text-xs mb-4 text-slate-500 dark:text-slate-400">{cat.description}</p>
{compact ? (
<Link
href={`/uslugi/${cat.slug}`}
className="text-sm text-emerald-500 font-medium hover:underline"
>
Подробнее и цены →
</Link>
) : (
<ServicePriceList category={cat} />
)}
</div>
</Reveal>
);
})}
</div>
{moreHref && (
<Reveal className="mt-8">
<Link href={moreHref} className="text-sm text-emerald-500 font-medium hover:underline">
Все услуги и полный прайс-лист →
</Link>
</Reveal>
)}
{!compact && !moreHref && (
<Reveal className="mt-8">
<div className="rounded-lg border p-5 bg-white border-slate-200 dark:bg-slate-900 dark:border-slate-800">
<p className="text-xs text-slate-400 dark:text-slate-500">
Цены указаны в рублях (RUB). Точная стоимость рассчитывается индивидуально после анализа задач.
</p>
</div>
</Reveal>
)}
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
// Используется в клиентских компонентах ('use client'). Идёт через относительный
// /api - в браузере это резолвится через Caddy, который проксирует на бэкенд.
async function request(path, options = {}) {
const res = await fetch(`/api${path}`, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
let message = 'Request failed';
try {
const data = await res.json();
message = data.error || message;
} catch (e) {
// no JSON body
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
export const clientApi = {
submitReview: (data) => request('/reviews', { method: 'POST', body: JSON.stringify(data) }),
trackVisit: () => request('/stats/visit', { method: 'POST' }).catch(() => {}),
};
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more