feat: migrate to Next.js 15 App Router with i18n and multi-page SEO

- Replace Vite+React SPA with Next.js 15, TypeScript, App Router
- Static export (output: 'export'), deploy via npm run deploy
- i18n routing: /ru/... and /en/... with generateStaticParams
- New pages: /uslugi/ (catalog), /uslugi/[slug]/ (8 service pages), /faq/, /kontakty/
- SEO: generateMetadata on all pages, JSON-LD Service schema, hreflang alternates
- ContactForm: 'use client', Turnstile + POST to /api/contact.php
- Fonts: Manrope via next/font/google (CSS variable)
- Remove: vite.config.js, entry-server.jsx, prerender scripts, LanguageContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:19:18 +03:00
parent 18fff2e3f6
commit a0ebac1544
46 changed files with 1454 additions and 1960 deletions

View File

@@ -0,0 +1,28 @@
import type { Metadata } from 'next'
import { getDictionary, LOCALES, type Locale } from '@/lib/i18n'
import FaqSection from '@/components/sections/FaqSection'
export function generateStaticParams() {
return LOCALES.map(lang => ({ lang }))
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
const d = getDictionary(lang)
return {
title: d.faq.title,
description: d.faq.subtitle,
alternates: { canonical: `https://sag24.ru/${lang}/faq/` },
}
}
export default async function FaqPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang: langStr } = await params
const lang = langStr as Locale
const d = getDictionary(lang)
return (
<div className="pt-16">
<FaqSection d={d} standalone />
</div>
)
}

View File

@@ -0,0 +1,83 @@
import type { Metadata } from 'next'
import { getDictionary, LOCALES, type Locale } from '@/lib/i18n'
import ContactForm from '@/components/ui/ContactForm'
import { Phone, Mail, MapPin } from 'lucide-react'
export function generateStaticParams() {
return LOCALES.map(lang => ({ lang }))
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
const d = getDictionary(lang)
return {
title: d.contact.title,
description: d.contact.subtitle,
alternates: { canonical: `https://sag24.ru/${lang}/kontakty/` },
}
}
export default async function KontaktyPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang: langStr } = await params
const lang = langStr as Locale
const d = getDictionary(lang)
return (
<div className="pt-16">
<section className="py-24 bg-slate-900">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="grid md:grid-cols-2 gap-16">
<div>
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-4">{d.contact.title}</h1>
<p className="text-slate-400 text-lg mb-10">{d.contact.subtitle}</p>
<div className="flex flex-col gap-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<Phone size={20} className="text-blue-400" />
</div>
<div>
<div className="text-slate-400 text-xs uppercase tracking-wider mb-1">{d.contact.phone}</div>
<div className="flex flex-col gap-1">
{d.contact.phones.map((phone, i) => (
<a key={i} href={`tel:${phone.replace(/\D/g,'')}`} className="text-white font-medium hover:text-blue-400 transition-colors">{phone}</a>
))}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
<Mail size={20} className="text-blue-400" />
</div>
<div>
<div className="text-slate-400 text-xs uppercase tracking-wider mb-0.5">{d.contact.email}</div>
<a href="mailto:info@sag24.ru" className="text-white font-medium hover:text-blue-400 transition-colors">info@sag24.ru</a>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<MapPin size={20} className="text-blue-400" />
</div>
<div>
<div className="text-slate-400 text-xs uppercase tracking-wider mb-0.5">{d.contact.address}</div>
<span className="text-white font-medium">{d.contact.addressValue}</span>
</div>
</div>
</div>
<div className="mt-8 rounded-xl overflow-hidden border border-white/10">
<iframe
src="https://yandex.ru/map-widget/v1/?ll=37.857200%2C56.009400&z=16&pt=37.857200%2C56.009400%2Cpm2rdm&l=map"
width="100%"
height="220"
style={{ border: 0 }}
title="Офис Сисадмингрупп"
loading="lazy"
allowFullScreen
/>
</div>
</div>
<ContactForm d={d} />
</div>
</div>
</section>
</div>
)
}

34
src/app/[lang]/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from 'next'
import { LOCALES } from '@/lib/i18n'
import Header from '@/components/layout/Header'
import Footer from '@/components/layout/Footer'
import FloatingContacts from '@/components/ui/FloatingContacts'
import ScrollToTop from '@/components/ui/ScrollToTop'
export function generateStaticParams() {
return LOCALES.map(lang => ({ lang }))
}
export const metadata: Metadata = {
title: { default: 'Сисадмингрупп — IT-аутсорсинг в Пушкино', template: '%s | Сисадмингрупп' },
description: 'IT-аутсорсинг, техническая поддержка, кибербезопасность для бизнеса в Пушкино и Московской области. Опыт 10+ лет, 150+ клиентов, SLA от 15 минут.',
}
export default async function LangLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ lang: string }>
}) {
const { lang } = await params
return (
<>
<Header lang={lang} />
<main>{children}</main>
<Footer lang={lang} />
<FloatingContacts />
<ScrollToTop />
</>
)
}

