diff --git a/backend/migrations/010_faq_slugs_and_more_questions.sql b/backend/migrations/010_faq_slugs_and_more_questions.sql
new file mode 100644
index 0000000..8b79a22
--- /dev/null
+++ b/backend/migrations/010_faq_slugs_and_more_questions.sql
@@ -0,0 +1,48 @@
+-- Добавляет slug к FAQ (для отдельных страниц под каждый вопрос - по образцу
+-- сайтов с базой знаний, где один вопрос/проблема = один адрес, а не только
+-- пункт в общем аккордеоне) и добавляет более полный набор реальных вопросов
+-- про условия работы, а не только про общий сервис.
+-- Автоматически применяется при следующем запуске бэкенда.
+BEGIN;
+
+ALTER TABLE faqs ADD COLUMN IF NOT EXISTS slug TEXT;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_faqs_slug ON faqs(slug) WHERE slug IS NOT NULL;
+
+UPDATE faqs SET slug = 'vremya-reakcii-na-zayavki' WHERE question = 'Как быстро вы реагируете на заявки?' AND slug IS NULL;
+UPDATE faqs SET slug = 'udalennaya-podderzhka-po-rossii' WHERE question = 'Работаете ли вы полностью удалённо?' AND slug IS NULL;
+UPDATE faqs SET slug = 'chto-vhodit-v-bazovyy-tarif' WHERE question = 'Что входит в базовый тариф IT-аутсорсинга?' AND slug IS NULL;
+UPDATE faqs SET slug = 'kak-oplachivat-uslugi' WHERE question = 'Как происходит оплата услуг?' AND slug IS NULL;
+UPDATE faqs SET slug = 'razovaya-usluga-bez-abonementa' WHERE question = 'Можно ли заказать разовую услугу без абонемента?' AND slug IS NULL;
+UPDATE faqs SET slug = 'konfidencialnost-dannyh' WHERE question = 'Как обеспечивается конфиденциальность данных?' AND slug IS NULL;
+
+INSERT INTO faqs (question, answer, slug, sort_order)
+SELECT v.question, v.answer, v.slug, (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM faqs) + v.rn - 1
+FROM (VALUES
+ ('Как рассчитывается стоимость услуг?',
+ 'Стоимость абонентского обслуживания зависит от количества рабочих мест, графика поддержки (5/2 или 24/7) и состава инфраструктуры. Разовые работы (настройка сервера, аудит, разработка) оцениваются по объёму задачи после короткого созвона или Discovery-воркшопа. Точную сумму называем после короткого аудита - до этого момента ориентируемся на прайс-лист.',
+ 'kak-rasschityvaetsya-stoimost', 1),
+ ('Можно ли расторгнуть договор на обслуживание?',
+ 'Да, договор на абонентское обслуживание можно расторгнуть, уведомив нас заранее (обычно за 30 дней) - жёсткой привязки на длительный срок нет, кроме случаев, когда клиент сам выбрал тариф с фиксированным сроком ради скидки (см. раздел «Акции»). Все условия фиксируются в договоре до начала работ.',
+ 'kak-rastorgnut-dogovor', 2),
+ ('Сколько занимает подключение к обслуживанию?',
+ 'Обычно от 1 до 5 рабочих дней с момента подписания договора: сначала проводим аудит текущей инфраструктуры, получаем доступы, документируем текущее состояние - и только после этого переходим на плановое обслуживание. Для срочных случаев (например, уже произошёл инцидент) можем начать работу быстрее.',
+ 'skolko-zanimaet-podklyuchenie', 3),
+ ('Что если у нас уже есть свой системный администратор?',
+ 'Работаем и в гибридном формате: берём на себя часть инфраструктуры (например, кибербезопасность или почтовые серверы), пока штатный специалист занимается остальным, либо выступаем вторым уровнем поддержки на случай отпуска или больничного. Разграничение зон ответственности фиксируется в договоре.',
+ 'rabota-so-svoim-sisadminom', 4),
+ ('С какими операционными системами и СУБД вы работаете?',
+ 'Администрируем инфраструктуру на Linux и Windows Server, из СУБД - чаще всего PostgreSQL и MySQL, но беремся и за другие популярные СУБД по запросу. Перед стартом уточняем состав вашего стека на аудите.',
+ 's-kakimi-sistemami-rabotaete', 5),
+ ('Как обеспечивается резервное копирование данных?',
+ 'Настраиваем регулярные автоматические бэкапы, изолированные от основной инфраструктуры (чтобы шифровальщик или сбой сервера не уничтожил и рабочие данные, и резервные копии одновременно), и периодически проверяем, что из бэкапа реально можно восстановиться - не только что он создаётся.',
+ 'kak-obespechivaetsya-rezervnoe-kopirovanie', 6),
+ ('Работаете ли вы с требованиями 152-ФЗ о персональных данных?',
+ 'Да, при настройке инфраструктуры учитываем требования 152-ФЗ к обработке и хранению персональных данных: разграничение доступов, шифрование каналов, локализация данных на серверах в РФ при необходимости. Полный аудит на соответствие 152-ФЗ - по отдельному запросу.',
+ 'rabota-s-152-fz', 7),
+ ('Что происходит, если проблему не удаётся решить сразу?',
+ 'Если проблема не решается на первом уровне поддержки, подключаем более опытного специалиста - это часть стандартного процесса, без дополнительной оплаты для клиентов на абонентском обслуживании. О ходе решения сложных инцидентов сообщаем в личном кабинете или напрямую.',
+ 'eskalaciya-slozhnyh-problem', 8)
+) AS v(question, answer, slug, rn)
+WHERE NOT EXISTS (SELECT 1 FROM faqs WHERE slug = v.slug);
+
+COMMIT;
diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js
index 19c8f74..3160f14 100644
--- a/backend/src/routes/public.js
+++ b/backend/src/routes/public.js
@@ -42,11 +42,20 @@ router.get('/cases', async (req, res) => {
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'
+ 'SELECT id, question, answer, slug FROM faqs WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
+router.get('/faqs/:slug', async (req, res) => {
+ const { rows } = await pool.query(
+ 'SELECT id, question, answer, slug FROM faqs WHERE slug = $1 AND active = true',
+ [req.params.slug]
+ );
+ if (!rows.length) return res.status(404).json({ error: 'not found' });
+ res.json(rows[0]);
+});
+
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'
diff --git a/public-site/app/aktsii/page.jsx b/public-site/app/aktsii/page.jsx
index b579603..f71e4dd 100644
--- a/public-site/app/aktsii/page.jsx
+++ b/public-site/app/aktsii/page.jsx
@@ -1,6 +1,7 @@
import Header from '../../components/Header.jsx';
import Footer from '../../components/Footer.jsx';
import Promotions from '../../components/sections/Promotions.jsx';
+import { ORGANIZATION_ID, getOrganizationSchema } from '../../lib/schema.js';
import { serverApi } from '../../lib/serverApi.js';
export const metadata = {
@@ -10,11 +11,38 @@ export const metadata = {
alternates: { canonical: '/aktsii' },
};
+function parsePrice(priceText) {
+ const digits = (priceText.match(/\d[\d\s ]*/) || [])[0];
+ if (!digits) return null;
+ const value = Number(digits.replace(/[\s ]/g, ''));
+ return Number.isFinite(value) && value > 0 ? value : null;
+}
+
export default async function PromotionsPage() {
- const promotions = await serverApi.getPromotions();
+ const [promotions, orgSchema] = await Promise.all([serverApi.getPromotions(), getOrganizationSchema()]);
+
+ const offersSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'OfferCatalog',
+ name: 'Акции TopSysOps',
+ itemListElement: promotions.map((p) => {
+ const price = parsePrice(p.price_text);
+ return {
+ '@type': 'Offer',
+ name: p.title,
+ description: p.description,
+ seller: { '@id': ORGANIZATION_ID },
+ ...(price ? { price, priceCurrency: 'RUB' } : {}),
+ };
+ }),
+ };
return (