commit 3b3d29e21cead06daa2847e05d4671d01e030d2c Author: Arsen Akhmetzyanov Date: Tue Feb 3 23:16:16 2026 +0500 Initial commit for iiEasy: all files included diff --git a/-1.gitignore b/-1.gitignore new file mode 100755 index 0000000..a547bf3 --- /dev/null +++ b/-1.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d9b261 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +iiEasy +.gitignore +.env +.env.local +.env.*.local +*.log +dist +.idea +.vscode +*.md +!README.md diff --git a/.env.nomad.example b/.env.nomad.example new file mode 100644 index 0000000..448f372 --- /dev/null +++ b/.env.nomad.example @@ -0,0 +1,10 @@ +# Copy to .env.nomad and source before deploy: source .env.nomad +# Do NOT commit .env.nomad (contains credentials) + +export NOMAD_ADDR=http://192.168.1.16:4646 +export NOMAD_HOST=192.168.1.16 +export NOMAD_USER=its +export STRAPI_PUBLIC_URL=http://192.168.1.16:1337 + +# For SSH: use key-based auth or enter password when prompted +# ssh-copy-id its@192.168.1.16 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..34821f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env.nomad + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100755 index 0000000..a7c8e08 --- /dev/null +++ b/App.tsx @@ -0,0 +1,497 @@ + + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import Sidebar from './components/Sidebar'; +import HeroSection from './components/HeroSection'; +import BlogSection from './components/BlogSection'; +import NewsSection from './components/NewsSection'; +import ResearchSection from './components/ResearchSection'; +import BusinessSection from './components/BusinessSection'; +import ConnectSection from './components/ConnectSection'; +import AboutUsSection from './components/AboutUsSection'; +import OurMissionSection from './components/OurMissionSection'; +import CareersSection from './components/CareersSection'; +import BusinessLandingSection from './components/BusinessLandingSection'; +import BusinessServicesSection from './components/BusinessServicesSection'; +import EducationBusinessSection from './components/EducationBusinessSection'; +import EducationStudentsSection from './components/EducationStudentsSection'; +import ResearchAllSection from './components/ResearchAllSection'; +import NewsAllSection from './components/NewsAllSection'; +import StoriesAllSection from './components/StoriesAllSection'; +import AcceleratorAboutSection from './components/AcceleratorAboutSection'; +import AcceleratorProjectsSection from './components/AcceleratorProjectsSection'; +import AcceleratorInvestmentSection from './components/AcceleratorInvestmentSection'; +import TermsOfUseSection from './components/TermsOfUseSection'; +import PrivacyPolicySection from './components/PrivacyPolicySection'; +import Logo from './components/Logo'; +import { Bars3Icon, XMarkIcon, SidebarToggleIcon } from './components/icons'; +import { CurrentView, Post, NewsArticle, ResearchPaper, BusinessStory, ServiceItemData, ClientLogo, BusinessCourse, StudentProgram, AcceleratorProject, InvestmentProject, Vacancy } from './types'; +import { SECTION_IDS, FOOTER_CONTENT, UI_TEXTS } from './constants'; +import ChatInput from './components/ChatInput'; + + +// Import Strapi service +import { + fetchPosts, fetchNewsArticles, fetchResearchPapers, fetchBusinessStories, + fetchServiceItems, fetchClientLogos, fetchBusinessCourses, fetchStudentPrograms, + fetchAcceleratorProjects, fetchInvestmentProjects, fetchVacancies +} from './strapiService'; + +// Import Detail Page Components +import BlogPostDetail from './components/BlogPostDetail'; +import NewsArticleDetail from './components/NewsArticleDetail'; +import ResearchPaperDetail from './components/ResearchPaperDetail'; +import BusinessStoryDetail from './components/BusinessStoryDetail'; +import VacancyDetail from './components/VacancyDetail'; + + +const App: React.FC = () => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(false); + const [currentView, setCurrentView] = useState('main'); + const [selectedItemId, setSelectedItemId] = useState(null); + const [showStickyChat, setShowStickyChat] = useState(false); + const [isStickyChatExpanded, setIsStickyChatExpanded] = useState(false); + const stickyChatRef = useRef(null); + + + // State for Strapi data + const [posts, setPosts] = useState([]); + const [newsArticles, setNewsArticles] = useState([]); + const [researchPapers, setResearchPapers] = useState([]); + const [businessStories, setBusinessStories] = useState([]); + const [serviceItems, setServiceItems] = useState([]); + const [clientLogos, setClientLogos] = useState([]); + const [businessCourses, setBusinessCourses] = useState([]); + const [studentPrograms, setStudentPrograms] = useState([]); + const [acceleratorProjects, setAcceleratorProjects] = useState([]); + const [investmentProjects, setInvestmentProjects] = useState([]); + const [vacancies, setVacancies] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + + const toggleSidebar = useCallback(() => { + setIsSidebarOpen(prev => !prev); + }, []); + + const handleSetCurrentView = useCallback((view: CurrentView) => { + setCurrentView(view); + if (!view.endsWith('Detail') && view !== 'termsOfUse' && view !== 'privacyPolicy') { + setSelectedItemId(null); + } + }, []); + + const handleSetSelectedItemId = useCallback((id: string | null) => { + setSelectedItemId(id); + }, []); + + useEffect(() => { + const loadData = async () => { + setIsLoading(true); + setError(null); + try { + const [ + fetchedPosts, + fetchedNews, + fetchedResearch, + fetchedBusinessStories, + fetchedServiceItems, + fetchedClientLogos, + fetchedBusinessCourses, + fetchedStudentPrograms, + fetchedAcceleratorProjects, + fetchedInvestmentProjects, + fetchedVacanciesData + ] = await Promise.all([ + fetchPosts(), + fetchNewsArticles(), + fetchResearchPapers(), + fetchBusinessStories(), + fetchServiceItems(), + fetchClientLogos(), + fetchBusinessCourses(), + fetchStudentPrograms(), + fetchAcceleratorProjects(), + fetchInvestmentProjects(), + fetchVacancies(), + ]); + setPosts(fetchedPosts); + setNewsArticles(fetchedNews); + setResearchPapers(fetchedResearch); + setBusinessStories(fetchedBusinessStories); + setServiceItems(fetchedServiceItems); + setClientLogos(fetchedClientLogos); + setBusinessCourses(fetchedBusinessCourses); + setStudentPrograms(fetchedStudentPrograms); + setAcceleratorProjects(fetchedAcceleratorProjects); + setInvestmentProjects(fetchedInvestmentProjects); + setVacancies(fetchedVacanciesData); + + } catch (err) { + console.error("Failed to load data from Strapi:", err); + setError("Не удалось загрузить данные. Пожалуйста, попробуйте позже."); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, []); + + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 768) { + setIsSidebarOpen(false); + } + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + // On non-main pages, the sticky chat should always be visible. + if (currentView !== 'main') { + setShowStickyChat(true); + // We don't need a scroll listener on these pages for this feature. + return; + } + + // On the main page, show the sticky chat only after scrolling past the hero. + const handleScroll = () => { + const heroSection = document.getElementById(SECTION_IDS.hero); + if (heroSection) { + const shouldShow = heroSection.getBoundingClientRect().bottom < 0; + setShowStickyChat(current => current === shouldShow ? current : shouldShow); + } else { + // Fallback if hero section is not found on main page for some reason + setShowStickyChat(false); + } + }; + + // Set initial state for 'main' view (in case user reloads scrolled down) + handleScroll(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [currentView]); // Dependency on currentView ensures this logic re-runs on page change + + // Hook for handling clicks outside of the sticky chat input to collapse it + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (isStickyChatExpanded && stickyChatRef.current && !stickyChatRef.current.contains(event.target as Node)) { + setIsStickyChatExpanded(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isStickyChatExpanded]); + + useEffect(() => { + let targetId: string | null = null; + const baseTitle = 'iiEasy'; + let newTitle = `iiEasy: Разработка Сайтов, Приложений, ИИ-Решений | ИИ Исследования и Обучение Уфа`; // Default + + const findItemTitle = (items: {id: string; title: string}[], id: string | null) => items.find(item => item.id === id)?.title; + + switch(currentView) { + case 'about': targetId = SECTION_IDS.aboutUsPage; newTitle = `О нас | ${baseTitle}`; break; + case 'mission': targetId = SECTION_IDS.ourMissionPage; newTitle = `Наша Миссия | ${baseTitle}`; break; + case 'careers': targetId = SECTION_IDS.careersPage; newTitle = `Карьера | ${baseTitle}`; break; + case 'businessLanding': targetId = SECTION_IDS.businessLandingPage; newTitle = `Кейсы для Бизнеса | ${baseTitle}`; break; + case 'businessServices': targetId = SECTION_IDS.businessServicesPage; newTitle = `Услуги для Бизнеса | ${baseTitle}`; break; + case 'educationBusiness': targetId = SECTION_IDS.educationBusinessPage; newTitle = `Обучение для Бизнеса | ${baseTitle}`; break; + case 'educationStudents': targetId = SECTION_IDS.educationStudentsPage; newTitle = `Обучение для Студентов | ${baseTitle}`; break; + case 'researchAll': targetId = SECTION_IDS.researchAllPage; newTitle = `Все Исследования | ${baseTitle}`; break; + case 'newsAll': targetId = SECTION_IDS.newsAllPage; newTitle = `Все Новости | ${baseTitle}`; break; + case 'storiesAll': targetId = SECTION_IDS.storiesAllPage; newTitle = `Все Истории | ${baseTitle}`; break; + case 'acceleratorAbout': targetId = SECTION_IDS.acceleratorAboutPage; newTitle = `Об Акселераторе | ${baseTitle}`; break; + case 'acceleratorProjects': targetId = SECTION_IDS.acceleratorProjectsPage; newTitle = `Проекты Акселератора | ${baseTitle}`; break; + case 'acceleratorInvestment': targetId = SECTION_IDS.acceleratorInvestmentPage; newTitle = `Инвестиции в стартапы | ${baseTitle}`; break; + case 'blogPostDetail': + targetId = SECTION_IDS.blogPostDetailPage; + newTitle = `${findItemTitle(posts, selectedItemId) || 'История'} | ${baseTitle}`; + break; + case 'newsArticleDetail': + targetId = SECTION_IDS.newsArticleDetailPage; + newTitle = `${findItemTitle(newsArticles, selectedItemId) || 'Новость'} | ${baseTitle}`; + break; + case 'researchPaperDetail': + targetId = SECTION_IDS.researchPaperDetailPage; + newTitle = `${findItemTitle(researchPapers, selectedItemId) || 'Исследование'} | ${baseTitle}`; + break; + case 'businessStoryDetail': + targetId = SECTION_IDS.businessStoryDetail; + newTitle = `${findItemTitle(businessStories, selectedItemId) || 'Кейс'} | ${baseTitle}`; + break; + case 'vacancyDetail': + targetId = SECTION_IDS.vacancyDetailPage; + newTitle = `${findItemTitle(vacancies, selectedItemId) || 'Вакансия'} | ${baseTitle}`; + break; + case 'termsOfUse': targetId = SECTION_IDS.termsOfUsePage; newTitle = `Правила использования | ${baseTitle}`; break; + case 'privacyPolicy': targetId = SECTION_IDS.privacyPolicyPage; newTitle = `Политика конфиденциальности | ${baseTitle}`; break; + case 'main': + window.scrollTo({ top: 0, behavior: 'smooth' }); + break; + } + + document.title = newTitle; + + if (targetId) { + const element = document.getElementById(targetId); + if (element) { + element.scrollIntoView({behavior: 'smooth', block: 'start'}); + } else { + setTimeout(() => { + document.getElementById(targetId)?.scrollIntoView({behavior: 'smooth', block: 'start'}); + }, 150); + } + } else if (currentView !== 'main') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }, [currentView, selectedItemId, posts, newsArticles, researchPapers, businessStories, vacancies]); + + if (isLoading) { + return ( +
+
+ +

