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:
ogrechkoandClaude Sonnet 5 committed 2026-08-25 23:12:16 +03:00
commit 130c5382cd
108 files changed
+7146

No files matched your search

+32
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+11
View File
@@ -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">&gt; {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>
);
}
+23
View File
@@ -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>
);
}
+53
View File
@@ -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;
}
+35
View File
@@ -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>
);
}
+11
View File
@@ -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">
&gt; 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>
);
}
+50
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+77
View File
@@ -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">
&gt; 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>
);
}