Initial commit for iiEasy: all files included
This commit is contained in:
171
components/AboutUsSection.tsx
Executable file
171
components/AboutUsSection.tsx
Executable file
@@ -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: "Мы разрабатываем и внедряем кастомные ИИ-решения для бизнеса и государства. <strong>Для государства:</strong> улучшение обработки обращений граждан, предиктивная аналитика. <strong>Для бизнеса:</strong> оптимизация логистики, персонализация продаж, автоматизация документооборота."
|
||||
},
|
||||
{
|
||||
title: "2. Исследования и Разработки (R&D)",
|
||||
content: "Адаптируем и дообучаем передовые модели для локальных задач. Особый фокус — развитие языковых моделей для русского и башкирского языков, создание датасетов и сохранение культурного наследия."
|
||||
},
|
||||
{
|
||||
title: "3. Развитие Сообщества и Образование",
|
||||
content: "Мы стремимся стать центром притяжения для талантов в Уфе. Организуем митапы, воркшопы и образовательные программы, формируя кадровый резерв для ИИ-индустрии региона."
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const PrincipleItem: React.FC<{ title: string; children: React.ReactNode; index: number }> = ({ title, children, index }) => (
|
||||
<li
|
||||
className="flex items-start scroll-animate"
|
||||
style={{ transitionDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<SparklesIcon className="w-6 h-6 text-slate-500 mt-1" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-xl font-semibold font-quicksand text-slate-800">{title}</h4>
|
||||
<p className="mt-1 text-slate-600">{children}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.aboutUsPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen overflow-x-hidden"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl">
|
||||
{/* Header */}
|
||||
<header className="mb-12 md:mb-16 text-center scroll-animate">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900 leading-tight">
|
||||
iiEasy: Создаем Будущее с Помощью Искусственного Интеллекта.
|
||||
</h1>
|
||||
<p className="mt-4 text-2xl sm:text-3xl font-quicksand text-slate-600">Сделано в Уфе.</p>
|
||||
</header>
|
||||
|
||||
{/* Intro */}
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700 mb-12 md:mb-16 scroll-animate" style={{ transitionDelay: '100ms' }}>
|
||||
<p>Добро пожаловать в iiEasy — центр передовых разработок и исследований в области искусственного интеллекта. Мы — команда инженеров, исследователей и стратегов из Уфы, объединенных миссией по решению сложных задач для бизнеса и государства с помощью технологий. Мы не просто создаем сайты и приложения; мы проектируем интеллектуальные системы, которые оптимизируют процессы, улучшают принятие решений и открывают новые возможности для роста.</p>
|
||||
<p>Наша работа основана на синтезе фундаментальной экспертизы в программной инженерии и глубокого понимания современных ИИ-архитектур, включая большие языковые модели (LLM), компьютерное зрение (CV) и предиктивную аналитику.</p>
|
||||
</div>
|
||||
|
||||
<figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
|
||||
<img src="https://picsum.photos/seed/iieasy-team-work/1200/800" alt="Команда iiEasy за работой в современном офисе в Уфе, обсуждает проект на фоне доски с диаграммами и кодом. Атмосфера сфокусированной совместной работы." className="rounded-lg shadow-xl aspect-video object-cover"/>
|
||||
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Команда iiEasy в Уфе: от идеи к реализации.</figcaption>
|
||||
</figure>
|
||||
|
||||
{/* Mission */}
|
||||
<div className="my-16 md:my-24 text-center scroll-animate" style={{ transitionDelay: '100ms' }}>
|
||||
<h2 className="font-quicksand text-3xl sm:text-4xl font-bold text-slate-800">Наша Миссия</h2>
|
||||
<div className="prose prose-xl max-w-3xl mx-auto font-inter text-slate-700 mt-6">
|
||||
<p className="font-semibold text-slate-800">Сделать Башкортостан лидирующим регионом в области искусственного интеллекта.</p>
|
||||
<p>Мы стремимся к этой цели через решение практически значимых задач, создание продуктов мирового уровня и формирование сильного ИИ-сообщества. Мы верим, что технологии ИИ — это ключ к повышению эффективности экономики, улучшению качества жизни граждан и созданию устойчивого будущего для нашего региона.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approach */}
|
||||
<div className="my-16 md:my-24 scroll-animate" style={{ transitionDelay: '200ms' }}>
|
||||
<h2 className="font-quicksand text-3xl sm:text-4xl font-bold text-slate-800 mb-8 text-center">Наш Подход</h2>
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700 mb-6">
|
||||
<p>Наша деятельность строится на трех ключевых направлениях:</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{approachData.map((item, index) => (
|
||||
<div key={item.title} className="bg-white p-6 rounded-lg scroll-animate" style={{ transitionDelay: `${index * 100}ms` }}>
|
||||
<h3 className="font-quicksand text-xl font-semibold text-slate-800 mb-2">{item.title}</h3>
|
||||
<p className="text-slate-600" dangerouslySetInnerHTML={{ __html: item.content }}></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
|
||||
<img src="https://picsum.photos/seed/iieasy-code/1200/800" alt="Крупный план экрана с кодом или дашбордом предиктивной аналитики. На заднем плане — сфокусированный взгляд разработчика, отражающийся в мониторе." className="rounded-lg shadow-xl aspect-video object-cover"/>
|
||||
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Глубокая техническая работа — основа наших решений.</figcaption>
|
||||
</figure>
|
||||
|
||||
{/* Team */}
|
||||
<div className="my-16 md:my-24 scroll-animate" style={{ transitionDelay: '100ms' }}>
|
||||
<h2 className="font-quicksand text-3xl sm:text-4xl font-bold text-slate-800 mb-6 text-center">Наша Команда</h2>
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
<p>Команда iiEasy — это сплав системного мышления, инженерной прагматичности и стратегического видения. В наших рядах — высококвалифицированные специалисты по машинному обучению, NLP, CV, анализу данных и разработке высоконагруженных систем. Нас объединяет культура постоянного самосовершенствования, нацеленность на результат и презрение к поверхностным решениям. Мы ценим скорость, смысл и ответственность за конечный продукт.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
|
||||
<img src="https://picsum.photos/seed/iieasy-team-group/1200/800" alt="Групповой снимок команды iiEasy. Разнообразные лица, уверенные и дружелюбные улыбки. Неформальная, но профессиональная обстановка." className="rounded-lg shadow-xl aspect-video object-cover"/>
|
||||
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Наша команда — наш главный актив.</figcaption>
|
||||
</figure>
|
||||
|
||||
{/* Principles */}
|
||||
<div className="my-16 md:my-24 scroll-animate" style={{ transitionDelay: '100ms' }}>
|
||||
<h2 className="font-quicksand text-3xl sm:text-4xl font-bold text-slate-800 mb-10 text-center">Наши Принципы</h2>
|
||||
<ul className="space-y-8">
|
||||
{principlesData.map((p, index) => (
|
||||
<PrincipleItem key={p.title} title={p.title} index={index}>
|
||||
{p.content}
|
||||
</PrincipleItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<figure className="mt-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
|
||||
<img src="https://picsum.photos/seed/iieasy-ufa-view/1200/800" alt="Панорамный вид на Уфу с акцентом на современные здания (например, Конгресс-холл Торатау), символизирующий связь компании с городом и ее нацеленность на будущее развитие региона." className="rounded-lg shadow-xl aspect-video object-cover"/>
|
||||
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Нацелены на будущее развитие нашего региона.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutUsSection;
|
||||
114
components/AcceleratorAboutSection.tsx
Executable file
114
components/AcceleratorAboutSection.tsx
Executable file
@@ -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<FeatureCardProps> = ({ icon: Icon, title, description }) => (
|
||||
<div className="bg-white p-6 rounded-xl">
|
||||
<div className="flex items-center text-slate-700 mb-4">
|
||||
<Icon className="w-8 h-8 mr-3" />
|
||||
<h3 className="font-quicksand text-xl font-semibold text-slate-800">{title}</h3>
|
||||
</div>
|
||||
<p className="font-inter text-slate-600 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BOLD_IDEAS_1 = [
|
||||
"Создать ИИ-систему для мониторинга экологии Башкортостана",
|
||||
"Разработать приложение для изучения башкирского языка с ИИ-помощником",
|
||||
"Автоматизировать документооборот в государственных учреждениях",
|
||||
"Платформа для предиктивной аналитики в сельском хозяйстве региона",
|
||||
];
|
||||
|
||||
const BOLD_IDEAS_2 = [
|
||||
"ИИ-ассистент для туристов, путешествующих по Уфе и окрестностям",
|
||||
"Система компьютерного зрения для контроля качества на производстве",
|
||||
"Адаптивная образовательная платформа для школьников",
|
||||
"Разработать ИИ для оптимизации городской логистики в Уфе"
|
||||
];
|
||||
|
||||
|
||||
const AcceleratorAboutSection: React.FC = () => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.acceleratorAboutPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{ACCELERATOR_ABOUT_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{ACCELERATOR_ABOUT_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700 mb-12">
|
||||
<h2 className="font-quicksand text-2xl sm:text-3xl font-semibold mt-0 mb-4 text-slate-800">{ACCELERATOR_ABOUT_CONTENT.whatWeOfferTitle}</h2>
|
||||
<p>
|
||||
{ACCELERATOR_ABOUT_CONTENT.whatWeOfferParagraph}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-16">
|
||||
{mockAcceleratorFeatures.map((feature: AcceleratorFeature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
icon={feature.icon}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New "Bold Ideas" Section */}
|
||||
<div className="my-16 md:my-20">
|
||||
<h2 className="text-center font-quicksand text-3xl sm:text-4xl font-semibold text-slate-700 mb-10">
|
||||
У вас есть смелые идеи?
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<BoldIdeasCarousel ideas={BOLD_IDEAS_1} direction="reverse" duration="80s" />
|
||||
<BoldIdeasCarousel ideas={BOLD_IDEAS_2} direction="normal" duration="90s" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center bg-white p-8 md:p-12 rounded-xl">
|
||||
<h2 className="text-3xl font-quicksand font-semibold text-slate-800 mb-6">
|
||||
{ACCELERATOR_ABOUT_CONTENT.ctaTitle}
|
||||
</h2>
|
||||
<p className="font-inter text-lg text-slate-600 mb-8 max-w-xl mx-auto">
|
||||
{ACCELERATOR_ABOUT_CONTENT.ctaSubtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap justify-center items-center gap-4 sm:gap-6">
|
||||
{ACCELERATOR_ABOUT_CONTENT.contactMethods.map((method) => (
|
||||
<a
|
||||
key={method.id}
|
||||
href={method.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group inline-flex items-center justify-center px-6 py-3 text-base font-medium text-slate-800 ${method.bgColor} ${method.hoverBgColor} rounded-full focus:outline-none focus:ring-2 ${method.ringColor} focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand w-full sm:w-auto sm:min-w-[180px]`}
|
||||
aria-label={method.ariaLabel}
|
||||
>
|
||||
<method.icon className="w-5 h-5" />
|
||||
<span className="ml-2.5">{method.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcceleratorAboutSection;
|
||||
130
components/AcceleratorInvestmentSection.tsx
Executable file
130
components/AcceleratorInvestmentSection.tsx
Executable file
@@ -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<InvestmentCardProps> = ({ project }) => (
|
||||
<div className="bg-white rounded-xl transition-all duration-300 ease-in-out flex flex-col overflow-hidden">
|
||||
<div className="relative w-full aspect-[16/9] overflow-hidden">
|
||||
<img src={project.imageUrl} alt={project.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-grow">
|
||||
<h3 className="font-quicksand text-2xl font-semibold text-slate-800 mb-1">{project.name}</h3>
|
||||
<p className="font-inter text-sm text-slate-600 mb-3">{project.industry}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className="font-inter text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">{ACCELERATOR_INVESTMENT_CONTENT.problemLabel}</h4>
|
||||
<div className="font-inter text-sm text-slate-600 line-clamp-2" dangerouslySetInnerHTML={{ __html: project.problem }}></div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h4 className="font-inter text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">{ACCELERATOR_INVESTMENT_CONTENT.solutionLabel}</h4>
|
||||
<div className="font-inter text-sm text-slate-600 line-clamp-2" dangerouslySetInnerHTML={{ __html: project.solution }}></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-4 font-inter text-slate-700">
|
||||
<div className="flex items-center">
|
||||
<UsersIcon className="w-4 h-4 mr-1.5 text-slate-500"/> {ACCELERATOR_INVESTMENT_CONTENT.teamLabel} {project.teamSize} чел.
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CurrencyDollarIcon className="w-4 h-4 mr-1.5 text-slate-500"/> {ACCELERATOR_INVESTMENT_CONTENT.fundingLabel} {project.fundingSought}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project.contactEmail ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => window.location.href = `mailto:${project.contactEmail}?subject=Инвестиции в ${project.name}`}
|
||||
leftIcon={<PresentationChartLineIcon />}
|
||||
className="mt-auto"
|
||||
>
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.contactProjectButtonText}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled
|
||||
className="mt-auto"
|
||||
>
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.requestContactButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface AcceleratorInvestmentSectionProps {
|
||||
investmentProjects: InvestmentProject[];
|
||||
}
|
||||
|
||||
const AcceleratorInvestmentSection: React.FC<AcceleratorInvestmentSectionProps> = ({ investmentProjects }) => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.acceleratorInvestmentPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{investmentProjects && investmentProjects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{investmentProjects.map((project: InvestmentProject) => (
|
||||
<InvestmentCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white p-6 rounded-xl">
|
||||
<CurrencyDollarIcon className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{ACCELERATOR_INVESTMENT_CONTENT.emptyStateTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 text-center bg-white p-8 md:p-12 rounded-xl">
|
||||
<LightBulbIcon className="w-12 h-12 mx-auto text-slate-500 mb-4" />
|
||||
<h2 className="text-3xl font-quicksand font-semibold text-slate-800 mb-6">
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.investorCtaTitle}
|
||||
</h2>
|
||||
<p className="font-inter text-lg text-slate-600 mb-8 max-w-xl mx-auto">
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.investorCtaSubtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap justify-center items-center gap-4 sm:gap-6">
|
||||
{ACCELERATOR_INVESTMENT_CONTENT.contactMethods.map((method) => (
|
||||
<a
|
||||
key={method.id}
|
||||
href={method.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group inline-flex items-center justify-center px-6 py-3 text-base font-medium text-slate-800 ${method.bgColor} ${method.hoverBgColor} rounded-full focus:outline-none focus:ring-2 ${method.ringColor} focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand w-full sm:w-auto sm:min-w-[180px]`}
|
||||
aria-label={method.ariaLabel}
|
||||
>
|
||||
<method.icon className="w-5 h-5" />
|
||||
<span className="ml-2.5">{method.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcceleratorInvestmentSection;
|
||||
85
components/AcceleratorProjectsSection.tsx
Executable file
85
components/AcceleratorProjectsSection.tsx
Executable file
@@ -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<ProjectCardProps> = ({ project }) => (
|
||||
<div className="bg-white rounded-xl transition-all duration-300 ease-in-out flex flex-col overflow-hidden">
|
||||
<div className="relative w-full aspect-[16/9] overflow-hidden">
|
||||
<img src={project.imageUrl} alt={project.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-grow">
|
||||
<h3 className="font-quicksand text-2xl font-semibold text-slate-800 mb-1">{project.name}</h3>
|
||||
<p className="font-inter text-sm text-slate-600 mb-2">{project.tagline}</p>
|
||||
<div className="flex items-center text-xs text-slate-500 mb-3">
|
||||
<TagIcon className="w-4 h-4 mr-1 text-slate-500" />
|
||||
<span>{project.category}</span>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-700">
|
||||
{project.statuspro}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-inter text-slate-600 leading-relaxed line-clamp-3 mb-4 flex-grow" dangerouslySetInnerHTML={{ __html: project.description }}></div>
|
||||
{project.websiteUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(project.websiteUrl, '_blank')}
|
||||
leftIcon={<LinkIcon />}
|
||||
className="mt-auto"
|
||||
>
|
||||
{ACCELERATOR_PROJECTS_CONTENT.projectWebsiteButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface AcceleratorProjectsSectionProps {
|
||||
acceleratorProjects: AcceleratorProject[];
|
||||
}
|
||||
|
||||
const AcceleratorProjectsSection: React.FC<AcceleratorProjectsSectionProps> = ({ acceleratorProjects }) => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.acceleratorProjectsPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{ACCELERATOR_PROJECTS_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{ACCELERATOR_PROJECTS_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{acceleratorProjects && acceleratorProjects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{acceleratorProjects.map((project: AcceleratorProject) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white p-6 rounded-xl">
|
||||
<CubeTransparentIcon className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{ACCELERATOR_PROJECTS_CONTENT.emptyStateTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{ACCELERATOR_PROJECTS_CONTENT.emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcceleratorProjectsSection;
|
||||
86
components/AdminLoginPage.tsx
Executable file
86
components/AdminLoginPage.tsx
Executable file
@@ -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<string> {
|
||||
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<AdminLoginPageProps> = ({ 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 (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-white p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Logo width={120} height={90} className="mx-auto" />
|
||||
<h1 className="mt-6 text-3xl font-quicksand font-bold text-slate-800">
|
||||
Админ-панель {APP_NAME}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Редактирование констант приложения
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl p-8 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700 font-quicksand">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => 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="Введите ваш пароль"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Вход...' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-8 text-center text-xs text-slate-500">
|
||||
Только для авторизованного персонала. Все действия могут отслеживаться.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
31
components/ApproachCard.tsx
Executable file
31
components/ApproachCard.tsx
Executable file
@@ -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<ApproachCardProps> = ({ title, description, visual }) => {
|
||||
return (
|
||||
<div className="border border-slate-200 p-6 rounded-lg group flex flex-col h-full bg-white">
|
||||
<h3 className="text-xl font-semibold font-quicksand text-slate-800">{title}</h3>
|
||||
<div className="mt-4 flex-grow flex items-center justify-center min-h-[12rem]">
|
||||
{visual}
|
||||
</div>
|
||||
<div className="mt-4 flex-grow flex flex-col">
|
||||
<p className="text-slate-600 font-inter text-sm flex-grow">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 flex items-center justify-center w-8 h-8 rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700 transition-colors"
|
||||
aria-label={`Learn more about ${title}`}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApproachCard;
|
||||
178
components/BlogPostDetail.tsx
Executable file
178
components/BlogPostDetail.tsx
Executable file
@@ -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<BlogPostDetailProps> = ({ itemId, allPosts, setCurrentView, setSelectedItemId }) => {
|
||||
const post = allPosts.find(p => p.id === itemId);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('storiesAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
const handleNavigateItem = (newItemId: string) => {
|
||||
setSelectedItemId(newItemId);
|
||||
};
|
||||
|
||||
const handleShowAllKeepReading = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('storiesAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<section id={SECTION_IDS.blogPostDetailPage} className="py-16 md:py-24 min-h-screen flex items-center justify-center">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-slate-700">{BLOG_POST_DETAIL_CONTENT.notFoundTitle}</h1>
|
||||
<Button onClick={handleBackClick} leftIcon={<ArrowUturnLeftIcon />} className="mt-8">
|
||||
{BLOG_POST_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.blogPostDetailPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="mb-6"
|
||||
>
|
||||
{BLOG_POST_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
|
||||
{post.videoUrl ? (
|
||||
<div className="mb-8 rounded-lg overflow-hidden relative aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full object-cover"
|
||||
poster={post.imageUrl}
|
||||
onPlay={() => setIsVideoPlaying(true)}
|
||||
onPause={() => setIsVideoPlaying(false)}
|
||||
>
|
||||
<source src={post.videoUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
) : post.imageUrl && (
|
||||
<div className="mb-8 rounded-lg overflow-hidden">
|
||||
<img src={post.imageUrl} alt={post.title} className="w-full h-auto object-cover max-h-[60vh]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-800 mb-3">
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="font-inter text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-600">{post.category}</span>
|
||||
{(post.date || post.readTime) && <span className="mx-1.5">•</span>}
|
||||
{post.date && <span>{post.date}</span>}
|
||||
{post.date && post.readTime && <span className="mx-1.5">•</span>}
|
||||
{post.readTime && <span>{post.readTime}</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{typeof post.fullContent === 'string' ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: post.fullContent }} />
|
||||
) : (
|
||||
post.fullContent
|
||||
)}
|
||||
</article>
|
||||
|
||||
{post.gallery && post.gallery.length > 0 && (
|
||||
<GalleryComponent items={post.gallery} title={BLOG_POST_DETAIL_CONTENT.galleryTitle} />
|
||||
)}
|
||||
|
||||
<ItemDetailNavigation
|
||||
previousItem={previousPost ? { id: previousPost.id, title: previousPost.title } : undefined}
|
||||
nextItem={nextPost ? { id: nextPost.id, title: nextPost.title } : undefined}
|
||||
onNavigate={handleNavigateItem}
|
||||
previousItemButtonLabel={BLOG_POST_DETAIL_CONTENT.previousItemButtonLabel || `Предыдущий ${BLOG_POST_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
nextItemButtonLabel={BLOG_POST_DETAIL_CONTENT.nextItemButtonLabel || `Следующий ${BLOG_POST_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noPreviousItemText={BLOG_POST_DETAIL_CONTENT.noPreviousItemText || `Это первая история`}
|
||||
noNextItemText={BLOG_POST_DETAIL_CONTENT.noNextItemText || `Это последняя история`}
|
||||
themeColorClass="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<div className="mt-12 md:mt-16">
|
||||
<div className="flex justify-between items-baseline mb-8">
|
||||
<h2 className="font-quicksand text-3xl font-bold text-slate-800">
|
||||
{BLOG_POST_DETAIL_CONTENT.keepReadingTitle || "Читайте также"}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.storiesAllPage}`}
|
||||
onClick={handleShowAllKeepReading}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={`Посмотреть все ${BLOG_POST_DETAIL_CONTENT.itemTypePlural}`}
|
||||
>
|
||||
{BLOG_POST_DETAIL_CONTENT.viewAllButtonText || `Все ${BLOG_POST_DETAIL_CONTENT.itemTypePlural}`}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{relatedPosts.map(relatedPost => (
|
||||
<PostCard
|
||||
key={relatedPost.id}
|
||||
post={relatedPost}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPostDetail;
|
||||
98
components/BlogSection.tsx
Executable file
98
components/BlogSection.tsx
Executable file
@@ -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<BlogSectionProps> = ({ posts, setCurrentView, setSelectedItemId }) => {
|
||||
const featuredPost = posts.find(p => p.isFeatured);
|
||||
const otherPosts = posts.filter(p => !p.isFeatured);
|
||||
|
||||
const handleShowAllClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
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 (
|
||||
<section id={SECTION_IDS.story} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-baseline mb-12 md:mb-16">
|
||||
<h2 className="font-quicksand text-4xl sm:text-5xl font-bold text-slate-900">
|
||||
{BLOG_SECTION_CONTENT.title}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.storiesAllPage}`}
|
||||
onClick={handleShowAllClick}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={BLOG_SECTION_CONTENT.showAllAriaLabel}
|
||||
>
|
||||
{BLOG_SECTION_CONTENT.showAllText}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-center py-10">
|
||||
<p className="text-slate-600 font-inter text-lg">Проектов пока нет.</p>
|
||||
<p className="text-slate-500 font-inter mt-2">Возможно, они еще не опубликованы. Пожалуйста, проверьте позже.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={SECTION_IDS.story} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-baseline mb-12 md:mb-16">
|
||||
<h2 className="font-quicksand text-4xl sm:text-5xl font-bold text-slate-900">
|
||||
{BLOG_SECTION_CONTENT.title}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.storiesAllPage}`}
|
||||
onClick={handleShowAllClick}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={BLOG_SECTION_CONTENT.showAllAriaLabel}
|
||||
>
|
||||
{BLOG_SECTION_CONTENT.showAllText}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 xl:gap-12">
|
||||
{featuredPost && (
|
||||
<div className="lg:col-span-3 lg:sticky lg:top-24 self-start">
|
||||
<PostCard
|
||||
post={featuredPost}
|
||||
isFeatured
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`lg:col-span-1 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-1 gap-6 lg:gap-8 ${featuredPost ? '' : 'lg:col-span-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'}`}>
|
||||
{otherPosts.slice(featuredPost ? -3 : -4).reverse().map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogSection;
|
||||
31
components/BoldIdeasCarousel.tsx
Executable file
31
components/BoldIdeasCarousel.tsx
Executable file
@@ -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<BoldIdeasCarouselProps> = ({ ideas, direction = 'normal', duration = '72s' }) => {
|
||||
return (
|
||||
<div className="w-full overflow-hidden">
|
||||
<div
|
||||
className="flex w-fit"
|
||||
style={{ animation: `auto-scroll ${duration} linear infinite`, animationDirection: direction }}
|
||||
>
|
||||
{/* Render the list twice for seamless loop */}
|
||||
{[...ideas, ...ideas].map((idea, index) => (
|
||||
<div key={`${direction}-idea-${index}`} className="group relative mx-1 flex w-72 items-center justify-center self-stretch rounded-md bg-slate-50 p-4 transition-all hover:bg-slate-100">
|
||||
<a href="#" onClick={(e) => e.preventDefault()} className="text-sm text-center text-slate-700 font-medium cursor-default">
|
||||
{idea}
|
||||
<ArrowTopRightIcon className="ml-1.5 inline-block h-3 w-3 -translate-y-px transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-1" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoldIdeasCarousel;
|
||||
85
components/BusinessCard.tsx
Executable file
85
components/BusinessCard.tsx
Executable file
@@ -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<BusinessCardProps> = ({ story, setCurrentView, setSelectedItemId, className = "", viewMode = 'grid' }) => {
|
||||
|
||||
const handleCardClick = () => {
|
||||
setCurrentView('businessStoryDetail');
|
||||
setSelectedItemId(story.id);
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={`group block p-4 rounded-lg bg-white transition-all duration-300 ease-in-out hover:bg-slate-50 cursor-pointer w-full ${className}`}
|
||||
role="article"
|
||||
aria-labelledby={`business-title-${story.id}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-md overflow-hidden bg-slate-200">
|
||||
<img
|
||||
src={story.imageUrl}
|
||||
alt={`Изображение для: ${story.title}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 id={`business-title-${story.id}`} className="text-base md:text-lg font-quicksand font-semibold text-slate-800 group-hover:text-black mb-1 transition-colors duration-200 leading-tight">
|
||||
{story.title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
<span className="font-semibold text-slate-600">{story.category}</span>
|
||||
</p>
|
||||
{story.description && (
|
||||
<p className="text-sm text-slate-600 line-clamp-2 font-inter">{story.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid view
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={`group relative bg-white rounded-xl overflow-hidden transition-all duration-300 ease-in-out transform hover:-translate-y-1 cursor-pointer shadow-sm hover:shadow-md ${className}`}
|
||||
role="article"
|
||||
aria-labelledby={`business-title-${story.id}`}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden aspect-[4/3]">
|
||||
<img
|
||||
src={story.imageUrl}
|
||||
alt={`Изображение для: ${story.title}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 md:p-5">
|
||||
<p className="text-xs text-slate-600 font-semibold mb-1">
|
||||
{story.category}
|
||||
</p>
|
||||
<h3 id={`business-title-${story.id}`} className="font-quicksand font-semibold text-md md:text-lg text-slate-800 group-hover:text-black mb-1 transition-colors duration-200 leading-tight">
|
||||
{story.title}
|
||||
</h3>
|
||||
{story.description && (
|
||||
<p className="text-sm text-slate-600 mt-2 line-clamp-3 font-inter">{story.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessCard;
|
||||
118
components/BusinessLandingSection.tsx
Executable file
118
components/BusinessLandingSection.tsx
Executable file
@@ -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<BusinessLandingSectionProps> = ({ businessStories, setCurrentView, setSelectedItemId }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedSort, setSelectedSort] = useState<SortOptionKey>('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 (
|
||||
<section
|
||||
id={SECTION_IDS.businessLandingPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen flex justify-center items-center"
|
||||
>
|
||||
<p className="text-slate-600 font-inter">Загрузка кейсов...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.businessLandingPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{BUSINESS_LANDING_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{BUSINESS_LANDING_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FilterSortBar
|
||||
viewMode={viewMode}
|
||||
onViewChange={setViewMode}
|
||||
availableCategories={availableCategories}
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onClearCategories={() => setSelectedCategories([])}
|
||||
sortOptions={sortOptions}
|
||||
selectedSort={selectedSort}
|
||||
onSortChange={setSelectedSort}
|
||||
/>
|
||||
|
||||
{filteredAndSortedStories.length > 0? (
|
||||
<div className={layoutClasses}>
|
||||
{filteredAndSortedStories.map(story => (
|
||||
<BusinessCard
|
||||
key={story.id}
|
||||
story={story}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white p-6 rounded-xl">
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{BUSINESS_LANDING_CONTENT.emptyStateTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{BUSINESS_LANDING_CONTENT.emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessLandingSection;
|
||||
67
components/BusinessSection.tsx
Executable file
67
components/BusinessSection.tsx
Executable file
@@ -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<BusinessSectionProps> = ({ businessStories, setCurrentView, setSelectedItemId }) => {
|
||||
|
||||
const handleShowAllClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('businessLanding');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
if (!businessStories || businessStories.length === 0) {
|
||||
return (
|
||||
<section id={SECTION_IDS.businessShowcase} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
{/* Optionally, you can add a loading or empty state specific to this section */}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={SECTION_IDS.businessShowcase} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-baseline mb-10 md:mb-12">
|
||||
<h2 className="font-quicksand text-4xl sm:text-5xl font-bold text-slate-900">
|
||||
{BUSINESS_SECTION_CONTENT.title}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.businessLandingPage}`}
|
||||
onClick={handleShowAllClick}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={BUSINESS_SECTION_CONTENT.showAllAriaLabel}
|
||||
>
|
||||
{BUSINESS_SECTION_CONTENT.showAllText}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-x-auto overflow-y-hidden no-scrollbar snap-x snap-mandatory">
|
||||
<div className="flex flex-nowrap gap-4 sm:gap-6 px-4 sm:px-6 lg:px-8 min-w-max pb-2 -mb-2">
|
||||
{businessStories.slice(-6).reverse().map(story => ( // Show up to 6 stories in the scroller
|
||||
<BusinessCard
|
||||
key={story.id}
|
||||
story={story}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
className="flex-shrink-0 w-72 sm:w-80 md:w-[22rem] snap-start"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessSection;
|
||||
153
components/BusinessServicesSection.tsx
Executable file
153
components/BusinessServicesSection.tsx
Executable file
@@ -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<string, React.ElementType> = {
|
||||
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<ServiceItemProps> = ({ iconName, title, description }) => {
|
||||
const IconComponent = iconMap[iconName] || CpuChipIcon; // Fallback to a default icon
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className="w-8 h-8 text-slate-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="font-quicksand text-xl font-semibold text-slate-800 mb-2">{title}</h3>
|
||||
<p className="font-inter text-slate-600 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface BusinessServicesSectionProps {
|
||||
serviceItems: ServiceItemData[];
|
||||
clientLogos: ClientLogo[];
|
||||
}
|
||||
|
||||
const BusinessServicesSection: React.FC<BusinessServicesSectionProps> = ({ serviceItems, clientLogos }) => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.businessServicesPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{BUSINESS_SERVICES_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{BUSINESS_SERVICES_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{serviceItems && serviceItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{serviceItems.map((service) => (
|
||||
<ServiceItem
|
||||
key={service.title}
|
||||
iconName={service.icon} // Pass iconName string
|
||||
title={service.title}
|
||||
description={service.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">
|
||||
<WrenchScrewdriverIcon className="w-12 h-12 mx-auto text-slate-300 mb-4" />
|
||||
<p className="text-slate-600 font-inter">Услуги скоро будут добавлены.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clientLogos && clientLogos.length > 0 && (
|
||||
<div className="mt-16 md:mt-24">
|
||||
<h2 className="text-center font-quicksand text-3xl sm:text-4xl font-semibold text-slate-700 mb-10">
|
||||
{BUSINESS_SERVICES_CONTENT.trustedByTitle}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-x-6 gap-y-8 items-center">
|
||||
{clientLogos.map((logo) => (
|
||||
<div key={logo.id} className="flex justify-center items-center group">
|
||||
<img
|
||||
src={logo.imageUrl}
|
||||
alt={logo.name}
|
||||
className="h-12 md:h-16 object-contain filter grayscale group-hover:grayscale-0 transition-all duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 md:mt-24 text-center bg-white p-8 md:p-12 rounded-xl">
|
||||
<h2 className="text-3xl font-quicksand font-semibold text-slate-800 mb-6">
|
||||
{BUSINESS_SERVICES_CONTENT.ctaTitle}
|
||||
</h2>
|
||||
<p className="font-inter text-lg text-slate-600 mb-8 max-w-xl mx-auto">
|
||||
{BUSINESS_SERVICES_CONTENT.ctaSubtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap justify-center items-center gap-4 sm:gap-6">
|
||||
{BUSINESS_SERVICES_CONTENT.contactMethods.map((method) => (
|
||||
<a
|
||||
key={method.id}
|
||||
href={method.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group inline-flex items-center justify-center px-6 py-3 text-base font-medium text-slate-800 ${method.bgColor} ${method.hoverBgColor} rounded-full focus:outline-none focus:ring-2 ${method.ringColor} focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand w-full sm:w-auto sm:min-w-[180px]`}
|
||||
aria-label={method.ariaLabel}
|
||||
>
|
||||
<method.icon className="w-5 h-5" />
|
||||
<span className="ml-2.5">{method.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessServicesSection;
|
||||
151
components/BusinessStoryDetail.tsx
Executable file
151
components/BusinessStoryDetail.tsx
Executable file
@@ -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<BusinessStoryDetailProps> = ({ 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<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('businessLanding');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
|
||||
if (!story) {
|
||||
return (
|
||||
<section id={SECTION_IDS.businessStoryDetail} className="py-16 md:py-24 min-h-screen flex items-center justify-center">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-slate-700">{BUSINESS_STORY_DETAIL_CONTENT.notFoundTitle}</h1>
|
||||
<Button onClick={handleBackClick} leftIcon={<ArrowUturnLeftIcon />} className="mt-8">
|
||||
{BUSINESS_STORY_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.businessStoryDetail}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="mb-6"
|
||||
>
|
||||
{BUSINESS_STORY_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
|
||||
{story.imageUrl && (
|
||||
<div className="mb-8 rounded-lg overflow-hidden">
|
||||
<img src={story.imageUrl} alt={story.title} className="w-full h-auto object-cover max-h-[50vh]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-800 mb-3">
|
||||
{story.title}
|
||||
</h1>
|
||||
<div className="font-inter text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-600">{story.category}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{story.description && <p className="lead text-xl">{story.description}</p>}
|
||||
{typeof story.fullContent === 'string' ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: story.fullContent }} />
|
||||
) : (
|
||||
story.fullContent
|
||||
)}
|
||||
</article>
|
||||
|
||||
{story.gallery && story.gallery.length > 0 && (
|
||||
<GalleryComponent items={story.gallery} title={BUSINESS_STORY_DETAIL_CONTENT.galleryTitle} />
|
||||
)}
|
||||
|
||||
<ItemDetailNavigation
|
||||
previousItem={previousStory ? { id: previousStory.id, title: previousStory.title } : undefined}
|
||||
nextItem={nextStory ? { id: nextStory.id, title: nextStory.title } : undefined}
|
||||
onNavigate={handleNavigateItem}
|
||||
previousItemButtonLabel={BUSINESS_STORY_DETAIL_CONTENT.previousItemButtonLabel || `Предыдущий ${BUSINESS_STORY_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
nextItemButtonLabel={BUSINESS_STORY_DETAIL_CONTENT.nextItemButtonLabel || `Следующий ${BUSINESS_STORY_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noPreviousItemText={BUSINESS_STORY_DETAIL_CONTENT.noPreviousItemText || `Это первый ${BUSINESS_STORY_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noNextItemText={BUSINESS_STORY_DETAIL_CONTENT.noNextItemText || `Это последний ${BUSINESS_STORY_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
themeColorClass="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relatedStories.length > 0 && (
|
||||
<div className="mt-12 md:mt-16">
|
||||
<div className="flex justify-between items-baseline mb-8">
|
||||
<h2 className="font-quicksand text-3xl font-bold text-slate-800">
|
||||
{BUSINESS_STORY_DETAIL_CONTENT.keepReadingTitle || "Другие кейсы"}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.businessLandingPage}`}
|
||||
onClick={handleShowAllKeepReading}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={`Посмотреть все ${BUSINESS_STORY_DETAIL_CONTENT.itemTypePlural}`}
|
||||
>
|
||||
{BUSINESS_STORY_DETAIL_CONTENT.viewAllButtonText || `Все ${BUSINESS_STORY_DETAIL_CONTENT.itemTypePlural}`}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{relatedStories.map(relatedStory => (
|
||||
<BusinessCard
|
||||
key={relatedStory.id}
|
||||
story={relatedStory}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessStoryDetail;
|
||||
47
components/Button.tsx
Executable file
47
components/Button.tsx
Executable file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'outline' | 'secondary';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseStyles} ${sizeStyles[size]} ${variantStyles[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{leftIcon && <span className="mr-2 -ml-1 h-5 w-5">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="ml-2 -mr-1 h-5 w-5">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
96
components/CareersSection.tsx
Executable file
96
components/CareersSection.tsx
Executable file
@@ -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<CareersSectionProps> = ({ vacancies, setCurrentView, setSelectedItemId }) => {
|
||||
|
||||
const handleDetailsClick = (vacancyId: string) => {
|
||||
setCurrentView('vacancyDetail');
|
||||
setSelectedItemId(vacancyId);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.careersPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16 text-center md:text-left">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{CAREERS_PAGE_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-2xl mx-auto md:mx-0">
|
||||
{CAREERS_PAGE_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-8">
|
||||
{vacancies && vacancies.length > 0 ? (
|
||||
vacancies.map((vacancy: Vacancy) => (
|
||||
<div key={vacancy.id} className="bg-white p-6 rounded-xl">
|
||||
<div className="md:flex md:justify-between md:items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-800 mb-1">{vacancy.title}</h2>
|
||||
<p className="text-sm text-slate-500 mb-2">
|
||||
{vacancy.department} • {vacancy.location} • {vacancy.type}
|
||||
</p>
|
||||
<p className="text-slate-600 font-inter line-clamp-2">{vacancy.description}</p>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0 md:ml-6 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => handleDetailsClick(vacancy.id)}
|
||||
rightIcon={<ChevronRightIcon />}
|
||||
aria-label={`Подробнее о вакансии ${vacancy.title}`}
|
||||
>
|
||||
Подробнее
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white p-6 rounded-xl">
|
||||
<BriefcaseIcon className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{CAREERS_PAGE_CONTENT.noVacanciesTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{CAREERS_PAGE_CONTENT.noVacanciesMessagePt1}
|
||||
</p>
|
||||
<p className="text-slate-600 font-inter mt-3">
|
||||
Вы можете следить за обновлениями на этой странице или отправить свое резюме и сопроводительное письмо на адрес <a href={`mailto:${CAREERS_PAGE_CONTENT.hrEmail}`} className="text-slate-700 hover:text-black hover:underline">{CAREERS_PAGE_CONTENT.noVacanciesContactEmailText}</a>. {CAREERS_PAGE_CONTENT.noVacanciesMessagePt2}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<h3 className="text-xl font-quicksand font-semibold text-slate-700 mb-3">{CAREERS_PAGE_CONTENT.notFoundTitle}</h3>
|
||||
<p className="text-slate-600 font-inter mb-6 max-w-xl mx-auto">
|
||||
{CAREERS_PAGE_CONTENT.notFoundMessage}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => window.location.href = `mailto:${CAREERS_PAGE_CONTENT.hrEmail}?subject=${encodeURIComponent(CAREERS_PAGE_CONTENT.applySubject)}`}
|
||||
>
|
||||
{CAREERS_PAGE_CONTENT.applyButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CareersSection;
|
||||
194
components/ChatInput.tsx
Executable file
194
components/ChatInput.tsx
Executable file
@@ -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<ChatInputProps> = ({ setCurrentView, isSticky = false, autoFocusOnMount = false }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPlaceholderIndex, setCurrentPlaceholderIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<div className={wrapperClasses}>
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<label className={labelClasses}>
|
||||
<div className={`text-slate-400 min-h-[24px] pointer-events-none absolute left-0 top-0 w-full select-none px-4 pt-[18px] text-base transition-opacity duration-200 ${prompt ? 'opacity-0' : 'opacity-100'}`} aria-hidden={!!prompt}>
|
||||
<div className="transition-transform ease-in-out duration-300" style={{ transform: `translateY(-${currentPlaceholderIndex * 1.5}rem)` }}>
|
||||
{PLACEHOLDERS.map((text, index) => (
|
||||
<div key={index} className={`overflow-hidden text-ellipsis whitespace-nowrap transition-opacity duration-300 ${currentPlaceholderIndex === index ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) handleSubmit(e); }}
|
||||
className="w-full resize-none bg-transparent text-base focus:outline-none text-slate-800"
|
||||
rows={1}
|
||||
style={{ minHeight: '24px' }}
|
||||
aria-label="Задайте ваш вопрос"
|
||||
/>
|
||||
</label>
|
||||
<div className="absolute bottom-2 right-2 mt-auto flex justify-end">
|
||||
<button
|
||||
className="bg-slate-800 text-white disabled:bg-slate-200 disabled:text-slate-400 relative h-10 w-10 rounded-full p-0 transition-colors hover:bg-black disabled:hover:bg-slate-200 flex items-center justify-center"
|
||||
type="submit"
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
aria-label="Отправить запрос"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<ArrowUpIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error && <p className="mt-2 text-center text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
40
components/ConnectSection.tsx
Executable file
40
components/ConnectSection.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { SECTION_IDS, CONNECT_SECTION_CONTENT } from '../constants';
|
||||
// Icons are now part of CONNECT_SECTION_CONTENT.contactMethods
|
||||
|
||||
const ConnectSection: React.FC = () => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.connect}
|
||||
className="py-20 md:py-24 bg-white"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-xl p-8 md:p-12 text-center transform transition-all">
|
||||
<h2 className="font-quicksand text-4xl sm:text-5xl font-bold text-slate-900 mb-4">
|
||||
{CONNECT_SECTION_CONTENT.title}
|
||||
</h2>
|
||||
<p className="font-inter text-lg text-slate-600 mb-10 md:mb-12">
|
||||
{CONNECT_SECTION_CONTENT.subtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap justify-center items-center gap-4 sm:gap-6">
|
||||
{CONNECT_SECTION_CONTENT.contactMethods.map((method) => (
|
||||
<a
|
||||
key={method.id}
|
||||
href={method.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group inline-flex items-center justify-center px-6 py-3 text-base font-medium text-slate-800 ${method.bgColor} ${method.hoverBgColor} rounded-full focus:outline-none focus:ring-2 ${method.ringColor} focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand w-full sm:w-auto sm:min-w-[180px]`}
|
||||
aria-label={method.ariaLabel}
|
||||
>
|
||||
<method.icon className="w-5 h-5" />
|
||||
<span className="ml-2.5">{method.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectSection;
|
||||
1080
components/ConstantsEditorPage.tsx
Executable file
1080
components/ConstantsEditorPage.tsx
Executable file
File diff suppressed because it is too large
Load Diff
184
components/EducationBusinessSection.tsx
Executable file
184
components/EducationBusinessSection.tsx
Executable file
@@ -0,0 +1,184 @@
|
||||
|
||||
import React from 'react';
|
||||
import { SECTION_IDS, EDUCATION_BUSINESS_CONTENT } from '../constants';
|
||||
import { BusinessCourse } from '../types';
|
||||
import {
|
||||
CpuChipIcon, ChartBarIcon, CommandLineIcon, AcademicCapIcon, LightBulbIcon, CheckIcon
|
||||
} from '../components/icons';
|
||||
import ApproachCard from './ApproachCard';
|
||||
|
||||
// Icon map for Business Courses
|
||||
const businessCourseIconMap: Record<string, React.ElementType> = {
|
||||
CpuChipIcon,
|
||||
ChartBarIcon,
|
||||
CommandLineIcon,
|
||||
AcademicCapIcon,
|
||||
LightBulbIcon,
|
||||
};
|
||||
|
||||
interface CourseHighlightProps {
|
||||
iconName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CourseHighlight: React.FC<CourseHighlightProps> = ({ iconName, title, description }) => {
|
||||
const IconComponent = businessCourseIconMap[iconName] || AcademicCapIcon; // Fallback icon
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className="w-8 h-8 text-slate-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="font-quicksand text-xl font-semibold text-slate-800 mb-2">{title}</h3>
|
||||
<p className="font-inter text-slate-600 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PersonIcon = ({ color }: { color: string }) => (
|
||||
<div className="relative w-12 h-12">
|
||||
<div className={`w-8 h-8 ${color} rounded-full absolute top-0 left-1/2 -translate-x-1/2`}></div>
|
||||
<div className={`w-12 h-6 ${color} rounded-t-lg absolute bottom-0`}></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
interface EducationBusinessSectionProps {
|
||||
businessCourses: BusinessCourse[];
|
||||
}
|
||||
|
||||
const EducationBusinessSection: React.FC<EducationBusinessSectionProps> = ({ businessCourses }) => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.educationBusinessPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{EDUCATION_BUSINESS_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{EDUCATION_BUSINESS_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* "Our Approach" section */}
|
||||
<div className="my-16 md:my-24">
|
||||
<div className="max-w-3xl mx-auto text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold font-quicksand text-slate-800">Наш подход к обучению</h2>
|
||||
<p className="mt-4 text-lg text-slate-600 font-inter">
|
||||
Мы верим в комплексный подход, который готовит специалистов к реальным вызовам в области ИИ.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<ApproachCard
|
||||
title="Обучаем"
|
||||
description="Мы начинаем с фундаментальных принципов работы с ИИ, отсеивая сложную теорию и фокусируясь на практическом применении для решения реальных задач."
|
||||
visual={
|
||||
<div className="relative w-40 h-24 flex items-center justify-center">
|
||||
<div className="absolute top-0 left-0 w-20 h-20 bg-black rounded-full"></div>
|
||||
<div className="absolute bottom-0 right-0 w-20 h-20 bg-gray-300 rounded-full"></div>
|
||||
<div className="absolute bottom-4 right-4 w-8 h-8 bg-green-400 rounded-lg flex items-center justify-center ring-4 ring-white">
|
||||
<CheckIcon className="w-5 h-5 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ApproachCard
|
||||
title="Тестируем"
|
||||
description="Мы проводим внутренние оценки и работаем с экспертами для проверки знаний в реальных сценариях, что помогает закрепить навыки и подготовиться к сертификации."
|
||||
visual={
|
||||
<div className="relative w-40 h-24 flex items-center justify-center">
|
||||
<div className="relative w-36 h-16 bg-gray-100 rounded-lg p-3 space-y-2 flex flex-col justify-center">
|
||||
<div className="w-full h-1.5 bg-gray-300 rounded-full"></div>
|
||||
<div className="w-2/3 h-1.5 bg-gray-300 rounded-full"></div>
|
||||
</div>
|
||||
<div className="absolute top-4 left-4 w-8 h-8 bg-green-400 rounded-lg flex items-center justify-center ring-4 ring-white">
|
||||
<CheckIcon className="w-5 h-5 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ApproachCard
|
||||
title="Делимся опытом"
|
||||
description="Мы используем обратную связь из реального мира и опыт наших экспертов, чтобы сделать наши курсы по ИИ более понятными, актуальными и полезными для людей."
|
||||
visual={
|
||||
<div className="w-32 h-32 grid grid-cols-2 gap-2 content-center justify-items-center">
|
||||
<PersonIcon color="bg-gray-300" />
|
||||
<PersonIcon color="bg-black" />
|
||||
<PersonIcon color="bg-gray-300" />
|
||||
<div className="relative">
|
||||
<PersonIcon color="bg-black" />
|
||||
<div className="absolute bottom-1 right-0 w-6 h-6 bg-green-400 rounded-lg flex items-center justify-center ring-2 ring-white">
|
||||
<CheckIcon className="w-4 h-4 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-16 md:my-20">
|
||||
<h2 className="text-center font-quicksand text-3xl sm:text-4xl font-semibold text-slate-700 mb-10">
|
||||
Курсы для бизнеса
|
||||
</h2>
|
||||
{businessCourses && businessCourses.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{businessCourses.map((course: BusinessCourse) => (
|
||||
<CourseHighlight
|
||||
key={course.title}
|
||||
iconName={course.icon}
|
||||
title={course.title}
|
||||
description={course.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white p-6 rounded-xl">
|
||||
<AcademicCapIcon className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">Курсы для бизнеса скоро появятся</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
Мы активно работаем над созданием актуальных программ обучения. Следите за обновлениями!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center bg-white p-8 md:p-12 rounded-xl">
|
||||
<h2 className="text-3xl font-quicksand font-semibold text-slate-800 mb-6">
|
||||
{EDUCATION_BUSINESS_CONTENT.ctaTitle}
|
||||
</h2>
|
||||
<p className="font-inter text-lg text-slate-600 mb-8 max-w-xl mx-auto">
|
||||
{EDUCATION_BUSINESS_CONTENT.ctaSubtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap justify-center items-center gap-4 sm:gap-6">
|
||||
{EDUCATION_BUSINESS_CONTENT.contactMethods.map((method) => (
|
||||
<a
|
||||
key={method.id}
|
||||
href={method.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group inline-flex items-center justify-center px-6 py-3 text-base font-medium text-slate-800 ${method.bgColor} ${method.hoverBgColor} rounded-full focus:outline-none focus:ring-2 ${method.ringColor} focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand w-full sm:w-auto sm:min-w-[180px]`}
|
||||
aria-label={method.ariaLabel}
|
||||
>
|
||||
<method.icon className="w-5 h-5" />
|
||||
<span className="ml-2.5">{method.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default EducationBusinessSection;
|
||||
148
components/EducationStudentsSection.tsx
Executable file
148
components/EducationStudentsSection.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
|
||||
import React from 'react';
|
||||
import { SECTION_IDS, EDUCATION_STUDENTS_CONTENT } from '../constants';
|
||||
import { StudentProgram } from '../types';
|
||||
import {
|
||||
LightBulbIcon, CodeBracketIcon, AcademicCapIcon, UserGroupIcon, CheckIcon
|
||||
} from '../components/icons';
|
||||
|
||||
// Icon map for Student Programs
|
||||
const studentProgramIconMap: Record<string, React.ElementType> = {
|
||||
LightBulbIcon,
|
||||
CodeBracketIcon,
|
||||
AcademicCapIcon,
|
||||
UserGroupIcon, // Example
|
||||
// ... other icons
|
||||
};
|
||||
|
||||
|
||||
interface ProgramHighlightProps {
|
||||
iconName: string; // Changed from icon: React.ElementType
|
||||
title: string;
|
||||
targetAudience: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ProgramHighlight: React.FC<ProgramHighlightProps> = ({ iconName, title, targetAudience, description }) => {
|
||||
const IconComponent = studentProgramIconMap[iconName] || AcademicCapIcon; // Fallback icon
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl">
|
||||
<div className="flex items-center text-slate-700 mb-4">
|
||||
<IconComponent className="w-8 h-8 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-quicksand text-2xl font-semibold text-slate-800">{title}</h3>
|
||||
<p className="font-inter text-sm text-slate-500">{targetAudience}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-inter text-slate-600 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface EducationStudentsSectionProps {
|
||||
studentPrograms: StudentProgram[];
|
||||
}
|
||||
|
||||
const EducationStudentsSection: React.FC<EducationStudentsSectionProps> = ({ studentPrograms }) => {
|
||||
const internshipBenefits = [
|
||||
"Участие в реальных проектах для нашего портфолио.",
|
||||
"Менторство от ведущих разработчиков и исследователей ИИ.",
|
||||
"Создание сильного портфолио с кейсами мирового уровня.",
|
||||
"Возможность трудоустройства для лучших стажеров.",
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.educationStudentsPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-12 md:mb-16">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{EDUCATION_STUDENTS_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{EDUCATION_STUDENTS_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="my-16 md:my-24 grid md:grid-cols-2 gap-12 md:gap-16 items-center">
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
<h2 className="font-quicksand text-3xl sm:text-4xl font-semibold text-slate-800 mb-4">Стажировки и Практика: Ваш Старт в Мире ИИ</h2>
|
||||
<p>
|
||||
Мы в iiEasy верим, что будущее создается сегодня. Поэтому мы открываем двери для талантливых и амбициозных студентов, желающих получить реальный опыт в разработке сайтов, приложений и передовых ИИ-решений. Наша программа стажировки в Уфе — это не просто практика, это полное погружение в рабочие процессы ведущей технологической компании.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3 not-prose">
|
||||
{internshipBenefits.map((benefit, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<CheckIcon className="w-6 h-6 text-green-500 mr-3 mt-1 flex-shrink-0" />
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<img src="https://picsum.photos/seed/iieasy-intern-coding/600/400" alt="Студент-стажер в iiEasy пишет код на ноутбуке в современном офисе в Уфе." className="rounded-lg shadow-md aspect-[3/2] object-cover" />
|
||||
<img src="https://picsum.photos/seed/iieasy-intern-meeting/600/400" alt="Группа стажеров и ментор обсуждают проект у доски в переговорной комнате iiEasy." className="rounded-lg shadow-md aspect-[3/2] object-cover mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-16 md:my-20">
|
||||
<h2 className="text-center font-quicksand text-3xl sm:text-4xl font-semibold text-slate-700 mb-10">
|
||||
Наши программы
|
||||
</h2>
|
||||
{studentPrograms && studentPrograms.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{studentPrograms.map((program: StudentProgram) => (
|
||||
<ProgramHighlight
|
||||
key={program.title}
|
||||
iconName={program.icon}
|
||||
title={program.title}
|
||||
targetAudience={program.targetAudience}
|
||||
description={program.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white p-6 rounded-xl">
|
||||
<AcademicCapIcon className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">Программы для студентов скоро будут здесь</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
Мы готовим увлекательные курсы и программы. Заглядывайте позже!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center bg-white p-8 md:p-12 rounded-xl">
|
||||
<h2 className="text-3xl font-quicksand font-semibold text-slate-800 mb-6">
|
||||
{EDUCATION_STUDENTS_CONTENT.ctaTitle}
|
||||
</h2>
|
||||
<p className="font-inter text-lg text-slate-600 mb-8 max-w-xl mx-auto">
|
||||
{EDUCATION_STUDENTS_CONTENT.ctaSubtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap justify-center items-center gap-4 sm:gap-6">
|
||||
{EDUCATION_STUDENTS_CONTENT.contactMethods.map((method) => (
|
||||
<a
|
||||
key={method.id}
|
||||
href={method.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group inline-flex items-center justify-center px-6 py-3 text-base font-medium text-slate-800 ${method.bgColor} ${method.hoverBgColor} rounded-full focus:outline-none focus:ring-2 ${method.ringColor} focus:ring-offset-2 transition-all duration-150 ease-in-out font-quicksand w-full sm:w-auto sm:min-w-[180px]`}
|
||||
aria-label={method.ariaLabel}
|
||||
>
|
||||
<method.icon className="w-5 h-5" />
|
||||
<span className="ml-2.5">{method.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default EducationStudentsSection;
|
||||
185
components/FilterSortBar.tsx
Executable file
185
components/FilterSortBar.tsx
Executable file
@@ -0,0 +1,185 @@
|
||||
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { AdjustmentsHorizontalIcon, ChevronDownIcon, Squares2X2Icon, ListBulletIcon, CheckIcon } from './icons';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
export type SortOptionKey = 'newest' | 'oldest' | 'alphabetical';
|
||||
|
||||
export interface SortOption {
|
||||
key: SortOptionKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FilterSortBarProps {
|
||||
viewMode: ViewMode;
|
||||
onViewChange: (view: ViewMode) => void;
|
||||
|
||||
availableCategories: string[];
|
||||
selectedCategories: string[];
|
||||
onCategoryChange: (category: string) => void;
|
||||
onClearCategories: () => void;
|
||||
|
||||
sortOptions: SortOption[];
|
||||
selectedSort: SortOptionKey;
|
||||
onSortChange: (sort: SortOptionKey) => void;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
className?: string;
|
||||
}> = ({ isOpen, onClose, children, align = 'left', className = '' }) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const alignmentClass = align === 'left' ? 'left-0' : 'right-0';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`absolute top-full mt-2 w-64 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 z-20 ${alignmentClass} ${className}`}
|
||||
>
|
||||
<div className="py-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const FilterSortBar: React.FC<FilterSortBarProps> = ({
|
||||
viewMode,
|
||||
onViewChange,
|
||||
availableCategories,
|
||||
selectedCategories,
|
||||
onCategoryChange,
|
||||
onClearCategories,
|
||||
sortOptions,
|
||||
selectedSort,
|
||||
onSortChange
|
||||
}) => {
|
||||
const [isFilterOpen, setFilterOpen] = useState(false);
|
||||
const [isSortOpen, setSortOpen] = useState(false);
|
||||
|
||||
const selectedSortLabel = sortOptions.find(o => o.key === selectedSort)?.label || 'Сортировка';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 py-4 mb-8 border-t border-b border-slate-200">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen(prev => !prev)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<AdjustmentsHorizontalIcon className="h-5 w-5 text-slate-500" />
|
||||
<span>Фильтры</span>
|
||||
{selectedCategories.length > 0 && <span className="bg-slate-800 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">{selectedCategories.length}</span>}
|
||||
</button>
|
||||
<Dropdown isOpen={isFilterOpen} onClose={() => setFilterOpen(false)}>
|
||||
<div className="px-4 py-2 flex justify-between items-center border-b border-slate-100">
|
||||
<h4 className="font-semibold text-slate-800">Категории</h4>
|
||||
{selectedCategories.length > 0 && (
|
||||
<button
|
||||
onClick={() => { onClearCategories(); setFilterOpen(false); }}
|
||||
className="text-xs text-slate-600 hover:text-black hover:underline"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto no-scrollbar">
|
||||
{availableCategories.length > 0 ? availableCategories.map(category => (
|
||||
<label key={category} className="flex items-center px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(category)}
|
||||
onChange={() => onCategoryChange(category)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-slate-600 focus:ring-slate-500"
|
||||
/>
|
||||
<span className="ml-3">{category}</span>
|
||||
</label>
|
||||
)) : (
|
||||
<p className="px-4 py-2 text-sm text-slate-500">Нет доступных категорий.</p>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOpen(prev => !prev)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-full transition-colors"
|
||||
>
|
||||
<span>{selectedSortLabel}</span>
|
||||
<ChevronDownIcon className="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
<Dropdown isOpen={isSortOpen} onClose={() => setSortOpen(false)}>
|
||||
{sortOptions.map(option => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => { onSortChange(option.key); setSortOpen(false); }}
|
||||
className={`w-full text-left flex items-center px-4 py-2 text-sm transition-colors ${selectedSort === option.key ? 'font-semibold text-slate-900 bg-slate-50' : 'text-slate-700 hover:bg-slate-50'}`}
|
||||
>
|
||||
{option.label}
|
||||
{selectedSort === option.key && <CheckIcon className="ml-auto h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center ml-auto gap-1 bg-slate-100 p-1 rounded-full shrink-0">
|
||||
<label htmlFor="grid-view" className="cursor-pointer" aria-label="Вид сеткой">
|
||||
<input
|
||||
id="grid-view"
|
||||
type="radio"
|
||||
name="view-toggle"
|
||||
value="grid"
|
||||
checked={viewMode === 'grid'}
|
||||
onChange={() => onViewChange('grid')}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="p-1.5 rounded-full text-slate-600 peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm hover:bg-slate-200 transition-colors">
|
||||
<Squares2X2Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="list-view" className="cursor-pointer" aria-label="Вид списком">
|
||||
<input
|
||||
id="list-view"
|
||||
type="radio"
|
||||
name="view-toggle"
|
||||
value="list"
|
||||
checked={viewMode === 'list'}
|
||||
onChange={() => onViewChange('list')}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="p-1.5 rounded-full text-slate-600 peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm hover:bg-slate-200 transition-colors">
|
||||
<ListBulletIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterSortBar;
|
||||
62
components/GalleryComponent.tsx
Executable file
62
components/GalleryComponent.tsx
Executable file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { GalleryItem } from '../types';
|
||||
import { PhotoIcon, VideoCameraIcon } from './icons'; // Assuming these exist or will be added
|
||||
|
||||
interface GalleryComponentProps {
|
||||
items: GalleryItem[] | undefined;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const GalleryComponent: React.FC<GalleryComponentProps> = ({ items, title }) => {
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 md:py-12">
|
||||
{title && (
|
||||
<h2 className="font-quicksand text-2xl sm:text-3xl font-bold text-slate-800 mb-6 md:mb-8 text-center">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="group relative bg-slate-100 rounded-lg overflow-hidden">
|
||||
<div className="aspect-[16/9] w-full">
|
||||
{item.type === 'image' ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.altText || item.caption || 'Gallery image'}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : item.type === 'video' ? (
|
||||
<video
|
||||
src={item.url}
|
||||
controls
|
||||
className="w-full h-full object-cover"
|
||||
poster={item.altText} // Use altText as poster if available
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-200">
|
||||
<PhotoIcon className="w-16 h-16 text-slate-400" /> {/* Fallback icon */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/70 to-transparent transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||||
<p className="text-white text-sm font-inter leading-snug line-clamp-2">{item.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Icon overlay for type indication, optional */}
|
||||
<div className="absolute top-2 right-2 p-1.5 bg-black/40 rounded-full text-white opacity-80 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{item.type === 'image' ? <PhotoIcon className="w-4 h-4" /> : <VideoCameraIcon className="w-4 h-4" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryComponent;
|
||||
43
components/HeroSection.tsx
Executable file
43
components/HeroSection.tsx
Executable file
@@ -0,0 +1,43 @@
|
||||
|
||||
import React from 'react';
|
||||
import ChatInput from './ChatInput';
|
||||
import { CurrentView } from '../types';
|
||||
import { ArrowDownTrayIcon } from './icons';
|
||||
import { SECTION_IDS } from '../constants';
|
||||
|
||||
interface HeroSectionProps {
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
isChatHidden?: boolean;
|
||||
}
|
||||
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({ setCurrentView, isChatHidden = false }) => {
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative min-h-screen flex items-center justify-center overflow-hidden bg-white p-4"
|
||||
>
|
||||
<div className="w-full max-w-3xl text-center">
|
||||
<h1 className="mb-6 mx-auto text-4xl sm:text-5xl font-semibold leading-tight tracking-tight text-slate-900 font-quicksand">
|
||||
Чем мы можем помочь?
|
||||
</h1>
|
||||
<div className={`transition-opacity duration-300 ${isChatHidden ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<ChatInput setCurrentView={setCurrentView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`#${SECTION_IDS.story}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(SECTION_IDS.story)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10"
|
||||
aria-label="Прокрутить вниз"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-8 w-8 text-slate-500 animate-bounce hover:text-slate-700 transition-colors" />
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
90
components/ItemDetailNavigation.tsx
Executable file
90
components/ItemDetailNavigation.tsx
Executable file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import Button from './Button';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from './icons'; // Assuming these are added to icons.tsx
|
||||
|
||||
interface NavigableItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ItemDetailNavigationProps {
|
||||
previousItem?: NavigableItem;
|
||||
nextItem?: NavigableItem;
|
||||
onNavigate: (itemId: string) => void;
|
||||
previousItemButtonLabel: string;
|
||||
nextItemButtonLabel: string;
|
||||
noPreviousItemText: string;
|
||||
noNextItemText: string;
|
||||
themeColorClass?: 'blue' | 'teal' | 'indigo' | 'amber' | 'slate';
|
||||
}
|
||||
|
||||
const ItemDetailNavigation: React.FC<ItemDetailNavigationProps> = ({
|
||||
previousItem,
|
||||
nextItem,
|
||||
onNavigate,
|
||||
previousItemButtonLabel,
|
||||
nextItemButtonLabel,
|
||||
noPreviousItemText,
|
||||
noNextItemText,
|
||||
themeColorClass = 'slate', // Default theme
|
||||
}) => {
|
||||
|
||||
const themeStyles: Record<string, string> = {
|
||||
blue: 'border-blue-500 text-blue-600 hover:bg-blue-50',
|
||||
teal: 'border-teal-500 text-teal-600 hover:bg-teal-50',
|
||||
indigo: 'border-indigo-500 text-indigo-600 hover:bg-indigo-50',
|
||||
amber: 'border-amber-500 text-amber-600 hover:bg-amber-50',
|
||||
slate: 'border-slate-400 text-slate-700 hover:bg-slate-100',
|
||||
}
|
||||
|
||||
const buttonBaseClasses = themeStyles[themeColorClass] || themeStyles.slate;
|
||||
const disabledClasses = "opacity-50 cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center py-8 my-8 border-t border-b border-slate-200">
|
||||
{previousItem ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => onNavigate(previousItem.id)}
|
||||
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
||||
className={buttonBaseClasses}
|
||||
aria-label={`${previousItemButtonLabel}: ${previousItem.title}`}
|
||||
>
|
||||
<div className="text-left">
|
||||
<span className="block text-xs uppercase tracking-wider opacity-75">{previousItemButtonLabel}</span>
|
||||
<span className="block font-semibold truncate max-w-[150px] sm:max-w-[200px] md:max-w-[250px]">{previousItem.title}</span>
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<div className={`flex items-center text-sm text-slate-500 ${disabledClasses}`}>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
{noPreviousItemText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextItem ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => onNavigate(nextItem.id)}
|
||||
rightIcon={<ArrowRightIcon className="w-5 h-5" />}
|
||||
className={buttonBaseClasses}
|
||||
aria-label={`${nextItemButtonLabel}: ${nextItem.title}`}
|
||||
>
|
||||
<div className="text-right">
|
||||
<span className="block text-xs uppercase tracking-wider opacity-75">{nextItemButtonLabel}</span>
|
||||
<span className="block font-semibold truncate max-w-[150px] sm:max-w-[200px] md:max-w-[250px]">{nextItem.title}</span>
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<div className={`flex items-center text-sm text-slate-500 ${disabledClasses}`}>
|
||||
{noNextItemText}
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemDetailNavigation;
|
||||
27
components/Logo.tsx
Executable file
27
components/Logo.tsx
Executable file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LogoProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Logo: React.FC<LogoProps> = ({ width = 200, height = 200, className = '' }) => {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 200 200"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-label="iiEasy Logo"
|
||||
>
|
||||
<circle cx="100" cy="100" r="70" stroke="#1F2937" strokeWidth="6" fill="none" strokeDasharray="15 85" transform="rotate(-90 100 100)"/>
|
||||
<circle cx="100" cy="100" r="50" stroke="#1F2937" strokeWidth="6" fill="none" strokeDasharray="12 58" transform="rotate(-60 100 100)"/>
|
||||
<circle cx="100" cy="100" r="30" stroke="#1F2937" strokeWidth="6" fill="none" strokeDasharray="8 32" transform="rotate(-30 100 100)"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
149
components/NewsAllSection.tsx
Executable file
149
components/NewsAllSection.tsx
Executable file
@@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { CurrentView, NewsArticle } from '../types';
|
||||
import { SECTION_IDS, NEWS_ALL_CONTENT } from '../constants';
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon } from './icons';
|
||||
import NewsCard from './NewsCard';
|
||||
import FilterSortBar, { ViewMode, SortOptionKey, SortOption } from './FilterSortBar';
|
||||
|
||||
interface NewsAllSectionProps {
|
||||
newsArticles: NewsArticle[];
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const sortOptions: SortOption[] = [
|
||||
{ key: 'newest', label: 'Сначала новые' },
|
||||
{ key: 'oldest', label: 'Сначала старые' },
|
||||
{ key: 'alphabetical', label: 'По алфавиту (А-Я)' },
|
||||
];
|
||||
|
||||
const NewsAllSection: React.FC<NewsAllSectionProps> = ({ newsArticles, setCurrentView, setSelectedItemId }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedSort, setSelectedSort] = useState<SortOptionKey>('newest');
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('main');
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => {
|
||||
document.getElementById(SECTION_IDS.latestNews)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!newsArticles) return [];
|
||||
return Array.from(new Set(newsArticles.map(p => p.category))).sort();
|
||||
}, [newsArticles]);
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const filteredAndSortedArticles = useMemo(() => {
|
||||
if (!newsArticles) return [];
|
||||
|
||||
const filtered = selectedCategories.length > 0
|
||||
? newsArticles.filter(p => selectedCategories.includes(p.category))
|
||||
: newsArticles;
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
switch (selectedSort) {
|
||||
case 'newest':
|
||||
return new Date(b.publishedAt || 0).getTime() - new Date(a.publishedAt || 0).getTime();
|
||||
case 'oldest':
|
||||
return new Date(a.publishedAt || 0).getTime() - new Date(b.publishedAt || 0).getTime();
|
||||
case 'alphabetical':
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [newsArticles, selectedCategories, selectedSort]);
|
||||
|
||||
|
||||
if (!newsArticles) {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.newsAllPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen flex justify-center items-center"
|
||||
>
|
||||
<p className="text-slate-600 font-inter">Загрузка новостей...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.newsAllPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-8 md:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900 mb-4 sm:mb-0">
|
||||
{NEWS_ALL_CONTENT.title}
|
||||
</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="self-start sm:self-center"
|
||||
>
|
||||
{NEWS_ALL_CONTENT.backButtonTextPrefix} {NEWS_ALL_CONTENT.backButtonTextSuffix}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{NEWS_ALL_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FilterSortBar
|
||||
viewMode={viewMode}
|
||||
onViewChange={setViewMode}
|
||||
availableCategories={availableCategories}
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onClearCategories={() => setSelectedCategories([])}
|
||||
sortOptions={sortOptions}
|
||||
selectedSort={selectedSort}
|
||||
onSortChange={setSelectedSort}
|
||||
/>
|
||||
|
||||
{filteredAndSortedArticles.length > 0 ? (
|
||||
<div className={layoutClasses}>
|
||||
{filteredAndSortedArticles.map(article => (
|
||||
<NewsCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{NEWS_ALL_CONTENT.emptyStateTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{NEWS_ALL_CONTENT.emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsAllSection;
|
||||
148
components/NewsArticleDetail.tsx
Executable file
148
components/NewsArticleDetail.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { CurrentView, NewsArticle } from '../types';
|
||||
import { SECTION_IDS, NEWS_ARTICLE_DETAIL_CONTENT } from '../constants'; // Removed mockNews
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon, ChevronRightIcon } from './icons';
|
||||
import ItemDetailNavigation from './ItemDetailNavigation';
|
||||
import NewsCard from './NewsCard';
|
||||
import GalleryComponent from './GalleryComponent'; // Import GalleryComponent
|
||||
|
||||
interface NewsArticleDetailProps {
|
||||
itemId: string;
|
||||
allNews: NewsArticle[];
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const NewsArticleDetail: React.FC<NewsArticleDetailProps> = ({ itemId, allNews, setCurrentView, setSelectedItemId }) => {
|
||||
const article = allNews.find(p => p.id === itemId);
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('newsAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
const handleNavigateItem = (newItemId: string) => {
|
||||
setSelectedItemId(newItemId);
|
||||
};
|
||||
|
||||
const handleShowAllKeepReading = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('newsAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<section id={SECTION_IDS.newsArticleDetailPage} className="py-16 md:py-24 min-h-screen flex items-center justify-center">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-slate-700">{NEWS_ARTICLE_DETAIL_CONTENT.notFoundTitle}</h1>
|
||||
<Button onClick={handleBackClick} leftIcon={<ArrowUturnLeftIcon />} className="mt-8">
|
||||
{NEWS_ARTICLE_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndex = allNews.findIndex(p => p.id === itemId);
|
||||
const previousArticle = currentIndex > 0 ? allNews[currentIndex - 1] : undefined;
|
||||
const nextArticle = currentIndex < allNews.length - 1 ? allNews[currentIndex + 1] : undefined;
|
||||
|
||||
const relatedArticles = allNews
|
||||
.filter(p => p.id !== itemId)
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.newsArticleDetailPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="mb-6"
|
||||
>
|
||||
{NEWS_ARTICLE_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
|
||||
{article.imageUrl && (
|
||||
<div className="mb-8 rounded-lg overflow-hidden">
|
||||
<img src={article.imageUrl} alt={article.title} className="w-full h-auto object-cover max-h-[50vh]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-800 mb-3">
|
||||
{article.title}
|
||||
</h1>
|
||||
<div className="font-inter text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-600">{article.category}</span>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span>{article.date}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{article.description && <p className="lead text-xl">{article.description}</p>}
|
||||
{typeof article.fullContent === 'string' ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: article.fullContent }} />
|
||||
) : (
|
||||
article.fullContent
|
||||
)}
|
||||
</article>
|
||||
|
||||
{article.gallery && article.gallery.length > 0 && (
|
||||
<GalleryComponent items={article.gallery} title={NEWS_ARTICLE_DETAIL_CONTENT.galleryTitle} />
|
||||
)}
|
||||
|
||||
<ItemDetailNavigation
|
||||
previousItem={previousArticle ? { id: previousArticle.id, title: previousArticle.title } : undefined}
|
||||
nextItem={nextArticle ? { id: nextArticle.id, title: nextArticle.title } : undefined}
|
||||
onNavigate={handleNavigateItem}
|
||||
previousItemButtonLabel={NEWS_ARTICLE_DETAIL_CONTENT.previousItemButtonLabel || `Предыдущая ${NEWS_ARTICLE_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
nextItemButtonLabel={NEWS_ARTICLE_DETAIL_CONTENT.nextItemButtonLabel || `Следующая ${NEWS_ARTICLE_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noPreviousItemText={NEWS_ARTICLE_DETAIL_CONTENT.noPreviousItemText || `Это первая ${NEWS_ARTICLE_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noNextItemText={NEWS_ARTICLE_DETAIL_CONTENT.noNextItemText || `Это последняя ${NEWS_ARTICLE_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
themeColorClass="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relatedArticles.length > 0 && (
|
||||
<div className="mt-12 md:mt-16">
|
||||
<div className="flex justify-between items-baseline mb-8">
|
||||
<h2 className="font-quicksand text-3xl font-bold text-slate-800">
|
||||
{NEWS_ARTICLE_DETAIL_CONTENT.keepReadingTitle || "Другие новости"}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.newsAllPage}`}
|
||||
onClick={handleShowAllKeepReading}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={`Посмотреть все ${NEWS_ARTICLE_DETAIL_CONTENT.itemTypePlural}`}
|
||||
>
|
||||
{NEWS_ARTICLE_DETAIL_CONTENT.viewAllButtonText || `Все ${NEWS_ARTICLE_DETAIL_CONTENT.itemTypePlural}`}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
{relatedArticles.map(relatedArticle => (
|
||||
<NewsCard
|
||||
key={relatedArticle.id}
|
||||
article={relatedArticle}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsArticleDetail;
|
||||
87
components/NewsCard.tsx
Executable file
87
components/NewsCard.tsx
Executable file
@@ -0,0 +1,87 @@
|
||||
|
||||
import React from 'react';
|
||||
import { NewsArticle, CurrentView } from '../types';
|
||||
import { ViewMode } from './FilterSortBar';
|
||||
|
||||
interface NewsCardProps {
|
||||
article: NewsArticle;
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
||||
const NewsCard: React.FC<NewsCardProps> = ({ article, setCurrentView, setSelectedItemId, viewMode = 'grid' }) => {
|
||||
|
||||
const handleCardClick = () => {
|
||||
setCurrentView('newsArticleDetail');
|
||||
setSelectedItemId(article.id);
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group block p-4 rounded-lg bg-white transition-all duration-300 ease-in-out hover:bg-slate-50 cursor-pointer w-full"
|
||||
role="article"
|
||||
aria-labelledby={`news-title-${article.id}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-md overflow-hidden bg-slate-200">
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt={`Изображение для новости: ${article.title}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 id={`news-title-${article.id}`} className="text-base md:text-lg font-quicksand font-semibold text-slate-800 group-hover:text-black mb-1 transition-colors duration-200 leading-tight">
|
||||
{article.title}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
<span className="font-semibold text-slate-600">{article.category}</span>
|
||||
<span className="mx-1.5 text-slate-400">•</span>
|
||||
<span>{article.date}</span>
|
||||
</p>
|
||||
{article.description && (
|
||||
<p className="text-sm text-slate-600 line-clamp-2 font-inter">{article.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group block rounded-xl overflow-hidden transition-all duration-300 ease-in-out bg-white transform hover:-translate-y-1 cursor-pointer shadow-sm hover:shadow-md"
|
||||
role="article"
|
||||
aria-labelledby={`news-title-${article.id}`}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden aspect-video">
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt={`Изображение для новости: ${article.title}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
<span className="font-semibold text-slate-600">{article.category}</span>
|
||||
<span className="mx-1.5 text-slate-400">•</span>
|
||||
<span>{article.date}</span>
|
||||
</p>
|
||||
<h4 id={`news-title-${article.id}`} className="text-base font-quicksand font-semibold text-slate-800 group-hover:text-black mb-1 transition-colors duration-200 leading-tight">
|
||||
{article.title}
|
||||
</h4>
|
||||
{article.description && (
|
||||
<p className="text-sm text-slate-600 line-clamp-3 font-inter">{article.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsCard;
|
||||
64
components/NewsSection.tsx
Executable file
64
components/NewsSection.tsx
Executable file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { CurrentView, NewsArticle } from '../types'; // Added NewsArticle type
|
||||
import { SECTION_IDS, NEWS_SECTION_CONTENT } from '../constants';
|
||||
import { ChevronRightIcon } from './icons';
|
||||
import NewsCard from './NewsCard';
|
||||
|
||||
interface NewsSectionProps {
|
||||
newsArticles: NewsArticle[]; // Changed from mockNews
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const NewsSection: React.FC<NewsSectionProps> = ({ newsArticles, setCurrentView, setSelectedItemId }) => {
|
||||
|
||||
const handleShowAllClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('newsAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
if (!newsArticles || newsArticles.length === 0) {
|
||||
return (
|
||||
<section id={SECTION_IDS.latestNews} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<p className="text-slate-600 font-inter">Загрузка новостей...</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={SECTION_IDS.latestNews} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-baseline mb-12 md:mb-16">
|
||||
<h2 className="font-quicksand text-4xl sm:text-5xl font-bold text-slate-900">
|
||||
{NEWS_SECTION_CONTENT.title}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.newsAllPage}`}
|
||||
onClick={handleShowAllClick}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={NEWS_SECTION_CONTENT.showAllAriaLabel}
|
||||
>
|
||||
{NEWS_SECTION_CONTENT.showAllText}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
{newsArticles.slice(-4).reverse().map(article => (
|
||||
<NewsCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsSection;
|
||||
54
components/OurMissionSection.tsx
Executable file
54
components/OurMissionSection.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { SECTION_IDS, OUR_MISSION_CONTENT } from '../constants';
|
||||
import { BookOpenIcon } from './icons'; // A fitting icon for a manifesto
|
||||
|
||||
const MissionSectionItem: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div className="mb-10">
|
||||
<h2 className="font-quicksand text-2xl sm:text-3xl font-bold text-slate-800 mb-4">{title}</h2>
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{typeof children === 'string' ? <p>{children}</p> : children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OurMissionSection: React.FC = () => {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.ourMissionPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
|
||||
<header className="mb-12 md:mb-16 text-center">
|
||||
<BookOpenIcon className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900">
|
||||
{OUR_MISSION_CONTENT.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-xl max-w-none font-inter text-slate-700 mb-12">
|
||||
{typeof OUR_MISSION_CONTENT.preamble === 'string'
|
||||
? <p>{OUR_MISSION_CONTENT.preamble}</p>
|
||||
: OUR_MISSION_CONTENT.preamble
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
{OUR_MISSION_CONTENT.sections.map((section, index) => (
|
||||
<MissionSectionItem key={index} title={section.title}>
|
||||
{section.content}
|
||||
</MissionSectionItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-12 pt-8 border-t border-slate-200">
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
<p className="font-semibold">{OUR_MISSION_CONTENT.closingStatement}</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default OurMissionSection;
|
||||
146
components/PostCard.tsx
Executable file
146
components/PostCard.tsx
Executable file
@@ -0,0 +1,146 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Post, CurrentView } from '../types';
|
||||
import { PlayIcon, PauseIcon } from './icons';
|
||||
import { UI_TEXTS } from '../constants';
|
||||
import { ViewMode } from './FilterSortBar';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
isFeatured?: boolean;
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
||||
const PostCard: React.FC<PostCardProps> = ({ post, isFeatured = false, setCurrentView, setSelectedItemId, viewMode = 'grid' }) => {
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const toggleVideoPlay = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent card click event when clicking video button
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
setIsVideoPlaying(true);
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
setIsVideoPlaying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
setCurrentView('blogPostDetail');
|
||||
setSelectedItemId(post.id);
|
||||
};
|
||||
|
||||
if (viewMode === 'list' && !isFeatured) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group block p-4 rounded-lg bg-white transition-all duration-300 ease-in-out hover:bg-slate-50 cursor-pointer w-full"
|
||||
role="article"
|
||||
aria-labelledby={`post-title-${post.id}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-md overflow-hidden bg-slate-200">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 id={`post-title-${post.id}`} className="text-base md:text-lg font-quicksand font-semibold text-slate-800 group-hover:text-black mb-1 transition-colors duration-200 leading-tight">
|
||||
{post.title}
|
||||
</h3>
|
||||
<div className="font-inter text-xs md:text-sm text-slate-500 mb-2">
|
||||
<span className="font-semibold text-slate-600">{post.category}</span>
|
||||
{(post.date || post.readTime) && <span className="mx-1.5">•</span>}
|
||||
{post.date && <span>{post.date}</span>}
|
||||
{post.date && post.readTime && <span className="mx-1.5">•</span>}
|
||||
{post.readTime && <span>{post.readTime}</span>}
|
||||
</div>
|
||||
{post.description && (
|
||||
<p className="text-sm text-slate-600 line-clamp-2 font-inter">{post.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid View (or Featured Card)
|
||||
const imageAspectRatio = isFeatured ? "aspect-[4/5] md:aspect-[16/9]" : "aspect-[1/1]";
|
||||
const titleSize = isFeatured ? "text-2xl md:text-3xl lg:text-4xl" : "text-xl md:text-lg";
|
||||
const scaleEffect = isFeatured ? "group-hover:scale-[1.0125]" : "group-hover:scale-[1.025]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative rounded-xl overflow-hidden transition-all duration-300 bg-white ${isFeatured ? 'flex flex-col' : ''} cursor-pointer shadow-sm hover:shadow-md`}
|
||||
onClick={handleCardClick}
|
||||
role="article"
|
||||
aria-labelledby={`post-title-${post.id}`}
|
||||
>
|
||||
<div className={`relative w-full overflow-hidden ${imageAspectRatio} ${scaleEffect} transition-transform duration-300`}>
|
||||
{post.videoUrl && !isFeatured ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
loop
|
||||
playsInline
|
||||
muted
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
poster={post.imageUrl}
|
||||
onPlay={() => setIsVideoPlaying(true)}
|
||||
onPause={() => setIsVideoPlaying(false)}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent card click on video itself
|
||||
>
|
||||
<source src={post.videoUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<button
|
||||
onClick={toggleVideoPlay}
|
||||
className="absolute top-2 right-2 z-10 p-2 bg-black bg-opacity-50 rounded-full text-white hover:bg-opacity-75 transition-opacity"
|
||||
aria-label={isVideoPlaying ? UI_TEXTS.pauseVideoAriaLabel : UI_TEXTS.playVideoAriaLabel}
|
||||
>
|
||||
{isVideoPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={post.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</div>
|
||||
<div className={`p-4 md:p-6 ${isFeatured ? 'flex-grow flex flex-col justify-between' : ''}`}>
|
||||
<div>
|
||||
<h3 id={`post-title-${post.id}`} className={`font-quicksand font-bold ${titleSize} mb-2 text-slate-800 transition-colors`}>
|
||||
{post.title}
|
||||
</h3>
|
||||
{isFeatured && post.description && (
|
||||
<p className="font-inter text-sm md:text-base text-slate-600 mb-3 line-clamp-3">{post.description}</p>
|
||||
)}
|
||||
{!isFeatured && post.description && (
|
||||
<p className="font-inter text-sm text-slate-600 mb-3 line-clamp-2">{post.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-inter text-xs md:text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-600">{post.category}</span>
|
||||
{(post.date || post.readTime) && <span className="mx-1.5">•</span>}
|
||||
{post.date && <span>{post.date}</span>}
|
||||
{post.date && post.readTime && <span className="mx-1.5">•</span>}
|
||||
{post.readTime && <span>{post.readTime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostCard;
|
||||
54
components/PrivacyPolicySection.tsx
Executable file
54
components/PrivacyPolicySection.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { CurrentView } from '../types';
|
||||
import { SECTION_IDS, PRIVACY_POLICY_CONTENT } from '../constants';
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon } from './icons';
|
||||
|
||||
interface PrivacyPolicySectionProps {
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
}
|
||||
|
||||
const PrivacyPolicySection: React.FC<PrivacyPolicySectionProps> = ({ setCurrentView }) => {
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('main');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.privacyPolicyPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="mb-6"
|
||||
>
|
||||
{PRIVACY_POLICY_CONTENT.backButtonText}
|
||||
</Button>
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-900">
|
||||
{PRIVACY_POLICY_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-slate-500 font-inter">
|
||||
{PRIVACY_POLICY_CONTENT.lastUpdated}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{PRIVACY_POLICY_CONTENT.sections.map((section, index) => (
|
||||
<div key={index} className="mb-6">
|
||||
{section.heading && <h2 className="font-quicksand text-2xl font-semibold mt-0 mb-3 text-slate-700">{section.heading}</h2>}
|
||||
{typeof section.content === 'string' ? <p>{section.content}</p> : section.content}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicySection;
|
||||
150
components/ResearchAllSection.tsx
Executable file
150
components/ResearchAllSection.tsx
Executable file
@@ -0,0 +1,150 @@
|
||||
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { CurrentView, ResearchPaper } from '../types';
|
||||
import { SECTION_IDS, RESEARCH_ALL_CONTENT } from '../constants';
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon } from './icons';
|
||||
import ResearchCard from './ResearchCard';
|
||||
import FilterSortBar, { ViewMode, SortOptionKey, SortOption } from './FilterSortBar';
|
||||
|
||||
interface ResearchAllSectionProps {
|
||||
researchPapers: ResearchPaper[];
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const sortOptions: SortOption[] = [
|
||||
{ key: 'newest', label: 'Сначала новые' },
|
||||
{ key: 'oldest', label: 'Сначала старые' },
|
||||
{ key: 'alphabetical', label: 'По алфавиту (А-Я)' },
|
||||
];
|
||||
|
||||
const ResearchAllSection: React.FC<ResearchAllSectionProps> = ({ researchPapers, setCurrentView, setSelectedItemId }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedSort, setSelectedSort] = useState<SortOptionKey>('newest');
|
||||
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('main');
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => {
|
||||
document.getElementById(SECTION_IDS.latestResearch)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!researchPapers) return [];
|
||||
return Array.from(new Set(researchPapers.map(p => p.category))).sort();
|
||||
}, [researchPapers]);
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const filteredAndSortedPapers = useMemo(() => {
|
||||
if (!researchPapers) return [];
|
||||
|
||||
const filtered = selectedCategories.length > 0
|
||||
? researchPapers.filter(p => selectedCategories.includes(p.category))
|
||||
: researchPapers;
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
switch (selectedSort) {
|
||||
case 'newest':
|
||||
return new Date(b.publishedAt || 0).getTime() - new Date(a.publishedAt || 0).getTime();
|
||||
case 'oldest':
|
||||
return new Date(a.publishedAt || 0).getTime() - new Date(b.publishedAt || 0).getTime();
|
||||
case 'alphabetical':
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [researchPapers, selectedCategories, selectedSort]);
|
||||
|
||||
|
||||
if (!researchPapers) {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.researchAllPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen flex justify-center items-center"
|
||||
>
|
||||
<p className="text-slate-600 font-inter">Загрузка исследований...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.researchAllPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-8 md:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900 mb-4 sm:mb-0">
|
||||
{RESEARCH_ALL_CONTENT.title}
|
||||
</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="self-start sm:self-center"
|
||||
>
|
||||
{RESEARCH_ALL_CONTENT.backButtonTextPrefix} {RESEARCH_ALL_CONTENT.backButtonTextSuffix}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{RESEARCH_ALL_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FilterSortBar
|
||||
viewMode={viewMode}
|
||||
onViewChange={setViewMode}
|
||||
availableCategories={availableCategories}
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onClearCategories={() => setSelectedCategories([])}
|
||||
sortOptions={sortOptions}
|
||||
selectedSort={selectedSort}
|
||||
onSortChange={setSelectedSort}
|
||||
/>
|
||||
|
||||
{filteredAndSortedPapers.length > 0 ? (
|
||||
<div className={layoutClasses}>
|
||||
{filteredAndSortedPapers.map(paper => (
|
||||
<ResearchCard
|
||||
key={paper.id}
|
||||
paper={paper}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{RESEARCH_ALL_CONTENT.emptyStateTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{RESEARCH_ALL_CONTENT.emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchAllSection;
|
||||
98
components/ResearchCard.tsx
Executable file
98
components/ResearchCard.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
|
||||
import React from 'react';
|
||||
import { ResearchPaper, CurrentView } from '../types';
|
||||
import { ViewMode } from './FilterSortBar';
|
||||
|
||||
interface ResearchCardProps {
|
||||
paper: ResearchPaper;
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
||||
const ResearchCard: React.FC<ResearchCardProps> = ({ paper, setCurrentView, setSelectedItemId, viewMode = 'grid' }) => {
|
||||
|
||||
const handleCardClick = () => {
|
||||
setCurrentView('researchPaperDetail');
|
||||
setSelectedItemId(paper.id);
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group block p-4 rounded-lg bg-white transition-all duration-300 ease-in-out hover:bg-slate-50 cursor-pointer w-full"
|
||||
role="article"
|
||||
aria-labelledby={`research-title-${paper.id}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-md overflow-hidden bg-slate-200">
|
||||
<img
|
||||
src={paper.imageUrl}
|
||||
alt={`Визуализация для исследования: ${paper.title}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 id={`research-title-${paper.id}`} className="text-base md:text-lg font-quicksand font-semibold text-slate-800 group-hover:text-black mb-1 transition-colors duration-200 leading-tight">
|
||||
{paper.title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
<span className="font-semibold text-slate-600">{paper.category}</span>
|
||||
<span className="mx-1.5 text-slate-400">•</span>
|
||||
<span>{paper.date}</span>
|
||||
</p>
|
||||
{paper.authors && (
|
||||
<p className="text-xs text-slate-600 mt-2 font-inter line-clamp-1">
|
||||
Авторы: {paper.authors.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{paper.abstract && !paper.authors && (
|
||||
<p className="text-sm text-slate-600 line-clamp-2 font-inter">{paper.abstract}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid view
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group block rounded-xl overflow-hidden transition-all duration-300 ease-in-out bg-white transform hover:-translate-y-1 cursor-pointer shadow-sm hover:shadow-md"
|
||||
role="article"
|
||||
aria-labelledby={`research-title-${paper.id}`}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden aspect-[4/3]">
|
||||
<img
|
||||
src={paper.imageUrl}
|
||||
alt={`Визуализация для исследования: ${paper.title}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 ease-in-out"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 md:p-5">
|
||||
<h3 id={`research-title-${paper.id}`} className="font-quicksand font-semibold text-lg md:text-xl text-slate-800 group-hover:text-black mb-2 transition-colors duration-200 leading-tight">
|
||||
{paper.title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
<span className="font-semibold text-slate-600">{paper.category}</span>
|
||||
<span className="mx-1.5 text-slate-400">•</span>
|
||||
<span>{paper.date}</span>
|
||||
</p>
|
||||
{paper.authors && (
|
||||
<p className="text-xs text-slate-600 mt-2 font-inter line-clamp-1">
|
||||
Авторы: {paper.authors.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{paper.abstract && !paper.authors && (
|
||||
<p className="text-sm text-slate-600 mt-2 font-inter line-clamp-2">{paper.abstract}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchCard;
|
||||
159
components/ResearchPaperDetail.tsx
Executable file
159
components/ResearchPaperDetail.tsx
Executable file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { CurrentView, ResearchPaper } from '../types';
|
||||
import { SECTION_IDS, RESEARCH_PAPER_DETAIL_CONTENT } from '../constants'; // Removed mockResearchPapers
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon, ChevronRightIcon } from './icons';
|
||||
import ItemDetailNavigation from './ItemDetailNavigation';
|
||||
import ResearchCard from './ResearchCard';
|
||||
import GalleryComponent from './GalleryComponent'; // Import GalleryComponent
|
||||
|
||||
interface ResearchPaperDetailProps {
|
||||
itemId: string;
|
||||
allResearchPapers: ResearchPaper[];
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const ResearchPaperDetail: React.FC<ResearchPaperDetailProps> = ({ itemId, allResearchPapers, setCurrentView, setSelectedItemId }) => {
|
||||
const paper = allResearchPapers.find(p => p.id === itemId);
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('researchAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
const handleNavigateItem = (newItemId: string) => {
|
||||
setSelectedItemId(newItemId);
|
||||
};
|
||||
|
||||
const handleShowAllKeepReading = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('researchAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
if (!paper) {
|
||||
return (
|
||||
<section id={SECTION_IDS.researchPaperDetailPage} className="py-16 md:py-24 min-h-screen flex items-center justify-center">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-slate-700">{RESEARCH_PAPER_DETAIL_CONTENT.notFoundTitle}</h1>
|
||||
<Button onClick={handleBackClick} leftIcon={<ArrowUturnLeftIcon />} className="mt-8">
|
||||
{RESEARCH_PAPER_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndex = allResearchPapers.findIndex(p => p.id === itemId);
|
||||
const previousPaper = currentIndex > 0 ? allResearchPapers[currentIndex - 1] : undefined;
|
||||
const nextPaper = currentIndex < allResearchPapers.length - 1 ? allResearchPapers[currentIndex + 1] : undefined;
|
||||
|
||||
const relatedPapers = allResearchPapers
|
||||
.filter(p => p.id !== itemId)
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.researchPaperDetailPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="mb-6"
|
||||
>
|
||||
{RESEARCH_PAPER_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
|
||||
{paper.imageUrl && (
|
||||
<div className="mb-8 rounded-lg overflow-hidden">
|
||||
<img src={paper.imageUrl} alt={paper.title} className="w-full h-auto object-cover max-h-[60vh]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-800 mb-3">
|
||||
{paper.title}
|
||||
</h1>
|
||||
<div className="font-inter text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-600">{paper.category}</span>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span>{paper.date}</span>
|
||||
{paper.authors && paper.authors.length > 0 && (
|
||||
<>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span>Авторы: {paper.authors.join(', ')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{paper.abstract && (
|
||||
<div className="p-4 bg-slate-50 rounded-md mb-6">
|
||||
<h2 className="text-xl font-semibold font-quicksand text-slate-700">{RESEARCH_PAPER_DETAIL_CONTENT.abstractTitle}</h2>
|
||||
<p>{paper.abstract}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof paper.fullContent === 'string' ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: paper.fullContent }} />
|
||||
) : (
|
||||
paper.fullContent
|
||||
)}
|
||||
</article>
|
||||
|
||||
{paper.gallery && paper.gallery.length > 0 && (
|
||||
<GalleryComponent items={paper.gallery} title={RESEARCH_PAPER_DETAIL_CONTENT.galleryTitle} />
|
||||
)}
|
||||
|
||||
<ItemDetailNavigation
|
||||
previousItem={previousPaper ? { id: previousPaper.id, title: previousPaper.title } : undefined}
|
||||
nextItem={nextPaper ? { id: nextPaper.id, title: nextPaper.title } : undefined}
|
||||
onNavigate={handleNavigateItem}
|
||||
previousItemButtonLabel={RESEARCH_PAPER_DETAIL_CONTENT.previousItemButtonLabel || `Предыдущее ${RESEARCH_PAPER_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
nextItemButtonLabel={RESEARCH_PAPER_DETAIL_CONTENT.nextItemButtonLabel || `Следующее ${RESEARCH_PAPER_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noPreviousItemText={RESEARCH_PAPER_DETAIL_CONTENT.noPreviousItemText || `Это первое ${RESEARCH_PAPER_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
noNextItemText={RESEARCH_PAPER_DETAIL_CONTENT.noNextItemText || `Это последнее ${RESEARCH_PAPER_DETAIL_CONTENT.itemTypeSingular}`}
|
||||
themeColorClass="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relatedPapers.length > 0 && (
|
||||
<div className="mt-12 md:mt-16">
|
||||
<div className="flex justify-between items-baseline mb-8">
|
||||
<h2 className="font-quicksand text-3xl font-bold text-slate-800">
|
||||
{RESEARCH_PAPER_DETAIL_CONTENT.keepReadingTitle || "Дальнейшие исследования"}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.researchAllPage}`}
|
||||
onClick={handleShowAllKeepReading}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={`Посмотреть все ${RESEARCH_PAPER_DETAIL_CONTENT.itemTypePlural}`}
|
||||
>
|
||||
{RESEARCH_PAPER_DETAIL_CONTENT.viewAllButtonText || `Все ${RESEARCH_PAPER_DETAIL_CONTENT.itemTypePlural}`}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{relatedPapers.map(relatedPaper => (
|
||||
<ResearchCard
|
||||
key={relatedPaper.id}
|
||||
paper={relatedPaper}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchPaperDetail;
|
||||
65
components/ResearchSection.tsx
Executable file
65
components/ResearchSection.tsx
Executable file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { CurrentView, ResearchPaper } from '../types'; // Added ResearchPaper type
|
||||
import { SECTION_IDS, RESEARCH_SECTION_CONTENT } from '../constants';
|
||||
import { ChevronRightIcon } from './icons';
|
||||
import ResearchCard from './ResearchCard';
|
||||
|
||||
interface ResearchSectionProps {
|
||||
researchPapers: ResearchPaper[]; // Changed from mockResearchPapers
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const ResearchSection: React.FC<ResearchSectionProps> = ({ researchPapers, setCurrentView, setSelectedItemId }) => {
|
||||
|
||||
const handleShowAllClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('researchAll');
|
||||
setSelectedItemId(null);
|
||||
};
|
||||
|
||||
if (!researchPapers || researchPapers.length === 0) {
|
||||
return (
|
||||
<section id={SECTION_IDS.latestResearch} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<p className="text-slate-600 font-inter">Загрузка исследований...</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<section id={SECTION_IDS.latestResearch} className="py-16 md:py-24 bg-white">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-baseline mb-12 md:mb-16">
|
||||
<h2 className="font-quicksand text-4xl sm:text-5xl font-bold text-slate-900">
|
||||
{RESEARCH_SECTION_CONTENT.title}
|
||||
</h2>
|
||||
<a
|
||||
href={`#${SECTION_IDS.researchAllPage}`}
|
||||
onClick={handleShowAllClick}
|
||||
className="group inline-flex items-center text-base font-medium text-slate-700 hover:text-black transition-colors whitespace-nowrap"
|
||||
aria-label={RESEARCH_SECTION_CONTENT.showAllAriaLabel}
|
||||
>
|
||||
{RESEARCH_SECTION_CONTENT.showAllText}
|
||||
<ChevronRightIcon className="ml-1 w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8 xl:gap-10">
|
||||
{researchPapers.slice(-4).reverse().map(paper => (
|
||||
<ResearchCard
|
||||
key={paper.id}
|
||||
paper={paper}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchSection;
|
||||
83
components/SafetyDiagram.tsx
Executable file
83
components/SafetyDiagram.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeftIcon } from './icons';
|
||||
|
||||
const DiagramNode: React.FC<{ label: string; className?: string; children?: React.ReactNode }> = ({ label, className = '', children }) => (
|
||||
<div className={`absolute ${className}`}>
|
||||
<div className="relative w-[220px] h-[220px]">
|
||||
<div className="absolute left-1/2 top-1/2 flex h-[150px] w-[150px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-slate-100 text-center text-xl font-semibold text-slate-700">
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OrbitingLabel: React.FC<{ label: string; className?: string }> = ({ label, className = '' }) => (
|
||||
<div className={`absolute bg-slate-50 uppercase px-4 py-2 rounded-lg font-mono text-xs text-slate-600 text-center whitespace-nowrap ${className}`}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnimatedArrowCircle: React.FC<{ duration?: string, direction?: 'normal' | 'reverse' }> = ({ duration = '30s', direction = 'normal' }) => (
|
||||
<div
|
||||
className="absolute inset-[-5px] border-2 border-dashed border-green-500 rounded-full"
|
||||
style={{
|
||||
borderColor: '#22c55e transparent transparent transparent',
|
||||
animation: `spin ${duration} linear infinite`,
|
||||
animationDirection: direction,
|
||||
}}
|
||||
>
|
||||
<div className={`absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full ${direction === 'reverse' ? '-rotate-90' : 'rotate-90'}`}>
|
||||
<div className="h-0 w-0" style={{
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '0 4px 7px 4px',
|
||||
borderColor: 'transparent transparent #22c55e transparent',
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConnectingArrow: React.FC<{ className?: string, rotation?: string }> = ({ className = '', rotation = '0deg' }) => (
|
||||
<div className={`absolute flex items-center flex-col gap-y-1 w-10 text-slate-400 ${className}`} style={{ transform: `rotate(${rotation})` }}>
|
||||
<div className="animate-safety-arrow"><ArrowLeftIcon className="w-4 h-4 rotate-180" /></div>
|
||||
<div className="animate-safety-arrow" style={{animationDelay: '1s'}}><ArrowLeftIcon className="w-4 h-4 rotate-180" /></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SafetyDiagram: React.FC = () => {
|
||||
return (
|
||||
<div className="relative w-full max-w-5xl mx-auto my-12" style={{ aspectRatio: '1920 / 1080' }}>
|
||||
<div className="relative w-full h-full font-mono uppercase scale-[0.5] sm:scale-[0.6] md:scale-[0.75] lg:scale-[0.9] xl:scale-100 origin-top-left">
|
||||
|
||||
<DiagramNode label="Teach" className="left-[440px] top-[76px]">
|
||||
<AnimatedArrowCircle duration="30s" direction="normal" />
|
||||
<OrbitingLabel label="Filter Data" className="left-1/2 top-[-20px] -translate-x-1/2 -translate-y-full" />
|
||||
<OrbitingLabel label="Human Values" className="left-[-20px] top-1/2 -translate-x-full -translate-y-1/2" />
|
||||
<OrbitingLabel label="Company Policies" className="left-1/2 bottom-[-20px] -translate-x-1/2 translate-y-full" />
|
||||
</DiagramNode>
|
||||
|
||||
<DiagramNode label="Test" className="left-[1030px] top-[76px]">
|
||||
<AnimatedArrowCircle duration="40s" direction="reverse" />
|
||||
<OrbitingLabel label="Red Teaming" className="left-1/2 top-[-20px] -translate-x-1/2 -translate-y-full" />
|
||||
<OrbitingLabel label="Preparedness Evals" className="right-[-20px] top-1/2 translate-x-full -translate-y-1/2" />
|
||||
<OrbitingLabel label="System Cards" className="left-1/2 bottom-[-20px] -translate-x-1/2 translate-y-full" />
|
||||
</DiagramNode>
|
||||
|
||||
<DiagramNode label="Share" className="left-[735px] top-[552px]">
|
||||
<AnimatedArrowCircle duration="35s" direction="normal" />
|
||||
<OrbitingLabel label="Safety Committees" className="left-1/2 top-[-20px] -translate-x-1/2 -translate-y-full" />
|
||||
<OrbitingLabel label="Feedback" className="left-[-20px] top-1/2 -translate-x-full -translate-y-1/2" />
|
||||
<OrbitingLabel label="Alpha / Beta" className="right-[-20px] top-1/2 translate-x-full -translate-y-1/2" />
|
||||
</DiagramNode>
|
||||
|
||||
{/* Connecting Arrows */}
|
||||
<ConnectingArrow className="left-[940px] top-[282px]" />
|
||||
<ConnectingArrow className="left-[1090px] top-[525px]" rotation="-55deg" />
|
||||
<ConnectingArrow className="left-[790px] top-[525px] rotate-[55deg]" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SafetyDiagram;
|
||||
78
components/ServiceOfferCard.tsx
Executable file
78
components/ServiceOfferCard.tsx
Executable file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import Button from './Button'; // Import the Button component
|
||||
|
||||
// Define a basic interface for a service offer, exported for potential use elsewhere
|
||||
export interface ServiceOffer {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price?: string;
|
||||
imageUrl?: string;
|
||||
imageAlt?: string;
|
||||
actionText?: string;
|
||||
onAction?: (id: string) => void;
|
||||
themeColor?: 'purple' | 'blue' | 'teal' | 'amber'; // Optional theme color
|
||||
}
|
||||
|
||||
interface ServiceOfferCardProps {
|
||||
offer: ServiceOffer;
|
||||
}
|
||||
|
||||
const ServiceOfferCard: React.FC<ServiceOfferCardProps> = ({ offer }) => {
|
||||
const handleActionClick = () => {
|
||||
if (offer.onAction) {
|
||||
offer.onAction(offer.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl transition-all duration-300 ease-in-out overflow-hidden flex flex-col h-full group"
|
||||
role="article"
|
||||
aria-labelledby={`offer-title-${offer.id}`}
|
||||
>
|
||||
{offer.imageUrl && (
|
||||
<div className="w-full h-48 sm:h-56 bg-slate-200 overflow-hidden">
|
||||
<img
|
||||
src={offer.imageUrl}
|
||||
alt={offer.imageAlt || `Visual for ${offer.title}`}
|
||||
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 md:p-6 flex flex-col flex-grow">
|
||||
<h3
|
||||
id={`offer-title-${offer.id}`}
|
||||
className={`font-quicksand text-xl sm:text-2xl font-bold text-slate-900 mb-2 group-hover:text-black transition-colors`}
|
||||
>
|
||||
{offer.title}
|
||||
</h3>
|
||||
<p className="font-inter text-slate-600 text-sm leading-relaxed mb-4 flex-grow">
|
||||
{offer.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between mt-auto">
|
||||
{offer.price && (
|
||||
<p className={`font-quicksand text-xl font-bold text-slate-700 mb-3 sm:mb-0`}>
|
||||
{offer.price}
|
||||
</p>
|
||||
)}
|
||||
{offer.actionText && offer.onAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={handleActionClick}
|
||||
className={`${!offer.price ? 'ml-auto' : ''}`}
|
||||
aria-label={`${offer.actionText} for ${offer.title}`}
|
||||
>
|
||||
{offer.actionText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceOfferCard;
|
||||
146
components/Sidebar.tsx
Executable file
146
components/Sidebar.tsx
Executable file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { NAV_LINKS, ABOUT_CONTEXT_NAV_LINKS, BUSINESS_CONTEXT_NAV_LINKS, EDUCATION_CONTEXT_NAV_LINKS, ACCELERATOR_CONTEXT_NAV_LINKS, SECTION_IDS, APP_NAME, FOOTER_CONTENT } from '../constants';
|
||||
import { NavLinkItem, CurrentView } from '../types';
|
||||
import { ChevronRightIcon } from './icons';
|
||||
import Logo from './Logo'; // Import the new Logo component
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentView: CurrentView;
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
isDesktopCollapsed: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, currentView, setCurrentView, isDesktopCollapsed }) => {
|
||||
|
||||
const scrollToSection = (hash: string) => {
|
||||
if (hash.startsWith('#')) {
|
||||
const sectionId = hash.substring(1);
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
console.warn(`Element with ID ${sectionId} not found for scrolling.`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Invalid hash for scrolling: ${hash}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavLinkClick = (item: NavLinkItem) => {
|
||||
if (item.targetView) {
|
||||
setCurrentView(item.targetView);
|
||||
setTimeout(() => scrollToSection(item.href), 50); // Increased delay
|
||||
} else {
|
||||
setCurrentView('main');
|
||||
setTimeout(() => scrollToSection(item.href), 50); // Increased delay
|
||||
}
|
||||
|
||||
if (window.innerWidth < 768) { // md breakpoint
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isAboutContextView = ['about', 'mission', 'careers'].includes(currentView);
|
||||
const isBusinessContextView = ['businessLanding', 'businessServices'].includes(currentView);
|
||||
const isEducationContextView = ['educationBusiness', 'educationStudents'].includes(currentView);
|
||||
const isAcceleratorContextView = ['acceleratorAbout', 'acceleratorProjects', 'acceleratorInvestment'].includes(currentView);
|
||||
|
||||
let linksToRender: NavLinkItem[];
|
||||
if (isAboutContextView) {
|
||||
linksToRender = ABOUT_CONTEXT_NAV_LINKS;
|
||||
} else if (isBusinessContextView) {
|
||||
linksToRender = BUSINESS_CONTEXT_NAV_LINKS;
|
||||
} else if (isEducationContextView) {
|
||||
linksToRender = EDUCATION_CONTEXT_NAV_LINKS;
|
||||
} else if (isAcceleratorContextView) {
|
||||
linksToRender = ACCELERATOR_CONTEXT_NAV_LINKS;
|
||||
} else {
|
||||
linksToRender = NAV_LINKS;
|
||||
}
|
||||
|
||||
const getLinkClasses = (item: NavLinkItem) => {
|
||||
let baseClasses = `relative flex items-center p-3 rounded-lg hover:bg-slate-100
|
||||
transition-colors duration-150
|
||||
focus:outline-none focus:ring-2 focus:ring-slate-400 focus:bg-slate-100`;
|
||||
|
||||
if (item.label === 'На Главную') {
|
||||
baseClasses += ' text-slate-500 hover:text-slate-700';
|
||||
} else {
|
||||
baseClasses += ' text-slate-700 hover:text-slate-900';
|
||||
}
|
||||
|
||||
const isActive = (isAboutContextView || isBusinessContextView || isEducationContextView || isAcceleratorContextView) && item.targetView === currentView;
|
||||
|
||||
if (isActive) {
|
||||
baseClasses += ' bg-slate-200 text-slate-900 font-semibold';
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
};
|
||||
|
||||
const sidebarClasses = `h-screen w-72 flex flex-col p-6 transform transition-all duration-300 ease-in-out
|
||||
fixed top-0 left-0 z-40
|
||||
bg-white/95 backdrop-blur-sm
|
||||
md:bg-white md:z-30
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
${isDesktopCollapsed ? 'md:-translate-x-full' : 'md:translate-x-0'}`;
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="sidebar-drawer"
|
||||
className={sidebarClasses}
|
||||
aria-label="Sidebar"
|
||||
>
|
||||
<div className="flex items-center justify-start mb-8 pl-1">
|
||||
<a
|
||||
href={`#${SECTION_IDS.hero}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentView('main');
|
||||
setTimeout(() => scrollToSection(`#${SECTION_IDS.hero}`), 50);
|
||||
if (window.innerWidth < 768) onClose();
|
||||
}}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Logo width={32} height={32} />
|
||||
<span className={`font-quicksand text-2xl font-bold text-slate-900 transition-opacity duration-200 ${isDesktopCollapsed ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{APP_NAME}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav className="flex-grow flex flex-col justify-center overflow-y-auto no-scrollbar">
|
||||
<ul className="space-y-2 font-medium">
|
||||
{linksToRender.map((item: NavLinkItem) => (
|
||||
<li key={item.id} className="group relative">
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavLinkClick(item);
|
||||
}}
|
||||
className={`${getLinkClasses(item)}`}
|
||||
>
|
||||
{item.icon && <item.icon className="w-5 h-5 shrink-0 text-slate-500 group-hover:text-slate-700 transition-colors duration-150 mr-4" />}
|
||||
<span className="font-quicksand text-base whitespace-nowrap">{item.label}</span>
|
||||
{item.children && (
|
||||
<ChevronRightIcon className="w-4 h-4 ml-auto opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="pt-4 mt-8 border-t border-slate-200">
|
||||
<p className="text-xs text-center text-slate-500">
|
||||
{FOOTER_CONTENT.copyrightText(new Date().getFullYear())}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
151
components/StoriesAllSection.tsx
Executable file
151
components/StoriesAllSection.tsx
Executable file
@@ -0,0 +1,151 @@
|
||||
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { CurrentView, Post } from '../types';
|
||||
import { SECTION_IDS, STORIES_ALL_CONTENT } from '../constants';
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon } from './icons';
|
||||
import PostCard from './PostCard';
|
||||
import FilterSortBar, { ViewMode, SortOptionKey, SortOption } from './FilterSortBar';
|
||||
|
||||
interface StoriesAllSectionProps {
|
||||
posts: Post[];
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const sortOptions: SortOption[] = [
|
||||
{ key: 'newest', label: 'Сначала новые' },
|
||||
{ key: 'oldest', label: 'Сначала старые' },
|
||||
{ key: 'alphabetical', label: 'По алфавиту (А-Я)' },
|
||||
];
|
||||
|
||||
const StoriesAllSection: React.FC<StoriesAllSectionProps> = ({ posts, setCurrentView, setSelectedItemId }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedSort, setSelectedSort] = useState<SortOptionKey>('newest');
|
||||
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('main');
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => {
|
||||
document.getElementById(SECTION_IDS.story)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!posts) return [];
|
||||
return Array.from(new Set(posts.map(p => p.category))).sort();
|
||||
}, [posts]);
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const filteredAndSortedPosts = useMemo(() => {
|
||||
if (!posts) return [];
|
||||
|
||||
const filtered = selectedCategories.length > 0
|
||||
? posts.filter(p => selectedCategories.includes(p.category))
|
||||
: posts;
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
switch (selectedSort) {
|
||||
case 'newest':
|
||||
return new Date(b.publishedAt || 0).getTime() - new Date(a.publishedAt || 0).getTime();
|
||||
case 'oldest':
|
||||
return new Date(a.publishedAt || 0).getTime() - new Date(b.publishedAt || 0).getTime();
|
||||
case 'alphabetical':
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [posts, selectedCategories, selectedSort]);
|
||||
|
||||
|
||||
if (!posts) {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.storiesAllPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen flex justify-center items-center"
|
||||
>
|
||||
<p className="text-slate-600 font-inter">Загрузка проектов...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
id={SECTION_IDS.storiesAllPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-8 md:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-quicksand text-4xl sm:text-5xl md:text-6xl font-bold text-slate-900 mb-4 sm:mb-0">
|
||||
{STORIES_ALL_CONTENT.title}
|
||||
</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="self-start sm:self-center"
|
||||
>
|
||||
{STORIES_ALL_CONTENT.backButtonTextPrefix} {STORIES_ALL_CONTENT.backButtonTextSuffix}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-lg md:text-xl text-slate-700 max-w-3xl">
|
||||
{STORIES_ALL_CONTENT.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FilterSortBar
|
||||
viewMode={viewMode}
|
||||
onViewChange={setViewMode}
|
||||
availableCategories={availableCategories}
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onClearCategories={() => setSelectedCategories([])}
|
||||
sortOptions={sortOptions}
|
||||
selectedSort={selectedSort}
|
||||
onSortChange={setSelectedSort}
|
||||
/>
|
||||
|
||||
{filteredAndSortedPosts.length > 0 ? (
|
||||
<div className={layoutClasses}>
|
||||
{filteredAndSortedPosts.map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isFeatured={false}
|
||||
setCurrentView={setCurrentView}
|
||||
setSelectedItemId={setSelectedItemId}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">
|
||||
<h2 className="text-2xl font-quicksand font-semibold text-slate-700 mb-3">{STORIES_ALL_CONTENT.emptyStateTitle}</h2>
|
||||
<p className="text-slate-600 font-inter">
|
||||
{STORIES_ALL_CONTENT.emptyStateMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoriesAllSection;
|
||||
55
components/TermsOfUseSection.tsx
Executable file
55
components/TermsOfUseSection.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { CurrentView } from '../types';
|
||||
import { SECTION_IDS, TERMS_OF_USE_CONTENT } from '../constants';
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon } from './icons';
|
||||
|
||||
interface TermsOfUseSectionProps {
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
}
|
||||
|
||||
const TermsOfUseSection: React.FC<TermsOfUseSectionProps> = ({ setCurrentView }) => {
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('main');
|
||||
// Optional: Scroll to top or specific section if needed
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.termsOfUsePage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
className="mb-6"
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
>
|
||||
{TERMS_OF_USE_CONTENT.backButtonText}
|
||||
</Button>
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-900">
|
||||
{TERMS_OF_USE_CONTENT.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-slate-500 font-inter">
|
||||
{TERMS_OF_USE_CONTENT.lastUpdated}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{TERMS_OF_USE_CONTENT.sections.map((section, index) => (
|
||||
<div key={index} className="mb-6">
|
||||
{section.heading && <h2 className="font-quicksand text-2xl font-semibold mt-0 mb-3 text-slate-700">{section.heading}</h2>}
|
||||
{typeof section.content === 'string' ? <p>{section.content}</p> : section.content}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsOfUseSection;
|
||||
166
components/VacancyDetail.tsx
Executable file
166
components/VacancyDetail.tsx
Executable file
@@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { CurrentView, Vacancy } from '../types';
|
||||
import { SECTION_IDS, VACANCY_DETAIL_CONTENT } from '../constants'; // mockVacancies removed
|
||||
import Button from './Button';
|
||||
import { ArrowUturnLeftIcon, BriefcaseIcon, MapPinIcon, ClockIcon, EmailIcon, CheckIcon } from './icons';
|
||||
|
||||
interface VacancyDetailProps {
|
||||
itemId: string;
|
||||
allVacancies: Vacancy[]; // Changed to accept allVacancies from Strapi
|
||||
setCurrentView: (view: CurrentView) => void;
|
||||
setSelectedItemId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const VacancyDetail: React.FC<VacancyDetailProps> = ({ itemId, allVacancies, setCurrentView, setSelectedItemId }) => {
|
||||
const vacancy = allVacancies.find(v => v.id === itemId || v.href === itemId);
|
||||
|
||||
const handleBackClick = () => {
|
||||
setCurrentView('careers');
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => {
|
||||
document.getElementById(SECTION_IDS.careersPage)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (!vacancy) {
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.vacancyDetailPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen flex items-center justify-center"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<BriefcaseIcon className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<h1 className="text-2xl font-semibold font-quicksand text-slate-700 mb-4">{VACANCY_DETAIL_CONTENT.notFoundTitle}</h1>
|
||||
<p className="text-slate-600 mb-8">
|
||||
{VACANCY_DETAIL_CONTENT.vacancyNotFoundMessage}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
variant="outline"
|
||||
>
|
||||
{VACANCY_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const applyMailto = `mailto:${VACANCY_DETAIL_CONTENT.hrEmail}?subject=${encodeURIComponent(VACANCY_DETAIL_CONTENT.mailtoSubjectPrefix + vacancy.title)}&body=${encodeURIComponent(VACANCY_DETAIL_CONTENT.mailtoBodyPrefix + vacancy.title + "\"." + " Мое резюме во вложении.")}`;
|
||||
|
||||
|
||||
return (
|
||||
<section
|
||||
id={SECTION_IDS.vacancyDetailPage}
|
||||
className="py-16 md:py-24 bg-white min-h-screen"
|
||||
>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
leftIcon={<ArrowUturnLeftIcon />}
|
||||
className="mb-6"
|
||||
>
|
||||
{VACANCY_DETAIL_CONTENT.backButtonText}
|
||||
</Button>
|
||||
<h1 className="font-quicksand text-3xl sm:text-4xl md:text-5xl font-bold text-slate-800 mb-3">
|
||||
{vacancy.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 font-inter text-sm text-slate-600">
|
||||
<div className="flex items-center">
|
||||
<BriefcaseIcon className="w-4 h-4 mr-1.5 text-slate-500" />
|
||||
<span>{vacancy.department}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MapPinIcon className="w-4 h-4 mr-1.5 text-slate-500" />
|
||||
<span>{vacancy.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ClockIcon className="w-4 h-4 mr-1.5 text-slate-500" />
|
||||
<span>{vacancy.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="bg-white p-6 sm:p-8 md:p-10 rounded-xl">
|
||||
<div className="prose prose-lg max-w-none font-inter text-slate-700">
|
||||
{vacancy.fullDescription && (
|
||||
<>
|
||||
<h2 className="font-quicksand text-xl font-semibold text-slate-700">{VACANCY_DETAIL_CONTENT.descriptionTitle}</h2>
|
||||
{typeof vacancy.fullDescription === 'string' && vacancy.fullDescription.includes('<') ?
|
||||
<div dangerouslySetInnerHTML={{ __html: vacancy.fullDescription }} /> :
|
||||
<p>{vacancy.fullDescription}</p>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{vacancy.responsibilities && vacancy.responsibilities.length > 0 && (
|
||||
<>
|
||||
<h2 className="font-quicksand text-xl font-semibold text-slate-700 mt-8">{VACANCY_DETAIL_CONTENT.responsibilitiesTitle}</h2>
|
||||
<ul className="list-none p-0 space-y-3">
|
||||
{vacancy.responsibilities.map((item, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<CheckIcon className="w-5 h-5 text-slate-500 mr-3 mt-1 flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{vacancy.qualifications && vacancy.qualifications.length > 0 && (
|
||||
<>
|
||||
<h2 className="font-quicksand text-xl font-semibold text-slate-700 mt-8">{VACANCY_DETAIL_CONTENT.qualificationsTitle}</h2>
|
||||
<ul className="list-none p-0 space-y-3">
|
||||
{vacancy.qualifications.map((item, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<CheckIcon className="w-5 h-5 text-slate-500 mr-3 mt-1 flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{vacancy.offer && vacancy.offer.length > 0 && (
|
||||
<>
|
||||
<h2 className="font-quicksand text-xl font-semibold text-slate-700 mt-8">{VACANCY_DETAIL_CONTENT.offerTitle}</h2>
|
||||
<ul className="list-none p-0 space-y-3">
|
||||
{vacancy.offer.map((item, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<CheckIcon className="w-5 h-5 text-slate-500 mr-3 mt-1 flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-8 border-t border-slate-200 text-center">
|
||||
<h3 className="text-xl font-quicksand font-semibold text-slate-700 mb-4">
|
||||
{VACANCY_DETAIL_CONTENT.applyPromptTitle}
|
||||
</h3>
|
||||
<p className="text-slate-600 font-inter mb-6 max-w-lg mx-auto">
|
||||
{VACANCY_DETAIL_CONTENT.applyPromptMessage}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
leftIcon={<EmailIcon />}
|
||||
onClick={() => window.location.href = applyMailto}
|
||||
>
|
||||
{VACANCY_DETAIL_CONTENT.applyButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default VacancyDetail;
|
||||
148
components/icons.tsx
Executable file
148
components/icons.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowDownTrayIcon as ArrowDownTray,
|
||||
SparklesIcon as Sparkles,
|
||||
Bars3Icon as Hamburger,
|
||||
XMarkIcon as Close,
|
||||
ChevronRightIcon as ChevronRight,
|
||||
ChevronDownIcon as ChevronDown, // Added
|
||||
Squares2X2Icon as Squares2X2, // Added
|
||||
PlayIcon as Play,
|
||||
PauseIcon as Pause,
|
||||
AcademicCapIcon,
|
||||
ShieldCheckIcon,
|
||||
CpuChipIcon,
|
||||
SparklesIcon as SoraIcon, // Using SparklesIcon for Sora as a placeholder
|
||||
CircleStackIcon,
|
||||
BuildingOffice2Icon,
|
||||
BookOpenIcon,
|
||||
InformationCircleIcon,
|
||||
NewspaperIcon,
|
||||
EnvelopeIcon as Email, // Renaming for clarity
|
||||
HomeIcon as HomeOutline, // For About context nav
|
||||
UserGroupIcon as UserGroup, // For About Us
|
||||
LightBulbIcon as LightBulb, // For Our Mission
|
||||
BriefcaseIcon as Briefcase, // For Careers
|
||||
WrenchScrewdriverIcon, // For Business Services
|
||||
ArrowUturnLeftIcon as ArrowUturnLeft, // Added for back buttons
|
||||
RocketLaunchIcon, // For Startup Accelerator
|
||||
CubeTransparentIcon as CubeTransparent, // For Accelerator Projects
|
||||
CurrencyDollarIcon, // For Accelerator Investment
|
||||
BuildingLibraryIcon, // Added for Education
|
||||
MapPinIcon, // Added for VacancyDetail
|
||||
ClockIcon, // Added for VacancyDetail
|
||||
ChartBarIcon, // Added for BusinessCourse, AcceleratorFeature
|
||||
UsersIcon, // Added for AcceleratorFeature
|
||||
CodeBracketIcon, // Added for BusinessCourse, StudentProgram, ServiceItemData
|
||||
PaintBrushIcon, // Added for ServiceItemData
|
||||
MagnifyingGlassIcon, // Added for ServiceItemData
|
||||
PuzzlePieceIcon, // Added for ServiceItemData
|
||||
PresentationChartLineIcon, // Added for AcceleratorInvestment
|
||||
TagIcon, // Added for AcceleratorProjects
|
||||
LinkIcon, // Added for AcceleratorProjects
|
||||
ComputerDesktopIcon, // Added for development services
|
||||
CommandLineIcon, // Added for development/programs
|
||||
ArrowLeftIcon as ArrowLeft, // For item navigation
|
||||
ArrowRightIcon as ArrowRight, // For item navigation
|
||||
PhotoIcon as Photo, // Added for Gallery
|
||||
VideoCameraIcon as VideoCamera, // Added for Gallery
|
||||
ArrowUpIcon as ArrowUp,
|
||||
PlusIcon,
|
||||
CheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export const ChevronDownIcon = ChevronDown;
|
||||
export const Squares2X2Icon = Squares2X2;
|
||||
|
||||
export const ArrowDownTrayIcon = ArrowDownTray;
|
||||
export const SparklesIcon = Sparkles;
|
||||
export const Bars3Icon = Hamburger;
|
||||
export const XMarkIcon = Close;
|
||||
export const ChevronRightIcon = ChevronRight;
|
||||
export const PlayIcon = Play;
|
||||
export const PauseIcon = Pause;
|
||||
export const EmailIcon = Email; // Export with specific name for ConnectSection
|
||||
export const ArrowUturnLeftIcon = ArrowUturnLeft; // Exporting ArrowUturnLeftIcon
|
||||
export const ArrowLeftIcon = ArrowLeft;
|
||||
export const ArrowRightIcon = ArrowRight;
|
||||
export const PhotoIcon = Photo;
|
||||
export const VideoCameraIcon = VideoCamera;
|
||||
export const ArrowUpIcon = ArrowUp;
|
||||
|
||||
export {
|
||||
CubeTransparent as CubeTransparentIcon,
|
||||
PlusIcon,
|
||||
AcademicCapIcon,
|
||||
ShieldCheckIcon,
|
||||
CpuChipIcon,
|
||||
SoraIcon,
|
||||
CircleStackIcon,
|
||||
BuildingOffice2Icon,
|
||||
BookOpenIcon,
|
||||
InformationCircleIcon,
|
||||
NewspaperIcon,
|
||||
UserGroup as UserGroupIcon,
|
||||
LightBulb as LightBulbIcon,
|
||||
Briefcase as BriefcaseIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
RocketLaunchIcon,
|
||||
CurrencyDollarIcon,
|
||||
BuildingLibraryIcon,
|
||||
MapPinIcon,
|
||||
ClockIcon,
|
||||
ChartBarIcon,
|
||||
UsersIcon,
|
||||
CodeBracketIcon,
|
||||
PaintBrushIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PuzzlePieceIcon,
|
||||
PresentationChartLineIcon,
|
||||
TagIcon,
|
||||
LinkIcon,
|
||||
ComputerDesktopIcon,
|
||||
CommandLineIcon,
|
||||
CheckIcon,
|
||||
HomeOutline as HomeIcon,
|
||||
};
|
||||
|
||||
export const AdjustmentsHorizontalIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" {...props}>
|
||||
<path d="M9.66667 3.33333C8.93029 3.33333 8.33333 3.93029 8.33333 4.66667C8.33333 5.40305 8.93029 6 9.66667 6C10.403 6 11 5.40305 11 4.66667C11 3.93029 10.403 3.33333 9.66667 3.33333ZM7.08401 4C7.38004 2.84985 8.42411 2 9.66667 2C10.9092 2 11.9533 2.84985 12.2493 4H13.3333C13.7015 4 14 4.29848 14 4.66667C14 5.03486 13.7015 5.33333 13.3333 5.33333H12.2493C11.9533 6.48349 10.9092 7.33333 9.66667 7.33333C8.42411 7.33333 7.38004 6.48349 7.08401 5.33333H2.66667C2.29848 5.33333 2 5.03486 2 4.66667C2 4.29848 2.29848 4 2.66667 4H7.08401ZM6.33333 10C5.59695 10 5 10.597 5 11.3333C5 12.0697 5.59695 12.6667 6.33333 12.6667C7.06971 12.6667 7.66667 12.0697 7.66667 11.3333C7.66667 10.597 7.06971 10 6.33333 10ZM3.75068 10.6667C4.04671 9.51652 5.09077 8.66667 6.33333 8.66667C7.57589 8.66667 8.61996 9.51652 8.91599 10.6667H13.3333C13.7015 10.6667 14 10.9651 14 11.3333C14 11.7015 13.7015 12 13.3333 12H8.91599C8.61996 13.1502 7.57589 14 6.33333 14C5.09077 14 4.04671 13.1502 3.75068 12H2.66667C2.29848 12 2 11.7015 2 11.3333C2 10.9651 2.29848 10.6667 2.66667 10.6667H3.75068Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ListBulletIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 12" fill="currentColor" {...props}>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M1 0C0.447715 0 0 0.447715 0 1C0 1.55228 0.447715 2 1 2C1.55228 2 2 1.55228 2 1C2 0.447715 1.55228 0 1 0ZM5 0C4.44772 0 4 0.447715 4 1C4 1.55228 4.44771 2 5 2H15C15.5523 2 16 1.55228 16 1C16 0.447715 15.5523 0 15 0H5ZM0 6C0 5.44772 0.447715 5 1 5C1.55228 5 2 5.44772 2 6C2 6.55228 1.55228 7 1 7C0.447715 7 0 6.55228 0 6ZM5 5C4.44772 5 4 5.44772 4 6C4 6.55228 4.44771 7 5 7H15C15.5523 7 16 6.55228 16 6C16 5.44772 15.5523 5 15 5H5ZM0 11C0 10.4477 0.447715 10 1 10C1.55228 10 2 10.4477 2 11C2 11.5523 1.55228 12 1 12C0.447715 12 0 11.5523 0 11ZM5 10C4.44772 10 4 10.4477 4 11C4 11.5523 4.44771 12 5 12H15C15.5523 12 16 11.5523 16 11C16 10.4477 15.5523 10 15 10H5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
export const ArrowTopRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg width="11" height="12" viewBox="0 0 11 12" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M1.70985 4.5H7.7804M7.7804 4.5V10.5705M7.7804 4.5L0.780396 11.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarToggleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M9.35719 3H14.6428C15.7266 2.99999 16.6007 2.99998 17.3086 3.05782C18.0375 3.11737 18.6777 3.24318 19.27 3.54497C20.2108 4.02433 20.9757 4.78924 21.455 5.73005C21.7568 6.32234 21.8826 6.96253 21.9422 7.69138C22 8.39925 22 9.27339 22 10.3572V13.6428C22 14.7266 22 15.6008 21.9422 16.3086C21.8826 17.0375 21.7568 17.6777 21.455 18.27C20.9757 19.2108 20.2108 19.9757 19.27 20.455C18.6777 20.7568 18.0375 20.8826 17.3086 20.9422C16.6008 21 15.7266 21 14.6428 21H9.35717C8.27339 21 7.39925 21 6.69138 20.9422C5.96253 20.8826 5.32234 20.7568 4.73005 20.455C3.78924 19.9757 3.02433 19.2108 2.54497 18.27C2.24318 17.6777 2.11737 17.0375 2.05782 16.3086C1.99998 15.6007 1.99999 14.7266 2 13.6428V10.3572C1.99999 9.27341 1.99998 8.39926 2.05782 7.69138C2.11737 6.96253 2.24318 6.32234 2.54497 5.73005C3.02433 4.78924 3.78924 4.02433 4.73005 3.54497C5.32234 3.24318 5.96253 3.11737 6.69138 3.05782C7.39926 2.99998 8.27341 2.99999 9.35719 3ZM6.85424 5.05118C6.24907 5.10062 5.90138 5.19279 5.63803 5.32698C5.07354 5.6146 4.6146 6.07354 4.32698 6.63803C4.19279 6.90138 4.10062 7.24907 4.05118 7.85424C4.00078 8.47108 4 9.26339 4 10.4V13.6C4 14.7366 4.00078 15.5289 4.05118 16.1458C4.10062 16.7509 4.19279 17.0986 4.32698 17.362C4.6146 17.9265 5.07354 18.3854 5.63803 18.673C5.90138 18.8072 6.24907 18.8994 6.85424 18.9488C7.47108 18.9992 8.26339 19 9.4 19H14.6C15.7366 19 16.5289 18.9992 17.1458 18.9488C17.7509 18.8994 18.0986 18.8072 18.362 18.673C18.9265 18.3854 19.3854 17.9265 19.673 17.362C19.8072 17.0986 19.8994 16.7509 19.9488 16.1458C19.9992 15.5289 20 14.7366 20 13.6V10.4C20 9.26339 19.9992 8.47108 19.9488 7.85424C19.8994 7.24907 19.8072 6.90138 19.673 6.63803C19.3854 6.07354 18.9265 5.6146 18.362 5.32698C18.0986 5.19279 17.7509 5.10062 17.1458 5.05118C16.5289 5.00078 15.7366 5 14.6 5H9.4C8.26339 5 7.47108 5.00078 6.85424 5.05118ZM7 7C7.55229 7 8 7.44772 8 8V16C8 16.5523 7.55229 17 7 17C6.44772 17 6 16.5523 6 16V8C6 7.44772 5 7 5 7Z" />
|
||||
<path d="M10 8C10 7.44772 10.4477 7 11 7H13C13.5523 7 14 7.44772 14 8C14 8.55228 13.5523 9 13 9H11C10.4477 9 10 8.55228 10 8ZM11 15C10.4477 15 10 15.4477 10 16C10 16.5523 10.4477 17 11 17H13C13.5523 17 14 16.5523 14 16C14 15.4477 13.5523 15 13 15H11Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TelegramIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}><path d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.51-.16-.52-.53.11-.7L16.94 6c.4-.15.75.12.63.53l-2.22 10.21c-.13.51-.47.64-1.01.38l-4.56-3.35-2.14 2.05c-.22.21-.4.33-.66.33z"></path></svg>
|
||||
);
|
||||
|
||||
export const WhatsAppIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}><path d="M16.75 13.96c.25.13.43.2.5.33.07.13.07.66-.03 1.39-.1.73-.55 1.34-1.14 1.78-.59.44-1.34.58-1.9.58-.55 0-1.12-.08-1.74-.25-1.8-.5-3.35-1.42-4.63-2.82-1.49-1.63-2.5-3.5-2.82-5.1-.17-.62-.25-1.23-.25-1.78 0-.58.14-1.33.58-1.9.44-.6.99-1.07 1.72-1.17.73-.1 1.26-.1 1.39-.03.13.07.2.26.33.5.13.25.28.58.31.62.03.04.06.07.06.14 0 .07-.03.13-.06.19-.03.06-2.14 2.44-2.14 2.44s-.59.58-.59 1.02c0 .44.59 1.02.59 1.02s2.14 2.44 2.14 2.44c.06.06.13.1.19.1.07 0 .14-.03.14-.06.04-.03.36-.19.61-.31zM12 2a10 10 0 100 20 10 10 0 000-20z"></path></svg>
|
||||
);
|
||||
|
||||
export const VKIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}><path d="M13.125 10.368l1.453-.162c.594 0 .97.234 1.13.729.135.45.032 1.152-.451 2.034-.516.945-1.11 1.836-1.11 1.836s.693 1.17 1.828 1.485c.421.126 1.026.153 1.69.153h.27s.243.027.243.162c0 .243-.648.783-1.432.891-.972.108-2.025-.135-2.97-.621-.972-.513-1.62-.972-2.16-1.296-.54-.351-.89-.27-.89-.27s-.054 1.269-.432 1.62c-.378.351-1.053.405-1.215.27-.405-.297-1.242-1.323-2.457-3.267C5.176 12.333 4 10.09 4 10.09s-.027-.189.135-.297c.135-.081.405 0 .405 0h2.43s.189 0 .297.054c.108.054.135.216.135.216s.324 1.026.702 1.674c.756 1.296 1.404 1.647 1.62 1.485.351-.27.243-1.782.243-1.782s.027-.972-.27-1.35c-.297-.378-.891-.378-1.053-.351l-1.836.216c-.243 0-.405-.081-.405-.27s.054-.27.216-.324c.162-.054.513-.108.972-.108h1.269c.594 0 1.026.054 1.296.189.27.135.378.432.243.594-.135.162-.216.216-.324.216s-.297.027-.297.027z"></path></svg>
|
||||
);
|
||||
|
||||
export const RutubeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}><path d="M12.012 1.8a10.2 10.2 0 100 20.4 10.2 10.2 0 000-20.4zm-1.63 15.637V6.56l7.863 3.92-7.863 7.017zm-4.63-7.567a.555.555 0 01.555-.554h2.723a.555.555 0 01.554.554v2.723a.555.555 0 01-.554.554H6.307a.555.555 0 01-.555-.554V9.87z"></path></svg>
|
||||
);
|
||||
Reference in New Issue
Block a user