48
src/app/[lang]/page.tsx Normal file
View File

@@ -0,0 +1,48 @@
import type { Metadata } from 'next'
import { getDictionary, LOCALES, type Locale } from '@/lib/i18n'
import Hero from '@/components/sections/Hero'
import ServicesGrid from '@/components/sections/ServicesGrid'
import AboutSection from '@/components/sections/AboutSection'
import ClientsSection from '@/components/sections/ClientsSection'
import PartnersSection from '@/components/sections/PartnersSection'
import FaqSection from '@/components/sections/FaqSection'
export function generateStaticParams() {
return LOCALES.map(lang => ({ lang }))
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
return {
title: lang === 'ru' ? 'Сисадмингрупп — IT-аутсорсинг в Пушкино' : 'SysadminGroup — IT Outsourcing in Pushkino',
description: lang === 'ru'
? 'IT-аутсорсинг, техническая поддержка, кибербезопасность для бизнеса в Пушкино и Московской области. Опыт 10+ лет, 150+ клиентов, SLA от 15 минут.'
: 'IT outsourcing, technical support, cybersecurity for businesses in Pushkino and Moscow Region. 10+ years experience, 150+ clients, SLA from 15 min.',
alternates: {
canonical: `https://sag24.ru/${lang}/`,
languages: { 'ru': 'https://sag24.ru/ru/', 'en': 'https://sag24.ru/en/' },
},
openGraph: {
title: 'Сисадмингрупп — IT-аутсорсинг',
description: 'IT-поддержка для бизнеса в Пушкино',
url: `https://sag24.ru/${lang}/`,
images: [{ url: '/og-image.png', width: 1200, height: 630 }],
},
}
}
export default async function HomePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang: langStr } = await params
const lang = langStr as Locale
const d = getDictionary(lang)
return (
<>
<Hero d={d} lang={lang} />
<ServicesGrid d={d} lang={lang} />
<AboutSection d={d} />
<ClientsSection d={d} />
<PartnersSection d={d} />
<FaqSection d={d} />
</>
)
}

View File

