Consolidate Organization schema, add ItemList/Offer markup, expand FAQ

- Merge the duplicate Organization/LocalBusiness JSON-LD blocks (home's
  LocalBusiness + o-kompanii's Organization) into one shared
  getOrganizationSchema() call rendered on every page. Deliberately NOT
  wired through the root layout: fetches there get frozen into the
  static shell at build time (backend unreachable during image build)
  and never revalidate, silently serving stale data to crawlers - each
  page calls it directly instead, where revalidation is proven to work.
- Add optional ItemList schema on /uslugi and Offer/OfferCatalog schema
  on /aktsii; entity pages (/uslugi/[slug] Service, /blog/[slug]
  Article) reference the organization via @id instead of duplicating it.
- Add 8 more real FAQ entries (pricing model, contract terms,
  onboarding time, hybrid IT staff setup, supported stacks, backups,
  152-FZ compliance, escalation) and give every FAQ its own slug and
  dedicated /faq/[slug] page with its own title/H1/FAQPage schema,
  modeled on the one-topic-per-page pattern (h/t remontka.pro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-26 00:57:07 +03:00
1 parent f53c8b1ed7
commit 1e4f97030a
19 files changed
+271 -69

No files matched your search

+29 -1
View File
@@ -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 (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(offersSchema) }} />
<Header />
<Promotions promotions={promotions} showMailNote headingLevel="h1" />
<Footer />
+2 -1
View File
@@ -5,6 +5,7 @@ import Header from '../../../components/Header.jsx';
import Footer from '../../../components/Footer.jsx';
import Reveal from '../../../components/Reveal.jsx';
import { serverApi } from '../../../lib/serverApi.js';
import { ORGANIZATION_ID } from '../../../lib/schema.js';
function formatDate(iso) {
if (!iso) return '';
@@ -31,7 +32,7 @@ export default async function BlogPostPage({ params }) {
headline: post.title,
...(post.excerpt ? { description: post.excerpt } : {}),
...(post.published_at ? { datePublished: post.published_at } : {}),
author: { '@id': `${process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru'}/#organization` },
author: { '@id': ORGANIZATION_ID },
};
return (
+4 -1
View File
@@ -5,6 +5,7 @@ import Footer from '../../components/Footer.jsx';
import SectionHeading from '../../components/SectionHeading.jsx';
import Reveal from '../../components/Reveal.jsx';
import { serverApi } from '../../lib/serverApi.js';
import { getOrganizationSchema } from '../../lib/schema.js';
export const metadata = {
title: 'Блог',
@@ -18,10 +19,12 @@ function formatDate(iso) {
}
export default async function BlogListPage() {
const posts = await serverApi.getBlogPosts();
const [posts, orgSchema] = await Promise.all([serverApi.getBlogPosts(), getOrganizationSchema()]);
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
<Header />
<section className="max-w-3xl mx-auto px-5 sm:px-8 py-20 min-h-[60vh]">
<SectionHeading
+67
View File
@@ -0,0 +1,67 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, ArrowRight } 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';
import { getOrganizationSchema } from '../../../lib/schema.js';
export async function generateMetadata({ params }) {
const faq = await serverApi.getFaq(params.slug);
if (!faq) return { title: 'Вопрос не найден' };
return {
title: faq.question,
description: faq.answer.length > 160 ? `${faq.answer.slice(0, 157)}...` : faq.answer,
alternates: { canonical: `/faq/${params.slug}` },
};
}
export default async function FaqDetailPage({ params }) {
const [faq, orgSchema] = await Promise.all([serverApi.getFaq(params.slug), getOrganizationSchema()]);
if (!faq) notFound();
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: faq.question,
acceptedAnswer: { '@type': 'Answer', text: faq.answer },
},
],
};
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }} />
<Header />
<article className="max-w-2xl mx-auto px-5 sm:px-8 py-20 min-h-[60vh]">
<Link href="/faq" 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 uppercase tracking-widest text-emerald-500 mb-4 font-mono">&gt; faq --detail</p>
<h1 className="text-2xl sm:text-3xl font-bold mb-6 font-display">{faq.question}</h1>
<p className="text-base text-slate-700 dark:text-slate-300">{faq.answer}</p>
</Reveal>
<Reveal delay={100} className="mt-10">
<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>
</article>
<Footer />
</div>
);
}
+4 -1
View File
@@ -2,6 +2,7 @@ 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';
import { getOrganizationSchema } from '../../lib/schema.js';
export const metadata = {
title: 'Частые вопросы об IT-аутсорсинге и услугах',
@@ -10,7 +11,7 @@ export const metadata = {
};
export default async function FaqPage() {
const faqs = await serverApi.getFaqs();
const [faqs, orgSchema] = await Promise.all([serverApi.getFaqs(), getOrganizationSchema()]);
const faqSchema =
faqs.length > 0
@@ -27,6 +28,8 @@ export default async function FaqPage() {
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
{faqSchema && (
// eslint-disable-next-line react/no-danger
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }} />
+4 -1
View File
@@ -2,6 +2,7 @@ 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';
import { getOrganizationSchema } from '../../lib/schema.js';
export const metadata = {
title: 'Кейсы и портфолио — сетевая инфраструктура, ИИ, кибербезопасность',
@@ -11,10 +12,12 @@ export const metadata = {
};
export default async function CasesPage() {
const cases = await serverApi.getCases();
const [cases, orgSchema] = await Promise.all([serverApi.getCases(), getOrganizationSchema()]);
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
<Header />
<Cases cases={cases} showDisclaimer headingLevel="h1" />
<Footer />
+4 -1
View File
@@ -2,6 +2,7 @@ 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';
import { getOrganizationSchema } from '../../lib/schema.js';
export const metadata = {
title: 'Контакты',
@@ -10,10 +11,12 @@ export const metadata = {
};
export default async function ContactPage() {
const contact = await serverApi.getContact();
const [contact, orgSchema] = await Promise.all([serverApi.getContact(), getOrganizationSchema()]);
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
<Header />
<Contact contact={contact} headingLevel="h1" />
<Footer />
+5 -3
View File
@@ -2,7 +2,6 @@ import './globals.css';
import { ThemeProvider } from '../components/ThemeProvider.jsx';
import SupportChat from '../components/SupportChat.jsx';
import VisitTracker from '../components/VisitTracker.jsx';
import { getOrganizationSchema } from '../lib/schema.js';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru';
@@ -28,14 +27,17 @@ const NO_FLASH_SCRIPT = `
})();
`;
// Organization/LocalBusiness JSON-LD намеренно НЕ рендерится здесь: root layout
// в этом проекте частично запекается в статическую оболочку на этапе сборки
// (когда бэкенд ещё недоступен), и любые fetch-данные отсюда попадают в HTML
// устаревшими навсегда, даже с revalidate. Каждая страница вызывает
// getOrganizationSchema() сама - там это надёжно ревалидируется на каждый запрос.
export default function RootLayout({ children }) {
return (
<html lang="ru">
<head>
{/* eslint-disable-next-line react/no-danger */}
<script dangerouslySetInnerHTML={{ __html: NO_FLASH_SCRIPT }} />
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(getOrganizationSchema()) }} />
</head>
<body className="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
<ThemeProvider>{children}</ThemeProvider>
+2 -14
View File
@@ -4,7 +4,7 @@ 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';
import { getOrganizationSchema } from '../../lib/schema.js';
export const metadata = {
title: 'О компании',
@@ -43,24 +43,12 @@ const DIFFERENTIATORS = [
];
export default async function AboutPage() {
const contact = await serverApi.getContact();
const orgSchema = {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru'}/#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' } } : {}),
};
const orgSchema = await getOrganizationSchema();
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>
+4 -1
View File
@@ -2,6 +2,7 @@ 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';
import { getOrganizationSchema } from '../../lib/schema.js';
export const metadata = {
title: 'Отзывы клиентов TopSysOps',
@@ -10,10 +11,12 @@ export const metadata = {
};
export default async function ReviewsPage() {
const reviews = await serverApi.getReviews();
const [reviews, orgSchema] = await Promise.all([serverApi.getReviews(), getOrganizationSchema()]);
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
<Header />
<Reviews reviews={reviews} headingLevel="h1" />
<Footer />
+4 -37
View File
@@ -12,59 +12,26 @@ 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';
import { getOrganizationSchema } from '../lib/schema.js';
export const metadata = {
alternates: { canonical: '/' },
};
export default async function HomePage() {
const [services, promotions, reviews, cases, contact] = await Promise.all([
const [services, promotions, reviews, cases, contact, orgSchema] = await Promise.all([
serverApi.getServices(),
serverApi.getPromotions(),
serverApi.getReviews(),
serverApi.getCases(),
serverApi.getContact(),
getOrganizationSchema(),
]);
const business = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
'@id': `${process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru'}/#organization`,
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) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
<Header />
<Hero />
<AccountPromo />
+10 -1
View File
@@ -3,7 +3,11 @@ 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()]);
const [posts, services, faqs] = await Promise.all([
serverApi.getBlogPosts(),
serverApi.getServices(),
serverApi.getFaqs(),
]);
return [
{ url: `${SITE_URL}/`, changeFrequency: 'weekly', priority: 1.0 },
@@ -18,6 +22,11 @@ export default async function sitemap() {
{ 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 },
...faqs.filter((f) => f.slug).map((f) => ({
url: `${SITE_URL}/faq/${f.slug}`,
changeFrequency: 'monthly',
priority: 0.4,
})),
{ url: `${SITE_URL}/kontakty`, changeFrequency: 'monthly', priority: 0.6 },
{ url: `${SITE_URL}/blog`, changeFrequency: 'weekly', priority: 0.7 },
...posts.map((p) => ({
+2 -1
View File
@@ -8,6 +8,7 @@ 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';
import { ORGANIZATION_ID } from '../../../lib/schema.js';
async function findCategory(slug) {
const services = await serverApi.getServices();
@@ -37,7 +38,7 @@ export default async function ServiceLandingPage({ params }) {
'@type': 'Service',
name: copy.h1,
description: copy.metaDescription,
provider: { '@id': `${process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru'}/#organization` },
provider: { '@id': ORGANIZATION_ID },
areaServed: 'RU',
};
+19 -1
View File
@@ -2,6 +2,9 @@ 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';
import { getOrganizationSchema } from '../../lib/schema.js';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru';
export const metadata = {
title: 'Услуги IT-аутсорсинга и техподдержки — прайс-лист',
@@ -11,10 +14,25 @@ export const metadata = {
};
export default async function ServicesPage() {
const services = await serverApi.getServices();
const [services, orgSchema] = await Promise.all([serverApi.getServices(), getOrganizationSchema()]);
const itemListSchema = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: services.map((cat, i) => ({
'@type': 'ListItem',
position: i + 1,
name: cat.title,
url: `${SITE_URL}/uslugi/${cat.slug}`,
})),
};
return (
<div>
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} />
{/* eslint-disable-next-line react/no-danger */}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListSchema) }} />
<Header />
<Services categories={services} headingLevel="h1" />
<Footer />
+10 -1
View File
@@ -18,7 +18,16 @@ function FaqItem({ item }) {
<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>}
{open && (
<div className="pb-4">
<p className="text-sm text-slate-600 dark:text-slate-400">{item.answer}</p>
{item.slug && (
<Link href={`/faq/${item.slug}`} className="mt-2 inline-block text-xs text-emerald-500 font-medium hover:underline">
Открыть страницу вопроса →
</Link>
)}
</div>
)}
</div>
);
}
+42 -3
View File
@@ -1,12 +1,51 @@
import { serverApi } from './serverApi.js';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://top-sysops.ru';
export const ORGANIZATION_ID = `${SITE_URL}/#organization`;
// Единая сущность Organization/LocalBusiness для всего сайта - рендерится один
// раз в корневом layout, чтобы на каждой странице был ровно один JSON-LD блок
// про компанию, а не несколько дублирующих друг друга.
export async function getOrganizationSchema() {
const [contact, reviews, services] = await Promise.all([
serverApi.getContact(),
serverApi.getReviews(),
serverApi.getServices(),
]);
export function getOrganizationSchema() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${SITE_URL}/#organization`,
'@type': 'LocalBusiness',
'@id': ORGANIZATION_ID,
name: 'TopSysOps',
url: SITE_URL,
foundingDate: '2023',
numberOfEmployees: { '@type': 'QuantitativeValue', minValue: 20 },
...(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 },
}))
),
},
}
: {}),
};
}
+1
View File
@@ -20,6 +20,7 @@ export const serverApi = {
getReviews: () => get('/reviews').then((v) => v || []),
getCases: () => get('/cases').then((v) => v || []),
getFaqs: () => get('/faqs').then((v) => v || []),
getFaq: (slug) => get(`/faqs/${slug}`),
getContact: () => get('/contact'),
getBlogPosts: () => get('/blog').then((v) => v || []),
getBlogPost: (slug) => get(`/blog/${slug}`, { revalidate: 60 }),