diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/app/components/About.tsx b/app/components/About.tsx new file mode 100644 index 0000000..bf35d1b --- /dev/null +++ b/app/components/About.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { History, GraduationCap, Brain, Users, Coins } from 'lucide-react'; +import Image from 'next/image'; +import { motion, useAnimation, useInView } from 'framer-motion'; + +const About = () => { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-100px' }); + const controls = useAnimation(); + + useEffect(() => { + if (isInView) { + controls.start('visible'); + } + }, [isInView, controls]); + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2, + duration: 0.5, + }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + duration: 0.5, + }, + }, + }; + + const imageVariants = { + hidden: { scale: 0.8, opacity: 0 }, + visible: { + scale: 1, + opacity: 1, + transition: { + duration: 0.8, + ease: [0.16, 1, 0.3, 1], + }, + }, + }; + + const featureVariants = { + hidden: { x: -20, opacity: 0 }, + visible: { + x: 0, + opacity: 1, + transition: { + duration: 0.5, + }, + }, + }; + + const statsVariants = { + hidden: { scale: 0.5, opacity: 0 }, + visible: { + scale: 1, + opacity: 1, + transition: { + duration: 0.5, + }, + }, + }; + + return ( +
+ + +

+ О нашей компании +

+

+ Мы предоставляем профессиональные услуги экспертизы с неизменно + высоким качеством +

+
+ +
+
+ +
+ +
+
+

+ Более 9 лет на рынке +

+

+ Многолетний опыт работы позволяет нам решать задачи любой + сложности и гарантировать высокое качество услуг. +

+
+
+ + +
+ +
+
+

+ Квалифицированные специалисты +

+

+ В нашей организации работают дипломированные специалисты, + кандидат технических наук, инженеры с большим стажем работы и + квалифицированный сметчик. +

+
+
+ + +
+ +
+
+

+ Огромный опыт +

+

+ Наработан огромный опыт, позволяющий провести экспертизу даже + в самых сложных ситуациях. +

+
+
+
+ + + Наш офис + +
+ +
+ +
+
+ +
+
+

+ Работаем со всеми +

+

+ Работаем как с юридическими, так и с физическими лицами. + Индивидуальный подход к каждому клиенту. +

+
+
+
+ + +
+
+ +
+
+

+ Честные цены +

+

+ Не накручиваем цены и не навязываем ненужные допуслуги. + Прозрачное ценообразование и понятные условия сотрудничества. +

+
+
+
+
+ + + +
9+
+
лет опыта
+
+ +
500+
+
проектов
+
+ +
50+
+
экспертов
+
+ +
98%
+
довольных клиентов
+
+
+
+
+ ); +}; + +export default About; diff --git a/app/components/Certificates.tsx b/app/components/Certificates.tsx new file mode 100644 index 0000000..81d52e9 --- /dev/null +++ b/app/components/Certificates.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useState, useRef } from 'react'; +import Image from 'next/image'; +import { FileText, X, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { motion, AnimatePresence, useInView } from 'framer-motion'; + +const certificates = [ + { + id: 1, + title: 'Свидетельство СРО', + description: 'Свидетельство о членстве в саморегулируемой организации', + image: '/images/certificates/sro.jpg', + }, + { + id: 2, + title: 'Свидетельство НОПРИЗ', + description: + 'Свидетельство о членстве в Национальном объединении изыскателей и проектировщиков', + image: '/images/certificates/nopriz.jpg', + }, +]; + +const Certificates = () => { + const [selectedCert, setSelectedCert] = useState(null); + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-100px' }); + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2, + delayChildren: 0.3, + }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + duration: 0.5, + ease: [0.16, 1, 0.3, 1], + }, + }, + }; + + const modalVariants = { + hidden: { opacity: 0, scale: 0.8 }, + visible: { + opacity: 1, + scale: 1, + transition: { + duration: 0.3, + ease: [0.16, 1, 0.3, 1], + }, + }, + exit: { + opacity: 0, + scale: 0.8, + transition: { + duration: 0.2, + }, + }, + }; + + const handlePrev = () => { + if (selectedCert !== null) { + setSelectedCert( + selectedCert === 0 ? certificates.length - 1 : selectedCert - 1 + ); + } + }; + + const handleNext = () => { + if (selectedCert !== null) { + setSelectedCert( + selectedCert === certificates.length - 1 ? 0 : selectedCert + 1 + ); + } + }; + + return ( +
+
+ + +

+ Сертификаты и лицензии +

+

+ Все необходимые документы, подтверждающие нашу компетенцию и + надежность +

+
+ +
+ {certificates.map((cert, index) => ( + setSelectedCert(index)} + > +
+ {cert.title} + + + + + +
+
+

+ {cert.title} +

+

{cert.description}

+
+
+ ))} +
+ + + {selectedCert !== null && ( + setSelectedCert(null)} + > + e.stopPropagation()} + > + + +
+ {certificates[selectedCert].title} +
+ + + + + + + +
+
+ )} +
+ + +

