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,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(() => {}),
|
||||
};
|
||||
@@ -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,26 @@
|
||||
// Используется только внутри Server Component'ов (во время SSR/сборки).
|
||||
// Обращается к бэкенду напрямую по внутреннему docker-адресу, минуя Caddy -
|
||||
// это работает только на сервере Next.js, у браузера такого адреса нет.
|
||||
const BASE = process.env.BACKEND_INTERNAL_URL || 'http://backend:4000';
|
||||
|
||||
async function get(path, { revalidate = 60 } = {}) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api${path}`, { next: { revalidate } });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch (err) {
|
||||
console.error(`[serverApi] failed to fetch ${path}`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const serverApi = {
|
||||
getServices: () => get('/services').then((v) => v || []),
|
||||
getPromotions: () => get('/promotions').then((v) => v || []),
|
||||
getReviews: () => get('/reviews').then((v) => v || []),
|
||||
getCases: () => get('/cases').then((v) => v || []),
|
||||
getFaqs: () => get('/faqs').then((v) => v || []),
|
||||
getContact: () => get('/contact'),
|
||||
getBlogPosts: () => get('/blog').then((v) => v || []),
|
||||
getBlogPost: (slug) => get(`/blog/${slug}`, { revalidate: 60 }),
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
export const SERVICE_LANDING_COPY = {
|
||||
support: {
|
||||
metaTitle: 'IT-аутсорсинг и техническая поддержка серверов',
|
||||
metaDescription:
|
||||
'Удалённая и выездная техподдержка IT-инфраструктуры: мониторинг, устранение инцидентов, администрирование серверов. Тарифы от 3/4 до 24/7, отклик от 15 минут.',
|
||||
h1: 'IT-аутсорсинг и техническая поддержка для бизнеса',
|
||||
intro:
|
||||
'Берём на абонентское обслуживание вашу IT-инфраструктуру: серверы, рабочие места, сеть. Удалённый мониторинг и устранение инцидентов, при необходимости — выезд специалиста по Санкт-Петербургу.',
|
||||
bullets: [
|
||||
'Три уровня тарифов — от базовой удалённой поддержки 3/4 до полного сопровождения 24/7',
|
||||
'Среднее время реакции на заявку от 15 минут на тарифе «Премиум»',
|
||||
'Работаем удалённо с клиентами по всей России, выезд специалиста — по Санкт-Петербургу',
|
||||
'NDA перед началом работ и защищённые каналы доступа к инфраструктуре',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
metaTitle: 'Кибербезопасность и защита IT-инфраструктуры',
|
||||
metaDescription:
|
||||
'Аудит информационной безопасности, внедрение IDS/IPS, мониторинг угроз. Комплексная защита данных и инфраструктуры бизнеса от кибератак.',
|
||||
h1: 'Кибербезопасность и защита IT-инфраструктуры бизнеса',
|
||||
intro:
|
||||
'Проводим аудит защищённости, внедряем системы обнаружения и предотвращения вторжений, настраиваем постоянный мониторинг угроз — чтобы инцидент не стал неожиданностью.',
|
||||
bullets: [
|
||||
'Комплексный аудит и мониторинг инфраструктуры на регулярной основе',
|
||||
'Внедрение IDS/IPS для обнаружения и предотвращения вторжений',
|
||||
'Подходит для инфраструктуры любого масштаба, включая финтех и e-commerce',
|
||||
'Отчёты и рекомендации по итогам аудита на понятном языке, без лишнего технического жаргона',
|
||||
],
|
||||
},
|
||||
db: {
|
||||
metaTitle: 'Администрирование баз данных: настройка и резервное копирование',
|
||||
metaDescription:
|
||||
'Настройка и оптимизация PostgreSQL, MySQL и других СУБД, регулярное резервное копирование и восстановление данных.',
|
||||
h1: 'Администрирование баз данных: настройка, оптимизация, резервное копирование',
|
||||
intro:
|
||||
'Настраиваем и оптимизируем работу СУБД, выстраиваем регулярное резервное копирование и проверяем восстановление данных, чтобы сбой сервера не означал потерю бизнес-информации.',
|
||||
bullets: [
|
||||
'Работаем с PostgreSQL, MySQL и другими популярными СУБД',
|
||||
'Оптимизация запросов и настройка под реальную нагрузку вашего проекта',
|
||||
'Регулярные бэкапы с проверкой восстановления, а не просто «файл на диске»',
|
||||
'Почасовая и абонентская модель — под разовую задачу или на постоянной основе',
|
||||
],
|
||||
},
|
||||
network: {
|
||||
metaTitle: 'Сетевая инфраструктура и почтовые серверы под ключ',
|
||||
metaDescription:
|
||||
'Проектирование корпоративных сетей, настройка серверов, развёртывание и администрирование почтовых серверов, управление сетевым оборудованием.',
|
||||
h1: 'Сетевая инфраструктура и почтовые серверы под ключ',
|
||||
intro:
|
||||
'Проектируем и поддерживаем корпоративные сети: от подбора и настройки оборудования до развёртывания почтовых серверов с защитой от спама и подделки писем.',
|
||||
bullets: [
|
||||
'Проектирование, монтаж и настройка сети под ваш офис или сеть филиалов',
|
||||
'Почтовый сервер на вашем оборудовании или в аренду с администрированием',
|
||||
'Отдельная акция на полный пакет: SPF/DKIM/DMARC, антиспам, веб-почта — смотрите раздел «Акции»',
|
||||
'Управление роутерами и коммутаторами на абонентской основе',
|
||||
],
|
||||
},
|
||||
cloud: {
|
||||
metaTitle: 'Облачные решения и миграция инфраструктуры в облако',
|
||||
metaDescription:
|
||||
'Перенос IT-инфраструктуры и сервисов в облако, настройка и сопровождение облачных сервисов, оптимизация облачных затрат.',
|
||||
h1: 'Облачные решения и миграция инфраструктуры в облако',
|
||||
intro:
|
||||
'Переносим серверы и сервисы в облако с минимальным простоем, настраиваем инфраструктуру под нагрузку и следим, чтобы счета за облако не росли быстрее бизнеса.',
|
||||
bullets: [
|
||||
'Миграция серверов и сервисов в облако без остановки продаж',
|
||||
'Подбор конфигурации под реальную нагрузку — без переплаты за лишние мощности',
|
||||
'Настройка резервного копирования и мониторинга облачной инфраструктуры',
|
||||
'Сопровождение после миграции на абонентской основе',
|
||||
],
|
||||
},
|
||||
ai: {
|
||||
metaTitle: 'ИИ-ассистенты и автоматизация бизнес-процессов',
|
||||
metaDescription:
|
||||
'Разработка ботов и ИИ-ассистентов с RAG, интеграция с CRM и мессенджерами, автоматизация рутинных бизнес-процессов.',
|
||||
h1: 'Внедрение ИИ-ассистентов и автоматизация бизнес-процессов',
|
||||
intro:
|
||||
'Разрабатываем ботов и ИИ-ассистентов, обученных на базе знаний вашей компании (RAG), и интегрируем их с CRM, мессенджерами и внутренними сервисами — чтобы снять рутину с сотрудников.',
|
||||
bullets: [
|
||||
'Telegram-боты и ИИ-ассистенты с обучением на ваших данных (RAG)',
|
||||
'Интеграция с CRM, мессенджерами и внешними API',
|
||||
'Снижение нагрузки на операторов поддержки за счёт автоматизации типовых вопросов',
|
||||
'Помесячное сопровождение и дообучение ассистента после запуска',
|
||||
],
|
||||
},
|
||||
development: {
|
||||
metaTitle: 'Разработка сайтов и веб-сервисов под ключ',
|
||||
metaDescription:
|
||||
'От лендинга и MVP до продакшн-разработки: сайты, личные кабинеты, интеграции. Сроки от 1 недели, сопровождение после запуска.',
|
||||
h1: 'Разработка сайтов и веб-сервисов под ключ',
|
||||
intro:
|
||||
'Делаем веб-проекты любого масштаба: от простой посадочной страницы за неделю до продакшн-разработки с архитектурой, тестами и DevOps под масштабирование.',
|
||||
bullets: [
|
||||
'Лендинг — от 1 недели, MVP «Быстрый старт» — 2-3 недели, MVP Pro — 4-6 недель',
|
||||
'Discovery-воркшоп перед стартом — фиксируем ТЗ и оценку за 1-2 дня',
|
||||
'Продакшн-разработка с архитектурой, тестами и DevOps для проектов, которым нужно масштабирование',
|
||||
'Ежемесячное сопровождение и доработки уже запущенных продуктов',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getServiceLandingCopy(category) {
|
||||
return (
|
||||
SERVICE_LANDING_COPY[category.slug] || {
|
||||
metaTitle: category.title,
|
||||
metaDescription: category.description,
|
||||
h1: category.title,
|
||||
intro: category.description,
|
||||
bullets: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "topsysops-public-site",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.383.0",
|
||||
"next": "^14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Whitespace-only changes.
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,jsx}', './components/**/*.{js,jsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['Unbounded', 'sans-serif'],
|
||||
body: ['Manrope', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
};
|
||||
Reference in new issue
Block a user