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:
commit
130c5382cd
108 files changed
+7146
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
@@ -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->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 или аналог.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }) }),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">> {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user