@@ -0,0 +1,125 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getDictionary, LOCALES, type Locale } from '@/lib/i18n'
import { SERVICE_SLUGS } from '@/lib/services'
import { Server, Shield, Headphones, Camera, Network, HardDrive, Phone, Cloud, CheckCircle2, ChevronRight } from 'lucide-react'
const ICONS = [Server, Shield, Headphones, Camera, Network, HardDrive, Phone, Cloud]
export function generateStaticParams() {
return LOCALES.flatMap(lang =>
SERVICE_SLUGS.map(slug => ({ lang, slug }))
)
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string; slug: string }> }): Promise<Metadata> {
const { lang, slug } = await params
const d = getDictionary(lang)
const svc = d.services.items.find(s => s.slug === slug)
if (!svc) return {}
return {
title: svc.title,
description: svc.description,
alternates: {
canonical: `https://sag24.ru/${lang}/uslugi/${slug}/`,
languages: {
'ru': `https://sag24.ru/ru/uslugi/${slug}/`,
'en': `https://sag24.ru/en/uslugi/${slug}/`,
},
},
openGraph: {
title: `${svc.title} | Сисадмингрупп`,
description: svc.description,
images: [{ url: '/og-image.png' }],
},
}
}
export default async function ServicePage({ params }: { params: Promise<{ lang: string; slug: string }> }) {
const { lang: langStr, slug } = await params
const lang = langStr as Locale
const d = getDictionary(lang)
const idx = d.services.items.findIndex(s => s.slug === slug)
if (idx === -1) notFound()
const svc = d.services.items[idx]
const Icon = ICONS[idx]
return (
<div className="pt-16">
<section className="py-24 bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<div className="w-16 h-16 bg-blue-500/20 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon size={32} className="text-blue-400" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">{svc.title}</h1>
<p className="text-xl text-slate-300 max-w-2xl mx-auto mb-10">{svc.description}</p>
<Link href={`/${lang}/kontakty/`}
className="inline-flex items-center gap-2 px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-colors">
{lang === 'ru' ? 'Обсудить проект' : 'Discuss project'}
<ChevronRight size={20} />
</Link>
</div>
</section>
<section className="py-16 bg-white">
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<h2 className="text-2xl font-bold text-slate-900 mb-8">
{lang === 'ru' ? 'Что включено' : 'What is included'}
</h2>
<ul className="grid md:grid-cols-2 gap-4">
{svc.points.map((point, i) => (
<li key={i} className="flex items-start gap-3 p-4 bg-slate-50 rounded-lg border border-slate-100">
<CheckCircle2 size={20} className="text-blue-600 flex-shrink-0 mt-0.5" />
<span className="text-slate-700">{point}</span>
</li>
))}
</ul>
</div>
</section>
<section className="py-16 bg-slate-50 border-t border-slate-100">
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<h2 className="text-2xl font-bold text-slate-900 mb-4">
{lang === 'ru' ? 'Нужна консультация?' : 'Need a consultation?'}
</h2>
<p className="text-slate-600 mb-8">
{lang === 'ru' ? 'Свяжитесь с нами — обсудим детали и рассчитаем стоимость.' : 'Contact us — we will discuss details and calculate the cost.'}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="tel:+74953637476" className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-colors">
+7 495 363-74-76
</a>
<Link href={`/${lang}/kontakty/`}
className="px-6 py-3 border border-slate-300 hover:border-blue-300 text-slate-700 font-semibold rounded-lg transition-colors">
{lang === 'ru' ? 'Написать нам' : 'Write to us'}
</Link>
</div>
</div>
</section>
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-8">
<Link href={`/${lang}/uslugi/`} className="text-blue-600 hover:text-blue-800 text-sm font-medium">
{lang === 'ru' ? 'Все услуги' : 'All services'}
</Link>
</div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Service',
'name': svc.title,
'description': svc.description,
'provider': {
'@type': 'Organization',
'name': 'Сисадмингрупп',
'url': 'https://sag24.ru',
},
}),
}}
/>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getDictionary, LOCALES, type Locale } from '@/lib/i18n'
import { Server, Shield, Headphones, Camera, Network, HardDrive, Phone, Cloud } from 'lucide-react'
export function generateStaticParams() {
return LOCALES.map(lang => ({ lang }))
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
return {
title: lang === 'ru' ? 'Услуги IT-аутсорсинга' : 'IT Outsourcing Services',
description: lang === 'ru' ? 'Полный спектр IT-услуг для бизнеса: аутсорсинг, безопасность, поддержка, сети, серверы.' : 'Full range of IT services for business.',
alternates: { canonical: `https://sag24.ru/${lang}/uslugi/` },
}
}
const ICONS = [Server, Shield, Headphones, Camera, Network, HardDrive, Phone, Cloud]
export default async function UslugiPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang: langStr } = await params
const lang = langStr as Locale
const d = getDictionary(lang)
return (
<div className="pt-16">
<section className="py-24 bg-slate-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="text-center mb-16">
<h1 className="text-4xl sm:text-5xl font-bold text-slate-900 mb-4">{d.services.title}</h1>
<p className="text-slate-500 text-lg max-w-2xl mx-auto">{d.services.subtitle}</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{d.services.items.map((svc, i) => {
const Icon = ICONS[i]
return (
<Link key={svc.slug} href={`/${lang}/uslugi/${svc.slug}/`}
className="bg-white border border-slate-200 rounded-xl p-8 hover:shadow-lg hover:border-blue-200 transition-all duration-300 flex flex-col gap-4">
<div className="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
<Icon size={24} className="text-blue-700" />
</div>
<h2 className="text-xl font-bold text-slate-900">{svc.title}</h2>
<p className="text-slate-600 text-sm leading-relaxed">{svc.description}</p>
<ul className="flex flex-col gap-2 mt-auto pt-4 border-t border-slate-100">
{svc.points.map((p, j) => (
<li key={j} className="flex items-center gap-2 text-sm text-slate-700">
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full flex-shrink-0" />
{p}
</li>
))}
</ul>
<span className="text-blue-600 text-sm font-semibold mt-2">
{lang === 'ru' ? 'Подробнее →' : 'Learn more →'}
</span>
</Link>
)
})}
</div>
</div>
</section>
</div>
)
}

21
src/app/globals.css Normal file
View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
}
@layer utilities {
.section-reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.section-reveal.visible {
opacity: 1;
transform: none;
}
}

37
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import { Manrope } from 'next/font/google'
import Script from 'next/script'
import './globals.css'
const manrope = Manrope({
subsets: ['latin', 'cyrillic'],
variable: '--font-manrope',
display: 'swap',
})
export const metadata: Metadata = {
metadataBase: new URL('https://sag24.ru'),
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={manrope.variable}>
<head>
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" strategy="lazyOnload" />
{/* Yandex.Metrika */}
<Script id="ym" strategy="afterInteractive">{`
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(97525679, "init", { clickmap:true, trackLinks:true, accurateTrackBounce:true, webvisor:true });
`}</Script>
</head>
<body className="font-sans antialiased">{children}</body>
</html>
)
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Root() {
redirect('/ru')
}