+ Все наши документы и сертификаты актуальны и действительны +

+
+
+
+
+ ); +}; + +export default Certificates; diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx new file mode 100644 index 0000000..8e205a9 --- /dev/null +++ b/app/components/ContactForm.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { motion, useInView } from 'framer-motion'; +import { Phone, CheckCircle2, HelpCircle, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { formatPhoneNumber, validatePhoneNumber } from '@/lib/utils'; +import { sendTelegramNotification } from '@/lib/telegram'; + +const ContactForm = () => { + const [phone, setPhone] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-100px' }); + + const handlePhoneChange = (e: React.ChangeEvent) => { + const formattedPhone = formatPhoneNumber(e.target.value); + setPhone(formattedPhone); + setError(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validatePhoneNumber(phone)) { + setError('Пожалуйста, введите корректный российский номер телефона'); + return; + } + + setIsLoading(true); + + try { + const success = await sendTelegramNotification(phone, 'Форма контактов'); + + if (success) { + setIsSubmitted(true); + setError(''); + setTimeout(() => { + setIsSubmitted(false); + setPhone(''); + }, 3000); + } else { + setError( + 'Произошла ошибка при отправке заявки. Пожалуйста, попробуйте позже.' + ); + } + } catch (error) { + setError( + 'Произошла ошибка при отправке заявки. Пожалуйста, попробуйте позже.' + ); + } finally { + setIsLoading(false); + } + }; + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2, + delayChildren: 0.3, + }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + duration: 0.5, + ease: [0.16, 1, 0.3, 1], + }, + }, + }; + + const listItemVariants = { + hidden: { x: -20, opacity: 0 }, + visible: { + x: 0, + opacity: 1, + transition: { + duration: 0.3, + }, + }, + }; + + return ( +
+
+ + + {/* Фоновый паттерн */} + +
+ + +
+ {/* Левая часть с текстом */} + + + + + +

+ Нужна консультация? +

+
+

+ Оставьте свой номер телефона, и наш специалист + проконсультирует вас по всем вопросам +

+ + + + Бесплатная консультация + + + + Ответим в течение 15 минут + + + + Подберём оптимальное решение + + +
+ + {/* Правая часть с формой */} + + +
+
+ + + {error && ( +
+ + {error} +
+ )} +
+ + + +

+ Нажимая кнопку, вы соглашаетесь с{' '} + + политикой конфиденциальности + +

+
+
+
+
+ + +
+
+ ); +}; + +export default ContactForm; diff --git a/app/components/ContactModal.tsx b/app/components/ContactModal.tsx new file mode 100644 index 0000000..d10fade --- /dev/null +++ b/app/components/ContactModal.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Phone, CheckCircle2, X, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { formatPhoneNumber, validatePhoneNumber } from '@/lib/utils'; +import { sendTelegramNotification } from '@/lib/telegram'; + +interface ContactModalProps { + isOpen: boolean; + onClose: () => void; +} + +const ContactModal = ({ isOpen, onClose }: ContactModalProps) => { + const [phone, setPhone] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handlePhoneChange = (e: React.ChangeEvent) => { + const formattedPhone = formatPhoneNumber(e.target.value); + setPhone(formattedPhone); + setError(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validatePhoneNumber(phone)) { + setError('Пожалуйста, введите корректный российский номер телефона'); + return; + } + + setIsLoading(true); + + try { + const success = await sendTelegramNotification(phone, 'Модальное окно'); + + if (success) { + setIsSubmitted(true); + setError(''); + setTimeout(() => { + setIsSubmitted(false); + setPhone(''); + onClose(); + }, 2000); + } else { + setError( + 'Произошла ошибка при отправке заявки. Пожалуйста, попробуйте позже.' + ); + } + } catch (error) { + setError( + 'Произошла ошибка при отправке заявки. Пожалуйста, попробуйте позже.' + ); + } finally { + setIsLoading(false); + } + }; + + const modalVariants = { + hidden: { opacity: 0, scale: 0.8 }, + visible: { + opacity: 1, + scale: 1, + transition: { + duration: 0.2, + ease: [0.16, 1, 0.3, 1], + }, + }, + exit: { + opacity: 0, + scale: 0.8, + transition: { + duration: 0.2, + }, + }, + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > + + +
+

+ Оставить заявку +

+

+ Оставьте свой номер телефона, и мы свяжемся с вами в ближайшее + время +

+
+ +
+
+ + + {error && ( +
+ + {error} +
+ )} +
+ + + +

+ Нажимая кнопку, вы соглашаетесь с{' '} + e.stopPropagation()} + > + политикой конфиденциальности + +

+
+
+
+ )} +
+ ); +}; + +export default ContactModal; diff --git a/app/components/Contacts.tsx b/app/components/Contacts.tsx new file mode 100644 index 0000000..6099a42 --- /dev/null +++ b/app/components/Contacts.tsx @@ -0,0 +1,280 @@ +'use client'; + +import { useRef } from 'react'; +import { motion, useInView } from 'framer-motion'; +import { + Phone, + Mail, + Clock, + MapPin, + Building, + MessagesSquare, + FileText, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const cityContacts = { + Москва: { + address: 'г. Москва, ул. Пресненская, д. 6, стр. 2', + phone: '+7 (916) 830-58-58', + email: 'ckeproekt@yandex.ru', + workHours: 'ПН-ПТ: 8:00 - 20:00', + mapLink: 'https://yandex.ru/maps/-/CCUzYXu7xC', + coordinates: [37.539042, 55.74733], + }, + Чебоксары: { + address: 'г. Чебоксары, пр. Тракторостроителей, д. 11', + phone: '+7 (916) 830-58-58', + email: 'ckeproekt@yandex.ru', + workHours: 'ПН-ПТ: 8:00 - 20:00', + mapLink: 'https://yandex.ru/maps/-/CCUzYXBpkD', + coordinates: [47.290091, 56.107257], + }, +} as const; + +const features = [ + { + icon: MessagesSquare, + title: 'Оперативная связь', + description: 'Быстро отвечаем на звонки и сообщения в рабочее время', + }, + { + icon: FileText, + title: 'Документы онлайн', + description: 'Возможность получить документы в электронном виде', + }, + { + icon: Building, + title: 'Удобное расположение', + description: 'Офис в центре города с удобной транспортной доступностью', + }, +]; + +interface ContactsProps { + selectedCity: keyof typeof cityContacts; +} + +const Contacts = ({ selectedCity }: ContactsProps) => { + const cityData = cityContacts[selectedCity]; + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-100px' }); + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2, + delayChildren: 0.3, + }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + duration: 0.5, + ease: [0.16, 1, 0.3, 1], + }, + }, + }; + + const mapVariants = { + hidden: { scale: 0.8, opacity: 0 }, + visible: { + scale: 1, + opacity: 1, + transition: { + duration: 0.8, + ease: [0.16, 1, 0.3, 1], + }, + }, + }; + + return ( +
+
+ + +

+ Контакты +

+

+ Выберите удобный способ связи или посетите наш офис +

+
+ +
+ + +
+ +
+ + + +
+
+

+ Адрес +

+

{cityData.address}

+ + Открыть на карте + +
+
+ + +
+ + + +
+
+

+ Телефон +

+ + {cityData.phone} + +
+
+ + +
+ + + +
+
+

+ Email +

+ + {cityData.email} + +
+
+ + +
+ + + +
+
+

+ Время работы +

+

{cityData.workHours}

+

Сб-Вс: выходной

+
+
+
+
+ +
+ {features.map((feature, index) => ( + + +
+ +
+
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+ + +