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:
28
src/app/[lang]/faq/page.tsx
Normal file
28
src/app/[lang]/faq/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/app/[lang]/kontakty/page.tsx
Normal file
83
src/app/[lang]/kontakty/page.tsx
Normal 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
34
src/app/[lang]/layout.tsx
Normal 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
48
src/app/[lang]/page.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
125
src/app/[lang]/uslugi/[slug]/page.tsx
Normal file
125
src/app/[lang]/uslugi/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
src/app/[lang]/uslugi/page.tsx
Normal file
63
src/app/[lang]/uslugi/page.tsx
Normal 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
21
src/app/globals.css
Normal 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
37
src/app/layout.tsx
Normal 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
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Root() {
|
||||
redirect('/ru')
|
||||
}
|
||||
Reference in New Issue
Block a user