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,26 @@
import type { Dictionary } from '@/lib/i18n'
export default function AboutSection({ d }: { d: Dictionary }) {
const stats = [d.about.stat1, d.about.stat2, d.about.stat3]
return (
<section id="about" className="py-24 bg-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="grid md:grid-cols-2 gap-16 items-center">
<div>
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-6">{d.about.title}</h2>
<p className="text-slate-600 text-lg leading-relaxed mb-4">{d.about.text1}</p>
<p className="text-slate-600 leading-relaxed">{d.about.text2}</p>
</div>
<div className="grid grid-cols-3 gap-4">
{stats.map((s, i) => (
<div key={i} className="bg-slate-50 rounded-xl p-6 text-center border border-slate-100">
<div className="text-3xl font-bold text-blue-700 mb-1">{s.value}</div>
<div className="text-sm text-slate-500">{s.label}</div>
</div>
))}
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,21 @@
import type { Dictionary } from '@/lib/i18n'
export default function ClientsSection({ d }: { d: Dictionary }) {
return (
<section className="py-16 bg-white border-t border-slate-100">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="text-center mb-10">
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900 mb-2">{d.clients.title}</h2>
<p className="text-slate-500">{d.clients.subtitle}</p>
</div>
<div className="flex flex-wrap justify-center gap-3">
{d.clients.list.map(name => (
<span key={name} className="bg-slate-50 border border-slate-200 rounded-lg px-4 py-2 text-sm text-slate-700 hover:border-blue-200 hover:text-slate-900 transition-colors">
{name}
</span>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { Dictionary } from '@/lib/i18n'
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false)
return (
<div className="border border-slate-200 rounded-xl overflow-hidden">
<button onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-6 py-4 text-left bg-white hover:bg-slate-50 transition-colors">
<span className="font-semibold text-slate-800 pr-4">{q}</span>
<ChevronDown size={18} className={`text-slate-400 flex-shrink-0 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="px-6 py-4 bg-slate-50 border-t border-slate-200 text-slate-600 text-sm leading-relaxed">{a}</div>
)}
</div>
)
}
export default function FaqSection({ d, standalone }: { d: Dictionary; standalone?: boolean }) {
return (
<section id="faq" className={`${standalone ? 'py-24' : 'py-24 border-t border-slate-100'} bg-white`}>
<div className="max-w-3xl mx-auto px-4 sm:px-6">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">{d.faq.title}</h2>
<p className="text-slate-500 text-lg">{d.faq.subtitle}</p>
</div>
<div className="flex flex-col gap-3">
{d.faq.items.map((item, i) => (
<FaqItem key={i} q={item.q} a={item.a} />
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,36 @@
import Link from 'next/link'
import { ChevronRight, Phone } from 'lucide-react'
import type { Dictionary, Locale } from '@/lib/i18n'
export default function Hero({ d, lang }: { d: Dictionary; lang: Locale }) {
const firstPhone = d.contact.phones[0]
return (
<section className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 relative overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-blue-700 rounded-full blur-3xl" />
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-32 text-center relative z-10">
<div className="inline-flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 rounded-full px-4 py-1.5 mb-8">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
<span className="text-blue-300 text-sm font-medium">IT-аутсорсинг · Безопасность · Поддержка</span>
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-white leading-tight mb-6">{d.hero.title}</h1>
<p className="text-lg sm:text-xl text-slate-300 max-w-2xl mx-auto mb-10 leading-relaxed">{d.hero.subtitle}</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-10">
<Link href={`/${lang}/kontakty/`} className="px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-all duration-300 flex items-center justify-center gap-2 group">
{d.hero.cta}
<ChevronRight size={20} className="group-hover:translate-x-1 transition-transform" />
</Link>
<Link href={`/${lang}/uslugi/`} className="px-8 py-4 border border-white/20 hover:border-white/40 text-white font-semibold rounded-lg transition-all duration-300">
{d.hero.ctaSecondary}
</Link>
</div>
<a href={`tel:${firstPhone.replace(/\D/g,'')}`} className="inline-flex items-center gap-2 text-blue-300 hover:text-white transition-colors text-lg font-medium">
<Phone size={18} />
{firstPhone}
</a>
</div>
</section>
)
}

View File

@@ -0,0 +1,29 @@
import type { Dictionary } from '@/lib/i18n'
const PARTNERS = [
{ name: 'RU-CENTER', sub: 'Руцентр' },
{ name: 'REG.RU', sub: 'Регистратор доменов' },
{ name: 'МТВ', sub: 'Телекоммуникации' },
{ name: 'КОНТУР', sub: 'Электронная отчётность' },
]
export default function PartnersSection({ d }: { d: Dictionary }) {
return (
<section className="py-16 bg-slate-50 border-t border-slate-100">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="text-center mb-10">
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900 mb-2">{d.partners.title}</h2>
<p className="text-slate-500">{d.partners.subtitle}</p>
</div>
<div className="flex flex-wrap justify-center gap-4">
{PARTNERS.map(p => (
<div key={p.name} className="bg-white border border-slate-200 rounded-xl px-8 py-5 flex flex-col items-center gap-1 min-w-[160px] hover:border-blue-200 hover:shadow-sm transition-all duration-200">
<span className="text-xl font-bold text-slate-800 tracking-tight">{p.name}</span>
<span className="text-xs text-slate-400">{p.sub}</span>
</div>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,46 @@
import Link from 'next/link'
import { Server, Shield, Headphones, Camera, Network, HardDrive, Phone, Cloud, CheckCircle2 } from 'lucide-react'
import type { Dictionary, Locale } from '@/lib/i18n'
const ICONS = [Server, Shield, Headphones, Camera, Network, HardDrive, Phone, Cloud]
export default function ServicesGrid({ d, lang }: { d: Dictionary; lang: Locale }) {
return (
<section id="services" className="py-24 bg-slate-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-900 mb-4">{d.services.title}</h2>
<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.slice(0, 3).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>
<h3 className="text-xl font-bold text-slate-900">{svc.title}</h3>
<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">
<CheckCircle2 size={16} className="text-blue-600 flex-shrink-0" />
{p}
</li>
))}
</ul>
</Link>
)
})}
</div>
<div className="text-center mt-10">
<Link href={`/${lang}/uslugi/`} className="inline-flex items-center gap-2 px-6 py-3 border border-slate-300 hover:border-blue-300 text-slate-700 hover:text-blue-700 font-semibold rounded-lg transition-colors">
{lang === 'ru' ? 'Все услуги' : 'All services'}
</Link>
</div>
</div>
</section>
)
}