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:
26
src/components/sections/AboutSection.tsx
Normal file
26
src/components/sections/AboutSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/components/sections/ClientsSection.tsx
Normal file
21
src/components/sections/ClientsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/components/sections/FaqSection.tsx
Normal file
38
src/components/sections/FaqSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/sections/Hero.tsx
Normal file
36
src/components/sections/Hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/components/sections/PartnersSection.tsx
Normal file
29
src/components/sections/PartnersSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/components/sections/ServicesGrid.tsx
Normal file
46
src/components/sections/ServicesGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user