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,69 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { usePathname, useRouter } from 'next/navigation'
import { Menu, X } from 'lucide-react'
import { getDictionary, type Locale } from '@/lib/i18n'
export default function Header({ lang }: { lang: string }) {
const [menuOpen, setMenuOpen] = useState(false)
const pathname = usePathname()
const router = useRouter()
const d = getDictionary(lang)
const links = [
{ label: d.nav.services, href: `/${lang}/uslugi/` },
{ label: d.nav.about, href: `/${lang}/#about` },
{ label: d.nav.faq, href: `/${lang}/faq/` },
]
function toggleLang() {
const newLang = lang === 'ru' ? 'en' : 'ru'
const newPath = pathname.replace(`/${lang}`, `/${newLang}`)
router.push(newPath)
}
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-white shadow-sm">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<Link href={`/${lang}/`} className="flex items-center">
<Image src="/logo.png" alt="Сисадмингрупп" width={48} height={48} className="h-12 w-auto" />
</Link>
<nav className="hidden md:flex items-center gap-8">
{links.map(l => (
<Link key={l.href} href={l.href} className="text-sm font-medium text-slate-600 hover:text-blue-700 transition-colors">
{l.label}
</Link>
))}
<button onClick={toggleLang} className="text-sm font-semibold text-slate-500 hover:text-blue-700 transition-colors border border-slate-200 rounded px-2 py-0.5">
{lang === 'ru' ? 'EN' : 'RU'}
</button>
<Link href={`/${lang}/kontakty/`} className="px-4 py-2 bg-blue-700 hover:bg-blue-800 text-white text-sm font-semibold rounded transition-colors">
{d.nav.contact}
</Link>
</nav>
<div className="md:hidden flex items-center gap-3">
<button onClick={toggleLang} className="text-sm font-semibold text-slate-500 border border-slate-200 rounded px-2 py-0.5">
{lang === 'ru' ? 'EN' : 'RU'}
</button>
<button onClick={() => setMenuOpen(!menuOpen)} className="p-1 text-slate-700">
{menuOpen ? <X size={22} /> : <Menu size={22} />}
</button>
</div>
</div>
{menuOpen && (
<div className="md:hidden bg-white border-t border-slate-100 px-4 py-4 flex flex-col gap-4">
{links.map(l => (
<Link key={l.href} href={l.href} onClick={() => setMenuOpen(false)} className="text-sm font-medium text-slate-700 hover:text-blue-700">
{l.label}
</Link>
))}
<Link href={`/${lang}/kontakty/`} onClick={() => setMenuOpen(false)} className="text-sm font-medium text-blue-700">
{d.nav.contact}
</Link>
</div>
)}
</header>
)
}