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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
.next/
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
151
index.html
151
index.html
@@ -1,151 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#1d4ed8" />
|
||||
|
||||
<!-- Preload critical fonts -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/assets/manrope-cyrillic-700-normal-2ad647b9.woff2" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary -->
|
||||
<title>Сисадмингрупп — IT-решения для бизнеса</title>
|
||||
<meta name="description" content="Сисадмингрупп — IT-аутсорсинг, кибербезопасность и техническая поддержка для бизнеса в Пушкино и Московской области. Поддержка 24/7, время реакции от 15 минут." />
|
||||
<meta name="keywords" content="IT аутсорсинг Пушкино, системный администратор, техподдержка, кибербезопасность, обслуживание компьютеров, IT компания Пушкино" />
|
||||
<link rel="canonical" href="https://sag24.ru/" />
|
||||
|
||||
<!-- hreflang -->
|
||||
<link rel="alternate" hreflang="ru" href="https://sag24.ru/" />
|
||||
<link rel="alternate" hreflang="en" href="https://sag24.ru/" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://sag24.ru/" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://sag24.ru/" />
|
||||
<meta property="og:site_name" content="Сисадмингрупп" />
|
||||
<meta property="og:title" content="Сисадмингрупп — IT-решения для бизнеса" />
|
||||
<meta property="og:description" content="IT-аутсорсинг, кибербезопасность и техническая поддержка для бизнеса в Пушкино и Московской области. Поддержка 24/7, время реакции от 15 минут." />
|
||||
<meta property="og:image" content="https://sag24.ru/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Сисадмингрупп — IT-решения для бизнеса" />
|
||||
<meta name="twitter:description" content="IT-аутсорсинг, кибербезопасность и техническая поддержка в Пушкино." />
|
||||
<meta name="twitter:image" content="https://sag24.ru/og-image.png" />
|
||||
|
||||
<!-- IndexNow -->
|
||||
<meta name="indexnow-key" content="40c65b722891b81d944f2c3fea6cab95" />
|
||||
|
||||
<!-- JSON-LD: LocalBusiness + Organization -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "LocalBusiness",
|
||||
"@id": "https://sag24.ru/#business",
|
||||
"name": "Сисадмингрупп",
|
||||
"alternateName": "SysadminGroup",
|
||||
"description": "IT-аутсорсинг, кибербезопасность и техническая поддержка для бизнеса в Пушкино и Московской области.",
|
||||
"url": "https://sag24.ru",
|
||||
"logo": "https://sag24.ru/logo.png",
|
||||
"image": "https://sag24.ru/og-image.png",
|
||||
"telephone": ["+74953637476", "+74953637335", "+79099454456"],
|
||||
"email": "info@sag24.ru",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "пр-кт Московский, д. 38/14",
|
||||
"addressLocality": "Пушкино",
|
||||
"addressRegion": "Московская область",
|
||||
"postalCode": "141207",
|
||||
"addressCountry": "RU"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 56.0094,
|
||||
"longitude": 37.8572
|
||||
},
|
||||
"openingHoursSpecification": {
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],
|
||||
"opens": "00:00",
|
||||
"closes": "23:59"
|
||||
},
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "IT-услуги",
|
||||
"itemListElement": [
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "IT-аутсорсинг"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Кибербезопасность"}},
|
||||
{"@type": "Offer", "itemOffered": {"@type": "Service", "name": "Техническая поддержка"}}
|
||||
]
|
||||
},
|
||||
"priceRange": "$$",
|
||||
"areaServed": {
|
||||
"@type": "GeoCircle",
|
||||
"geoMidpoint": {"@type": "GeoCoordinates", "latitude": 56.0094, "longitude": 37.8572},
|
||||
"geoRadius": "50000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": "https://sag24.ru/#organization",
|
||||
"name": "Сисадмингрупп",
|
||||
"url": "https://sag24.ru",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://sag24.ru/logo.png"
|
||||
},
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": ["+74953637476", "+74953637335", "+79099454456"],
|
||||
"contactType": "customer service",
|
||||
"availableLanguage": ["Russian", "English"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://sag24.ru/#website",
|
||||
"url": "https://sag24.ru",
|
||||
"name": "Сисадмингрупп",
|
||||
"inLanguage": ["ru", "en"]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Cloudflare Turnstile -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-C9J0D8FFH3"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-C9J0D8FFH3');
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(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(13027513,'init',{clickmap:true,trackLinks:true,accurateTrackBounce:true,webvisor:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/13027513" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
</body>
|
||||
</html>
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
10
next.config.ts
Normal file
10
next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
distDir: process.env.BUILD_DIR || 'out',
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
1344
package-lock.json
generated
1344
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"name": "sag24-website",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist/server && node scripts/prerender.mjs",
|
||||
"deploy": "BUILD_DIR=../public_html vite build && vite build --ssr src/entry-server.jsx --outDir dist/server && BUILD_DIR=../public_html node scripts/prerender.mjs",
|
||||
"preview": "vite preview"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"deploy": "BUILD_DIR=../public_html next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"lucide-react": "^0.263.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"next": "^15.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"lucide-react": "^0.263.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"typescript": "^5",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"vite": "^4.4.0"
|
||||
"tailwindcss": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import sharp from 'sharp'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = resolve(__dirname, '../public')
|
||||
|
||||
// og-image: SVG → PNG 1200×630
|
||||
const svgBuffer = readFileSync(resolve(publicDir, 'og-image.svg'))
|
||||
await sharp(svgBuffer)
|
||||
.resize(1200, 630)
|
||||
.png({ quality: 90 })
|
||||
.toFile(resolve(publicDir, 'og-image.png'))
|
||||
console.log('✓ og-image.png (1200×630)')
|
||||
|
||||
// apple-touch-icon: logo.png → 180×180
|
||||
await sharp(resolve(publicDir, 'logo.png'))
|
||||
.resize(180, 180, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } })
|
||||
.png()
|
||||
.toFile(resolve(publicDir, 'apple-touch-icon.png'))
|
||||
console.log('✓ apple-touch-icon.png (180×180)')
|
||||
|
||||
// favicon.ico: logo.png → multi-size ICO (16, 32, 48)
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
const icoSizes = [16, 32, 48]
|
||||
const pngBuffers = await Promise.all(
|
||||
icoSizes.map(size =>
|
||||
sharp(resolve(publicDir, 'logo.png'))
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer()
|
||||
)
|
||||
)
|
||||
|
||||
// Build ICO: header + directory + image data
|
||||
const count = icoSizes.length
|
||||
const headerSize = 6
|
||||
const dirEntrySize = 16
|
||||
const dirSize = count * dirEntrySize
|
||||
let offset = headerSize + dirSize
|
||||
|
||||
const header = Buffer.alloc(headerSize)
|
||||
header.writeUInt16LE(0, 0) // reserved
|
||||
header.writeUInt16LE(1, 2) // type: 1 = ICO
|
||||
header.writeUInt16LE(count, 4)
|
||||
|
||||
const dirs = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const dir = Buffer.alloc(dirEntrySize)
|
||||
const size = icoSizes[i]
|
||||
dir.writeUInt8(size === 256 ? 0 : size, 0) // width
|
||||
dir.writeUInt8(size === 256 ? 0 : size, 1) // height
|
||||
dir.writeUInt8(0, 2) // color count
|
||||
dir.writeUInt8(0, 3) // reserved
|
||||
dir.writeUInt16LE(1, 4) // color planes
|
||||
dir.writeUInt16LE(32, 6) // bits per pixel
|
||||
dir.writeUInt32LE(pngBuffers[i].length, 8)
|
||||
dir.writeUInt32LE(offset, 12)
|
||||
offset += pngBuffers[i].length
|
||||
dirs.push(dir)
|
||||
}
|
||||
|
||||
const ico = Buffer.concat([header, ...dirs, ...pngBuffers])
|
||||
writeFileSync(resolve(publicDir, 'favicon.ico'), ico)
|
||||
console.log('✓ favicon.ico (16×16, 32×32, 48×48)')
|
||||
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync, rmSync, readdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(__dirname, '..')
|
||||
const buildDir = process.env.BUILD_DIR || 'dist'
|
||||
|
||||
// Import SSR bundle (always built to dist/server inside project)
|
||||
const { render } = await import('../dist/server/entry-server.js')
|
||||
|
||||
let html = readFileSync(resolve(root, buildDir, 'index.html'), 'utf-8')
|
||||
|
||||
// Inject prerendered HTML
|
||||
const appHtml = render()
|
||||
html = html.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`)
|
||||
|
||||
// Fix preload font: find actual cyrillic-700 woff2 hash in dist/assets
|
||||
const assetsDir = resolve(root, buildDir, 'assets')
|
||||
const fontFile = readdirSync(assetsDir).find(f => f.match(/manrope-cyrillic-700-normal-.+\.woff2/))
|
||||
if (fontFile) {
|
||||
html = html.replace(
|
||||
/href="\/assets\/manrope-cyrillic-700-normal-[^"]+\.woff2"/,
|
||||
`href="/assets/${fontFile}"`
|
||||
)
|
||||
console.log(`✓ Preload font updated: ${fontFile}`)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, buildDir, 'index.html'), html)
|
||||
|
||||
// Cleanup SSR bundle
|
||||
rmSync(resolve(root, 'dist/server'), { recursive: true, force: true })
|
||||
|
||||
console.log('✓ Prerendered index.html')
|
||||
43
src/App.jsx
43
src/App.jsx
@@ -1,43 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext.jsx'
|
||||
import Navigation from './components/Navigation.jsx'
|
||||
import Footer from './components/Footer.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import NotFound from './pages/NotFound.jsx'
|
||||
import FloatingContacts from './components/FloatingContacts.jsx'
|
||||
import ScrollToTop from './components/ScrollToTop.jsx'
|
||||
|
||||
function LangSync() {
|
||||
const { lang } = useLanguage()
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang
|
||||
}, [lang])
|
||||
return null
|
||||
}
|
||||
|
||||
function Router() {
|
||||
const [path, setPath] = useState(typeof window !== 'undefined' ? window.location.pathname : '/')
|
||||
useEffect(() => {
|
||||
const handler = () => setPath(window.location.pathname)
|
||||
window.addEventListener('popstate', handler)
|
||||
return () => window.removeEventListener('popstate', handler)
|
||||
}, [])
|
||||
|
||||
const isHome = path === '/' || path === '/index.html'
|
||||
return isHome ? <Home /> : <NotFound />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<LangSync />
|
||||
<Navigation />
|
||||
<main>
|
||||
<Router />
|
||||
</main>
|
||||
<Footer />
|
||||
<FloatingContacts />
|
||||
<ScrollToTop />
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
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')
|
||||
}
|
||||
31
src/components/layout/Footer.tsx
Normal file
31
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { getDictionary } from '@/lib/i18n'
|
||||
|
||||
export default function Footer({ lang }: { lang: string }) {
|
||||
const d = getDictionary(lang)
|
||||
const year = `2011 – ${new Date().getFullYear()}`
|
||||
return (
|
||||
<footer className="bg-slate-900 text-slate-400">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-10 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<Link href={`/${lang}/`}>
|
||||
<Image src="/logo.png" alt="Сисадмингрупп" width={40} height={40} className="h-10 w-auto brightness-0 invert" />
|
||||
</Link>
|
||||
<span className="text-sm">{year} © {d.footer.rights}</span>
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm">
|
||||
{d.contact.phones.map((p, i) => (
|
||||
<a key={i} href={`tel:${p.replace(/\D/g,'')}`} className="hover:text-white transition-colors">{p}</a>
|
||||
))}
|
||||
<a href="mailto:info@sag24.ru" className="hover:text-white transition-colors">info@sag24.ru</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-800">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 text-xs text-slate-600 leading-relaxed">
|
||||
<p className="mb-1">ООО «Сисадмингрупп» · ИНН 5038080741 · КПП 503801001 · ОГРН 1115038000890</p>
|
||||
<p className="mb-1">Юридический адрес: 141207, Россия, Московская область, г. Пушкино, пр-кт Московский, д. 38/14, кв. 33</p>
|
||||
<p>Р/с 40702810510000179731 · АО «ТБанк» · БИК 044525974 · К/с 30101810145250000974</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
69
src/components/layout/Header.tsx
Normal file
69
src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
63
src/components/ui/ContactForm.tsx
Normal file
63
src/components/ui/ContactForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import type { Dictionary } from '@/lib/i18n'
|
||||
|
||||
const TURNSTILE_SITE_KEY = '0x4AAAAAACrQS-dAb7E9RGPQ'
|
||||
const FORM_ENDPOINT = '/api/contact.php'
|
||||
|
||||
export default function ContactForm({ d }: { d: Dictionary }) {
|
||||
const [formState, setFormState] = useState({ name: '', company: '', email: '', phone: '', message: '' })
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSending(true)
|
||||
setError('')
|
||||
try {
|
||||
const turnstileToken = (document.querySelector('[name="cf-turnstile-response"]') as HTMLInputElement)?.value || ''
|
||||
const res = await fetch(FORM_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ ...formState, turnstileToken }),
|
||||
})
|
||||
if (res.ok) { setSubmitted(true) } else { setError(d.contact.formError) }
|
||||
} catch { setError(d.contact.formError) }
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const inputClass = "w-full bg-white/10 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500 transition-colors text-sm"
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-8">
|
||||
{submitted ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 py-8">
|
||||
<CheckCircle2 size={48} className="text-green-400" />
|
||||
<p className="text-white text-center text-lg">{d.contact.formSuccess}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input type="text" required placeholder={d.contact.formName} value={formState.name}
|
||||
onChange={e => setFormState({ ...formState, name: e.target.value })} className={inputClass} />
|
||||
<input type="text" placeholder={d.contact.formCompany} value={formState.company}
|
||||
onChange={e => setFormState({ ...formState, company: e.target.value })} className={inputClass} />
|
||||
<input type="email" placeholder={d.contact.formEmail} value={formState.email}
|
||||
onChange={e => setFormState({ ...formState, email: e.target.value })} className={inputClass} />
|
||||
<input type="tel" placeholder={d.contact.formPhone} value={formState.phone}
|
||||
onChange={e => setFormState({ ...formState, phone: e.target.value })} className={inputClass} />
|
||||
<textarea required rows={4} placeholder={d.contact.formMessage} value={formState.message}
|
||||
onChange={e => setFormState({ ...formState, message: e.target.value })}
|
||||
className={`${inputClass} resize-none`} />
|
||||
<div className="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY} data-theme="dark" />
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button type="submit" disabled={sending}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-60 text-white font-semibold rounded-lg transition-colors">
|
||||
{sending ? d.contact.formSending : d.contact.formSubmit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/components/ui/FloatingContacts.tsx
Normal file
20
src/components/ui/FloatingContacts.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export default function FloatingContacts() {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3">
|
||||
<a href="https://t.me/sag24ru" target="_blank" rel="noopener noreferrer" aria-label="Написать в Telegram"
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center shadow-lg transition-transform hover:scale-110"
|
||||
style={{ background: '#2AABEE' }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.93 6.77-1.9 8.96c-.14.62-.5.77-.99.48l-2.76-2.03-1.33 1.28c-.15.15-.27.27-.55.27l.2-2.82 5.1-4.61c.22-.2-.05-.31-.34-.11l-6.31 3.97-2.72-.85c-.59-.18-.6-.59.12-.87l10.63-4.1c.49-.18.93.12.75.83z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://wa.me/74953637476" target="_blank" rel="noopener noreferrer" aria-label="Написать в WhatsApp"
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center shadow-lg transition-transform hover:scale-110"
|
||||
style={{ background: '#25D366' }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/ui/ScrollToTop.tsx
Normal file
19
src/components/ui/ScrollToTop.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > 400)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
if (!visible) return null
|
||||
return (
|
||||
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} aria-label="Наверх"
|
||||
className="fixed bottom-6 left-6 z-50 w-10 h-10 rounded-full bg-slate-800/80 hover:bg-slate-700 text-white flex items-center justify-center shadow-lg transition-all hover:scale-110">
|
||||
<ChevronUp size={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Настройки для связи и формы
|
||||
export const WHATSAPP_PHONE = '74953637476' // без +
|
||||
export const TELEGRAM_USERNAME = 'sag24ru' // @username без @
|
||||
export const FORM_ENDPOINT = '/api/contact.php'
|
||||
export const EMAIL = 'info@sag24.ru'
|
||||
|
||||
// Cloudflare Turnstile — создать виджет: dash.cloudflare.com → Turnstile → Add site (sag24.ru)
|
||||
export const TURNSTILE_SITE_KEY = '0x4AAAAAACrQS-dAb7E9RGPQ'
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
import { ru, en } from '../translations/index.js'
|
||||
|
||||
const translations = { ru, en }
|
||||
|
||||
const LanguageContext = createContext(null)
|
||||
|
||||
export function LanguageProvider({ children }) {
|
||||
const [lang, setLang] = useState(() => {
|
||||
return (typeof localStorage !== 'undefined' && localStorage.getItem('lang')) || 'ru'
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
const next = lang === 'ru' ? 'en' : 'ru'
|
||||
setLang(next)
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem('lang', next)
|
||||
}
|
||||
|
||||
const t = (key) => {
|
||||
const keys = key.split('.')
|
||||
let value = translations[lang]
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
}
|
||||
return value ?? key
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ lang, toggle, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(LanguageContext)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import App from './App.jsx'
|
||||
|
||||
export function render() {
|
||||
return renderToString(<App />)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
@import '@fontsource/manrope/400.css';
|
||||
@import '@fontsource/manrope/500.css';
|
||||
@import '@fontsource/manrope/600.css';
|
||||
@import '@fontsource/manrope/700.css';
|
||||
@import '@fontsource/manrope/800.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
@apply bg-white text-slate-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.section-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
.section-reveal.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
14
src/lib/i18n.ts
Normal file
14
src/lib/i18n.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ru } from '@/locales/ru'
|
||||
import { en } from '@/locales/en'
|
||||
|
||||
export type Locale = 'ru' | 'en'
|
||||
export const LOCALES: Locale[] = ['ru', 'en']
|
||||
export const DEFAULT_LOCALE: Locale = 'ru'
|
||||
|
||||
const dictionaries = { ru, en }
|
||||
|
||||
export function getDictionary(lang: string) {
|
||||
return dictionaries[lang as Locale] ?? dictionaries.ru
|
||||
}
|
||||
|
||||
export type Dictionary = ReturnType<typeof getDictionary>
|
||||
29
src/lib/services.ts
Normal file
29
src/lib/services.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDictionary, type Locale } from './i18n'
|
||||
|
||||
export interface Service {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
points: readonly string[]
|
||||
}
|
||||
|
||||
export function getServices(lang: Locale): Service[] {
|
||||
const d = getDictionary(lang)
|
||||
return d.services.items.map(item => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
points: item.points,
|
||||
}))
|
||||
}
|
||||
|
||||
export const SERVICE_SLUGS = [
|
||||
'it-autsorsing',
|
||||
'kiberbezopasnost',
|
||||
'tehpodderzhka',
|
||||
'videonablyudenie',
|
||||
'seti',
|
||||
'servery',
|
||||
'telefoniya',
|
||||
'oblako',
|
||||
] as const
|
||||
124
src/locales/en.ts
Normal file
124
src/locales/en.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export const en = {
|
||||
nav: {
|
||||
services: 'Services',
|
||||
about: 'About',
|
||||
faq: 'FAQ',
|
||||
contact: 'Contact',
|
||||
},
|
||||
hero: {
|
||||
title: 'IT Solutions for Your Business',
|
||||
subtitle: 'We take care of your entire IT infrastructure — from workstation support to data protection',
|
||||
cta: 'Discuss a Project',
|
||||
ctaSecondary: 'Learn More',
|
||||
},
|
||||
services: {
|
||||
title: 'Our Services',
|
||||
subtitle: 'Comprehensive turnkey IT infrastructure management',
|
||||
items: [
|
||||
{
|
||||
slug: 'it-autsorsing',
|
||||
title: 'IT Outsourcing',
|
||||
description: 'Full management of your IT infrastructure: servers, networks, workstations. Fast response, results-driven.',
|
||||
points: ['24/7 support', 'Remote and on-site service', 'Fixed monthly cost'],
|
||||
},
|
||||
{
|
||||
slug: 'kiberbezopasnost',
|
||||
title: 'Cybersecurity',
|
||||
description: 'Corporate data protection, security audits, firewall and antivirus configuration.',
|
||||
points: ['Vulnerability audit & analysis', 'Firewall & VPN setup', 'Data loss prevention'],
|
||||
},
|
||||
{
|
||||
slug: 'tehpodderzhka',
|
||||
title: 'Technical Support',
|
||||
description: 'Fast resolution of employee technical issues. Help desk, ticket management, user training.',
|
||||
points: ['Help desk & ticketing', 'User training', 'SLA from 15 minutes'],
|
||||
},
|
||||
{
|
||||
slug: 'videonablyudenie',
|
||||
title: 'Video Surveillance',
|
||||
description: 'Design and installation of video surveillance systems. IP cameras, cloud storage, remote viewing.',
|
||||
points: ['Design and installation', 'IP cameras and NVR', 'Remote access'],
|
||||
},
|
||||
{
|
||||
slug: 'seti',
|
||||
title: 'Network Infrastructure',
|
||||
description: 'Design, installation and maintenance of corporate networks. Wi-Fi, VPN, routing.',
|
||||
points: ['Network design', 'Wi-Fi and VPN', 'Managed switches'],
|
||||
},
|
||||
{
|
||||
slug: 'servery',
|
||||
title: 'Server Infrastructure',
|
||||
description: 'Supply, configuration and maintenance of servers. Virtualization, backup solutions.',
|
||||
points: ['VMware/Hyper-V virtualization', 'Backup solutions', '24/7 monitoring'],
|
||||
},
|
||||
{
|
||||
slug: 'telefoniya',
|
||||
title: 'IP Telephony',
|
||||
description: 'Office PBX setup, SIP trunks, CRM integration. Asterisk, FreePBX, cloud solutions.',
|
||||
points: ['Office PBX (Asterisk)', 'SIP trunks', 'CRM integration'],
|
||||
},
|
||||
{
|
||||
slug: 'oblako',
|
||||
title: 'Cloud Solutions',
|
||||
description: 'Cloud migration, Microsoft 365 and Google Workspace setup, cloud storage.',
|
||||
points: ['Microsoft 365', 'Google Workspace', 'Cloud storage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
about: {
|
||||
title: 'About the Company',
|
||||
text1: 'SysadminGroup is an IT company from Pushkino with many years of experience serving small and medium businesses. We take care of the entire IT infrastructure so you can focus on your business.',
|
||||
text2: 'Our team consists of certified specialists with expertise in network technologies, information security, and system administration.',
|
||||
stat1: { value: '10+', label: 'years on the market' },
|
||||
stat2: { value: '150+', label: 'clients' },
|
||||
stat3: { value: '15 min', label: 'response time' },
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us',
|
||||
subtitle: 'Tell us about your task — we will suggest a solution',
|
||||
phones: ['+7 495 363-74-76', '+7 495 363-73-35', '+7 909 945-44-56'],
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
address: 'Address',
|
||||
addressValue: 'Moscow Region, Pushkino, 38/14',
|
||||
formName: 'Your name',
|
||||
formCompany: 'Company',
|
||||
formEmail: 'Your email',
|
||||
formMessage: 'Describe your task',
|
||||
formPhone: 'Your phone',
|
||||
formSubmit: 'Send Request',
|
||||
formSending: 'Sending...',
|
||||
formSuccess: 'Request sent! We will contact you shortly.',
|
||||
formError: 'Sending error. Please call us directly.',
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions',
|
||||
subtitle: 'Answers to common questions about IT outsourcing',
|
||||
items: [
|
||||
{ q: 'How much does IT outsourcing cost?', a: 'The cost depends on the number of workstations and the set of services. For 5–10 workstations — from 15,000 RUB/month. Contact us for an accurate quote.' },
|
||||
{ q: 'How quickly do you respond to issues?', a: 'SLA from 15 minutes for remote tasks. On-site specialist within 2–4 hours in Pushkino and nearby cities.' },
|
||||
{ q: 'Do you work outside of Pushkino?', a: 'Yes, we serve companies throughout the Moscow Region. Remote support — with no geographic restrictions.' },
|
||||
{ q: 'What is included in the service contract?', a: 'The list of services is fixed in the SLA: 24/7 tech support, infrastructure monitoring, remote and on-site service, antivirus protection.' },
|
||||
{ q: 'Do we need to interrupt operations when onboarding?', a: 'No. Onboarding takes 1–2 days and does not require stopping business processes.' },
|
||||
{ q: 'Do you only work with small businesses?', a: 'We serve companies from 3 to 200+ workstations, including government agencies and manufacturing enterprises.' },
|
||||
],
|
||||
},
|
||||
partners: {
|
||||
title: 'Partners',
|
||||
subtitle: 'We work with reliable suppliers and vendors',
|
||||
},
|
||||
clients: {
|
||||
title: 'Trusted by',
|
||||
subtitle: 'Companies that chose SysadminGroup',
|
||||
list: [
|
||||
'ООО «Бастион-Сервис»', 'АО «Стифтер Хаус»', 'ООО «Вэйстроймастер»',
|
||||
'ООО «Комплекс»', 'ООО «Пит Стоп»', 'ООО «Астида-М»',
|
||||
'ФАУ ДПО ВИПКЛХ', 'ООО «Грейт Групп»', 'АО «Дельта»',
|
||||
'ООО «Глобал»', 'ООО «Навант»', 'ООО «ТД Адонит»',
|
||||
'ООО «Экопродукт»', 'ООО «Астида»', 'ДПК «Сосновый бор»',
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
rights: 'All rights reserved.',
|
||||
},
|
||||
} as const
|
||||
@@ -19,16 +19,49 @@ export const ru = {
|
||||
title: 'IT-аутсорсинг',
|
||||
description: 'Полное обслуживание вашей IT-инфраструктуры: серверы, сети, рабочие станции. Реагируем быстро, работаем на результат.',
|
||||
points: ['Поддержка 24/7', 'Удалённое и выездное обслуживание', 'Фиксированная стоимость'],
|
||||
slug: 'it-autsorsing',
|
||||
},
|
||||
{
|
||||
title: 'Кибербезопасность',
|
||||
description: 'Защита корпоративных данных, аудит безопасности, настройка межсетевых экранов и антивирусной защиты.',
|
||||
points: ['Аудит и анализ уязвимостей', 'Настройка Firewall и VPN', 'Защита от утечек данных'],
|
||||
slug: 'kiberbezopasnost',
|
||||
},
|
||||
{
|
||||
title: 'Техническая поддержка',
|
||||
description: 'Оперативное решение технических проблем сотрудников. Help desk, управление заявками, обучение пользователей.',
|
||||
points: ['Help desk и тикет-система', 'Обучение пользователей', 'SLA от 15 минут'],
|
||||
slug: 'tehpodderzhka',
|
||||
},
|
||||
{
|
||||
title: 'Видеонаблюдение',
|
||||
description: 'Проектирование и монтаж систем видеонаблюдения. IP-камеры, облачное хранение, удалённый просмотр.',
|
||||
points: ['Проектирование и монтаж', 'IP-камеры и NVR', 'Удалённый доступ'],
|
||||
slug: 'videonablyudenie',
|
||||
},
|
||||
{
|
||||
title: 'Сетевая инфраструктура',
|
||||
description: 'Проектирование, монтаж и обслуживание корпоративных сетей. Wi-Fi, VPN, маршрутизация.',
|
||||
points: ['Проектирование сети', 'Wi-Fi и VPN', 'Управляемые коммутаторы'],
|
||||
slug: 'seti',
|
||||
},
|
||||
{
|
||||
title: 'Серверная инфраструктура',
|
||||
description: 'Поставка, настройка и обслуживание серверов. Виртуализация, резервное копирование.',
|
||||
points: ['Виртуализация VMware/Hyper-V', 'Резервное копирование', 'Мониторинг 24/7'],
|
||||
slug: 'servery',
|
||||
},
|
||||
{
|
||||
title: 'IP-телефония',
|
||||
description: 'Настройка офисных АТС, SIP-транков, интеграция с CRM. Asterisk, FreePBX, облачные решения.',
|
||||
points: ['Офисные АТС (Asterisk)', 'SIP-транки', 'Интеграция с CRM'],
|
||||
slug: 'telefoniya',
|
||||
},
|
||||
{
|
||||
title: 'Облачные решения',
|
||||
description: 'Миграция в облако, настройка Microsoft 365, Google Workspace, облачные хранилища.',
|
||||
points: ['Microsoft 365', 'Google Workspace', 'Облачное хранение данных'],
|
||||
slug: 'oblako',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -62,30 +95,12 @@ export const ru = {
|
||||
title: 'Частые вопросы',
|
||||
subtitle: 'Ответы на типовые вопросы об IT-аутсорсинге',
|
||||
items: [
|
||||
{
|
||||
q: 'Сколько стоит IT-аутсорсинг?',
|
||||
a: 'Стоимость зависит от количества рабочих мест и состава услуг. Для 5–10 рабочих мест — от 15 000 руб./мес. Свяжитесь с нами для точного расчёта.',
|
||||
},
|
||||
{
|
||||
q: 'Как быстро вы реагируете на проблемы?',
|
||||
a: 'SLA от 15 минут для удалённых задач. Выезд специалиста в течение 2–4 часов по Пушкино и ближайшим городам.',
|
||||
},
|
||||
{
|
||||
q: 'Работаете ли вы за пределами Пушкино?',
|
||||
a: 'Да, обслуживаем компании по всей Московской области. Удалённая поддержка — без ограничений по географии.',
|
||||
},
|
||||
{
|
||||
q: 'Что входит в договор на обслуживание?',
|
||||
a: 'Перечень услуг фиксируется в SLA: техподдержка 24/7, мониторинг инфраструктуры, удалённое и выездное обслуживание, антивирусная защита.',
|
||||
},
|
||||
{
|
||||
q: 'Нужно ли прерывать работу при подключении?',
|
||||
a: 'Нет. Подключение к обслуживанию занимает 1–2 дня и не требует остановки бизнес-процессов.',
|
||||
},
|
||||
{
|
||||
q: 'Вы работаете только с малым бизнесом?',
|
||||
a: 'Обслуживаем компании от 3 до 200+ рабочих мест, включая государственные учреждения и производственные предприятия.',
|
||||
},
|
||||
{ q: 'Сколько стоит IT-аутсорсинг?', a: 'Стоимость зависит от количества рабочих мест и состава услуг. Для 5–10 рабочих мест — от 15 000 руб./мес. Свяжитесь с нами для точного расчёта.' },
|
||||
{ q: 'Как быстро вы реагируете на проблемы?', a: 'SLA от 15 минут для удалённых задач. Выезд специалиста в течение 2–4 часов по Пушкино и ближайшим городам.' },
|
||||
{ q: 'Работаете ли вы за пределами Пушкино?', a: 'Да, обслуживаем компании по всей Московской области. Удалённая поддержка — без ограничений по географии.' },
|
||||
{ q: 'Что входит в договор на обслуживание?', a: 'Перечень услуг фиксируется в SLA: техподдержка 24/7, мониторинг инфраструктуры, удалённое и выездное обслуживание, антивирусная защита.' },
|
||||
{ q: 'Нужно ли прерывать работу при подключении?', a: 'Нет. Подключение к обслуживанию занимает 1–2 дня и не требует остановки бизнес-процессов.' },
|
||||
{ q: 'Вы работаете только с малым бизнесом?', a: 'Обслуживаем компании от 3 до 200+ рабочих мест, включая государственные учреждения и производственные предприятия.' },
|
||||
],
|
||||
},
|
||||
partners: {
|
||||
@@ -95,8 +110,15 @@ export const ru = {
|
||||
clients: {
|
||||
title: 'Нам доверяют',
|
||||
subtitle: 'Компании, которые выбрали Сисадмингрупп',
|
||||
list: [
|
||||
'ООО «Бастион-Сервис»', 'АО «Стифтер Хаус»', 'ООО «Вэйстроймастер»',
|
||||
'ООО «Комплекс»', 'ООО «Пит Стоп»', 'ООО «Астида-М»',
|
||||
'ФАУ ДПО ВИПКЛХ', 'ООО «Грейт Групп»', 'АО «Дельта»',
|
||||
'ООО «Глобал»', 'ООО «Навант»', 'ООО «ТД Адонит»',
|
||||
'ООО «Экопродукт»', 'ООО «Астида»', 'ДПК «Сосновый бор»',
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
rights: 'Все права защищены.',
|
||||
},
|
||||
}
|
||||
} as const
|
||||
10
src/main.jsx
10
src/main.jsx
@@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -1,365 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Server, Shield, Headphones, Phone, Mail, MapPin, CheckCircle2, ChevronDown } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext.jsx'
|
||||
import { useReveal } from '../components/useReveal.js'
|
||||
import { FORM_ENDPOINT, EMAIL, TURNSTILE_SITE_KEY } from '../config.js'
|
||||
|
||||
const serviceIcons = [Server, Shield, Headphones]
|
||||
|
||||
function ServiceCard({ icon: Icon, title, description, points }) {
|
||||
const ref = useReveal()
|
||||
return (
|
||||
<div ref={ref} className="section-reveal 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">{title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{description}</p>
|
||||
<ul className="flex flex-col gap-2 mt-auto pt-4 border-t border-slate-100">
|
||||
{points.map((p, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<CheckCircle2 size={16} className="text-blue-600 flex-shrink-0" />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FaqItem({ q, a }) {
|
||||
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 Home() {
|
||||
const { t } = useLanguage()
|
||||
const [formState, setFormState] = useState({ name: '', company: '', email: '', phone: '', message: '' })
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const aboutRef = useReveal()
|
||||
const contactRef = useReveal()
|
||||
const faqRef = useReveal()
|
||||
|
||||
const services = t('services.items')
|
||||
const stats = [t('about.stat1'), t('about.stat2'), t('about.stat3')]
|
||||
const faqItems = t('faq.items')
|
||||
const firstPhone = t('contact.phones')[0]
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setSending(true)
|
||||
setError('')
|
||||
|
||||
if (FORM_ENDPOINT) {
|
||||
try {
|
||||
const turnstileToken = document.querySelector('[name="cf-turnstile-response"]')?.value || ''
|
||||
const res = await fetch(FORM_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ ...formState, turnstileToken }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setSubmitted(true)
|
||||
} else {
|
||||
setError(t('contact.formError'))
|
||||
}
|
||||
} catch {
|
||||
setError(t('contact.formError'))
|
||||
}
|
||||
} else {
|
||||
// Fallback: mailto
|
||||
const body = `Имя: ${formState.name}\nКомпания: ${formState.company}\nТелефон: ${formState.phone}\n\n${formState.message}`
|
||||
window.location.href = `mailto:${EMAIL}?subject=Заявка с сайта sag24.ru&body=${encodeURIComponent(body)}`
|
||||
setSubmitted(true)
|
||||
}
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<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">
|
||||
{t('hero.title')}
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-slate-300 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
{t('hero.subtitle')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-10">
|
||||
<a href="#contact" 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">
|
||||
{t('hero.cta')}
|
||||
<ChevronRight size={20} className="group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
<a href="#services" className="px-8 py-4 border border-white/20 hover:border-white/40 text-white font-semibold rounded-lg transition-all duration-300">
|
||||
{t('hero.ctaSecondary')}
|
||||
</a>
|
||||
</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>
|
||||
|
||||
{/* Services */}
|
||||
<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">{t('services.title')}</h2>
|
||||
<p className="text-slate-500 text-lg max-w-2xl mx-auto">{t('services.subtitle')}</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{services.map((svc, i) => (
|
||||
<ServiceCard key={i} icon={serviceIcons[i]} {...svc} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About */}
|
||||
<section id="about" className="py-24 bg-white">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div ref={aboutRef} className="section-reveal grid md:grid-cols-2 gap-16 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-6">{t('about.title')}</h2>
|
||||
<p className="text-slate-600 text-lg leading-relaxed mb-4">{t('about.text1')}</p>
|
||||
<p className="text-slate-600 leading-relaxed">{t('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>
|
||||
|
||||
{/* Clients */}
|
||||
<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">{t('clients.title')}</h2>
|
||||
<p className="text-slate-500">{t('clients.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{[
|
||||
'ООО «Бастион-Сервис»',
|
||||
'АО «Стифтер Хаус»',
|
||||
'ООО «Вэйстроймастер»',
|
||||
'ООО «Комплекс»',
|
||||
'ООО «Пит Стоп»',
|
||||
'ООО «Астида-М»',
|
||||
'ФАУ ДПО ВИПКЛХ',
|
||||
'ООО «Грейт Групп»',
|
||||
'АО «Дельта»',
|
||||
'ООО «Глобал»',
|
||||
'ООО «Навант»',
|
||||
'ООО «ТД Адонит»',
|
||||
'ООО «Экопродукт»',
|
||||
'ООО «Астида»',
|
||||
'ДПК «Сосновый бор»',
|
||||
].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>
|
||||
|
||||
{/* Partners */}
|
||||
<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">{t('partners.title')}</h2>
|
||||
<p className="text-slate-500">{t('partners.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{[
|
||||
{ name: 'RU-CENTER', sub: 'Руцентр' },
|
||||
{ name: 'REG.RU', sub: 'Регистратор доменов' },
|
||||
{ name: 'МТВ', sub: 'Телекоммуникации' },
|
||||
{ name: 'КОНТУР', sub: 'Электронная отчётность' },
|
||||
].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>
|
||||
|
||||
{/* FAQ */}
|
||||
<section id="faq" className="py-24 bg-white border-t border-slate-100">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6">
|
||||
<div ref={faqRef} className="section-reveal">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 mb-4">{t('faq.title')}</h2>
|
||||
<p className="text-slate-500 text-lg">{t('faq.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{faqItems.map((item, i) => (
|
||||
<FaqItem key={i} q={item.q} a={item.a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section id="contact" className="py-24 bg-slate-900">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div ref={contactRef} className="section-reveal grid md:grid-cols-2 gap-16">
|
||||
<div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">{t('contact.title')}</h2>
|
||||
<p className="text-slate-400 text-lg mb-10">{t('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">{t('contact.phone')}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{t('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">{t('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">{t('contact.address')}</div>
|
||||
<span className="text-white font-medium">{t('contact.addressValue')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yandex Map */}
|
||||
<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"
|
||||
frameBorder="0"
|
||||
title="Офис Сисадмингрупп на карте"
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-8">
|
||||
{submitted ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 py-8">
|
||||
<CheckCircle2 size={48} className="text-green-400" />
|
||||
<p className="text-white text-center text-lg">{t('contact.formSuccess')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t('contact.formName')}
|
||||
value={formState.name}
|
||||
onChange={e => setFormState({ ...formState, name: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500 transition-colors text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('contact.formCompany')}
|
||||
value={formState.company}
|
||||
onChange={e => setFormState({ ...formState, company: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500 transition-colors text-sm"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t('contact.formEmail')}
|
||||
value={formState.email}
|
||||
onChange={e => setFormState({ ...formState, email: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500 transition-colors text-sm"
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder={t('contact.formPhone')}
|
||||
value={formState.phone}
|
||||
onChange={e => setFormState({ ...formState, phone: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500 transition-colors text-sm"
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
placeholder={t('contact.formMessage')}
|
||||
value={formState.message}
|
||||
onChange={e => setFormState({ ...formState, message: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-blue-500 transition-colors text-sm resize-none"
|
||||
/>
|
||||
{TURNSTILE_SITE_KEY && (
|
||||
<div
|
||||
className="cf-turnstile"
|
||||
data-sitekey={TURNSTILE_SITE_KEY}
|
||||
data-theme="dark"
|
||||
/>
|
||||
)}
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-60 text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
{sending ? t('contact.formSending') : t('contact.formSubmit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext.jsx'
|
||||
|
||||
export default function NotFound() {
|
||||
const { lang } = useLanguage()
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
<div className="text-8xl font-bold text-blue-600 mb-4">404</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-3">
|
||||
{lang === 'ru' ? 'Страница не найдена' : 'Page not found'}
|
||||
</h1>
|
||||
<p className="text-slate-400 mb-8">
|
||||
{lang === 'ru'
|
||||
? 'Такой страницы не существует или она была удалена'
|
||||
: 'This page does not exist or has been removed'}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
{lang === 'ru' ? 'На главную' : 'Back to home'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
export const en = {
|
||||
nav: {
|
||||
services: 'Services',
|
||||
about: 'About',
|
||||
faq: 'FAQ',
|
||||
contact: 'Contact',
|
||||
},
|
||||
hero: {
|
||||
title: 'IT Solutions for Your Business',
|
||||
subtitle: 'We take full responsibility for your IT infrastructure — from workstation support to data protection',
|
||||
cta: 'Discuss a Project',
|
||||
ctaSecondary: 'Learn More',
|
||||
},
|
||||
services: {
|
||||
title: 'Our Services',
|
||||
subtitle: 'End-to-end IT infrastructure management',
|
||||
items: [
|
||||
{
|
||||
title: 'IT Outsourcing',
|
||||
description: 'Full management of your IT infrastructure: servers, networks, workstations. Fast response, results-driven.',
|
||||
points: ['24/7 support', 'Remote and on-site service', 'Fixed monthly cost'],
|
||||
},
|
||||
{
|
||||
title: 'Cybersecurity',
|
||||
description: 'Corporate data protection, security audits, firewall and antivirus configuration.',
|
||||
points: ['Vulnerability audit & analysis', 'Firewall & VPN setup', 'Data loss prevention'],
|
||||
},
|
||||
{
|
||||
title: 'Technical Support',
|
||||
description: 'Fast resolution of employee technical issues. Help desk, ticket management, user training.',
|
||||
points: ['Help desk & ticketing', 'User training', 'SLA from 15 minutes'],
|
||||
},
|
||||
],
|
||||
},
|
||||
about: {
|
||||
title: 'About Us',
|
||||
text1: 'SysadminGroup is an IT company from Pushkino with years of experience serving small and medium businesses. We handle all IT infrastructure so you can focus on your business.',
|
||||
text2: 'Our team consists of certified specialists in networking, information security, and system administration.',
|
||||
stat1: { value: '10+', label: 'years on market' },
|
||||
stat2: { value: '150+', label: 'clients' },
|
||||
stat3: { value: '15 min', label: 'response time' },
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us',
|
||||
subtitle: 'Tell us about your challenge — we will propose a solution',
|
||||
phones: ['+7 495 363-74-76', '+7 495 363-73-35', '+7 909 945-44-56'],
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
address: 'Address',
|
||||
addressValue: 'Moscow Region, Pushkino, 38/14',
|
||||
formName: 'Your name',
|
||||
formCompany: 'Company',
|
||||
formEmail: 'Your email',
|
||||
formMessage: 'Describe your task',
|
||||
formPhone: 'Your phone',
|
||||
formSubmit: 'Send Request',
|
||||
formSending: 'Sending...',
|
||||
formSuccess: 'Request sent! We will contact you shortly.',
|
||||
formError: 'Send error. Please call us directly.',
|
||||
},
|
||||
faq: {
|
||||
title: 'FAQ',
|
||||
subtitle: 'Common questions about IT outsourcing',
|
||||
items: [
|
||||
{
|
||||
q: 'How much does IT outsourcing cost?',
|
||||
a: 'Cost depends on the number of workstations and service scope. For 5–10 workstations — from 15,000 RUB/month. Contact us for an exact quote.',
|
||||
},
|
||||
{
|
||||
q: 'How fast do you respond to issues?',
|
||||
a: 'SLA from 15 minutes for remote tasks. On-site visit within 2–4 hours in Pushkino and nearby cities.',
|
||||
},
|
||||
{
|
||||
q: 'Do you work outside Pushkino?',
|
||||
a: 'Yes, we serve companies across the Moscow Region. Remote support has no geographic limits.',
|
||||
},
|
||||
{
|
||||
q: 'What is included in the service contract?',
|
||||
a: 'Services are defined in the SLA: 24/7 support, infrastructure monitoring, remote and on-site service, antivirus protection.',
|
||||
},
|
||||
{
|
||||
q: 'Do we need to stop operations during onboarding?',
|
||||
a: 'No. Onboarding takes 1–2 days and does not require stopping any business processes.',
|
||||
},
|
||||
{
|
||||
q: 'Do you only work with small businesses?',
|
||||
a: 'We serve companies from 3 to 200+ workstations, including government institutions and manufacturing enterprises.',
|
||||
},
|
||||
],
|
||||
},
|
||||
partners: {
|
||||
title: 'Partners',
|
||||
subtitle: 'We work with trusted suppliers and vendors',
|
||||
},
|
||||
clients: {
|
||||
title: 'Trusted by',
|
||||
subtitle: 'Companies that chose SysadminGroup',
|
||||
},
|
||||
footer: {
|
||||
rights: 'All rights reserved.',
|
||||
},
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ru } from './ru.js'
|
||||
export { en } from './en.js'
|
||||
25
tailwind.config.ts
Normal file
25
tailwind.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-manrope)', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: process.env.BUILD_DIR || 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user