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 (
+ {/* eslint-disable-next-line react/no-danger */} +