Комплексные улучшения: FAQ, форма, карта, WhatsApp/Telegram, SEO

- FloatingContacts: кнопки WhatsApp + Telegram (правый нижний угол)
- ScrollToTop: кнопка "наверх" (появляется после 400px скролла)
- FAQ секция: 6 вопросов с аккордеоном, id=faq в навбаре
- Hero: телефон под CTA кнопками
- Форма: поле телефона, реальная отправка (fetch FormSpree или mailto fallback)
- Яндекс.Карты embed в блоке контактов
- Navigation: убран дубль "Контакты" из links, добавлен FAQ
- img width/height на логотипах (антиCLS)
- JSON-LD: og-image.svg → .png, добавлен postalCode, уточнен streetAddress
- prerender.mjs: динамически обновляет хеш preload шрифта
- src/config.js: централизованный конфиг (WhatsApp, Telegram, form endpoint)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 05:54:19 +03:00
parent 27169b6a65
commit 851dd6173b
11 changed files with 260 additions and 18 deletions

View File

@@ -56,14 +56,15 @@
"description": "IT-аутсорсинг, кибербезопасность и техническая поддержка для бизнеса в Пушкино и Московской области.",
"url": "https://sag24.ru",
"logo": "https://sag24.ru/logo.png",
"image": "https://sag24.ru/og-image.svg",
"image": "https://sag24.ru/og-image.png",
"telephone": ["+74953637476", "+74953637335", "+74959454456"],
"email": "info@sag24.ru",
"address": {
"@type": "PostalAddress",
"streetAddress": "д. 38/14",
"streetAddress": "пр-кт Московский, д. 38/14",
"addressLocality": "Пушкино",
"addressRegion": "Московская область",
"postalCode": "141207",
"addressCountry": "RU"
},
"geo": {

View File

@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, rmSync } from 'fs'
import { readFileSync, writeFileSync, rmSync, readdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
@@ -8,9 +8,23 @@ const root = resolve(__dirname, '..')
// Import SSR bundle built by vite build --ssr
const { render } = await import('../dist/server/entry-server.js')
const template = readFileSync(resolve(root, 'dist/index.html'), 'utf-8')
let html = readFileSync(resolve(root, 'dist/index.html'), 'utf-8')
// Inject prerendered HTML
const appHtml = render()
const html = template.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`)
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, 'dist/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, 'dist/index.html'), html)
// Cleanup SSR bundle

View File

@@ -4,6 +4,8 @@ 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()
@@ -34,6 +36,8 @@ export default function App() {
<Router />
</main>
<Footer />
<FloatingContacts />
<ScrollToTop />
</LanguageProvider>
)
}

View File

@@ -0,0 +1,33 @@
import React from 'react'
import { WHATSAPP_PHONE, TELEGRAM_USERNAME } from '../config.js'
export default function FloatingContacts() {
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3">
<a
href={`https://t.me/${TELEGRAM_USERNAME}`}
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/${WHATSAPP_PHONE}`}
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>
)
}

View File

@@ -10,7 +10,7 @@ export default function Footer() {
<footer className="bg-slate-900 text-slate-400">
{/* Main footer row */}
<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">
<img src="/logo.png" alt="Сисадмингрупп" className="h-10 w-auto brightness-0 invert" />
<img src="/logo.png" alt="Сисадмингрупп" className="h-10 w-auto brightness-0 invert" width="40" height="40" />
<span className="text-sm">{year} © {t('footer.rights')}</span>
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm">
{phones.map((p, i) => (

View File

@@ -9,14 +9,14 @@ export default function Navigation() {
const links = [
{ label: t('nav.services'), href: '#services' },
{ label: t('nav.about'), href: '#about' },
{ label: t('nav.contact'), href: '#contact' },
{ label: t('nav.faq'), href: '#faq' },
]
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">
<a href="#" className="flex items-center">
<img src="/logo.png" alt="Сисадмингрупп" className="h-12 w-auto" />
<img src="/logo.png" alt="Сисадмингрупп" className="h-12 w-auto" width="48" height="48" />
</a>
{/* Desktop nav */}

View File

@@ -0,0 +1,24 @@
import React, { 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>
)
}

5
src/config.js Normal file
View File

@@ -0,0 +1,5 @@
// Настройки для связи и формы
export const WHATSAPP_PHONE = '74953637476' // без +
export const TELEGRAM_USERNAME = 'sag24ru' // @username без @
export const FORM_ENDPOINT = '' // https://formspree.io/f/XXXXXXX — оставить пустым, будет mailto:
export const EMAIL = 'info@sag24.ru'

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import { ChevronRight, Server, Shield, Headphones, Phone, Mail, MapPin, CheckCircle2 } from 'lucide-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 } from '../config.js'
const serviceIcons = [Server, Shield, Headphones]
@@ -26,20 +27,69 @@ function ServiceCard({ icon: Icon, title, description, points }) {
)
}
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: '', message: '' })
const [formState, setFormState] = useState({ name: '', company: '', 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 = (e) => {
const handleSubmit = async (e) => {
e.preventDefault()
setSubmitted(true)
setSending(true)
setError('')
if (FORM_ENDPOINT) {
try {
const res = await fetch(FORM_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(formState),
})
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 (
@@ -61,7 +111,7 @@ export default function Home() {
<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">
<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" />
@@ -70,6 +120,10 @@ export default function Home() {
{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>
@@ -165,6 +219,23 @@ export default function Home() {
</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">
@@ -197,8 +268,8 @@ export default function Home() {
<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-center gap-4">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
<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>
@@ -207,6 +278,19 @@ export default function Home() {
</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">
@@ -232,19 +316,28 @@ export default function Home() {
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="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={5}
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"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-colors"
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"
>
{t('contact.formSubmit')}
{sending ? t('contact.formSending') : t('contact.formSubmit')}
</button>
</form>
)}

View File

@@ -2,6 +2,7 @@ export const en = {
nav: {
services: 'Services',
about: 'About',
faq: 'FAQ',
contact: 'Contact',
},
hero: {
@@ -50,8 +51,41 @@ export const en = {
formName: 'Your name',
formCompany: 'Company',
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 510 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 24 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 12 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',

View File

@@ -2,6 +2,7 @@ export const ru = {
nav: {
services: 'Услуги',
about: 'О нас',
faq: 'FAQ',
contact: 'Контакты',
},
hero: {
@@ -50,8 +51,41 @@ export const ru = {
formName: 'Ваше имя',
formCompany: 'Компания',
formMessage: 'Опишите задачу',
formPhone: 'Ваш телефон',
formSubmit: 'Отправить заявку',
formSending: 'Отправка...',
formSuccess: 'Заявка отправлена! Мы свяжемся с вами в ближайшее время.',
formError: 'Ошибка отправки. Позвоните нам напрямую.',
},
faq: {
title: 'Частые вопросы',
subtitle: 'Ответы на типовые вопросы об IT-аутсорсинге',
items: [
{
q: 'Сколько стоит IT-аутсорсинг?',
a: 'Стоимость зависит от количества рабочих мест и состава услуг. Для 510 рабочих мест — от 15 000 руб./мес. Свяжитесь с нами для точного расчёта.',
},
{
q: 'Как быстро вы реагируете на проблемы?',
a: 'SLA от 15 минут для удалённых задач. Выезд специалиста в течение 24 часов по Пушкино и ближайшим городам.',
},
{
q: 'Работаете ли вы за пределами Пушкино?',
a: 'Да, обслуживаем компании по всей Московской области. Удалённая поддержка — без ограничений по географии.',
},
{
q: 'Что входит в договор на обслуживание?',
a: 'Перечень услуг фиксируется в SLA: техподдержка 24/7, мониторинг инфраструктуры, удалённое и выездное обслуживание, антивирусная защита.',
},
{
q: 'Нужно ли прерывать работу при подключении?',
a: 'Нет. Подключение к обслуживанию занимает 12 дня и не требует остановки бизнес-процессов.',
},
{
q: 'Вы работаете только с малым бизнесом?',
a: 'Обслуживаем компании от 3 до 200+ рабочих мест, включая государственные учреждения и производственные предприятия.',
},
],
},
partners: {
title: 'Партнёры',