Загрузка данных...

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

Ошибка загрузки

+

{error}

+ +
+
+ ); + } + + + return ( +
+ setIsSidebarOpen(false)} + currentView={currentView} + setCurrentView={handleSetCurrentView} + isDesktopCollapsed={isDesktopSidebarCollapsed} + /> + + {/* Desktop toggle button */} + + +
+
+ + {isSidebarOpen && ( + + )} + + + +
+ {currentView === 'main' && ( + <> + + + + + + + + )} + {currentView === 'about' && } + {currentView === 'mission' && } + {currentView === 'careers' && } + + {currentView === 'businessLanding' && } + {currentView === 'businessServices' && } + + {currentView === 'educationBusiness' && } + {currentView === 'educationStudents' && } + + {currentView === 'researchAll' && } + {currentView === 'newsAll' && } + {currentView === 'storiesAll' && } + + {currentView === 'acceleratorAbout' && } + {currentView === 'acceleratorProjects' && } + {currentView === 'acceleratorInvestment' && } + + {/* Detail Pages */} + {currentView === 'blogPostDetail' && selectedItemId && ( + + )} + {currentView === 'newsArticleDetail' && selectedItemId && ( + + )} + {currentView === 'researchPaperDetail' && selectedItemId && ( + + )} + {currentView === 'businessStoryDetail' && selectedItemId && ( + + )} + {currentView === 'vacancyDetail' && selectedItemId && ( + + )} + {/* Legal Pages */} + {currentView === 'termsOfUse' && } + {currentView === 'privacyPolicy' && } +
+ + +
+
+ {/* Sticky Chat Area */} +
+
+ {isStickyChatExpanded ? ( +
+
+ +
+
+ ) : ( +
+ +
+ )} +
+
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7696b03 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Этап 1: Сборка +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +# Этап 2: Раздача через Nginx +FROM nginx:stable-alpine + +# 1. Копируем то, что собрал Vite (индекс, стили, основной JS) +COPY --from=build /app/dist /usr/share/nginx/html + +# 2. Копируем твою папку js вручную (раз Vite её игнорирует) +# Мы берем её из /app/js (где она оказалась после COPY . .) +# и кладем в /usr/share/nginx/html/js +COPY --from=build /app/js /usr/share/nginx/html/js + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..a1331a2 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/AboutUsSection.tsx b/components/AboutUsSection.tsx new file mode 100755 index 0000000..0f87ab7 --- /dev/null +++ b/components/AboutUsSection.tsx @@ -0,0 +1,171 @@ +import React, { useEffect } from 'react'; +import { SECTION_IDS } from '../constants'; +import { SparklesIcon } from './icons'; // Using an icon for principles + +const principlesData = [ + { + title: "Технологическое Лидерство", + content: "Мы не следуем трендам — мы стремимся их создавать, применяя самые передовые и эффективные подходы для решения задач." + }, + { + title: "Ответственность и Этика", + content: "Мы осознаем влияние ИИ и разрабатываем безопасные, прозрачные и надежные системы, которые приносят пользу обществу." + }, + { + title: "Фокус на Результате", + content: "Каждое наше решение, будь то сайт, приложение или сложная ИИ-модель, должно приносить измеримую пользу нашему партнеру. Мы ориентированы на ROI и практическую ценность." + }, + { + title: "Открытое Партнерство", + content: "Великие проекты создаются в синергии. Мы открыты к сотрудничеству с государственными структурами, корпорациями, университетами и технологическими стартапами." + }, + { + title: "Развитие Экосистемы", + content: "Наш вклад — это не только наши проекты, но и развитие ИИ-сообщества в Уфе и Башкортостане. Мы делимся знаниями, чтобы растить рынок вместе." + }, +]; + +const approachData = [ + { + title: "1. Прикладные ИИ-Решения", + content: "Мы разрабатываем и внедряем кастомные ИИ-решения для бизнеса и государства. Для государства: улучшение обработки обращений граждан, предиктивная аналитика. Для бизнеса: оптимизация логистики, персонализация продаж, автоматизация документооборота." + }, + { + title: "2. Исследования и Разработки (R&D)", + content: "Адаптируем и дообучаем передовые модели для локальных задач. Особый фокус — развитие языковых моделей для русского и башкирского языков, создание датасетов и сохранение культурного наследия." + }, + { + title: "3. Развитие Сообщества и Образование", + content: "Мы стремимся стать центром притяжения для талантов в Уфе. Организуем митапы, воркшопы и образовательные программы, формируя кадровый резерв для ИИ-индустрии региона." + } +]; + + +const PrincipleItem: React.FC<{ title: string; children: React.ReactNode; index: number }> = ({ title, children, index }) => ( +
  • +
    + +
    +
    +

    {title}

    +

    {children}

    +
    +
  • +); + + +const AboutUsSection: React.FC = () => { + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('scroll-animate-visible'); + observer.unobserve(entry.target); + } + }); + }, + { + threshold: 0.1, + } + ); + + const elements = document.querySelectorAll('.scroll-animate'); + elements.forEach((el) => observer.observe(el)); + + return () => observer.disconnect(); + }, []); + + return ( +
    +
    + {/* Header */} +
    +

    + iiEasy: Создаем Будущее с Помощью Искусственного Интеллекта. +

    +

    Сделано в Уфе.

    +
    + + {/* Intro */} +
    +

    Добро пожаловать в iiEasy — центр передовых разработок и исследований в области искусственного интеллекта. Мы — команда инженеров, исследователей и стратегов из Уфы, объединенных миссией по решению сложных задач для бизнеса и государства с помощью технологий. Мы не просто создаем сайты и приложения; мы проектируем интеллектуальные системы, которые оптимизируют процессы, улучшают принятие решений и открывают новые возможности для роста.

    +

    Наша работа основана на синтезе фундаментальной экспертизы в программной инженерии и глубокого понимания современных ИИ-архитектур, включая большие языковые модели (LLM), компьютерное зрение (CV) и предиктивную аналитику.

    +
    + +
    + Команда iiEasy за работой в современном офисе в Уфе, обсуждает проект на фоне доски с диаграммами и кодом. Атмосфера сфокусированной совместной работы. +
    Команда iiEasy в Уфе: от идеи к реализации.
    +
    + + {/* Mission */} +
    +

    Наша Миссия

    +
    +

    Сделать Башкортостан лидирующим регионом в области искусственного интеллекта.

    +

    Мы стремимся к этой цели через решение практически значимых задач, создание продуктов мирового уровня и формирование сильного ИИ-сообщества. Мы верим, что технологии ИИ — это ключ к повышению эффективности экономики, улучшению качества жизни граждан и созданию устойчивого будущего для нашего региона.

    +
    +
    + + {/* Approach */} +
    +

    Наш Подход

    +
    +

    Наша деятельность строится на трех ключевых направлениях:

    +
    +
    + {approachData.map((item, index) => ( +
    +

    {item.title}

    +

    +
    + ))} +
    +
    + +
    + Крупный план экрана с кодом или дашбордом предиктивной аналитики. На заднем плане — сфокусированный взгляд разработчика, отражающийся в мониторе. +
    Глубокая техническая работа — основа наших решений.
    +
    + + {/* Team */} +
    +

    Наша Команда

    +
    +

    Команда iiEasy — это сплав системного мышления, инженерной прагматичности и стратегического видения. В наших рядах — высококвалифицированные специалисты по машинному обучению, NLP, CV, анализу данных и разработке высоконагруженных систем. Нас объединяет культура постоянного самосовершенствования, нацеленность на результат и презрение к поверхностным решениям. Мы ценим скорость, смысл и ответственность за конечный продукт.

    +
    +
    + +
    + Групповой снимок команды iiEasy. Разнообразные лица, уверенные и дружелюбные улыбки. Неформальная, но профессиональная обстановка. +
    Наша команда — наш главный актив.
    +
    + + {/* Principles */} +
    +

    Наши Принципы

    +
      + {principlesData.map((p, index) => ( + + {p.content} + + ))} +
    +
    + +
    + Панорамный вид на Уфу с акцентом на современные здания (например, Конгресс-холл Торатау), символизирующий связь компании с городом и ее нацеленность на будущее развитие региона. +
    Нацелены на будущее развитие нашего региона.
    +
    +
    +
    + ); +}; + +export default AboutUsSection; \ No newline at end of file diff --git a/components/AcceleratorAboutSection.tsx b/components/AcceleratorAboutSection.tsx new file mode 100755 index 0000000..c99258c --- /dev/null +++ b/components/AcceleratorAboutSection.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { SECTION_IDS, mockAcceleratorFeatures, ACCELERATOR_ABOUT_CONTENT } from '../constants'; // Import constants +import { AcceleratorFeature } from '../types'; // Import AcceleratorFeature type +import BoldIdeasCarousel from './BoldIdeasCarousel'; // Import the new component +// Icons for contact methods are now part of ACCELERATOR_ABOUT_CONTENT.contactMethods + +interface FeatureCardProps { + icon: React.ElementType; + title: string; + description: string; +} + +const FeatureCard: React.FC = ({ icon: Icon, title, description }) => ( +
    +
    + +

    {title}

    +
    +

    + {description} +

    +
    +); + +const BOLD_IDEAS_1 = [ + "Создать ИИ-систему для мониторинга экологии Башкортостана", + "Разработать приложение для изучения башкирского языка с ИИ-помощником", + "Автоматизировать документооборот в государственных учреждениях", + "Платформа для предиктивной аналитики в сельском хозяйстве региона", +]; + +const BOLD_IDEAS_2 = [ + "ИИ-ассистент для туристов, путешествующих по Уфе и окрестностям", + "Система компьютерного зрения для контроля качества на производстве", + "Адаптивная образовательная платформа для школьников", + "Разработать ИИ для оптимизации городской логистики в Уфе" +]; + + +const AcceleratorAboutSection: React.FC = () => { + return ( +
    +
    +
    +

    + {ACCELERATOR_ABOUT_CONTENT.title} +

    +

    + {ACCELERATOR_ABOUT_CONTENT.subtitle} +

    +
    + +
    +

    {ACCELERATOR_ABOUT_CONTENT.whatWeOfferTitle}

    +

    + {ACCELERATOR_ABOUT_CONTENT.whatWeOfferParagraph} +

    +
    + +
    + {mockAcceleratorFeatures.map((feature: AcceleratorFeature) => ( + + ))} +
    + + {/* New "Bold Ideas" Section */} +
    +

    + У вас есть смелые идеи? +

    +
    + + +
    +
    + +
    +

    + {ACCELERATOR_ABOUT_CONTENT.ctaTitle} +

    +

    + {ACCELERATOR_ABOUT_CONTENT.ctaSubtitle} +

    +
    + {ACCELERATOR_ABOUT_CONTENT.contactMethods.map((method) => ( + + + {method.name} + + ))} +
    +
    + +
    +
    + ); +}; + +export default AcceleratorAboutSection; \ No newline at end of file diff --git a/components/AcceleratorInvestmentSection.tsx b/components/AcceleratorInvestmentSection.tsx new file mode 100755 index 0000000..30cd86f --- /dev/null +++ b/components/AcceleratorInvestmentSection.tsx @@ -0,0 +1,130 @@ + + + +import React from 'react'; +import { SECTION_IDS, ACCELERATOR_INVESTMENT_CONTENT } from '../constants'; +import { InvestmentProject } from '../types'; +import Button from './Button'; +import { CurrencyDollarIcon, PresentationChartLineIcon, UsersIcon, LightBulbIcon } from '@heroicons/react/24/outline'; + +interface InvestmentCardProps { + project: InvestmentProject; +} + +const InvestmentCard: React.FC = ({ project }) => ( +
    +
    + {project.name} +
    +
    +

    {project.name}

    +

    {project.industry}

    + +
    +

    {ACCELERATOR_INVESTMENT_CONTENT.problemLabel}

    +
    +
    +
    +

    {ACCELERATOR_INVESTMENT_CONTENT.solutionLabel}

    +
    +
    + +
    +
    + {ACCELERATOR_INVESTMENT_CONTENT.teamLabel} {project.teamSize} чел. +
    +
    + {ACCELERATOR_INVESTMENT_CONTENT.fundingLabel} {project.fundingSought} +
    +
    + + {project.contactEmail ? ( + + ) : ( + + )} +
    +
    +); + +interface AcceleratorInvestmentSectionProps { + investmentProjects: InvestmentProject[]; +} + +const AcceleratorInvestmentSection: React.FC = ({ investmentProjects }) => { + return ( +
    +
    +
    +

    + {ACCELERATOR_INVESTMENT_CONTENT.title} +

    +

    + {ACCELERATOR_INVESTMENT_CONTENT.subtitle} +

    +
    + + {investmentProjects && investmentProjects.length > 0 ? ( +
    + {investmentProjects.map((project: InvestmentProject) => ( + + ))} +
    + ) : ( +
    + +

    {ACCELERATOR_INVESTMENT_CONTENT.emptyStateTitle}

    +

    + {ACCELERATOR_INVESTMENT_CONTENT.emptyStateMessage} +

    +
    + )} + +
    + +

    + {ACCELERATOR_INVESTMENT_CONTENT.investorCtaTitle} +

    +

    + {ACCELERATOR_INVESTMENT_CONTENT.investorCtaSubtitle} +

    +
    + {ACCELERATOR_INVESTMENT_CONTENT.contactMethods.map((method) => ( + + + {method.name} + + ))} +
    +
    +
    +
    + ); +}; + +export default AcceleratorInvestmentSection; diff --git a/components/AcceleratorProjectsSection.tsx b/components/AcceleratorProjectsSection.tsx new file mode 100755 index 0000000..3b86706 --- /dev/null +++ b/components/AcceleratorProjectsSection.tsx @@ -0,0 +1,85 @@ + + +import React from 'react'; +import { SECTION_IDS, ACCELERATOR_PROJECTS_CONTENT } from '../constants'; +import { AcceleratorProject } from '../types'; +import Button from './Button'; +import { CubeTransparentIcon, TagIcon, LinkIcon } from '@heroicons/react/24/outline'; + +interface ProjectCardProps { + project: AcceleratorProject; +} + +const ProjectCard: React.FC = ({ project }) => ( +
    +
    + {project.name} +
    +
    +

    {project.name}

    +

    {project.tagline}

    +
    + + {project.category} + + + {project.statuspro} + +
    +
    + {project.websiteUrl && ( + + )} +
    +
    +); + +interface AcceleratorProjectsSectionProps { + acceleratorProjects: AcceleratorProject[]; +} + +const AcceleratorProjectsSection: React.FC = ({ acceleratorProjects }) => { + return ( +
    +
    +
    +

    + {ACCELERATOR_PROJECTS_CONTENT.title} +

    +

    + {ACCELERATOR_PROJECTS_CONTENT.subtitle} +

    +
    + + {acceleratorProjects && acceleratorProjects.length > 0 ? ( +
    + {acceleratorProjects.map((project: AcceleratorProject) => ( + + ))} +
    + ) : ( +
    + +

    {ACCELERATOR_PROJECTS_CONTENT.emptyStateTitle}

    +

    + {ACCELERATOR_PROJECTS_CONTENT.emptyStateMessage} +

    +
    + )} +
    +
    + ); +}; + +export default AcceleratorProjectsSection; \ No newline at end of file diff --git a/components/AdminLoginPage.tsx b/components/AdminLoginPage.tsx new file mode 100755 index 0000000..e4f4cb9 --- /dev/null +++ b/components/AdminLoginPage.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import Button from './Button'; +import Logo from './Logo'; +import { APP_NAME } from '../constants'; + +interface AdminLoginPageProps { + onLoginSuccess: () => void; +} + +// SHA-256 hash of "IZI348ax@" is 8b1f38e07289947545ea88540871147573199ce071804100412e0b5f590d7c71 +const STORED_HASH = '8b1f38e07289947545ea88540871147573199ce071804100412e0b5f590d7c71'; + +async function sha256(message: string): Promise { + const msgBuffer = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; +} + +const AdminLoginPage: React.FC = ({ onLoginSuccess }) => { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + const inputHash = await sha256(password); + if (inputHash === STORED_HASH) { + onLoginSuccess(); + } else { + setError('Неверный пароль. Пожалуйста, попробуйте снова.'); + } + setIsLoading(false); + setPassword(''); + }; + + return ( +
    +
    +
    + +

    + Админ-панель {APP_NAME} +

    +

    + Редактирование констант приложения +

    +
    +
    +
    + + setPassword(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-white border border-slate-300 rounded-md placeholder-slate-400 focus:outline-none focus:ring-slate-500 focus:border-slate-500 sm:text-sm text-slate-900" + placeholder="Введите ваш пароль" + /> +
    + {error &&

    {error}

    } + +
    +

    + Только для авторизованного персонала. Все действия могут отслеживаться. +

    +
    +
    + ); +}; + +export default AdminLoginPage; \ No newline at end of file diff --git a/components/ApproachCard.tsx b/components/ApproachCard.tsx new file mode 100755 index 0000000..ea54b06 --- /dev/null +++ b/components/ApproachCard.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { PlusIcon } from './icons'; + +interface ApproachCardProps { + title: string; + description: string; + visual: React.ReactNode; +} + +const ApproachCard: React.FC = ({ title, description, visual }) => { + return ( +
    +

    {title}

    +
    + {visual} +
    +
    +

    {description}

    + +
    +
    + ); +}; + +export default ApproachCard; diff --git a/components/BlogPostDetail.tsx b/components/BlogPostDetail.tsx new file mode 100755 index 0000000..f5558d2 --- /dev/null +++ b/components/BlogPostDetail.tsx @@ -0,0 +1,178 @@ +import React, { useRef, useState } from 'react'; +import { CurrentView, Post } from '../types'; +import { SECTION_IDS, BLOG_POST_DETAIL_CONTENT, UI_TEXTS } from '../constants'; // Removed mockPosts +import Button from './Button'; +import { ArrowUturnLeftIcon, PlayIcon, PauseIcon, ChevronRightIcon } from './icons'; +import ItemDetailNavigation from './ItemDetailNavigation'; +import PostCard from './PostCard'; +import GalleryComponent from './GalleryComponent'; // Import GalleryComponent + +interface BlogPostDetailProps { + itemId: string; + allPosts: Post[]; + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; +} + +const BlogPostDetail: React.FC = ({ itemId, allPosts, setCurrentView, setSelectedItemId }) => { + const post = allPosts.find(p => p.id === itemId); + const videoRef = useRef(null); + const [isVideoPlaying, setIsVideoPlaying] = useState(false); + + const handleBackClick = () => { + setCurrentView('storiesAll'); + setSelectedItemId(null); + }; + + const handleNavigateItem = (newItemId: string) => { + setSelectedItemId(newItemId); + }; + + const handleShowAllKeepReading = (e: React.MouseEvent) => { + e.preventDefault(); + setCurrentView('storiesAll'); + setSelectedItemId(null); + }; + + if (!post) { + return ( +
    +
    +

    {BLOG_POST_DETAIL_CONTENT.notFoundTitle}

    + +
    +
    + ); + } + + const currentIndex = allPosts.findIndex(p => p.id === itemId); + const previousPost = currentIndex > 0 ? allPosts[currentIndex - 1] : undefined; + const nextPost = currentIndex < allPosts.length - 1 ? allPosts[currentIndex + 1] : undefined; + + const relatedPosts = allPosts + .filter(p => p.id !== itemId) + .slice(0, 3); + + const toggleVideoPlay = (e: React.MouseEvent) => { + e.stopPropagation(); + if (videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play(); + setIsVideoPlaying(true); + } else { + videoRef.current.pause(); + setIsVideoPlaying(false); + } + } + }; + + return ( +
    +
    + + + {post.videoUrl ? ( +
    + +
    + ) : post.imageUrl && ( +
    + {post.title} +
    + )} + +
    +
    +

    + {post.title} +

    +
    + {post.category} + {(post.date || post.readTime) && } + {post.date && {post.date}} + {post.date && post.readTime && } + {post.readTime && {post.readTime}} +
    +
    + +
    + {typeof post.fullContent === 'string' ? ( +
    + ) : ( + post.fullContent + )} +
    + + {post.gallery && post.gallery.length > 0 && ( + + )} + + +
    + + {relatedPosts.length > 0 && ( +
    +
    +

    + {BLOG_POST_DETAIL_CONTENT.keepReadingTitle || "Читайте также"} +

    + + {BLOG_POST_DETAIL_CONTENT.viewAllButtonText || `Все ${BLOG_POST_DETAIL_CONTENT.itemTypePlural}`} + + +
    +
    + {relatedPosts.map(relatedPost => ( + + ))} +
    +
    + )} +
    +
    + ); +}; + +export default BlogPostDetail; \ No newline at end of file diff --git a/components/BlogSection.tsx b/components/BlogSection.tsx new file mode 100755 index 0000000..21cb352 --- /dev/null +++ b/components/BlogSection.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { CurrentView, Post } from '../types'; // Added Post type +import { SECTION_IDS, BLOG_SECTION_CONTENT } from '../constants'; +import { ChevronRightIcon } from './icons'; +import PostCard from './PostCard'; + +interface BlogSectionProps { + posts: Post[]; // Changed from mockPosts to accept fetched data + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; +} + +const BlogSection: React.FC = ({ posts, setCurrentView, setSelectedItemId }) => { + const featuredPost = posts.find(p => p.isFeatured); + const otherPosts = posts.filter(p => !p.isFeatured); + + const handleShowAllClick = (e: React.MouseEvent) => { + e.preventDefault(); + setCurrentView('storiesAll'); + setSelectedItemId(null); + }; + + // App.tsx handles the main loading and error states. + // This component now assumes that if `posts` is empty, it's an empty data state, not a loading state. + if (posts.length === 0) { + return ( +
    +
    +
    +

    + {BLOG_SECTION_CONTENT.title} +

    + + {BLOG_SECTION_CONTENT.showAllText} + + +
    +
    +

    Проектов пока нет.

    +

    Возможно, они еще не опубликованы. Пожалуйста, проверьте позже.

    +
    +
    +
    + ); + } + + return ( +
    +
    +
    +

    + {BLOG_SECTION_CONTENT.title} +

    + + {BLOG_SECTION_CONTENT.showAllText} + + +
    + +
    + {featuredPost && ( +
    + +
    + )} + +
    + {otherPosts.slice(featuredPost ? -3 : -4).reverse().map(post => ( + + ))} +
    +
    +
    +
    + ); +}; + +export default BlogSection; \ No newline at end of file diff --git a/components/BoldIdeasCarousel.tsx b/components/BoldIdeasCarousel.tsx new file mode 100755 index 0000000..04bdd94 --- /dev/null +++ b/components/BoldIdeasCarousel.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { ArrowTopRightIcon } from './icons'; + +interface BoldIdeasCarouselProps { + ideas: string[]; + direction?: 'normal' | 'reverse'; + duration?: string; +} + +const BoldIdeasCarousel: React.FC = ({ ideas, direction = 'normal', duration = '72s' }) => { + return ( +
    +
    + {/* Render the list twice for seamless loop */} + {[...ideas, ...ideas].map((idea, index) => ( + + ))} +
    +
    + ); +}; + +export default BoldIdeasCarousel; diff --git a/components/BusinessCard.tsx b/components/BusinessCard.tsx new file mode 100755 index 0000000..1923f2e --- /dev/null +++ b/components/BusinessCard.tsx @@ -0,0 +1,85 @@ + +import React from 'react'; +import { BusinessStory, CurrentView } from '../types'; +import { ViewMode } from './FilterSortBar'; + +interface BusinessCardProps { + story: BusinessStory; + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; + className?: string; + viewMode?: ViewMode; +} + +const BusinessCard: React.FC = ({ story, setCurrentView, setSelectedItemId, className = "", viewMode = 'grid' }) => { + + const handleCardClick = () => { + setCurrentView('businessStoryDetail'); + setSelectedItemId(story.id); + }; + + if (viewMode === 'list') { + return ( +
    +
    +
    + {`Изображение +
    +
    +

    + {story.title} +

    +

    + {story.category} +

    + {story.description && ( +

    {story.description}

    + )} +
    +
    +
    + ); + } + + // Grid view + return ( +
    +
    + {`Изображение +
    +
    +

    + {story.category} +

    +

    + {story.title} +

    + {story.description && ( +

    {story.description}

    + )} +
    +
    + ); +}; + +export default BusinessCard; diff --git a/components/BusinessLandingSection.tsx b/components/BusinessLandingSection.tsx new file mode 100755 index 0000000..24070f6 --- /dev/null +++ b/components/BusinessLandingSection.tsx @@ -0,0 +1,118 @@ +import React, { useState, useMemo } from 'react'; +import { CurrentView, BusinessStory } from '../types'; +import { SECTION_IDS, BUSINESS_LANDING_CONTENT } from '../constants'; +import BusinessCard from './BusinessCard'; +import FilterSortBar, { ViewMode, SortOptionKey, SortOption } from './FilterSortBar'; + +interface BusinessLandingSectionProps { + businessStories: BusinessStory[]; + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; +} + +const sortOptions: SortOption[] = [ + { key: 'alphabetical', label: 'По алфавиту (А-Я)' }, +]; + +const BusinessLandingSection: React.FC = ({ businessStories, setCurrentView, setSelectedItemId }) => { + const [viewMode, setViewMode] = useState('grid'); + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedSort, setSelectedSort] = useState('alphabetical'); + + const availableCategories = useMemo(() => { + if (!businessStories) return []; + return Array.from(new Set(businessStories.map(p => p.category))).sort(); + }, [businessStories]); + + const handleCategoryChange = (category: string) => { + setSelectedCategories(prev => + prev.includes(category) + ? prev.filter(c => c !== category) + : [...prev, category] + ); + }; + + const filteredAndSortedStories = useMemo(() => { + if (!businessStories) return []; + + const filtered = selectedCategories.length > 0 + ? businessStories.filter(p => selectedCategories.includes(p.category)) + : businessStories; + + return [...filtered].sort((a, b) => { + if (selectedSort === 'alphabetical') { + return a.title.localeCompare(b.title); + } + return 0; // Only alphabetical sort is supported + }); + }, [businessStories, selectedCategories, selectedSort]); + + + if (!businessStories) { + return ( +
    +

    Загрузка кейсов...

    +
    + ); + } + + const layoutClasses = viewMode === 'grid' + ? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8" + : "flex flex-col gap-4"; + + return ( +
    +
    +
    +

    + {BUSINESS_LANDING_CONTENT.title} +

    +

    + {BUSINESS_LANDING_CONTENT.subtitle} +

    +
    + + setSelectedCategories([])} + sortOptions={sortOptions} + selectedSort={selectedSort} + onSortChange={setSelectedSort} + /> + + {filteredAndSortedStories.length > 0? ( +
    + {filteredAndSortedStories.map(story => ( + + ))} +
    + ) : ( +
    +

    {BUSINESS_LANDING_CONTENT.emptyStateTitle}

    +

    + {BUSINESS_LANDING_CONTENT.emptyStateMessage} +

    +
    + )} +
    +
    + ); +}; + +export default BusinessLandingSection; \ No newline at end of file diff --git a/components/BusinessSection.tsx b/components/BusinessSection.tsx new file mode 100755 index 0000000..a961c3c --- /dev/null +++ b/components/BusinessSection.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { CurrentView, BusinessStory } from '../types'; +import { SECTION_IDS, BUSINESS_SECTION_CONTENT } from '../constants'; +import { ChevronRightIcon } from './icons'; +import BusinessCard from './BusinessCard'; + +interface BusinessSectionProps { + businessStories: BusinessStory[]; // Added to accept fetched data + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; +} + +const BusinessSection: React.FC = ({ businessStories, setCurrentView, setSelectedItemId }) => { + + const handleShowAllClick = (e: React.MouseEvent) => { + e.preventDefault(); + setCurrentView('businessLanding'); + setSelectedItemId(null); + }; + + if (!businessStories || businessStories.length === 0) { + return ( +
    +
    + {/* Optionally, you can add a loading or empty state specific to this section */} +
    +
    + ); + } + + return ( +
    +
    +
    +

    + {BUSINESS_SECTION_CONTENT.title} +

    + + {BUSINESS_SECTION_CONTENT.showAllText} + + +
    +
    + +
    +
    + {businessStories.slice(-6).reverse().map(story => ( // Show up to 6 stories in the scroller + + ))} +
    +
    +
    + ); +}; + +export default BusinessSection; \ No newline at end of file diff --git a/components/BusinessServicesSection.tsx b/components/BusinessServicesSection.tsx new file mode 100755 index 0000000..a4c7cfe --- /dev/null +++ b/components/BusinessServicesSection.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { SECTION_IDS, BUSINESS_SERVICES_CONTENT } from '../constants'; +import { ServiceItemData, ClientLogo } from '../types'; +import { + ComputerDesktopIcon, CodeBracketIcon, CpuChipIcon, MagnifyingGlassIcon, + PaintBrushIcon, ShieldCheckIcon, UsersIcon, LightBulbIcon, ChartBarIcon, AcademicCapIcon, + PuzzlePieceIcon, CommandLineIcon, BriefcaseIcon, BuildingOffice2Icon, BuildingLibraryIcon, + CircleStackIcon, CurrencyDollarIcon, HomeIcon, InformationCircleIcon, NewspaperIcon, RocketLaunchIcon, + SparklesIcon as SoraIcon, WrenchScrewdriverIcon +} from '../components/icons'; // Ensure all potential icons are imported + +// Define an icon map +const iconMap: Record = { + ComputerDesktopIcon, + CodeBracketIcon, + CpuChipIcon, + MagnifyingGlassIcon, + PaintBrushIcon, + ShieldCheckIcon, + UsersIcon, + LightBulbIcon, + ChartBarIcon, + AcademicCapIcon, + PuzzlePieceIcon, + CommandLineIcon, + BriefcaseIcon, + BuildingOffice2Icon, + BuildingLibraryIcon, + CircleStackIcon, + CurrencyDollarIcon, + HomeIcon, + InformationCircleIcon, + NewspaperIcon, + RocketLaunchIcon, + SoraIcon, + WrenchScrewdriverIcon, + // Add other icons as needed, ensure keys match Strapi values +}; + +interface ServiceItemProps { + iconName: string; // Changed from icon: React.ElementType to iconName: string + title: string; + description: string; +} + +const ServiceItem: React.FC = ({ iconName, title, description }) => { + const IconComponent = iconMap[iconName] || CpuChipIcon; // Fallback to a default icon + return ( +
    +
    +
    +
    +
    +

    {title}

    +

    + {description} +

    +
    +
    +
    + ); +}; + +interface BusinessServicesSectionProps { + serviceItems: ServiceItemData[]; + clientLogos: ClientLogo[]; +} + +const BusinessServicesSection: React.FC = ({ serviceItems, clientLogos }) => { + return ( +
    +
    +
    +

    + {BUSINESS_SERVICES_CONTENT.title} +

    +

    + {BUSINESS_SERVICES_CONTENT.subtitle} +

    +
    + + {serviceItems && serviceItems.length > 0 ? ( +
    + {serviceItems.map((service) => ( + + ))} +
    + ) : ( +
    + +

    Услуги скоро будут добавлены.

    +
    + )} + + {clientLogos && clientLogos.length > 0 && ( +
    +

    + {BUSINESS_SERVICES_CONTENT.trustedByTitle} +

    +
    + {clientLogos.map((logo) => ( +
    + {logo.name} +
    + ))} +
    +
    + )} + +
    +

    + {BUSINESS_SERVICES_CONTENT.ctaTitle} +

    +

    + {BUSINESS_SERVICES_CONTENT.ctaSubtitle} +

    +
    + {BUSINESS_SERVICES_CONTENT.contactMethods.map((method) => ( + + + {method.name} + + ))} +
    +
    + +
    +
    + ); +}; + +export default BusinessServicesSection; diff --git a/components/BusinessStoryDetail.tsx b/components/BusinessStoryDetail.tsx new file mode 100755 index 0000000..aed0b50 --- /dev/null +++ b/components/BusinessStoryDetail.tsx @@ -0,0 +1,151 @@ + + +import React from 'react'; +import { CurrentView, BusinessStory } from '../types'; +import { SECTION_IDS, BUSINESS_STORY_DETAIL_CONTENT } from '../constants'; +import Button from './Button'; +import { ArrowUturnLeftIcon, ChevronRightIcon } from './icons'; +import ItemDetailNavigation from './ItemDetailNavigation'; +import BusinessCard from './BusinessCard'; +import GalleryComponent from './GalleryComponent'; // Import GalleryComponent + + +interface BusinessStoryDetailProps { + itemId: string; + allBusinessStories: BusinessStory[]; + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; +} + +const BusinessStoryDetail: React.FC = ({ itemId, allBusinessStories, setCurrentView, setSelectedItemId }) => { + const story = allBusinessStories.find(p => p.id === itemId); + + const handleBackClick = () => { + setCurrentView('businessLanding'); + setSelectedItemId(null); + }; + + const handleNavigateItem = (newItemId: string) => { + setSelectedItemId(newItemId); + }; + + const handleShowAllKeepReading = (e: React.MouseEvent) => { + e.preventDefault(); + setCurrentView('businessLanding'); + setSelectedItemId(null); + }; + + + if (!story) { + return ( +
    +
    +

    {BUSINESS_STORY_DETAIL_CONTENT.notFoundTitle}

    + +
    +
    + ); + } + + const currentIndex = allBusinessStories.findIndex(s => s.id === itemId); + const previousStory = currentIndex > 0 ? allBusinessStories[currentIndex - 1] : undefined; + const nextStory = currentIndex < allBusinessStories.length - 1 ? allBusinessStories[currentIndex + 1] : undefined; + + const relatedStories = allBusinessStories + .filter(s => s.id !== itemId) + .slice(0, 3); + + return ( +
    +
    + + + {story.imageUrl && ( +
    + {story.title} +
    + )} + +
    +
    +

    + {story.title} +

    +
    + {story.category} +
    +
    + +
    + {story.description &&

    {story.description}

    } + {typeof story.fullContent === 'string' ? ( +
    + ) : ( + story.fullContent + )} +
    + + {story.gallery && story.gallery.length > 0 && ( + + )} + + +
    + + {relatedStories.length > 0 && ( +
    +
    +

    + {BUSINESS_STORY_DETAIL_CONTENT.keepReadingTitle || "Другие кейсы"} +

    + + {BUSINESS_STORY_DETAIL_CONTENT.viewAllButtonText || `Все ${BUSINESS_STORY_DETAIL_CONTENT.itemTypePlural}`} + + +
    +
    + {relatedStories.map(relatedStory => ( + + ))} +
    +
    + )} + +
    +
    + ); +}; + +export default BusinessStoryDetail; \ No newline at end of file diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100755 index 0000000..a1bf064 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'outline' | 'secondary'; + size?: 'sm' | 'md' | 'lg'; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + children: React.ReactNode; +} + +const Button: React.FC = ({ + variant = 'primary', + size = 'md', + leftIcon, + rightIcon, + children, + className = '', + ...props +}) => { + const baseStyles = "inline-flex items-center justify-center font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand disabled:opacity-50 disabled:cursor-not-allowed"; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + const variantStyles = { + primary: 'text-slate-800 bg-slate-50 hover:bg-slate-100 focus:ring-slate-300 border border-slate-200', + outline: 'text-slate-700 border border-slate-300 hover:bg-slate-100 focus:ring-slate-300', + secondary: 'text-slate-700 hover:bg-slate-100 focus:ring-slate-300', + }; + + return ( + + ); +}; + +export default Button; diff --git a/components/CareersSection.tsx b/components/CareersSection.tsx new file mode 100755 index 0000000..66ec713 --- /dev/null +++ b/components/CareersSection.tsx @@ -0,0 +1,96 @@ + + +import React from 'react'; +import { SECTION_IDS, CAREERS_PAGE_CONTENT } from '../constants'; // mockVacancies removed +import Button from './Button'; +import { ChevronRightIcon, BriefcaseIcon } from './icons'; // Added BriefcaseIcon +import { CurrentView, Vacancy } from '../types'; + +interface CareersSectionProps { + vacancies: Vacancy[]; // Changed from mockVacancies to accept prop + setCurrentView: (view: CurrentView) => void; + setSelectedItemId: (id: string | null) => void; +} + +const CareersSection: React.FC = ({ vacancies, setCurrentView, setSelectedItemId }) => { + + const handleDetailsClick = (vacancyId: string) => { + setCurrentView('vacancyDetail'); + setSelectedItemId(vacancyId); + }; + + return ( +
    +
    +
    +

    + {CAREERS_PAGE_CONTENT.title} +

    +

    + {CAREERS_PAGE_CONTENT.subtitle} +

    +
    + +
    + {vacancies && vacancies.length > 0 ? ( + vacancies.map((vacancy: Vacancy) => ( +
    +
    +
    +

    {vacancy.title}

    +

    + {vacancy.department} • {vacancy.location} • {vacancy.type} +

    +

    {vacancy.description}

    +
    +
    + +
    +
    +
    + )) + ) : ( +
    + +

    {CAREERS_PAGE_CONTENT.noVacanciesTitle}

    +

    + {CAREERS_PAGE_CONTENT.noVacanciesMessagePt1} +

    +

    + Вы можете следить за обновлениями на этой странице или отправить свое резюме и сопроводительное письмо на адрес {CAREERS_PAGE_CONTENT.noVacanciesContactEmailText}. {CAREERS_PAGE_CONTENT.noVacanciesMessagePt2} +

    +
    + )} +
    + +
    +

    {CAREERS_PAGE_CONTENT.notFoundTitle}

    +

    + {CAREERS_PAGE_CONTENT.notFoundMessage} +

    + +
    + +
    +
    + ); +}; + +export default CareersSection; \ No newline at end of file diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx new file mode 100755 index 0000000..2757d8b --- /dev/null +++ b/components/ChatInput.tsx @@ -0,0 +1,194 @@ + + + +import React, { useState, useEffect, useRef } from 'react'; +import { CurrentView } from '../types'; +import { NAVIGABLE_VIEWS } from '../constants'; +import { ArrowUpIcon } from './icons'; + +const PLACEHOLDERS = [ + "Создайте мне сайт для моего бизнеса", + "Какие у вас есть вакансии?", + "Расскажите о ваших услугах для бизнеса", + "Мне нужно мобильное приложение", + "Что такое ваш ИИ-акселератор?", + "Я хочу научиться ИИ", + "Покажите ваши последние исследования" +]; + +interface ChatInputProps { + setCurrentView: (view: CurrentView) => void; + isSticky?: boolean; + autoFocusOnMount?: boolean; +} + +const ChatInput: React.FC = ({ setCurrentView, isSticky = false, autoFocusOnMount = false }) => { + const [prompt, setPrompt] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPlaceholderIndex, setCurrentPlaceholderIndex] = useState(0); + const textareaRef = useRef(null); + + useEffect(() => { + const interval = setInterval(() => { + setCurrentPlaceholderIndex((prevIndex) => (prevIndex + 1) % PLACEHOLDERS.length); + }, 5000); // Change placeholder every 5 seconds + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + } + }, [prompt]); + + useEffect(() => { + if (autoFocusOnMount && textareaRef.current) { + // Small timeout to ensure the element is fully visible and transitions are complete before focusing + const timer = setTimeout(() => { + textareaRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + } + }, [autoFocusOnMount]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!prompt.trim() || isLoading) return; + + setIsLoading(true); + setError(null); + + const validViewsString = NAVIGABLE_VIEWS.join(', '); + const systemInstruction = `You are an expert navigation assistant for a website. Your task is to analyze the user's query and determine which section of the website they want to visit. Respond ONLY with a JSON object containing a single key "view". The possible values for "view" are: ${validViewsString}. + + Here are some examples of user queries and your expected JSON response: + - "I want to see your work" -> {"view": "storiesAll"} + - "Do you have any jobs?" -> {"view": "careers"} + - "What services do you offer for businesses?" -> {"view": "businessServices"} + - "I need a website" or "I need a program" -> {"view": "businessServices"} + - "Tell me about your company" -> {"view": "about"} + - "How can I contact you?" -> {"view": "main"} + - "What AI research do you do?" -> {"view": "researchAll"} + - "I want to learn AI" -> {"view": "educationStudents"} + - "I have an idea" or "I have a project" -> {"view": "acceleratorAbout"} + - "Do you have an accelerator?" -> {"view": "acceleratorAbout"} + + If the user's query is about having an idea or a project they want to develop, direct them to the accelerator. If they explicitly want to order a service like a website or an app, direct them to business services. + + If the user's query is unclear or doesn't map to a specific view, respond with {"view": "main"}. + Your entire response must be only the JSON object, with no other text, explanation, or markdown formatting.`; + + try { + const response = await fetch('https://ai.iieasy.ru/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'local-model', + messages: [ + { role: 'system', content: systemInstruction }, + { role: 'user', content: prompt } + ], + temperature: 0.1, + }), + }); + + if (!response.ok) { + throw new Error(`Ошибка сети: ${response.status} ${response.statusText}. Убедитесь, что сервер LM Studio запущен.`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(`LM Studio вернула ошибку: ${data.error.message}`); + } + + const responseJsonString = data.choices?.[0]?.message?.content; + if (!responseJsonString) { + throw new Error("LM Studio вернула пустой ответ."); + } + + // More robust JSON extraction + let parsedData = null; + const jsonMatch = responseJsonString.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + parsedData = JSON.parse(jsonMatch[0]); + } catch (e) { + // JSON might be invalid, fall through to error + console.error("Failed to parse JSON from response:", e); + } + } + + if (parsedData && parsedData.view && NAVIGABLE_VIEWS.includes(parsedData.view as any)) { + setCurrentView(parsedData.view); + } else { + setError("К сожалению, я не смог понять ваш запрос. Попробуйте переформулировать."); + } + } catch (err: any) { + console.error("Ошибка при обращении к LM Studio:", err); + setError(err.message || "Произошла ошибка. Убедитесь, что сервер LM Studio запущен и модель загружена. Пожалуйста, попробуйте еще раз."); + } finally { + setIsLoading(false); + } + }; + + const wrapperClasses = isSticky ? "" : "relative z-10 mx-auto w-full max-w-3xl"; + + const baseLabelClasses = "relative flex w-full cursor-text flex-col overflow-hidden rounded-3xl py-3 pl-4 pr-14 transition-all duration-300"; + + const nonStickyLabelClasses = "border border-slate-200 bg-white shadow-sm focus-within:shadow-md"; + const stickyLabelClasses = "border border-slate-300 bg-white/80 shadow-lg backdrop-blur-xl focus-within:shadow-xl"; + + const labelClasses = `${baseLabelClasses} ${isSticky ? stickyLabelClasses : nonStickyLabelClasses}`; + + + return ( +
    +
    +